《ECharts入门》第 28 章 流程与指标:sankey、funnel、gauge、chord

本文深入解析ECharts中sankey(桑基图)、funnel(漏斗图)、gauge(仪表盘)和chord(弦图)四类核心流向与状态可视化图表。重点涵盖数据结构差异:sankey与chord采用节点+边的图数据模型,而funnel与gauge使用一维数值数组;强调关键原理——sankey节点高度取流入/流出/原始值三者最大值,funnel宽度由min/max映射而非裁剪,gauge指针角度严格对应值在量程中的归一化位置,chord节点值为关联边值双向累加。同时指出常见工程陷阱,如sankey不强制流量守恒、环形数据导致拓扑排序失败、chord节点值恒为边值合计两倍等。

【本章目标】

在这里插入图片描述

  • 认得流向数据的写法:nodes(别名 data)加 links(别名 edges),links 里是 {source, target, value},source/target 写的就是节点名;能说清 sankey 与 chord 怎样把同一份数据变成有向图,funnel 与 gauge 又为什么不需要边。
  • 会画桑基图,并记住节点高度取「流入合计、流出合计、数据项原始值」三者的最大:流量守恒不是 ECharts 强制的,节点上那段没被出边铺满的空白就是失配的现场证据(实测入 650、出 500 的节点画成 300 px 高,出边只占 230.8 px,底部空 69.2 px)。
  • 能解释桑基图为什么不能有环:布局用拓扑排序算层号,环上的边永远排不到,源码把 Sankey is a DAG, the original data has cycle! 直接抛给调用方,生产版也抛;同时知道弦图不受这条限制。
  • 会用 orient、nodeAlign、nodeWidth/nodeGap/layoutIterations、levels、draggable 与 6.x 新增的 roam 把一张桑基图排好、拖得动、放得大;会用 emphasis.focus:'adjacency'|'trajectory'|'none' 决定悬停时谁被调暗。
  • 会画漏斗图,并记住 min/max 是数值映射区间,不是显示范围:minSize/maxSize 才决定「最窄那格画多宽」;能说出 sort:'ascending' 为什么把整张图连同顺序一起翻过来、最后一项的底宽为什么总是 0、横版的 funnelAlign 为什么只认 top/center/bottom。
  • 会用 label.formatter 现算相邻两级的转化率,并用两个 series 摆一组左右对称的两期对比漏斗——前提是两边共用同一组 min/max,否则宽度比不再等于数值比。
  • 会画仪表盘:startAngle/endAngle/min/max/splitNumber 定量程与刻度,axisLine.lineStyle.color 做分段染色,progress 的 show/width/roundCap 加上 6.1 的 itemStyle.color:'auto' 让进度条自动吃所在分段的颜色;能配 pointer/anchor/title/detail 的 offsetCenter、formatter 与富文本,能用 data 数组画多指针,也能把整张仪表盘削成一只极简环形进度条。
  • 认得 6.0 全新的弦图 chord:它与 graph 的数据差别、只有环形布局、弦的粗细怎样编码流量、padAngle/minAngle 的角度账,以及节点值把每条边在两端各记一次(实测边值合计 48、节点值合计 96);能说出它跟桑基图怎么取舍,也知道 6.0 缺 ./lib/chart/chord 这条导出、6.1.0 才补上。
  • 完成三个实战:会员等级流转弦图、2025 转化漏斗、国贸店年度目标完成率仪表盘——最后一张读 data/store-metrics.json 的真实销售额 ¥5,816,552,目标写 600 万,完成率 96.94%。

第 27 章的三兄弟把数据组织成「谁包含谁」,这一章换成「谁流向谁」和「现在跑到哪了」。位置不再由层级给出,而是由拓扑给出:桑基图里一个节点落在第几列,取决于它离源头有几跳;弦图干脆把节点排成一个圆环,只让弧长和弦宽去说数;漏斗把顺序交给 sort;仪表盘连关系都不要了,只把一个数摆到一段圆弧上。四张图都没有坐标轴,共同点是几何本身在编码——节点高 = 值 ÷ 一列的值合计,漏斗宽 = (值 − min) ÷ (max − min),指针转角 = 值在量程里的位置,弦图弧长 = 值 ÷ 节点值合计再扣掉缝隙。所以本章每一个黄框读数,都是在把某一次除法反过来验算一遍。

【原理先行】

四张图,两种数据形状

前两张(sankey、chord)吃的是图数据:一份节点表加一份边表。两边的键名都留了别名,而且优先级一致——SankeySeries.js:62-63 与 ChordSeries.js:70-71 写的都是 option.edges || option.links 和 option.data || option.nodes,所以 data/nodes、edges/links 只是同一件事的两种拼法,同写时排在前面的那个生效。边对象最短形态就是 {source: '进店', target: '首单', value: 260},source/target 写节点名;节点表里可以只写 name,也可以带 value、depth(sankey 专用,写了就直接当层号用,sankeyLayout.js:125-130)和拖动产生的 localX/localY。

后两张(funnel、gauge)吃的是普通数据数组:data: [{name: '曝光', value: 1000}, ...]。没有边,也就不需要拓扑——漏斗把数组顺序当成阶段顺序,仪表盘把数组长度当成指针根数。

四张图的另一个共同点是没有坐标系:既不需要 grid,也不会有 xAxis/yAxis。三个系列各自声明这件事的写法并不统一——sankey 与 funnel 写 coordinateSystemUsage:'box'(SankeySeries.js:177、FunnelSeries.js:96),chord 更直接,写 coordinateSystem:'none'(ChordSeries.js:153),gauge 则两个键都不写、走基类缺省。所有几何量都从「值 ÷ 某个基准」来:桑基图除的是同列节点值的合计,漏斗除的是 max − min,仪表盘除的也是 max − min,弦图除的是节点值合计再扣掉 padAngle 吃掉的缝。本章黄框里的每一个像素数和角度数,都是把那次除法反算一遍——能对回去,说明你读懂了这张图在编什么。

sankey:节点高度是三条线的最大值

桑基图的第一件事,是把每条边的值汇总到节点上。computeNodeValues(sankeyLayout.js:79-91)对每个节点算三个数——流出边合计、流入边合计、数据项自己写的 value——然后取最大:var value = Math.max(value1, value2, nodeRawValue)(sankeyLayout.js:85)。为什么要三个里取大?因为汇点没有出边、源点没有入边,只认其中一项就会画不出另一端;而有些节点(比如一个纯粹的中间站)两边都不等于原始值,得给数据项留一个「我说我有多大」的口子。

这个值再乘上一个全局比例变成节点高度:dy = value × minKy,而 minKy 是所有列里最挤的那一列给出的——ky = (可用长度 − (列内节点数 − 1) × nodeGap) ÷ 列值合计,取各列的最小值(sankeyLayout.js:276-300)。同一张图里所有节点共用一个 minKy,出边的粗细也用它,于是守恒的节点会被出边正好铺满。本章面板一实测:「进店」入 300、出 300,节点高 284 px、出边合计 284 px、空 0 px。

但源码从来不检查流入等于流出。面板一第三张把「进店」写成入边 400 + 250 = 650、出边 300 + 200 = 500,节点按 650 画成 300 px 高,两条出边只占 230.8 px(就是 300 × 500 / 650),底部留 69.2 px 的空白,控制台一声不吭。这句话值得记住:流量守恒是读者的期待,不是 ECharts 的约束。桑基图不会替你校验数据,它只会把不守恒的位置留白给你看——反过来说,读者看到一段空白,第一反应应该是「这里漏了一去向」,而不是「图坏了」。

orient 有两个值,'horizontal'(缺省)与 'vertical'(SankeySeries.js:182)。纵向时同一套算法转 90°:节点值映射成横向厚度 dx,深度沿 y 轴推进(sankeyLayout.js:287-294)。面板一第二张实测 dx = 342 px:440 px 宽的容器扣掉左右边距剩 366 px,第 0 列四块节点合计 300、之间三段 8 px 的缝,minKy = (366 − 24) / 300 = 1.14,「进店」值 300 于是厚 342 px——一整块几乎横贯画布。纵向桑基适合「层数少、每层节点多」的场景,反之会很挤。

层号(depth)由 computeNodeBreadths 用 Kahn 拓扑排序算出来(sankeyLayout.js:97-156):从没有入边的节点出发,一层层把边消费掉。环上的节点永远没有「入度归零」的那一刻,遍历结束后还有边没被消费,源码就在 sankeyLayout.js:152-155 抛出 Error('Sankey is a DAG, the original data has cycle!')。注意这条 throw 不在 process.env.NODE_ENV 判断里,所以压缩过的生产版照样抛;setOption 是同步的,异常直接打到你的调用栈上,后面的脚本一起遭殃——本章面板一的按钮①用 try/catch 把它接住,才让页面活下来。要画「A↔B 互相来往」,就得换弦图(面板十、十一)。

层号算出来后,nodeAlign 可以再改一次(sankeyLayout.js:168-198),三个值对应三种直觉:

  • left:拓扑层号原样保留,「离源头几跳就在第几列」。
  • justify(缺省,SankeySeries.js:202):调 moveSinksRight(:208)把所有没有出边的汇点统一推到最右列。好处是图右边齐平,代价是一条短边可能被拉得很长。
  • right:从汇点反向 BFS,按「离汇点还有几跳」从右往左排(:169-196),适合读「归因」——同一件事离终点多远。

数据项上写了 depth 的节点,三种对齐都不会覆盖它(isNodeDepth 判断在 sankeyLayout.js:191-195 与 :210-214),这是手工钉层的唯一入口。

layoutIterations(缺省 32,SankeySeries.js:186)只管同列内部的上下顺序:反复做 Gauss–Seidel 式松弛,把节点往它的邻居加权中心挪,再拆开重叠(sankeyLayout.js:247-255),目的是少交叉。它有一个容易被踩的自动失效条件:只要有一个节点的值是 0,源码就把迭代次数直接改成 0(sankeyLayout.js:63-66)。所以「我明明写了 32 为什么排得这么呆」的第一站是去看有没有 0 值节点。

levels 在桑基图里是按 depth 建的一张表,和 treemap/sunburst 那种「数组下标 = 层」完全不是一套用法。源码在 SankeySeries.js:67-75 遍历 levels,把每一项按它自己写的 depth 塞进 levelModels[depth];depth 缺失或为负时,开发版抛 levels[i].depth is mandatory and should be natural number,生产版静默跳过那一项。节点样式按节点自己的层号取,边的样式按它起点(node1)的层号取(SankeySeries.js:91-101),所以想让某一层的出边换色,只要给那一层写 lineStyle。

交互有两把。draggable(缺省 true,SankeySeries.js:185)让节点可以单独拖:视图把落点除以布局框的宽高,得到 0~1 的 localX/localY(SankeyView.js:256-273),再发 dragNode 动作(sankey/install.js:55-68),动作处理器调用 setNodePosition 把这两个数写回 option.data[dataIndex](SankeySeries.js:106-110)。也就是说,拖一下会污染你传进来的原始 option——想复位必须重新 setOption,不能只靠重绘。好处是拖动位置是比例存的,窗口一 resize 节点还停在相对位置。

第二把是 6.x 新增的 roam:5.6.0 的 sankey 里根本没有这个键,6.1.0 的缺省是 roam:false、roamTrigger:'global'、center:null、zoom:1(SankeySeries.js:188-191),视图挂上 RoamController(SankeyView.js:107-126)。动作名是 sankeyRoam,由 registerRoamActionSimply 把 'sankey' 与 'Roam' 拼出来(sankey/install.js:69、roamHelper.js:122-141),payload 里的 zoom 是倍乘而不是绝对值。面板三按钮②程序化发一次 sankeyRoam,实测 zoom 从 1 变 1.6、center 从 null 变成 [133.1, 165]。

悬停取景写在 emphasis.focus,源码认三个值:'adjacency' 走 getAdjacentDataIndices()(一跳邻居),'trajectory' 走 getTrajectoryDataIndices()(所有通往它的路径),其余值原样交给通用高亮管线(SankeyView.js:213-214 与 251-252)。桑基图没有缺省的 focus(SankeySeries.js:208-215 的 emphasis 只写了 label 与 lineStyle),不写就没有调暗。用「渠道 → 进店 → 购买 → 去向」这张图,就是本章面板一的主数据:源列写渠道,中间列写行为,末列写去向,读者从左往右读完一个用户的一生。

funnel:宽度是映射出来的,不是数值本身

漏斗的布局只有四行核心逻辑(funnelLayout.js:246-293):先把 minSize/maxSize 按可分配尺寸换算成两个像素数(竖版除的是宽度,横版除的是高度,:246),再把每个数据项的值用 linearMap(值, [min, max], [minSize像素, maxSize像素], true) 映射成一条横线的长度(:260-293),最后把相邻两项的横线连成梯形。

min 与 max 的缺省不在 FunnelSeries.js 里——那里的默认值是两行被注释掉的 // min: 0 与 // max: 100(FunnelSeries.js:108-109),真正生效的是布局里的兜底:min 取「数据最小值与 0 里更小的那个」,max 取数据最大值(funnelLayout.js:247-254)。这句话就是本章最容易被误解的地方:min/max 是数值的映射区间,不是显示的裁剪范围。把 min 写成 96,不会把小于 96 的数据扔掉,而是让「96」这个值映射到区间左端,也就是 minSize 那个像素数——面板六第三张实测复购(值 96)的上宽和下宽都是 0,五个阶段照样占满五格。想让最小的那项「画得出来」,改的是 minSize,不是 min。

第二张把 minSize 写成 '20%',实测曝光仍是 276 px(276 = 容器 300 减左右各 12),复购的下宽变成 55.2 px(= 20% × 276)、上宽 76.4 px(= 55.2 + 96/1000 × 220.8)。同一段数据,两个参数改出来的东西完全不同:min 改分母,minSize 改分子下限。

最后一项的底宽为什么永远是 0?源码在算每一项时,用 indices[i + 1] 当「下一项」的索引用(funnelLayout.js:308)。最后一项的 i + 1 越界,取不到值,值 || 0 落到 0,于是映射成区间左端那个像素数——缺省就是 0。这不是 bug,是「漏斗收到没有」的默认观感;嫌它难看就抬 minSize。

sort 三个值(FunnelSeries.js:112 缺省 'descending';排序在 funnelLayout.js:49-68 的 getSortedIndices,ascending 还要再翻一次(:295-305)):'descending' 按值从大到小排,'none' 保持数据顺序,'ascending' 不只是反过来——它把索引数组 reverse(),把每格厚度取负、起点挪到边界另一端,所以整张图连阅读顺序一起上下翻转(面板五实测:屏幕上从上到下变成「复购 → 曝光」)。sort 还可以写回调(:51-53)。业务顺序本来就有意义时(曝光 → 进店 → 下单),要么用 none,要么保证数据恰好单调,否则缺省的 descending 会替你重排,标签里的「本 ÷ 上」立刻算错对象。

orient:'horizontal' 把整张图转成从左到右收口(缺省 'vertical',FunnelSeries.js:113),此时宽度这个概念换成高度,funnelAlign 的合法值也换成 top/center/bottom(funnelLayout.js:266-277)。竖版对应的是 left/center/right(:282-292)。注意两边的 switch 都没有 default:横版写 funnelAlign:'left',y0 保持 undefined,四个顶点算出 NaN,图什么都不画也不报错——面板六的按钮①只在三个合法值之间循环。反过来,竖版把标签位置写成 top/bottom 时源码会纠正成 left 并打一条 warning(:107-117),那是 label.position 的兼容处理,跟 funnelAlign 是两回事,别混。

gap(缺省 0,FunnelSeries.js:114)在相邻两格之间垫一条空缝;每格的厚度默认是「可分配长度减缝再平分」,想给某一项单独定厚度,写在数据项的 itemStyle.height(横版是 itemStyle.width)上(funnelLayout.js:310-335)。

转化率怎么标?漏斗给的内置 percent 是相对总和的百分比(FunnelSeries.js:88-92),不是相对上一阶段,所以「相邻转化率」必须自己算:label.formatter 回调里拿 params.dataIndex 去除以前一项的值(面板七)。两个 series 摆左右对称的对比漏斗时,除了 sort:'none' 钉住阶段顺序,还必须写同一组 min/max——映射区间一致,宽度比才等于数值比。面板七实测 2024 的「进店」宽 123.6 px、2025 宽 141.9 px,比值 1.15,正好等于 620/540。

gauge:转角是值在量程里的位置

仪表盘是四张图里唯一一个「角度即数值」的。核心是一行 linearMap:angleExtent = [startAngle, endAngle],指针与进度条的落点都等于 linearMap(值, [min, max], angleExtent)(GaugeView.js:300-305、:364、:374)。缺省 startAngle:225、endAngle:-45、min:0、max:100、clockwise:true(GaugeSeries.js:68-74),量程 270°,值 72 就该落在 225 − 270 × 0.72 = 30.6°。

