☰
JointJS 系谱图(Genogram)自动布局实战:基于 DirectedGraph 与夫妻容器的多代族谱构建指南
2026/10/6 2:31:48 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】joint

A proven SVG-based JavaScript diagramming library powering exceptional UIs

项目地址:https://gitcode.com/gh_mirrors/jo/joint
点击查看免费下载

系谱图(Genogram)是医学、心理学与社会工作中广泛使用的扩展家族树,它在基础血缘关系之上用标准化符号编码性别、死亡、收养、双胞胎等信息。本指南以开源仓库中 examples/genogram-ts 示例为蓝本,讲解如何用 JointJS 与@joint/layout-directed-graph(dagre)将一份扁平的 JSON 家族数据自动排布成层次清晰、零人工定位的多代族谱。读完本文,你将掌握夫妻容器抽象、五阶段交叉最小化、两种连线路由风格、链接到链接(link-to-link)连接以及自定义 HighlighterView 等完整实战方案。

一、Genogram 是什么:超越血缘的标准化符号系统

Genogram 与普通家谱的关键区别在于编码了额外的社会与医学信息。本示例在 shapes.ts 中用三类元素形状承载性别信息:

  • 男性以圆角矩形表示(浅蓝填充,colors.maleFill);
  • 女性以椭圆表示(浅粉填充,colors.femaleFill);
  • 性别未知以菱形/多边形表示(灰色填充,colors.unknownFill)。

同时通过高亮器(HighlighterView)叠加两类符号,参见 highlighters.ts:

  • 已故(dod字段非空):在形状上叠加一个内切 X 形十字;
  • 收养(adopted字段为true):在形状左右两侧绘制方括号。

此外,**配偶连线(mate link)**由共享子女推导而来,同卵双胞胎之间用虚线连接。数据中的multiple字段标记多胞胎组,identical字段指向同卵双胞胎的另一方。

二、技术选型与项目结构

示例基于以下技术栈(见 package.json):

  • @joint/core— JointJS 图编辑核心库;
  • @joint/layout-directed-graph— 基于 dagre 的分层自动布局包;
  • Vite— 开发服务器与打包器(yarn dev);
  • TypeScript— 全量类型化源码。

项目结构如下(见 README.md):

examples/genogram-ts/ src/ main.ts 入口:paper 初始化、数据集加载、渲染调度 shapes.ts 元素与连线形状定义(MalePerson、FemalePerson、UnknownPerson) highlighters.ts 自定义 dia.HighlighterView(已故十字、收养括号) layout/ index.ts Genogram 布局算法(DirectedGraph + 夫妻容器) minimize-crossings.ts 五阶段交叉最小化(供 customOrder 使用) utils.ts 元素创建、血统高亮、家族树图 data.ts 数据类型与解析(PersonNode、亲子/配偶链接) theme.ts 集中式尺寸、颜色、z-index 默认值与连线样式覆盖 styles.css Paper 与悬停样式 families/ 数据集 JSON 文件 scripts/ test-layout.cts 无浏览器 Node.js 布局测试

