前言:有一天,老板跟我说"做个图表,要能看出数据大小,还要好看"。我心想:这不简单?结果...
第一步:先把"舞台"搭起来
<input type="button" value="折线" id="b1">
<input type="button" value="曲线" id="b2">
<input type="button" value="垂直" id="b3">
<div id="chart-container" style="position: relative;height: 90vh;overflow: hidden;"></div>
<script src="https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js"></script>
第二步:准备一些"数学武器"
在开始画图之前,我需要准备几个数学函数。别怕,虽然看起来很吓人,但你有思路啥公式你都可以交给AI实现
武器一:Catmull-Rom 曲线生成器
function getCatmullRomPoints(points, numOfPoints) {
const crPoints = [];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] || points[i];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[i + 2] || p2;
for (let t = 0; t < numOfPoints; t++) {
const tScaled = t / numOfPoints;
const t2 = tScaled * tScaled;
const t3 = t2 * tScaled;
// Catmull-Rom公式 —— 别问我为什么,抄的
const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * tScaled +
(2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 +
(-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * tScaled +
(2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 +
(-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
crPoints.push({x, y});
}
}
return crPoints;
}
这是整个项目里最核心的函数。它能把几个离散的点变成一条丝滑的曲线。
原理是什么?我大概理解是:每个点都受前后几个点的影响,通过某种"神秘"的插值计算,生成中间的过渡点。反正能用就行。
武器二:宽度插值
function interpolateWidth(startWidth, endWidth, t) {
return startWidth + (endWidth - startWidth) * t;
}
这个简单!就是线性插值,让宽度从起点到终点平滑变化。
武器三:计算切线方向
function getTangent(p1, p2) {
return Math.atan2(p2.y - p1.y, p2.x - p1.x);
}
Math.atan2 是个好东西,它能根据 Y 和 X 的差值算出角度。高中数学终于派上用场了!
第三步:开始画图——三种模式
模式一:曲线(最复杂,先说它)
function drawVariableWidthCurve(ctx, points) {
const numOfPointsPerSegment = 100; // 每段插值点数
const crPoints = getCatmullRomPoints(points, numOfPointsPerSegment);
const totalPoints = crPoints.length;
const upperPath = []; // 上边界
const lowerPath = []; // 下边界
for (let i = 0; i < totalPoints; i++) {
const p = crPoints[i];
const t = i / (totalPoints - 1);
const width = interpolateWidth(points[0].width, points[points.length - 1].width, t);
// 计算前后点以获取切线
const pPrev = crPoints[i > 0 ? i - 1 : i];
const pNext = crPoints[i < totalPoints - 1 ? i + 1 : i];
const angle = getTangent(pPrev, pNext);
// 计算垂直方向(切线方向 + 90度)
const perpendicular = angle + Math.PI / 2;
const offsetX = Math.cos(perpendicular) * (width / 2);
const offsetY = Math.sin(perpendicular) * (width / 2);
// 上边界和下边界点
upperPath.push({x: p.x + offsetX, y: p.y + offsetY});
lowerPath.push({x: p.x - offsetX, y: p.y - offsetY});
}
// 开始绘制路径
ctx.beginPath();
ctx.moveTo(upperPath[0].x, upperPath[0].y);
for (let i = 1; i < upperPath.length; i++) {
ctx.lineTo(upperPath[i].x, upperPath[i].y);
}
// 绘制下边界(反向)
for (let i = lowerPath.length - 1; i >= 0; i--) {
ctx.lineTo(lowerPath[i].x, lowerPath[i].y);
}
ctx.closePath();
}
核心思路:
- 先用 Catmull-Rom 生成曲线上的所有点
- 对每个点,计算它的切线方向
- 在垂直于切线的方向上,根据宽度往两边扩展
- 把所有上边界点连起来,再把下边界点反向连回去
- 闭合路径,填充颜色
模式二:折线(简单版)
function drawLine(ctx, points) {
const numOfPointsPerSegment = 100;
let pathPoints = [];
// 对直线生成插值点(比曲线简单,直接线性插值)
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
for (let t = 0; t <= numOfPointsPerSegment; t++) {
const tScaled = t / numOfPointsPerSegment;
const x = p1.x + (p2.x - p1.x) * tScaled;
const y = p1.y + (p2.y - p1.y) * tScaled;
pathPoints.push({x, y});
}
}
// 后面的逻辑和曲线一样...
// 省略,因为完全一样
}
折线模式其实就是曲线模式的"简化版"。区别在于:
- 曲线用 Catmull-Rom 插值
- 折线用线性插值
为什么折线也要插值? 因为宽度要变化啊!不插值的话,宽度变化会很生硬。
模式三:垂直线(最诡异)
function drawH(ctx, points) {
let upperPath = [];
let lowerPath = [];
for (let i = 0; i < points.length; i++) {
const curP = points[i]
const r1 = curP.width / 2
if (i === 0) {
upperPath.push({x: curP.x, y: curP.y + r1})
lowerPath.push({x: curP.x, y: curP.y - r1})
}
const nextP = points[i + 1]
if (nextP && curP.y !== nextP.y) {
const r2 = nextP.width / 2
const d = (r2 - r1) / 2
const isLast = ((i + 1) === points.length - 1)
if (nextP.y > curP.y) {
// 下一个点比当前点高
upperPath.push({x: nextP.x - r2 + d, y: curP.y + r1 + d})
lowerPath.push({x: nextP.x + r2 - d, y: curP.y - r1 - d})
upperPath.push({x: nextP.x - r2, y: isLast ? nextP.y : (nextP.y + r2)})
lowerPath.push({x: nextP.x + r2, y: isLast ? nextP.y : (nextP.y - r2)})
} else {
// 下一个点比当前点低
upperPath.push({x: nextP.x + r2 - d, y: curP.y + r1 + d})
lowerPath.push({x: nextP.x - r2 + d, y: curP.y - r1 - d})
upperPath.push({x: nextP.x + r2, y: isLast ? nextP.y : (nextP.y + r2)})
lowerPath.push({x: nextP.x - r2, y: isLast ? nextP.y : (nextP.y - r2)})
}
} else if (nextP && nextP.y === curP.y) {
// 同一水平线
const r2 = nextP.width / 2
upperPath.push({x: nextP.x, y: nextP.y + r2})
lowerPath.push({x: nextP.x, y: nextP.y - r2})
}
}
// 绘制路径
ctx.beginPath();
ctx.moveTo(upperPath[0].x, upperPath[0].y);
for (const p of upperPath) {
ctx.lineTo(p.x, p.y);
}
for (let i = lowerPath.length - 1; i >= 0; i--) {
const p = lowerPath[i];
ctx.lineTo(p.x, p.y);
}
ctx.closePath();
ctx.fill();
}
这个函数的逻辑... 怎么说呢,写完我自己都快看不懂了。
核心思想是:每个点之间用垂直线连接,但宽度会变化,所以需要计算"过渡区域"。那个 d = (r2 - r1) / 2 就是用来处理宽度差异的。
为什么这么复杂? 因为要考虑各种情况:
- 下一个点比当前点高
- 下一个点比当前点低
- 两个点在同一水平线
- 是最后一个点
写这个函数的时候,我画了整整一张流程图...
第四步:注册自定义图形
const newRenderShape = echarts.graphic.extendShape({
buildPath: (ctx, shape) => {
const {points, isLast, type} = shape
if (isLast) {
if (type === 1) {
drawLine(ctx, points) // 直男折线
} else if (type === 2) {
drawVariableWidthCurve(ctx, points) // 温柔曲线
} else if (type === 3) {
drawH(ctx, points) // 诡异垂直线
}
}
}
})
echarts.graphic.registerShape('newRenderShape', newRenderShape);
这段代码把我们的绘制函数"注册"到 ECharts 里,让它知道怎么画我们的自定义图形。
关键点:isLast 的判断很重要!因为 ECharts 会为每个数据点调用一次 buildPath,如果不判断,同样的线条会被画 N 次!
// params来源于series项type为custom时的renderItem函数
const isLast = params.dataIndex === params.dataInsideLength - 1
第五步:准备数据
let rArr = [10, 20, 25, 30, 35] // 预设的半径档位
let data1 = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] // X轴标签
let data2 = [500, 23, 224, 290, 290, 147, 260] // Y轴数据
let data3 = [300, 300, 300, 456, 456, 231, 897] // 用于计算大小的数据
let cc = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'red'] // 颜色
// 把原始数据映射到预设的半径档位
let newRArr = getCircleR(data3)
function getCircleR(arr) {
let max = Math.max(...arr)
let min = Math.min(...arr)
let intervalWidth = (max - min) / 5
let intervalArr = [
min + intervalWidth,
min + intervalWidth * 2,
min + intervalWidth * 3,
min + intervalWidth * 4,
max
]
return arr.map(r => {
for (let i = 0; i < intervalArr.length; i++) {
if (r <= intervalArr[i]) {
return rArr[i]
}
}
})
}
// 组装最终数据
let data4 = []
let data5 = []
data1.forEach((v, index) => {
data4.push([v, data2[index], newRArr[index], cc[index]])
})
data1.forEach((v, index) => {
data5.push([v, data2[index] / 2, newRArr[index], cc[index]])
})
数据格式是 [x轴值, y轴值, 大小, 颜色]。
为什么要分档? 因为如果不分档,数据差异太大的时候,有的点会大得像太阳,有的点会小到看不见。
第六步:初始化图表
var dom = document.getElementById('chart-container');
var myChart = echarts.init(dom, null, {
renderer: 'canvas',
useDirtyRect: false // 关闭脏矩形渲染,确保自定义图形正确绘制
});
useDirtyRect: false 这个配置很重要!如果开启脏矩形渲染,自定义图形可能会出现奇怪的残影。
第七步:配置图表选项
option = {
grid: [
{left: 120, height: 200, bottom: 300}, // 上面的图表
{left: 120, height: 200, bottom: 20}, // 下面的图表
],
xAxis: [
{gridIndex: 0, type: 'category', data: data1},
{gridIndex: 1, type: 'category', data: data1}
],
yAxis: [
{gridIndex: 0, type: 'value'},
{gridIndex: 1, type: 'value'}
],
tooltip: {
trigger: 'item',
formatter: function(params) {
return `${params.data[0]}:${params.data[1]}<br />大小:${params.data[2]}`
}
},
series: [
// 上面的图表:散点 + 自定义线条
{
yAxisIndex: 0, xAxisIndex: 0,
type: 'scatter',
symbol: 'circle',
symbolSize: function(data) {
return data[2] * 0.4 * 2; // 根据大小调整圆的尺寸
},
itemStyle: {
color: '#000',
borderColor: 'rgba(255, 255, 255, 1)',
borderWidth: 4,
},
data: data4,
animation: false,
label: {show: true, formatter: '{b}', position: 'top'}
},
{
yAxisIndex: 0, xAxisIndex: 0,
type: 'custom',
renderItem: renderItem,
data: data4,
tooltip: {show: false},
},
// 下面的图表:散点 + 自定义线条
// ... 类似配置
]
};
myChart.setOption(option)
这里有两个 grid,每个 grid 里有两组 series:一组是散点(显示数据点),一组是自定义(显示连接线)。
为什么散点和线条分开? 因为散点需要显示 tooltip,而线条不需要。分开控制更灵活。
第八步:核心渲染函数
function renderItem(params, api) {
// 把数据坐标转换成像素坐标
var start = api.coord([api.value(0), api.value(1)]);
var color = api.value(3)
var r1 = api.value(2) * 0.15 // 缩放半径
const obs = {
x: start[0],
y: start[1],
width: r1 * 2,
color,
}
// 把所有点收集起来
if (params.context.points) {
params.context.points.push(obs)
} else {
params.context.points = [obs]
}
const isLast = params.dataIndex === params.dataInsideLength - 1
let gradient
if (isLast) {
// 创建渐变色填充样式
gradient = new echarts.graphic.LinearGradient(0, 0, 1, 1);
for (let i = 0; i < params.context.points.length; i++) {
gradient.addColorStop(i / (params.context.points.length - 1), params.context.points[i].color);
}
}
return {
type: 'newRenderShape',
shape: {
points: params.context.points,
isLast,
type, // 全局变量,控制绘制模式
},
style: {
fill: gradient ? gradient : '#000000'
}
}
}
这个函数是 ECharts 自定义图形的核心。它会被每个数据点调用一次,每次调用时:
- 把当前点的信息收集到
params.context.points里 - 如果是最后一个点,创建渐变色
- 返回自定义图形的配置
第九步:添加交互
let type = 1 // 默认折线模式
for (let i = 1; i <= 3; i++) {
let btn = document.querySelector('#b' + i)
btn.addEventListener('click', () => {
type = i
myChart.resize() // 触发重绘
})
}
window.addEventListener('resize', myChart.resize);
三个按钮,分别切换三种模式。点击后改变 type 变量,然后调用 resize() 触发重绘。
总结:从零到一的实现过程
- 搭建舞台:HTML 结构 + 引入 ECharts
- 准备武器:数学函数(插值、切线计算等)
- 实现绘制:三种模式的绘制函数
- 注册图形:把绘制函数"告诉" ECharts
- 准备数据:数据映射和组装
- 配置图表:grid、axis、series
- 核心渲染:renderItem 函数
- 添加交互:按钮切换模式
整个过程就像搭积木,一块一块往上堆。虽然中间踩了很多坑,但最终效果还是很满意的。
最后的话
写这个图表的过程,让我深刻体会到:
- 数学真的很重要:虽然可以抄公式,但理解原理才能灵活运用
- Canvas 很强大:只要你想得到,它就能画出来
- ECharts 很灵活:自定义图形功能让一切皆有可能
- 调试是常态:不调试个几十次,都不好意思说自己写过代码
如果你也想尝试自定义 ECharts 图表,我的建议是:别怕麻烦,大胆尝试,多查文档,善用调试工具。
最后,附上一句我调试时的内心独白:
"这代码能跑? 能跑就别动它!"
P.S. 如果你发现代码里有 bug,那一定是 feature,不是我写错了 😅