资讯动态

用SVG技术绘制血缘关系图:数据建模、布局算法与交互实践

发布时间:2026/10/16 22:09:24 来源:尧图企业网站定制
1. 先回答一个问题画家族谱图为什么我选了SVG而不是Canvas1.1 我最初用Canvas画了一版最后推倒重来去年我接了一个家族档案数字化的整理任务其中最重要的部分就是一张血缘关系图。需求听起来很朴素展示家族成员之间的父子、夫妻关系能逐代展开能导出成图片能直接放到网页上给家里人看。我一开始图省事直接用了Canvas来画想着反正就是画几个方块和几条线。结果画到第三代成员的时候问题就出来了。Canvas在绘制完成后所有图形都变成了像素你需要自己维护一份坐标数据才能做点击命中检测当鼠标悬停到某个成员节点上想让整条分支高亮就得重新调用绘制函数重绘整个画布。更要命的是文本渲染Canvas在缩放状态下文字边缘发虚家里长辈戴老花镜看好几处名字都认不清。于是我把第一版推翻改用SVG重新实现。SVG的图形元素本身存在于DOM结构里每个节点就是一个可以绑定事件、设置样式、单独更新的元素完全不用自己做命中检测。而且它是矢量格式无论浏览器窗口怎么缩放文字和边框始终保持清晰锐利。换过来之后很多之前让人头疼的问题瞬间消失了。1.2 SVG、Canvas与纯DOM三种方案的取舍血缘关系图和普通的数据可视化图表不一样它有几个非常明确的特征节点数量通常不会特别大单张图几百个成员已经算很多交互却非常密集展开、收起、悬停、点击高亮都要求即时响应内容以中英文姓名、日期等文本为主对文字的清晰度和选中复制有天然要求。这三种技术方案的差异用一张表可以看得很清楚对比维度SVGCanvas纯DOM布局节点数量上限中等千级别内稳定非常高上万也能跑较低DOM节点多则卡顿文本清晰度矢量渲染缩放不糊位图渲染缩放易模糊依赖浏览器文本渲染清晰点击/悬停命中元素自带事件天然支持需自行计算坐标命中元素自带事件天然支持连线绘制能力支持path路径简洁优雅原生支持性能好实现麻烦得用边框或伪元素动态增删节点增删DOM元素即可方便需要整体重绘进行DOM操作方便但不适合复杂连线导出图片序列化成本较高直接toDataURL导出很难干净导出血缘关系图恰恰落在SVG最擅长的区间节点量中等、交互高频、文本重要。Canvas在金融大屏那种几万个点实时刷新的场景里是王者但拿来做家谱图就像用卡车载人能装但到处都不舒服。纯DOM方案虽然每个节点本身好做但父子之间那条跨越多代的贝塞尔曲线你很难找到一个干净的方式实现最后基本都是歪招。1.3 选型这件事本质上是在选“维护成本”我自己做技术选型时很少直接看性能排行这类指标更看重的是三个月后再来改需求哪个方案能让我少脱一层皮。Canvas的维护成本在于所有状态都要自己记录节点移动一个像素对应的连接线坐标也要算一遍SVG的维护成本则低很多一个节点就是一个g元素里子外子什么都写在属性里后台数据结构变化之后直接拿数据重派生成一份新的SVG片段就行。血缘关系图这种项目还有一个特点需求一定会改。今天说要加出生日期明天说要按分支上不同颜色后天说要把近亲关系用虚线高亮出来。SVG的声明式特性在这种反复迭代中优势极大——你要做的只是修改数据或者修改模板而不是重写绘制逻辑。这也是我把标题定为“SVG技术绘制血缘关系”而不是“用某个图表库实现”的原因图表库解决的是通用需求而血缘图有太多行业细节自己掌握底层绘制能力才能真正做到想怎么改就怎么改。2. 画图之前先把家族关系变成计算机看得懂的数据2.1 血缘关系图的数据模型比普通树结构更复杂很多教程一上来就教你写布局算法、画贝塞尔曲线但我做下来的体会是想清楚“数据长什么样”比画图本身重要得多。普通树形结构只要父子关系就够了血缘图却至少有三种关系要表达。第一是血缘关系也就是一个人和其父亲、母亲之间的连接第二是婚姻关系夫妻之间的一条横线第三是相对关系比如兄弟姐妹、堂表亲虽然不必专门画线但在布局算法里要用到。更现实一点现代家庭的形态很多离异、再婚、收养都会出现这些关系如果不提前在数据模型里留下位置后面画图时就会卡住。我最终把每个成员建模成这样的结构const members [ { id: m1, name: 长辈甲, gender: male, // male / female / unknown generation: 0, // 第几代0表示最顶端 birthYear: 1930, fatherId: null, // 血缘父亲 motherId: null, // 血缘母亲 spouseIds: [m2], // 配偶们可多段婚姻 adopted: false // 是否收养关系决定连线样式 }, { id: m2, name: 长辈乙, gender: female, generation: 0, birthYear: 1935, fatherId: null, motherId: null, spouseIds: [m1] }, { id: m3, name: 二代长子, gender: male, generation: 1, birthYear: 1955, fatherId: m1, motherId: m2, spouseIds: [m4] } ];这个结构里我把配偶单独用spouseIds数组保存而不是只放一个人就是为了兼容多段婚姻。adopted字段用来控制连线的虚实亲生关系用实线收养关系用虚线这是血缘图里一个很常见的区分需求。2.2 用扁平数组而不是嵌套树遍历会顺利很多处理家族数据时人们很容易下意识把它做成嵌套结构祖父下面挂父亲父亲下面挂子女。表面看很直观但真要写算法时就发现嵌套树没法回答“某人的祖父是谁”这类跨级问题也没法快速过滤出所有同一代的人更别提交互展开时只更新局部。我强烈建议直接用扁平数组加引用ID。每个节点只有一个ID关系通过fatherId、motherId、spouseIds来表达。需要某个人的子女时写一个查询方法function getChildren(id) { return members.filter(m m.fatherId id || m.motherId id ); }需要某一代所有人的时候直接按generation过滤。这样写代码的时候思路非常直白不用一层一层递归去解引用。而且后面如果要引入“时间轴”这类维度扁平结构也更容易扩展——给节点加一个deathYear字段一条时间线就出来了。2.3 录入和校验阶段的一点点前置工作数据模型定了之后别忘了做数据校验。我刚开始没做结果绘制的第一版图上出现了三个“孤儿节点”有两个人既没有父亲也没有母亲却在中间代位置另有一对夫妻一方性别填错了导致图例渲染风格异常。这些脏数据在原型阶段顶多让人皱眉但如果要给长辈展示就很尴尬。我在正式生成SVG前加了一道校验逻辑function validateMembers(members) { const problems []; const idSet new Set(members.map(m m.id)); members.forEach(m { if (m.fatherId !idSet.has(m.fatherId)) { problems.push(${m.name}: 父亲ID指向不存在的节点); } if (m.motherId !idSet.has(m.motherId)) { problems.push(${m.name}: 母亲ID指向不存在的节点); } if (m.generation 0) { problems.push(${m.name}: 世代数异常); } }); return problems; }这步花不了多少时间却能避免后面所有因为关系错乱导致的绘制异常。血缘图项目里数据干净了画图就成功了一半。3. 布局算法这一步躲不开从树状递归到队列化排布3.1 第一版递归布局节点全都叠在一起拿到数据之后最自然的思路是直接递归从最顶端的长辈开始把他的子女排在同一行然后子女的子女再排到下一行。我第一版就是这样写的子节点坐标只是简单地把父节点的y坐标加上一个固定值x坐标则平均散开。结果画出来自己都不忍直视一个儿子有五个孩子另外一个儿子只有一个孩子五个孩子的那一分支占用了很宽的地方却没有把空间让给旁边单分支的子节点。于是同一个世代里有的节点挤得密密麻麻有的空了一大片。这还是其次更严重的是当一个父节点有多个子女时如果不提前计算每个子树占用的宽度几层递归下来不同分支的节点就会撞在一起连线交叉成乱麻。这个问题的本质是树状布局不能只考虑“当前这一层的顺序”还要考虑“整棵子树的最终宽度”。父节点应该摆在自己的所有子树正中间而不是简单地放在某个固定坐标上。3.2 按子树宽度分配水平空间的具体做法我使用的是一种简单但可靠的算法先递归计算出每一棵子树的叶子节点数量或者最大宽度把宽度作为子树占用的空间再把当前节点放在所有子树的正中间。核心逻辑大概是这样function layout(member, x, y) { const children getChildren(member.id); const verticalGap 90; // 相邻代际之间的垂直间距 const siblingGap 60; // 同一代相邻节点之间的水平间距 if (children.length 0) { nodePositions[member.id] { x, y }; return { width: 100, x }; } let totalWidth 0; const childLayouts children.map(child { const subTreeWidth 0; return { subTreeWidth, child }; }); // 先递归布局所有子节点拿到每棵子树的实际宽度 let cursorX x - totalWidth / 2; children.forEach(child { const result layout(child, cursorX, y verticalGap); cursorX result.width siblingGap; totalWidth result.width siblingGap; }); totalWidth Math.max(100, totalWidth - siblingGap); const centerX x; nodePositions[member.id] { x: centerX, y }; return { width: totalWidth, x: centerX }; }上面的代码是简化的关键在于“先遍历孩子递归布局再回头定位父节点”。这样父节点会恰好落在所有孩子横向范围的正中间。实际项目里如果还想让同一代的人尽量水平对齐还需要在递归之后加一轮“后处理”把同一generation且没有约束关系的节点按预设间距做二次调整。这一步处理完之后整张图看起来就是一棵标准、清楚的分层树。3.3 离异、再婚、收养这些特殊关系怎么放进布局布局算法只解决“常规两个孩子一个父亲”的形状遇到特殊关系就得单独约定。我的处理方式是给每条连线附一个关系类型字段根据类型决定线的颜色和虚实。比如离异关系夫妻之间的横线我只绘制一条但如果双方各自再婚就会产生同父异母或同母异父的兄弟姐妹。他们共享同一个父节点或母节点但在布局时共同的那个父/母节点只能出现一次不能复制一份放到两个分支里。因此我的做法是在数据层增加一个families数组把每个“家庭单元”单独建立起来一个家庭单元包含一个或两个成年人以及他们的共同子女。布局时以家庭单元为最小单位而不是以单个成员为最小单位。这个改动很关键。它解决了再婚家庭中同一位母亲在两个家庭里都有子女的“共享节点”问题。绘图上家庭单元的一侧画这位母亲另一侧画她另一位配偶中间用婚姻线连接各自家庭的孩子排在各自配偶下方。同一代的兄弟姐妹可能不在同一个水平行的正中间但整个家庭的视觉边界是清晰完整的不会出现同一个人画了两遍这种硬伤。4. 正式绘制第一版血缘图坐标系统、节点与连线的积累4.1 viewBox与坐标系“画布”怎么设定才不糊SVG绘图的第一步是设定坐标系。很多人直接写width和height就开画后来一缩放就出问题。我的建议是用viewBox来定义逻辑坐标系让实际的width和height只作为初始展示大小。svg width1200 height900 viewBox0 0 1200 900 preserveAspectRatioxMidYMid meet /svgviewBox里定义的是“逻辑像素”不管屏幕上的物理尺寸是多大坐标值始终不变。你设计好一套基于逻辑坐标的节点位置后以后做缩放平移只需要在外层套一个transform不需要修改每个节点的坐标。这一点在血缘图这种节点多、需要经常查看细节的场景里尤其重要。还有一个容易被忽略的点如果节点文字的裁剪范围不够会出现文字显示不全。我的经验是节点矩形的宽度不要拍脑袋定死要预先根据名字长度估算。可以生成一个临时的text元素来测量渲染宽度再去倒推矩形宽度。中文字符每个字大概等于fontSize的宽度英文则更窄一些这个测量方法比手动估准。4.2 绘制节点用g分组让节点真正“成形”一个完整节点通常由一个圆角矩形、一个头像占位符、姓名文本、生卒年文本组成。如果直接用四个元素散放在画布里既难管理又难维护。正确做法是把它们包在同一个g元素里坐标变化时就只需要改g的transform属性里头的元素用相对坐标。function drawMember(member, x, y) { const g document.createElementNS(http://www.w3.org/2000/svg, g); g.setAttribute(transform, translate(${x}, ${y})); g.setAttribute(data-member-id, member.id); g.setAttribute(class, member-node); const rect createNodeRect(member); // width按名字长度估算 const nameText createText(member.name, { x: 0, y: 4 }); g.appendChild(rect); g.appendChild(nameText); return g; }这个g元素是所有交互的基础。绑定click事件、设置悬停样式、做整支高亮全部可以只操作这一个元素。我还会用CSS来控制节点的高亮状态.member-node { cursor: pointer; } .member-node:hover rect { stroke: #1880f0; stroke-width: 3; } .member-node.highlight rect { fill: #fff7e0; }性别差异我通过矩形的填充色弱化区分而不是靠图形icon因为小尺寸下图形不如颜色直观。男性用浅蓝色系、女性用浅粉色系、未知性别用灰色系并且在节点右下角放一个很小的性别符号方便截图传播时也能看清。4.3 父子连线的贝塞尔曲线写法连线是血缘图里最能体现“做没做过”的部分。直线虽然能连但几层排列之后直线要么穿兄弟节点要么分支太多显得生硬。稳妥方案是用二次贝塞尔曲线从父节点底部连到子节点顶部。function drawEdge(parentPos, childPos, relationType) { const path document.createElementNS(http://www.w3.org/2000/svg, path); const py parentPos.y NODE_HEIGHT / 2; const cy childPos.y - NODE_HEIGHT / 2; const controlY (py cy) / 2; const d M ${parentPos.x} ${py} C ${parentPos.x} ${controlY}, ${childPos.x} ${controlY}, ${childPos.x} ${cy}; path.setAttribute(d, d); path.setAttribute(fill, none); path.setAttribute(stroke, relationType adoption ? #aaa : #555); path.setAttribute(stroke-dasharray, relationType adoption ? 6,4 : none); return path; }控制点选在纵坐标中点能让曲线在垂直方向上显得比较自然横跨距离大时也不会出现死弯。配偶之间的连线则简单得多直接画一条水平实线两边端点连接两个人矩形的内侧边缘稍微往中间收一点视觉上像一根纽带。实际干活的时候我建议先把所有连线绘制到所有节点底下也就是先画线再画节点。这样即使坐标轻微重叠节点矩形也能盖住线的端点不会露出难看的接头。这算是SVG绘制场景里一个能救命的小习惯。5. 加交互时踩过的坑展开收起、缩放平移与性能5.1 动态展开收起如何避免整个图重排血缘关系图落到网页上之后交互是逃不掉的。我不可能一次性把所有成员全部展开那样图会大得离谱必须默认只显示顶端几代点一个节点再加载他的子女分支。最直接的办法是点击节点后重新把整个members数组过滤一遍只保留已展开的节点和连线再重新跑布局、重新生成所有元素。说实话节点少的时候这招没问题但成员一多每次展开都闪一下视觉体验很差。后来我改成“全量布局、按需显隐”先把所有节点的坐标一次性算完DOM元素也一次性创建好但把未展开节点的display设成none连线同理。点击展开时只需要把相关的那几个节点和线的display改回来完全不需要重新布局。这样不仅流畅代码也简单很多。唯一的代价是初始渲染时间稍微长一点但对几百个成员来说完全可以接受。5.2 缩放平移与边界控制血缘图整体尺寸经常会超出屏幕所以缩放平移是标配。我用一个外层g包住所有内容然后监听鼠标滚轮和拖拽来修改这个g的transformlet scale 1; let translateX 0; let translateY 0; function applyTransform() { container.setAttribute(transform, translate(${translateX}, ${translateY}) scale(${scale})); } // 滚轮缩放时以鼠标位置为中心 svg.addEventListener(wheel, e { e.preventDefault(); const rect svg.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; const oldScale scale; scale Math.min(3, Math.max(0.3, scale * (e.deltaY 0 ? 0.9 : 1.1))); translateX mx - (mx - translateX) * (scale / oldScale); translateY my - (my - translateY) * (scale / oldScale); applyTransform(); });有两个坑值得提醒。第一个是缩放调节范围血缘图缩到太小没有意义放到太大又会失焦0.3到3倍是我们实际测试下来比较舒服的范围。第二个是拖动事件和文本选择的冲突如果不手动禁用文本选择拖画面时容易选中节点里的文字又难看又影响操作记得在拖拽开始时设置userSelect: none。还要考虑一个细节缩放后节点里的文字会跟着变大变小虽然SVG矢量渲染不糊但个别设计上希望文字始终保持可读。我的办法是对文字做反向缩放在节点g内部给文字加一个scale(1/scale)的变换保证字符大小恒定这样在俯瞰全图时不会有一堆巨大文字挡视线。5.3 节点多起来之后SVG的DOM性能怎么救SVG的DOM节点也是节点不会因为它是矢量就免疫性能问题。成员超过一千时光创建出来的矩形和文本响应就明显变慢。我实测下来真正起作用的优化手段主要有三个。第一个是统一样式把所有节点能靠CSS算出的样式全部用class表达避免几千个元素各自带内联style。内联样式的解析开销比class高一个数量级。第二个是避免频繁操作DOM大批量创建节点时要合并操作用文档片段先构建好再一次性插入。第三个是合理应用will-change或对具体交互元素做局部优化但不要滥用否则会拖慢首屏渲染。如果图真的巨大无比还有一个手段按当前视口渲染区域过滤。血缘图滚动平移时只绘制当前可见范围内的节点区间外的节点用display:none隐藏。这个方案我们留给五千节点以上的极端场景一般家谱达不到这个量级。6. 给同样要做血缘图的朋友几条实在建议6.1 数据层面的坑永远比渲染层面的坑多我前面反复提到数据问题不是没原因的。血缘关系图的核心难点从来不在SVG API本身而在于人们理不清“谁是继父”“谁是养父”“谁是同母异父”这些复杂关系。绘图代码写得再漂亮数据模型不对出来的图就是错的。一个很实用的做法是在数据录入阶段就固定好规则每个成员必须有generation字段父子关系必须指向真实存在的节点一个人的配偶数量大于1时必须明确标注婚姻顺序收养关系必须和亲生关系在数据上分开存储不能用同一对fatherId/motherId硬顶。这些规则整理成一张校验表每次导入数据都跑一遍能省下大量后期盯着图找人名怨气的精力。6.2 导出、打印与分享的隐藏需求做家谱图的人十个里有八个是想把输出打印出来或者转发到家族群里的。这个需求看起来不起眼却决定了SVG方案的一些细节。导出PNG图片时SVG不能直接转位图得先序列化成字符串画到Canvas里再导出。这里有个坑如果SVG里用到了外部字体导出时可能显示不全或变成系统默认字体。所以项目里我要求必须对文字做兜底至少保证中文字体在无外部font-face时也能被系统字体替代。打印时要记得把背景色逻辑从“透明”改成“白底”。很多浏览器打印SVG默认不渲染背景色节点是浅色的纸一白就看不清区分。给SVG根节点设一个background-color:#fff的样式打印样式里再加一个-webkit-print-color-adjust: exact能解决大部分问题。6.3 从血缘图到家族编年史的扩展思路SVG血缘关系图做稳定之后我顺手扩展了一个功能给每个成员节点加左侧的一根竖线表示他在世的时间跨度从出生年画到离世年还在世的画到今天。这样家族故事就能在血缘结构之上叠出一条时间线来谁和谁的人生有交集谁是某个大事件的同龄人一目了然。这条竖线本质上也是个矩形x坐标放在节点旁边y坐标根据出生年映射到一个公共时间轴上去。听起来复杂但实现上没有任何新的技术难点。它让我意识到血缘关系图真正的价值不只是“看谁是谁的父亲”而是让人能够用一种可交互、可扩展的视角去重新理解家族历史。SVG的灵活度让我在后期加这个功能时几乎没有改动原有结构只是往节点模板里多塞了几个元素这大概就是选择SVG技术最实实在在的回报。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