欢迎光临
我们一直在努力

《ECharts入门》第 13 章 折线图全解

【本章目标】

学完这一章,你能:

  • 说出折线在 SVG 里到底是哪几种图元:一条 fill="none" 的 ec-polyline、每个数据点一个 ec-symbol(单位圆 + matrix 缩放),有面积时再加一个 ec-polygon;并能用 renderToSVGString() 把这三类图元读出来做验证。
  • 解释符号为什么写不出「直径」而只有 matrix(3,0,0,3,…):symbolSize:6 → scale=3,环的局部线宽 = 2/3 = 0.6667,等效设备像素恒为 2px;symbolSize:20 → scale=10、局部线宽 0.2,环仍是 2px。
  • 把 smooth 的算法讲清到能复算:getSmooth 的三行语义(true≡0.5,数字原样用,null/0/'abc' 各变成什么)、控制点公式、以及源码里那两次夹逼(mathMin/mathMax 把控制点关进相邻两点的包围盒)为什么让默认平滑不会过冲。
  • 用实测推翻一个流传很广的说法:「平滑会过冲」。锯齿数据 10/90/10/90/10 下,smooth 取 0.1/0.2/0.5/0.8/0.9/1/2/3 的曲线 y 极值全部落在数据边界 [34,226] 内;只有 smoothMonotone:'y' 会越界到 [14.41,241.54]。
  • 区分 step 的四种形态:false 6 个折点、true≡'start'≡非法字符串 11 个、'middle' 16 个、'end' 11 个;step 与 smooth 同时给时 step 赢,退化成一串零长度的 C 命令。
  • 算清 areaStyle.origin 的封底位置:'auto' = 跨零时贴 0 线、全正时贴轴最小、全负时贴轴最大;'start'/'end' 的字面含义;数字也有效(origin:50 → 封底在值 50 处,实测 y=130),但文档没写;并知道它在 5.6 里同样存在,不是 6.1 新增。
  • 列出缺失值的全部等价写法:null、undefined、'-'、''、NaN、'abc'、{value:null} 都会断线且丢掉那个点的符号;'86' 这种数字串能画;connectNulls:true 只把线连起来,符号照样缺席。
  • 验证堆叠的真实顺序与 stackStrategy:series[0] 画在最底层(5.6 与 6.1 一致,不存在所谓「6.x 堆叠顺序反转」);stackStrategy 四取值的实测落点集合;写非法值会让整组堆叠彻底失效且不给任何警告。
  • 说清 sampling 的真实开关条件:源码 count > 10 && cartesian2d && rate = round(count / 轴像素长) > 1——所以 400 点在 380px 宽的轴上一点都不会被采样,1000 点才降到 334 点(SVG 从 11036 字节缩到 4348 字节)。
  • 搞懂 showAllSymbol 与类目轴标签抽稀的联动:50 个类目点,默认只画 25 个符号(跟标签一样被抽稀);showAllSymbol:true → 50 个;showSymbol:false → 0 个;axisLabel.interval:0 → 标签 50 个、符号也 50 个。
  • 用对三件套标记:markLine 的 type 只认 average|min|max|median,写 'avg' 会抛异常把整张图打挂;markPoint 的统计值会把图钉放在最接近的那个真实数据点上并显示该点的值(均值 59.83 → 显示 52);markArea 画的是 <polygon>,且类目轴上贴着刻度而不是格子边界。
  • 讲清 triggerEvent 是 6.1 新增:true/'line'/'area' 三种取值分别给哪个图元挂事件,事件参数里多出来的 selfType 是什么;5.6.0 里只有布尔的 triggerLineEvent,且事件参数没有 selfType。

  • 在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

    【原理先行】

    本章的所有结论都来自同一套探针:把 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 组):

    你写的生效曲率曲线里的 C 段数(5 段数据)
    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,用正确采样三次曲线的算法求极值,不是只看端点):

    写法曲线真实 y 极值是否越过数据范围
    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 个折点):

    写法折点数d 片段
    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):

    数据与轴origin封底 y封底对应的值
    全正(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,然后误判成「库根本没有断线」:

    数据写法段数符号的 dataIndex
    [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 值反推出的数据:

    series 顺序线 0(画在最下)线 1线 2
    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 的实测落点(两版一致):

    stackStrategy三条线的落点值说明
    不写 −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 的抖动):

    stackStrategy四条线的落点关键点
    不写 / '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):

    点数average 后折线点数说明
    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 宽)给出各取值的对比:

    sampling折线点数SVG 字节
    '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 时属性消失,虚线长度由线宽决定

    写法SVG 上的 stroke-width观感
    不写 2 默认
    width:10 10 —
    width:1 属性被省略 仍是 1px(SVG 默认值就是 1,zrender 不写重复的默认属性)
    width:0 0 线消失,但符号还在
    width:-3 -3(原样写出) 库不做夹逼;负数在 SVG 里是非法值,浏览器会忽略这条声明
    type 写法stroke-dasharray
    '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 有点出界)实测:

    配置折线 d 的 y 极值符号数符号 y
    默认 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):

    zsymbolsymbolSizelabel 默认其他
    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):

    triggerEvent触发结果
    不写 / 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 的写成「—」,意思是库里根本没有这个默认值,写了才生效;这类键往往就是「写了没用」和「写了有效果」的分界线。

    键类型默认值(6.1.0)说明
    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 的系列不占调色板槽位,后面所有线的颜色整体前挪一位。

    赞(0)
    未经允许不得转载:171主机测评 » 《ECharts入门》第 13 章 折线图全解
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址