这里有个纯粹是符号的坑:源码在 GaugeView.js:100-101 把配置里的角度取负再转弧度(因为画布 y 轴朝下),紧接着 normalizeArcAngles 按 clockwise 归一(:108-111)。所以配置里的 225°→−45° 在内部是 −225°→45°,值 72 落在 −30.6°,指针元素的 rotation 又等于 -(内部角 + 90°)(:331)。同一种状态有三套写法,抄代码时别把它们混起来——本章黄框读数时统一换算回配置角,所以你看到的是 30.6°,而不是 −30.6° 或 300.6°。

splitNumber(缺省 10,GaugeSeries.js:76)决定大刻度几段,axisTick.splitNumber(缺省 5,:115)决定每段里再切几个小刻度;刻度文字用 round(...) 处理过浮点误差(GaugeView.js:212-214),所以你写 max:100、splitNumber:3 得到的是 33.3 而不是 33.333333334。

分段染色的键是 axisLine.lineStyle.color,形状是 [[累计比例, 颜色], ...],缺省 [[1, 中性灰]](GaugeSeries.js:78-86)。每一项写的是从 0 累加到这里的比例,不是区间上下限,所以「60 以下绿、85 以下橙、再往上红」要写成 [[0.6, 绿], [0.85, 橙], [1, 红]]。每段弧的角度 = 总角 × 比例差:面板八实测 270° 被切成 162°、67.5°、40.5°,正好是 270 × 0.6、270 × 0.25、270 × 0.15。axisLine.roundCap 是轴自己的圆头开关,和 progress 的同名键是两套。

progress 缺省不显示(GaugeSeries.js:88-94:show:false、width:10、overlap:true、roundCap:false、clip:true)。overlap:true 让进度弧叠在轴弧上面(画在同一半径上),写 false 就改成往内圈叠、每根占一条环带;clip:true 把超出量程的值夹在端点,不会绕出去;roundCap:true 换的是元素类型——从 sector 换成 sausage,两端带圆头(GaugeView.js:337-338),本章黄框读到的 progress sausage 弧 194.4° 就是它。itemStyle.color 可以写 'auto',源码会取值所在分段的轴色(GaugeView.js:419、:448-450):面板八按①循环 45/72/92,进度条颜色实测 #5a8f6b/#c98b4b/#b04a3a。另外 detail 的数字颜色在 progress.show 为真时跟着 progress 走(GaugeView.js:528),这也是「仪表盘配色很干净」的原因之一。

pointer 缺省 show:true、length:'60%'、width:6、showAbove:true、icon:null(GaugeSeries.js:134-141):icon 写任意图符(rect、triangle、甚至图片 URI)就能换掉那根针,itemStyle 管颜色,offsetCenter 让针整体偏移。anchor 是轴心那颗点,缺省 show:false、size:6、icon:'circle'、showAbove:false(:143-152),把它放大换色,半圆仪表盘的轴心存在感立刻出来(面板九第一张)。

title 与 detail 是两块文本,缺省分别摆在 [0,'20%'] 与 [0,'40%'](title 整块在 GaugeSeries.js:156-164,detail 在 :165-182)——offsetCenter 是相对圆心的偏移,百分比相对的是半径,不是画布;写成 [0,'-30%'] 就把文字搬到圆心上方。formatter 支持两种写法:字符串里的 {value} 会被替换成值,回调直接把值当参数(GaugeView.js:71-81);富文本按第 23 章那套 rich 写,{a|文字} 与 rich.a 配对。valueAnimation(缺省 false,:163 与 :181)打开后,数字会在过渡帧上一路滚到新值——注意它只挂在 title 和 detail 下面,series 层写这个键没有效果。

多指针不需要特殊配置:data 数组有几项,就画几根针、几条 progress(GaugeView.js:357-380 的 data.diff(...) 循环),轴与量程共用,颜色按 colorBy:'data' 从调色板依次取。面板九第二张一个 data 放 62 与 81,实测两根针分别指 57.6° 与 6.3°。想「一只环」——即常见的完成度环——就把 pointer/anchor/splitLine/axisTick/axisLabel 全关,只留 progress 和 detail,再写 startAngle:90、endAngle:-270 绕满一圈(面板九第三张实测 86.5 对应 311.4° = 360 × 0.865)。

chord:每条边在两端各记一次

弦图是 6.0 才有的新图表。实测对比:echarts@5.6.0 的包里连 chord 这个字样都搜不到(tar -tzf 过滤计数 0),6.0 有 lib/chart/chord/ 目录却漏了一条导出——npm view echarts@6.0.0 exports 里查不到 ./lib/chart/chord,6.1.0 把它补上了(实测 node_modules/echarts/package.json:177)。所以如果你在 6.0 上写 import 'echarts/lib/chart/chord' 会报解析失败;顶层入口 echarts/charts 的 ChordChart 一直是好的(本章实测 require('echarts/charts') 能拿到它),用整包 lib/echarts.min.js 更是自带。

数据格式与桑基图同形:data || nodes、edges || links(ChordSeries.js:70-71)。但它和第 26 章的 graph 只差一张表,能力上差得远:chord 只有环形布局,没有 layout、没有 x/y、没有 categories、没有 force、没有 thumbnail,节点也画不成 symbol——每个节点是一段圆弧(itemStyle.borderRadius 缺省 [0,0,5,5],ChordSeries.js:169-171),每条边是一条 ribbon 路径,lineStyle.width 缺省是 0(:172-176):弦的粗细来自几何填充,不是描边。它是有向图,node1 是起点、node2 是终点,因此允许 A→B 与 B→A 同时存在——这正是桑基图会抛环错的那种子。

节点值是弦图与桑基图最本质的分歧。chordLayout.js:92-95 遍历每条边,把它的值同时加到两端的节点上;如果数据项自己写了 value,再取 Math.max(显式值, 边和)(:99-103)。于是「节点值合计」恒等于「边值合计」的两倍——面板十实测 48 与 96,面板十一实测 42 与 84。面板十一把同一份四节点四条边的无环流量同时喂给两种图,弦图给 A/B/C/D 的值是 30/24/18/12,桑基图给 30/20/10/12:B 的两条边(进 20、出 4)在弦图里相加成 24,在桑基图里取 max 成 20。两边都没错,只是「节点有多大」在两张图里定义不同。

角度账在 chordLayout.js:114-122:先从整圈里扣掉 padAngle × 渲染节点数(缺省 padAngle:3,ChordSeries.js:168),剩下的除以节点值合计得到 unitAngle,每块弧 = 值 × unitAngle。如果 padAngle 太大把圈吃满,源码先砍 padAngle 保住 minAngle(:114-118),连 minAngle 也放不下时再一起缩(:119-121)。minAngle(缺省 0)把太小的弧抬起来抬到看得见,多出来的角度要从大节点「借」:按各自富余比例逐个扣,一旦某个节点扣不下去就整体缩放(:124-200)。所以「借」不是均摊——面板十第三张 A 值 102、B 值 100、C 值 2,写 minAngle:20 后实测 A 158.9°、B 172.1°、C 画成 20°,三段加起来 351°,配上 3 段 3° 的 padAngle 正好一圈。

弦的宽度:一条 ribbon 在两端各占「边值 × 那一端的单位角」。面板十第一张 A→B 的 20 在 A 端占 20/36 × 130.5° = 72.5°,黄框实测「首条弦两端各 72.5°」。注意同一根弦在两端的角度跨度一般不相等(两端的单位角不同),所以弦看起来是收口的。

取舍很清楚:弦图擅长「两两之间的对称关系」——谁和谁互相来往、总量多少,一眼读圆环;桑基图擅长「有向流动与守恒」——从左读到右,中间每一站进出对不对得上。节点一多,弦图的 ribbon 会在圆心里交叉成毛线球,这时要么换桑基图(前提是没有环),要么换成矩阵/热力图。面板十二的会员流转就是一个典型的弦图场景:降级(黑卡 → 金卡)与流失(人人可去)都是回边,桑基图会当场抛环错。

【最小可跑】

桑基图是四张图里最适合先跑起来的:数据只有 nodes 与 links 两张表,没有坐标系、没有轴,也不用额外注册什么组件——本章用的整包 lib/echarts.min.js 里就带着它。下面这个文件不读外部数据,file:// 双击就能画。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>第 28 章最小可跑:一张桑基图</title>
  <style>
    body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; }
    h1 { font-size: 20px; margin: 0 0 6px; }
    #chart { width: 680px; height: 420px; border: 1px solid #d9d2c7; }
    #status { margin-top: 8px; padding: 8px 12px; background: #fff8ea; border-left: 4px solid #b6902f;
              font: 13px/1.7 Consolas, monospace; }
  </style>
</head>
<body>
  <h1>一张桑基图:渠道 → 进店 → 首单 → 复购</h1>
  <p>数据只有 <code>nodes</code> 与 <code>links</code> 两张表,页面不读外部文件,双击就能画。节点可以拖动,空白处可以平移、滚轮缩放。</p>
  <div id="chart"></div>
  <div id="status">正在画……</div>

  <script src="../../lib/echarts.min.js"></script>
  <script>
    var FLOW = {
      nodes: [{ name: '广告投放' }, { name: '自然搜索' }, { name: '门店海报' },
        { name: '进店' }, { name: '首单' }, { name: '复购' }, { name: '流失' }],
      links: [
        { source: '广告投放', target: '进店', value: 200 },
        { source: '自然搜索', target: '进店', value: 120 },
        { source: '门店海报', target: '进店', value: 80 },
        { source: '进店', target: '首单', value: 260 },
        { source: '进店', target: '流失', value: 140 },
        { source: '首单', target: '复购', value: 180 },
        { source: '首单', target: '流失', value: 80 }
      ]
    };

    var chart = echarts.init(document.getElementById('chart'), null, { renderer: 'svg' });
    chart.setOption({
      series: [{
        type: 'sankey',
        left: 12, right: 100, top: 24, bottom: 24,
        nodeWidth: 22, nodeGap: 10, nodeAlign: 'justify', layoutIterations: 32,
        draggable: true,
        roam: true,
        data: FLOW.nodes,
        links: FLOW.links,
        label: { fontSize: 12 },
        itemStyle: { borderWidth: 0 },
        lineStyle: { color: 'source', opacity: 0.25, curveness: 0.5 },
        emphasis: { focus: 'trajectory' }
      }]
    });

    // 读数来自布局:节点的 value 与厚度、每条出边的厚度。
    // 节点的 value 取「流入、流出、原始值」三者的最大,不是流入也不是流出。
    var statusEl = document.getElementById('status');
    try {
      var g = chart.getModel().getSeriesByIndex(0).getGraph();
      var node = null, nodes = 0, edges = 0;
      g.eachNode(function (n) { nodes++; if (n.id === '进店') node = n; });
      g.eachEdge(function () { edges++; });
      var layout = node.getLayout();
      var outH = 0;
      for (var i = 0; i < node.outEdges.length; i++) outH += node.outEdges[i].getLayout().dy;
      var inSum = 0, outSum = 0;
      for (var j = 0; j < node.inEdges.length; j++) inSum += node.inEdges[j].getValue('value');
      for (var k = 0; k < node.outEdges.length; k++) outSum += node.outEdges[k].getValue('value');
      statusEl.textContent = nodes + ' 个节点、' + edges + ' 条边;「进店」入 ' + inSum + '、出 ' + outSum +
        ',节点高 ' + Math.round(layout.dy) + ' px,出边铺满 ' + Math.round(outH) + ' px,差 ' +
        Math.round(layout.dy - outH) + ' px。';
    } catch (e) {
      statusEl.textContent = '布局读数不可用:' + e.message;
    }
  </script>
</body>
</html>

黄框实测:7 个节点、7 条边;「进店」入 400、出 400,节点高 352 px,出边铺满 352 px,差 0 px。这 352 px 可以从头算出来:容器高 420 px,扣掉 top:24 与 bottom:24 剩 372 px;最挤的一列是第 0 列的「广告投放 / 自然搜索 / 门店海报」三块,值合计 400、块间两段 nodeGap:10,于是 minKy = (372 − 20) / 400 = 0.88 px/单位。「进店」的值是 max(出 400, 入 400, 原始 0) = 400,高 400 × 0.88 = 352 px;它的两条出边(260 + 140)用同一个 0.88,铺满 352 px,差 0。把「进店 → 首单」的值改成 240,节点高不动、出边合计掉到 334.4 px,差立刻变成 18 px——这就是「守恒不被强制」最小的可复现样本。

页面里三处配置值得单独记:roam: true 打开 6.x 新增的漫游,空白处按住拖动是平移、滚轮是缩放(缺省 roam:false);draggable 缺省就是 true,节点可以单独拖,落点以 0~1 的比例写回 option.data;emphasis.focus:'trajectory' 让悬停沿整条轨迹回溯,把通往「复购」的所有渠道一起留下。right:100 是给最右一列的标签留位——桑基图缺省 right:'20%'(SankeySeries.js:180),因为它默认假设标签在节点右侧(label.position:'right',:194)。

读数那段用了 chart.getModel().getSeriesByIndex(0).getGraph() 与 node.getLayout(),这是内部接口,版本一换就可能变,生产代码别照抄;本章用它,是因为只有这里能拿到「值」与「厚度」,而正文的所有断言都要能被实测对账。

【逐步加料】

面板一:sankey 的节点与流量守恒

三张图共用一套「渠道 → 进店 → 购买 → 去向」的拓扑。左边是十节点、十二边的守恒流量:每个中间节点的入边合计都等于出边合计。中间只把 orient 换成 'vertical'。右边故意让「进店」流入 400 + 250 = 650、流出 300 + 200 = 500。

黄框三行读数把上面三个场景各钉死一个数:横向那张「10 节点 / 12 边;进店入 300、出 300,节点高 284 px,出边铺满 284 px,空 0 px」;纵向那张换成「节点厚度换成 dx = 342 px,深度方向沿 y 轴」;失配那张是「入 650、出 500,节点按 650 画成 300 px 高,出边只占 230.8 px,底部空 69.2 px」。230.8 与 69.2 就是 300 × 500/650 与 300 − 230.8,比例严丝合缝——节点高度只认那个 max,出边只认各自的值,两边对不上时谁也不报错,只留白。

按①会往第三张图塞一条 A→B→C→A 的三边数据,setOption 同步抛出:

Sankey is a DAG, the original data has cycle!

黄框第四行原样打印这条消息(setOption 抛出:Sankey is a DAG, the original data has cycle!)。这条 throw 不带环境判断(sankeyLayout.js:152-155),生产版一样抛,所以线上「桑基图整块空白 + 控制台一条未捕获异常」通常是同一件事:数据里有环,或者你把「回流」当成了普通的一条边。按②复位。

真实业务里这条最容易撞上:「退货」指向「订单」、「复购」指回「进店」。处理办法有三种,按你的问题选——把它拆成两个不同名字的节点(复购 与 再次进店)、用弦图画(面板十)、或者干脆用普通折线画时间序列。

面板二:nodeAlign 的三种对齐

同一份六节点数据:广告投放直接接成交(经由「直接下单」)、也接短信召回与公众号,公众号走私域社群再到成交。三种 nodeAlign 的读数(黄框报每个节点的层号 d,「广告投放」恒为 d0 所以没列进来):

  • left:短信召回 d1,公众号 d1,直接下单 d1,私域社群 d2,成交 d3
  • justify(缺省):短信召回 d1,公众号 d1,直接下单 d3,私域社群 d2,成交 d3
  • right:短信召回 d2,公众号 d1,直接下单 d3,私域社群 d2,成交 d3

差别只有一处最直观:「直接下单」在 left 里是第 1 列,在另外两种里被推到第 3 列。justify 的规则是「没有出边的节点一律搬到最右列」(moveSinksRight,sankeyLayout.js:208),成交与直接下单都是汇点,于是并排站在最右,代价是「广告投放 → 直接下单」这条边被拉得横跨三列,视觉上像一条很长的捷径。right 从汇点反向数跳数(:169-196),公众号离成交两跳,所以只拿到 d1,短信召回一跳、拿到 d2。

选法按读者的问题来:问「谁先谁后、经过几站」用 left;问「最终都归到哪里」用 justify;问「离终点还有多远」(归因、剩几步转化)用 right。层号一旦定了,levels 的分组就跟着变——同一份 levels 在三种对齐下会染出不同的颜色,面板三的 levels 就是靠层号工作的。

面板三:nodeWidth、nodeGap、layoutIterations、levels 与漫游

