【本章目标】
学完这一章,你能:








【原理先行】
本章的所有结论都来自同一套探针:把 option 交给 echarts.init(null, null, {renderer:'svg', ssr:true, width:400, height:260}),取 renderToSVGString() 的原文,再把图元拆成属性表。探针代码在 tools/__probe/(第 8 章介绍过 SSR,第 12 章用了它一整章)。除特别说明外,基准 option 都是这一份:
{
xAxis: { type: 'category', data: ['1月','2月','3月','4月','5月','6月'], /* 装饰件全关 */ },
yAxis: { type: 'value', min: 0, max: 100 },
grid: { left: 10, right: 10, top: 10, bottom: 10 },
series: [{ type: 'line', data: [10, 86, 47, 93, 52, 71] }]
}
grid 四边都是 10,所以绘图区是 x∈[10,390]、y∈[10,250];yAxis 锁 0~100,所以值 v 映射成 y = 250 − 2.4v(值 10 → 226,值 90 → 34)。类目轴 boundaryGap 默认 true,六个点的 x 是 10 + (i+0.5)×63.33,即 41.67 / 105 / 168.33 / 231.67 / 295 / 358.33。下面所有像素读数都能用这两个公式反推——这就是第 12 章教的 convertToPixel 在折线图上的用法。
一、折线由三种图元组成,符号是「单位圆 × 缩放矩阵」
上面那份 option 渲染出来是 2415 字节,里面有 7 个图形元素:1 条折线 + 6 个符号。原文长这样(截断):
<path d="M41.7 226L105 43.6L168.3 137.2L231.7 26.8L295 125.2L358.3 79.6"
fill="none" pointer-events="visible" stroke="#5070dd" stroke-width="2" stroke-linejoin="bevel">
<path d="M1 0A1 1 0 1 1 1 -0.1A1 1 0 0 1 1 0"
transform="matrix(3,0,0,3,41.6667,226)"
fill="#fff" stroke="#5070dd" stroke-width="0.6666666666666666"
ecmeta_series_index="0" ecmeta_data_index="1">
三个要点:
其一,折线本体是 fill="none" + stroke。 默认线宽 2、连接处 bevel(第 12 章的 1px 线同理,但折线是 2px,所以没有 .5 半像素对齐问题)。
其二,符号不是 <circle>。 ECharts 把每个符号画成半径 1 的单位路径,再用 matrix(sx,0,0,sy,tx,ty) 缩放平移到数据点。所以 symbolSize:6 对应 matrix(3,…):直径 = 2 × scale。symbolSize:[30,10] 对应 matrix(15,0,0,5,…),是个 30×10 的椭圆。想按数据点改大小,回调 symbolSize:(v,params)=>params.dataIndex*4 实测得到 scale 序列 [0,2,4,6,8,10]。
其三,空心圆那一圈「边框」永远是 2 个像素。 注意符号上的 stroke-width:scale=3 时是 0.6667,scale=10(symbolSize:20)时是 0.2。库把线宽除回了缩放,使得「局部线宽 × scale ≡ 2」。这条规律在 5.6.0 和 6.1.0 上都成立,意味着你把符号放大到 40px,那圈描边也不会跟着变粗——想改描边得用别的办法(见第十一节的坑)。
顺带把 itemStyle.borderWidth 的无效性钉死:对 symbol:'emptyCircle' 写 borderWidth:6、borderColor:'blue',实测 scale 仍是 3、局部线宽仍是 0.6667、描边色仍是系列色。折线的符号不接受 itemStyle 的边框配置(柱状图接受,第 14 章会对比)。
元素在内部的类型名是 ec-polyline / ec-polygon / ec-symbol,写在 el.type 上而不是 el.name 上;DOM 层面它们是 SVG 的 <path>。探针第一次跑这件事时读 el.name 读成一片空,白找了半小时。
二、折线和柱状图共用同一套坐标系:改一个词就换图
把基准 option 的 series[0].type 从 'line' 改成 'bar',其余一个字都不动,实测两份 SVG 的坐标轴文本:
| type:'line' | 0,20,40,60,80,100,a,b,c,d | 非符号 path 8 | 4392 |
| type:'bar' | 0,20,40,60,80,100,a,b,c,d | 非符号 path 11 | 4273 |
刻度文本逐字相同。这不是巧合:坐标系属于 grid/xAxis/yAxis 这三件组件,series 只是把自己的数据投进去。所以第 12 章学到的轴配置——boundaryGap、min/max/interval、axisLabel、双轴、breaks——对折线图全部适用,本章只讲折线专属的那些键:lineStyle、symbol*、areaStyle、smooth、step、sampling、connectNulls、stack、showAllSymbol、clip、三件套标记。
不过 boundaryGap 在折线上的观感和柱状图相反:类目轴 boundaryGap:false 时六个点的 x 变成 10/86/162/238/314/390——首尾两点正好压在绘图区边界上。折线常用 false(时间序列首尾不留白),柱状图必须 true(否则边柱溢出,第 12 章坑 4)。
三、smooth 的真身:一段 Catmull-Rom 式的三次贝塞尔 + 两次夹逼
LineView.js 里把配置值转成曲率只有一行:
// echarts/lib/chart/line/LineView.js
var getSmooth = function (smooth) {
return isNumber(smooth) ? smooth : (smooth ? 0.5 : 0);
};
对照实测(B1 组):
| false / 0 / -1 / null / undefined | 0 | 0(直线) |
| true | 0.5 | 4 |
| 0.1 / 0.25 / 0.5 / 0.8 | 原样 | 4 |
| "abc" | 0.5 | 4 |
| 1 / 2 / 3 | 1/2/3 | 4,但 d 属性与 0.5 完全一致 |
两件事值得注意。第一,负数是数字,按数字处理,但 poly.js 里画曲线的分支是 if (smooth > 0),所以 -1 走 else ctx.lineTo(),结果是直线。第二,1/2/3 之所以和 0.5 一字不差,是因为控制点被夹逼了。源码(echarts/lib/chart/line/poly.js,非单调分支):
cpx1 = x – vx * smooth * (1 – ratioNextSeg);
cpy1 = y – vy * smooth * (1 – ratioNextSeg);
nextCpx0 = x + vx * smooth * ratioNextSeg;
nextCpy0 = y + vy * smooth * ratioNextSeg;
// 夹进「当前点 ~ 下一点」的包围盒
nextCpx0 = mathMin(nextCpx0, mathMax(nextX, x));
nextCpy0 = mathMin(nextCpy0, mathMax(nextY, y));
nextCpx0 = mathMax(nextCpx0, mathMin(nextX, x));
nextCpy0 = mathMax(nextCpy0, mathMin(nextY, y));
smooth 越大,未夹逼前的控制点跑得越远,但夹逼之后它被死死按在相邻两个点的包围盒角上——曲率超过某个阈值后继续加大数字,结果不再变化。三次贝塞尔的极值一定落在四个控制点的凸包里,控制点被关在数据点包围盒里,就推出「默认平滑不会越过数据的最大/最小值」。
把这条推理拿数据验一遍(锯齿 10/90/10/90/10,数据边界 y=34 与 226,用正确采样三次曲线的算法求极值,不是只看端点):
| smooth:false | [34, 226] | — |
| smooth:true | [34, 226] | 否 |
| smooth:0.2 | [34, 226] | 否 |
| smooth:0.8 | [34, 226] | 否 |
| smooth:2 | [34, 226] | 否 |
| smooth:true, smoothMonotone:'x' | [34, 226] | 否 |
| smooth:true, smoothMonotone:'y' | [14.41, 241.54] | 是 |
结论要说得比网上多数文章准确:默认的 smooth 不会过冲,它只是让线在相邻点之间「鼓」一下,峰值不超过真实峰值。会过冲的是 smoothMonotone:'y'——单调分支在源码里是另一套公式,没有那两次夹逼:
} else if (smoothMonotone === 'y') {
lenPrevSeg = Math.abs(dy0);
var dir_2 = vy > 0 ? 1 : –1;
cpy1 = y – dir_2 * lenPrevSeg * smooth; // 方向取自 prev→next 的净方向
nextCpy0 = y + dir_2 * lenNextSeg * smooth;
}
在局部极值点(波峰/波谷),prev→next 的净方向和「进峰」「离峰」两段的方向相反,于是控制点被推到极值点的外侧,曲线就冲出去了。'x' 分支没这个问题(它只把控制点沿 x 方向偏移,y 恒等于锚点 y)。
最后拿单调上升的数据做一次手算对账,证明公式没读错。数据 [10,30,50,70](映射后 y 依次 226/178/130/82),smooth:true 实测:
d = M57.5 226 C 57.5 226, 105 202, 152.5 178 C 200 154, 200 154, 247.5 130 C 295 106, 342.5 82, 342.5 82
按公式手算第二段起点的前一个控制点:vx = 248−58 = 190,vy = 130−226 = −96,lenPrevSeg = lenNextSeg → ratio = 0.5 → nextCpx0 = 152.5 + 190×0.5×0.5 = 200.0,nextCpy0 = 178 − 96×0.25 = 154.0 → (200.5, 154) 与 SVG 里的 (200, 154) 在显示精度内一致。书里给的公式能算出库的输出,这一节就过关了。
四、step:把折点拆成直角,三种形态有 5 个名字
实测(数据 10/86/47/93/52/71,六点的直线是 6 个折点):
| false | 6 | M41.7 226L105 43.6L168.3 137.2… |
| true | 11 | M41.7 226L41.7 43.6L105 43.6L105 137.2… |
| 'start' | 11 | 与 true 完全一致 |
| 'middle' | 16 | M41.7 226L73.3 226L73.3 43.6L105 43.6… |
| 'end' | 11 | M41.7 226L105 226L105 43.6L168.3 43.6… |
| 'abc'(非法) | 11 | 落回 'start' 的行为 |
LineView.js 的 turnPointsIntoStep 用 switch 处理 'middle'/'end',default: 分支就是 'start' 的画法——这解释了为什么 true、'start'、'abc' 三者一模一样。
step 与 smooth 同时打开时,step 赢:实测 d 变成一串首尾重合的 C 命令(C41.7 226, 41.7 43.6, 41.7 43.6,控制点恰好是段的两端),画出来仍是直角折线。源码注释也写了这一点(LineSeries.js 的 // Disabled if step is true,指 smooth)。所以「step + smooth 想要圆角台阶」是不存在的,要做圆角台阶只能自己把数据密采后用 smooth。
五、areaStyle:面积的封底在哪、颜色怎么来、透明度默认多少
只写 areaStyle:{} 时,实测面积图元是 fill="#5070dd" + fill-opacity="0.7"——默认 0.7,不是 1。颜色默认吃系列色;渐变两种写法都实测通过:
| areaStyle:{} | fill="#5070dd", fill-opacity=0.7 |
| areaStyle:{color:'red'} | fill="red" |
| areaStyle:{opacity:0.9} | fill-opacity=0.9 |
| areaStyle:{color:{type:'linear',x:0,y:0,x2:0,y2:1,colorStops:[…]}} | fill="url(#zr37-g0)",<linearGradient> 两个 stop |
| 同上 type:'radial' | <radialGradient> |
| 渐变写在 lineStyle.color | 折线用渐变,面积没有图元(没写 areaStyle 时不画面积) |
origin 决定「从哪儿往上/往下填」。规则是:取 origin 指定的值和 0/轴界的某个组合,作为面积路径闭合边的 y。把 d 原文拆开看封底 y(绘图区 y∈[10,250],值→y = 250−2.4v):
| 全正(10~93),轴 0~100 | 不写 / 'auto' / 'start' | 250 | 轴最小 0 |
| 全正 | 'end' | 10 | 轴最大 100(填满到顶) |
| 跨零,轴 −50~150 | 不写 / 'auto' | 190 | 0 线(250−2.4×… 按 −50~150 映射,0 → 190) |
| 跨零 | 'start' | 250 | 轴最小 −50 |
| 跨零 | 50(数字) | 130 | 值 50 |
| 全负(−90~−10),轴 −100~−10 | 不写 / 'auto' | 10 | 轴最大 −10 |
| 轴 20~100,数据含 10(被截断) | 'auto' | 250 | 轴最小 20,线跑到 280(绘图区外,被裁) |
数字 origin 有效这件事官方类型里是 'auto'|'start'|'end',但源码 getValueStart 会原样把数字当基准值用。跨零数据默认贴 0 线是故意的:正负两边会分别往上、往下填,形成「蝴蝶」而不是整块糊满——这正是很多人做正负面积图时觉得「颜色怎么从中间断开」的原因。
origin 不是 6.1 新增:同一份 option 在 5.6.0 上实测封底 y 完全一致(undefined→214、'start'→250、'end'→10 三个数字两版相同)。
六、缺失值:六种写法全都断线,'-' 和 null 没有区别
yAxis 0~100、六点数据,把第 3 个点换成各种「缺失」写法,实测折线被切成几段(一段 = 折线 d 里的一个 M 子路径)、以及符号落在哪些 dataIndex。这里有个数法上的陷阱要先交代:ECharts 把断开的线段塞进同一个 <path> 元素,段与段之间用第二个 M 分隔。实测中间缺点的那条线是 1 个 <path>、d="M41.7 226L105 43.6M231.7 26.8L295 125.2L358.3 79.6"——两个 M,所以算 2 段。按 <path> 个数去数会数出 1,然后误判成「库根本没有断线」:
| [10,86,47,93,52,71] 完整 | 1 | 0,1,2,3,4,5 |
| 中间 null | 2 | 0,1,(缺 2),3,4,5 |
| 中间 undefined | 2 | 0,1,3,4,5 |
| 中间 '-' | 2 | 0,1,3,4,5 |
| 中间 ''(空串) | 2 | 0,1,3,4,5 |
| 中间 NaN | 2 | 0,1,3,4,5 |
| 中间 '86'(数字串) | 1 | 0,1,2,3,4,5(正常画) |
| 中间 'abc' | 2 | 0,2,3,4,5 |
| {value:null} | 2 | 0,2,3,4,5 |
| 两头都缺 [null,…,null] | 1 | 1,2,3,4 |
| 全 null | 0 | 无 |
| 数组少一个元素(长度 5) | 1 | 0,1,2,3,4 |
三条结论:「缺失」= 值转成数字后非有限值,null/undefined/'-'/''/NaN 一律等价,没有 '-' 更「正式」这种区别;缺失点连符号都不画(showSymbol 也救不回来);数组长度不够只是尾巴上没有,不会断线。两头缺失时段数仍是 1——因为断掉的空段不产生图元。
connectNulls:true 只改变连线行为,实测:段数从 2 变 1,但符号集合照旧是 0,1,3,4,5(缺口那个点仍然没有符号)。面积图在这件事上差别更直观:
connectNulls:false 面积 2 段 d=M41.7 226L105 43.6L105 250L41.7 250Z M231.7 26.8…L231.7 250Z
connectNulls:true 面积 1 段 d=M41.7 226L105 43.6L231.7 26.8…L231.7 250L168.3 250L105 250L41.7 250Z
true 时缺口处的 x=168.3(缺失点)仍然出现在封底序列里——面积跨过缺口,但形状按「点在基线上」处理,所以视觉上会看到一个「V 形塌到基线」的假象,这不是数据错了,是 connectNulls 的补偿方式。真实项目里,缺两天数据用 connectNulls:true 连线比让它断开更容易误导读者(第 25 章讲图表伦理时会回到这个例子)。
七、堆叠:series[0] 在最底层,stackStrategy 管正负怎么叠
三条线 stack 同名(A=[10,12,14,16,18,20]、B=[2,3,4,5,6,7]、C=[1,1,1,1,1,1]),实测三条线首点的 y 值反推出的数据:
| A, B, C | A=10(y=259.2) | A+B=12 | A+B+C=13 |
| C, B, A | C=1 | C+B=3 | C+B+A=13 |
两版(5.6.0 / 6.1.0)都是 series[0] 当底,逐条往上加。所以「6.x 把堆叠顺序反过来了」这个说法是错的(大纲里原来就是这么写的,已经改掉)。图例从上到下通常列的是 series[0..],而画出来 series[0] 在最下面——这是很多人觉得「图例顺序和图形反了」的根源。想让某个系列在顶部,就把它写在数组最后。
正负值混合时由 stackStrategy 决定,源码默认值是 get('stackStrategy') || 'samesign'(processor/dataStack.js)。判定条件就写在源码那一个 if 里:从自己往前逐条回看,第一个「满足条件的上一系列结果」就是自己的落点,找不到就落在基线(0)上:
// echarts/lib/processor/dataStack.js(节选)
if (stackStrategy === 'all' // 无条件叠上去
|| stackStrategy === 'positive' && val > 0 // 只往「正列」叠
|| stackStrategy === 'negative' && val < 0 // 只往「负列」叠
|| stackStrategy === 'samesign' && sum >= 0 && val > 0 // 自己非负 → 找正列
|| stackStrategy === 'samesign' && sum <= 0 && val < 0) // 自己非正 → 找负列
注意条件里的 val 是前面那条系列叠完之后的结果,不是它自己的原始值;而 positive/negative 不看自己正负,只看目标列。于是三条线 A=−10、B=+10、C=+5 同一 stack 的实测落点(两版一致):
| 不写 | −10, 10, 15 | 与 'samesign' 一致 |
| 'samesign' | −10, 10, 15 | A 负 → 找负列(没有)→ 落 −10;B 正 → 叠 A?A 的结果 −10 不是正 → 落基线 10;C 正 → 叠 B 的 10 → 15 |
| 'all' | −10, 0, 5 | 无视符号,逐条累加(−10+10=0,0+5=5) |
| 'positive' | −10, 10, 15 | 只认正列:B 找不到正列 → 自己就是正列起点 |
| 'negative' | −10, 0, −5 | 只认负列:B 叠到 A 的 −10 上 → 0;C 再找负列,还是 A 的 −10 → −5 |
| 'abc'(非法) | −10, 10, 5 | 四个条件一个都不满足 → 全组退回基线,stack 形同没写,且没有任何警告 |
最后一行是本节最重要的坑:stackStrategy 拼错时,库不报错、不打警告,而是让整组堆叠全部失效——三条线各自从 0 画起,图形的形状变了,但你从控制台得不到任何提示。这类「静默改变语义」的错误只有类型门禁能拦(第 15 章讲 EChartsOption 时说明为什么)。
拿真实数据再验一次,才看得出 positive 和 samesign 到底差在哪。品类月销的 2025 年 10 月环比增量(万元):经典美式 +6.87、燕麦拿铁 +8.45、卡布奇诺 +8.55、冷萃冰沙 −12.52(data/sales-category-month.json,四条系列按这个顺序进 stack)。实测每条线在 10 月那一列的高度换算回数值(轴锁 −40~40,像素→值用 convertFromPixel,SVG 只保留两位小数,所以读数有 ±0.03 的抖动):
| 不写 / 'samesign' | 6.87, 15.33, 23.87, −12.53 | 前三条正数累加;第四条自己为负 → 只找负列 → 前面没有负列 → 落回 0 基线,画出 −12.52 |
| 'all' / 'positive' | 6.87, 15.33, 23.87, 11.37 | 第四条往正列上叠:23.87 − 12.52 = 11.35 |
| 'negative' / 'abc' | 6.87, 8.43, 8.57, −12.53 | 正数一律叠不上去 → 三条各自独立 = 堆失效 |
| 系列倒序(冷萃冰沙在前),不写 strategy | −12.53, 8.57, 17, 23.87 | 冷萃占住负列;第一条正数(卡布奇诺)找不到正列 → 落回基线 8.55;后两条依次叠上去 → 17、23.87 |
第四行顺带证实了本节开头那条:堆叠是从 series[0] 往上长的。同一份数据只把数组顺序倒过来,顶部总量仍是 23.87,但每条线待的层级全变了——经典美式从最底下(6.87)换到最顶上(23.87),卡布奇诺从顶上换到贴着基线(8.55)。「谁当 series[0]」在带负值的堆叠图里决定的是谁贴着基线、谁压在顶上,不是图形的总高度。
stack 与 areaStyle/smooth 组合都正常;stack 与 step 组合时,台阶是逐条累加后画的,观感上会出现「台阶叠台阶」,不建议混用。
八、showAllSymbol:符号数量被类目轴的标签抽稀绑架了
50 个类目点、宽度 400px,实测(轴标签保持可见、字号 6px 旋转,避免把「标签没了」和「符号没了」混为一谈):
| 默认 | 25 / 50 | 25 | 11965 |
| showSymbol:false | 0 / 50 | 25 | 5635 |
| showAllSymbol:true | 50 / 50 | 25 | 18230 |
| showAllSymbol:false | 25 / 50 | 25 | 11965 |
| showAllSymbol:true + axisLabel.interval:0 | 50 / 50 | 50 | 22484 |
| 10 个点(不密集) | 10 / 10 | 10 | 5169 |
LineSeries.js 的默认值是 showAllSymbol: 'auto',注释写着「If possible, show all symbols, otherwise follow the label interval strategy」。实现方式是把符号的可见性和轴标签的抽稀结果绑在一起:轴标签被抽稀成每两个显示一个,符号就跟着只剩一半。想让符号全出来,写 showAllSymbol:true(代价:字节数从 11965 → 18230,翻半倍);想彻底关掉符号,写 showSymbol:false(5635 字节)。数据点少于轴能容纳的数量时(10 点那行),三种写法没有区别。
九、sampling:不是「大数据就自动降采样」,而是「点比像素多才降」
LineSeries.js 的默认值是 sampling: 'none'。真正的开关写在 processor/dataSample.js 里,条件有三个:
var count = data.count();
if (count > 10 && coordSys.type === 'cartesian2d' && sampling) {
var extent = baseAxis.getExtent();
var size = Math.abs(extent[1] – extent[0]) * (dpr || 1);
var rate = Math.round(count / size);
if (isFinite(rate) && rate > 1) { /* 才真的采样 */ }
}
把这三行翻译成人话:数据条数要大于 10;坐标系必须是直角坐标(极坐标/地理上的折线不吃这套);而且点数要超过基准轴的像素长度(rate = round(点数 / 轴像素长) 必须 > 1)。rate 就是「几个点合成一个」的帧长,1/rate 作为降采样比例传给数据层。
实测扫描(400×260 画布、绘图区宽 380px、正弦数据、showSymbol:false):
| 10 | 10 | 卡在 count > 10 之外 |
| 11 | 11 | rate = round(11/380) = 0 → 不采 |
| 100 | 100 | round(0.26) = 0 → 不采 |
| 380 | 380 | round(1.0) = 1 → 不采 |
| 400 | 400 | round(1.05) = 1 → 不采 |
| 1000 | 334 | round(2.63) = 3 → 每 3 个点出一个 |
也就是说,你画 400 个点的月度趋势,sampling 一个字也不会生效;只有到几千点以上才有意义。另一组实测(1000 点、400px 宽)给出各取值的对比:
| 'none' | 960 | 11036 |
| 'average' | 334 | 4410 |
| 'max' | 334 | 4368 |
| 'min' | 334 | 4354 |
| 'sum' | 334 | 4687 |
| 'lttb' | 334 | 4348 |
| 'abc'(非法) | 960 | 11036(等同不采) |
注意 'none' 那一行的 960 而不是 1000:源码会跳过「长度平方 < 0.5」的极短线段(if (dx*dx + dy*dy < 0.5) continue),40 个点因为像素上重合被合并掉了。这条规则会影响你对 SVG 元素计数的一切预期。
lttb(Largest-Triangle-Three-Buckets)走的是另一条路:data.lttbDownSample(),在 rate > 1 时先执行,之后还会再跑一遍普通 sampler(源码里没有 return),所以它的字节数最低但形状保持最好——适合股价/传感器这种「要看趋势但不要毛刺」的场景。
两个没写在文档表里但实测有效的取值:'minmax'(1000 点 → 652 点,专门保留极值,比 average 胖但峰谷不丢)和直接传函数:
sampling: function (frame) { return Math.max.apply(null, frame); } // 等价于 'max'
isString(sampling) 走查表,isFunction(sampling) 直接把回调当 sampler。这两条在 5.6.0 上同样可用(实测两版数字一致:334 / 652 / 334)。
十、symbol 家族:六个键的真实语义与「无效值不报错」清单
| symbol | circle/rect/roundRect/triangle/diamond/pin/arrow/emptyCircle(默认)都能画出各自单位路径;'none' → 0 个符号;''(空串)→ 落回默认 emptyCircle;'nonex'(拼错)→ 照样画 6 个符号,看起来和默认一样,其实画的是空路径 |
| symbolSize | 数字 = 直径像素(scale=size/2);数组 [30,10] = 椭圆;回调 (value, params) => params.dataIndex*4 可用;'abc' → scale 0(图元仍在,看不见) |
| symbolRotate | 45 → 矩阵变成 [2.121,-2.121,2.121,2.121,…](真旋转);'45' 字符串同样生效 |
| symbolOffset | [20,10] → 平移 (+20,+10);['50%','50%'] 是相对符号自身尺寸的百分比,symbolSize:6 时只偏移 3px |
| symbolPosition | 写 'inside'/'bottom'/'abc' → 矩阵与默认完全一致。这个键属于 pictorialBar(第 14 章),折线上写了没有任何作用 |
| symbolClip | 折线没有这个键(只有 pictorialBar 有)。写 symbolClip:true/false 时符号数都是 4(被 clip 决定),两版实测一致 |
| symbolKeepAspect | 对内置符号无效([30,10] + keepAspect:true 仍是 matrix(15,0,0,5));只在 path:// 与 image:// 符号上起作用(第 15 章展开) |
path:// 自定义符号在折线上是可以用的,实测 'path://M0,0 L10,10 L0,10 Z' 被归一化成单位路径 d="M-1 -1L1 1L-1 1Z" 再乘 scale——库会把你画的形状塞进 2×2 的单位盒,所以自定义 path 的坐标尺度不重要,比例才重要。
十一、lineStyle:宽度为 1 时属性消失,虚线长度由线宽决定
| 不写 | 2 | 默认 |
| width:10 | 10 | — |
| width:1 | 属性被省略 | 仍是 1px(SVG 默认值就是 1,zrender 不写重复的默认属性) |
| width:0 | 0 | 线消失,但符号还在 |
| width:-3 | -3(原样写出) | 库不做夹逼;负数在 SVG 里是非法值,浏览器会忽略这条声明 |
| 'solid' / 不写 | 无属性 |
| 'dashed' | 8,4 |
| 'dotted' | 2 |
| [6,3] | 6,3 |
| [2,2,8] | 2,2,8(奇数个也原样写,SVG 会自行重复成偶数个) |
| 'abc'(非法) | 无属性(实线) |
dashed 的 8,4 和 dotted 的 2 会随 width 等比放大(zrender 里虚线模板按线宽缩放),所以 width:6 + 'dotted' 的点会明显变粗——想让点保持细小,只能自己写 type:[2,2] 并把 width 压低。
颜色优先级实测(G2 组,折线本体的 stroke):
| 什么都不写 | #5070dd(调色板第 1 色) |
| series.color:'red' | red |
| series.itemStyle.color:'red' | red(注意:这里 itemStyle 的 color 会同时传给 lineStyle) |
| series.lineStyle.color:'blue' | blue |
| itemStyle 与 lineStyle 都给 | blue(lineStyle 赢) |
| data 项里写 itemStyle.color | 折线不变(系列级颜色),颜色落在该点符号的描边上 |
| itemStyle.color 回调 + symbol:'circle' | 落在符号的 fill 上 |
最后两行是同一个事实的两面:按数据项染色时,emptyCircle 的 fill 永远是白的,所以你的颜色只能出现在描边;换成 circle 才会出现在填充。默认描边宽就是第 12 章说的 2px,想让按项颜色显眼,要么改 symbol,要么把 symbolSize 拉大。
十二、clip:裁的不是路径,是「可见性」
LineSeries.js 默认 clip: true。把 yAxis.min/max 收成 20~80(数据 10~93 有点出界)实测:
| 默认 clip:true | [-42, 290](路径照原样画到界外) | 3 | 142, 122, 46(只有界内的三个点) |
| clip:false | [-42, 290] | 6 | 290, -14, 142, -42, 122, 46 |
真相是两件事叠加:折线路径本身不裁剪,靠 <g clip-path="url(#zrN-c0)"> 把整组裁掉(clip:false 时这个 <g> 还在但 clip 形状被放大到能容下越界符号);符号则是逐个判断、界外的直接不创建(LineView.js 的 clipShapeForSymbol,注释明确 gated on clip)。所以 clip:false 的副作用是「超出范围的点重新出现」,而 clip:true 下你永远数不到那些符号——排查「数据有 6 个点但图上只有 4 个圈」时先想到这里。
出界还有第三种成因:轴被数据撑开后又被 min/max 收窄(本例);第一种是 boundaryGap:false 时首尾点压在边界上(第十节);第二种是 log 轴静默丢弃非正值(第 12 章坑 5,那种情况连线都画不出来,与 clip 无关)。
十三、标记三件套:markLine 会炸图,markPoint 会把均值挪到最近点
三者的 defaultOption(源码 component/marker/Mark*Model.js):
| markLine | 5 | ['circle','arrow'] | [8,16] | {show:true, position:'end', distance:5} | lineStyle.type:'dashed'、precision:2、emphasis 时线宽 3 |
| markPoint | 5 | 'pin' | 50 | {show:true, position:'inside'} | itemStyle.borderWidth:2 |
| markArea | 1 | — | — | {show:true, position:'top'} | animation:false、itemStyle.borderWidth:0 |
合法 type 只有 average/min/max/median(markerHelper.js 的 markerTypeCalculator 就这四个键)。写错的后果两类完全不同:
| markLine.data:[{type:'avg'}] | 抛异常:Cannot read properties of undefined (reading 'coord'),整张图渲染失败(不是只丢这条线) |
| markLine.data:[{type:'standarddev'}] | 同上(5.x 早期有过 standarddev,6.1 的计算器里没有) |
| markLine.data:[{}](没有 type 也没有 coord) | 同上 |
| markPoint.data:[{type:'avg'}] | 静默忽略,符号数从 7 掉到 6(只剩那个图例点),不打警告 |
markLine 之所以炸,是因为 MarkLineView 的 filter 会读 item[0].coord,而 type 认不出来时数据项没被转换成 coord。这是本章最危险的一个坑——一个拼写错误让整块图消失,控制台只有一行堆栈。类型门禁能拦住它(第 15 章)。
统计值的落点也有讲究。markerTypeCalculatorWithExtent 算出统计值后,用 seriesModel.indicesOfNearest(…) 找回最接近的真实数据点,并把 item.value 强制成那个点的值。于是同一个「平均值」在两个组件里表现不同(数据 10/86/47/93/52/71,均值 59.83):
- markLine {type:'average'}:横线画在 y=106.5(值 59.83 处),标签显示 59.83(precision:2 生效)。
- markPoint {type:'average'}:图钉落在 52 那个点(第 5 个数据点,最接近 59.83),标签显示 52。
markLine 的横向拉伸也是实测出来的:data:[{yAxis:80}] 画出的路径是 M10 106.5L390 106.5——横跨整个绘图区,两端各挂一个 symbol(左 circle、右 arrow):
<path d="M10 106.5L390 106.5" fill="none" stroke="#5070dd" stroke-dasharray="4,2">
<path d="M4 0A4 4 0 1 1 4 -0.4…" transform="matrix(0,-1,1,0,10,106.408)" fill="#5070dd">
<path d="M0 0L5.3 16L0 12L-5.3 16L0 0Z" transform="matrix(0,1,-1,0,390,106.408)" fill="#5070dd">
标签在 x=395,也就是跑到绘图区右边框之外(position:'end' + distance:5)。窄图里它会被容器裁掉,这时候要么写 label:{position:'insideEndTop'},要么把 grid.right 留大一点。
markArea 画的是 <polygon>,不是 <path>——第一次找它的时候按 <path 搜,一无所获,还以为功能没生效:
<polygon points="105 250 231.7 250 231.7 10 105 10"
fill="rgb(80,112,221)" fill-opacity="0.4" stroke="#5070dd" stroke-width="0">
三件事:默认填充是系列色 + fill-opacity:0.4;itemStyle.color/opacity/borderColor/borderWidth 都能覆盖(实测变 fill="#ff0" fill-opacity="0.9" stroke="red" stroke-width="2");只给 {xAxis:'2月'}→{xAxis:'4月'} 时 y 自动铺满整个绘图区(10~250),只给 {yAxis:20}→{yAxis:60} 时 x 自动铺满(10~390)。
类目轴上的区间是「贴着刻度」而不是「贴着格子」:2月~4月 的 x 是 105→231.7,正好是 2 月和 4 月两个点的 x 位置,而不是「2 月格子的左边 ~ 4 月格子的右边」(那应该是 73.3→263.3)。想在类目轴上标出「整个 2 月到整个 4 月」,得自己扩边界:把起点写成 {xAxis:'1月'} 再靠 coord 微调,或者换 time 轴——实测 time 轴上 xAxis: 2025-01-02 → 2025-01-03 得到 points="136.7 … 263.3 …",与那两个数据点的 x 完全一致,边界精确到日。第 4 节的促销标注就靠这个。
markArea 的 z:1 意味着它默认画在折线底下(系列 z=3),这正是我们想要的效果:底色不遮线。
十四、triggerEvent:6.1 新增,'line' 与 'area' 能分开点
LineView.js 的最后几行决定了事件挂在哪:
var triggerEvent = seriesModel.get('triggerEvent');
var triggerLineEvent = seriesModel.get('triggerLineEvent');
if (process.env.NODE_ENV !== 'production') {
triggerLineEvent && warnDeprecated('triggerLineEvent', 'Use the `triggerEvent` option instead.');
}
var shouldTriggerLineEvent = triggerLineEvent === true || triggerEvent === true || triggerEvent === 'line';
var shouldTriggerAreaEvent = triggerLineEvent === true || triggerEvent === true || triggerEvent === 'area';
this.packEventData(seriesModel, polyline, shouldTriggerLineEvent);
polygon && this.packEventData(seriesModel, polygon, shouldTriggerAreaEvent);
packEventData 往图元的 ecData.eventData 里塞 {componentType:'series', componentSubType:'line', seriesIndex, seriesName, seriesType:'line', selfType: el === this._polygon ? 'area' : 'line'}。
这件事可以不用浏览器就验证:SSR 实例照样有 chart.getZr(),可以拿到图元列表并手动触发:
var list = chart.getZr().storage.getDisplayList();
var targets = list.filter(el => /polyline|polygon/.test(el.type));
targets.forEach(el => chart.getZr().trigger('click', { target: el, topTarget: el }));
实测结果(chart.on('click', …) 里打印 componentType/seriesType/dataIndex/name/value/selfType):
| 不写 / false | 一次都没有(点线和点面都没反应) |
| true | 两次:series/line + selfType:'area',series/line + selfType:'line' |
| 'line' | 只有 selfType:'line' |
| 'area' | 只有 selfType:'area' |
| 'abc'(非法) | 一次都没有(静默) |
| 5.6.0 + triggerEvent:'line' | 一次都没有——5.6 不认这个键 |
| 5.6.0 + triggerLineEvent:true | 两次,但 selfType 是 undefined |
三点结论:triggerEvent 是 6.1 新增;'line'/'area' 的粒度、以及事件参数里的 selfType 都是 6.1 才有的;triggerLineEvent 在 6.1 仍然能用(向后兼容),但 dev 包会打废弃警告、min 包静默。默认不写时折线本体根本不接受点击——很多人以为「ECharts 的线点不动是 bug」,其实是这个键没开。注意它和 tooltip.trigger:'axis' 无关:轴触发的 tooltip 不需要 triggerEvent。
十五、多条线、颜色槽位与图例联动
6.1 的默认调色板在 visual/tokens.js:
theme: ['#5070dd','#b6d634','#505372','#ff994d','#0ca8df','#ffd10a','#fb628b','#785db0','#3fbe95']
(5.6.0 是 #5470c6, #91cc75, #fac858, …。同一份最简折线在两版的差异只有两处:线色,以及符号 scale 2 vs 3,即 symbolSize 从 4 变成了 6。)
显式写 color 的系列不占调色板槽位。四条线、第 2/3 条写死 red/blue,实测:["#5070dd","red","blue","#b6d634"]——第四条拿到的是色板第 2 色 #b6d634 而不是第 4 色。含义是:给中间某条线写死颜色,后面所有线的颜色都会往前挪一位。跨图表想固定「外卖永远是橙色」,要么按名字 color 数组对齐、要么给每条线都写死。
图例联动方面实测:legend.selected:{'A':false} 时 A 的线根本不产生图元(只剩 #b6d634 一条);legend.data 里写了不存在的名字('C')不会把已有系列藏起来,也不会警告。还有一条容易吃暗亏的:被图例关掉的系列从堆叠组里整个消失,不是「挪到基线上」——实测把 stack 组里那条 −12.52 的系列关掉,其余三条从 −5.63 / 2.8 / 11.37 变成 6.87 / 15.33 / 23.87,全体上移 12.52 万(原因写在 dataStack.js:106 的注释里:stack 分组列表本身依赖图例选择)。更细的图例规则在第 18 章。
十六、SSR 测不出来的三件事:emphasis、progressive、hoverLayerThreshold
诚实交代探针的边界。用 chart.dispatchAction({type:'highlight', seriesIndex:0, dataIndex:2}) 之后再取一次 renderToSVGString(),实测两次字符串完全相同(差异字节 = 0),无论 emphasis.focus 是 'series' 还是 'self'、blur.itemStyle.opacity 写不写。原因:高亮/淡出是 zrender 的 state 样式(el.currentStates),只在真实绘制时参与合成,SSR 序列化只写 normal 状态。所以 emphasis 的效果必须在浏览器里看——本章实验台的按钮就是给你亲手点/悬停用的。
progressive 与 large 在折线上同样「写了没用」:3000 点的折线,progressive:1000/large:true/hoverLayerThreshold:0 三种写法的 SVG 都是 28478 或 28484 字节,图元数一样。这不是探针的问题,是设计如此——LineSeries.js 里写得很明白:
// Disable progressive
progressive: 0,
hoverLayerThreshold: Infinity,
折线默认就关掉渐进渲染(渐进是给散点/关系图那种「几十万个独立图元」准备的,见第 15 章);hoverLayerThreshold 是 canvas 渲染器的分层阈值,对 SVG 渲染器无意义。给折线写 large:true 不会报错,但也不会变快——这是从散点图章节抄配置过来时的常见事故。
【最小可跑】
这一页不用服务器、不用 fetch,双击就能跑。它把本章最重要的一个能力交到你手上:用 renderToSVGString() 把折线拆成图元,再自己数一遍。每按一个按钮,日志里就多一行现场读数——段数、贝塞尔段数、符号数、曲线 y 极值、字节数。你在【原理先行】里看到的每个数字都能在这里复现。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第 13 章 最小可跑:把一条折线拆开数</title>
<style>
body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; }
h1 { font-size: 20px; margin: 0 0 4px; }
#chart { border: 1px solid #d9d2c7; background: #fff; }
#log { background: #f6f4f1; border: 1px solid #d9d2c7; padding: 12px; margin: 12px 0 0;
white-space: pre; overflow: auto; font: 12px/1.7 Consolas, monospace; min-height: 12em; }
button { padding: 6px 12px; margin: 12px 8px 0 0; cursor: pointer; }
</style>
</head>
<body>
<h1>把一条折线拆开数:段数 / 贝塞尔段 / 符号 / 曲线极值</h1>
<div id="chart" style="width:640px; height:340px;"></div>
<p>
<button id="b1">① 默认直线</button>
<button id="b2">② smooth:true</button>
<button id="b3">③ smooth + monotone:'y'</button>
<button id="b4">④ smooth:2</button>
<button id="b5">⑤ step:'middle'</button>
<button id="b6">⑥ areaStyle</button>
<button id="b7">⑦ 中间缺点</button>
<button id="b8">⑧ 缺点 + connectNulls</button>
</p>
<pre id="log"></pre>
<script src="../../lib/echarts.min.js"></script>
<script>
// 用 SVG 渲染器:只有它能把图元当文本读出来(canvas 读不到)。
var chart = echarts.init(document.getElementById('chart'), null, { renderer: 'svg' });
var logEl = document.getElementById('log');
function log(msg) { logEl.textContent += msg + '\\n'; }
var CATS = ['1月', '2月', '3月', '4月', '5月', '6月'];
var DATA = [10, 86, 47, 93, 52, 71];
function baseOpt() {
return {
grid: { left: 46, right: 24, top: 24, bottom: 34 },
xAxis: { type: 'category', data: CATS.slice() },
yAxis: { type: 'value', min: 0, max: 100 },
series: [{ type: 'line', data: DATA.slice() }]
};
}
// SVG 原文 → 图元统计。折线本体 = fill="none" 且有 stroke 且 d 较长的那个 path。
function analyze(svg) {
var body = svg.replace(/<defs[\\s\\S]*?<\\/defs>/g, '');
var lines = [], areas = [], symbols = 0, m;
var rePath = /<path([^>]*)>/g;
while ((m = rePath.exec(body))) {
var a = m[1];
var d = (/d="([^"]*)"/.exec(a) || [])[1] || '';
if (!d) continue;
if (/transform="matrix/.test(a)) { symbols++; continue; } // 符号:单位路径 + 缩放矩阵
var fill = (/fill="([^"]*)"/.exec(a) || [])[1];
var stroke = (/stroke="([^"]*)"/.exec(a) || [])[1];
if (fill === 'none' && stroke && d.length > 24) lines.push(d); // 轴线/网格线只有两个点,被长度筛掉
else if (fill && fill !== 'none' && d.length > 10) areas.push(d);
}
var polyPts = [];
var rePoly = /<polygon[^>]*points="([^"]*)"/g;
while ((m = rePoly.exec(body))) polyPts.push(m[1]);
lines.sort(function (x, y) { return y.length – x.length; });
var main = lines[0] || '';
return {
// 段数 = d 里 M 的个数。ECharts 把断开的线段塞进同一个 <path>,
// 所以「数 <path> 元素」得到的是 1,而真实段数是 2(⑦ 那一行就是证据)。
segments: main ? (main.match(/M/g) || []).length : 0,
paths: lines.length, main: main,
turns: main ? (main.match(/[ML]/g) || []).length : 0,
bezier: main ? (main.match(/C/g) || []).length : 0,
symbols: symbols, areas: areas.length, polygons: polyPts.length,
bytes: svg.length
};
}
// 三次贝塞尔的真实 y 极值:必须按参数方程采样,只看端点会漏掉曲线鼓出去的部分。
function bezierY(d) {
var lo = Infinity, hi = –Infinity, py = 0;
var re = /([MLC])([^MLC]+)/g, m;
while ((m = re.exec(d))) {
var a = m[2].trim().split(/[\\s,]+/).filter(function (s) { return s.length; }).map(Number);
if (m[1] !== 'C') {
for (var i = 0; i + 1 < a.length; i += 2) { py = a[i + 1]; lo = Math.min(lo, py); hi = Math.max(hi, py); }
} else {
for (var j = 0; j + 5 < a.length; j += 6) {
var c0y = a[j + 1], c1y = a[j + 3], ey = a[j + 5];
for (var k = 0; k <= 200; k++) {
var t = k / 200, u = 1 – t;
var y = u * u * u * py + 3 * u * u * t * c0y + 3 * u * t * t * c1y + t * t * t * ey;
lo = Math.min(lo, y); hi = Math.max(hi, y);
}
py = ey;
}
}
}
return { min: Math.round(lo * 100) / 100, max: Math.round(hi * 100) / 100 };
}
function draw(label, mut) {
var opt = baseOpt();
mut(opt);
chart.setOption(opt, true);
var svg = typeof chart.renderToSVGString === 'function' ? chart.renderToSVGString() : '';
var s = analyze(svg);
var ext = s.main ? bezierY(s.main) : { min: 0, max: 0 };
log(label.padEnd(22) +
' 段数=' + s.segments +
' 折点=' + s.turns +
' 贝塞尔=' + s.bezier +
' 符号=' + s.symbols +
' 面积=' + (s.areas + s.polygons) +
' 曲线y=[' + ext.min + ',' + ext.max + ']' +
' 字节=' + s.bytes);
log(' d=' + (s.main.slice(0, 76) + (s.main.length > 76 ? '…' : '')));
}
draw('① 默认直线', function (o) { });
draw('② smooth:true', function (o) { o.series[0].smooth = true; });
draw("③ smooth+monotone:'y'", function (o) { o.series[0].smooth = true; o.series[0].smoothMonotone = 'y'; });
draw('④ smooth:2', function (o) { o.series[0].smooth = 2; });
draw("⑤ step:'middle'", function (o) { o.series[0].step = 'middle'; });
draw('⑥ areaStyle', function (o) { o.series[0].areaStyle = {}; });
draw('⑦ 中间缺点', function (o) { o.series[0].data = [10, 86, null, 93, 52, 71]; });
draw('⑧ 缺点+connectNulls', function (o) {
o.series[0].data = [10, 86, null, 93, 52, 71];
o.series[0].connectNulls = true;
});
log('数据边界:值 10 → 某 y,值 90 → 更小的 y。③ 那一行的曲线 y 会越过数据端点,这就是唯一真的「过冲」。');
log('提示:本页面用 SVG 渲染器(third arg 的 renderer:"svg"),所以能把图元当文本读出来。');
</script>
</body>
</html>
跑法:双击 code/ch13/min.html(extract.mjs 会把它重建出来)。日志里能直接对上正文的几组数字:①②两行段数都是 1、折点分别是 6 和 6,但 贝塞尔 从 0 变 4;③ 的「曲线 y」会突破数据端点;⑤ 折点从 6 涨到 16;⑦ 段数 2、符号 5;⑧ 段数 1、符号仍是 5。
【逐步加料】
最小可跑那页只拆了一条线。实验台要把前面十六节里每一条能读出来的结论做成面板上的数字:符号的 scale 与环宽、平滑的曲线极值、台阶的折点数、面积的封底、缺失值切出的段数、堆叠的落点、采样的开关阈值,以及标记三件套在真实数据上的位置。九个实例全部用 SVG 渲染器(echarts.init(dom, null, {renderer:'svg'}))——只有 SVG 能把图元当文本读出来,这是本章一切验证的前提。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>第 13 章 折线图实验台</title>
<style>
body { font: 14px/1.7 "Microsoft YaHei", sans-serif; margin: 24px; color: #2b2b2b; }
h1 { font-size: 20px; margin: 0 0 6px; }
h2 { font-size: 15px; margin: 28px 0 6px; border-left: 4px solid #8a5a2b; 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; }
.hint { font-size: 12px; color: #6f675c; margin: 4px 0 0; }
</style>
</head>
<body>
<h1>折线图实验台:符号 / 平滑 / 台阶 / 面积 / 缺失 / 堆叠 / 采样 / 标记</h1>
<p id="status">正在加载数据……</p>
<h2>面板一 · symbol 家族:数 scale 和环宽</h2>
<div id="p1" class="chart" style="width:640px; height:280px;"></div>
<p>
<button id="b1a">emptyCircle(默认)</button>
<button id="b1b">circle</button>
<button id="b1c">rect</button>
<button id="b1d">roundRect</button>
<button id="b1e">triangle</button>
<button id="b1f">diamond</button>
<button id="b1g">pin</button>
<button id="b1h">arrow</button>
<button id="b1i">'none'</button>
<button id="b1j">'nonex'(拼错)</button>
<button id="b1k">symbolSize:20</button>
<button id="b1l">symbolSize:[30,10]</button>
<button id="b1m">symbolSize 回调</button>
<button id="b1n">symbolPosition:'inside'</button>
<button id="b1o">itemStyle.borderWidth:6</button>
</p>
<p class="hint">看第二行那个「局部线宽 × scale」:不管符号多大,它永远是 2。</p>
<p id="p1Info" class="readout"></p>
<h2>面板二 · smooth 实验室:曲线到底会不会冲出数据范围</h2>
<div id="p2" class="chart" style="width:640px; height:280px;"></div>
<p>
<button id="b2a">false</button>
<button id="b2b">0.2</button>
<button id="b2c">true(≡0.5)</button>
<button id="b2d">0.8</button>
<button id="b2e">1</button>
<button id="b2f">3</button>
<button id="b2g">'abc'(非法)</button>
<button id="b2h">true + monotone:'x'</button>
<button id="b2i">true + monotone:'y'</button>
</p>
<p class="hint">数据是锯齿 10/90/10/90/10/90。只有最后一行的「过冲」会是「是」。</p>
<p id="p2Info" class="readout"></p>
<h2>面板三 · step 的五种写法</h2>
<div id="p3" class="chart" style="width:640px; height:260px;"></div>
<p>
<button id="b3a">false</button>
<button id="b3b">true</button>
<button id="b3c">'start'</button>
<button id="b3d">'middle'</button>
<button id="b3e">'end'</button>
<button id="b3f">'abc'(非法)</button>
<button id="b3g">step + smooth 同时开</button>
</p>
<p id="p3Info" class="readout"></p>
<h2>面板四 · areaStyle.origin:跨零数据的封底在哪儿</h2>
<div id="p4" class="chart" style="width:640px; height:280px;"></div>
<p>
<button id="b4a">不写</button>
<button id="b4b">'auto'</button>
<button id="b4c">'start'</button>
<button id="b4d">'end'</button>
<button id="b4e">数字 20</button>
<button id="b4f">'yAxis'(旧写法)</button>
<button id="b4g">全正数据 + 不写</button>
</p>
<p class="hint">默认那组数据是 −20/15/−8/30/−12/25,轴 −40~60,所以 0 线在绘图区中间。</p>
<p id="p4Info" class="readout"></p>
<h2>面板五 · 缺失值(真实数据:2025 年 365 天,4 天为 null)</h2>
<div id="p5" class="chart" style="width:900px; height:300px;"></div>
<p>
<button id="b5a">默认(断线)</button>
<button id="b5b">connectNulls:true</button>
<button id="b5c">connectNulls + 面积</button>
<button id="b5d">把 null 换成 0(错误做法)</button>
<button id="b5e">保留符号</button>
</p>
<p id="p5Info" class="readout"></p>
<h2>面板六 · 堆叠与 stackStrategy(真实数据:2025 品类环比增量,有正有负)</h2>
<div id="p6" class="chart" style="width:900px; height:340px;"></div>
<p>
<button id="b6a">不堆叠</button>
<button id="b6b">stack,strategy 不写</button>
<button id="b6c">'samesign'</button>
<button id="b6d">'all'</button>
<button id="b6e">'positive'</button>
<button id="b6f">'negative'</button>
<button id="b6g">'abc'(非法,静默)</button>
<button id="b6h">series 数组倒序</button>
</p>
<p class="hint">读数盯 2025-10 那一列:经典美式 +6.87、燕麦拿铁 +8.45、卡布奇诺 +8.55、冷萃冰沙 −12.52(万元)。</p>
<p id="p6Info" class="readout"></p>
<h2>面板七 · sampling 的阈值:点比像素多才采样</h2>
<div id="p7" class="chart" style="width:640px; height:260px;"></div>
<p>
<button id="b7a">11 点 average</button>
<button id="b7b">580 点 average</button>
<button id="b7c">1000 点 none</button>
<button id="b7d">1000 点 average</button>
<button id="b7e">1000 点 max</button>
<button id="b7f">1000 点 min</button>
<button id="b7g">1000 点 sum</button>
<button id="b7h">1000 点 lttb</button>
<button id="b7i">1000 点 minmax</button>
<button id="b7j">1000 点 函数</button>
<button id="b7k">3000 点 average</button>
</p>
<p class="hint">绘图区宽 580 CSS 像素;源码里阈值用的是设备像素,所以同一页在不同 dpr 的屏幕上结果不同。</p>
<p id="p7Info" class="readout"></p>
<h2>面板八 · 实战:36 个月趋势 + 标记三件套 / 2025 与 2024 同期对比</h2>
<div id="p8a" class="chart" style="width:900px; height:360px;"></div>
<p>
<button id="b8a1">全部标记</button>
<button id="b8a2">无标记</button>
<button id="b8a3">只 markPoint</button>
<button id="b8a4">只 markArea(类目轴)</button>
<button id="b8a5">markLine type:'avg'(炸图演示)</button>
</p>
<p id="p8aInfo" class="readout"></p>
<div id="p8b" class="chart" style="width:900px; height:340px; margin-top:18px;"></div>
<p>
<button id="b8b1">triggerEvent 不写</button>
<button id="b8b2">true</button>
<button id="b8b3">'line'</button>
<button id="b8b4">'area'</button>
<button id="b8b5">重新生成图(清日志)</button>
</p>
<p class="hint">切换上面四个按钮之后,用鼠标点折线本体或色块,日志会记下事件参数。</p>
<p id="p8bLog" class="readout">等待点击……</p>
<script src="../../lib/echarts.min.js"></script>
<script src="app.js"></script>
</body>
</html>
// 第 13 章实验台:八个面板、九个实例,全部 SVG 渲染器,读数是当场从 SVG 里拆出来的。
var statusEl = document.getElementById('status');
var daily = null; // data/sales-daily.json:1096 行(2023-01-01 ~ 2025-12-31)
var catMonth = null; // data/sales-category-month.json:288 行(36 月 × 8 品类)
fetch('../../data/sales-daily.json')
.then(function (r) { return r.json(); })
.then(function (d) { daily = d; return fetch('../../data/sales-category-month.json'); })
.then(function (r) { return r.json(); })
.then(function (c) {
catMonth = c;
statusEl.textContent = '数据加载完成:日销 ' + daily.length + ' 行、品类月销 ' + catMonth.length + ' 行。'
+ '每个面板下面的黄框是从 renderToSVGString() 的原文里数出来的,不是写死的。';
initAll();
})
.catch(function (e) {
statusEl.textContent = '数据加载失败:' + e.message + '。请用 node tools/serve.mjs 启动服务器后访问本页。';
});
function initAll() {
var panels = [initPanel1, initPanel2, initPanel3, initPanel4, initPanel5, initPanel6, initPanel7, initPanel8];
for (var i = 0; i < panels.length; i++) {
try { panels[i](); }
catch (e) { statusEl.textContent += '\\n' + panels[i].name + ' 初始化抛错:' + e.message; }
}
}
/* ===== 读数小工具:把 SVG 原文拆成图元 ===== */
function svgOf(chart) {
return typeof chart.renderToSVGString === 'function' ? chart.renderToSVGString() : '';
}
function r2(n) { return Math.round(n * 100) / 100; }
function eachPath(svg, cb) {
var body = svg.replace(/<defs[\\s\\S]*?<\\/defs>/g, ''); // <defs> 里有 clipPath 的矩形,会混进统计
var m, re = /<path([^>]*)>/g;
while ((m = re.exec(body))) {
var a = m[1];
var d = (/d="([^"]*)"/.exec(a) || [])[1] || '';
if (d) cb(d, a);
}
}
function nums(d) {
return (d.match(/-?\\d+(?:\\.\\d+)?(?:e-?\\d+)?/gi) || []).map(Number);
}
// 折线本体:fill="none" 且有 stroke,且整条路径的坐标对 ≥ 3(轴线只有两个点,被这条筛掉)。
// 关键:ECharts 把一条系列的多个线段塞进**同一个 <path>**,段与段之间用 M 分隔。
// 所以先按 path 筛,再把 d 拆成子路径——数 <path> 元素的人会永远数出「1 段」。
function lineDs(svg) {
var out = [];
eachPath(svg, function (d, a) {
if (/transform="matrix/.test(a)) return;
if (!(/fill="none"/.test(a) && /stroke=/.test(a))) return;
if (nums(d).length < 6) return; // 整条路径太短 → 不是折线本体
var parts = d.split(/(?=M)/); // 按 M 拆成子路径
for (var i = 0; i < parts.length; i++) {
if (nums(parts[i]).length >= 4) out.push(parts[i]); // 两个点的短段要保留
}
});
return out;
}
function mainD(svg) {
var l = lineDs(svg);
l.sort(function (x, y) { return y.length – x.length; });
return l[0] || '';
}
function areaD(svg) {
var best = '';
eachPath(svg, function (d, a) {
if (/transform="matrix/.test(a)) return;
var f = (/fill="([^"]*)"/.exec(a) || [])[1];
if (!f || f === 'none') return;
if (d.length > best.length) best = d;
});
return best;
}
// 符号 = 单位路径 + matrix(sx,0,0,sy,tx,ty)
function symbolList(svg) {
var out = [];
eachPath(svg, function (d, a) {
var t = /transform="matrix\\(([^)]*)\\)/.exec(a);
if (!t) return;
var p = t[1].split(',').map(Number);
out.push({
sx: p[0], sy: p[3], x: p[4], y: p[5], raw: t[1],
sw: parseFloat((/stroke-width="([^"]*)"/.exec(a) || [])[1]),
d0: d
});
});
return out;
}
// 三次贝塞尔的真实 y 极值:必须按参数方程采样,只看端点会漏掉鼓出去的部分
function bezierY(d) {
var lo = Infinity, hi = –Infinity, py = 0;
var m, re = /([MLC])([^MLC]+)/g;
while ((m = re.exec(d))) {
var a = nums(m[2]);
if (m[1] !== 'C') {
for (var i = 1; i < a.length; i += 2) { py = a[i]; lo = Math.min(lo, py); hi = Math.max(hi, py); }
} else {
for (var j = 5; j < a.length; j += 6) {
var c0y = a[j – 4], c1y = a[j – 2], ey = a[j];
for (var k = 0; k <= 200; k++) {
var t = k / 200, u = 1 – t;
var y = u * u * u * py + 3 * u * u * t * c0y + 3 * u * t * t * c1y + t * t * t * ey;
lo = Math.min(lo, y); hi = Math.max(hi, y);
}
py = ey;
}
}
}
return { min: r2(lo), max: r2(hi) };
}
// 把像素 y 反算成轴上的数值。finder 必须同时给两个轴,只给 yAxisIndex 会返回 null。
function yToValue(chart, y) {
try {
var v = /** @type {any} */ (chart).convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [0, y]);
return (v && isFinite(v[1])) ? r2(v[1]) : null;
} catch (e) { return null; }
}
function bind(id, fn) { document.getElementById(id).onclick = fn; }
/* ===== 面板一:symbol 家族 ===== */
function initPanel1() {
var chart = echarts.init(document.getElementById('p1'), null, { renderer: 'svg' });
var info = document.getElementById('p1Info');
var CATS = ['1月', '2月', '3月', '4月', '5月', '6月'];
var DATA = [10, 86, 47, 93, 52, 71];
function draw(mode) {
var opt = {
grid: { left: 40, right: 20, top: 24, bottom: 34 },
xAxis: { type: 'category', data: CATS.slice() },
yAxis: { type: 'value', min: 0, max: 100 },
series: [{ type: 'line', data: DATA.slice() }]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode === 'b') s.symbol = 'circle';
else if (mode === 'c') s.symbol = 'rect';
else if (mode === 'd') s.symbol = 'roundRect';
else if (mode === 'e') s.symbol = 'triangle';
else if (mode === 'f') s.symbol = 'diamond';
else if (mode === 'g') s.symbol = 'pin';
else if (mode === 'h') s.symbol = 'arrow';
else if (mode === 'i') s.symbol = 'none';
else if (mode === 'j') s.symbol = 'nonex';
else if (mode === 'k') s.symbolSize = 20;
else if (mode === 'l') s.symbolSize = [30, 10];
else if (mode === 'm') s.symbolSize = function (value, params) { return params.dataIndex * 4; };
else if (mode === 'n') s.symbolPosition = 'inside';
else if (mode === 'o') s.itemStyle = { borderWidth: 6, borderColor: 'blue' };
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var syms = symbolList(svgOf(chart));
var f = syms[0];
info.textContent =
'写法=' + mode + ' 符号数=' + syms.length +
' 单位路径 d=' + (f ? f.d0.slice(0, 46) : '—') + '\\n' +
'直径序列=[' + syms.map(function (x) { return r2(x.sx * 2); }).join(',') + ']' +
' 高度序列=[' + syms.map(function (x) { return r2(x.sy * 2); }).join(',') + ']\\n' +
'第一条 matrix=' + (f ? f.raw : '—') +
' 局部线宽=' + (f ? f.sw : '—') +
' 局部线宽×scale=' + (f ? r2(f.sw * f.sx) : '—') + ' ← 恒等于 2,放大符号不会让环变粗';
}
bind('b1a', function () { draw('a'); });
bind('b1b', function () { draw('b'); });
bind('b1c', function () { draw('c'); });
bind('b1d', function () { draw('d'); });
bind('b1e', function () { draw('e'); });
bind('b1f', function () { draw('f'); });
bind('b1g', function () { draw('g'); });
bind('b1h', function () { draw('h'); });
bind('b1i', function () { draw('i'); });
bind('b1j', function () { draw('j'); });
bind('b1k', function () { draw('k'); });
bind('b1l', function () { draw('l'); });
bind('b1m', function () { draw('m'); });
bind('b1n', function () { draw('n'); });
bind('b1o', function () { draw('o'); });
draw('a');
}
/* ===== 面板二:smooth 实验室 ===== */
function initPanel2() {
var chart = echarts.init(document.getElementById('p2'), null, { renderer: 'svg' });
var info = document.getElementById('p2Info');
var CATS = ['1月', '2月', '3月', '4月', '5月', '6月'];
var SAW = [10, 90, 10, 90, 10, 90];
function draw(mode) {
var opt = {
grid: { left: 40, right: 20, top: 24, bottom: 34 },
xAxis: { type: 'category', data: CATS.slice() },
yAxis: { type: 'value', min: 0, max: 100 },
series: [{ type: 'line', data: SAW.slice(), symbolSize: 8 }]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode === 'b') s.smooth = 0.2;
else if (mode === 'c') s.smooth = true;
else if (mode === 'd') s.smooth = 0.8;
else if (mode === 'e') s.smooth = 1;
else if (mode === 'f') s.smooth = 3;
else if (mode === 'g') s.smooth = 'abc';
else if (mode === 'h') { s.smooth = true; s.smoothMonotone = 'x'; }
else if (mode === 'i') { s.smooth = true; s.smoothMonotone = 'y'; }
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var d = mainD(svgOf(chart));
var cy = bezierY(d);
var ys = symbolList(svgOf(chart)).map(function (x) { return x.y; });
var dy = ys.length ? { min: r2(Math.min.apply(null, ys)), max: r2(Math.max.apply(null, ys)) } : { min: NaN, max: NaN };
var over = cy.min < dy.min – 0.5 || cy.max > dy.max + 0.5;
info.textContent =
'写法=' + mode + ' C 段数=' + (d.match(/C/g) || []).length +
' 线段数=' + lineDs(svgOf(chart)).length + '\\n' +
'数据点 y=[' + dy.min + ',' + dy.max + '](值 ' + yToValue(chart, dy.max) + ' ~ ' + yToValue(chart, dy.min) + ')\\n' +
'曲线 y=[' + cy.min + ',' + cy.max + '](值 ' + yToValue(chart, cy.max) + ' ~ ' + yToValue(chart, cy.min) + ')' +
' 过冲=' + (over ? '是' : '否') + '\\n' +
'd 片段=' + d.slice(0, 80) + '…';
}
bind('b2a', function () { draw('a'); });
bind('b2b', function () { draw('b'); });
bind('b2c', function () { draw('c'); });
bind('b2d', function () { draw('d'); });
bind('b2e', function () { draw('e'); });
bind('b2f', function () { draw('f'); });
bind('b2g', function () { draw('g'); });
bind('b2h', function () { draw('h'); });
bind('b2i', function () { draw('i'); });
draw('a');
}
/* ===== 面板三:step ===== */
function initPanel3() {
var chart = echarts.init(document.getElementById('p3'), null, { renderer: 'svg' });
var info = document.getElementById('p3Info');
var CATS = ['1月', '2月', '3月', '4月', '5月', '6月'];
var DATA = [10, 86, 47, 93, 52, 71];
function draw(mode) {
var opt = {
grid: { left: 40, right: 20, top: 24, bottom: 34 },
xAxis: { type: 'category', data: CATS.slice() },
yAxis: { type: 'value', min: 0, max: 100 },
series: [{ type: 'line', data: DATA.slice() }]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode === 'b') s.step = true;
else if (mode === 'c') s.step = 'start';
else if (mode === 'd') s.step = 'middle';
else if (mode === 'e') s.step = 'end';
else if (mode === 'f') s.step = 'abc';
else if (mode === 'g') { s.step = 'middle'; s.smooth = true; }
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var d = mainD(svgOf(chart));
info.textContent =
'写法=' + mode + ' 折点数=' + (d.match(/[MLC]/g) || []).length +
' 坐标对=' + (nums(d).length / 2) + '\\n' +
'd=' + d.slice(0, 96) + '…';
}
bind('b3a', function () { draw('a'); });
bind('b3b', function () { draw('b'); });
bind('b3c', function () { draw('c'); });
bind('b3d', function () { draw('d'); });
bind('b3e', function () { draw('e'); });
bind('b3f', function () { draw('f'); });
bind('b3g', function () { draw('g'); });
draw('a');
}
/* ===== 面板四:areaStyle.origin ===== */
function initPanel4() {
var chart = echarts.init(document.getElementById('p4'), null, { renderer: 'svg' });
var info = document.getElementById('p4Info');
var CATS = ['1月', '2月', '3月', '4月', '5月', '6月'];
var CROSS = [–20, 15, –8, 30, –12, 25];
var POS = [10, 22, 14, 30, 18, 26];
function draw(mode) {
var cross = mode !== 'g';
var opt = {
grid: { left: 44, right: 20, top: 24, bottom: 34 },
xAxis: { type: 'category', data: CATS.slice() },
yAxis: { type: 'value', min: cross ? –40 : 0, max: cross ? 60 : 60 },
series: [{ type: 'line', data: (cross ? CROSS : POS).slice(), areaStyle: {} }]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode === 'b') s.areaStyle.origin = 'auto';
else if (mode === 'c') s.areaStyle.origin = 'start';
else if (mode === 'd') s.areaStyle.origin = 'end';
else if (mode === 'e') s.areaStyle.origin = 20;
else if (mode === 'f') s.areaStyle.origin = 'yAxis';
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var svg = svgOf(chart);
var ad = areaD(svg);
var an = nums(ad);
var baseY = an.length ? r2(an[an.length – 1]) : null;
var zeroY = null;
try {
zeroY = r2(/** @type {any} */ (chart).convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [0, 0])[1]);
} catch (e) { zeroY = null; }
info.textContent =
'写法=' + mode + ' 面积图元=' + (ad ? 1 : 0) + ' 轴范围=[' + opt.yAxis.min + ',' + opt.yAxis.max + ']' + '\\n' +
'封底 y=' + baseY + '(对应值 ' + yToValue(chart, baseY) + ') 0 线 y=' + zeroY +
' 数据 y=[' + bezierY(ad).min + ',' + bezierY(ad).max + ']';
}
bind('b4a', function () { draw('a'); });
bind('b4b', function () { draw('b'); });
bind('b4c', function () { draw('c'); });
bind('b4d', function () { draw('d'); });
bind('b4e', function () { draw('e'); });
bind('b4f', function () { draw('f'); });
bind('b4g', function () { draw('g'); });
draw('a');
}
/* ===== 面板五:真实缺失值 ===== */
function initPanel5() {
var chart = echarts.init(document.getElementById('p5'), null, { renderer: 'svg' });
var info = document.getElementById('p5Info');
var rows = [];
for (var i = 0; i < daily.length; i++) if (daily[i].date.slice(0, 4) === '2025') rows.push(daily[i]);
var days = rows.map(function (r) { return r.date; });
var realSales = rows.map(function (r) { return r.sales; });
var nullDays = [];
for (var k = 0; k < rows.length; k++) if (rows[k].sales === null) nullDays.push(rows[k].date.slice(5));
function draw(mode) {
var data = realSales.slice();
if (mode === 'd') data = data.map(function (v) { return v === null ? 0 : v; });
var opt = {
grid: { left: 70, right: 20, top: 24, bottom: 46 },
xAxis: { type: 'category', data: days.slice() },
yAxis: { type: 'value', name: '日销售额' },
series: [{ type: 'line', data: data, showSymbol: mode !== 'e', lineStyle: { width: 1 } }]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode === 'b' || mode === 'c') s.connectNulls = true;
if (mode === 'c') s.areaStyle = {};
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var svg = svgOf(chart);
var ds = lineDs(svg);
var counts = ds.map(function (d) { return nums(d).length / 2; });
info.textContent =
'写法=' + mode + ' 2025 共 ' + rows.length + ' 天,其中 sales 为 null 的 ' + nullDays.length + ' 天:' + nullDays.join('、') + '\\n' +
'折线被切成 ' + ds.length + ' 段,各段点数=' + counts.join(' + ') + ' 符号=' + symbolList(svg).length +
' 字节=' + svg.length +
((mode === 'a' || mode === 'b' || mode === 'c') ? '\\n(面积/线段跨过缺口时缺失点不落笔,' + (mode === 'c' ? '但 connectNulls 会把两侧直连,看起来像「跌到零再弹回」的前置条件' : '图上留白,读者一眼看出这是缺数据') + ')' : (mode === 'd' ? '\\n换成 0 之后线是连着的,但 4 根针扎到底——这就是把「没营业/没统计」画成「营业额 0」的后果' : ''));
}
bind('b5a', function () { draw('a'); });
bind('b5b', function () { draw('b'); });
bind('b5c', function () { draw('c'); });
bind('b5d', function () { draw('d'); });
bind('b5e', function () { draw('e'); });
draw('a');
}
/* ===== 面板六:堆叠与 stackStrategy ===== */
function initPanel6() {
var chart = echarts.init(document.getElementById('p6'), null, { renderer: 'svg' });
var info = document.getElementById('p6Info');
var IDS = ['C01', 'C02', 'C03', 'C06'];
var NAMES = { C01: '经典美式', C02: '燕麦拿铁', C03: '卡布奇诺', C06: '冷萃冰沙' };
var lookup = {};
for (var i = 0; i < catMonth.length; i++) {
lookup[catMonth[i].categoryId + '|' + catMonth[i].month] = catMonth[i].sales;
}
var months = [];
for (var m = 1; m <= 12; m++) months.push('2025-' + (m < 10 ? '0' : '') + m);
function prev(mm) {
if (mm === '2025-01') return '2024-12';
var n = +mm.slice(5, 7) – 1;
return '2025-' + (n < 10 ? '0' : '') + n;
}
// 环比增量,单位万元:正负都有,2025-09 那一列前三家是正、冷萃冰沙是 −22.97
var deltas = {};
for (var j = 0; j < IDS.length; j++) {
var id = IDS[j], arr = [];
for (var t = 0; t < months.length; t++) {
arr.push(r2(((lookup[id + '|' + months[t]] || 0) – (lookup[id + '|' + prev(months[t])] || 0)) / 10000));
}
deltas[id] = arr;
}
function draw(mode) {
var order = mode === 'h' ? IDS.slice().reverse() : IDS.slice();
var series = [];
for (var i = 0; i < order.length; i++) {
var s = /** @type {any} */ ({ type: 'line', name: NAMES[order[i]], data: deltas[order[i]].slice(), stack: 'g' });
if (mode === 'a') delete s.stack;
else if (mode === 'g') s.stackStrategy = 'abc';
else if (mode === 'c' || mode === 'd' || mode === 'e' || mode === 'f') s.stackStrategy = mode;
series.push(s);
}
var opt = {
grid: { left: 70, right: 20, top: 30, bottom: 46 },
legend: { data: [NAMES.C01, NAMES.C02, NAMES.C03, NAMES.C06] },
xAxis: { type: 'category', data: months.slice() },
yAxis: { type: 'value', name: '环比增量(万元)' },
series: series
};
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var svg = svgOf(chart);
var ds = lineDs(svg);
// 读第 10 个点(2025-10)在每条线上的高度;ds 的顺序就是绘制顺序,即 series 数组顺序
var lines = [];
for (var i2 = 0; i2 < ds.length; i2++) {
var pairs = nums(ds[i2]);
var y = pairs.length >= 20 ? r2(pairs[19]) : null;
lines.push('第' + (i2 + 1) + '条 ' + yToValue(chart, y));
}
info.textContent =
'写法=' + mode + ' 系列顺序=' + order.join('→') + '\\n' +
'2025-10 各线的落点值:' + lines.join(' ') + '\\n' +
'原始增量:' + IDS.map(function (x) { return NAMES[x] + ' ' + deltas[x][9]; }).join(' ') + '\\n' +
'参考:samesign→6.87 / 15.33 / 23.87 / −12.53;all 与 positive→6.87 / 15.33 / 23.87 / 11.37;' +
'negative 与非法值→6.87 / 8.43 / 8.57 / −12.53(= 堆叠失效);倒序不写 strategy→−12.53 / 8.57 / 17 / 23.87';
}
bind('b6a', function () { draw('a'); });
bind('b6b', function () { draw('b'); });
bind('b6c', function () { draw('c'); });
bind('b6d', function () { draw('d'); });
bind('b6e', function () { draw('e'); });
bind('b6f', function () { draw('f'); });
bind('b6g', function () { draw('g'); });
bind('b6h', function () { draw('h'); });
draw('b');
}
/* ===== 面板七:sampling 阈值 ===== */
function initPanel7() {
var chart = echarts.init(document.getElementById('p7'), null, { renderer: 'svg' });
var info = document.getElementById('p7Info');
// 源码用的阈值是「基准轴的像素长度 × devicePixelRatio」,
// 所以这里不拿 640−40−20 当作轴长(outerBounds 会把 grid 往里缩),而是直接问轴。
function axisPx(n) {
try {
var a = /** @type {any} */ (chart).convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [0, 0]);
var b = /** @type {any} */ (chart).convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [n – 1, 0]);
return Math.abs(b[0] – a[0]);
} catch (e) { return NaN; }
}
function draw(n, kind) {
var data = [];
for (var i = 0; i < n; i++) data.push([i, Math.round((Math.sin(i / 9) * 40 + 60) * 100) / 100]);
var opt = {
grid: { left: 40, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'value', min: 0, max: n – 1 },
yAxis: { type: 'value' },
series: [{ type: 'line', data: data, showSymbol: false, sampling: kind }]
};
var s = /** @type {any} */ (opt.series[0]);
if (kind === 'fn') s.sampling = function (frame) {
var mx = –Infinity;
for (var i = 0; i < frame.length; i++) if (frame[i] > mx) mx = frame[i];
return mx;
};
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var svg = svgOf(chart);
var d = mainD(svg);
var dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
var size = r2(axisPx(n));
var rate = Math.round(n / (size * dpr));
info.textContent =
'点数=' + n + ' sampling=' + (kind === 'fn' ? 'function' : kind) +
' 轴像素长=' + size + ' × dpr ' + dpr + ' = ' + r2(size * dpr) + '\\n' +
'源码阈值 rate=round(点数/轴像素)=' + rate +
(rate > 1 && n > 10 ? ' → 每 ' + rate + ' 个点合成 1 个(点数>' + r2(1.5 * size * dpr) + ' 才会生效)' : ' → 不采样(还要求点数 > 10 且是直角坐标)') + '\\n' +
'折线实际点数=' + (nums(d).length / 2) + ' 字节=' + svg.length +
'\\nd 片段=' + d.slice(0, 72) + '…';
}
bind('b7a', function () { draw(11, 'average'); });
bind('b7b', function () { draw(580, 'average'); });
bind('b7c', function () { draw(1000, 'none'); });
bind('b7d', function () { draw(1000, 'average'); });
bind('b7e', function () { draw(1000, 'max'); });
bind('b7f', function () { draw(1000, 'min'); });
bind('b7g', function () { draw(1000, 'sum'); });
bind('b7h', function () { draw(1000, 'lttb'); });
bind('b7i', function () { draw(1000, 'minmax'); });
bind('b7j', function () { draw(1000, 'fn'); });
bind('b7k', function () { draw(3000, 'average'); });
draw(1000, 'none');
}
/* ===== 面板八:实战图与标记三件套 ===== */
function initPanel8() {
initPanel8a();
initPanel8b();
}
function monthTotals() {
var sum = {};
for (var i = 0; i < daily.length; i++) {
var k = daily[i].date.slice(0, 7);
sum[k] = (sum[k] || 0) + (daily[i].sales || 0);
}
var keys = Object.keys(sum).sort();
var out = { months: keys, values: [], wan: [] };
for (var j = 0; j < keys.length; j++) {
out.values.push(r2(sum[keys[j]] / 10000));
out.wan.push(r2(sum[keys[j]] / 10000));
}
return out;
}
function initPanel8a() {
var chart = echarts.init(document.getElementById('p8a'), null, { renderer: 'svg' });
var info = document.getElementById('p8aInfo');
var mt = monthTotals();
function draw(mode) {
var opt = {
grid: { left: 70, right: 90, top: 30, bottom: 60 },
title: { text: '36 个月营业额(万元)与促销月', left: 12, top: 4, textStyle: { fontSize: 14 } },
xAxis: { type: 'category', data: mt.months, axisLabel: { rotate: 45 } },
yAxis: { type: 'value', name: '万元' },
series: [{ type: 'line', name: '月营业额', data: mt.values.slice(), smooth: true, areaStyle: {}, symbolSize: 6 }]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode !== 'none') s.markPoint = { data: [{ type: 'max' }, { type: 'min' }] };
if (mode !== 'none' && mode !== 'mp') s.markLine = { data: [{ type: 'average' }, { yAxis: 300, name: '300 万线' }] };
if (mode !== 'none' && mode !== 'mp' && mode !== 'avg') s.markArea = {
data: [[{ xAxis: '2023-08', name: '会员日上线' }, { xAxis: '2024-08' }]]
};
if (mode === 'avg') s.markLine = { data: [{ type: 'avg' }] };
try {
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
var svg = svgOf(chart);
var pins = [];
eachPath(svg, function (d, a) {
if (/transform="matrix\\(([^)]*)\\)/.test(a) && /^M-0\\.54/.test(d)) pins.push((/matrix\\(([^)]*)\\)/.exec(a) || [])[1]);
});
var pol = (svg.match(/<polygon[^>]*points="([^"]*)"/g) || []).length;
info.textContent =
'写法=' + mode + ' 折线点数=' + (nums(mainD(svg)).length / 2) +
' 面积图元=' + (areaD(svg) ? 1 : 0) + ' polygon(markArea)=' + pol +
' 图钉(markPoint)=' + pins.length + '\\n' +
'markPoint 的图钉位置=' + pins.join(' | ') +
'\\n字节=' + svg.length;
} catch (e) {
info.textContent = '写法=' + mode + ' 抛错:' + e.message +
'\\n整张图没了——这就是 markLine 的 type 拼成 avg 的后果(合法值只有 average/min/max/median)。点「无标记」可以恢复。';
}
}
bind('b8a1', function () { draw('all'); });
bind('b8a2', function () { draw('none'); });
bind('b8a3', function () { draw('mp'); });
bind('b8a4', function () { draw('ma'); });
bind('b8a5', function () { draw('avg'); });
draw('all');
}
function initPanel8b() {
var chart = echarts.init(document.getElementById('p8b'), null, { renderer: 'svg' });
var logEl = document.getElementById('p8bLog');
function mmdd(dateStr) { return +dateStr.slice(5, 7) * 100 + +dateStr.slice(8, 10); }
function pair(year) {
var out = [];
for (var i = 0; i < daily.length; i++) {
if (daily[i].date.slice(0, 4) === year && daily[i].sales !== null) {
out.push([mmdd(daily[i].date), daily[i].sales]);
}
}
return out;
}
function draw(mode) {
var opt = {
grid: { left: 70, right: 30, top: 30, bottom: 46 },
legend: { data: ['2025', '2024'] },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'value', min: 101, max: 1231, interval: 100,
axisLabel: {
formatter: function (v) { return Math.floor(v / 100) + ' 月'; }
}
},
yAxis: { type: 'value', name: '日销售额(元)' },
series: [
{ type: 'line', name: '2025', data: pair('2025'), showSymbol: false, sampling: 'average',
lineStyle: { width: 1.5 }, areaStyle: {},
markArea: { data: [[{ xAxis: 212, name: '春节' }, { xAxis: 218 }]] },
markLine: { data: [{ xAxis: 808, name: '会员日' }] } },
{ type: 'line', name: '2024', data: pair('2024'), showSymbol: false, sampling: 'average',
lineStyle: { width: 1.5, type: 'dashed' } }
]
};
var s = /** @type {any} */ (opt.series[0]);
if (mode === 'true') s.triggerEvent = true;
else if (mode === 'line') s.triggerEvent = 'line';
else if (mode === 'area') s.triggerEvent = 'area';
else if (mode === 'reset') s.triggerEvent = false;
chart.setOption(/** @type {import('echarts').EChartsOption} */ (opt), true);
if (mode === 'reset') logEl.textContent = '已重建图元,等待点击……';
var svg = svgOf(chart);
logEl.textContent += '\\n[' + mode + '] 面积图元=' + (areaD(svg) ? 1 : 0) +
' 线段数=' + lineDs(svg).length + ' 字节=' + svg.length;
}
chart.on('click', function (e) {
var p = /** @type {any} */ (e);
if (p.componentType !== 'series') return;
logEl.textContent = '点击事件:componentType=' + p.componentType +
' seriesType=' + p.seriesType + ' seriesName=' + p.seriesName +
' selfType=' + p.selfType + ' dataIndex=' + p.dataIndex +
' value=' + JSON.stringify(p.value);
});
bind('b8b1', function () { draw('none'); });
bind('b8b2', function () { draw('true'); });
bind('b8b3', function () { draw('line'); });
bind('b8b4', function () { draw('area'); });
bind('b8b5', function () { draw('reset'); });
draw('area');
}
跑法(本章实验台必须有服务器,面板五、六、八都 fetch 了 data/ 目录):
node tools/extract.mjs –write # 把本章代码重建成 code/ch13/
node tools/serve.mjs # 打开 http://127.0.0.1:18000/code/ch13/index.html
min.html 不用服务器,双击就能跑——它只画六写死的数据,一个 fetch 都没有。
app.js 里六处值得单独指出来的写法:
- 读数全部来自 renderToSVGString():lineDs() 先按 path 筛「fill="none" + 有 stroke + 整条路径坐标对 ≥ 3」,这样轴线、网格线(两个点的 M…L…)和 <defs> 里的 clipPath 矩形都被排除;然后把 d 按 M 拆成子路径,因为 ECharts 会把断开的线段塞进同一个 <path>(第六节)。symbolList() 只收带 transform="matrix(…)" 的,也就是符号。这两个过滤条件是本章所有像素读数的基础,写歪了就会把 clipPath 的 fill="#000" 当成 markArea。
- bezierY() 按参数方程采样:三次曲线的极值不在端点上,只扫 d 里的坐标对会得出「平滑没有过冲」的假结论(面板二就是靠它抓到 smoothMonotone:'y' 的越界)。
- yToValue() 用 convertFromPixel({xAxisIndex:0,yAxisIndex:0}, …):finder 只给 yAxisIndex 时实测返回 null,必须两个轴都给。这条在 SSR 和浏览器上都一致。
- 面板六读第 10 个点:nums(ds[i])[19] 是「第 10 个坐标对的 y」(0 起算、一对两个数,所以是 2×9+1=19)。十点就是 2025-10,那一列三条品类为正、冷萃冰沙为 −12.52,samesign 与 all 的差别只落在最后一条上。
- 面板七把 dpr 打进读数:源码算阈值用的是 轴像素 × devicePixelRatio,所以同一份 1000 点在 dpr=1 的屏幕上会被采样(rate=2),在 dpr=3 的手机上 rate=1、一个点都不会少。sampling 的效果是不可移植的,这行读数就是证据。
- 面板八甲的「炸图演示」用 try/catch 包住 setOption:markLine 的 type:'avg' 会让整张图渲染失败,捕获之后把异常文本打印到黄框里,读者能看到「一个字母毁掉一块图」现场;点「无标记」就恢复。
【参数速查】
「默认值(6.1.0)」这一列不是抄文档抄来的,是用 chart.getOption().series[0] 和 seriesModel.get(…) 在 6.1.0 的 min 包上现场读出来的(探针 tools/__probe/ch13def.mjs、ch13def2.mjs)。读到 undefined 的写成「—」,意思是库里根本没有这个默认值,写了才生效;这类键往往就是「写了没用」和「写了有效果」的分界线。
| series.type | string | ‘line’ | 折线系列 |
| series.data | array | — | 数字、数组、对象都行;null/undefined/''/'-'/NaN 完全等价(第六节),'86' 会被认,'abc' 不认 |
| series.coordinateSystem | string | ‘cartesian2d’ | 也可以是 ‘polar’(第 17 章);非直角坐标下 sampling 一律不生效 |
| series.xAxisIndex | number | — | 不写就落到第一个 x 轴;实测 get() 返回 undefined |
| series.yAxisIndex | number | — | 同上 |
| series.z | number | 3 | markLine/markPoint 是 5,markArea 是 1,所以面积默认画在线底下 |
| series.legendHoverLink | boolean | true | 鼠标悬停图例时高亮这条线 |
| series.color | ColorLike | 调色板槽位 | 显式写色不占槽位,后面所有线的颜色整体前挪一位(第十五节) |
| series.colorBy | string | ‘series’ | 实测默认;‘data’ 会改成按数据项取色 |
| series.lineStyle.width | number | 2 | 写 1 时 SVG 上 stroke-width 属性整个消失(第十一节);写 -3 原样输出、被浏览器忽略 |
| series.lineStyle.type | string/number[] | ‘solid’ | 'dashed’→8,4,'dotted’→2,数组原样写进 stroke-dasharray,非法值=实线 |
| series.lineStyle.color | ColorLike | —(吃系列色) | 与 itemStyle.color 同时写时 lineStyle 赢 |
| series.lineStyle.opacity | number | —(属性不出现) | 想半透明得自己写 |
| series.areaStyle | object | —(不写就不画面积) | 写 {} 就产生一个 ec-polygon 图元 |
| series.areaStyle.color | ColorLike | 系列色 | linear/radial 渐变实测都能出 <linearGradient>/<radialGradient> |
| series.areaStyle.opacity | number | 0.7 | 实测 fill-opacity="0.7",不是 1 |
| series.areaStyle.origin | ‘auto’/‘start’/‘end’/number | ‘auto’ | 跨零贴 0 线、全正贴轴最小、全负贴轴最大;数字直接当基准值(第五节表) |
| series.symbol | string/function | ‘emptyCircle’ | 内置 8 种 + path:// + image://;'none’→0 个符号;拼错静默画空路径 |
| series.symbolSize | number/number[]/function | 6 | 数字=直径,SVG 里的 scale=size/2;数组是椭圆;回调签名 (value, params) |
| series.symbolRotate | number/number[] | null | 45 → 矩阵真旋转;字符串 '45' 同样生效 |
| series.symbolOffset | number[] | —(不偏移) | 百分比是相对符号自身尺寸,symbolSize:6 时 '50%' 只挪 3px |
| series.symbolKeepAspect | boolean | —(undefined) | 对内置符号无效,只在 path:///image:// 上起作用(第 15 章) |
| series.showSymbol | boolean | true | false → 一个符号都不画 |
| series.showAllSymbol | boolean/‘auto’ | ‘auto’ | 可见性和类目轴标签抽稀绑在一起(第八节) |
| series.smooth | boolean/number | false | true→0.5;数字原样当曲率;负数是数字但被 if (smooth>0) 拦成直线 |
| series.smoothMonotone | ‘x’/‘y’ | null | ‘y’ 分支没有夹逼,会让曲线越过数据的最大/最小值 |
| series.step | boolean/‘start’/‘middle’/‘end’ | false | true≡’start’,非法值也落 ‘start’;与 smooth 同开时 step 赢 |
| series.connectNulls | boolean | false | true 只改连线,缺口那个点照样没有符号 |
| series.sampling | string/function | ‘none’ | average/sum/max/min/nearest/lttb/minmax/回调函数;点数要 > 1.5×轴像素×dpr 才生效(第九节) |
| series.stack | string | — | 同名系列逐条累加,series[0] 当底 |
| series.stackStrategy | ‘samesign’/‘all’/‘positive’/‘negative’ | ‘samesign’(源码兜底) | 拼错不报错,但整组堆叠静默失效 |
| series.stackOrder | ‘seriesAsc’/‘seriesDesc’ | ‘seriesAsc’(源码兜底) | dataStack.js:86 用它决定分组内遍历方向 |
| series.clip | boolean | true | 决定界外符号要不要创建;折线本体靠 clip-path 裁(第十二节) |
| series.label.show | boolean | —(等效不显示) | 实测 get(['label','show']) 是 undefined,写 true 才有标签 |
| series.label.position | string | ‘top’ | 合并后的默认值,第六节实测 label:{show:true} 出 3 个 <text> |
| series.label.formatter | string/function | — | {a} 系列名 / {b} 类目 / {c} 值 |
| series.endLabel.show | boolean | false | 只支持直角坐标;极坐标下 dev 包会警告(LineView.js:453) |
| series.endLabel.valueAnimation | boolean | true | 动画过程中末端标签显示的是插值,不是最终值 |
| series.endLabel.distance | number | 8 | 到最后一个点的像素距离 |
| series.emphasis.scale | boolean | true | 6.1 折线默认放大符号;SSR 测不出来(第十六节) |
| series.emphasis.focus | ‘none’/‘series’/‘self’ | —(等效 ‘none’) | 写 ‘series’ 会让其它线被淡出 |
| series.blur | object | —(undefined) | 折线没有默认 blur 配置,淡出效果吃的是 emphasis.focus |
| series.select | object | —(undefined) | 折线默认可选中性为空 |
| series.animation | ‘auto’/boolean | ‘auto’ | 实测默认 |
| series.animationDuration | number | 1000 | 与全局一致 |
| series.animationEasing | string | ‘linear’ | 折线专属:其它系列的描线动画默认不是线性 |
| series.progressive | number | 0 | 源码注释写明 // Disable progressive,折线主动关掉渐进渲染 |
| series.progressiveThreshold | number | 3000 | 因为上面那条 0,这个阈值对折线无意义 |
| series.hoverLayerThreshold | number | Infinity | canvas 的分层阈值,对 SVG 渲染器无意义 |
| series.large | boolean | —(undefined) | 折线没实现,写了不报错也不会变快 |
| series.triggerEvent | boolean/‘line’/‘area’ | false | 6.1 新增;不写时点折线本身没有任何反应(第十四节) |
| series.triggerLineEvent | boolean | false | 已废弃但 6.1 仍生效;dev 包打警告,min 包静默 |
| series.universalTransition.divideShape | string | ‘clone’ | 第 43 章讲跨系列过渡时用 |
| series.markLine | object | z:5、symbol ['circle','arrow']、symbolSize [8,16]、lineStyle.type ‘dashed’、precision 2、label {show:true,position:'end',distance:5} | 源码 component/marker/MarkLineModel.js:57 |
| series.markLine.data[].type | ‘average’/‘min’/‘max’/‘median’ | — | 只认这四个;拼错抛异常,整张图渲染失败 |
| series.markPoint | object | z:5、symbol ‘pin’、symbolSize 50、itemStyle.borderWidth 2、label {show:true,position:'inside'} | 统计值会被挪到最近的真实数据点 |
| series.markPoint.data[].type | ‘average’/‘min’/‘max’/‘median’ | — | 拼错静默忽略,和 markLine 的后果完全不同 |
| series.markArea | object | z:1、animation:false、itemStyle.borderWidth 0、label {show:true,position:'top'} | 画出来是 <polygon>,默认 fill-opacity:0.4 |
| series.markArea.data[].xAxis | string/number | — | 类目轴上贴的是刻度而不是格子边界 |
| series.markArea.data[].yAxis | number | — | 只给 x 端点时 y 自动铺满绘图区 |
| xAxis.boundaryGap | boolean | true(类目轴) | 折线的时间序列常用 false 让首尾压边(第二节) |
| xAxis.axisLabel.interval | number/‘auto’ | ‘auto’ | 它顺带决定 showAllSymbol:'auto' 时符号显示几个 |
| tooltip.trigger | string | ‘item’ | 折线基本都该写 ‘axis’(第 19 章);轴触发不需要 triggerEvent |
【常见坑】
坑 1:markLine 的 type 拼错,整张图消失
data:[{type:'avg'}] 抛 Cannot read properties of undefined (reading 'coord'),不是只丢这条线,是整张图渲染失败,控制台只有一行堆栈。合法值只有 average/min/max/median 四个(markerHelper.js 的 markerTypeCalculator 就这几个键),standarddev 在 6.1 里也已经没有了。同一个拼写错误写在 markPoint 上则是静默忽略——两种后果差这么远,是最容易踩的一处。
坑 2:stackStrategy 拼错让整组堆叠静默失效
四个判定条件全不满足时,每条系列都退回基线,stack 形同没写。没有报错、没有警告,只是图形的形状变了。实测非法值 'abc' 与 'negative' 在同一份真实数据上给出完全相同的读数(6.87 / 8.43 / 8.57 / −12.53)。这类错误只有类型门禁能拦。
坑 3:图例从上到下的顺序,和堆叠的上下顺序是反的
series[0] 永远画在最底下,而它通常列在图例第一行。带负值时这不只是观感问题:把冷萃冰沙(−12.52)从数组末尾挪到开头,三条正数各自待的层级全变了(经典美式从贴着基线的 6.87 换到顶上的 23.87,卡布奇诺从顶上换到贴着基线的 8.55)。想让某条线固定在顶上,就把它写在数组最后。
坑 4:符号放大到 40px,那圈描边也不会变粗
空心圆描边满足「局部线宽 × scale ≡ 2」,永远是 2 个像素。想改描边只能换 symbol:'circle' 或 path://。而 itemStyle.borderWidth / borderColor 写在折线系列上完全无效(实测 scale 和线宽一个数都没变)——柱状图吃这两个键,折线不吃,从第 14 章的例子里抄配置过来就会疑惑半天。
坑 5:sampling 对 400 个点一个像素都不动
生效条件是 round(点数 / 轴像素) > 1,也就是点数要超过轴像素的 1.5 倍。月度 36 点、日度 365 点在常规宽度下都不会被采样。更麻烦的是公式里乘了 devicePixelRatio:同一份 1000 点在 dpr=1 的显示器上 rate=2(会被抽稀),在 dpr=3 的手机上 rate=1(一个点都不少)。「我这台机器上没问题」对 sampling 不成立。
坑 6:符号少了一半,是轴标签抽稀带的
50 个类目点在 400px 宽下默认只画 25 个符号,因为 showAllSymbol:'auto' 把符号可见性绑到了 axisLabel.interval:'auto' 的结果上。数据看着「缺点」,其实线是完整的。要么 showAllSymbol:true(字节数 11965→18230),要么 showSymbol:false(11965→5635)。
坑 7:clip:true 下越界的点根本不创建图元
yAxis.min/max 收窄后,出界的点符号一个都不会生成(clipShapeForSymbol 逐个判断),于是「数据 6 个点、图上 4 个圈」。折线本体反而不裁——路径照样画到界外(实测 y 极值 −42 和 290),靠 <g clip-path> 遮掉。数 SVG 里的符号数时别忘了这一点。
坑 8:给中间某条线写死颜色,后面所有线的颜色都往前挪
四条线、第 2/3 条写死 red/blue,实测得到 ["#5070dd","red","blue","#b6d634"]——第四条拿的是色板第 2 色。跨图表想固定「外卖永远是橙色」,要么每条都写死,要么用 legend.data 的顺序对齐一个完整 color 数组。
坑 9:lineStyle.width:1 时 stroke-width 属性消失
zrender 不重复写默认值,所以 1px 是「没有属性」而不是「属性=1」。用正则数属性的人会以为配置没生效。写 0 会让线消失但符号留下;写负数会原样输出成非法值。
坑 10:step:true + smooth:true 不产生圆角台阶
step 赢(LineView.js 把折点拆完后 smooth 只在直角段上生成首尾重合的 C 命令)。想要圆角台阶只能自己密采数据再平滑。同理 step:'abc' 非法值不报错,落回 'start' 的行为。
坑 11:symbolPosition / symbolClip 写在折线上是空操作
它们是 pictorialBar 的键。实测 symbolPosition:'inside'、'bottom'、'abc' 三种写法的符号矩阵与默认一字不差;symbolClip:true/false 在折线上符号数都是 4(那个数由 clip 决定)。从柱状图/pictorialBar 章节抄配置时最容易带上这两个。
坑 12:markArea 在类目轴上贴刻度,不贴格子
{xAxis:'2月'} → {xAxis:'4月'} 实测 x 是 105→231.7(两个数据点的位置),而不是 73.3→263.3(格子边界)。想覆盖「整个 2 月到整个 4 月」得自己扩边,或者换 time 轴(实测边界精确到日)。另外它是 <polygon> 不是 <path>,按 <path 搜图元会一无所获。
坑 13:markPoint 的「平均值」不是平均值
markLine {type:'average'} 的横线画在真均值 59.83 处,标签也显示 59.83;markPoint {type:'average'} 会用 indicesOfNearest 把图钉挪到最接近的真实数据点,标签显示 52。同一个统计量在两个组件里位置不同、文本不同。
坑 14:点折线没反应,是因为 triggerEvent 没开
默认 false 时线和面都收不到点击(实测一次都没有)。开 true 才有 selfType:'line' / 'area' 两种事件。反过来,'abc' 这种非法值也是「一次都没有」,静默。跨版本迁移还要注意 5.6 完全不认 triggerEvent。
坑 15:从散点图抄来 large:true / progressive:1000
LineSeries.js 里写着 progressive: 0 和 hoverLayerThreshold: Infinity,3000 点的折线在这三种写法下 SVG 字节数分别是 28478 / 28484 / 28484,图元数一样——不报错,也不变快。折线的大数据手段是 sampling,不是 large。
坑 16:convertFromPixel 只给 yAxisIndex 会返回 null
实测 convertFromPixel({yAxisIndex:0}, [0, y]) → null,必须写 {xAxisIndex:0, yAxisIndex:0}(或 {gridIndex:0})。SSR 和浏览器上表现一致。本章所有「像素→数值」的读数都靠这个 finder。
坑 17:emphasis 的效果不能靠 SSR 断言
dispatchAction({type:'highlight'}) 之后再取一次 renderToSVGString(),实测两次字符串完全相同(差异字节 = 0)。高亮/淡出是 zrender 的 state 样式,只在真实绘制时参与合成。想看 emphasis.focus:'series' 的效果必须在浏览器里点——这也是本章实验台存在的理由。
坑 18:跨零数据加了 areaStyle 后「颜色从中间断开」
origin:'auto' 在轴跨零时把封底取在 0 线,正负两边分别往上、往下填,中间留一条空白带。这不是 bug,是默认行为;想让面积连成一片,写 origin:'start'(贴轴最小)或一个具体数字。反过来 origin:'end' 会把面积填到轴顶,线像是被面积「压」在下面。
坑 19:数「段数」时数 <path> 元素会数出 1
断开的线段被塞进同一个 <path>,段与段之间用第二个 M 分隔(第六节实测:六点中间缺一个 → 1 个 <path>、d 里两个 M)。所以「看 SVG 里有几个 path 就知道线断成几截」这个直觉是错的,得数 M 的个数。同理,markArea 是 <polygon>、符号是带 matrix 的 <path>,按元素名粗筛会把它们混进折线本体。
【工程文件】
ch13/
├── min.html ← 最小可跑:把折线拆成图元自己数(段数/贝塞尔段数/符号数/字节数)
├── index.html ← 八面板实验台骨架(9 张图)
└── app.js ← 读数、切换、堆叠实测、sampling 阈值、同期对比,644 行
跑法(实验台必须有服务器:面板五、六、八用 fetch 读 data/):
node tools/extract.mjs –write # 把本章代码重建成 code/ch13/
node tools/serve.mjs # 打开 http://127.0.0.1:18000/code/ch13/index.html
min.html 不用服务器,双击就能跑——它只画写死的六点数据,一个 fetch 都没有。
app.js 里几个键的用法值得再点一次:读数全部走 renderToSVGString() + 正则,lineDs() 先用「fill="none"、有 stroke、整条路径坐标对 ≥ 6」筛出折线本体,再把 d 按 M 拆成子路径——面板五报的「折线被切成 N 段」数的是子路径,不是 <path> 元素;bezierY() 按参数方程采样而不是只看 d 里的坐标对,否则抓不到 smoothMonotone:'y' 的过冲;面板六把 nums(ds[i])[19](第 10 个坐标对的 y)换算回数值来读堆叠高度;面板七用 convertToPixel 现场量轴长而不是硬编码绘图区宽度,因为 6.1 的标签感知布局会把轴缩短。
【练习】
练习 1:算一算你的图会不会真的被采样
data/sales-daily.json 有 1096 行日数据。假设你把 2025 年的 365 个点画进一个「手机上 375px 宽、grid 左右各留 70/20」的折线图,并写上 sampling:'lttb'。请回答:会不会生效?同一份配置放在桌面 640px 宽(dpr=1)的容器里呢?如果把三年的 1095 点都画进去呢?
答案:源码的判定是 `count > 10 && 坐标系为 cartesian2d && rate = round(count / (轴像素 × devicePixelRatio)) > 1`(`dataSample.js`),`rate > 1` 等价于 **点数 ≥ 1.5 × 轴像素 × dpr**。① 手机:轴 = 375 − 70 − 20 = 285px,dpr=3 → 阈值 1.5 × 285 × 3 ≈ 1283 点,365 点远不够,**一个点都不会少**。② 桌面:轴 = 640 − 70 − 20 = 550px,dpr=1 → 阈值 825 点,365 点同样不生效。③ 三年 1095 点:桌面上 1095 ≥ 825 → `rate = round(1095/550) = 2`,**每 2 个点合成 1 个**;手机上 1095 < 1283 → rate=1,**照原样画**。所以第三问的答案是「取决于设备」,这也是为什么面板七把 dpr 打进读数里:`sampling` 的效果不可移植,别拿一台机器的观察当结论。顺便,轴像素要用 `convertToPixel` 现场量(第 12 章),别拿 `grid` 的百分比硬算——6.1 的标签感知布局会把轴缩短。
练习 2:断几天 vs 断一天,段数一样吗
用 365 个点的日序列做实验:分别把 3 个连续点、18 个连续点、两个各 18 天的内部区间、以及开头 18 天、结尾 18 天、两头同时缺 18 天设成 null。数一数每种情况下折线被切成几段、各段几个点。总结出一条规律,并说明为什么规律对「读者会不会误以为数据连续」很重要。
答案(全部实测,`tools/__probe/ch13gap.mjs`):
| 无缺失 | 1 | 365 |
| 内部 3 个连续点(7~9) | 2 | 7 + 355 |
| 内部 18 个连续点(100~117) | 2 | 100 + 247 |
| 两个内部区间(5067、200217) | 3 | 50 + 132 + 147 |
| 开头 18 天 | 1 | 347 |
| 结尾 18 天 | 1 | 347 |
| 两头各 18 天 | 1 | 329 |
| 全部缺失 | 0 | — |
规律:段数 = 内部缺口数 + 1,与缺口的长度无关(缺 3 天和缺 18 天都是 2 段);缺口贴在首尾时不产生新段;全缺时一个图元都没有。总点数 = 365 − 缺失数,缺失点连符号都不生成。这件事对读者的意义:断口在图上留下的空白宽度取决于缺了几天,但段数只告诉你断了几次——所以「线断了一次」既可能是缺一天,也可能是缺三个月。想让人一眼看出缺多久,靠 markArea 把缺失区间涂出来,而不是指望他们自己数空白。
练习 3:图例关掉一条负数系列,另外三条会搬家
按「经典美式 +6.87、燕麦拿铁 +8.45、卡布奇诺 +8.55、冷萃冰沙 −12.52」的顺序做四条同 stack 的折线,再做一个版本把冷萃冰沙挪到数组最前面。在第二个版本里用 legend.selected 关掉冷萃冰沙,比较四种 stackStrategy 下其余三条线的落点。
答案(实测,轴锁 −40~40、像素→值用 `convertFromPixel`):负数系列在前时,四条全显示的落点是——不写 strategy / `samesign` / `positive` 都是 `−12.53, 6.87, 15.33, 23.87`;`all` 是 `−12.53, −5.63, 2.8, 11.37`;`negative` 是 `−12.53, −5.63, 2.8, 2.9`(最后一条越过了两个正数列、找到最早的负数列才叠上去,源码的回看循环是 `for (j = idxInStack – 1; j >= 0; j–)`)。关掉冷萃冰沙之后:前三种仍是 `6.87, 15.33, 23.87`(没有变化,因为正数从来不会叠到负数列上),但 `all` 变成 `6.87, 15.33, 23.87`——**其余三条整体上移了 12.52**;`negative` 变成 `6.87, 8.43, 8.57`,三条各自独立。结论:图例关掉的系列是**从堆叠组里整个消失**(`dataStack.js:106` 的注释写明 stack 列表依赖图例选择),不是「画到基线上」。所以做堆叠图交互时要提醒读者:勾选框改变的不仅是「少一条线」,而是所有线的绝对高度。
【小结】
- 折线由 1 条 fill="none" 的线 + N 个符号组成;符号是半径 1 的单位路径 × matrix(sx,0,0,sy,tx,ty),symbolSize 是直径,所以 scale = size/2。空心圆那一圈描边永远等于 2px(局部线宽 × scale ≡ 2),itemStyle.borderWidth 对折线符号无效。
- 折线和柱状图共用同一套坐标系:把 type 从 'line' 换成 'bar',轴刻度文本逐字相同。第 12 章的轴配置全部适用于折线,本章只管 lineStyle/symbol*/areaStyle/smooth/step/sampling/connectNulls/stack/clip 和标记三件套。
- smooth:true 就是 0.5,数字原样当曲率,负数被 if (smooth > 0) 拦成直线。默认分支有两次夹逼,把控制点关在相邻点的包围盒里,所以默认平滑不会越过数据的最大/最小值;会过冲的是 smoothMonotone:'y'(实测 14.41~241.54,越出数据边界 34~226)。
- step 有 5 个名字:true≡'start'≡非法值,'middle' 折点最多,'end' 最省;step 与 smooth 同开时 step 赢,没有圆角台阶。
- areaStyle 写 {} 就出面积,默认 fill-opacity 是 0.7。origin 除了 'auto'/'start'/'end' 还能写数字;跨零时默认贴 0 线,这就是「面积颜色从中间断开」的原因。
- 缺失值:null/undefined/'-'/''/NaN 完全等价,缺失点连符号都不画;'86' 会被认、'abc' 不认。段数 = 内部缺口数 + 1,贴在首尾的缺口不产生新段。多段塞在同一个 <path> 里,数段要数 M。connectNulls:true 会让面积跨过缺口,是个容易误导读者的开关。
- 堆叠从 series[0] 往上长,图例顺序和图形上下顺序是反的。stackStrategy 的语义由源码里那一个 if 决定:val 是前一条系列叠完的结果,positive/negative 不看自己的正负;非法值让整组堆叠静默失效。图例关掉的系列从堆叠组里消失,会移动其余所有线的高度。
- showAllSymbol:'auto' 把符号可见性绑在类目轴标签的抽稀结果上,50 点默认只剩 25 个圈。sampling 的门槛是「点数 > 1.5 × 轴像素 × dpr」,月度/日度数据基本都不会触发,而且结果随设备变。lttb/minmax/回调函数三个取值不在文档表里但实测有效。
- clip:true 决定界外符号要不要创建,折线本体靠 clip-path 裁(路径照样画到界外)。convertFromPixel 的 finder 只给 yAxisIndex 会返回 null。
- 标记三件套:markLine 的 type 拼错会炸掉整张图,markPoint 拼错只是静默不画;markPoint 的「平均值」会被挪到最近的真实数据点(标签显示 52 而不是 59.83);markArea 是 <polygon>、z:1 画在线底下,在类目轴上贴刻度而不贴格子。
- triggerEvent 是 6.1 新增('line'/'area' 可分开点,事件参数里多了 selfType);triggerLineEvent 仍可用但已废弃,5.6 不认新键。默认不写时点折线没有任何反应。
- 折线主动关掉了渐进渲染(progressive:0、hoverLayerThreshold:Infinity),large:true 抄过来不会变快;大数据的手段是 sampling。emphasis/blur 只能浏览器里看,SSR 两次序列化一字不差。
- 显式写 color 的系列不占调色板槽位,后面所有线的颜色整体前挪一位。
