ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ECharts折线流光:dashOffset驱动与zrender直改优化

ECharts折线流光:dashOffset驱动与zrender直改优化 做过可视化大屏的人大概都被问过同一句话这条线能不能动起来像电流或者数据在管道里跑一样。第一次听到这个需求我的第一反应是翻 ECharts 的配置项翻完series-line那一页会发现一个挺尴尬的事实——折线系列压根没有 effect 相关的配置。地图飞线series-lines有trailLength和effect普通直角坐标系里的折线没有。所以这个效果只能自己造。我前后在三个项目里落地过这个效果从最早每帧整图 setOption 硬刷的土办法到现在直接改 zrender 元素的lineDashOffset中间踩的坑基本集中在两个地方一是虚线相位这个机制本身的脾性二是非平滑曲线smooth: false拐点带来的视觉断裂。这篇文章就把这两块拆开讲清楚顺便给一份能直接抄的配置和一份大屏调参经验。如果你手里正好有一个数据太死板、想让线条活起来的需求看完应该能省掉大半天试错。1. series-line 没有 effect流动特效只能从虚线相位里要1.1 折线能做的动画和它做不到的事先把 ECharts 折线自带的动画能力盘一遍免得白找。animationDuration控制的是入场绘制效果是线从左往右长出来跑完一次就停animationDelay是逐点延迟能做点位依次出现的节奏感animationEasing只是给上面两个换缓动函数。这三个都是一次性的没有循环的语义。想让线条持续流动本质只有两条路可走改几何或者改样式。改几何就是每帧改data把数据整体平移一格视觉上线上的点会位置变化看起来更像波形在扭动而不是光在跑。而且每帧改数据会触发坐标轴范围重算、tooltip 内容重算、图例和 dataZoom 状态刷新几百个点的图上基本跑不到 30fps。改样式只影响描边方式几何路径一动不动。这意味着 tooltip 的命中区域不变、坐标轴不变、缩放不变、图例高亮状态不变成本只在渲染层。所以下面所有内容都围绕改样式来做。1.2 dashOffset 是改一个数字就能动的唯一入口lineStyle.dashOffset的作用是把虚线图案沿着路径整体推一段距离。注意两个前提缺一不可lineStyle.type不能是solid。如果还是实线根本没有 dash 图案dashOffset设成什么都看不出来。这是新手最常犯的错代码写了两百行效果零一查发现忘了改type。单位是像素和lineWidth没有换算关系。线宽 4px 和线宽 12px 的同一组 dash 参数视觉上疏密完全一样。方向这块要说清楚dashOffset递减时虚线图案是沿着路径绘制方向前进的也就是从第一个数据点流向最后一个数据点。绝大多数场景下数据点是从左往右排的所以递减就对应视觉上的向右流动。但是一旦你给 x 轴加了inverse: true或者数据点顺序是反的方向感知就会倒过来这时候得把符号反过来。版本上有个坑必须提醒dashOffset是 ECharts 4.5 之后才有的字段。4.4 及更早的版本里写这个字段不会报错也不会生效静默忽略。我之前接手过一个老项目排查了两小时才发现问题出在版本号上。1.3 三层结构管道、光带、光点别乱开 zlevel流动折线的标准结构是三层叠加的系列层级作用关键配置底层深色粗线充当管道width比流动层大 2-4px低透明度中层亮色虚线跑dashOffsettype: dashed、silent: true顶层可选的光点单独元素不在 series 里有个很多人会踩的坑为了分层给每一层开zlevel。zlevel的语义是独立 canvas 层一层就多一张画布三层的图表内存占用和重绘面积直接翻三倍。在单图小数据量下感觉不出来大屏上挂六个图的时候就开始掉帧。同一坐标系里的分层用 series 数组顺序加上z值就够了别碰zlevel。另外流动层不要开areaStyle。底层的实线如果要渐变填充那条是单独的 area 路径不受 dash 影响但流动层开了之后会再叠一层区域填充颜色会明显变深而且看起来像有两层阴影在动。2. 非平滑曲线给流动特效带来的两个后果2.1 smooth 开关改的是路径不是虚线节奏很多人以为smooth: true会让虚线变均匀smooth: false会让虚线变不均匀其实不对。stroke-dasharray这一机制是按整条路径的总长度累计切分的跟你中间有几个数据点、路径是不是平滑完全无关。smooth: true时ECharts 会用类似 Catmull-Rom 的插值把相邻点之间变成三次贝塞尔段路径总长度会比折线长一点曲率绕了远路smooth: false时路径就是各点顺序连成的折线总长度就是各段直线长度之和。所以两者的虚线节奏差异只来自总长度不同不来自平滑这个属性本身。真正的差别在拐角。平滑曲线在数据点附近是渐变的虚线图案过渡很自然非平滑折线在拐角处有一个硬硬的夹角虚线图案会被折一下。折得越狠夹角越接近 90°越明显。2.2 拐点处的亮口产生原因和四种补法具体现象是这样的如果某个拐角正好落在 dash 的间隙里屏幕上就会出现一个明显的暗口如果落在 dash 的中间就会出现一个尖角形状的亮段看起来像被掐了一下。数据点越少、单段越长这个现象越显眼。我常用的四种补法按成本从低到高排拐点叠 symbol。在数据点位置显示一个和亮带同色的小圆点直径等于线宽把缺口盖住。这是最省事的一行showSymbol: true加symbolSize就完事我大多数项目都用这招。lineJoin: round。让外角变圆削弱尖角感。它不改路径只改描边的连接方式成本为零但只能缓解不能消除。缩短 dash 周期。让亮口密度提高眼睛会自动把它当成纹理而不是错误。缺点是周期太短会让整条线糊成一条亮线失去流动感得配合 §3.3 的参数推导来定。角落让一层低透明度光晕。用底层的shadowBlur把拐角糊掉一点这是视觉层面的糊弄但大屏上看确实有效。这里顺便辟个谣有人第一反应是数据点太少了我插值加密一下点位让拐角变多、每个拐角变小。实测对虚线节奏没有任何影响因为 dasharray 是按整条路径长度算的加密点位不改变总长度也不改变切分方式。这条我亲眼见同事白折腾了一个下午。2.3 非平滑折线让光点坐标可以手算如果只需要流动虚线上面就够了。但要加光点在线上跑就得知道路径进度为 p 的那个像素点在哪。这一步在平滑曲线下很麻烦要么对贝塞尔段做数值采样求长再反解要么在 SVG 渲染器下蹭path.getTotalLength()和getPointAtLength()——canvas 渲染器根本没这两个方法。非平滑折线就简单多了因为路径就是一堆直线段累计长度之后做线性插值即可// points 是像素坐标数组 [[x0,y0],[x1,y1],...]progress 取 0~1 function getPointAtProgress(points, progress) { const segLens []; let total 0; for (let i 1; i points.length; i) { const len Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]); segLens.push(len); total len; } if (total 0) return points[0]; const target total * progress; let acc 0; for (let i 0; i segLens.length; i) { if (acc segLens[i] target) { const t (target - acc) / segLens[i]; return [ points[i][0] (points[i 1][0] - points[i][0]) * t, points[i][1] (points[i 1][1] - points[i][1]) * t ]; } acc segLens[i]; } return points[points.length - 1]; }顺带还能拿到当前所在线段的斜率如果光点是个箭头形状或者有拖尾方向的图形就能让朝向跟着线段走拐弯的时候会自然折向观感比一直朝右的圆点好很多。3. 一套可以直接抄的流动折线实现3.1 双层 series 的完整配置和字段取舍const dom document.getElementById(chart); const chart echarts.init(dom, null, { renderer: canvas }); const CATEGORY [00:00, 02:00, 04:00, 06:00, 08:00, 10:00, 12:00]; const VALUE [120, 200, 150, 260, 180, 300, 220]; const option { animation: false, grid: { left: 48, right: 32, top: 40, bottom: 40 }, tooltip: { trigger: axis, transitionDuration: 0 }, xAxis: { type: category, boundaryGap: false, data: CATEGORY }, yAxis: { type: value }, series: [ { id: pipe, type: line, data: VALUE, smooth: false, symbol: none, lineStyle: { width: 8, color: rgba(58,120,255,0.16), cap: round, join: round }, silent: true, z: 2 }, { id: flow, type: line, data: VALUE, smooth: false, symbol: none, lineStyle: { width: 4, color: #a8e6ff, type: dashed, dashOffset: 0, cap: butt, join: round }, silent: true, z: 3, animation: false, animationDurationUpdate: 0 } ] }; chart.setOption(option);几个字段的取舍理由底层symbol: none是必须的否则数据点上会冒出一堆管道颜色的圆点把亮带盖住。cap: round让管道两端圆润视觉上更像管子。流动层的cap反而是butt——这一条特别反直觉cap: round会给每一个 dash 段的两端各加一个半圆虚线会变成一串胶囊短周期下看起来就是一连串圆点流动感全没了。想要光点串的效果才故意用 round想要光带就必须用 butt。silent: true是给 tooltip 让路。两层系列如果都响应鼠标tooltip 的触发源会在两层之间跳内容会闪。让底层不响应、由流动层或者反过来统一承接行为就稳定了。animation: false和animationDurationUpdate: 0是必须的。不关的话每次 update 都会走一遍动画流程dashOffset会从旧值缓动到新值而下一帧你又塞了新值进去动画永远追不上目标表现出来的现象是速度变慢 一顿一顿。3.2 用 requestAnimationFrame 驱动相位而不是 setIntervalconst SPEED 90; // 单位像素/秒 const DASH_CYCLE 38; // dash gap 的总周期见 3.3 的推导 let offset 0; let lastTs 0; let rafId 0; let playing true; function frame(ts) { if (!playing) return; if (!lastTs) lastTs ts; // 截断 dt防止切后台回来时相位一次性跳过好几百像素 const dt Math.min((ts - lastTs) / 1000, 0.05); lastTs ts; offset - SPEED * dt; // 保持数值在合理区间跑一整天也不会变成 -1e8 if (offset -DASH_CYCLE * 1000) offset DASH_CYCLE * 1000; chart.setOption({ series: [{ id: flow, lineStyle: { dashOffset: offset } }] }); rafId requestAnimationFrame(frame); } rafId requestAnimationFrame(frame);这里每一行都有理由速度写成像素/秒而不是每帧减几个像素是因为刷新率不一样的设备上同样的每帧增量会跑出两倍速差。60Hz 和 120Hz 的屏幕上每帧减 1.5 会变成 90px/s 和 180px/s。用时间差换算就和设备无关了。dt必须截断。浏览器切到后台再切回来时间差可能是好几秒一个dt就能把相位推走几百像素画面上看起来就是光带闪了一下。截到 50ms 就够了。series 必须带id。ECharts 的合并策略是按id匹配没有id就退回按索引匹配。你现在只有一个流动层无所谓等以后在这个图里插入新系列比如加一条对比线索引就会错位dashOffset可能被塞到别的系列上效果直接失效。给id是零成本的保险。offset 做归零处理。虽然是浮点数跑到 -1e7 也不会出错但保持在小范围内可以让你在控制台里一眼看出当前值调试方便。而且极端情况下浮点精度损失会让罕见的跳跃出现。3.3 dash 和 gap 到底取多长按线段长度反推这是最影响观感、也最容易被随手写死的参数。我一般的推导顺序是这样先算出各段直线的平均像素长度 L。七个点均匀分布在一个宽 800px 的绘图区里L 大概是 130px。然后dash长度建议取1.2 ~ 2倍线宽。线宽 4px 就取 6-8px太短会糊太长看起来像一截一截的直线而不像光带。gap长度建议取0.6 ~ 1倍平均线段长度 L。L130 就取 80-130px。平均线段长度 L推荐 dash推荐 gap目视效果30px密集折线824线上同时 4-6 个亮带节奏密但有层次130px常规8100线上 2-3 个亮带最舒服400px稀疏折线10220线上 1-2 个亮带偏慢适合汇报节奏反过来两个反例L30 却把 gap 给到 120整条线上只有一两个亮带看起来像卡住了L400 却把 gap 给到 20屏幕上全是亮点糊成一条亮线完全没有流动感。还有一个更细的经验周期性要避开线段长度的整数分之一。如果dash gap的总周期刚好等于 L或者 L/2、L/3那么每个拐点都会落在完全相同的相位上。要是那个相位恰好落在 gap 里屏幕上就会出现每个拐点都缺一块的规律性暗点特别显眼而且越看越像 bug。用 L130、周期 130 试试就明白了改成 118 或者 145 立刻就正常了。至于定制的 dash 长短有些版本lineStyle.type允许直接给数组大致 5.3 之后老版本只能选dashed或dotted。如果你的版本不支持数组又非要定制可以直接去改 zrender 元素的style.lineDash方法在 §4.2。4. 每帧 setOption 的代价以及直改 zrender 的做法4.1 setOption 每帧走一遍的完整链路上面那套requestAnimationFrame setOption在小图上跑得很稳但把数据量从 7 个点加到 500 个点、系列从 2 条加到 6 条之后帧率会掉到 25-40fps。原因在于每次setOption走的不是只改一个数字而是一条完整的链路option 合并 → 模型更新 → 系列 update → 视图层差异检测 → 元素重建或复用 → 渲染。更烦的是 tooltip。每帧触发 update 会连带刷新 tooltip 的内容计算鼠标停在线上不动的时候tooltip 会持续闪axisPointer 的竖线也会抖。用户第一眼看不出帧率掉了多少但一定能看出 tooltip 在抖。调优手段有几个按代价排把 rAF 降级成setInterval33ms锁定 30fps。流动效果本身不需要 60fps30fps 完全看不出卡。这是最省事的一招。加上lazyUpdate: true让 ECharts 把渲染推迟到下一帧跟 rAF 的节奏对齐。用series.id精确匹配 只传变化字段上面代码已经这么做了。彻底绕开setOption直接操作 zrender 元素。这条最狠也最有效。4.2 找到那条折线元素直接改 lineDashOffset先明确映射关系ECharts 配置里的lineStyle.dashOffset落到 zrender 图层就是元素的style.lineDashOffsetlineStyle.type: dashed落到 zrender 层是style.lineDash一个数组比如[8, 100]。只要能拿到那个折线元素el每帧改el.style.lineDashOffset就够了完全不进 ECharts 的模型层tooltip 不会抖坐标轴不会重算。难点是怎么找到那个元素。ECharts 没有公开这个 API得从 zrender 的显示列表里筛。我一般的做法是先打印一遍结构看清楚再写死条件// 第一步打印确认结构 const list chart.getZr().storage.getDisplayList(true); console.log(list.map(el ({ type: el.type, shapeKeys: el.shape ? Object.keys(el.shape) : null, lineDash: el.style el.style.lineDash, stroke: el.style el.style.stroke }))); // 第二步按特征筛选折线系列的 shape 上有 points且 lineDash 存在 function findFlowPath(chart) { const list chart.getZr().storage.getDisplayList(true); return list.find(el el.shape Array.isArray(el.shape.points) el.style Array.isArray(el.style.lineDash) ); }不同版本的shape结构略有差异可能是{ points, smooth, smoothMonotone, connectNulls }也可能是压缩过的字段名所以先打印再写死这一步别跳过比看文档猜快得多。拿到之后每帧的操作变成const path findFlowPath(chart); path.style.lineDash [8, 100]; // 顺便定制 dash 长短走配置写不了 path.style.lineDashOffset offset; path.dirtyStyle path.dirtyStyle(); chart.getZr().refresh();这里有三个必须处理的现实问题引用会失效。只要发生setOption、resize、数据更新、resize 后的重新布局ECharts 都可能重建或替换元素你缓存的path就指向了一个已经从画布上摘下来的孤儿对象改它半天没反应。解决办法是不要缓存每帧或者每次chart.on(rendered)之后重新抓一次。find一次几百个元素的开销在毫秒级以下完全可以每帧跑。光点可以用同样的思路接上。path.shape.points拿到的就是折线在像素坐标系里的点直接喂给 §2.3 那个插值函数然后用chart.getZr().add()挂一个自己创建的zrender.Circle每帧改x/y再dirty()。完全不经过 series连 setOption 都不用调。refresh()是全量重绘。如果图上有其他重元素比如几十个柱子的柱状图混排每帧refresh()会把它们也重画一遍。这时候退回到setOption反而更划算因为 ECharts 的差异检测会跳过没变的部分。所以这条路线适合图上元素不多、但数据点很多的场景反过来就要慎用。4.3 三个必须处理的边界null 断点、页面隐藏、多线相位null 断点会让相位重置。如果数据里有null或者-表示缺测ECharts 会把折线拆成多段独立的 path每段各自从头开始应用 dasharray。现象是流动到断点处跳一下相位不连续。三种处理用connectNulls: true把线连起来但会改变数据语义看业务能不能接受用前值填充数据在图例或 tooltip 里注明缺测按前值处理或者把 dash 周期调小让跳变幅度小到看不出来。我一般选第二个因为大屏场景下线断开本身也不好看。页面隐藏要暂停。document.addEventListener(visibilitychange, ...)里判断document.hidden隐藏时cancelAnimationFrame并把lastTs归零回来时重新起循环。单图页面感觉不明显但大屏上经常一页挂 6 个图表6 个 rAF 循环叠在一起切出去的时候 CPU 占用还挺可观。大屏虽然一般不会切后台但笔记本上做演示的时候经常切这个三行的处理值得加。多条线的相位要错开。如果三条线共用同一个offset它们的亮带位置会完全同步看起来像三条线被同一只手推着走反而假。做法是给每条线一个固定相位差offsetFor(index) { return offset - index * DASH_CYCLE / 3; }三段错开之后三条线的光带就会交替出现观感自然很多。如果还想让所有线的光带同时到达终点那就得按各自路径总长度归一化速度——长线的像素速度要乘以本线总长 / 最长线总长这个用path.shape.points加一遍长度求和就能算出来。5. 大屏上真正决定观感的几个参数5.1 流光配色渐变放底层亮带放上层很多人想要流光渐变线也就是线本身从蓝渐变到紫同时还有光在跑。这两件事在 ECharts 里不能在同一个系列上同时做dashOffset是把同一条线切成一段一段每段用的是同一个stroke没法逐段上色。正确做法是拆开渐变放在底层实线用linearGradient流动层用单色的浅色调。底层承托颜色上层负责动合起来就是流动渐变的效果。配色上我踩过的坑底层别用纯灰。用和亮带同色相的深色比如亮带是#a8e6ff底层就用同色系rgba(58,120,255,0.16)看起来像同一条线在发光而不是两条线叠着。亮带别用纯白。大屏背光环境下纯白容易过曝看起来刺眼。用主色提亮两档的浅色观感更贵。想要发光把shadowBlurshadowColor加在底层实线上不要加在流动层。虚线是分段绘制的每一段都会画一次阴影段数一多开销很明显大屏上直接反映成掉帧。如果觉得单层亮带太单调可以再叠一条相位错开 1/3 周期的虚线系列会自然形成主光带 拖尾的层次成本就是多一个 series非常划算。5.2 参数速查表参数建议值说明底层线宽8-12px按大屏尺寸往上调4K 下建议 12px 起流动层线宽底层线宽的 40%-60%太接近会看不出层次流动层capbutt用round会变成胶囊串流动层joinround缓解拐点尖角dash1.2-2 倍流动层线宽太短糊太长像直线gap0.6-1 倍平均线段长度核心参数见 §3.3速度60-120 px/s汇报场景偏慢监控大屏偏快帧率30fps流动效果不需要 60fpszlevel不用一层一张画布纯亏5.3 验收时我会盯的四个细节挂了十分钟之后还顺不顺。offset一直递减虽然做了归零但相位周期如果不是整数像素长时间跑之后可能出现细微的速度漂移感。测法是挂十分钟盯着看肉眼看不出节奏变化就算过。切图例、拖 dataZoom 之后光带还在不在。这两个操作都会触发重绘如果你用了 §4.2 那种缓存引用的写法很可能切一下图例光带就没了。这是最常见的线上 bug一定要手动过一遍。鼠标悬停时线变粗会不会突变。ECharts 默认的 emphasis 会让线加粗变色虚线相位不动但线宽一跳观感很突兀。新版本可以用emphasis: { disabled: true }老版本就把 emphasis 的lineStyle配成和 normal 一模一样。高 DPI 大屏下线宽是不是偏细。lineWidth是逻辑像素会乘devicePixelRatio。同一套 4px 的参数在 1080p 和 4K 大屏上的观感差挺多大屏项目我会按屏宽做一档系数宽屏把线宽和 dash 一起放大 1.3-1.5 倍。最后分享一个我自己一直在用的小习惯调这套参数的时候我会临时在页面上放三四个input分别绑SPEED、dash、gap、线宽边看边拖。在代码里改数字、等编译、刷新页面调一组参数要十分钟做了那个小面板之后两分钟能试十几组。尤其是做多尺寸大屏适配的时候同一套参数在 1920 和 4K 上的观感差别很大必须现场盯着屏幕拖凭想象调出来的参数基本都要返工。
返回列表