四张图画同一份十节点流量,每张只改一件事,黄框五段读数一一对应:

  • nodeWidth 是节点的横向厚度,缺省 20。第二张改成 8,黄框报「厚度 8 px」。
  • nodeGap 是同列相邻两块之间的最小缝,缺省 8。第二张改成 24,黄框「第 0 列 4 块,最小相邻缝 = 24 px」。注意第一张同样读数是 8 px——缝就是 nodeGap 本身,不会更大。
  • 但 nodeGap 一变,节点会跟着变矮:第一张「广告投放」高 109.6 px,第二张只有 90.4 px。原因在 minKy = (可用高 − (块数 − 1) × nodeGap) ÷ 列值合计(sankeyLayout.js:276-300)——四块节点本来能吃掉 298 − 3×8 = 274 px 的竖向预算,缝改成 24 之后只剩 298 − 3×24 = 226 px。缝是从节点身上扣的,这是 nodeGap 最反直觉的一面。
  • layoutIterations:0 关掉同列排序的松弛,黄框读「迭代 32→0:「小程序」y 53.3→0,「首单」y 39.3→0」:迭代只搬同列内的上下位置,层号、厚度一概不动。再提醒一次那个自动失效条件:只要有一个节点值为 0,源码会把迭代强制归零(sankeyLayout.js:63-66)。
  • levels 按 depth 上色,第四张给深度 0~5 各配一色,黄框读「levels 深度 1/3/5 的颜色:#7a9e7e / #b04a3a / #5b6f8a」——分别是「进店」「首单」「流失」拿到的颜色。漏写 depth 的话,开发版直接抛 levels[i].depth is mandatory and should be natural number(SankeySeries.js:68-72),生产版静默忽略那一项,图照画、颜色照旧,属于「不报错但没生效」那一类。

两个交互按钮把上面说的机制拆开验证。按①发 dragNode,把「进店」放到布局框 28% 宽、20% 高的位置,黄框变成「localX = 0.28、localY = 0.2,矩形 x = 63.3、y = 59.6」:存的是比例,画的是像素(300 − 10 − 64 = 226 的 28% 是 63.3,330 − 16 − 16 = 298 的 20% 是 59.6),而且这两个数被写回了 option.data(SankeySeries.js:106-110)——按③重建面板一之后 localX 才回到 null。按②发 sankeyRoam(zoom:1.6、originX:150、originY:165),黄框读「zoom = 1.6,center = [133.1, 165]」;zoom 是倍乘,center 从 null 被算成一个具体像素点,说明漫游状态确实存在系列模型上。这两个动作都不需要真鼠标,是程序化演示与自动化测试的入口。

面板四:emphasis.focus 的三种取景

八节点、七条边的同一份流量,三张图分别写 adjacency、trajectory、none,页面一启动就高亮最右侧的汇点「复购」。黄框列出每张被调暗的部分:

  • adjacency:暗节点 6(广告投放、线下地推、自然流量、进店、只逛不买、单次客),暗边 6 条
  • trajectory:暗节点 2(只逛不买、单次客),暗边 2 条
  • none:暗节点 0,暗边 0 条

「复购」只有一个入边邻居「首单」,所以 adjacency 只留下「首单 + 复购」两块,其余 6 块全暗(7 条边里也只留 1 条,暗 6 条)。trajectory 沿路径往回走到底,三个渠道、进店、首单一起亮,只暗掉两条死路(只逛不买、单次客)与它们的两条边——「轨迹」看的是通往谁,「相邻」看的是挨着谁。想读「哪些渠道喂出了复购客」,trajectory 一张就够;adjacency 会 misleading 地把上游渠道全按掉。

按②取消高亮,三张图立刻都变成「暗节点 0,暗边 0 条」:调暗不是永久属性,只是 emphasis 状态的产物,downplay 与鼠标移出等价。按①再高亮回来。顺带记住面板四没写出来的那一条:桑基图的 emphasis 缺省不含 focus(SankeySeries.js:208-215 只配了 label 与 lineStyle),所以缺省悬停就是 none 的表现。

面板五:funnel 的 sort 与 orient

一组五级转化数 1000 / 620 / 310 / 180 / 96,四张图分别改 sort 与 orient。黄框按屏幕先后顺序列出每一格的两条边(竖版是上宽/下宽,横版是左高/右高):

  • descending(缺省):曝光 276/171.1 → 进店 171.1/85.6 → 下单 85.6/49.7 → 首单 49.7/26.5 → 复购 26.5/0
  • ascending:复购 26.5/0 → 首单 49.7/26.5 → 下单 85.6/49.7 → 进店 171.1/85.6 → 曝光 276/171.1
  • none:与 descending 完全一致
  • horizontal:曝光 290/179.8 → 进店 179.8/89.9 → 下单 89.9/52.2 → 首单 52.2/27.8 → 复购 27.8/0

三个数字关系读一遍就够记住算法:每格的上宽等于前一格的下宽(171.1 接 171.1),所以相邻两格连成一条收口的斜线;每一格的宽度都是 值 ÷ 1000 × 276(620 → 171.1,96 → 26.5);最后一格的下宽恒为 0,因为源码把「下一项」当成不存在的第 6 项,值取 0(funnelLayout.js:308)。

ascending 那一行值得多看一眼:它不是「数值反过来」,而是整张图上下翻转(索引数组 reverse()、每格厚度取负、起点挪到下边界,funnelLayout.js:295-305)。所以屏幕上从上到下是复购到曝光——如果你的业务里「漏斗口朝下」才对,就别用 ascending 来表达,改数据顺序更不容易出错。none 与 descending 这一组恰好同序,因为数据本身就单调递减;把值打乱就会分开——none 的价值正在于「顺序是业务定的,不是大小定的」。

横版四张里,同一组数从 276 满量程变成 290:竖版除的是宽度(300 − 12 − 12 = 276),横版除的是高度(330 − 24 − 16 = 290)。换 orient 会顺带换分母,所以两版的绝对像素数不可比,比值才可比。

面板六:min/max、minSize/maxSize 与 funnelAlign

前五张是同一份 1000/620/310/180/96 在四组参数下的样子,黄框五段读数:

  • 缺省(min:0、max:1000、minSize:'0%'):曝光宽 276,复购上宽 26.5 / 下宽 0,复购 x0 = 136.8
  • minSize:'20%':曝光宽 276,复购上宽 76.4 / 下宽 55.2
  • min:96:曝光宽 276,复购上宽 0 / 下宽 0
  • funnelAlign:'left':曝光 x0 = 12,复购 x0 = 12
  • 横版 funnelAlign:'top':曝光 y0 = 24,复购 y0 = 24(高度 27.8)

第二、第三行是本章最想让人停下来的对照:它们改的是同一件事的两端。minSize:'20%' 把映射区间的输出下限抬到 55.2 px,于是复购整个梯形被抬高、下宽 55.2、上宽 55.2 + 96/1000 × (276 − 55.2) = 76.4;min:96 把映射区间的输入下限抬到 96,于是复购正好落在左端点,映射成 0 宽——可是画布上仍然是五格、曝光仍然是 276 px。这就是「min/max 是数据映射范围,不是显示范围」的实测版:它不裁剪数据,只重新定义「谁算零」。

第一行的 x0 = 136.8 是 center 对齐的证据:复购宽 26.5,(276 − 26.5) / 2 + 12 = 136.75。第四行改成 left 之后每一格的左边缘都贴 12(left 边距),梯形变成从左往右收口的斜边——做「瀑布式」阶梯观感时用 left/right,做传统漏斗时用 center。

按①在横版的三个合法对齐之间循环,黄框最后一行依次报 top、center、bottom 下复购的 y0:24、155.1、286.2,而它的高度 27.8 始终不变——funnelAlign 只搬位置,不改大小。这里也藏着横版最大的坑:写 funnelAlign:'left' 落不进横版那个只有 top/center/bottom 的 switch(funnelLayout.js:266-277),y0 保持 undefined,坐标变 NaN,五格一起消失且控制台毫无动静。竖版写 top/bottom 同理。

面板七:转化率标注与两期对比漏斗

左图五级是 12000 / 5400 / 2160 / 1512 / 605,黄框第一行的 45.0%、40.0%、70.0%、40.0% 是 label.formatter 里用 CONV[dataIndex - 1].value 现场除出来的。为什么不直接用 ECharts 给的百分比?因为 params.percent 的定义是「本项 ÷ 所有项之和」(FunnelSeries.js:88-92),这张图里它给的是 55.36、24.91、9.96、6.98、2.79——分母是五项加起来的 21677,不是上一级。想标「进店率」「下单率」这种相邻转化率,只能自己除;percent 能回答的是「这一级占了整个盘子的多少」,两者别混。第一项没有上一项,formatter 里判一下 dataIndex > 0,只写名字和值。

第二行是本章最该被读者抄走的一条:左图不写 min 与 max,黄框于是报出 390、175.5、70.2、49.1、19.7 px——正好是各值除以 12000 再乘上 390 px 的可用宽。如果把面板六那套 min:0, max:1000 照搬过来喂这份 12000 量级的数据,映射会被 linearMap(..., true) 夹住(funnelLayout.js:260-293 的最后一个参数就是 clamp),实测五格变成「390、390、390、390、236」——前四级全都被顶到 maxSize,漏斗塌成一摞方柱,而控制台一行日志都不打。这就是把 max 当「显示上限」的最常见后果:它其实是输入量程,写小了不裁剪数据,只把所有大值一起压成同一个宽度。

右图是两个 series 各占半幅:left:'4%' + width:'44%' 与 left:'52%' + width:'44%',两边都写 min:0, max:1000、sort:'none'、同一组阶段顺序。sort:'none' 是把「曝光/进店/下单/首单/复购」钉在同一行的关键——一旦让它 descending,两期的阶段顺序就各排各的,第 3 格根本不在同一水平线上。两个 series 共用同一组 min/max 才让「同一数值在两边一样宽」成立:黄框量到 2024 的「进店」123.6 px、2025 的 141.9 px,比值 1.15,正好等于数值比 620/540 = 1.15。这个「宽比 = 值比」就是共用量程的证明;哪天两个 series 的 max 写得不一样,这条等式当场断裂。

面板八:gauge 的角度、分段轴与 progress

三张图的角度模型完全一样:startAngle:225、endAngle:-45,顺时针扫过 270°。option 里的角度用的是数学约定——0° 指 3 点方向、逆时针为正、单位是度,所以 225° 是「左上」,-45° 是「右下」。值到角度的换算只有一行:θ = startAngle - (startAngle - endAngle) × (value - min) / (max - min)。第一张值 72、量程 0–100,代进去是 225 - 270 × 0.72 = 30.6°,黄框报的正是 30.6°,同时报出整条轴弧 270°。splitNumber:10 把量程均分成 10 段(每 10 个单位一格),axisTick.splitNumber:5 是每段里再切 5 根小刻度,这两个数字都跟角度无关,它们切的是量程。

第二张把 axisLine.lineStyle.color 写成 [[0.6,'#5a8f6b'],[0.85,'#c98b4b'],[1,'#b04a3a']]。这里的 0.6 / 0.85 / 1 是累计的比例,不是角度:黄框量到三段弧分别是 162°、67.5°、40.5°,也就是 270°×0.6、270°×0.25、270°×0.15。想改颜色分界,改的是「值在量程里的位置」;量程从 0–100 换成 0–200,分界点跟着走,弧线长度却一点不变。

第三张打开 progress。progress.width 与 axisLine.lineStyle.width 是两条独立宽度,overlap:true(缺省)让进度弧压在轴弧正上方,roundCap:true 把两端收成圆头——元素类型也因此从 sector 变成 sausage(GaugeView.js:337-338)。progress.itemStyle.color:'auto' 是 6.1 才有的取值,含义是「取当前值落在的那一段轴色」(GaugeView.js:448-450):值 72 落在 60–85 段,黄框报 sausage 弧 194.4°,fill #c98b4b,194.4 就是 270×0.72。按①在 45 / 72 / 92 之间循环,黄框跟着报 121.5° #5a8f6b、194.4° #c98b4b、248.4° #b04a3a——弧长和颜色同时换,读者一眼能看出「'auto' 的颜色是跟着值跑的,不是固定色」。

最后一处细节藏在 detail。开了 progress.show 之后,detail 的文字颜色缺省跟随进度弧的填充色(GaugeView.js:528),也就是那个「auto」会传染到数字上;第三张显式写了 detail.color:'#3c3c41' 把它按住,否则值一跨段,连中间的数字都会跟着变红变绿。黄框的角度是从 zrender 元素的 rotation 反推的,这里有个符号陷阱:GaugeView.js:100-101 把 option 角度取负再转弧度,:331 写 pointer.rotation = -(angle + π/2),所以反推必须用 deg(rotation) + 90。本章最初按直觉写了 -deg(rotation) - 90,值 72 报成 329.4°——360 − 30.6,正好差一个镜像。

面板九:半圆、双指针、富文本与极简环

第一张是量程 0–200 的半圆:startAngle:180、endAngle:0,扫过 180°,splitNumber:5 让轴标签落在 0/40/80/120/160/200。值 150 的角度是 180 - 180 × 150/200 = 45°,黄框报 45°。anchor 缺省是 show:false(GaugeSeries.js:143-148),这里打开并把 size 提到 16、颜色换成黄铜色,黄框数到 1 个 #b6902f 的 path 元素——anchor.icon 缺省是 'circle',也可以换成任意路径,它是独立元素,不是指针的一部分。detail.formatter 写回调时参数就是当前值,val + ' 单' 拼出「150 单」;写字符串时得用 {value} 占位。

第二张最能说明 gauge 的 data 语义:data 数组里放两项,就画两根指针。62 与 81 分别落在 225 − 270×0.62 = 57.6° 和 225 − 270×0.81 = 6.3°,黄框照此报出,两根指针的颜色是 #7a9e7e 与 #3d5a80——来自系列的 color 列表按 dataIndex 取,所以「2024 与 2025 比达标率」不需要两个 series,一个 series 两项数据即可,title/detail 也各跟一项。轴、刻度全关,只留一根细 pointer 和中心 anchor,这是双指针对比最克制的画法。

第三张把仪表盘做成极简环:startAngle:90、endAngle:-270,正好补满 360°;pointer、anchor、splitLine、axisTick、axisLabel 全部 show:false,画面上只剩背景环和进度弧。值 86.5 → 黄框报 sausage 弧 311.4°,即 360×0.865。中心的数字用富文本拼:formatter 返回 '{v|86.5}{u|%}',rich 里 v 是 40 px 粗体、u 是 16 px 灰字并带 padding:[0,0,8,2] 把百分号往下压一点,读起来才不像「86.5%」被拉成一行大字。黄框最后一行把这块的文字元素逐个列出,实测是 目标完成率 | 86.5 | %——title 与 detail 的每个富文本片段都是独立的 tspan,所以「%」和数字可以分别取色、分别调字号。

面板十:chord 的节点值、padAngle、minAngle 与显式 value

第一张四条边里有 A→B 与 B→A 这一对反向弦——这正是桑基图会当场抛错、弦图照样画得出的场景。弦图的节点值规则写在 chordLayout.js:92-95:每条边的 value 在两个端点各累加一次。于是 A = 20+6+10 = 36、B = 20+6+4 = 30、C = 10+8 = 18、D = 8+4 = 12,黄框同时报出「边值合计 48、节点值合计 96」——节点值永远是边值的两倍,这不是 bug,是弦图对「这个节点有多大」的定义:它挂着多少流量往来,进出都算。

弧角由节点值决定,padAngle 先扣空档。可用角 = 总角 − padAngle × 节点数(chordLayout.js:122),第一张是 360 − 3×4 = 348°,除以节点值合计 96 得每单位 3.625°:A 130.5°、B 108.75°、C 65.25°、D 43.5°,四段加起来 348°,再加四个 3° 空档正好一圈(黄框把角度四舍五入到一位小数,所以你看到的 B 是 108.7°)。第二张把 padAngle 提到 12,可用角缩成 360 − 12×4 = 312°,单位角变成 3.25°,于是同样的数据量出 117°、97.5°、58.5°、39°——所有弧都缩短,节点之间的空隙反倒成了主角。节点一多要留意这件事:padAngle 是按节点数收过路费的。这张图还是用 data/edges 这一组别名写的(ChordSeries.js:70-71),黄框第二行的标题就把这一点标着:p10b pad12/min6(data/edges 别名)——nodes/links 与 data/edges 是同一份数据的两个名字。

第三张专测 minAngle 的代价。数据只有三条边:A→B 100、A→C 2,节点值是 A 102、B 100、C 2;按 C 的自然角只有 3.4°,几乎看不见。写 minAngle:20 之后黄框报 C 2/3.4°(画 20°)——括号里是布局算出的自然角,外面是实际画出的角,差额从 A、B 这两个「有余量」的节点按比例借(chordLayout.js:124-200)。借完之后 A 158.9°、B 172.1°,角度比 0.923,而它们的值比是 102:100 = 1.02。也就是说 minAngle 会破坏弧角与数值的正比关系,它用「看得见」换掉「可比」。要做精确比较就把 minAngle 留在 0,只在小节点确实读不出来时才抬。

