MATLAB drawSankey:桑基图从数据到矢量图的完整实现 简介这份资源面向需要在MATLAB中绘制流向图的开发者与数据分析人员提供自定义函数drawSankey用于弥补MATLAB标准绘图库缺少Sankey图支持的空白。Sankey图以连接线宽度表示流量大小常用于能量转换、物质流动与信息传递等场景在环境科学、经济学和社会学领域均有应用。压缩包共2个文件包含1个m文件与1个txt文件整体约4KB其中m文件承载函数定义、参数处理、数据计算与图形绘制逻辑txt文件为许可协议说明使用条件与版权信息。已有286人学习下载。通过阅读源码读者可掌握节点列表、流量矩阵等输入的组织方式理解如何借助plot、patch等图形函数构建自定义图表并据此调整颜色、线宽与布局完成从数据准备到图形输出的完整流程同时获得一个深入学习MATLAB自定义图形编程的实例提升数据分析与可视化能力。1. 从一张流向图说起drawSankey 到底解决什么问题做过能流分析、物料平衡或者用户路径归因的人大概率都遇到过同一个尴尬数据算得清清楚楚画出来的图却像一团乱麻。尤其是当节点超过七八个、流向交叉三四层的时候用普通折线或者堆叠柱状图去表达“从哪来、到哪去、损耗多少”读者基本靠猜。drawSankey 这个方向本质上就是冲着这个痛点去的——它要在 MATLAB 环境里把一组带权重的源汇关系渲染成一张宽度正比于流量、节点可分层、路径可追溯的桑基图。它适合三类人一是做能源系统或过程工业仿真的工程师需要把能量流、物料流画进报告二是做用户行为漏斗、资金流转分析的数据岗想让汇报图一眼看懂三是已经在用 MATLAB 做科研绘图、不想为了画一张桑基图再切到 Python 或前端库的人。核心价值就一句话在 MATLAB 里用尽量少的依赖把流向数据变成可发表的矢量图。这一章先把“它是什么、给谁用”立住后面几章再拆怎么落地、参数怎么调、哪里容易翻车。2. 桑基图的数学骨架与 drawSankey 的选型逻辑2.1 桑基图不是“画带子”而是解一个流量守恒问题很多人第一次上手会以为桑基图就是画一堆粗细不同的曲线其实真正的难点在布局。一张合法的桑基图必须满足两个约束每个中间节点的流入总量等于流出总量守恒以及同一层节点在垂直方向上不能重叠可读性。把这两条翻译成可计算的模型就是给定节点集合 V、边集合 E 及每条边的权重 w先做拓扑分层再在每一层内做区间分配最后把边渲染成从源节点区间到目标节点区间的带状多边形。分层这一步常见做法是按有向无环图的最长路径深度来定层号。如果数据里存在环就得先做断环处理否则布局会死循环。区间分配则是把每个节点的可用高度按流入或流出权重切成若干段段与段之间留 padding。drawSankey 这类 MATLAB 实现的选型逻辑通常不会去解复杂的线性规划而是用“按权重比例分配 迭代松弛”的轻量方案牺牲一点最优性换来实现简单和可调试。这个取舍很关键如果你追求的是学术级的交叉最小化那得另找专门库如果你要的是“数据对、图能看、能改”这个方向就够用。2.2 节点顺序为什么比颜色更影响可读性一个血泪经验桑基图翻车十次有八次不是颜色丑而是节点顺序没排好。假设你有 A、B、C 三个源流向 X、Y、Z 三个汇如果源节点按字母序排、汇节点也按字母序排带子就会大量交叉读者眼睛跟着线跑两下就晕了。正确的做法是让源和汇的顺序尽量“同构”——流量大的源对应流量大的汇并且让主要流向尽量平行。在 MATLAB 里实现这一点可以在分层之后加一步排序对每一层节点按其与相邻层连接权重的重心barycenter排序迭代两到三轮。重心排序的公式很朴素节点 i 在下一层的位置 与它相连的下一层节点位置按权重加权的平均值。这个操作不需要额外工具箱几十行代码就能写。drawSankey 方向上的很多实现会把这个排序做成可选参数默认开一轮数据量大时开三轮。别小看这一步它直接决定了你的图是“能发论文”还是“只能自己看”。2.3 用 MATLAB 原生 patch 构建带状路径的最小实现下面这段代码演示的是最核心的渲染逻辑把一条边从源区间到目标区间用 patch 画成一个四边形带子。它不依赖任何第三方函数纯 MATLAB 原生。function drawRibbon(ax, srcX, srcTop, srcBot, dstX, dstTop, dstBot, color) % srcX/dstX: 源列和目标列的 x 坐标 % srcTop/srcBot: 源节点上、下边界 y 值 % dstTop/dstBot: 目标节点上、下边界 y 值 % color: RGB 三元组 % 用三次贝塞尔思路做平滑在中间插入两个控制点 midX (srcX dstX) / 2; t linspace(0, 1, 60); % 采样点60 段足够平滑 % 上边界曲线 xTop (1-t).^3*srcX 3*(1-t).^2.*t*midX 3*(1-t).*t.^2*midX t.^3*dstX; yTop (1-t).^3*srcTop 3*(1-t).^2.*t*srcTop 3*(1-t).*t.^2*dstTop t.^3*dstTop; % 下边界曲线 xBot xTop; yBot (1-t).^3*srcBot 3*(1-t).^2.*t*srcBot 3*(1-t).*t.^2*dstBot t.^3*dstBot; % 拼成一个闭合多边形上边界正向 下边界反向 X [xTop; flipud(xBot)]; Y [yTop; flipud(yBot)]; patch(ax, X, Y, color, EdgeColor, none, FaceAlpha, 0.6); end逻辑说明这里用三次贝塞尔曲线分别描述带子的上边界和下边界控制点都放在中间 x 位置保证带子从源到汇是平滑过渡而不是硬折线。采样 60 个点是为了在矢量输出时曲线足够顺又不至于让文件过大。参数方面FaceAlpha设 0.6 是经验值——太低看不清流向太高会遮住下层带子EdgeColor设 none 是为了避免带子边缘出现细黑线如果要做印刷稿可以改成细白边。srcTop和srcBot的差值就是这条边在源节点上占的高度它应该等于该边权重除以源节点总流出权重再乘以节点可用高度。这个换算关系是整个渲染的命门算错了带子宽度就不守恒。3. 从邻接表到成图drawSankey 的完整落地步骤3.1 输入数据格式三列还是矩阵先定死落地第一步不是写渲染而是定数据接口。我一般会要求输入是一个 N×3 的矩阵或三列表格三列分别是源节点名、目标节点名、流量值。节点名用字符串或 categorical 都行但同一批数据里类型要统一否则后面 unique 和索引会对不上。为什么不直接收邻接矩阵因为桑基图的数据往往是稀疏的邻接矩阵在节点上百时会浪费大量内存而且矩阵形式不容易表达“同名节点在不同层”的情况。% 推荐输入三列 cell 或 table % links.src {煤电,煤电,气电,风电,外购}; % links.dst {工业,居民,工业,居民,工业}; % links.value [120, 80, 60, 45, 30]; links table( ... {煤电;煤电;气电;风电;外购}, ... {工业;居民;工业;居民;工业}, ... [120;80;60;45;30], ... VariableNames, {src,dst,value});逻辑说明用 table 而不是裸 cell是为了后面能直接用groupsummary做聚合也方便处理缺失值。参数上value列必须是数值且非负出现负值要先做方向翻转或单独处理桑基图本身不表达负流量。如果同一对源汇出现多行要在预处理阶段合并否则会出现两条重叠带子视觉上像一条实际数据翻倍。3.2 分层与节点高度分配两个循环搞定拿到数据后先构建节点全集再做分层。分层用迭代法初始所有节点层号为 0然后反复扫描边把目标节点层号更新为 max(自身, 源节点层号1)直到不再变化。这个算法对有环图会不收敛所以扫描次数要设上限比如节点数的两倍超了就报警。nodes unique([links.src; links.dst]); layer containers.Map(nodes, num2cell(zeros(numel(nodes),1))); maxIter numel(nodes) * 2; for iter 1:maxIter changed false; for k 1:height(links) s links.src{k}; d links.dst{k}; if layer(d) layer(s) 1 layer(d) layer(s) 1; changed true; end end if ~changed, break; end end if iter maxIter warning(检测到可能的环布局结果仅供参考); end逻辑说明containers.Map在这里做节点到层号的映射比用结构体更灵活。maxIter设成节点数两倍是个保守值正常无环图迭代次数不会超过最长路径长度。如果触发了 warning说明数据里有循环依赖比如 A 流向 B、B 又流向 A这时候要么业务上断环要么在图上把其中一条边标成虚线表示“反馈流”但不要硬画成普通带子。节点高度分配按层进行每一层先算该层所有节点的总流量源层用流出中间层用流入流出较大者然后按总可用高度减去 padding 后按比例分配。padding 一般取总高度的 2% 到 5%节点少时取大值节点多时取小值。3.3 渲染顺序与透明度先画谁后画谁有讲究渲染顺序直接影响观感。正确顺序是先画带子再画节点矩形最后画文字标签。带子之间先画权重大的、后画权重小的这样细带子叠在粗带子上面不会被完全遮住。如果反过来细带子会被粗带子盖住读者以为数据丢了。[~, order] sort(links.value, descend); % 大流量先画 for k order % 计算该边在源节点和目标节点上的区间 [srcTop, srcBot] getSpan(srcNode, links.value(k), out); [dstTop, dstBot] getSpan(dstNode, links.value(k), in); drawRibbon(ax, xSrc, srcTop, srcBot, xDst, dstTop, dstBot, cmap(layer(dstNode),:)); end逻辑说明sort的descend保证大带子先入画布后画的小带子浮在上面。getSpan是自定义函数负责把某条边在节点上的相对位置换算成绝对 y 坐标它内部要维护每个节点已分配高度的游标。颜色按目标节点层号从 colormap 取这样同一层的带子色调一致读者能快速区分层级。透明度统一 0.6 左右如果层数超过五层可以降到 0.45避免颜色叠加后发黑。4. 避坑与排查drawSankey 落地时最容易翻车的五件事4.1 带子宽度不守恒图看着对但数据对不上现象画出来的图挺好看但把某节点所有流出带子宽度加起来和该节点流入总和对不上。原因在计算区间时源节点用流出权重分配、目标节点用流入权重分配两边各自归一化导致同一条边在源端和目标端的宽度不一致。解决每条边的宽度必须由同一个全局比例尺决定即边宽度 边权重 / 全局最大层流量 * 可用高度源端和目标端都用这个宽度而不是各自按节点归一化。这个坑非常隐蔽因为图不会报错只有拿尺子量或者导出数据核对才会发现。4.2 节点标签重叠调了字号还是挤现象节点一多标签文字互相压在一起缩小字号后虽然不重叠了但看不清。原因节点高度是按流量比例分的小流量节点天然就矮标签没地方放。解决不要死磕字号改用引导线——标签统一放到图外侧用一条短横线连回节点。如果节点实在太密就在标签层做一次简单的贪心避让按 y 坐标排序相邻标签间距小于阈值时把其中一个上移或下移。这个逻辑二十行以内能写完比反复调字号有效得多。4.3 数据里有零流量边画出来是一条看不见的线现象某些边权重为 0渲染后是一条宽度为零的线虽然看不见但占用了渲染时间而且鼠标悬停时可能触发异常。原因预处理没过滤零值。解决在构建 links 之后立刻加一行links(links.value 0, :) []。如果业务上零值有含义比如“曾经有、现在断了”那就单独用虚线表示不要混在正常带子里。另外要注意过滤零值后节点集合可能变小分层要重新算不能沿用过滤前的层号。4.4 导出矢量图后带子出现白边或锯齿现象屏幕上看着平滑导出 PDF 或 EPS 后带子边缘有细白线或者曲线变成折线。原因patch 的 EdgeColor 没关或者采样点太少。解决导出前把EdgeColor设为none采样点从 60 提到 120。如果还有白边检查是不是相邻带子之间留了亚像素缝隙可以把带子宽度整体放大 0.5 个像素再导出。这个属于渲染玄学但试两次基本能定位。4.5 节点一多就卡循环里反复查 Map现象节点数超过两百后出图要等十几秒甚至更久。原因在双层循环里反复用containers.Map做键查询Map 的查询是 O(1) 但常数不小循环次数是边数乘以迭代轮数。解决把节点名转成数值索引用数组代替 Map。具体做法是先[~,~,srcIdx] unique(links.src)拿到索引后面所有查层号、查区间都用layer(srcIdx(k))这种数组下标访问。改完之后同样数据量通常能快五到十倍。这个优化不改变任何视觉结果属于纯工程收益。5. 进阶技巧让 drawSankey 从“能画”到“好用”5.1 用回调做交互式高亮汇报时不用切图静态图最大的问题是没法回答“这条带子具体是多少”。一个实用技巧是给每个 patch 对象绑 ButtonDownFcn点击时把该带子对应的源、汇、流量显示在标题或文本框里同时把其他带子透明度降到 0.15。实现上在 drawRibbon 返回 patch 句柄后统一设置h drawRibbon(...); h.ButtonDownFcn (src,~) highlightFlow(src, links, k, allPatches);highlightFlow里先把所有 patch 的 FaceAlpha 重置为 0.15再把当前 patch 设为 0.85并更新一个预置的 text 对象内容。这个交互不需要 App Designer普通 figure 就能跑。汇报时鼠标一点流向和数值同时出来比翻备注页体面得多。5.2 多组数据对比并排还是叠加先想清楚有时候要对比两个场景的流向差异比如改造前和改造后。并排画两张图最稳妥但占地方叠加画一张图容易混淆。折中做法是画一张图但用带子边框颜色区分场景场景 A 用实心填充场景 B 只画轮廓不填充。这样同一节点位置上能看到两组带子的宽度差。实现时把两组数据合并后统一算全局比例尺否则宽度不可比。这个技巧在能流对比里很常用但前提是两组数据的节点集合要一致不一致的节点要补零。5.3 一个我常犯的错误过早调颜色最后说个习惯问题。我早期做这类图总是一上来就调 colormap换五六种配色结果布局没排好颜色再好看也白搭。后来固定成三步先保证数据守恒和分层正确再调节点顺序减少交叉最后才碰颜色。颜色只做一件事——区分层级或类别不要试图用颜色表达流量大小流量已经由宽度表达了。宽度和颜色表达同一个维度是信息冗余不是信息增强。这个顺序反过来返工率极高。希望帮到你。本文还有配套的精品资源点击获取