简介:这份资源面向需要在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,换五六种配色,结果布局没排好,颜色再好看也白搭。后来固定成三步:先保证数据守恒和分层正确,再调节点顺序减少交叉,最后才碰颜色。颜色只做一件事——区分层级或类别,不要试图用颜色表达流量大小,流量已经由宽度表达了。宽度和颜色表达同一个维度,是信息冗余,不是信息增强。这个顺序反过来,返工率极高。
希望帮到你。
本文还有配套的精品资源,点击获取