第四张测显式 value。A 写 value:50,两条边各 10(A 由边算出的是 20),布局取 Math.max(数据值, 边累加)(chordLayout.js:99-103),A 按 50 参与分配;节点值变成 50+10+10 = 70,单位角 (360-3×3)/70 = 5.0143°,于是 A 250.7°、B 50.1°、C 50.1°。写显式 value 只会被「取大」,不会把节点压小——想让某个节点在弦图里占更大的弧,光靠它不够,得改边。

面板十一:同一份流量,弦图与桑基图

同一个 DAG(A→B 20、A→C 10、B→D 4、C→D 8,无环),两张图各画一次。黄框并排列出节点值:弦图 A/B/C/D = 30/24/18/12,合计 84;桑基图 A/B/C/D = 30/20/10/12。两张图对 A 和 D 的判断一致——A 是纯源、D 是纯汇,「边在两端各记一次」和「取流入流出的最大」算出来都是它的通过量。分歧全在中间:B 有 20 流入、4 流出,桑基图取 Math.max(20, 4) = 20(sankeyLayout.js:79-91),弦图把这条进出都记上得 24;C 同理是 10 对 18。

这决定了选型时该看哪一列。你要读「每一跳实际过了多少」,用桑基图,中间节点的厚度就是它的通过量,而且流向自带左右顺序;你要读「谁和谁有关系、关系有多重」,用弦图,它不在乎有没有环、也不需要分层,A 与 B 之间的双向往来能一眼看出来。反过来也别硬套:拿弦图的节点值去跟桑基图的比大小,会得出「B 在弦图里比在桑基图里大」这种废话;拿桑基图画会员降级,则会被 Sankey is a DAG, the original data has cycle! 拦住。

面板十二:实战,会员流转、转化漏斗与门店达标率

左图是会员在五个等级之间的流转,画成弦图不是审美选择而是必要:黑卡 → 金卡 这条 6 人的降级边让「等级」这条链成了环,同样的数据喂给 sankey 会直接抛 Sankey is a DAG, the original data has cycle!。弦图把它当普通有向边收下了。黄框报五个节点的值与弧角:新客 600/114.5°、银卡 640/122.1°、金卡 236/45°、黑卡 62/11.8°、流失 270/51.5°,五段弧合计 345°,加上 5 个 padAngle:3 的 15° 空档正好一圈。注意银卡(640)比新客(600)还大——这是「进出都记一次」规则的实战版:新客只挂着出去的 420 + 180 = 600,银卡却是「收 420、再出去 160 + 60」两头都记,加起来反倒超过它的上游。金卡那 236 里也含黑卡退回的 6。看这种图要盯弦(每条往来多少),别拿弧长当「这个等级有多少人」。

中间是从曝光到复购的转化漏斗,同一份 12000/5400/2160/1512/605 数据、同一个 conversionFormatter,min:0, max:12000 这次写对了,黄框报出 45.0%、40.0%、70.0%、40.0% 四级相邻转化率。四个数里最扎眼的是 70%——下单到首单的转化反而最高,真正掉人的是曝光到进店(45%)和进店到下单(40%);这就是面板七那条「percent 占总和、formatter 自己除才算得相邻率」的落地:图上每格写的第三个数必须是现算的。

右图是本章唯一读真实数据的仪表盘。状态条写「已读取 12 家门店;国贸店 2025 年销售额 ¥5,816,552,目标 ¥6,000,000」,数字来自 data/store-metrics.json 里 storeId 为 S02 那一行的 annualSales。min:0, max:6000000,完成率 96.94%,progress 的弧因此是 270 × 0.9694 = 261.7°,黄框报 sausage 弧 261.7°,fill #5a8f6b。这里的分段轴是 [[0.6,'#b04a3a'],[0.85,'#c98b4b'],[1,'#5a8f6b']]——跟面板八反着写:达标率越高越好,所以红在下、绿在上,color:'auto' 便让 96.94% 拿到绿色。中心两行字用富文本拼成「¥581.7万」与「96.9%」,指针、刻度、轴标签全关;这就是面板九那张极简环在真实数据上的样子。

【参数速查】

sankey

键缺省说明
series.nodes[]也可写 series.data,两者等价(SankeySeries.js:62-63)
series.links[]也可写 series.edges;每项 { source, target, value }
series.orient'horizontal''vertical' 时「层」变成行
series.nodeWidth20节点矩形厚度,从可用宽里扣
series.nodeGap8同层相邻节点之间的最小缝,纵向预算按它排
series.nodeAlign'justify''left'/'right'/'justify',见面板二
series.layoutIterations32节点垂直位置的松弛次数;0 关掉;数据里有 0 值节点时被强制成 0
series.levels[]分层样式,每项必须有 depth;边的样式跟着它 source 节点的深度
series.draggabletrue拖动结果以 localX/localY 写回 series.data
series.roamfalse6.x 新增;可写 true/'move'/'scale'
series.zoom1漫游后的倍数,会被写回 option
series.centernull漫游后的中心,同样是 6.x 的键
series.lineStyle.opacity0.2弦的透明度;color 可写 'source'/'gradient'
series.emphasis.focus未写sankey 缺省不聚焦;'trajectory' 沿上下游整条路径
series.left'5%'right 缺省 '20%',是留给右侧标签的

funnel

键缺省说明
series.data[]{ name, value };value 映射成宽度(横版是高度)
series.sort'descending''ascending' 连整叠梯形一起翻;'none' 保持数据顺序
series.orient'vertical''horizontal' 时 funnelAlign 的合法取值换一套
series.gap0两格之间的缝,从总高里扣,每格高度 = (总高 − 缝)/(格数)
series.min数据最小值与 0 的较小者映射的输入下限,不是显示下限
series.max数据最大值映射的输入上限;超出它不裁剪,只夹到 maxSize
series.minSize'0%'映射的输出下限,按可用宽/高的百分比
series.maxSize'100%'映射的输出上限
series.funnelAlign'center'竖版 left/center/right;横版 top/center/bottom
series.left80缺省边距比想象中大:right:80、top:60、bottom:65
series.label.position'outer'标签在形外,配 labelLine.length:20
series.itemStyle.borderWidth1缺省白色描边 1 px,做无缝漏斗要显式写 0

竖版写 funnelAlign:'top'、横版写 'left' 都不会报错,但那一格会整块消失——见【常见坑】。

gauge

键缺省说明
series.data[]每项 { value, name };放两项就画两根指针
series.startAngle225option 角:0° 指 3 点、逆时针为正
series.endAngle-45与 startAngle 之差就是扫过角度,clockwise 决定方向
series.min0量程下端
series.max100量程上端;axisLine 的分段比例按它算
series.splitNumber10轴段数,切的是量程不是角度
series.axisLine.lineStyle.width10背景轴厚度
series.axisLine.lineStyle.color[[1,'#eee']]累计百分比分段上色
series.axisLine.roundCapfalse轴端头圆角,和 progress.roundCap 是两个键
series.progress.showfalse打开后 detail 的颜色缺省跟随进度弧
series.progress.width10与轴宽独立,可不等
series.progress.roundCapfalsetrue 时元素类型从 sector 变成 sausage
series.progress.itemStyle.color—写 'auto' 取当前值所在段的轴色(6.1 起)
series.pointer.length'60%'相对半径;width 缺省 6,offsetCenter 可整体偏移
series.anchor.showfalse圆心标,size 缺省 6、icon 缺省 'circle'
series.axisTick.splitNumber5每个 splitNumber 段内的小刻度数
series.detail.offsetCenter[0,'40%']数值文本;formatter 里 {value} 或回调
series.title.offsetCenter[0,'20%']文本取 data[i].name
series.detail.valueAnimationfalse数字滚动动画;title 下另有同名键

chord

键缺省说明
series.nodes[]也可写 series.data(ChordSeries.js:70-71)
series.links[]也可写 series.edges;每条边的 value 在两端各记一次
series.center['50%','50%']—
series.radius['70%','80%']内半径是弦汇聚处,外半径是节点环外缘
series.startAngle90第一个节点从这里开始排
series.endAngle'auto''auto' 补齐一整圈
series.clockwisetrue节点排列方向
series.padAngle3每个节点占一份空档,可用角 = 总角 − padAngle × 节点数
series.minAngle0小于它就向有余量的节点借角度,代价是破坏弧角比例
series.itemStyle.borderRadius[0,0,5,5]节点环的外侧两角
series.lineStyle.opacity0.2弦的透明度;width 缺省 0,color 缺省 'source'
series.emphasis.focus'adjacency'弦图缺省就带聚焦
series.label.position'outside'distance 缺省 5

弦图是 6.0 新增的系列。6.0 的 npm 包 package.json 里没有 ./lib/chart/chord 这条导出,按需引入拿不到它;6.1.0 才补上(node_modules/echarts/package.json:177)。完整引入两种版本都没问题。

【常见坑】

  1. 以为 sankey 会替你保证流量守恒。不会。面板一第三张「进店」流入 650、流出 500,ECharts 照画:节点值取「流入、流出、原始值」三者的最大(sankeyLayout.js:79-91),于是节点按 650 画 300 px 高,两条出边只铺满 230.8 px,底部空出 69.2 px。这段空白就是没守恒的证据,但页面不报错、控制台不警告,等于是你在数据里留了个洞。
  2. 拿有环数据画 sankey。source/target 里只要出现一个回路,sankeyLayout.js:152-155 那个无条件 throw 就炸出 Sankey is a DAG, the original data has cycle!——这是生产版行为,不是只在开发版提示。会员等级降级、资金来回转账这类数据天生有环,改用 chord。
  3. levels 里漏写 depth。桑基图的分层样式靠 depth 认领(SankeySeries.js:67-75),漏了就直接抛 levels[i].depth is mandatory and should be natural number。边没有 depth,它跟着 source 节点那一层走(:91-101)。
  4. 数据里混进一个 value:0 的节点,然后奇怪节点顺序为什么不动。sankeyLayout.js:63-66 只要发现 0 值节点就把 layoutIterations 强制归 0,你写的 32 一次都不跑。
  5. 把 localX/localY 当脏数据删掉,或者被它污染下一张图。拖动节点时 SankeyView.js:256-273 会调 setNodePosition(SankeySeries.js:106-110),把相对坐标写回你传进来的那个 option.data 对象。同一份对象再喂给下一张图,上一张的拖位就跟着过去了;本章所有构造器都先 deep() 再交给 ECharts。
  6. 把 sankey 的漫游当 5.x 就有的功能。实测 echarts@5.6.0 的 sankey 目录里 roam 出现 0 次,roam/zoom/center 与 sankeyRoam 动作都是 6.x 新增;反倒是 emphasis.focus:'trajectory' 在 5.6.0 就有。跨版本兼容声明要分开写,别一杆子说「6.x 才有」。
  7. min/max 当显示范围用。面板七实测:给 12000 量级的数据写 max:1000,linearMap(..., true) 把超出量程的值夹到 maxSize,五格变成「390、390、390、390、236」的一摞方柱。它改的是映射输入区间;要控制显示尺寸请用 minSize/maxSize(面板六第二张 minSize:'20%' 就是把最小的那格抬到 55.2 px)。
  8. 拿 params.percent 标转化率。它是「本项 ÷ 所有项之和」(FunnelSeries.js:88-92),面板七左图给的是 55.36/24.91/9.96/6.98/2.79,不是 45%/40%/70%/40%。相邻转化率只能在 formatter 里自己除,且第一项要特判。
  9. 横版漏斗写 funnelAlign:'left'。横版那个 switch 只认 top/center/bottom(funnelLayout.js:266-277),落不进去 y0 保持 undefined,坐标算成 NaN,五格一起消失且零报错。竖版写 top/bottom 同理(:282-292 只认 left/center/right)。
  10. 以为最窄的那一格就是最小值。最后一格的底宽按「下一项 = 不存在的第 6 项」映射,也就是按 0 算(funnelLayout.js:308),minSize:'0%' 时永远是 0。读漏斗要比上宽,别把最下面那条尖当成「归零」。
  11. 两期对比漏斗不共用 min/max 与 sort。sort 一栏写成缺省 descending,两期各自的顺序各排各的;max 不一致则同一数值两边不同宽。面板七右图那条「宽比 = 值比 = 1.15」只在两边参数完全一致时成立。
  12. 自己从 pointer.rotation 反推仪表盘角度时丢了符号。GaugeView.js:100-101 把 option 角度取负转弧度,:331 又是 -(angle + π/2),所以反推是 deg(rotation) + 90。本章最初写 -deg(rotation) - 90,值 72 报成 329.4°,正好差一个镜像。
  13. 把 axisLine.lineStyle.color 的分段当角度。那三对比例切的是量程(面板八实测 162°/67.5°/40.5° 就是 270°×0.6/0.25/0.15);改 min/max 会挪动分界,改 startAngle 不会。达标率这类「越高越好」的指标要把颜色顺序倒过来写。
  14. 开了 progress 却没固定 detail.color。progress.show 为真时数值文本的颜色缺省跟随进度弧(GaugeView.js:528),再叠上 color:'auto',值一跨段连数字都跟着变色。
  15. 拿弦图的节点值跟桑基图比大小。弦图把每条边在两端各记一次,节点值合计必然 = 2 × 边值合计(面板十实测 96 = 2×48);同一份流量面板十一给出 30/24/18/12 与 30/20/10/12 两套数,差的全是中转节点。
  16. 用 minAngle 之后还宣称「弧长正比于流量」。chordLayout.js:124-200 的借角度是「谁有余量谁出」,借完 A 158.9°、B 172.1° 的角度比是 0.923,而值比是 1.02。要精确比较就留 minAngle:0。
  17. 按需引入时以为 6.0 也能拿到弦图。6.0 的 npm 包没导出 ./lib/chart/chord(npm view echarts@6.0.0 exports 里没有这一项),6.1.0 才补上;完整包 echarts.min.js 两个版本都能画。
  18. 给状态条的变量起名 status。var status = document.getElementById('status') 在全局作用域会被浏览器早就放在 window 上的字符串属性 status 吃掉,随后 status.textContent = '…' 静默失败,控制台一行都不报。本章的 min.html 就是这么「图画出来了、读数永远是『正在画……』」的;改成 statusEl 立刻好。命名带上元素类型(statusEl、chartBox)能一劳永逸避开这类保留名。

【工程文件】

ch28/ 下三个文件,分工与前面几章一样:

ch28/
├── min.html   ← 最小可跑(76 行):七节点桑基图,file:// 双击可开
├── index.html ← 实验台骨架(163 行):12 个 h2、39 个容器、10 个按钮、12 个黄框
└── app.js     ← 面板脚本(1054 行):12 个面板 39 张图的 option、读数与按钮绑定

12 个面板的图数分配是 3+3+4+3+4+5+2+3+3+4+2+3 = 39:

面板一二三四五六七八九十十一十二合计
图数33434523342339

min.html 是自足的:只引 ../../lib/echarts.min.js,七节点流量写死在页面里,没有 fetch,file:// 双击就能画。index.html 引同一份 ECharts 加 app.js;app.js 启动时先把面板一至十一和面板十二的弦图、漏斗、空仪表盘全画好,最后才去读 data/store-metrics.json 里 S02 那行的真实销售额并重设仪表盘。这也是实验台必须走本地服务器的原因:file:// 下浏览器会拦掉这次读取,状态条变成「真实数据读取失败」,右图停在 0。

黄框的读数有一条固定的管线。所有图都是 echarts.init(el, null, {renderer:'svg'});sankey 与 chord 的节点值、厚度、弧角来自 series.getGraph() 上每个节点/边的 getLayout()——这是布局算完直接挂在图结构上的结果,比从图形元素反推稳;funnel 的四个顶点来自 getData().getItemLayout(i),宽度用 points[1][0] - points[0][0];gauge 的指针角、轴分段角、进度弧分别来自显示列表里的 pointer、sector、sausage 元素,文字取 tspan 的 style.text;面板四那些「被调暗」的节点与边,则是按图形元素的 style.opacity 数出来的——节点低于 0.5、边低于 0.05 各算一个。这些都不是公开 API,版本一换就可能变,生产代码不要照抄。bind() 在执行按钮动作后等 400 ms 统一重读所有面板,所以按钮自己的提示可能被后续读数覆盖;正文里的数都按「按下后等一秒」的稳态抄。

