当 ECharts 遇上数学:一个程序员的"曲线救国"之路

0 阅读8分钟

前言:有一天,老板跟我说"做个图表,要能看出数据大小,还要好看"。我心想:这不简单?结果...


第一步:先把"舞台"搭起来

<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();
}

核心思路

  1. 先用 Catmull-Rom 生成曲线上的所有点
  2. 对每个点,计算它的切线方向
  3. 在垂直于切线的方向上,根据宽度往两边扩展
  4. 把所有上边界点连起来,再把下边界点反向连回去
  5. 闭合路径,填充颜色

模式二:折线(简单版)

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 自定义图形的核心。它会被每个数据点调用一次,每次调用时:

  1. 把当前点的信息收集到 params.context.points
  2. 如果是最后一个点,创建渐变色
  3. 返回自定义图形的配置

第九步:添加交互

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() 触发重绘。


总结:从零到一的实现过程

  1. 搭建舞台:HTML 结构 + 引入 ECharts
  2. 准备武器:数学函数(插值、切线计算等)
  3. 实现绘制:三种模式的绘制函数
  4. 注册图形:把绘制函数"告诉" ECharts
  5. 准备数据:数据映射和组装
  6. 配置图表:grid、axis、series
  7. 核心渲染:renderItem 函数
  8. 添加交互:按钮切换模式

整个过程就像搭积木,一块一块往上堆。虽然中间踩了很多坑,但最终效果还是很满意的。


最后的话

写这个图表的过程,让我深刻体会到:

  • 数学真的很重要:虽然可以抄公式,但理解原理才能灵活运用
  • Canvas 很强大:只要你想得到,它就能画出来
  • ECharts 很灵活:自定义图形功能让一切皆有可能
  • 调试是常态:不调试个几十次,都不好意思说自己写过代码

如果你也想尝试自定义 ECharts 图表,我的建议是:别怕麻烦,大胆尝试,多查文档,善用调试工具

最后,附上一句我调试时的内心独白:

"这代码能跑? 能跑就别动它!"


P.S. 如果你发现代码里有 bug,那一定是 feature,不是我写错了 😅