2026/9/7 6:19:14

MATLAB手写桑基图:从贝塞尔曲线到流量宽度映射的完整实现

MATLAB手写桑基图:从贝塞尔曲线到流量宽度映射的完整实现 简介在MATLAB环境中绘制单向Sankey图可借助自定义函数drawSankey解决标准库无内置功能的短板。该资源面向需要展示能量转换、物质流动或信息传递关系的数据分析与可视化开发者尤其适合环境科学、经济学等领域的科研人员。压缩包内共有2个文件包含核心的drawSankey.m函数文件与license.txt许可协议总大小仅4KB轻量易用。已有280人学习下载。通过研读源码可学习MATLAB自定义图形绘制思路掌握节点列表、流量矩阵等输入处理以及patch、plot等底层绘图命令的调用方式同时license.txt明确了使用与分发条件便于合规应用。 做数据可视化的人大多躲不开桑基图。我最初接触是在一次能源流向分析里需要把不同来源的电力分配到各个用电环节还要让宽窄直观反映数值大小。PPT里这种图很好看但到了MATLAB里就有点尴尬——找了一圈居然没有现成的内建函数能直接画只能自己动手写一个drawSankey。这篇文章把我当时手写桑基图绘制的完整思路、数据格式设计、贝塞尔曲线计算和踩过的坑都梳理一遍代码可以直接拷贝运行。适合做能量流、资金流、用户转化漏斗、供应链分配可视化又不想被第三方依赖绑住的MATLAB使用者。我自己实测下来核心绘图逻辑大概一百多行放在函数文件里就能反复调用还能塞进App Designer做交互界面。1. 为什么要在MATLAB里手写桑基图桑基图的核心特征是“流量守恒”流入节点的总量等于流出节点的总量连接线的宽度按数值比例缩放。这个图在能源审计、碳排放追踪、电商转化漏斗、流量分配等场景里几乎成了标准表达方式。但MATLAB的绘图体系里并没有一个开箱即用的SankeyDiagram类graph和digraph虽然能画网络拓扑却做不出宽度映射的效果这也就给了自己动手的充分理由。1.1 没有内置方案但第三方实现各有各的坑MATLAB File Exchange上其实有人传过桑基图相关的代码但质量参差不齐。有的依赖封装好的类对象改个颜色要翻半天下划线属性有的用了较新的语法特性2020a之前的版本直接报错还有的在节点布局上写死了顺序换一套数据就乱成一锅粥。至于调用Python的plotly或者在MATLAB里用webread拉在线图表一旦离线或者依赖库版本变化整个流程就变得很脆弱。实际上桑基图的绘图逻辑并不复杂核心就三件事给节点排坐标、让连接线变成有宽度的流量带、再给流量带上色。手写一个drawSankey不仅能完全掌控视觉效果还能把数据接口设计成自己最顺手的样子更重要的是不依赖网络和第三方运行时所有逻辑都跑在MATLAB原生绘图命令上。1.2 自己写的核心收益可控、可扩展、可交互自己实现最大的好处是可控。比如节点文字重叠时我可以直接改布局逻辑而不是去翻第三方代码找对应属性需要把桑基图嵌进App Designer的UIAxes时也只需要暴露出一个Parent参数就能适配。延展起来也很方便比如加一个“按来源统计总量”的文本标签或者把鼠标悬停时的提示做成交互回调。代价也很明显布局算法、贝塞尔曲线控制点、颜色归一化全得自己处理。不过别被这些术语吓到MATLAB的fill、patch、line这些底层绘图命令足够灵活只要把数学关系理清楚代码量其实很小。我的方案里数据接口就是三四个数组加一个标签cell数组没有复杂对象封装对新手友好也方便二开。2. 数据接口与坐标布局设计层面的核心取舍任何绘图函数第一步都是把“我们要画的东西”翻译成“计算机能理解的数据”。桑基图的数据本质上是一张有向有权的边表每一条边记录“从哪个节点出发”、“到达哪个节点”、“流量是多少”。这个模型同时适用于漏斗分析访问→注册→下单和能源分配电厂→电网→工业用户。2.1 数据格式别绕弯子直接用数组我一开始尝试过用table存储边数据列名设成Source、Target、Value读起来很直观。但后来发现table在处理重复节点名、批量索引时偶尔要额外转换反而增加了心智负担。最终我采用了更简单的接口设计函数签名是drawSankey(src, dst, val, labels)其中src和dst是等长的字符串元胞数组val是等长的数值向量labels是节点名称元胞数组。这样做的好处是调用方可以直接从Excel、CSV或者数据库查询结果里抽出两列文本和一条数值列塞进变量就能画图不需要额外组装对象。数据量稍微大一点的时候用元胞数组的unique和ismember做映射也很快。更重要的是后续做节点坐标计算时需要频繁按节点名聚合数值元胞数组配合accumarray处理起来非常顺手。2.2 节点分层两类数据的处理方式桑基图节点通常按层级排列。现实数据往往分成两类。第一类自带层级信息典型的是漏斗访问→注册→下单→支付顺序天然固定。第二类没有层级比如多个供应商供给多个工厂工厂又供给多个仓库需要从连接关系推断层级。对于第一类直接在数据接口里加一个可选的layer参数让调用方传入每个节点所在层级序号即可。对于第二类我用的是一个简单的广度优先策略找到所有“没有入边”的节点作为第0层然后沿着边表逐步扩展目标节点的层级就是源节点层级加一。这个思路相当于从源头开始给整个网络分层类似从水源出发规划水渠走向只有先搞清楚水从哪来才能安排管道怎么排。层级确定后每个节点的x坐标就是所在层的索引乘以一个固定间距。比如第0层x0第1层x1第2层x2。这个间距之后会作为贝塞尔曲线水平控制偏移的参考值所以建议统一用归一化坐标最后再通过axis equal或者set(gca, DataAspectRatio, [1 1 1])来调整显示比例。2.3 坐标布局流量越大越靠中间同一层内多个节点上下排列的顺序和间距直接决定了桑基图的观感。一个在可视化领域被反复验证的经验是把流量大的节点尽量排在中间位置可以让连接线整体更紧凑、交叉更少。具体做法是对每一层节点按“进出总流量”排序然后从中间向两侧分配y坐标。第k层总共有n个节点按照流量排序后的索引是1到n。如果n是奇数中间节点y设为0如果n是偶数中间两个节点分别设为-0.5和0.5。其他节点依次向上或向下偏移相邻节点间距取固定值gap。每个节点的高度则正比于它的总流量nodeHeight(i) totalFlow(i) / layerTotalFlow * availableHeight。这样视觉上大节点占位大小节点占位小流量带的宽度映射也和节点尺寸形成呼应。我实际使用中还会给每层预留10%的高度余量防止文字标注溢出坐标轴范围。这部分参数全部做成可选项调用方可以自己调整间距和边距。3. 流量带绘制把数值变成视觉宽度桑基图最核心的视觉效果是每一条连接线都呈现为一条有宽度的“河流”。这条河流从源节点的右侧出发平滑地汇入目标节点的左侧宽度与流量值成正比。实现这个效果要解决两个问题第一用什么绘图原语填充一个不规则形状第二形状的左右边缘曲线如何生成。3.1 用patch而不是循环fill填充不规则区域MATLAB里最直接的是fill函数。但fill每次调用只能画一个多边形循环调用时图形对象数量暴涨画三五十条流量带就开始拖帧。更合理的做法是用patch一次传入多个面的顶点和面连接关系让MATLAB在底层批量渲染。我的做法是为每条流量带生成四个顶点源节点右边缘的上下两个点、目标节点左边缘的上下两个点。但这四个点只能构成一个梯形上下边都是直线看起来有点呆板。为了让连接线更自然我会把上下边各自细分成多个采样点用贝塞尔曲线来生成路径。这样每个流量带就变成一个由若干三角形拼成的多边形顶点数量可以自己控制。使用patch时需要注意两个细节顶点数组用N行2列或N行3列的矩阵面连接关系用单元数组或矩阵如果所有面都用相同数量的顶点矩阵形式效率更高。流量带通常需要半透明效果来表现交叉区域所以面的FaceAlpha统一设成0.5左右EdgeAlpha设成0.1既保留交叉处的层级感又不会让重叠色块糊成一片。3.2 贝塞尔曲线流量带流畅过渡的关键桑基图的连接线不能是折线最好是平滑曲线。工程上最常用的是三次贝塞尔曲线只需要起点、终点和两个控制点就能生成任意平滑形状。源节点右侧的点(x0, y0)到目标节点左侧的点(x1, y1)之间控制点坐标可以定义为ctrlOffset abs(x1 - x0) * 0.5; ctrl1 [x0 ctrlOffset, y0]; ctrl2 [x1 - ctrlOffset, y1];这个0.5的经验系数是我实测后比较舒服的取值。系数过小曲线拐得太急系数过大曲线会在水平方向冲出节点边界看起来很奇怪。两个控制点分别沿着水平方向偏移保证曲线在起终点处的切线方向基本水平这正好符合桑基图节点侧边出流、侧边入流的视觉习惯。实际绘制时我用linspace(0, 1, 20)生成20个参数点然后按三次贝塞尔公式计算路径坐标。20个采样点的精度已经足够肉眼完全看不出折线痕迹节点之间的曲线也足够平滑。如果连接线跨多个层级还可以用四段贝塞尔拼接来绕过中间层但绝大多数场景下直接画一条贯穿曲线效果就很好。3.3 颜色映射与透明度控制颜色分配有两种常见策略。第一种是按源节点固定颜色所有从同一个源节点出发的流量带颜色一致这样比较容易追踪“谁流向谁”。第二种是按流量值做连续渐变比如从低到高对应蓝到红适合表达数值大小。我个人更推荐第一种因为桑基图的核心是结构关系颜色作为类别标识比数值映射更直观。颜色映射的实现思路是先把所有源节点提取出来映射到parula或jet色图的索引位置然后把相应的RGB颜色赋给每一条流量带。为了让流量带和节点视觉上实现呼应节点填充色也可以使用同一套颜色方案。patch的FaceColor可以传入一个N行3列的颜色矩阵每个面板独立着色效率很高。透明度这块容易踩坑。patch的FaceAlpha设置成0.3到0.6之间都行但要注意MATLAB的alpha函数在某些渲染模式下会忽略面透明度如果发现设置后图形完全不透明可以在figure的renderer属性里切换opengl和painters试试。导出图片时也要确认导出设置保留了透明通道否则半透明区域会变成实心块。4. 完整实现可以直接运行的drawSankey示例理论和原理说得再多不如直接给一份能跑的代码。下面这个drawSankey函数是我在项目中使用的精简版本去掉了部分统计输出保留了核心布局与绘图流程。把它保存成drawSankey.m再运行示例脚本就能看到一张基本的桑基图。4.1 函数主框架与调用示例function h drawSankey(src, dst, val, labels, varargin) % drawSankey - 绘制桑基图 % 输入: % src - 源节点名称元胞数组例如 {A,A,B} % dst - 目标节点名称元胞数组与src对应 % val - 流量数值向量与src对应 % labels- 全部节点名称元胞数组可包含孤立节点 % 参数: % Parent - 目标坐标轴句柄默认 gca % Gap - 同层节点间距默认 0.3 % MinAlpha - 流量带透明度默认 0.5 % 解析输入参数 parent gca; gap 0.3; minAlpha 0.5; for i 1:2:length(varargin) switch varargin{i} case Parent parent varargin{i1}; case Gap gap varargin{i1}; case MinAlpha minAlpha varargin{i1}; end end % 节点去重并建立索引映射 [uniqLabels, ~, ic] unique(labels(:), stable); nNode length(uniqLabels); srcIdx zeros(size(src)); dstIdx zeros(size(dst)); for i 1:length(src) srcIdx(i) find(strcmp(uniqLabels, src{i})); dstIdx(i) find(strcmp(uniqLabels, dst{i})); end % 计算节点总流量 inFlow zeros(nNode, 1); outFlow zeros(nNode, 1); for i 1:length(src) outFlow(srcIdx(i)) outFlow(srcIdx(i)) val(i); inFlow(dstIdx(i)) inFlow(dstIdx(i)) val(i); end totalFlow max(inFlow, outFlow); % 用广度优先推断层级 layer zeros(nNode, 1); queue []; for i 1:nNode if outFlow(i) 0 inFlow(i) 0 queue(end1) i; end end while ~isempty(queue) cur queue(1); queue(1) []; for i 1:length(src) if srcIdx(i) cur nxt dstIdx(i); if layer(nxt) layer(cur) 1 layer(nxt) layer(cur) 1; end queue(end1) nxt; end end end % 按层分配x坐标 xCoord layer; nLayer max(layer) 1; % 每层内按流量排序确定y坐标 yCoord zeros(nNode, 1); nodeHeight zeros(nNode, 1); for lay 0:nLayer-1 idxLayer find(layer lay); if isempty(idxLayer) continue; end [~, sortOrder] sort(totalFlow(idxLayer), descend); idxLayerSorted idxLayer(sortOrder); n length(idxLayerSorted); yPos linspace((n-1)/2, -(n-1)/2, n) * gap; for k 1:n node idxLayerSorted(k); yCoord(node) yPos(k); nodeHeight(node) totalFlow(node) / sum(totalFlow(idxLayer)) * n * gap; end end % 绘制节点 hold(parent, on); nodeColor parula(nNode); for i 1:nNode rectangle(parent, Position, [xCoord(i), yCoord(i)-nodeHeight(i)/2, 0.15, nodeHeight(i)], ... FaceColor, nodeColor(i,:), EdgeColor, none); text(parent, xCoord(i)0.2, yCoord(i), uniqLabels{i}, FontSize, 10); end % 绘制流量带 for i 1:length(src) s srcIdx(i); t dstIdx(i); x0 xCoord(s) 0.15; y0Top yCoord(s) nodeHeight(s)/2; y0Bot y0Top - val(i) / outFlow(s) * nodeHeight(s); x1 xCoord(t); y1Top yCoord(t) nodeHeight(t)/2; y1Bot y1Top - val(i) / inFlow(t) * nodeHeight(t); ctrlOff abs(x1 - x0) * 0.5; c1Top [x0 ctrlOff, y0Top]; c2Top [x1 - ctrlOff, y1Top]; c1Bot [x0 ctrlOff, y0Bot]; c2Bot [x1 - ctrlOff, y1Bot]; tt linspace(0, 1, 20); topCurve bezier3([x0, y0Top], c1Top, c2Top, [x1, y1Top], tt); botCurve bezier3([x0, y0Bot], c1Bot, c2Bot, [x1, y1Bot], tt); xPoly [topCurve(:,1); flipud(botCurve(:,1))]; yPoly [topCurve(:,2); flipud(botCurve(:,2))]; patch(parent, xPoly, yPoly, nodeColor(s,:), FaceAlpha, minAlpha, ... EdgeColor, none); end axis(parent, equal); box(parent, on); xlim(parent, [-0.3, max(xCoord)1]); if nargout 0 h gcf; end end function p bezier3(p0, p1, p2, p3, tt) % 三次贝塞尔曲线求值 p (1-tt).^3 .* p0 3*(1-tt).^2 .* tt .* p1 ... 3*(1-tt) .* tt.^2 .* p2 tt.^3 .* p3; end调用示例很简单三行就能出图src {电厂A, 电厂A, 电厂B}; dst {工业用户, 居民用户, 工业用户}; val [50, 30, 20]; drawSankey(src, dst, val, {电厂A, 电厂B, 工业用户, 居民用户});4.2 让节点文字不重叠的实用技巧文字重叠是我在实际使用中最头疼的问题尤其是节点名称比较长、同一层节点很多的时候。最简单的处理策略是两个第一在text绘制时把字号调小适合节点数量中等的情况第二对同一层内的节点按名称长度做“波浪形”排列奇数索引节点往上偏移一点、偶数索引节点保持原位字多的时候能显著减少重叠。如果信息密度确实很高还有一个更彻底的办法把鼠标悬停提示做成交互式节点平时只显示一个短编号完整名称放在tooltip里。这在App Designer里用WindowButtonMotionFcn回调可以实现虽然代码量会多一些但展示效果比硬抗文字重叠强得多。4.3 在App Designer里嵌入UIAxes很多用户现在用MATLAB的App Designer做交互界面想把桑基图嵌进去。直接调用drawSankey默认用gca会出错因为App Designer里的坐标轴是UIAxes对象不是普通的Axes。解决办法是使用Parent参数显式传递坐标轴句柄例如drawSankey(src, dst, val, labels, Parent, app.UIAxes);另外rectangle和patch在UIAxes中的渲染和普通Figure略有差异主要体现在交互旋转和缩放响应上。如果只需要静态展示问题不大如果需要缩放查看细节建议把绘图逻辑放到普通Figure里再通过imshow之类的方案回传到界面这样性能和稳定性都更有保障。5. 常见问题与排查技巧实录任何绘图代码在实际使用中都会冒出各种幺蛾子。我把这段时间集中遇到的问题整理成一个速查表后面再展开讲排查思路。现象可能原因解决方案图形右侧明显比左侧宽/窄源节点总流量与目标节点总流量不相等检查数据是否漏了节点或边先做总量校验节点出现无意义的巨大空白同层节点间距gap过大或者坐标轴比例失衡调小gap或去掉axis equal改为手动daspect流量带交叉严重像一碗面条节点顺序未优化、边跨层级过多按流量排序节点尝试把大流量节点放中间半透明颜色变成实心色块渲染器不支持透明度通道set(figure, Renderer, opengl)中文标签显示成方框MATLAB默认字体不支持中文设置字体set(gca,FontName,Microsoft YaHei)绘制几百条流量带时很卡顿每个patch对象独立、采样点过多采样点降到12边合并成矩阵批量patch5.1 数据不对齐总量不守恒桑基图的美感很大程度来自“左边总宽等于右边总宽”。如果源节点的总流量之和与目标节点的总流量之和不相等图怎么看怎么别扭。我遇到过的情况包括Excel里某一行数据填错、两个源节点名称里多了个空格导致被视为不同节点、以及数据清洗时把某个目的节点过滤掉了。排查时我习惯先跑一个小代码片段if abs(sum(val) - sum(accumarray(dstIdx, val))) 1e-9 warning(目标节点总流量与边总流量不一致); end一旦发现不一致优先检查节点名称是不是完全一致特别是中英文空格、全半角括号这类隐形差异。名称匹配问题坑了我好几次后来我统一用strtrim提前清理所有标签文本。5.2 布局混乱节点顺序优化当网络结构比较复杂时节点顺序不当会导致流量带大量交叉。这个问题的本质是一个最小化交叉数的布局问题理论上没有最优解。我采用一个工程上够用的贪心策略逐层处理前一层节点顺序固定后下一层节点根据“与已排序节点的连接数量和权重”打分再按分数排序。这个策略在当地结构不复杂时效果相当好。如果交叉仍然多可以先检查是否有跨多级的边。比如A层直接有一条边连到C层把中间B层的节点“绕过去”了这种长边几乎必然产生交叉。一个缓解办法是加入虚拟中间节点把长边拆成两段代价是图里会多出一些辅助节点。大多数业务场景下流量带交叉带来的视觉负担不算太大不必为了优化到极致而牺牲代码复杂度。5.3 大数据量下的渲染卡顿我试过把一年的供应链数据画成一张桑基图边数量超过两千条patch对象数量几千个结果MATLAB窗口拖一下很久才响应。优化手段有几个方向。第一流量太小的边直接过滤比如低于总流量1%的边不画视觉上几乎无感第二把贝塞尔曲线采样点从20降到12第三将同色的相邻边合并成一个大面片这需要额外做聚类收益也很可观。导出图片时也要注意。用exportgraphics导出矢量格式PDF/SVG时几千个面片会让文件体积暴涨打开卡顿。我的做法是导出高分辨率PNG设置Resolution, 300效果足够打印和汇报使用。5.4 中文标签与系统字体MATLAB在跨平台使用中文标签时经常出现方框尤其是Windows下的中文路径、Linux下的字体缺失情况。最简单粗暴的解决方案是在画图前统一设置字体set(0, DefaultAxesFontName, Microsoft YaHei); set(0, DefaultTextFontName, Microsoft YaHei);如果是在Linux服务器上跑可能还需要检查系统是否装了中文字体没有的话可以用fc-list :langzh查看。字体问题虽然小但在最终汇报时影响观感值得提前处理。6. 我实际操作中的几点体会drawSankey这个函数写完之后我最大的体会是明确的坐标和几何关系是画好桑基图的基础。数据和视觉宽度之间的映射要提前定清楚节点高度、流量带宽度、纵坐标分布这些参数不要混在绘图代码里随手改最好集中成几个可调参数。把界面、数据和绘图逻辑分开后面迭代时会轻松很多。最后再分享一个小技巧在调试阶段先用一组只有三五个节点的模拟数据跑通流程确认所有参数都符合预期后再替换成真实数据。这个习惯帮我省掉了大量对着复杂图形找代码bug的时间。如果你也被桑基图或者类似的定制化图表困扰不妨试着围绕自己的数据特点写一个小函数。MATLAB的底层绘图工具非常灵活很多看似“没有现成方案”的图自己动手实现一遍反而能彻底理解它的逻辑以后改动和复用都游刃有余。本文还有配套的精品资源点击获取