本章有三处读数必须靠按钮现场触发才拿得到,正文标注了对应动作:面板一用 setOption 塞一条成环的边,捕获并原样打印那句 DAG 错误;面板三发 dragNode 动作与 sankeyRoam 事件,读被写回的 localX/localY 和 zoom/center;面板八把同一个值在 45/72/92 之间循环,看 color:'auto' 如何跨段换色。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>第 28 章 流程与指标实验台</title>
  <style>
    body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; max-width: 1120px; }
    h1 { font-size: 20px; margin: 0 0 6px; }
    h2 { font-size: 15px; margin: 30px 0 6px; border-left: 4px solid #3d5a80; padding-left: 8px; }
    #status { background: #eef5fb; border-left: 4px solid #3d7ab8; padding: 8px 12px; margin: 10px 0 14px;
              white-space: pre-wrap; }
    .readout { background: #fff8ea; border-left: 4px solid #b6902f; padding: 8px 12px; margin: 8px 0 0;
               font: 13px/1.7 Consolas, monospace; min-height: 3.4em; white-space: pre-wrap; }
    button { padding: 6px 12px; margin: 0 8px 6px 0; cursor: pointer; }
    .chart { border: 1px solid #d9d2c7; background: #fff; display: inline-block; vertical-align: top; }
    .lab { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
    .hint { font-size: 12px; color: #6f675c; margin: 4px 0 0; }
    code { background: #f1ece3; padding: 0 3px; }
    .wide { overflow-x: auto; }
  </style>
</head>
<body>
  <h1>流程与指标实验台:sankey、funnel、gauge、chord</h1>
  <div id="status">正在读取门店销售数据……</div>

  <h2>面板一 · sankey 的节点与流量守恒</h2>
  <div class="wide"><div class="lab">
    <div><div id="p1a" class="chart" style="width:440px;height:340px;"></div><p class="hint">横向,守恒的十节点流量</p></div>
    <div><div id="p1b" class="chart" style="width:440px;height:340px;"></div><p class="hint">orient:'vertical'</p></div>
    <div><div id="p1c" class="chart" style="width:300px;height:340px;"></div><p class="hint">进店:流入 650、流出 500</p></div>
  </div></div>
  <p>
    <button id="b1a">① 现场试一条成环的边</button>
    <button id="b1b">② 复位面板一</button>
  </p>
  <p class="hint">左边两张是同一份「渠道 → 进店 → 购买 → 去向」的流量。第三张故意让「进店」的入边 650、出边只有 500:ECharts 不报错,节点按 650 画高,出边只铺满 500,底部留一段空白。按①会往第三张塞一条成环的边,看它抛什么错。</p>
  <p id="p1Info" class="readout"></p>

  <h2>面板二 · nodeAlign 的三种对齐</h2>
  <div class="lab">
    <div><div id="p2a" class="chart" style="width:320px;height:340px;"></div><p class="hint">nodeAlign:'left'</p></div>
    <div><div id="p2b" class="chart" style="width:320px;height:340px;"></div><p class="hint">nodeAlign:'justify'(缺省)</p></div>
    <div><div id="p2c" class="chart" style="width:320px;height:340px;"></div><p class="hint">nodeAlign:'right'</p></div>
  </div>
  <p class="hint">同一份六节点流量,只改 <code>nodeAlign</code>。「广告投放」有一只脚直接走到「直接下单」,另有一只脚绕了两跳。黄框报每个节点落在第几层:<code>left</code> 按拓扑深度从左排,<code>justify</code> 把所有汇点推到最右,<code>right</code> 按「离汇点还有几跳」从右往左排。</p>
  <p id="p2Info" class="readout"></p>

  <h2>面板三 · nodeWidth、nodeGap、layoutIterations、levels 与漫游</h2>
  <div class="lab">
    <div><div id="p3a" class="chart" style="width:300px;height:330px;"></div><p class="hint">基准</p></div>
    <div><div id="p3b" class="chart" style="width:300px;height:330px;"></div><p class="hint">nodeWidth:8、nodeGap:24</p></div>
    <div><div id="p3c" class="chart" style="width:300px;height:330px;"></div><p class="hint">layoutIterations:0</p></div>
    <div><div id="p3d" class="chart" style="width:300px;height:330px;"></div><p class="hint">levels 按深度染色</p></div>
  </div>
  <p>
    <button id="b3a">① 拖动「进店」</button>
    <button id="b3b">② 漫游放大 1.6 倍</button>
    <button id="b3c">③ 复位基准图</button>
  </p>
  <p class="hint">黄框读节点厚度、同层节点的垂直缝、迭代开关对 y 的影响,以及 <code>levels</code> 给每一层上的颜色。按①用 <code>dragNode</code> 动作把「进店」挪到画布 28% 宽、20% 高的位置;按②发 <code>sankeyRoam</code>,看 <code>zoom</code> 与 <code>center</code> 怎么被改写。</p>
  <p id="p3Info" class="readout"></p>

  <h2>面板四 · emphasis.focus 的三种取景</h2>
  <div class="lab">
    <div><div id="p4a" class="chart" style="width:360px;height:340px;"></div><p class="hint">focus:'adjacency'</p></div>
    <div><div id="p4b" class="chart" style="width:360px;height:340px;"></div><p class="hint">focus:'trajectory'</p></div>
    <div><div id="p4c" class="chart" style="width:360px;height:340px;"></div><p class="hint">focus:'none'</p></div>
  </div>
  <p>
    <button id="b4a">① 高亮「复购」</button>
    <button id="b4b">② 取消高亮</button>
  </p>
  <p class="hint">同一份八节点流量,三张图都高亮最右侧的「复购」。黄框列出每张图被调暗的节点:<code>adjacency</code> 只留一跳邻居,<code>trajectory</code> 留出所有通往它的路径,<code>none</code> 谁也不暗。</p>
  <p id="p4Info" class="readout"></p>

  <h2>面板五 · funnel 的 sort 与 orient</h2>
  <div class="lab">
    <div><div id="p5a" class="chart" style="width:300px;height:330px;"></div><p class="hint">sort:'descending'</p></div>
    <div><div id="p5b" class="chart" style="width:300px;height:330px;"></div><p class="hint">sort:'ascending'</p></div>
    <div><div id="p5c" class="chart" style="width:300px;height:330px;"></div><p class="hint">sort:'none'</p></div>
    <div><div id="p5d" class="chart" style="width:300px;height:330px;"></div><p class="hint">orient:'horizontal'</p></div>
  </div>
  <p class="hint">同一组五级转化数 1000/620/310/180/96。黄框按屏幕上的先后顺序列出每项的上宽(横版是左高):<code>sort</code> 决定谁先谁后,<code>ascending</code> 还会把整个漏斗从下往上翻;最后一项的底宽永远是 0,因为它的「下一项」不存在,数值被当成 0。</p>
  <p id="p5Info" class="readout"></p>

  <h2>面板六 · min/max、minSize/maxSize 与 funnelAlign</h2>
  <div class="lab">
    <div><div id="p6a" class="chart" style="width:300px;height:330px;"></div><p class="hint">min:0、max:1000、minSize:'0%'</p></div>
    <div><div id="p6b" class="chart" style="width:300px;height:330px;"></div><p class="hint">minSize:'20%'</p></div>
    <div><div id="p6c" class="chart" style="width:300px;height:330px;"></div><p class="hint">min:96(不是显示下限)</p></div>
    <div><div id="p6d" class="chart" style="width:300px;height:330px;"></div><p class="hint">funnelAlign:'left'</p></div>
    <div><div id="p6e" class="chart" style="width:300px;height:330px;"></div><p class="hint">横版,funnelAlign 见按钮</p></div>
  </div>
  <p>
    <button id="b6a">① 切换横版对齐 top / center / bottom</button>
  </p>
  <p class="hint">前四张是竖版:<code>min</code>/<code>max</code> 把数值映射到 <code>minSize</code>/<code>maxSize</code>,它们改的是「映射区间」,不是「画多高」。第三张把 <code>min</code> 写成 96,最小的那一项映射到宽度 0,图仍然占满五格。第五张是横版,按①在 <code>top</code>/<code>center</code>/<code>bottom</code> 之间切换;竖版的 <code>left</code>/<code>right</code> 在横版没有意义。</p>
  <p id="p6Info" class="readout"></p>

  <h2>面板七 · 转化率标注与两期对比漏斗</h2>
  <div class="lab">
    <div><div id="p7a" class="chart" style="width:430px;height:380px;"></div><p class="hint">label.formatter 标出相邻转化率</p></div>
    <div><div id="p7b" class="chart" style="width:520px;height:380px;"></div><p class="hint">2024 / 2025 左右并排</p></div>
  </div>
  <p class="hint">左图用 <code>label.formatter</code> 现算「本项 ÷ 上一项」,第一项没有上一项,只写值;它不写 <code>min</code>/<code>max</code>,让 ECharts 自己按数据取。右图两个 series 各占半幅,<code>sort:'none'</code> 把阶段顺序钉住,两个 series 都写同一组 <code>min</code>/<code>max</code>,这样同一数值在两边的宽度才一样。黄框报每一级的转化率、左图五项的宽度,以及两期同级的宽度。</p>
  <p id="p7Info" class="readout"></p>

  <h2>面板八 · gauge 的角度、分段轴与 progress</h2>
  <div class="lab">
    <div><div id="p8a" class="chart" style="width:300px;height:300px;"></div><p class="hint">缺省仪表盘,值 72</p></div>
    <div><div id="p8b" class="chart" style="width:300px;height:300px;"></div><p class="hint">axisLine 分三段</p></div>
    <div><div id="p8c" class="chart" style="width:300px;height:300px;"></div><p class="hint">progress color:'auto' + roundCap</p></div>
  </div>
  <p>
    <button id="b8a">① 把值在 45 / 72 / 92 之间循环</button>
  </p>
  <p class="hint">三张图都从 225° 转到 -45°,共 270°。第二张把 <code>axisLine.lineStyle.color</code> 分成 60%/85%/100% 三段;第三张在此基础上打开 <code>progress</code>,并写 6.1 才有的 <code>color:'auto'</code>。黄框读指针落点、三段轴的角度与 <code>progress</code> 当前吃的颜色,按①看它跨段换色。</p>
  <p id="p8Info" class="readout"></p>

  <h2>面板九 · 半圆、双指针、富文本与极简环</h2>
  <div class="lab">
    <div><div id="p9a" class="chart" style="width:300px;height:300px;"></div><p class="hint">180°→0°,带 anchor</p></div>
    <div><div id="p9b" class="chart" style="width:300px;height:300px;"></div><p class="hint">data 数组里两根指针</p></div>
    <div><div id="p9c" class="chart" style="width:300px;height:300px;"></div><p class="hint">环形进度 + 富文本</p></div>
  </div>
  <p class="hint">左边是量程 0–200 的半圆,指针值 150,<code>anchor</code> 换成实心圆;中间一个 <code>data</code> 数组放两个值,就会画两根指针;右边把指针、刻度、轴标签全部关掉,只留 <code>progress</code>,<code>startAngle:90</code>、<code>endAngle:-270</code> 补成一个整圆,<code>detail.formatter</code> 用富文本拼出百分号。</p>
  <p id="p9Info" class="readout"></p>

  <h2>面板十 · chord 的节点值、padAngle、minAngle 与显式 value</h2>
  <div class="lab">
    <div><div id="p10a" class="chart" style="width:320px;height:320px;"></div><p class="hint">四节点、五条有向弦</p></div>
    <div><div id="p10b" class="chart" style="width:320px;height:320px;"></div><p class="hint">padAngle:12、minAngle:6</p></div>
    <div><div id="p10c" class="chart" style="width:320px;height:320px;"></div><p class="hint">A→B 100、A→C 2、minAngle:20</p></div>
    <div><div id="p10d" class="chart" style="width:320px;height:320px;"></div><p class="hint">A 显式 value:50</p></div>
  </div>
  <p class="hint">第一张的门店 A↔B 互相有来往,这是弦图能画、桑基图会报错的场景。黄框读每个节点的值与弧角:节点值把每条关联弦在两端各算一次,所以边值合计 48、节点值合计 96。第三张只有一个小节点 C,<code>minAngle:20</code> 会从大节点「借」角度;第四张给 A 写了显式 <code>value:50</code>,按大的那个算。</p>
  <p id="p10Info" class="readout"></p>

  <h2>面板十一 · 同一份流量:弦图与桑基图</h2>
  <div class="lab">
    <div><div id="p11a" class="chart" style="width:340px;height:320px;"></div><p class="hint">chord</p></div>
    <div><div id="p11b" class="chart" style="width:340px;height:320px;"></div><p class="hint">sankey</p></div>
  </div>
  <p class="hint">同一份四节点、四条边的有向无环流量。黄框并排列出两边的节点值:弦图给 A/B/C/D 的值是 30/24/18/12,桑基图是 30/20/10/12。差别不是 bug,而是两种图对「节点值」的定义不同——弦图把每条边在两端都记一次,桑基图取「流入、流出、原始值」三者的最大。</p>
  <p id="p11Info" class="readout"></p>

  <h2>面板十二 · 实战:会员流转、转化漏斗与门店达标率</h2>
  <div class="lab">
    <div><div id="p12a" class="chart" style="width:420px;height:360px;"></div><p class="hint">会员等级流转(示例数据)</p></div>
    <div><div id="p12b" class="chart" style="width:420px;height:360px;"></div><p class="hint">2025 转化漏斗(示例数据)</p></div>
    <div><div id="p12c" class="chart" style="width:300px;height:300px;"></div><p class="hint">国贸店 2025 达标率(真实数据)</p></div>
  </div>
  <p>
    <button id="b12a">① 重新读数</button>
  </p>
  <p class="hint">左边是会员在五个等级之间的流转,中间是从曝光到复购的转化漏斗,右边读 <code>data/store-metrics.json</code> 里国贸店 2025 年的真实销售额,拿 600 万作目标算完成率。黄框读数:弦图各等级的节点值、漏斗每级转化率、仪表盘的实际值与百分比。</p>
  <p id="p12Info" class="readout"></p>

  <script src="../../lib/echarts.min.js"></script>
  <script src="app.js"></script>
</body>
</html>
// 第 28 章实验台:12 个面板,39 张图。
// 面板一至十一的数据都写在代码里;面板十二的仪表盘读 data/store-metrics.json 的
// 真实销售额(国贸店 2025 年 ¥5,816,552),另两张是示例数据。
// 黄框里的数字全部现场读取:sankey 的节点值、厚度与边厚度来自 graph.getLayout(),
// funnel 的四个顶点来自 data.getItemLayout(),gauge 的弧角与颜色来自 displayList,
// chord 的节点值、弧角、弦的两端角来自 graph.getLayout() 与元素 shape。
// 门禁沙箱里没有真 DOM 也没有布局,读不到就打印占位——那里只负责验证图能渲染。
var statusEl = document.getElementById('status');

function $(id) { return document.getElementById(id); }
function any(o) { return /** @type {any} */ (o); }
function deep(o) { return JSON.parse(JSON.stringify(o)); }
function getJSON(url) {
  return fetch(url).then(function (r) {
    if (!r.ok) throw new Error(r.status + ' ' + url);
    return r.json();
  });
}
function safe(fn, fallback) { try { return fn(); } catch (e) { return fallback; } }
function later(fn, ms) { return setTimeout(fn, ms || 80); }
function setStatus(text) { if (statusEl) statusEl.textContent = text; }
function setInfo(id, rows) { var el = $(id); if (el) el.textContent = [].concat(rows).join('\n'); }
function bind(id, fn) {
  var el = $(id);
  if (!el) return;
  el.onclick = function () {
    fn();
    later(readEveryPanel, 400);
  };
}
function r0(v) { return typeof v === 'number' && isFinite(v) ? String(Math.round(v)) : '—'; }
function r1(v) { return typeof v === 'number' && isFinite(v) ? String(Math.round(v * 10) / 10) : '—'; }
function r2(v) { return typeof v === 'number' && isFinite(v) ? String(Math.round(v * 100) / 100) : '—'; }
function pct(v) { return typeof v === 'number' && isFinite(v) ? (v <= 1 ? v * 100 : v) : NaN; }
function pctText(v, digits) {
  var p = pct(v);
  if (!isFinite(p)) return '—';
  return p.toFixed(digits == null ? 1 : digits) + '%';
}
function money(v) {
  if (typeof v !== 'number' || !isFinite(v)) return '—';
  return '¥' + Math.round(v).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function deg(rad) { return rad * 180 / Math.PI; }
function mk(id) {
  var el = $(id);
  if (!el) return null;
  return echarts.getInstanceByDom(el) || echarts.init(el, null, { renderer: 'svg' });
}

var charts = {};
function render(id, option) {
  var c = mk(id);
  if (!c) return null;
  charts[id] = c;
  c.setOption(any(option), true);
  return c;
}
function seriesOfIndex(id, idx) {
  var c = charts[id];
  if (!c || !c.getModel) return null;
  var model = safe(function () { return c.getModel(); }, null);
  if (!model || !model.getSeriesByIndex) return null;
  return safe(function () { return model.getSeriesByIndex(idx); }, null);
}
function seriesOf(id) { return seriesOfIndex(id, 0); }
function graphOf(id) {
  var s = seriesOf(id);
  if (!s || !s.getGraph) return null;
  return safe(function () { return s.getGraph(); }, null);
}
function nodeOf(id, name) {
  var g = graphOf(id);
  if (!g) return null;
  var found = null;
  g.eachNode(function (n) { if (n.id === name) found = n; });
  return found;
}
function displayOf(id) {
  var c = charts[id];
  if (!c || !c.getZr) return [];
  var zr = safe(function () { return c.getZr(); }, null);
  var storage = zr && zr.storage;
  return storage && storage.getDisplayList ? storage.getDisplayList() : [];
}
function textsOf(id) {
  return displayOf(id).filter(function (e) { return e.type === 'tspan' && e.style && e.style.text; })
    .map(function (e) { return String(e.style.text); });
}
function sumEdgeValue(edges) {
  var sum = 0;
  for (var i = 0; i < edges.length; i++) sum += +(edges[i].getValue('value') || 0);
  return sum;
}
function sumEdgeLayout(edges, key) {
  var sum = 0;
  for (var i = 0; i < edges.length; i++) {
    var l = edges[i].getLayout();
    sum += +(l && l[key] || 0);
  }
  return sum;
}
// 第 depth 列的节点按屏幕位置排序后,相邻两块之间最小的那段缝(横版量 y,纵版量 x)
function columnGap(id, depth) {
  var g = graphOf(id);
  if (!g) return null;
  var s = seriesOf(id);
  var vertical = s && s.get('orient') === 'vertical';
  var list = [];
  g.eachNode(function (n) {
    var l = n.getLayout();
    if (l && l.depth === depth) list.push(l);
  });
  if (list.length < 2) return null;
  list.sort(function (m, n) { return (vertical ? m.x : m.y) - (vertical ? n.x : n.y); });
  var min = Infinity;
  for (var i = 1; i < list.length; i++) {
    var prev = list[i - 1], cur = list[i];
    var gap = (vertical ? cur.x : cur.y) - ((vertical ? prev.x + prev.dx : prev.y + prev.dy));
    if (gap < min) min = gap;
  }
  return { count: list.length, minGap: min };
}

/* ===== 数据 ===== */
// 面板一/三:十节点、十二边的渠道—购买—去向流量,每个中间节点进出相等
var FLOW = {
  nodes: [{ name: '广告投放' }, { name: '短信召回' }, { name: '自然搜索' }, { name: '门店海报' },
    { name: '进店' }, { name: '小程序' }, { name: '首单' }, { name: '复购' }, { name: '流失' }, { name: '私域社群' }],
  links: [
    { source: '广告投放', target: '进店', value: 120 },
    { source: '短信召回', target: '进店', value: 60 },
    { source: '自然搜索', target: '进店', value: 90 },
    { source: '门店海报', target: '进店', value: 30 },
    { source: '进店', target: '小程序', value: 200 },
    { source: '进店', target: '首单', value: 100 },
    { source: '小程序', target: '首单', value: 140 },
    { source: '小程序', target: '流失', value: 60 },
    { source: '首单', target: '复购', value: 180 },
    { source: '首单', target: '流失', value: 60 },
    { source: '复购', target: '私域社群', value: 120 },
    { source: '复购', target: '流失', value: 60 }
  ]
};
// 面板一:进店流入 650、流出 500,用来观察守恒不被强制
var MISMATCH = {
  nodes: [{ name: '广告投放' }, { name: '短信召回' }, { name: '进店' }, { name: '首单' }, { name: '流失' }],
  links: [
    { source: '广告投放', target: '进店', value: 400 },
    { source: '短信召回', target: '进店', value: 250 },
    { source: '进店', target: '首单', value: 300 },
    { source: '进店', target: '流失', value: 200 }
  ]
};
// 面板二:三种 nodeAlign 会给出三套不同深度
var ALIGN = {
  nodes: [{ name: '广告投放' }, { name: '短信召回' }, { name: '公众号' },
    { name: '直接下单' }, { name: '私域社群' }, { name: '成交' }],
  links: [
    { source: '广告投放', target: '直接下单', value: 20 },
    { source: '广告投放', target: '短信召回', value: 30 },
    { source: '短信召回', target: '成交', value: 30 },
    { source: '广告投放', target: '公众号', value: 50 },
    { source: '公众号', target: '私域社群', value: 50 },
    { source: '私域社群', target: '成交', value: 50 }
  ]
};
// 面板四:八个节点、七条边,高亮汇点「复购」区分三种 focus
var FOCUS = {
  nodes: [{ name: '广告投放' }, { name: '线下地推' }, { name: '自然流量' }, { name: '进店' },
    { name: '只逛不买' }, { name: '首单' }, { name: '复购' }, { name: '单次客' }],
  links: [
    { source: '广告投放', target: '进店', value: 200 },
    { source: '线下地推', target: '进店', value: 120 },
    { source: '自然流量', target: '进店', value: 80 },
    { source: '进店', target: '只逛不买', value: 100 },
    { source: '进店', target: '首单', value: 300 },
    { source: '首单', target: '复购', value: 180 },
    { source: '首单', target: '单次客', value: 120 }
  ]
};
// 面板五/六/七:五级转化,合计 1000 起
var FDATA = [
  { name: '曝光', value: 1000 }, { name: '进店', value: 620 }, { name: '下单', value: 310 },
  { name: '首单', value: 180 }, { name: '复购', value: 96 }
];
// 面板七:2024/2025 两期,阶段顺序相同
var FDATA24 = [
  { name: '曝光', value: 900 }, { name: '进店', value: 540 }, { name: '下单', value: 260 },
  { name: '首单', value: 150 }, { name: '复购', value: 80 }
];
// 面板十/十一:四节点、五条有向弦,A 与 B 互相有来往
var CH = {
  nodes: [{ name: 'A' }, { name: 'B' }, { name: 'C' }, { name: 'D' }],
  links: [
    { source: 'A', target: 'B', value: 20 },
    { source: 'B', target: 'A', value: 6 },
    { source: 'A', target: 'C', value: 10 },
    { source: 'C', target: 'D', value: 8 },
    { source: 'B', target: 'D', value: 4 }
  ]
};
// 面板十:小节点 C 只有 2,看 minAngle 从哪里借角度
var BORROW = {
  nodes: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
  links: [
    { source: 'A', target: 'B', value: 100 },
    { source: 'A', target: 'C', value: 2 }
  ]
};
// 面板十:给 A 显式 value:50,看它压过由边算出的 20
var EXPLICIT = {
  nodes: [{ name: 'A', value: 50 }, { name: 'B' }, { name: 'C' }],
  links: [
    { source: 'A', target: 'B', value: 10 },
    { source: 'A', target: 'C', value: 10 }
  ]
};
// 面板十一:同一份无环流量,两种图各画一次
var DAG = {
  nodes: [{ name: 'A' }, { name: 'B' }, { name: 'C' }, { name: 'D' }],
  links: [
    { source: 'A', target: 'B', value: 20 },
    { source: 'A', target: 'C', value: 10 },
    { source: 'B', target: 'D', value: 4 },
    { source: 'C', target: 'D', value: 8 }
  ]
};
// 面板十二:会员等级流转(示例),黑卡→金卡是降级
var MEMBER = {
  nodes: [{ name: '新客' }, { name: '银卡' }, { name: '金卡' }, { name: '黑卡' }, { name: '流失' }],
  links: [
    { source: '新客', target: '银卡', value: 420 },
    { source: '新客', target: '流失', value: 180 },
    { source: '银卡', target: '金卡', value: 160 },
    { source: '银卡', target: '流失', value: 60 },
    { source: '金卡', target: '黑卡', value: 48 },
    { source: '金卡', target: '流失', value: 22 },
    { source: '黑卡', target: '流失', value: 8 },
    { source: '黑卡', target: '金卡', value: 6 }
  ]
};
// 面板十二:转化漏斗(示例)
var CONV = [
  { name: '曝光', value: 12000 }, { name: '进店', value: 5400 }, { name: '下单', value: 2160 },
  { name: '首单', value: 1512 }, { name: '复购', value: 605 }
];
var TARGET = 6000000;

/* ===== 四种图的 option 骨架 ===== */
function sankeyOpt(ds, extra) {
  var s = {
    type: 'sankey',
    animation: false,
    left: 10, right: 64, top: 16, bottom: 16,
    nodeWidth: 20, nodeGap: 8, nodeAlign: 'justify', layoutIterations: 32,
    draggable: true,
    data: deep(ds.nodes), links: deep(ds.links),
    label: { fontSize: 9, color: '#3f3a33' },
    lineStyle: { color: 'source', opacity: 0.22, curveness: 0.5 },
    emphasis: { focus: 'adjacency' }
  };
  if (extra) for (var k in extra) s[k] = extra[k];
  return any({ series: [s] });
}

function funnelOpt(extra) {
  var s = {
    type: 'funnel',
    animation: false,
    left: 12, right: 12, top: 24, bottom: 16,
    min: 0, max: 1000, minSize: '0%', maxSize: '100%',
    sort: 'descending', orient: 'vertical', gap: 0, funnelAlign: 'center',
    label: { show: true, position: 'inside', fontSize: 10, color: '#fff' },
    itemStyle: { borderWidth: 0 },
    data: deep(FDATA)
  };
  if (extra) for (var k in extra) s[k] = extra[k];
  return any({ series: [s] });
}

function gaugeBase(extra) {
  var s = {
    type: 'gauge',
    animation: false,
    center: ['50%', '56%'],
    radius: '82%',
    min: 0, max: 100, splitNumber: 10,
    axisLine: { lineStyle: { width: 10, color: [[1, '#e9e4d9']] } },
    splitLine: { length: 7, distance: 5, lineStyle: { width: 1.5, color: '#b9ae9c' } },
    axisTick: { show: false },
    axisLabel: { show: true, distance: 14, fontSize: 9, color: '#6f675c' },
    pointer: { width: 4, length: '58%', itemStyle: { color: '#3d5a80' } },
    anchor: { show: true, size: 9, itemStyle: { color: '#3d5a80' } },
    title: { show: true, offsetCenter: [0, '64%'], fontSize: 11, color: '#6f675c' },
    detail: { show: true, offsetCenter: [0, '32%'], fontSize: 24, color: '#3d5a80' },
    data: [{ value: 72, name: '完成率' }]
  };
  if (extra) for (var k in extra) s[k] = extra[k];
  return s;
}

function chordOpt(ds, extra, alias) {
  var s = {
    type: 'chord',
    animation: false,
    left: 8, right: 8, top: 8, bottom: 8,
    radius: ['64%', '78%'],
    center: ['50%', '50%'],
    startAngle: 90,
    padAngle: 3,
    minAngle: 0,
    label: { show: true, fontSize: 10, color: '#4a453d' },
    itemStyle: { borderRadius: [0, 0, 4, 4] },
    lineStyle: { color: 'source', opacity: 0.28, width: 0 }
  };
  if (alias) {
    s.data = deep(ds.nodes);
    s.edges = deep(ds.links);
  } else {
    s.nodes = deep(ds.nodes);
    s.links = deep(ds.links);
  }
  if (extra) for (var k in extra) s[k] = extra[k];
  return any({ series: [s] });
}

/* ===== 面板一 ===== */
var cycleMsg = '(按①现场试)';
function p1() {
  render('p1a', sankeyOpt(FLOW));
  render('p1b', sankeyOpt(FLOW, { orient: 'vertical' }));
  render('p1c', sankeyOpt(MISMATCH, { right: 74 }));
}
function tryCycle() {
  var c = charts.p1c;
  if (!c) return;
  var cycle = {
    series: [{
      type: 'sankey', animation: false, left: 10, right: 74, top: 16, bottom: 16,
      data: [{ name: 'A' }, { name: 'B' }, { name: 'C' }],
      links: [
        { source: 'A', target: 'B', value: 10 },
        { source: 'B', target: 'C', value: 10 },
        { source: 'C', target: 'A', value: 10 }
      ]
    }]
  };
  try {
    c.setOption(any(cycle), true);
    cycleMsg = '没有报错——不应该发生';
  } catch (e) {
    cycleMsg = 'setOption 抛出:' + e.message;
  }
  render('p1c', sankeyOpt(MISMATCH, { right: 74 }));
}
function readP1() {
  var lines = [];
  var g = graphOf('p1a');
  if (g) {
    var nc = 0, ec = 0;
    g.eachNode(function () { nc++; });
    g.eachEdge(function () { ec++; });
    var n = nodeOf('p1a', '进店');
    if (n) {
      var l = n.getLayout();
      var outH = sumEdgeLayout(n.outEdges, 'dy');
      lines.push('p1a 横向:' + nc + ' 节点 / ' + ec + ' 边;「进店」入 300、出 300,节点高 ' +
        r1(l.dy) + ' px,出边铺满 ' + r1(outH) + ' px,空 ' + r1(l.dy - outH) + ' px');
    }
    var gb = graphOf('p1b');
    if (gb) {
      var nb = nodeOf('p1b', '进店');
      if (nb) {
        var lb = nb.getLayout();
        lines.push('p1b 纵向:节点厚度换成 dx = ' + r1(lb.dx) + ' px,深度方向沿 y 轴');
      }
    }
  }
  var gc = graphOf('p1c');
  if (gc) {
    var m = nodeOf('p1c', '进店');
    if (m) {
      var lm = m.getLayout();
      var outM = sumEdgeLayout(m.outEdges, 'dy');
      var inSum = sumEdgeValue(m.inEdges);
      var outSum = sumEdgeValue(m.outEdges);
      lines.push('p1c 失配:入 ' + r0(inSum) + '、出 ' + r0(outSum) + ',节点按 ' + r0(lm.value) +
        ' 画成 ' + r1(lm.dy) + ' px 高,出边只占 ' + r1(outM) + ' px,底部空 ' + r1(lm.dy - outM) + ' px');
    }
  }
  lines.push('① 环检测:' + cycleMsg);
  setInfo('p1Info', lines);
}

/* ===== 面板二 ===== */
function p2() {
  render('p2a', sankeyOpt(ALIGN, { nodeAlign: 'left', right: 70 }));
  render('p2b', sankeyOpt(ALIGN, { nodeAlign: 'justify', right: 70 }));
  render('p2c', sankeyOpt(ALIGN, { nodeAlign: 'right', right: 70 }));
}
function alignRead(id) {
  var g = graphOf(id);
  if (!g) return '—';
  var parts = [];
  g.eachNode(function (n) {
    var l = n.getLayout();
    if (n.id !== '广告投放') parts.push(n.id + ' d' + l.depth);
  });
  return parts.join(',');
}
function readP2() {
  setInfo('p2Info', [
    'left:' + alignRead('p2a'),
    'justify:' + alignRead('p2b'),
    'right:' + alignRead('p2c')
  ]);
}

/* ===== 面板三 ===== */
function p3() {
  render('p3a', sankeyOpt(FLOW));
  render('p3b', sankeyOpt(FLOW, { nodeWidth: 8, nodeGap: 24 }));
  render('p3c', sankeyOpt(FLOW, { layoutIterations: 0 }));
  render('p3d', sankeyOpt(FLOW, {
    levels: [
      { depth: 0, itemStyle: { color: '#3d5a80' } },
      { depth: 1, itemStyle: { color: '#7a9e7e' } },
      { depth: 2, itemStyle: { color: '#c98b4b' } },
      { depth: 3, itemStyle: { color: '#b04a3a' } },
      { depth: 4, itemStyle: { color: '#8a5a2b' } },
      { depth: 5, itemStyle: { color: '#5b6f8a' } }
    ]
  }));
}
function nodeColor(id, name) {
  var s = seriesOf(id);
  if (!s) return '—';
  var g = graphOf(id);
  if (!g) return '—';
  var n = nodeOf(id, name);
  if (!n) return '—';
  return safe(function () { return n.getVisual('color'); }, '—');
}
function p3Drag() {
  var c = charts.p3a;
  var s = seriesOf('p3a');
  if (!c || !s) return;
  var n = nodeOf('p3a', '进店');
  if (!n) return;
  c.dispatchAction(any({ type: 'dragNode', seriesId: s.id, dataIndex: n.dataIndex, localX: 0.28, localY: 0.2 }));
}
function p3Roam() {
  var c = charts.p3a;
  var s = seriesOf('p3a');
  if (!c || !s) return;
  c.dispatchAction(any({ type: 'sankeyRoam', seriesId: s.id, zoom: 1.6, originX: 150, originY: 165 }));
}
function readP3() {
  var lines = [];
  var a = nodeOf('p3a', '广告投放');
  var s = seriesOf('p3a');
  var ga = columnGap('p3a', 0);
  if (a && ga) {
    var la = a.getLayout();
    lines.push('p3a 基准:nodeWidth = ' + r1(la.dx) + ' px,第 0 列 ' + ga.count + ' 块,最小相邻缝 = ' +
      r1(ga.minGap) + ' px(nodeGap = 8),「广告投放」高 ' + r1(la.dy) + ' px');
  }
  var a2 = nodeOf('p3b', '广告投放');
  var gb = columnGap('p3b', 0);
  if (a2 && gb) {
    var l2a = a2.getLayout();
    lines.push('p3b nodeWidth:8、nodeGap:24:厚度 ' + r1(l2a.dx) + ' px,第 0 列 ' + gb.count +
      ' 块,最小相邻缝 = ' + r1(gb.minGap) + ' px,「广告投放」高 ' + r1(l2a.dy) + ' px');
  }
  var c2 = nodeOf('p3c', '小程序');
  var c3 = nodeOf('p3c', '首单');
  var a3 = nodeOf('p3a', '小程序');
  var b3 = nodeOf('p3a', '首单');
  if (c2 && c3 && a3 && b3) {
    lines.push('迭代 32→0:「小程序」y ' + r1(a3.getLayout().y) + '→' + r1(c2.getLayout().y) +
      ',「首单」y ' + r1(b3.getLayout().y) + '→' + r1(c3.getLayout().y));
  }
  lines.push('levels 深度 1/3/5 的颜色:' + nodeColor('p3d', '进店') + ' / ' +
    nodeColor('p3d', '首单') + ' / ' + nodeColor('p3d', '流失'));
  if (s) {
    var n = nodeOf('p3a', '进店');
    var localX = '—', localY = '—', shapeX = '—', shapeY = '—';
    if (n) {
      var m = safe(function () { return s.getData().getItemModel(n.dataIndex); }, null);
      if (m) {
        localX = m.get('localX');
        localY = m.get('localY');
      }
      var el = safe(function () { return s.getData().getItemGraphicEl(n.dataIndex); }, null);
      if (el && el.shape) { shapeX = el.shape.x; shapeY = el.shape.y; }
    }
    lines.push('交互后:「进店」localX = ' + (localX == null ? 'null' : r2(localX)) +
      '、localY = ' + (localY == null ? 'null' : r2(localY)) +
      ',矩形 x = ' + r1(shapeX) + '、y = ' + r1(shapeY));
    var center = s.get('center');
    lines.push('漫游:zoom = ' + (s.get('zoom') == null ? 'null' : r1(s.get('zoom'))) +
      ',center = ' + (center == null ? 'null' : JSON.stringify(any(center).map(function (v) { return Math.round(v * 10) / 10; }))));
  }
  setInfo('p3Info', lines);
}

/* ===== 面板四 ===== */
function p4() {
  render('p4a', sankeyOpt(FOCUS, { emphasis: { focus: 'adjacency' } }));
  render('p4b', sankeyOpt(FOCUS, { emphasis: { focus: 'trajectory' } }));
  render('p4c', sankeyOpt(FOCUS, { emphasis: { focus: 'none' } }));
}
function p4Highlight(on) {
  ['p4a', 'p4b', 'p4c'].forEach(function (id) {
    var c = charts[id];
    var s = seriesOf(id);
    if (!c || !s) return;
    var n = nodeOf(id, '复购');
    if (!n) return;
    c.dispatchAction(any({ type: on ? 'highlight' : 'downplay', seriesId: s.id, dataIndex: n.dataIndex }));
  });
}
function focusRead(id) {
  var s = seriesOf(id);
  if (!s || !s.getData) return '—';
  var dim = [];
  var nd = s.getData();
  nd.each(function (i) {
    var el = safe(function () { return nd.getItemGraphicEl(i); }, null);
    var op = el && el.style ? el.style.opacity : 1;
    if (op < 0.5) dim.push(nd.getName(i) || ('#' + i));
  });
  var dimEdges = 0;
  var ed = safe(function () { return s.getEdgeData(); }, null);
  if (ed) {
    ed.each(function (i) {
      var el = safe(function () { return ed.getItemGraphicEl(i); }, null);
      var op = el && el.style ? el.style.opacity : 1;
      if (op < 0.05) dimEdges++;
    });
  }
  return '暗节点 ' + dim.length + (dim.length ? '(' + dim.join('、') + ')' : '') + ',暗边 ' + dimEdges + ' 条';
}
function readP4() {
  setInfo('p4Info', [
    'adjacency:' + focusRead('p4a'),
    'trajectory:' + focusRead('p4b'),
    'none:' + focusRead('p4c')
  ]);
}

/* ===== 面板五 ===== */
function p5() {
  render('p5a', funnelOpt({ sort: 'descending' }));
  render('p5b', funnelOpt({ sort: 'ascending' }));
  render('p5c', funnelOpt({ sort: 'none' }));
  render('p5d', funnelOpt({ orient: 'horizontal' }));
}
function funnelItems(id) {
  var s = seriesOf(id);
  if (!s || !s.getData) return null;
  var d = s.getData();
  var arr = [];
  d.each(function (i) {
    var l = safe(function () { return d.getItemLayout(i); }, null);
    if (!l || !l.points || l.points.length < 4) return;
    var p = l.points;
    if (s.get('orient') === 'horizontal') {
      arr.push({ name: d.getName(i), a: p[1][1] - p[0][1], b: p[2][1] - p[3][1], order: p[0][0], x: p[0][0], y: p[0][1] });
    } else {
      arr.push({ name: d.getName(i), a: p[1][0] - p[0][0], b: p[2][0] - p[3][0], order: p[0][1], x: p[0][0], y: p[0][1] });
    }
  });
  arr.sort(function (m, n) { return m.order - n.order; });
  return { items: arr, horizontal: s.get('orient') === 'horizontal' };
}
function funnelSeq(id) {
  var f = funnelItems(id);
  if (!f) return '—';
  return f.items.map(function (it) { return it.name + ':' + r1(it.a) + '/' + r1(it.b); }).join(' → ');
}
function readP5() {
  setInfo('p5Info', [
    'descending(上宽/下宽):' + funnelSeq('p5a'),
    'ascending(屏幕从上到下):' + funnelSeq('p5b'),
    'none(数据顺序):' + funnelSeq('p5c'),
    'horizontal(左高/右高):' + funnelSeq('p5d')
  ]);
}

/* ===== 面板六 ===== */
var p6Aligns = ['top', 'center', 'bottom'];
var p6AlignIdx = 0;
function p6() {
  render('p6a', funnelOpt({}));
  render('p6b', funnelOpt({ minSize: '20%' }));
  render('p6c', funnelOpt({ min: 96 }));
  render('p6d', funnelOpt({ funnelAlign: 'left' }));
  render('p6e', funnelOpt({ orient: 'horizontal', funnelAlign: p6Aligns[p6AlignIdx] }));
}
function funnelItemNamed(id, name) {
  var f = funnelItems(id);
  if (!f) return null;
  for (var i = 0; i < f.items.length; i++) if (f.items[i].name === name) return f.items[i];
  return null;
}
function readP6() {
  var lines = [];
  var a1 = funnelItemNamed('p6a', '曝光'), a2 = funnelItemNamed('p6a', '复购');
  if (a1 && a2) lines.push('p6a 缺省:曝光宽 ' + r1(a1.a) + ',复购上宽 ' + r1(a2.a) + ' / 下宽 ' + r1(a2.b) + ',复购 x0 = ' + r1(a2.x));
  var b1 = funnelItemNamed('p6b', '曝光'), b2 = funnelItemNamed('p6b', '复购');
  if (b1 && b2) lines.push('p6b minSize 20%:曝光宽 ' + r1(b1.a) + ',复购上宽 ' + r1(b2.a) + ' / 下宽 ' + r1(b2.b));
  var c1 = funnelItemNamed('p6c', '曝光'), c2 = funnelItemNamed('p6c', '复购');
  if (c1 && c2) lines.push('p6c min:96:曝光宽 ' + r1(c1.a) + ',复购上宽 ' + r1(c2.a) + ' / 下宽 ' + r1(c2.b));
  var d1 = funnelItemNamed('p6d', '曝光'), d2 = funnelItemNamed('p6d', '复购');
  if (d1 && d2) lines.push('p6d funnelAlign left:曝光 x0 = ' + r1(d1.x) + ',复购 x0 = ' + r1(d2.x) + '(缺省中心对齐时复购 x0 见 p6a)');
  var e1 = funnelItemNamed('p6e', '曝光'), e2 = funnelItemNamed('p6e', '复购');
  if (e1 && e2) lines.push('p6e 横版 ' + p6Aligns[p6AlignIdx] + ':曝光 y0 = ' + r1(e1.y) + ',复购 y0 = ' + r1(e2.y) + '(高度 ' + r1(e2.a) + ')');
  setInfo('p6Info', lines);
}

/* ===== 面板七 ===== */
function p7() {
  var conv = {
    series: [{
      type: 'funnel', animation: false, left: 20, right: 20, top: 42, bottom: 18,
      minSize: '0%', maxSize: '100%',
      sort: 'none', orient: 'vertical', gap: 2, funnelAlign: 'center',
      label: {
        show: true, position: 'inside', fontSize: 11, color: '#fff',
        formatter: function (p) {
          var v = +p.value;
          var prev = p.dataIndex > 0 ? CONV[p.dataIndex - 1].value : null;
          return prev ? p.name + '\n' + v + '\n' + (v / prev * 100).toFixed(1) + '%' : p.name + '\n' + v;
        }
      },
      itemStyle: { borderWidth: 0 },
      data: deep(CONV)
    }]
  };
  render('p7a', any(conv));
  var pair = {
    color: ['#7a9e7e', '#3d5a80'],
    legend: { top: 2, data: ['2024', '2025'], itemWidth: 12, itemHeight: 8, textStyle: { fontSize: 11 } },
    series: [
      { name: '2024', type: 'funnel', animation: false, left: '4%', width: '44%', top: 36, bottom: 12,
        min: 0, max: 1000, minSize: '0%', maxSize: '100%', sort: 'none', orient: 'vertical', gap: 2,
        label: { show: true, position: 'inside', fontSize: 10, color: '#fff',
          formatter: function (p) { return p.name + ' ' + p.value; } },
        itemStyle: { borderWidth: 0 },
        data: deep(FDATA24) },
      { name: '2025', type: 'funnel', animation: false, left: '52%', width: '44%', top: 36, bottom: 12,
        min: 0, max: 1000, minSize: '0%', maxSize: '100%', sort: 'none', orient: 'vertical', gap: 2,
        label: { show: true, position: 'inside', fontSize: 10, color: '#fff',
          formatter: function (p) { return p.name + ' ' + p.value; } },
        itemStyle: { borderWidth: 0 },
        data: deep(FDATA) }
    ]
  };
  render('p7b', any(pair));
}
function conversionsOf(id, idx) {
  var s = seriesOfIndex(id, idx || 0);
  if (!s || !s.getData) return '—';
  var d = s.getData();
  var vals = [], names = [];
  d.each(function (i) { names.push(d.getName(i)); vals.push(+d.get('value', i)); });
  var parts = [];
  for (var i = 1; i < vals.length; i++) parts.push(names[i] + ' ' + (vals[i] / vals[i - 1] * 100).toFixed(1) + '%');
  return parts.join(',');
}
function readP7() {
  var lines = [];
  lines.push('p7a 相邻转化:' + conversionsOf('p7a', 0));
  var fa = funnelItems('p7a');
  if (fa && fa.items.length) {
    lines.push('p7a 宽度(不写 min/max,按数据自算):' + fa.items.map(function (it) {
      return it.name + ' ' + r1(it.a);
    }).join(','));
  }
  var a = funnelItemNamed('p7b', '进店');
  var s0 = seriesOfIndex('p7b', 0);
  var s1 = seriesOfIndex('p7b', 1);
  if (s0 && s1) {
    var d0 = s0.getData(), d1 = s1.getData();
    var i0 = -1, i1 = -1;
    d0.each(function (i) { if (d0.getName(i) === '进店') i0 = i; });
    d1.each(function (i) { if (d1.getName(i) === '进店') i1 = i; });
    var p0 = d0.getItemLayout(i0), p1 = d1.getItemLayout(i1);
    if (p0 && p1) {
      var w0 = p0.points[1][0] - p0.points[0][0];
      var w1 = p1.points[1][0] - p1.points[0][0];
      lines.push('p7b「进店」宽:2024 ' + r1(w0) + ' px,2025 ' + r1(w1) + ' px,比值 ' + r2(w1 / w0) + '(数值比 620/540 = ' + r2(620 / 540) + ')');
    }
  }
  setInfo('p7Info', lines);
}

/* ===== 面板八 ===== */
var P8_VALS = [45, 72, 92];
var P8_IDX = 1;
function p8() {
  render('p8a', any({ series: [gaugeBase({})] }));
  render('p8b', any({ series: [gaugeBase({
    axisLine: { lineStyle: { width: 12, color: [[0.6, '#5a8f6b'], [0.85, '#c98b4b'], [1, '#b04a3a']] } },
    splitLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false },
    title: { show: false }, detail: { offsetCenter: [0, '0%'], fontSize: 26 }
  })] }));
  render('p8c', any({ series: [gaugeBase({
    axisLine: { lineStyle: { width: 14, color: [[0.6, '#5a8f6b'], [0.85, '#c98b4b'], [1, '#b04a3a']] } },
    progress: { show: true, width: 14, roundCap: true, itemStyle: { color: 'auto' } },
    pointer: { show: false }, splitLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false },
    title: { show: false }, detail: { offsetCenter: [0, '0%'], fontSize: 26, color: '#3c3c41' }
  })] }));
}
function setGaugeValue(id, value) {
  var c = charts[id];
  if (!c) return;
  c.setOption(any({ series: [{ data: [{ value: value, name: '完成率' }] }] }));
}
function pointerAngle(id) {
  var list = displayOf(id);
  var p = null;
  for (var i = 0; i < list.length; i++) if (list[i].type === 'pointer') p = list[i];
  if (!p || typeof p.rotation !== 'number') return NaN;
  // zrender 的 rotation 是相对指针缺省朝向的弧度;换算回 option 里 0° 指 3 点、逆时针为正的角度。
  var a = deg(p.rotation) + 90;
  while (a < 0) a += 360;
  while (a >= 360) a -= 360;
  return a;
}
function axisSegments(id) {
  var list = displayOf(id);
  var out = [];
  for (var i = 0; i < list.length; i++) {
    var e = list[i];
    if (e.type !== 'sector' || !e.shape) continue;
    var span = Math.abs(deg(e.shape.endAngle - e.shape.startAngle));
    if (span > 5) out.push({ span: span, fill: e.style && e.style.fill });
  }
  out.sort(function (a, b) { return b.span - a.span; });
  return out;
}
function progressRead(id) {
  var list = displayOf(id);
  for (var i = 0; i < list.length; i++) {
    var e = list[i];
    if ((e.type === 'sausage' || e.type === 'sector') && e.shape && Math.abs(deg(e.shape.endAngle - e.shape.startAngle)) < 359) {
      if (e.__isAxis) continue;
      if (e.type === 'sausage') {
        return { type: e.type, span: Math.abs(deg(e.shape.endAngle - e.shape.startAngle)), fill: e.style && e.style.fill };
      }
    }
  }
  // 未 roundCap 时 progress 也是 sector,用颜色与轴分段区分:取最后绘制的弧形
  var arcs = list.filter(function (e) { return e.type === 'sector' && e.shape; });
  if (arcs.length) {
    var p = arcs[arcs.length - 1];
    return { type: p.type, span: Math.abs(deg(p.shape.endAngle - p.shape.startAngle)), fill: p.style && p.style.fill };
  }
  return null;
}
function gaugeValue(id) {
  var s = seriesOf(id);
  if (!s || !s.getData) return NaN;
  return safe(function () { return +s.getData().get('value', 0); }, NaN);
}
function readP8() {
  var lines = [];
  var v = gaugeValue('p8a');
  lines.push('p8a 值 ' + r1(v) + ':指针指向 ' + r1(pointerAngle('p8a')) + '°,轴弧 ' +
    r1(axisSegments('p8a')[0] ? axisSegments('p8a')[0].span : NaN) + '°');
  var segs = axisSegments('p8b');
  lines.push('p8b 轴分三段:' + segs.map(function (s) { return r1(s.span) + '° ' + s.fill; }).join(','));
  var pr = progressRead('p8c');
  lines.push('p8c 值 ' + r1(gaugeValue('p8c')) + ':progress ' + (pr ? pr.type + ' 弧 ' + r1(pr.span) + '°,fill ' + pr.fill : '—'));
  setInfo('p8Info', lines);
}

/* ===== 面板九 ===== */
function p9() {
  render('p9a', any({ series: [gaugeBase({
    startAngle: 180, endAngle: 0, min: 0, max: 200, splitNumber: 5,
    center: ['50%', '54%'], radius: '78%',
    axisLine: { lineStyle: { width: 14, color: [[1, '#e9e4d9']] } },
    progress: { show: true, width: 14, roundCap: true, itemStyle: { color: '#3d5a80' } },
    pointer: { width: 6, length: '52%', itemStyle: { color: '#3d5a80' } },
    anchor: { show: true, size: 16, itemStyle: { color: '#b6902f' } },
    title: { show: true, offsetCenter: [0, '-30%'], fontSize: 12, color: '#6f675c' },
    detail: { show: true, offsetCenter: [0, '-6%'], fontSize: 28, formatter: function (val) { return val + ' 单'; } },
    data: [{ value: 150, name: '今日订单' }]
  })] }));
  render('p9b', any({
    color: ['#7a9e7e', '#3d5a80'],
    series: [gaugeBase({
      data: [{ value: 62, name: '2024' }, { value: 81, name: '2025' }],
      axisLine: { lineStyle: { width: 10, color: [[1, '#e9e4d9']] } },
      splitLine: { show: false }, axisTick: { show: false },
      title: { show: false }, detail: { show: false },
      pointer: { width: 5, length: '60%' },
      anchor: { show: true, size: 10, itemStyle: { color: '#8a5a2b' } }
    })]
  }));
  render('p9c', any({ series: [gaugeBase({
    startAngle: 90, endAngle: -270, center: ['50%', '50%'], radius: '72%',
    axisLine: { lineStyle: { width: 16, color: [[1, '#eee']] } },
    progress: { show: true, width: 16, roundCap: true, itemStyle: { color: '#3d5a80' } },
    pointer: { show: false }, anchor: { show: false },
    splitLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false },
    title: { show: true, offsetCenter: [0, '-16%'], fontSize: 12, color: '#6f675c' },
    detail: {
      show: true, offsetCenter: [0, '8%'],
      formatter: function (val) { return '{v|' + val + '}{u|%}'; },
      rich: {
        v: { fontSize: 40, fontWeight: 'bold', color: '#3d5a80' },
        u: { fontSize: 16, color: '#8a8378', padding: [0, 0, 8, 2] }
      }
    },
    data: [{ value: 86.5, name: '目标完成率' }]
  })] }));
}
function pointerAngles(id) {
  return displayOf(id).filter(function (e) { return e.type === 'pointer'; }).map(function (e) {
    var a = deg(e.rotation) + 90;
    while (a < 0) a += 360;
    while (a >= 360) a -= 360;
    return r1(a) + '°' + (e.style && e.style.fill ? ' ' + e.style.fill : '');
  });
}
function readP9() {
  var lines = [];
  lines.push('p9a 半圆:指针 ' + r1(pointerAngle('p9a')) + '°,anchor 元素 ' +
    displayOf('p9a').filter(function (e) { return e.type === 'path' && e.style && e.style.fill === '#b6902f'; }).length + ' 个');
  lines.push('p9b 双指针:' + pointerAngles('p9b').join(','));
  var pr = progressRead('p9c');
  lines.push('p9c 环:' + (pr ? pr.type + ' 弧 ' + r1(pr.span) + '°,fill ' + pr.fill : '—') +
    ';文字 ' + textsOf('p9c').join(' | '));
  setInfo('p9Info', lines);
}

/* ===== 面板十 ===== */
function p10() {
  render('p10a', chordOpt(CH));
  render('p10b', chordOpt(CH, { padAngle: 12, minAngle: 6, radius: ['56%', '70%'], label: { show: true, fontSize: 11, position: 'outside', distance: 6 } }, true));
  render('p10c', chordOpt(BORROW, { minAngle: 20, label: { show: true, fontSize: 11 } }));
  render('p10d', chordOpt(EXPLICIT, { label: { show: true, fontSize: 11 } }));
}
function chordInfo(id) {
  var s = seriesOf(id);
  if (!s || !s.getGraph) return null;
  var g = safe(function () { return s.getGraph(); }, null);
  if (!g) return null;
  var nodes = [], nodeSum = 0, edgeSum = 0, edgeCount = 0;
  g.eachNode(function (n) {
    var l = n.getLayout();
    var el = safe(function () { return s.getData().getItemGraphicEl(n.dataIndex); }, null);
    var drawn = el && el.shape && isFinite(el.shape.startAngle) && isFinite(el.shape.endAngle)
      ? Math.abs(deg(el.shape.endAngle - el.shape.startAngle)) : NaN;
    nodes.push({ name: n.id, value: l.value, angle: Math.abs(deg(l.angle || 0)), drawn: drawn });
    nodeSum += +(l.value || 0);
  });
  var firstEdge = null;
  g.eachEdge(function (e) {
    edgeCount++;
    edgeSum += +(e.getValue('value') || 0);
    if (!firstEdge) {
      var l = e.getLayout();
      if (l) firstEdge = Math.abs(deg(l.sEndAngle - l.sStartAngle));
    }
  });
  return { nodes: nodes, nodeSum: nodeSum, edgeSum: edgeSum, edgeCount: edgeCount, ribbon: firstEdge };
}
function chordLine(id) {
  var c = chordInfo(id);
  if (!c) return '—';
  return c.nodes.map(function (n) {
    var extra = isFinite(n.drawn) && Math.abs(n.drawn - n.angle) > 0.05 ? '(画 ' + r1(n.drawn) + '°)' : '';
    return n.name + ' ' + r0(n.value) + '/' + r1(n.angle) + '°' + extra;
  }).join(',');
}
function readP10() {
  var a = chordInfo('p10a');
  var b = chordInfo('p10b');
  var c = chordInfo('p10c');
  var d = chordInfo('p10d');
  setInfo('p10Info', [
    'p10a:' + chordLine('p10a') + ';边 ' + (a ? a.edgeCount : '—') + ' 条,边值合计 ' + (a ? r0(a.edgeSum) : '—') +
      ',节点值合计 ' + (a ? r0(a.nodeSum) : '—') + ',首条弦两端各 ' + (a && isFinite(a.ribbon) ? r1(a.ribbon) + '°' : '—'),
    'p10b pad12/min6(data/edges 别名):' + chordLine('p10b') + (b ? ';边值合计 ' + r0(b.edgeSum) : ''),
    'p10c minAngle 20:' + chordLine('p10c'),
    'p10d 显式 A=50:' + chordLine('p10d')
  ]);
}

/* ===== 面板十一 ===== */
function p11() {
  render('p11a', chordOpt(DAG));
  render('p11b', sankeyOpt(DAG));
}
function sankeyValues(id) {
  var g = graphOf(id);
  if (!g) return '—';
  var parts = [];
  g.eachNode(function (n) { parts.push(n.id + ' ' + r0(n.getLayout().value)); });
  return parts.join(',');
}
function readP11() {
  var c = chordInfo('p11a');
  setInfo('p11Info', [
    'chord 节点值:' + (c ? c.nodes.map(function (n) { return n.name + ' ' + r0(n.value); }).join(',') : '—') +
      ';节点值合计 ' + (c ? r0(c.nodeSum) : '—') + ',边值合计 ' + (c ? r0(c.edgeSum) : '—'),
    'sankey 节点值:' + sankeyValues('p11b')
  ]);
}

/* ===== 面板十二 ===== */
function conversionFormatter(list) {
  return function (p) {
    var v = +p.value;
    var prev = p.dataIndex > 0 ? list[p.dataIndex - 1].value : null;
    return prev ? p.name + '\n' + v + '\n' + (v / prev * 100).toFixed(1) + '%' : p.name + '\n' + v;
  };
}
function p12() {
  render('p12a', chordOpt(MEMBER, {
    radius: ['60%', '74%'],
    label: { show: true, fontSize: 9, color: '#4a453d' }
  }));
  render('p12b', any({
    series: [{
      type: 'funnel', animation: false, left: 20, right: 20, top: 28, bottom: 16,
      min: 0, max: 12000, minSize: '0%', maxSize: '100%',
      sort: 'none', orient: 'vertical', gap: 2, funnelAlign: 'center',
      label: { show: true, position: 'inside', fontSize: 10, color: '#fff', formatter: conversionFormatter(CONV) },
      itemStyle: { borderWidth: 0 },
      data: deep(CONV)
    }]
  }));
  render('p12c', any({ series: [gaugeBase({
    min: 0, max: TARGET, splitNumber: 10,
    center: ['50%', '52%'], radius: '76%',
    axisLine: { lineStyle: { width: 16, color: [[0.6, '#b04a3a'], [0.85, '#c98b4b'], [1, '#5a8f6b']] } },
    progress: { show: true, width: 16, roundCap: true, itemStyle: { color: 'auto' } },
    pointer: { show: false }, anchor: { show: false },
    splitLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false },
    title: { show: true, offsetCenter: [0, '-18%'], fontSize: 12, color: '#6f675c' },
    detail: {
      show: true, offsetCenter: [0, '8%'],
      formatter: function (val) { return '{m|¥' + (val / 10000).toFixed(1) + '万}\n{p|' + (val / TARGET * 100).toFixed(1) + '%}'; },
      rich: {
        m: { fontSize: 24, fontWeight: 'bold', color: '#3d5a80', lineHeight: 30 },
        p: { fontSize: 15, color: '#8a8378', lineHeight: 22 }
      }
    },
    data: [{ value: 0, name: '国贸店 2025 目标完成率' }]
  })] }));
}
function readP12() {
  var c = chordInfo('p12a');
  var lines = [];
  lines.push('p12a 会员等级:' + (c ? c.nodes.map(function (n) { return n.name + ' ' + r0(n.value) + '/' + r1(n.angle) + '°'; }).join(',') : '—'));
  lines.push('p12b 转化率:' + conversionsOf('p12b', 0));
  var v = gaugeValue('p12c');
  var pr = progressRead('p12c');
  lines.push('p12c 销售额 ' + money(v) + ' / 目标 ' + money(TARGET) + ' = ' + pctText(v / TARGET, 2) +
    (pr ? ';progress ' + pr.type + ' 弧 ' + r1(pr.span) + '°,fill ' + pr.fill : ''));
  setInfo('p12Info', lines);
}
function loadReal() {
  getJSON('../../data/store-metrics.json').then(function (rows) {
    var s = null;
    for (var i = 0; i < rows.length; i++) if (rows[i].storeId === 'S02') s = rows[i];
    if (!s) throw new Error('没找到 S02 国贸店');
    setStatus('已读取 ' + rows.length + ' 家门店;国贸店 2025 年销售额 ' + money(s.annualSales) +
      ',目标 ' + money(TARGET) + '。');
    render('p12c', any({ series: [gaugeBase({
      min: 0, max: TARGET, splitNumber: 10,
      center: ['50%', '52%'], radius: '76%',
      axisLine: { lineStyle: { width: 16, color: [[0.6, '#b04a3a'], [0.85, '#c98b4b'], [1, '#5a8f6b']] } },
      progress: { show: true, width: 16, roundCap: true, itemStyle: { color: 'auto' } },
      pointer: { show: false }, anchor: { show: false },
      splitLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false },
      title: { show: true, offsetCenter: [0, '-18%'], fontSize: 12, color: '#6f675c' },
      detail: {
        show: true, offsetCenter: [0, '8%'],
        formatter: function (val) { return '{m|¥' + (val / 10000).toFixed(1) + '万}\n{p|' + (val / TARGET * 100).toFixed(1) + '%}'; },
        rich: {
          m: { fontSize: 24, fontWeight: 'bold', color: '#3d5a80', lineHeight: 30 },
          p: { fontSize: 15, color: '#8a8378', lineHeight: 22 }
        }
      },
      data: [{ value: s.annualSales, name: '国贸店 2025 目标完成率' }]
    })] }));
    later(readEveryPanel, 200);
  }).catch(function (e) {
    setStatus('读取 data/store-metrics.json 失败:' + e.message + '(实验台要走本地服务器,file:// 下会被拦)');
  });
}

