
做数据分析图表这些年我一直觉得有一个需求特别高频既想看整体盘子的大小又想看清楚每一类内部是怎么构成的。普通堆叠柱状图能解决一半——它能看到内部构成但每个柱子宽度一模一样如果某个品牌体量只有别人一半画出来完全没区别。Marimekko 可变宽度图就是为这种“双维度构成分析”设计的横轴宽度表达整体占比纵轴高度表达内部细分结构一张图把两个关键信息都压缩进去。这篇文章我会用 Highcharts 从零实现一个可直接运行的 Marimekko 示例并把实测中踩过的坐标系映射、标签防重叠、浮点累计误差这些坑一并写出来适合正在做经营分析、BI 看板或数据报表的前端和数据工程师参考。1. 一眼看懂 Marimekko 在表达什么1.1 它和普通堆叠柱状图的本质差异我先用一个具体例子说明问题。假设某品类市场只有 A、B 两个品牌A 的销售额占总盘子的 60%B 占 40%。A 内部高端产品占 20%B 内部高端产品占 70%。用普通堆叠柱状图画两个柱子高度相同、宽度相同视觉上你会觉得两个品牌体量相当。但实际上 A 的高端产品对整体贡献只有 60% × 20% 12%B 的高端产品贡献是 40% × 70% 28%——B 的高端产品总量几乎是 A 的两倍多。这种信息只有通过矩形的面积差异才能准确传递出来。Marimekko 图的思路就是把这个数学关系直接视觉化每个品牌的矩形宽度 该品牌占总体的份额每个品牌内部各子类按比例纵向堆叠到 100%某个子类所在矩形的面积 宽度 × 高度 该子类对总体的实际贡献所以说Marimekko 图最被低估的价值是“面积即全局贡献”。你在图上扫一眼不用做乘法就能大致判断哪个子类对整体盘子影响最大这对经营分析非常有用。1.2 图中四个视觉通道的调度规则我把一张 Marimekko 图的视觉通道拆开来讲方便后面写代码时对号入座视觉通道表达含义在 Highcharts 中的对应横向位置与宽度该类别的总体份额x 和 x2 的差值纵向位置与高度类别内部子项的占比通常堆叠到 100%y 和 y2 的差值颜色子项类别或细分属性point 或 series 的 fill 颜色标签与提示品牌名、占比数值、全局贡献度dataLabels、tooltip有一点要注意普通柱状图适合做精确比较因为人眼对“同一起点的高度差”非常敏感而 Marimekko 依赖的是矩形面积人眼对面积的估算误差其实很大所以我一般只把它用在“看结构、看大局”的场景比如市场占有率分布、产品组合健康度、流量渠道构成分析。如果需要逐项精确对比数值我宁可回到普通柱状图或表格。1.3 什么时候不要用 Marimekko再说两个反例。第一时间序列数据不适合用 Marimekko宽度维度没法表达时间连续性硬做只会让读者困惑第二类别特别多的时候也不适合超过 8~10 个类别横向累计宽度会把小类别挤压成一条细线标签根本放不下。遇到这些场景我更推荐普通堆叠柱状图、旭日图或矩形树图。2. Highcharts 没有现成图表类型为什么最终选了 xrange2.1 社区里常见的几种实现方案Highcharts 官方并没有直接提供 marimekko 图表类型所以做之前要先选实现路径。我实际对比过几种思路各有取舍方案可控性代码量保留交互程度推荐度column 系列 pointWidth 调整低所有柱子宽度统一少高不推荐多 xAxis column 堆叠中能实现差异宽度但坐标对齐困难多中不推荐自定义 SVG renderer 绘制矩形高完全可控多低tooltip/图例要自己写看情况xrange 系列高每个矩形四边界单独指定少高推荐第一个方案看起来很省事但 Highcharts 的pointWidth是系列级别配置同一系列内所有点宽度一样。你可以在不同 series 里设置不同的 pointWidth但 series 之间会围绕同一个 x 值对齐根本没法按“累计宽度”排列最终效果不是 marimekko。第二个方案是给每个类别单独建一个 x 轴通过left和width控制每个坐标轴的绘图区宽度再在各自坐标轴内做堆叠 column。理论上可行但多个 x 轴的标签、网格线、边距很难对齐一旦数据动态变化坐标轴位置要跟着重算维护成本很高。我做过一次之后就放弃了。第三个方案用chart.renderer.rect自己画矩形灵活度最高但图例点击、hover 联动、tooltip 全部需要手动绑定事件工作量大不适合快速交付。最终我选 xrange 系列它每个数据点本质就是一个矩形x、x2、y、y2 四个坐标完全独立可控正好完美覆盖 Marimekko 的矩形坐标需求而且 tooltip、dataLabel、legend 这些交互都是原生可用的。2.2 xrange 的坐标模型把矩形四个边界交给数据xrange 系列的每个 point 是这样一个矩形描述x矩形左边界x2矩形右边界y矩形下边界在未反转坐标轴时y2矩形上边界对 Marimekko 来说映射关系非常直接某品牌的份额 → 右边界减去左边界即x2 - x品牌内部某子项的占比 → 上边界减下边界即y2 - y所有品牌从左到右拼起来 → 每个品牌的 x 从上一个品牌的 x2 开始累加所有子项从下到上拼起来 → 每个子项的 y 从上一个子项的 y2 开始累加你甚至可以把它理解成“一块块的瓷砖拼图”每块瓷砖的位置、长宽都由数据指定Highcharts 只负责把矩形画出来并绑定交互。2.3 方向问题这个方案产出的是横向 Marimekko这里有一个我没法绕开的事实xrange 系列原生的方向是水平排布所以基于它实现的 Marimekko 是“横向版”也就是经典 Marimekko 顺时针旋转 90 度的呈现。经典垂直版里横轴宽度是类别份额纵轴高度是内部构成横向版里水平方向表达品牌份额垂直方向表达内部构成数据和代码逻辑其实完全一样。很多商业工具比如 Power BI 的自定义可视化默认展示的也是横向变体所以读者接受度并不差。如果你必须做经典的竖向版本我提供两个思路一是把原始数据做行列转置后再用 xrange 画本质上还是这套映射逻辑二是在绘制层自己用 renderer 画垂直矩形但交互成本高适合作为后续增强方向。我这次示例先以横向版为准。3. 把业务数据换算成 x/x2/y/y2 坐标的完整过程3.1 原始数据结构设计数据准备是整个实现里最容易想不明白的一步。我建议把原始数据设计成“品牌 份额 内部子项列表”的结构让每个子项保留自己的业务属性和原始数值[ { brand: 苹果, share: 30, segments: [ { name: 高端, value: 65 }, { name: 中端, value: 30 }, { name: 低端, value: 5 } ] }, { brand: 华为, share: 28, segments: [ { name: 高端, value: 55 }, { name: 中端, value: 35 }, { name: 低端, value: 10 } ] } ]这里的share是品牌整体份额可以是百分比也可以是绝对值。如果是绝对值后面需要先归一化segments里的value是品牌内部子项的占比理想情况每个品牌内部子项总和是 100为稳妥起见代码里最好再做一次归一化兜底。3.2 宽度累计从左往右“铺地板”宽度映射的公式是这样总份额 totalShare Σ share 品牌宽度 share / totalShare × 100 品牌左边界 xStart_i xStart_{i-1} 品牌宽度_{i-1}用 JavaScript 实现的时候核心是用一个游标变量xCursor从左往右累加function calcBrandWidths(data, totalWidth 100) { const totalShare data.reduce((sum, d) sum d.share, 0); let xCursor 0; return data.map((d) { const width (d.share / totalShare) * totalWidth; const result { brand: d.brand, xStart: xCursor, xEnd: xCursor width, segments: d.segments }; xCursor width; return result; }); }注意share之和如果不是 100最后xCursor就不会正好等于 100图表右侧会出现空白或最后一个矩形超出坐标轴范围。所以归一化操作很重要宁可多写一行totalShare也不要直接拿原始值当宽度。3.3 高度累计在每个品牌内部“纵向堆叠”每个品牌内部的子项要按比例堆到 100%。我用yCursor在品牌内部纵向累加function normalizeSegments(segments) { const sum segments.reduce((s, seg) s seg.value, 0); if (sum 0) return segments; return segments.map((seg) ({ ...seg, value: (seg.value / sum) * 100 })); } function calcSegmentCoords(brand, totalHeight 100) { const segments normalizeSegments(brand.segments); let yCursor 0; return segments.map((seg) { const height (seg.value / 100) * totalHeight; const point { x: brand.xStart, x2: brand.xEnd, y: yCursor, y2: yCursor height, brand: brand.brand, segment: seg.name, segmentValue: seg.value }; yCursor height; return point; }); }这里我建议始终把totalHeight固定为 100视觉上所有品牌矩形顶边和底边对齐观感最干净。如果你不是堆叠到 100%而是用绝对数值做高度虽然也能画但不同品牌的高度基准不一致读者会误读。3.4 边界情况0 值和过小份额必须提前处理写数据映射时最容易翻车的是边界数据子项 value 为 0y2 - y 0xrange 对零高度矩形渲染偶尔会出现矩形消失但 tooltip 还在的怪现象建议在构建数据时直接过滤掉高度为 0 的点。品牌份额为 0宽度为 0同样建议跳过不要让一个宽度为 0 的点占住一个序列位置。最后一个品牌贴不到右边界浮点累加会出现99.999999与100之间的小裂缝处理方式是在构建完所有 point 后强制把最后一个点的x2修正为totalWidth。我平常会直接写一个round2工具函数把所有坐标做两位小数收尾避免浮点误差累积太多。4. 最小可运行示例从引入模块到图表渲染4.1 引入模块漏掉 xrange 一定会报错Highcharts 核心包不包含 xrange 系列必须单独引入模块。用 CDN 的话在引入 highcharts.js 之后再引入 xrange.jsscript srchttps://code.highcharts.com/highcharts.js/script script srchttps://code.highcharts.com/modules/xrange.js/script如果你用的是 npm 和打包器import Highcharts from highcharts; import xrangeModule from highcharts/modules/xrange; xrangeModule(Highcharts);这一步漏掉会直接遇到Highcharts error #17提示Unknown series type。这个报错我做第一版时遇到过排查了很久才发现是模块没引所以先写在这里等你真遇到时能少走五分钟弯路。4.2 完整示例代码下面是一个完整的最小实现。数据是我随便捏的五个手机品牌模拟数据重点看坐标系映射和配置项写法!DOCTYPE html html head meta charsetutf-8 titleHighcharts Marimekko 可变宽度图/title script srchttps://code.highcharts.com/highcharts.js/script script srchttps://code.highcharts.com/modules/xrange.js/script /head body div idcontainer stylewidth: 920px; height: 540px; margin: 0 auto;/div script const rawData [ { brand: 苹果, share: 30, segments: [ { name: 高端, value: 65 }, { name: 中端, value: 30 }, { name: 低端, value: 5 } ]}, { brand: 华为, share: 28, segments: [ { name: 高端, value: 55 }, { name: 中端, value: 35 }, { name: 低端, value: 10 } ]}, { brand: 小米, share: 22, segments: [ { name: 高端, value: 25 }, { name: 中端, value: 55 }, { name: 低端, value: 20 } ]}, { brand: OPPO, share: 12, segments: [ { name: 高端, value: 15 }, { name: 中端, value: 45 }, { name: 低端, value: 40 } ]}, { brand: vivo, share: 8, segments: [ { name: 高端, value: 18 }, { name: 中端, value: 42 }, { name: 低端, value: 40 } ]} ]; const totalShare rawData.reduce((sum, d) sum d.share, 0); const totalWidth 100; const totalHeight 100; function round2(n) { return Math.round(n * 100) / 100; } const segmentNames [...new Set(rawData.flatMap(d d.segments.map(s s.name)))]; const seriesDataMap {}; segmentNames.forEach(name { seriesDataMap[name] []; }); let xCursor 0; rawData.forEach(brand { const width (brand.share / totalShare) * totalWidth; const segSum brand.segments.reduce((s, seg) s seg.value, 0); let yCursor 0; brand.segments.forEach(seg { if (seg.value 0) return; const height (seg.value / segSum) * totalHeight; seriesDataMap[seg.name].push({ x: round2(xCursor), x2: width 0 ? xCursor : round2(xCursor width), y: round2(yCursor), y2: round2(yCursor height), brand: brand.brand, segment: seg.name, segmentValue: round2((seg.value / segSum) * 100) }); yCursor height; }); xCursor width; }); // 修正浮点误差最后一块强制贴到 100 const allPoints Object.values(seriesDataMap).flat(); let maxX2 0; allPoints.forEach(p { maxX2 Math.max(maxX2, p.x2); }); if (Math.abs(maxX2 - totalWidth) 0.01) { Object.values(seriesDataMap).forEach(points { points.forEach(p { if (p.x2 maxX2) p.x2 totalWidth; }); }); } const series segmentNames.map(name ({ name, type: xrange, data: seriesDataMap[name] })); Highcharts.chart(container, { chart: { type: xrange }, title: { text: 某季度手机品牌份额与价位段构成模拟数据 }, xAxis: { min: 0, max: 100, tickInterval: 10, labels: { format: {value}% }, title: { text: 品牌销售额份额 } }, yAxis: { min: 0, max: 100, tickInterval: 10, labels: { format: {value}% }, title: { text: 品牌内价位段占比 } }, plotOptions: { series: { borderWidth: 1, borderColor: #ffffff, dataLabels: { enabled: true, inside: true, formatter: function () { const p this.point; const pixelWidth this.chart.plotSizeX * (p.x2 - p.x) / 100; const pixelHeight this.chart.plotSizeY * (p.y2 - p.y) / 100; if (pixelWidth 60 || pixelHeight 24) return null; return p.brand p.segment p.segmentValue %; }, style: { color: #333333, fontSize: 11px, textOutline: none } } } }, tooltip: { useHTML: true, pointFormatter: function () { const p this.point; const brandWidth p.x2 - p.x; const contribution brandWidth * p.segmentValue / 100; return b p.brand /bbr/ 价位段 p.segment br/ 段内占比 p.segmentValue %br/ 品牌份额 brandWidth.toFixed(1) %br/ 对总体贡献 contribution.toFixed(1) %; } }, colors: [#2e86c1, #5dade2, #aed6f1], series }); /script /body /html这段代码跑起来后你会看到五个品牌横向排列宽度明显不同每个品牌内部有三个颜色递进的小矩形从深蓝到浅蓝分别代表高端、中端和低端。4.3 关键配置说明我逐个讲一下核心配置为什么这样写。chart.type设为xrange后系列已经自动支持矩形绘制所以 series 里不用再写type也可以但我建议保留避免模块加载顺序影响系列类型推断。xAxis和yAxis都固定在 0 到 100这是百分比坐标和前面数据映射的 100 总宽、总高严格对应。要注意xAxis.tickInterval设为 10 只是为了让刻度均匀不是强制分成十段。dataLabels.inside保持 true让标签显示在矩形内部而不是外部。如果不设置这个配置标签默认可能跑到矩形外面甚至跑到图表边缘观感很乱。tooltip 的pointFormatter里我额外算了一个“对总体贡献”这是 Marimekko 最有分析价值的指标。代码里brandWidth * p.segmentValue / 100这个乘法对应面积公式读者鼠标悬停时能一眼看到这个子类对整体盘子的真实影响。4.4 运行效果与内置交互代码跑起来以后Highcharts 原生交互基本都能直接用鼠标悬停矩形会弹出完整数据提示图例会显示“高端”“中端”“低端”三个系列点击图例可以隐藏某个价位段观察各个品牌内部结构变化浏览器缩放时图表自适应重绘标签位置的像素判断也会跟着更新。这里有一个交互细节值得注意因为系列是按价位段维度拆分的点击图例隐藏“高端”所有品牌的高端矩形会同时消失但品牌矩形宽度不变视觉上会“缺一块”。这是正常行为不是 bug它反而能帮助分析某个价位段对全品牌的影响。5. 细节打磨标签防重叠、配色、tooltip 和动态更新5.1 数据标签防重叠用像素宽度和高度双重判断Marimekko 图最头疼的就是标签挤成一团。我在代码里给了一个基础的像素宽度阈值判断但实际使用建议再加两个条件formatter: function () { const p this.point; const pixelWidth this.chart.plotSizeX * (p.x2 - p.x) / 100; const pixelHeight this.chart.plotSizeY * (p.y2 - p.y) / 100; // 太窄只保留品牌名 if (pixelWidth 50) return p.brand; // 太矮直接不显示 if (pixelHeight 20) return null; // 高度不够放两行用单行缩写 if (pixelHeight 32) return p.brand p.segmentValue %; return p.brand p.segment p.segmentValue %; }这个策略完全依赖this.chart.plotSizeX和plotSizeY的换算好处是图表容器大小变化时阈值会自动跟着变不会出现桌面端显示正常、换到小屏就乱套的问题。还要记得设置textOutline: none。矩形本身有填充色和白色描边文字轮廓线在这个场景下反而会让字体变脏去掉之后更清爽。5.2 配色策略按细分属性做色阶而不是按品牌我见过很多初版 Marimekko 的配色习惯按品牌来五个品牌五种颜色。但在我们的系列拆分方式里图例展示的是价位段所以比较协调的做法是让同系列的所有品牌矩形共用一个颜色通过色阶体现价位层级高端用深色中端用中间色低端用浅色。这样读者从下往上看颜色由深到浅很容易理解“高端占比高不高”。如果业务上必须突出品牌可以换一种 series 拆分思路series 按品牌建每个系列内部的不同价位段 point 通过color属性分别指定深浅色。但那样图例变成品牌维度点击图例会隐藏整个品牌看不了跨品牌的价位段对比我需要提醒大家想清楚分析场景再选。5.3 tooltip 丰富化显示段内占比、品牌份额和全局贡献tooltip 是展示数值层次的主要入口。我的建议是每个悬浮矩形至少输出三层信息段内占比这个矩形在品牌内部占多少品牌份额这个品牌在整体盘子占多少全局贡献前两者的乘积也就是这个子类的真实影响代码里contribution.toFixed(1)保留一位小数就够了太多小数位会让看板显得冗余。5.4 数据动态更新封装一个 buildSeries 函数实际项目里原始数据不会一成不变我建议把坐标换算逻辑封装成独立的buildSeries(rawData)函数返回组装好的series配置和当前categories或坐标范围。数据刷新时只需要重新调用这个函数再执行const newSeries buildSeries(newRawData); chart.series.forEach((s, i) { s.setData(newSeries[i].data, true); });这样图表会带一点原生动画过渡旧矩形滑出、新矩形滑入比直接chart.update整体重绘观感好很多。6. 我在这套实现里踩过的坑和排查思路6.1 Highcharts error #17Unknown series type这个报错一出现我第一反应是查图表配置结果问题出在模块没引入。xrange 不是 Highcharts 核心系列CDN 必须额外加上 xrange.js。排查思路很简单打开 Network 面板看 xrange.js 是否加载成功或者直接看控制台的报错信息如果是 #17就是系列类型注册失败。6.2 有 tooltip 但矩形“消失不见”有几次我鼠标移到某块区域能弹出 tooltip但页面上看不到对应矩形。排查后发现是数据里出现了x2 x的情况。这通常发生在宽度归一化后 xCursor 累加错误比如份额为 0 或负数混进了数据。我把每个 point 打印出来逐个检查x2 - x是否为正问题就清楚了。建议在 buildSeries 函数里加一个防御if (point.x2 point.x) return; // 宽度无效丢弃 if (point.y2 point.y) return; // 高度无效丢弃对零值子项来说这个判断尤其重要。6.3 数据标签全跑到矩形外面xrange 的 dataLabels 行为跟 column 不完全一样。我第一版没加inside: true标签全部浮在矩形上方整个图看起来像散落的小纸条。加上inside: true之后标签才回到矩形内部。如果你改了配置还是不对再看一下dataLabels.align和verticalAlign默认是居中被文本撑开后可以调整为align: left让文字从矩形左边开始排。6.4 最后一个矩形和 100% 边缘之间有细缝这是浮点累计误差的典型表现。五个品牌份额加起来明明等于 100但累加 5 次小数之后最后一个x2可能是 99.999999。解决方案我在示例代码里写了构建完所有数据后找出最大x2如果它和 100 接近直接强制改成 100。这个方法也适用于高度方向保证所有品牌顶部对齐。6.5 小屏和容器变化带来的标签隐藏问题图表容器宽度小于 600px 时像素宽度阈值会让大部分标签被隐藏只剩品牌名甚至什么都不显示。严格说这不是 bug是面积实在放不下。我的处理方式是小屏环境降级成普通堆叠柱状图或者把 Marimekko 做成默认大屏看板专属组件不要在移动端勉强展示。最后再说一个我自己的习惯。做这个图之前先把业务口径和数据映射逻辑敲定比如品牌份额用什么指标、内部子项要不要归一化、图例按哪个维度拆分这些一旦定错后面调代码都是白调。Marimekko 的数据映射核心不复杂复杂的是数据口径统一我的建议是把buildSeries这个函数当成团队公共工具沉淀下来每次接新数据只传一份规范 JSON剩下的交给函数处理。这样后续加新品牌、新品类段位一两行调用就够了。