
1. 先摸清 Marimekko 到底在画什么1.1 从芬兰布料说起这个图为什么叫这个名字很多人第一次看到 Marimekko 图第一反应是这不就是一堆宽度不一样的堆叠柱吗。这个理解方向对但不完整。Marimekko 本来就是芬兰一家纺织品牌的名字他们家最出名的印花布料就是那种大色块、宽窄不一的条纹拼接。图表界把这个视觉语言借过来做成了现在常用于市场份额、销售构成分析的可变宽度图。国外有些 BI 工具管它叫 Mekko Chart 或者 Variable Width Bar Chart国内一般直接叫可变宽度图或者马利麦哥图中文世界里的叫法没统一但看到图片基本都能认出来。这个图最核心的价值在于它把两个维度同时压缩进了一张平面图里。横向的宽度代表一个分类维度比如某公司在某个行业里的市场份额纵向的高度代表另一个分类维度比如产品线的细分占比。你在图上看到的每一个矩形既告诉你这个类目在整个大盘里占多大份额又告诉你它内部是怎么切分的。一张图塞进了两组信息这在业务汇报场景里非常占便宜尤其是那种既要看全局又要看结构的场合。1.2 两个维度怎么合体宽、高、面积各代表什么用一张具体的业务场景来解释。假设你现在要给老板汇报某个消费品公司的年度销售构成横轴按渠道划分比如线上自营、电商平台、线下经销商、海外渠道纵轴按产品线划分比如护肤线、彩妆线、个护线。普通的堆叠柱状图只能做一件事要么按渠道堆叠要么按产品线堆叠。Marimekko 图则不同每个渠道的柱子宽度不同宽度代表该渠道销售额占总销售额的比例柱子里每一段的高度不同高度代表该产品线在这个渠道内部占的比例。这里有个容易混淆的点很多人以为矩形面积也代表占比其实不对。面积是宽度和高度的乘积恰好等于总销售额里这个细分格子所占的比例但这只是数学上的巧合不是设计意图。设计意图是宽度看一类占比高度看另一类占比面积只是顺带成立。搞清楚这一点后面做数据处理时才不会算错。1.3 什么场景适合用它什么场景别硬上我个人的判断标准是只有当分类占比和分类内部结构同等重要、且分类数量不超过四五个时Marimekko 才值回票价。比如分析竞争格局你要看每家公司占多少份额又要看每家公司里面的产品结构这个场景非常合适。再比如零售选址分析既要看不同城市的销售占比又要看各城市的门店类型构成也合适。反过来如果维度超过六七个矩形会被切得特别碎标签根本放不下视觉上就是一堆马赛克用户看半天看不出结论。这种情况下我一般直接劝退改用分组柱状图或者热力图。还有一种情况也不适合如果两个维度里有一个维度里只有一两个取值没必要用 Marimekko一个普通堆叠图就能表达清楚。工具是为问题服务的不是为了炫技。2. 为什么选 Highcharts 手搓而不是找现成插件2.1 Highcharts 的系列类型里确实没有它Highcharts 官方支持的系列类型很丰富line、column、bar、pie、scatter、heatmap、treemap、tilemap、variablepie、columnrange 等等翻遍全部列表确实没有 marimekko。有些开发者会误以为用 variablepie 或者 tilemap 可以曲线救国我试过效果都不理想。variablepie 做的是扇区角度和半径都可变的饼图变体虽然也是可变宽的概念但视觉上是个甜甜圈变形不是矩形排版做出来的东西老板不认。tilemap 是瓦片地图可以做色块但默认的对齐方式很难精确控制成每列占满整段高度的严格矩形网格。所以结论很清楚要得到标准的、可商用的 Marimekko 图在 Highcharts 里只有一条路——把矩形一个个画出来。这不是坏事反而给了你最大的控制权。2.2 画矩形的思路对比Columnrange、散点加SVG还是坐标推算确定了核心思路是画矩形接下来的问题是用什么方式画。我在项目里试过三种方案简单说说优劣。第一种方案是用type: columnrange。columnrange 本来是用来画数值区间柱的比如温度区间图它支持给每个数据点指定一个 y 轴区间[low, high]。如果配合 x 轴坐标和 pointWidth 设置确实能把柱子拉成自定义宽度。但问题是Highcharts 的 column 系列在分组排列时有很多内部算法你想精确控制每一列的起始 x 坐标和宽度需要绕过它的自动排版逻辑。调起来非常痛苦尤其是在图例、tooltip、事件绑定这些环节经常出现图对了但交互乱套的情况。第二种方案是完全脱离 Highcharts 的坐标系用renderer直接往 SVG 里插rect元素。这个方案最灵活图表上想画什么画什么但代价是你失去了 Highcharts 的数据绑定能力数据更新、动画、tooltip、图例全部要手动维护。刚开始写挺爽后面维护起来想骂人。第三种方案也是我最终采用的方案是用散点图scatter的 x、y 坐标机制配合自定义的 point 图形。具体来说每个矩形就是一个数据点把矩形的左下角坐标映射成散点的 x、y把矩形的宽高通过custom字段存起来然后用series.point.events或者render回调画一个rect出来。这个方案既保留了 Highcharts 的数据结构、图例、tooltip 绑定机制又让我们能完全控制矩形的位置和尺寸。后面扩展时我还给它加了颜色映射、点击跳转都挺顺手的。2.3 整体思路把矩形坐标算出来剩下交给 Highcharts所以这篇文章要实现的内容可以拆解成三个层次第一层是数据处理层。把原始业务数据做两次归一化横向算出每个列分类的宽度占比纵向算出每个列分类内部的行分类占比再换算成绘图坐标系里的绝对坐标。第二层是渲染层。用 Highcharts 创建一张 cartesian 图表隐藏掉默认的轴线、网格把计算好的矩形逐块画到图上。第三层是交互层。给矩形块绑定 tooltip、标签、点击事件让它用起来像一个真正的原生图表。这个分层思路是我推荐给所有想在自己业务里落地自定义图表的人。不要让图表库替你做太多决策先把数据层想清楚渲染方法自然就有答案了。3. 数据准备把业务数据变成可以画图的矩形3.1 拿到手的原始数据长什么样先定义一下输入数据的格式。为了让下面的代码清晰我用一个简单的 JavaScript 对象数组来描述。// 原始数据每个元素代表一个渠道内的一个产品线 const rawData [ { channel: 线上自营, product: 护肤, sales: 120 }, { channel: 线上自营, product: 彩妆, sales: 90 }, { channel: 电商平台, product: 护肤, sales: 80 }, { channel: 电商平台, product: 彩妆, sales: 60 }, { channel: 电商平台, product: 个护, sales: 40 }, { channel: 线下经销, product: 护肤, sales: 70 }, { channel: 线下经销, product: 个护, sales: 50 }, { channel: 海外渠道, product: 彩妆, sales: 30 }, { channel: 海外渠道, product: 个护, sales: 20 } ];这种一维是渠道、另一维是产品线的宽表展开形式是 Marimekko 最常见的输入。真实项目里你拿到的可能是一个 CSV 或者接口返回的嵌套对象但无论如何都要先转成这种扁平结构后面归一化才方便。3.2 横向归一化每一列的宽度怎么算先算每个渠道的总销售额。const channelSales {}; rawData.forEach(item { if (!channelSales[item.channel]) { channelSales[item.channel] 0; } channelSales[item.channel] item.sales; });接着算所有渠道的销售总额。const totalSales Object.values(channelSales).reduce((sum, v) sum v, 0);现在横向归一化就很简单了每个渠道的宽度占比等于该渠道的总销售额除以全部销售额。const channels Object.keys(channelSales); const channelWidths {}; // 每个渠道的宽度占比 channels.forEach(channel { channelWidths[channel] channelSales[channel] / totalSales; });以示例数据为例线上自营总额是 210电商平台是 180线下经销是 120海外渠道是 50全部加起来是 560。所以线上自营的宽度占比是 210 / 560 0.375电商平台是 0.3214线下经销是 0.2143海外渠道是 0.0893。四个占比加起来正好是 1。3.3 纵向归一化每一段高度怎么算纵向处理比横向稍微绕一点因为每个渠道内部的产品线占比是独立计算的不是全局统一算。// 每个渠道内部各产品线的销售额占比 const channelProductRatio {}; rawData.forEach(item { if (!channelProductRatio[item.channel]) { channelProductRatio[item.channel] {}; } channelProductRatio[item.channel][item.product] item.sales / channelSales[item.channel]; });以线上自营为例护肤的占比是 120 / 210 0.5714彩妆是 90 / 210 0.4286。电商平台里护肤是 80 / 180 0.4444彩妆是 60 / 180 0.3333个护是 40 / 180 0.2222。每个渠道内部的占比之和也应该等于 1。3.4 组装绘图坐标把占比换算成像素前的中间结构现在到了数据处理的关键一步。我们需要把每个矩形变成一个包含绝对坐标的对象。这里的坐标我打算全部用百分比表示也就是 0 到 1 之间的小数渲染时再交给图表库映射到画布。这样做的好处是图表容器尺寸变化时不需要重新计算数据。横向坐标的计算逻辑是第一个渠道从 0 开始下一个渠道的 x 等于上一个渠道的 x 加上上一个渠道的宽度依次累加。let xOffset 0; const rectangles []; channels.forEach(channel { const channelWidth channelWidths[channel]; const products Object.keys(channelProductRatio[channel]); let yOffset 0; products.forEach(product { const height channelProductRatio[channel][product]; rectangles.push({ x: xOffset, y: yOffset, width: channelWidth, height: height, channel: channel, product: product, sales: rawData.find(item item.channel channel item.product product).sales }); yOffset height; }); xOffset channelWidth; });这段代码跑完之后rectangles数组里每一个元素都是一个可以交给渲染层的矩形。每个矩形的 x、y、width、height 加起来都刚好拼成一个整体不会有重叠也不会有缝隙。这里有个细节要注意yOffset是从 0 开始往上累加的也就是最底下的矩形 y 是 0高度是自身占比下一个矩形的 y 正好落在它的上边缘。这种做法在后面画图时最省心因为坐标是明确的。4. 在 Highcharts 中一步步实现 Marimekko4.1 图表容器与最基础的 Highcharts 配置先准备一个容器这一步没什么特别的。HTML 里写一个 div 并给个 idJavaScript 里用 Highcharts.chart 初始化。div idmarimekkoChart stylewidth:100%;height:600px;/div基础配置大概是下面这样。为了把 Highcharts 变成一个只画自定义矩形的画板我要把很多默认开关关掉。Highcharts.chart(marimekkoChart, { chart: { type: scatter, backgroundColor: transparent, spacing: [20, 20, 20, 20] }, title: { text: 渠道 x 产品线销售构成Marimekko }, xAxis: { min: 0, max: 1, startOnTick: true, endOnTick: true, tickWidth: 0, labels: { enabled: false }, gridLineWidth: 0, lineWidth: 0 }, yAxis: { min: 0, max: 1, labels: { enabled: false }, gridLineWidth: 0, lineWidth: 0, title: { text: null } }, tooltip: { // 后面详细配置 }, series: [{ name: 销售构成, data: [] }] });因为我们的矩形坐标是 0 到 1 之间的百分比x 轴和 y 轴的范围直接设为[0, 1]就好。隐藏坐标轴标签和网格线是为了不让图表看起来像散点图。一旦视觉元素都被拆掉这个图本质就是一个空的坐标系画布。4.2 核心实现用散点数据点画矩形散点图默认的绘制效果是一个个圆形点我们要把它变成矩形。这里我用的是point的events以及render回调。先看一下如何在 series 配置里通过marker的symbol来自定义图形这是最干净的方案。Highcharts 的 marker symbol 支持字符串、URL 或者自定义函数。自定义函数会收到一个x、y、w、h四个参数的上下文用来描述当前 marker 的渲染位置。不过这里有个限制散点图 marker 的尺寸默认是基于点的位置和全局 marker 半径无法直接让每个点拥有不同的自定义宽高。所以我换了一个方案利用 Highcharts 的事件钩子在每次渲染后手动插入 SVG rect。const chart Highcharts.chart(marimekkoChart, { // ...上面的基础配置... series: [{ type: scatter, name: 销售构成, data: rectangles.map(rect ({ x: rect.x, y: rect.y, custom: rect, marker: { enabled: false } })), marker: { enabled: false }, events: { render() { drawRects(this.chart); } } }] }); function drawRects(chart) { const series chart.series[0]; const rects series.points.map(point point.custom); // 清除旧矩形避免重复绘制 const svg chart.renderer.box; const existing svg.querySelectorAll(.mekko-rect); existing.forEach(el el.remove()); const plotLeft chart.plotLeft; const plotTop chart.plotTop; const plotWidth chart.plotWidth; const plotHeight chart.plotHeight; rects.forEach(rect { const px plotLeft rect.x * plotWidth; const py plotTop rect.y * plotHeight; const pw rect.width * plotWidth; const ph rect.height * plotHeight; chart.renderer.rect(px, py, pw, ph, 0) .attr({ fill: getColor(rect), stroke: #ffffff, stroke-width: 2, class: mekko-rect }) .add(); }); }这个方案的原理是散点数据点本身不做任何可见渲染marker 禁掉它们只是坐标和自定义数据的载体。每次图表渲染时我们遍历所有点把自定义矩形按百分比换算成像素坐标用 renderer 画到 SVG 里。这样数据点绑定、图例开关、tooltip 触发区域都还能正常工作因为我们用于交互命中的仍然是对应坐标上的散点。有人会问散点本身都不可见了tooltip 怎么触发呢。这里有个技巧我们可以给每个散点设置一个足够大的marker.radius让命中区域存在但让它的透明度为 0。我后面在交互部分会细化这个操作。4.3 颜色、标签和图例的处理方式给矩形配色是 Marimekko 图的灵魂。我建议按纵向维度产品线统一配色而不是按横向维度渠道因为产品线的数量一般比较少视觉上更容易区分。const productColors { 护肤: #4e79a7, 彩妆: #f28e2c, 个护: #59a14f }; function getColor(rect) { return productColors[rect.product] || #999999; }图例就用 Highcharts 自带的 legend但 series 只有一个图例没法直接按产品线展示。我的做法是额外生成一个自定义图例放在图表右上角用简单的 HTML 来实现。这不是必须的你可以用chart.legend配置多个不可见 series 或者直接用legend的 labelFormatter 折腾但那个成本太高。我推荐自定义图例一个 flex 容器加几个色块就可以。标签的问题要更细。矩形宽度足够的时候可以在矩形内部居中显示产品和销售额矩形宽度特别窄的时候标签放到矩形外部又容易重叠。我常用的策略是只有当矩形宽度和高度都大于某个阈值时才显示内部标签否则只显示 tooltip鼠标移上去再看。这个逻辑放在 drawRects 里每次渲染时判断。function drawLabels(chart, rects) { rects.forEach(rect { const px chart.plotLeft rect.x * chart.plotWidth; const py chart.plotTop rect.y * chart.plotHeight; const pw rect.width * chart.plotWidth; const ph rect.height * chart.plotHeight; const labelText ${rect.product}\n${rect.sales}万; if (pw 60 ph 30) { chart.renderer.text(labelText, px pw / 2, py ph / 2) .attr({ text-anchor: middle, align: center }) .css({ color: #ffffff, fontSize: 12px, fontWeight: bold }) .addClass(mekko-label) .add(); } }); }这个步骤里有个坑每次 render 事件触发时不只要清除旧的矩形还要清除旧的标签否则图表上会出现文字残影。我在 drawRects 里会统一清除.mekko-rect和.mekko-label两类元素。4.4 Tooltip 与交互让散点和可见矩阵真正联动散点图默认的 tooltip 显示的是 x、y 坐标这对用户没有意义。我们要把 tooltip 的内容改成业务信息。方案是设置tooltip.formatter从this.point.custom里读取原始数据。tooltip: { formatter() { const rect this.point.custom; const pct (rect.width * rect.height * 100).toFixed(1); return b${rect.channel} · ${rect.product}/bbr/销售额${rect.sales} 万br/整体占比${pct}%; } }这里我特意把面积占比也显示出来了。前面说过面积占比在数学上等于两个维度占比的乘积虽然不作为设计主图但对业务用户来说这个格子占总盘的多少反而是最常用的问题。为了让 tooltip 能正常触发我们还要保证散点的命中区域存在。操作方式是给每个数据点设置 markermarker: { enabled: true, radius: 12, fillColor: rgba(0,0,0,0), lineColor: rgba(0,0,0,0) }用透明色填充线条透明视觉上看不出来但点击和 hover 都有效。如果还想支持点击跳转可以在 series 的 point 事件上绑定 clickplotOptions: { series: { point: { events: { click() { const rect this.custom; // 比如跳转到该渠道与产品线的明细页面 window.location.href /detail?channel${rect.channel}product${rect.product}; } } } } }这些交互补齐之后Marimekko 图就跟一个原生封装的图表组件差不多了。5. 参数计算与避坑指南5.1 用一组真实数字完整演示计算过程为了让上面的代码不悬空我拿示例数据完整推演一遍计算过程。数据集之前已经列过渠道总额线上自营 210电商平台 180线下经销 120海外渠道 50总销售额560横坐标 x 和宽度的计算渠道 销售额 宽度占比 起始x 结束x 线上自营 210 0.3750 0.0000 0.3750 电商平台 180 0.3214 0.3750 0.6964 线下经销 120 0.2143 0.6964 0.9107 海外渠道 50 0.0893 0.9107 1.0000纵坐标 y 和高度的计算线上自营: 护肤 120/2100.5714, y: 0.0000 ~ 0.5714 彩妆 90/2100.4286, y: 0.5714 ~ 1.0000 电商平台: 护肤 80/1800.4444, y: 0.0000 ~ 0.4444 彩妆 60/1800.3333, y: 0.4444 ~ 0.7778 个护 40/1800.2222, y: 0.7778 ~ 1.0000如果你把这些矩形渲染到一张宽 1000、高 600 的图里第一个矩形线上自营-护肤的像素坐标就是x: 0 * 1000 0 y: 0 * 600 0 width: 0.375 * 1000 375 height: 0.5714 * 600 342.8第二个矩形线上自营-彩妆x: 0 * 1000 0 y: 0.5714 * 600 342.8 width: 0.375 * 1000 375 height: 0.4286 * 600 257.2到这里你会发现线上自营的两个矩形恰好拼满了第一列的整条高度宽度也恰好是整张图的 37.5%。这就是 Marimekko 图严丝合缝的来源。5.2 标签重叠问题为什么一放大就乱我在实际项目里遇到最多的就是标签叠加。矩形多的时候相邻标签的文字会互相覆盖。试过用plotBands、用dataLabels的各种对齐配置最后发现最稳妥的还是阈值控制位置偏移。位置偏移的逻辑是如果矩形的宽度特别窄不要强制居中而是把标签放到矩形的下方或上方。我常用这么几档矩形宽度大于 70 像素、高度大于 30 像素居中显示矩形宽度大于 30 像素、高度小于 30 像素放到矩形下方的空白区矩形宽度小于 30 像素不显示矩形内标签只在 tooltip 里展示这样处理之后即使渠道数量较多图表也基本干净。唯一需要接受的事实是宽度极窄的那些格子注定不会直接显示标签这是信息密度的物理限制任何图表都绕不过去。5.3 小数位与舍入造成的缝隙百分比坐标计算时如果每个数保留 4 位小数所有矩形拼起来接近 1 的时候可能出现微小偏差导致图表右侧或者顶部出现一条几像素的缝隙。这个问题看着小但在分辨率高的屏幕上非常明显。我处理的办法是在渲染前做一次兜底修正最后一个渠道的宽度直接强制设为 1 减去前面所有渠道宽度之和每个渠道内最后一个产品线的高度也同理。这样能确保所有矩形拼起来严丝合缝。// 横向上最后一个渠道宽度修正 channels.forEach((channel, index) { if (index channels.length - 1) { channelWidths[channel] 1 - channels.slice(0, -1).reduce((sum, c) { return sum channelWidths[c]; }, 0); } });这里的核心原则是不要用计算出的结果直接作为最终值先用一遍兜底把误差归到最后一个元素。最后一个元素通常是最不吃紧的那个因为它在最边缘视觉上不明显。5.4 图例、数据更新和 tooltip 的联动陷阱用散点方式实现 Marimekko 之后会遇到一个新的麻烦Highcharts 的图例默认对应的是 series不是每个矩形。所以如果你后续接入了数据更新接口直接chart.series[0].setData(newData)只能更新散点矩形需要手动触发重绘。我的做法是在setData之后显式调用一次drawRects。同时更新数据时需要重新计算归一化值这一步也在更新函数里一起完成避免出现散点位置换了矩形还是旧的这种古怪状态。function updateMekko(newRawData) { const rects prepareRectangles(newRawData); // 复用前面写的归一化函数 const points rects.map(rect ({ x: rect.x, y: rect.y, custom: rect })); chart.series[0].setData(points, false); drawRects(chart); }另外tooltip 里展示的百分比如果来自rect.width * rect.height每次更新后都会自动反映最新的归一化结果不需要额外同步这也是我把 custom 对象绑在 point 上的好处之一。6. 我的实操心得与扩展方向6.1 几个踩过且不想再踩的坑第一别用column系列强行做。我最初图省事用 columnrange 做了第一版图表看起来是到位了但一旦要给柱子设置不同的起始宽度和位置Highcharts 内部的 pointWidth 换算逻辑会反复干扰调了一下午最后还是推倒重来。第二不要用chart.redraw()代替自定义事件里的drawRects。redraw 本身会触发 render 事件但如果你已经在红raw 回调里又调用了 drawRects会出现重复渲染的闪烁。第三SVG rect 元素一定要设置 class方便统一清理和更新样式否则每触发一次 render 就新增一层矩形内存和视觉都扛不住。工具层面倒是没什么门槛Highcharts 的 renderer API 比你想象中简单chart.renderer.rect(x, y, width, height, r)返回一个元素你只需要attr设置样式、add挂到图上。只要你理解了坐标映射到像素这个过程Marimekko 的实现就没有魔法。6.2 在这个图上继续扩展的几个方向这个方案的可扩展性很强。比如给矩形加圆角可以让视觉更柔和加渐变填充或者透明度可以突出重点渠道。再比如把悬浮提示改成整列的联动高亮鼠标悬停在某一列时整列所有矩形都高亮其他列降低透明度这样用户能快速聚焦到某个渠道的构成。还有一个很实用的方向增加排序功能。业务上用户经常希望按销售额大小排一下渠道顺序或者把某个特定产品线排到最前面。排序只需要在数据准备阶段改一下 channels 数组的顺序其他所有逻辑都不用动这个优势在 BI 场景里非常值钱。如果你想把图里的矩形再嵌一层信息比如把利润率映射成颜色的饱和度和明度也是顺手的事。颜色不一定要写死完全可以从数据字段里动态读取。这样一来一张 Marimekko 图实际上可以表达三个甚至四个维度的信息这在销售分析、竞品分析、组合结构分析里都很有价值。6.3 如果你打算把它封装成组件最后说点项目落地层面的体会。我在不同项目里把 Marimekko 封装过两次。第一次是在业务代码里直接写第二次抽成了一个独立的图表组件对外暴露data、widthDimension、heightDimension、colorByDimension这些配置项。抽组件的时候关键是把数据准备函数和绘制函数彻底分离这样以后换图表库或者调整布局都不至于牵一发动全身。我的封装思路是组件内部收数据后先调用prepareRectangles得到标准化的矩形数组然后根据外部输入的维度字段决定谁是横向谁是纵向最后负责调用绘制函数。外部只关心我传一个表格数据进去图表自动出来不关心内部算法。这种做法对团队的其他人最友好也让这个 Marimekko 图能在更多业务里快速复用。我自己的体会是Marimekko 图在 Highcharts 里虽然不是开箱即用但用散点加自定义渲染的方式实现反而比任何现成组件都更灵活。它不复杂难的是把数据先想清楚。你只要把宽度代表什么、高度代表什么、坐标怎么算这三件事想明白剩下的就是一行一行写代码的事。希望这篇文章能帮你少走几步弯路早日把这个图表稳稳落地到自己的项目里。