/* ===== 统一读数 ===== */
function guarded(fn) {
  try { fn(); } catch (e) { /* 门禁沙箱没有布局,读数跳过 */ }
}
function readEveryPanel() {
  guarded(readP1);
  guarded(readP2);
  guarded(readP3);
  guarded(readP4);
  guarded(readP5);
  guarded(readP6);
  guarded(readP7);
  guarded(readP8);
  guarded(readP9);
  guarded(readP10);
  guarded(readP11);
  guarded(readP12);
}

/* ===== 按钮 ===== */
bind('b1a', tryCycle);
bind('b1b', function () { cycleMsg = '(按①现场试)'; p1(); });
bind('b3a', p3Drag);
bind('b3b', p3Roam);
bind('b3c', function () { p3(); });
bind('b4a', function () { p4Highlight(true); });
bind('b4b', function () { p4Highlight(false); });
bind('b6a', function () {
  p6AlignIdx = (p6AlignIdx + 1) % p6Aligns.length;
  render('p6e', funnelOpt({ orient: 'horizontal', funnelAlign: p6Aligns[p6AlignIdx] }));
});
bind('b8a', function () {
  P8_IDX = (P8_IDX + 1) % P8_VALS.length;
  setGaugeValue('p8b', P8_VALS[P8_IDX]);
  setGaugeValue('p8c', P8_VALS[P8_IDX]);
});
bind('b12a', function () { /* 只读数 */ });