其中 main.ts 是编排核心:它一次性创建dia.Graph与dia.Paper(async: true、frozen: true提升渲染性能),通过import.meta.glob预加载families/*.json数据集,用下拉框切换数据集、按钮切换连线风格后重新执行layoutGenogram()并调用paper.transformToFitContent()自适应视口。

三、数据模型:扁平 JSON 与自动关系推导

每个数据集是扁平的 JSON 数组,一个 person 对象一个元素(见 README.md 与 data.ts):

{ "id": 1, "name": "Jane Doe", "sex": "F", "mother": 2, "father": 3, "dob": "1990-01-15", "dod": "2020-06-01", "adopted": true, "multiple": 1, "identical": 4 }
字段类型说明
idnumber唯一标识
namestring显示名称
sexstring"M"、"F"或"?"
mothernumber?母亲 ID
fathernumber?父亲 ID
dobstring?出生日期(YYYY-MM-DD)
dodstring?死亡日期(YYYY-MM-DD,未知时写"?")
adoptedboolean?是否收养
multiplenumber?多胞胎组 ID(兄弟姐妹共享)
identicalnumber?同卵双胞胎的另一方 ID

关键设计是关系全部自动推导,无需手写边:

  • 亲子链接:遍历每个 person,只要mother/father是number且存在于人员集合中,就生成{ parentId, childId }(见 data.ts 的getParentChildLinks);
  • 配偶链接:如果两个不同的人同时是某个孩子的mother和father,则他们是配偶,通过father|mother组合键去重生成MateLink(见getMateLinks,data.ts)。

仓库内置四份数据集(src/families):虚构的多代 Thompson 家族(含双胞胎、同卵三胞胎、收养、夭折等多种情形)、英国王室(George V 至今)、带角色标签的关系图(Father、Cousin 等),以及约 1000 人、7 代的benchmark.json压力数据集。

四、核心难点:Genogram 为何不能直接做 DAG 布局

与普通有向图相比,Genogram 的布局有五个独特约束(详见 TUTORIAL.md):

  1. 夫妻必须同 rank 且并排;
  2. 亲子连线应从夫妻中点出发;
  3. 双胞胎/多胞胎共享同一分叉点;
  4. 同卵双胞胎之间是"连线连到连线";
  5. 配偶连线是水平双向的,天然破坏 DAG 假设。

解决策略是一条五步流水线:

  1. 将每对夫妻替换为一个夫妻容器节点参与布局;
  2. 用带customOrder 回调的 DirectedGraph 布局最小化交叉;
  3. 后处理:把容器拆回两个真实人员、让连线经过夫妻中点、补上配偶连线;
  4. 用高亮器叠加已故/收养符号。

五、夫妻容器(Couple Containers)

dagre 按节点逐一布局。若把夫妻拆成两个独立节点,两人可能落在不同 rank 或相距很远。因此 layout/index.ts 为每对配偶创建一个不可见的宽矩形,宽度足以容纳两个符号并排:

const extraWidth = linkStyle === 'orthogonal' ? sizes.symbolWidth : 0; const container = new shapes.standard.Rectangle({ size: { width: sizes.symbolWidth * 2 + sizes.coupleGap + extraWidth, height: sizes.symbolHeight }, });

正交(orthogonal)模式下容器额外加宽一个symbolWidth,用来容纳向外偏移的姓名标签。随后用personIdToContainer记录"人员元素 → 容器"映射,layoutId()把每个人员的布局 ID 解析为其容器 ID(layout/index.ts)。所有亲子连线在布局阶段都重定向到容器,未成家的独身人员(solo)则直接参与布局。

六、边去重(Edge Deduplication)

当父母同处一个容器时,一个孩子会产生两条映射到同一条"容器→孩子"布局边的亲子连线,dagre 无法正确处理重复边。布局前按srcLayout→tgtLayout键去重(layout/index.ts):

const edgeKey = `${srcLayout}→${tgtLayout}`; const isDuplicate = layoutEdgeSet.has(edgeKey); layoutEdgeSet.add(edgeKey); if (isDuplicate) { (link as any)._layoutDuplicate = true; }

只有首次出现的边参与布局,重复边在布局完成后重新加回图(graph.addCells(duplicateLinks))。

七、DirectedGraph 布局与五阶段交叉最小化

布局调用如下(layout/index.ts):

DirectedGraph.layout(graph, { rankDir: 'TB', nodeSep: sizes.symbolGap, rankSep: sizes.levelGap, customOrder: (glGraph, jointGraph, defaultOrder) => minimizeCrossings( glGraph, jointGraph, defaultOrder, { parentChildLinks, layoutId, personById, identicalGroupOf, nodeMultipleGroup } ), });
  • rankDir: 'TB'— 自上而下的层级(世代向下流动);
  • nodeSep: sizes.symbolGap— 节点间水平间距(默认 20);
  • rankSep: sizes.levelGap— 世代间垂直间距(默认 70);
  • customOrder— 用自定义多阶段排序取代 dagre 内置排序。

customOrder是 dagre 暴露的排序钩子:布局引擎先计算 rank,再调用该回调确定每层节点的水平顺序。本示例的五阶段实现位于 minimize-crossings.ts。

Phase 1:以默认启发式结果作为种子

先用 dagre 自己的排序生成基线,测量总交叉数并保存最优状态:

defaultOrder(glGraph); let bestCrossings = totalCrossings(); let bestOrder = saveOrder();

Phase 2:多轮重心(barycenter)细化

重心(barycenter):一个节点的重心是其相邻 rank 上所有邻居位置的平均值——例如某个孩子有三个父母位于位置 1、3、5,则其重心为 (1+3+5)/3 = 3。按重心值排序各 rank 节点,相连节点会被拉近,从而减少边交叉。这是分层图绘制最经典的启发式之一,源自 Sugiyama、Tagawa 与 Toda(1981)。

实现中交替执行自上而下(看父母)与自下而上(看子女)两轮扫描,最多 24 轮(MAX_BARYCENTER_ITERATIONS),保留最佳结果,交叉数为 0 时提前终止:

for (let iter = 0; iter < 24; iter++) { for (const rank of ranks) reorderByBarycenter(rank, 'up'); for (let i = ranks.length - 1; i >= 0; i--) reorderByBarycenter(ranks[i], 'down'); const crossings = totalCrossings(); if (crossings < bestCrossings) { bestCrossings = crossings; bestOrder = saveOrder(); } if (crossings === 0) break; }

每次重心排序的平局(tie)由出生日期与同卵双胞胎组 ID打破,保证兄弟姐妹按自然顺序排列(minimize-crossings.ts)。

Phase 3:贪心节点重排

对每个节点,尝试其在 rank 内的每一个位置,选取使全局总交叉数最小的位置,以跳出重心扫描无法解决的局部最小值:

for (let i = 0; i < nodes.length; i++) { nodes.splice(i, 1); // 先移除节点 for (let j = 0; j <= nodes.length; j++) { nodes.splice(j, 0, nodeId); // 尝试每个插入位置 applyOrder(nodes); const cost = totalCrossings(); // 记录最优位置... nodes.splice(j, 1); } nodes.splice(bestPos, 0, nodeId); // 插入最优位置 }

该阶段复杂度为 O(n² × 位置数),因此宽度超过 50 个节点的 rank 会被跳过(MAX_RANK_WIDTH_FOR_RELOCATION),避免大数据集上的性能问题。

Phase 4:容器级交叉消解

dagre 在带 dummy 节点的图上做交叉最小化(长边被拆成短段),这可能在容器展开成两个配偶后遗漏视觉交叉。本阶段用computeContainerCrossings()——只统计真实边(容器到容器)的交叉检查——配合基于真实边邻接的重心扫描修正:真实节点用真实边邻居,dummy 节点用 dagre 图邻居(保住连线路由质量)。

关键洞察是:每个扫描方向(自上而下、自下而上)之后都要单独检查容器交叉并保存最优,因为自下而上的扫描可能推翻自上而下扫描刚修好的结果(minimize-crossings.ts)。

Phase 5:双胞胎/多胞胎相邻约束

交叉最小化可能把同一多胞胎组的成员拆散。本阶段把组成员聚拢到最左成员的位置,同卵双胞胎保持相邻,组内按"同卵组优先、再按出生日期"排序:

members.sort((a, b) => { // Identical group first, then birth date }); filtered.splice(insertAt, 0, ...members);

八、夫妻定位(Couple Positioning)

布局完成后每个容器都有坐标。接下来把容器拆回两个人员元素,根据各自父母(容器)的 X 坐标决定谁站左边,从而避免连线不必要地交叉(layout/index.ts):

const fromParentX = getParentX(fromId); const toParentX = getParentX(toId); const [leftEl, rightEl] = fromParentX <= toParentX ? [fromEl, toEl] : [toEl, fromEl]; const inset = linkStyle === 'orthogonal' ? sizes.symbolWidth / 2 : 0; leftEl.position(pos.x + inset, pos.y); rightEl.position(pos.x + inset + sizes.symbolWidth + gap, pos.y);

正交模式下人员相对容器边缘向内缩进以居中;姓名标签也向外偏移避免重叠:

if (linkStyle === 'orthogonal') { leftEl.attr('name', { textAnchor: 'end', x: `calc(w / 2 - ${sizes.nameMargin})` }); rightEl.attr('name', { textAnchor: 'start', x: `calc(w / 2 + ${sizes.nameMargin})` }); }

九、连线重连与路由:经过夫妻中点

亲子连线在视觉上必须从夫妻两者的中点出发,而不是从某一个配偶出发。布局后把每条连线重连回真实人员元素并添加 vertices(layout/index.ts),路由几何随连线风格而异。

Fan(扇型,默认)——水平到夫妻中点,垂直向下,再水平到孩子:

const midX = (sourceCenter.x + partnerCenter.x) / 2; const midY = (sourceCenter.y + partnerCenter.y) / 2; const halfwayY = (midY + targetCenter.y) / 2; link.vertices([ { x: midX, y: midY }, // 夫妻中点 { x: midX, y: halfwayY }, // 垂直向下 { x: targetCenter.x, y: halfwayY } // 转向孩子 ]);

Orthogonal(正交)——从父/母垂直向下、水平到夫妻中点、再垂直向下、水平到孩子:

const thirdY = midY + (targetCenter.y - midY) / 3; const twoThirdsY = midY + 2 * (targetCenter.y - midY) / 3; link.vertices([ { x: sourceCenter.x, y: thirdY }, // 从父/母垂直向下 { x: midX, y: thirdY }, // 水平到中点 { x: midX, y: twoThirdsY }, // 再次垂直向下 { x: targetCenter.x, y: twoThirdsY } // 水平到孩子 ]);

注意目标锚点使用anchor: { name: 'top', args: { useModelGeometry: true } }使连线从符号顶部接入,且重连时优先使用useModelGeometry保证几何一致。

十、双胞胎/多胞胎共享分叉点

双胞胎与三胞胎共享一个分叉点——各子连线不各自转向,而是在组的平均 X 坐标处汇合(layout/index.ts):

const avgX = uniqueIds.reduce((sum, id) => { return sum + (graph.getCell(id) as dia.Element).getCenter().x; }, 0) / uniqueIds.length;

Fan 风格下用forkX取代targetCenter.x:

link.vertices([ { x: midX, y: midY }, { x: midX, y: halfwayY }, { x: forkX, y: halfwayY } // 共享分叉点 ]);

分组键为源容器ID|multiple 值(twinGroupKey),同父同母且multiple相同的子女共享分叉点。

十一、同卵双胞胎连接:链接到链接(Link-to-Link)

同卵双胞胎用一条水平虚线连接各自的亲子连线。这用到 JointJS 的链接到链接连接能力,配合connectionRatio锚点(layout/index.ts):

const ratioA = computeAnchorRatio(linkA, ANCHOR_VERTICAL_OFFSET); const ratioB = computeAnchorRatio(linkB, ANCHOR_VERTICAL_OFFSET); new IdenticalLinkShape({ source: { id: linkA.id, anchor: { name: 'connectionRatio', args: { ratio: ratioA } } }, target: { id: linkB.id, anchor: { name: 'connectionRatio', args: { ratio: ratioB } } }, });

computeAnchorRatio沿连线路径从孩子端反向回溯,找到距孩子指定垂直偏移(ANCHOR_VERTICAL_OFFSET = levelGap / 4,正交模式为/8)的点,换算成 0–1 的路径比例。连线的几何样式定义在 shapes.ts:虚线strokeDasharray: '4 2',粉色描边。

十二、配偶连线(Mate Links):最后添加

配偶连线是两人之间的水平连接。它必须在布局之后添加,因为它是双向的,会破坏布局引擎的 DAG 假设(layout/index.ts):

const mateJointLinks = mateLinks.map((ml) => { return new MateLinkShape({ source: { id: String(ml.from), anchor: { name: 'center', args: { useModelGeometry: true } } }, target: { id: String(ml.to), anchor: { name: 'center', args: { useModelGeometry: true } } }, }); }); graph.addCells(mateJointLinks);

MateLink使用较粗(strokeWidth: 3)的粉色描边,且不带箭头(targetMarker: null)。

十三、符号高亮器:自定义 dia.HighlighterView

已故十字与收养括号作为自定义dia.HighlighterView子类实现,与形状定义解耦。每个高亮器用tagName = 'path',在preinitialize()中设置静态属性,仅在highlight()中计算动态的d属性(highlighters.ts):

class DeceasedHighlighter extends dia.HighlighterView { preinitialize() { this.tagName = 'path'; this.attributes = { stroke: colors.dark, strokeWidth: 2, strokeLinecap: 'round', fill: 'none', }; } protected highlight(elementView: dia.ElementView<dia.Element>) { const { width, height } = elementView.model.size(); const p = crossPadding; const d = `M ${p} ${p} ${width - p} ${height - p} M ${width - p} ${p} ${p} ${height - p}`; this.el.setAttribute('d', d); } }

高亮器在图渲染完成后施加。已故十字用z: 2使其渲染在年龄标签之下,收养括号无需特殊 z 顺序:

DeceasedHighlighter.add(cellView, 'body', 'deceased-cross', { z: 2 }); AdoptedHighlighter.add(cellView, 'body', 'adopted-brackets');

年龄与姓名的 SVG 标签、textWrap(换行宽度calc(w + 2*nameWrapOverlap)、maxLineCount、省略号)等都在 shapes.ts 的commonAttrs()中统一定义,年龄由 utils.ts 的computeAge依据dob/dod计算(生年-卒年取自dod,未去世时显示*)。

十四、交互:悬停血统高亮

悬停某个人员时,会高亮其直系祖先与后代。实现位于 utils.ts 的setupLineageHighlighting:

  • 血缘遍历使用单独的familyTree图(buildFamilyTree,仅含人员节点与亲子边),通过 JointJS 的getPredecessors/getSuccessors高效查询祖先与后代;
  • 悬停元素用内置highlighters.stroke添加粗描边(layer: dia.Paper.Layers.BACK);
  • 相关连线用 z 偏移置顶,无关元素变暗。

z-index 默认值集中在 theme.ts 的defaultZIndex中:

export const defaultZIndex = { person: 1, parentChildLink: 2, mateLink: 3, identicalLink: 3, focusedOffset: 10, };

每种连线类型有自己的默认 z,置顶时加focusedOffset(10)以保持相对堆叠顺序(例如配偶连线始终在亲子连线之上);mouseleave时从已知默认值恢复 z 而非读取当前值,避免脏状态问题。

元素用 opacity 变暗,而连线用变浅的描边颜色——因为带 opacity 的重叠线段在交叉处会显得更暗,产生视觉伪影:

.joint-element { transition: opacity 0.3s ease; } .joint-element.dimmed { opacity: 0.1; } .joint-link [joint-selector="line"] { transition: stroke 0.3s ease; } .joint-link.dimmed [joint-selector="line"] { stroke: #e2e8dd; }

十五、两种连线风格对比

UI 中的按钮可切换两种连线路由风格:

  • Fan(扇型,默认):连线先水平到夫妻中点、再垂直向下、再水平到孩子,形成紧凑的"T 形接头"外观;
  • Orthogonal(正交):连线先从每个父母垂直向下,再经过水平横杆路由,形成层级分明、更传统的树状外观。

连线风格会连锁影响布局的多个部分:

影响点FanOrthogonal
容器宽度symbolWidth*2 + coupleGap再加一个symbolWidth
夫妻定位从容器边缘对齐相对容器向内缩进symbolWidth/2
姓名标签居中左伴侣右对齐、右伴侣左对齐并外移
路由顶点水平→垂直→水平垂直→水平→垂直→水平
coupleGap/levelGap20 / 7030 / 100
姓名换行上限nameMaxLineCount24

十六、主题架构:集中式尺寸与样式覆盖

布局尺寸与颜色集中在 theme.ts。基础sizes对象不可变,按连线风格区分的覆盖单独定义在linkStyleOverrides:

export const linkStyleOverrides = { fan: {}, orthogonal: { coupleGap: 30, levelGap: 100, nameMaxLineCount: 4 }, } as const satisfies Record<string, Partial<typeof sizes>>;

渲染时把当前风格的覆盖与基础尺寸合并(见 main.ts):

const layoutSizes = { ...sizes, ...linkStyleOverrides[linkStyle] };

这样切换风格时不会污染基础主题对象。sizes的核心尺寸包括:符号宽高(50×50)、已故十字内缩(4)、收养括号间距(6)、夫妻间距(20)、节点间距(20)、代际间距(70)、画布内边距(50)、姓名换行外延(5)、正交标签边距(6)等。

十七、无浏览器布局测试

scripts/test-layout.cts 是一个 Node.js 脚本,复用浏览器版完全相同的layoutGenogram()(无重复实现),在无浏览器环境验证布局零交叉:

# 在 examples/genogram-ts 目录下 yarn test-layout # 默认跑 thompson.json yarn test-layout --data=thompson.json # 指定数据集

它解析--data=参数,用标准 Rectangle 形状替代自定义 SVG 形状(Node 环境无需 SVG markup),跑完布局后按 Y 坐标分组打印各代人员的水平位置,并做容器级视觉交叉检查:把同一夫妻的两条边按夫妻中点归一、按容器键去重后,用(srcX - srcX') * (tgtX - tgtX') < 0判定交叉,发现交叉即打印并process.exit(1)失败。这正是 Phase 4 中"只有容器到容器边才可能视觉交叉"思想的直接落地。

十八、数据设计考量

跨家族婚姻(不同根家族的孩子相互通婚)在平面布局中会造成不可避免的边交叉。为最小化交叉(见 TUTORIAL.md 末尾):

  • 尽量让联姻发生在相邻家族之间;
  • 避免同一代出现一连串跨家族婚姻;
  • 嫁入家族但树上没有父母的配偶(外部配偶)作为 solo 节点放置,不引入额外家族分支。

十九、运行指南

# 仓库根目录安装依赖后(yarn workspace 方式),进入示例目录 cd examples/genogram-ts yarn install yarn dev

打开终端显示的地址(默认http://localhost:5173),用下拉框切换 Thompson 家族、英国王室、关系图等数据集,用按钮切换 Fan/Orthogonal 连线风格观察布局差异;benchmark.json(约 1000 人、7 代)用于验证五阶段交叉最小化在大型数据集上的性能表现(Phase 3 会跳过宽度超过 50 的 rank 以控制开销)。完整的分步讲解见 TUTORIAL.md,布局流水线全图与各阶段细节可对照 layout/index.ts 与 minimize-crossings.ts 阅读。

  • 前端
  • UI组件

【免费下载链接】joint

A proven SVG-based JavaScript diagramming library powering exceptional UIs

项目地址:https://gitcode.com/gh_mirrors/jo/joint
点击查看免费下载

相关推荐

上一篇:Redis桌面管理革命:Another Redis Desktop Manager完整实战指南
下一篇:MikroORM 索引与唯一约束完整指南:从 `@Index`/`@Unique` 装饰器到类型安全的 `using` 查询提示

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询