ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Highcharts Treemap自定义布局算法:从入门到实战

Highcharts Treemap自定义布局算法:从入门到实战 做数据可视化这些年矩形树图Treemap是我觉得最容易被低估的一种图表。它用矩形面积表达数据权重用嵌套关系表达层级结构一眼就能看出“谁是大头、谁是小头”尤其适合磁盘占用分析、销售构成拆解、预算分配、流量来源这类“总量拆到类别、类别又拆到子项”的场景。而 Highcharts 的 Treemap 模块不仅把这种层级图做得开箱即用还留了一个很多人没注意到的口子布局算法是可以自定义的。默认的 squarified 算法够用但当你需要控制长宽比、对齐方向或者数据形态特殊时自己写一个布局算法反而能救命。这篇文章我会从矩形树图的设计逻辑讲起把 Highcharts Treemap 的数据模型、内置算法、自定义算法、交互细节和常见坑完整过一遍最后给一个能直接改来用的完整案例。1. 矩形树图的设计思路面积表达权重的根本逻辑1.1 面积为什么能表达权重人对面积大小的感知虽然不如对长度那么精确但矩形树图的场景里它反而是优势不需要精确读数值只要看色块和面积占比就能快速判断哪块业务占大头。一个 500 万的品类和一个 50 万的品类在饼图里要靠扇形角度分辨在树图里就是“一大块压着一个小角”的直觉差异。这里的核心规则是每个叶子节点的矩形面积和它的 value 值成正比。父节点的矩形区域等于所有子节点矩形区域拼起来的总面积所以整个图天然满足“父面积 子面积之和”。这种约束让树图在层级数据上特别稳定不会出现饼图多层嵌套时的视觉混乱。1.2 什么场景适合什么场景别硬上我用下来的体会是矩形树图最适合两类需求占比对比电商销售构成、库存分类占比、广告投放渠道分布。空间受限时的层级浏览比如仪表盘上只有一块固定区域却要塞下几十个分类树图比饼图图例省空间得多。不适合的场景也很明确一是趋势比较树图里面积变化不如折线图敏感二是精确数值读取读者很难从面积反推出具体数字必须靠 tooltip 或标签补充三是层级特别深的数据超过三四层后嵌套会非常密集视觉上基本崩溃这时候应该考虑下钻而不是全部平铺。1.3 Highcharts Treemap 的定位在 D3、ECharts、Highcharts 这些主流方案里Highcharts Treemap 的优势是配置化程度高、学习成本低而且和 Highcharts 生态导出、缩放、无障碍访问、React/Vue 封装无缝衔接。它支持内置多种布局算法、下钻交互、colorAxis 连续着色还允许你把布局算法写成自定义函数。这个“算法可插拔”的设计是它和其他图表库拉开差距的地方。2. 数据模型与基础配置先把图跑起来2.1 扁平数据与 id/parent 的关系Highcharts Treemap 的数据不是嵌套对象而是拍平的数组。每条数据是一个点通过id和parent字段组织层级data: [{ id: root, name: 总销售额 }, { id: phone, parent: root, name: 手机, value: 600 }, { id: appliance, parent: root, name: 家电, value: 300 }, { id: accessory, parent: root, name: 配件, value: 100 }]没有parent的节点是根节点parent指向另一个节点的id就形成父子关系。叶子节点必须有value父节点可以不给valueHighcharts 会自动把子节点 value 之和作为父节点的“权重”参与更上层的布局。注意父节点如果要显式给 value最好和子节点之和一致否则会出现“父级面积和子级拼出来的面积对不上”的错乱感。2.2 一个最小可运行实例引入模块的方式有两种。用 script 标签的话在highcharts.js之后再引入modules/treemap.js用 ES Module 则这样写import Highcharts from highcharts; import treemapModule from highcharts/modules/treemap; treemapModule(Highcharts);然后是最小可用配置Highcharts.chart(container, { title: { text: 销售构成 Treemap }, series: [{ type: treemap, layoutAlgorithm: squarified, data: [{ id: root, name: 总销售额 }, { id: phone, parent: root, name: 手机, value: 600 }, { id: appliance, parent: root, name: 家电, value: 300 }, { id: accessory, parent: root, name: 配件, value: 100 }] }] });跑起来之后就是三层结构根节点一块大矩形里面分成三块面积比例是 6 : 3 : 1。这个过程里我踩过一个小坑series 的type必须写treemap而不是tree写错的话整个 series 会被当成普通 scatter 处理。2.3 用 levels 逐层控制样式Treemap 最适合用levels数组做逐层样式定制。levels的索引对应树深度第 0 层是根节点第 1 层是根的子节点依此类推levels: [{ level: 1, borderWidth: 2, borderColor: #ffffff, dataLabels: { enabled: true, style: { fontSize: 14px } } }, { level: 2, borderWidth: 1, borderColor: #ffffff, dataLabels: { enabled: true, style: { fontSize: 11px } } }]这样很容易做出“第一层大块有粗边框第二层小块细边框”的层次感。如果不分层设样式第二层的小矩形会和第一层颜色完全混在一起读起来非常费劲。2.4 用 colorAxis 做第二维度Treemap 最赚的地方在于面积表达一个维度颜色还能表达另一个维度。比如面积是销售额占比颜色是毛利率高低。要启用这个能力需要配置colorAxis并在数据点上用colorValue指定数值colorAxis: { min: 0, max: 100, stops: [ [0, #d73027], [0.5, #fee08b], [1, #1a9850] ] }, series: [{ type: treemap, data: [{ id: phone, parent: root, name: 手机, value: 600, colorValue: 82 }] }]不指定colorValue时Highcharts 默认用value映射颜色也可以手动设置colorKey指向其他字段。这个机制在分析“大盘里哪块肉最肥、哪块最危险”时特别好用。3. 核心算法从内置四件套到自定义算法3.1 四种内置布局算法横评Highcharts Treemap 内置了四种布局算法通过layoutAlgorithm切换。我的实测感受如下算法布局特征适合场景短板squarified默认尽量让每个矩形接近正方形长宽比平均最优绝大多数通用场景数据大小悬殊时也稳定计算开销稍高动态更新时会有轻微跳动sliceAndDice按顺序沿一个方向切成细条切完一层换方向时间序列、排序有意义的场景能看到“从左往右推进”的顺序感会产生大量细长矩形看数值标签很痛苦strip按 value 排序后从左到右放“条带”条带内部再细分需要保持近似正方形且顺序敏感条带宽度变化快细长条仍难避免stripSquaredstrip的改良版条带内矩形更接近正方形重视可读性的通用场景对异常大值敏感簇状分布明显时表现一般我建议绝大多数项目直接用默认的squarified。只有当数据本身带有“顺序”或“时间”语义、希望用户按阅读顺序理解时再考虑sliceAndDice。3.2 方向参数与排序的影响layoutStartingDirection控制根节点第一次切割的方向默认是horizontalalternateStartingDirection: true可以让每层自动交替方向避免所有层都朝一个方向切产生密集细条。但真正影响布局质量的是数据顺序。内置算法基本都假设数据已经按 value 从大到小排序大的先放小的后填这样才能保持长宽比。如果你的数据是随意顺序树图会频繁出现“大块夹在小块中间”的奇怪形状。所以我每次构造数据时都会先sort一遍data.sort((a, b) (b.value || 0) - (a.value || 0));这个行为和很多排序算法的“预处理”思路一致算法本身依赖有序输入而不是在算法内部强行扭转顺序。理解了这一点再看自定义算法就容易了。3.3 自定义算法怎么写接口与最小实现Highcharts 的layoutAlgorithm除了接收字符串还可以接收一个函数。这个函数会被 Highcharts 在每一层递归布局时调用签名是function customLayout(parent, children, bounds) { // parent: 当前父节点对象 // children: 当前父节点下的直接子节点数组 // bounds: 父节点对应的矩形区域 { x, y, width, height } // 需要返回 children 对应的坐标数组元素包含 { x, y, width, height } }你不需要自己写递归。Highcharts 会从上到下遍历树结构每遇到一个内部节点就调用一次这个函数把它的子节点摆进bounds指定的矩形里然后继续对子节点内部递归。下面是一个自定义的“交替方向等比切条”算法function customLayout(parent, children, bounds) { var sorted children.slice().sort(function (a, b) { return (b.value || 0) - (a.value || 0); }); var total 0; sorted.forEach(function (child) { total child.value || 0; }); if (total 0) { total 1; sorted.forEach(function (child) { child.value child.value || 1; }); } var vertical (parent.level || 0) % 2 0; var offset vertical ? bounds.x : bounds.y; var result []; sorted.forEach(function (child) { var ratio (child.value || 0) / total; var rect; if (vertical) { rect { x: offset, y: bounds.y, width: ratio * bounds.width, height: bounds.height }; offset rect.width; } else { rect { x: bounds.x, y: offset, width: bounds.width, height: ratio * bounds.height }; offset rect.height; } result.push(rect); }); return result; }把函数传给layoutAlgorithmseries: [{ type: treemap, layoutAlgorithm: customLayout, data: ... // 同样需要 id/parent/value }]这段代码的效果是偶数层把父矩形按 value 比例切成垂直竖条奇数层切成水平横条。和内置的sliceAndDice有点像但你可以完全控制切分方向和分析逻辑。实操心得自定义算法函数里加console.log调试很麻烦因为每个节点都会调用一次。我会先在函数里用parent.name判断层级只记录特定层级的调用再改成配置参数避免刷屏。3.4 用自定义算法处理“固定高度”的特殊需求我做过一个项目业务方要求“每个一级类目在图上占用同样的高度宽度代表金额”。内置算法做不到这种语义因为它们的 width、height 都由值驱动。自定义算法就能轻松约束function fixedHeightLayout(parent, children, bounds) { var total 0; children.forEach(function (c) { total c.value || 0; }); if (total 0) { total 1; } var offsetY bounds.y; var rowHeight bounds.height / children.length; return children.map(function (child) { var rect { x: bounds.x, y: offsetY, width: (child.value || 0) / total * bounds.width, height: rowHeight }; offsetY rowHeight; return rect; }); }这就是“高度均分、宽度体现权重”的布局。真实业务里这种需求经常出现在“不同团队横向对比”的分析页里团队名从上往下排面积看总量一眼扫过去就知道谁的盘子大。3.5 自定义算法的避坑清单第一返回值必须包含 x、y、width、height少一个字段图就会错乱。第二别在函数里直接修改 children 的顺序后不返回对应顺序返回数组必须和 children 一一对应否则矩形位置对不上节点。第三注意边界上的 1px 间隙内置算法经常通过边框宽度制造视觉缝隙自定义算法如果不留间隙相邻色块会“黏”在一起。我习惯在计算 width/height 时减掉 1~2px并让 x/y 步进时保留这段距离。第四运行时改算法要重新触发布局。直接在chart.update里改layoutAlgorithm旧节点位置可能还在。最简单的方式是重新setData或者重建 series。4. 交互体验与细节打磨4.1 下钻、面包屑与状态复位层级数据做成交互图之后最常见的诉求就是“点进某个分类看下一层”。Highcharts Treemap 里只需要打开allowDrillToNodeplotOptions: { treemap: { allowDrillToNode: true } }开启之后点击任意节点会下钻到该节点视角。返回上级一般靠面包屑新版 Highcharts 提供了breadcrumbs配置breadcrumbs: { enabled: true, position: { align: right } }注意 treemap 的下钻是节点级行为不是普通 series 的 drilldown 事件链路配置事件时要区分。另外一个容易忽略的点下钻之后如果重新setData视图状态可能停在当前层级需要手动把rootNodetreemap 内部记录当前根节点的字段重置。4.2 数据标签在小矩形里优雅地“挤”矩形树图标签最大的问题是“小矩形放不下文字”。最简单的策略是尺寸过滤小于一定面积的矩形干脆不显示标签把信息留给 tooltip。dataLabels: { enabled: true, formatter: function () { var p this.point; if (p.shapeWidth 40 p.shapeHeight 20) { return p.name; } return ; }, style: { textOutline: 1px contrast, color: #333333 } }shapeWidth和shapeHeight是 treemap 点对象上的当前矩形尺寸字段不同版本可能存在差异保险起见也可以用this.point.shapeArgs.width。经验阈值不要用固定 40/20而是按容器大小动态计算。比如container.clientWidth / 25这样在大屏和小屏上都能保持观感一致。4.3 Tooltip、颜色提示与图例协同有了颜色维度后tooltip 一定要把面积维度和颜色维度都展示出来tooltip: { pointFormat: {point.name}br/销售额b{point.value}/bbr/毛利率b{point.colorValue}/b }同时建议在图例或说明区提示“颜色越绿代表毛利率越高”之类的规则。树图本身没有坐标轴用户无法从轴刻度理解颜色这个说明很重要。4.4 大数据量先聚合再上图Treemap 对数据量很敏感。几千个叶子节点的扁平数据渲染和交互都会明显卡顿。我的做法是在传给 Highcharts 之前先做一次业务层聚合把排名靠后的小分类合并成一个“其他”节点只保留 Top N 明细。function aggregate(data, topN) { var sorted data.slice().sort((a, b) b.value - a.value); var top sorted.slice(0, topN); var restValue sorted.slice(topN).reduce((sum, d) sum d.value, 0); if (restValue 0) { top.push({ id: other, name: 其他, value: restValue }); } return top; }这样可以显著减少矩形数量。如果项目要求展示全量明细再配合下钻交互把“其他”展开成下级节点。5. 实战把自定义算法放进一个完整项目5.1 需求与数据假设我们做一个门店运营看板第一层是区域华东、华南、华北第二层是门店类型第三层是具体门店。业务方希望“区域之间用等高横条对比区域内用面积体现门店贡献”并且颜色表示达标率。数据结构大致如下var rawData [{ id: root, name: 全国 }, { id: east, parent: root, name: 华东 }, { id: east_a, parent: east, name: 旗舰店, value: 320 }, { id: east_b, parent: east, name: 社区店, value: 180 }, { id: south, parent: root, name: 华南 }, { id: south_a, parent: south, name: 旗舰店, value: 200 }, { id: south_b, parent: south, name: 社区店, value: 120 }];5.2 完整配置代码Highcharts.chart(container, { title: { text: 门店销售与达标率 }, colorAxis: { min: 60, max: 100, stops: [ [0, #d73027], [0.5, #fee08b], [1, #1a9850] ] }, tooltip: { pointFormat: {point.name}br/销售额b{point.value}/bbr/达标率b{point.colorValue}%/b }, plotOptions: { treemap: { allowDrillToNode: true, breadcrumbs: { enabled: true }, dataLabels: { enabled: true, formatter: function () { var p this.point; if (p.shapeWidth 45 p.shapeHeight 20) { return p.name; } return ; } } } }, series: [{ type: treemap, layoutAlgorithm: fixedHeightLayout, data: rawData.map(function (item) { // 演示数据里动态补 colorValue item.colorValue item.value ? 75 Math.random() * 25 : undefined; return item; }) }] }); function fixedHeightLayout(parent, children, bounds) { var total 0; children.forEach(function (c) { total c.value || 0; }); if (total 0) { total 1; } var offsetY bounds.y; var rowHeight children.length ? bounds.height / children.length : bounds.height; return children.map(function (child) { var rect { x: bounds.x, y: offsetY, width: (child.value || 0) / total * bounds.width, height: Math.max(rowHeight - 1, 1) }; offsetY rowHeight; return rect; }); }5.3 运行后的表现第一层“区域”会变成等高的横条三条分别对应华东、华南第二层门店在各自区域内从左往右排列面积和 value 成正比。颜色由colorValue驱动达标率高的区域更绿。需要说明的是这个自定义算法第一层和第二层的行为不完全一样但能覆盖需求。如果希望第一层和第二层都更“树图”一些可以在fixedHeightLayout里判断parent.level第一层用等高横条第二层退回内置squarified效果。这正是自定义算法的意义你说了算。6. 常见问题与排查技巧实录6.1 问题速查表现象常见原因解法矩形全部挤成一团几乎没有留白边界间隙没处理或设置了过大的borderWidth导致负内边距把 borderWidth 调小或在矩形尺寸里预留 1~2px 间隔自定义算法写了但不生效函数写在了 data 节点上或配置在 series 里但被覆盖确认是 series 级或 plotOptions 级配置重新 setData 触发布局父节点标签不显示父节点矩形太小或 dataLabels 层级上没有开用 levels 单独给第 0/1 层开 dataLabels并调整 formatter 阈值下钻后回不到根节点rootNode状态没有重置在 reset 事件里设置series.rootNode 或重新chart.update面积和 value 对不上大值矩形反而小数据顺序乱或者多个 value 为 0 的节点参与布局先按 value 降序排序把 0 值节点过滤掉或聚合掉更新数据后新布局很怪页面残留旧的缓存布局或 diff 更新没有触发完整排版使用setData全量替换必要时销毁重建 series6.2 两个让布局“复活”的常用手段如果树图渲染后数据没变但布局乱了我通常会执行chart.series[0].update({ layoutAlgorithm: squarified, data: chart.series[0].options.data });这不是最优解但在调试阶段非常快。真正上线时更稳的方式是维护一份干净的原始数据在业务侧做完排序和聚合后再setData(cleanData)。另外一个隐藏能力是监听chart.events.redraw或系列事件做响应式重排容器尺寸变化时树图默认会重新计算但自定义算法里如果写死了某些尺寸就需要绑定 resize 事件强制重绘。这也是自定义算法最常见的翻车点。最后再分享一个小技巧写自定义算法时先用假数据把算法逻辑在纯 JS 环境里跑一遍确认返回的矩形数组 x/y 不重叠、总面积等于父区域再接入 Highcharts。树图算法本质上是“把一个矩形拆成若干个小矩形”的几何问题算法本身和图表库没多大关系用 console 验证一遍比在图表里反复刷新猜问题快得多。我每次调整布局算法都是先这么干上线后基本不需要再动第二版。
返回列表