/* ===== 启动 ===== */
p1();
p2();
p3();
p4();
p5();
p6();
p7();
p8();
p9();
p10();
p11();
p12();
p4Highlight(true);
loadReal();
later(readEveryPanel, 900);

【练习】

  1. 把 min.html 的「进店 → 首单」改成「首单 → 进店」,先猜会不会画出来,再按实际结果记下报错原文;然后把这条边删掉,确认其余流量不变。
  2. 在面板一第三张上把「进店」的出边补成 650(比如给「流失」再加 150),观察节点高度与出边铺满的像素差怎么从 69.2 px 变成 0。
  3. 面板二里把「广告投放 → 直接下单」这条一跳边删掉,重新读三张图的层级。哪种对齐的分层结果发生了变化?为什么?提示:justify 只做「把汇点推到最右」这一件事。
  4. 用面板三,把 nodeGap 依次设成 8、24、40,量「广告投放」的高;解释为什么缝越大节点越矮(提示:纵向预算是 画布高 −(同层节点数 − 1)× nodeGap)。
  5. 把面板五的 sort 改成 'none' 并把数据顺序倒过来写,确认画布上的先后完全由数组顺序决定;再改回 'ascending',说清它和「'none' + 反序数组」的差别在哪。
  6. 面板六:给横版漏斗写 funnelAlign:'left',先猜画面上还剩什么,再验证;然后解释为什么控制台什么都没打印。
  7. 把面板八的量程改成 min:0, max:200、值仍写 72,先算指针应该指到哪个角度再按黄框核对;然后把 axisLine 的分段比例改成 [[0.3,...],[0.425,...],[1,...]],确认三色边界仍然落在值 60 与 85 上——这就是「分段比例切的是量程」的证据。
  8. 用面板十二的会员流转数据画一张 sankey,把抛出的错误原文抄下来;再删掉那条 黑卡 → 金卡 的降级边,画出桑基版,并说明你更愿意用哪一张向店长汇报、为什么。

【小结】

这一章的四张图共用一句骨架:「一张图的形状由它把数值放进哪个通道决定」。桑基图把值放进高度,节点高度是「流入、流出、原始值」三者取大,流量守恒得你自己保证,数据有环则直接抛错;漏斗把值放进宽度,而 min/max 是映射的输入区间、minSize/maxSize 才是输出区间,混淆这两组键是本章最容易犯的错;仪表盘把值放进转角,角度只跟「值在量程里的位置」有关,分段轴色切的同样是量程;弦图把值放进弧角,代价是每条边在两个端点各记一次,节点值因此天然是边值合计的两倍。

选型也跟着这句话走。要看「从哪来到哪去、每一跳过了多少」,用桑基图;要看「谁和谁有关系、往来多重」,或者数据本身成环,用弦图;只有「一级比一级少」的固定阶段,用漏斗,两期对比务必共用 sort:'none' 和同一组 min/max;只有一个数对某个目标的距离,用仪表盘——progress 加 color:'auto' 已经能替你把「达标绿、临期橙、未达标红」讲清楚,不必再加一根指针。

还有一句关于读数的:本章黄框里没有一个数字是手抄的。节点值来自 getGraph() 的布局结果,漏斗宽度来自 getItemLayout() 的四个顶点,仪表盘角度从显示列表的 pointer/sector/sausage 元素上反推。这条路并不平坦——仪表盘角度的符号第一次就反推错了,值 72 报成 329.4°,是拿它跟手算的 225 − 270 × 0.72 = 30.6 对账才抓出来的;面板三那句「同层垂直缝」原本量的是两个并不相邻的节点,也是这么被戳穿的。把读数写成「取不到就留占位」而不是「取不到就猜一个」,这一章才有可抄性。

下一章离开「关系与流向」,回到带数值的时间序列:K 线与箱线。那里要处理的是同一个变量上的四个数怎么画、离群点怎么留,以及类目轴怎么按日期铺开——和这一章「一条边一个值」的世界是另一种数据形状。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值