echarts 动态增删 y 轴

1,298 阅读4分钟

最近做了一个图表的新需求,涉及到 y 轴的增删管理。最终效果如下:

动态增删y轴.gif

需求细化一下是这样的:

  • y 轴是后端提供的,要动态生成
  • 系列名称、单位相同的共用一个 y 轴
  • y 轴左侧放一个,剩下的在右侧依次显示
  • 点击图例隐藏系列数据后,要隐藏对应的 y 轴
  • 隐藏左侧的 y 轴后,右侧的第一个 y 轴会自动补位过去
  • 初始默认显示前四个系列,剩下的隐藏
  • 其他的小需求,例如缩放、tooltip 显示单位

因为涉及到的 echarts 配置项比较多,也会接触一些不常用的 echarts 功能,所以这里记录一下,避免大家走弯路。完整用例在文末

后端数据

后端提供的数据结构长这个样子:

 {
    xAxis: ['2022-06-10', '2022-06-11', '2022-06-12', '2022-06-13', '2022-06-14', '2022-06-15', '2022-06-16'],
    series: [
        {
            data: [150, 230, 224, 218, 135, 147, 260],
            name: '温度',
            unit: '℃',
        },
        {
            data: [1523, 321, 251, 666, 322, 612, 111],
            name: '湿度',
            unit: 'RH'
        }
    ]
}

是否加 y 轴的判断方法就是如果名字(name)和单位(unit)有一个不同的就会新增一条 y 轴。因为某些系列虽然单位相同,但是量级差别很大,显示在一起会把一个系列挤成直线,所以索性就分成两个轴了。

设计思路

思路其实不复杂,记住 immutable 就行了。只要数据变化就重新生成一份 option.yAxis,不要想着去修改之前的 y 轴配置项。

然后图表实例化之后绑定点击图例的事件,触发后复制一份原始数据,标记一下哪个隐藏了,之后生成一份新的配置项再拿去 setOptions 就可以了。

功能实现和一些踩到的坑

1、生成 y 轴配置项

代码如下:

/**
 * 从系列数据中生成 y 轴配置项
 *
 * @param {array} series 从后端返回的系列数据
 * @returns { yAxis, series } y 轴配置项和绑定了 y 轴索引的系列配置项
 */
const withMultipleYaxis = (series) => {
    const yAxisList = series.map(serie => {
        if (serie.hideYaxis) return false;

        const name = `${serie.name} ${serie.unit}`
        return [
            name,
            { serieName: serie.name, unit: serie.unit, name }
        ]
    }).filter(Boolean);
    // 去重
    const yAxisData = Array.from(new Map(yAxisList)).map(item => item[1]);

    // 填充其他 y 轴配置项
    const yAxis = yAxisData.map((yAxisItem, index) => {
        return {
            ...yAxisItem,
            alignTicks: true,
            position: index <= 0 ? 'left' : 'right',
            offset: index <= 0 ? 0 : 80 * (index - 1),
        }
    })

    // 给系列绑定对应的 y 轴
    const newSeries = series.map(serie => {
        // 找到对应的 yAxis 索引
        const yAxisIndex = yAxisData.findIndex(yAxisItem => yAxisItem.serieName === serie.name);
        const newSeries = {
            ...serie,
            // 这个必须设置,找不到 y 轴就不设置的话会报错
            yAxisIndex: yAxisIndex >= 0 ? yAxisIndex : 0
        }
        return newSeries
    })

    return {
        yAxis,
        series: newSeries
    }
}

这个纯函数接受后端返回的系列数据,然后生成 y 轴配置项,大体可以分为四步:

  • 从系列数据生成基础的 y 轴数组
  • 对数组进行去重
  • 填充 y 轴配置项,例如在左侧还是右侧、y 轴偏移量是多少
  • 将生成好的 y 轴绑定到系列数据上

上面用到了一个属性 hideYaxis,这个不是 echarts 的配置项,是我们自定义的。用于标注哪个数据被用户隐藏了,后面回调里会更新这个属性。

注意这里有一个坑,上面将生成好的 y 轴绑定到系列数据上有这么一句:

yAxisIndex: yAxisIndex >= 0 ? yAxisIndex : 0

这里如果没找到会绑定到第一个 y 轴上,不可以为空! 不然在点击图例隐藏系列数据时有可能会弹出来下面这个报错:

image.png

这个报错完全没法定位到问题,废了老大劲才解决的。

除此之外,这个配置项还使用了 alignTicks 来让多个 y 轴的横线可以保持一致,文档在这里:Documentation - Apache ECharts

2、生成图表配置项

/**
 * 使用的图表系列色
 */
const CHART_COLORS = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']

/**
 * 从后端数据生成 echart 配置项
 */
const getChartOptions = (data) => {
    const newData = withMultipleYaxis(data.series)

    const options = {
        xAxis: {
            type: 'category',
            data: data.xAxis
        },
        yAxis: newData.yAxis.map((item, index) => ({
            ...item,
            axisLine: {
                show: true,
                lineStyle: {
                    color: CHART_COLORS[index]
                }
            }
        })),
        dataZoom: [
            {
                type: 'inside',
                realtime: true,
                start: 0,
                end: 100,
                xAxisIndex: [0, 1]
            }
        ],
        tooltip: {
            trigger: 'axis',
            axisPointer: {
                type: 'cross'
            },
            formatter: params => {
                const html = params[0].name + '<br>' + params.map(serie => {
                    let content = (serie.value || '-') + ' ' + data.series[serie.seriesIndex].unit;

                    const html = serie.marker
                        + (serie.seriesName ? serie.seriesName + ':' : '')
                        + `<span style="float: right;font-weight: 900;margin-left: 24px;">${content}</span>`;
                    return html;
                }).join('<br />');

                return html;
            }
        },
        grid: {
            top: 50,
            left: 60,
            // 根据 y 轴数量留足显示空间
            right: newData.yAxis.length <= 1 ? 20 : 80 * (newData.yAxis.length - 1),
        },
        legend: {
            bottom: 10,
        },
        series: newData.series.map(serie => ({ ...serie, type: 'line' }))
    };

    return options;
}

这个函数接受后端传来的数据,然后生成最终的 chart 配置项,其中有几个注意点:

  • 根据 y 轴的数量配置 grid.right 防止多出来的 y 轴看不到,如果右侧的 y 轴在两个以内 echart 是会自己处理宽度的,但是多了就不行了。
  • yAxis.axisLine.lineStyle.color 给多个 y 轴配置颜色,不然会默认显示成灰色。
  • formatter tooltip 时使用了 serie.marker 来显示对应的系列色小圆点,这个官方文档里没有提到。

3、绑定回调事件

// 注册一下图例点击事件,用于在隐藏系列数据时同步隐藏对应的 y 轴
myChart.off('legendselectchanged');
myChart.on('legendselectchanged', params => {
    const selected = { ...params.selected };

    // 这里检查一下,如果全都没选中的话,就至少保留一个,以防止一个 y 轴都没有
    if (!Object.values(selected).includes(true)) {
        selected[params.name] = true;
    }

    const newData = {
        ...data,
        series: data.series.map(serie => {
            // 找到该系列是否选中
            return { ...serie, hideYaxis: !selected[serie.name] }
        })
    };

    myChart.setOption(getChartOptions(newData), { replaceMerge: ['yAxis'] });
});

// 触发一下图例点击事件,做到默认只显示前四个系列
data.series.slice(4).forEach(serie => {
    myChart.dispatchAction({ type: 'legendToggleSelect', name: serie.name });
})

这里需要注意的点:

点击图例隐藏 y 轴时的那个 setOption 要指定 replaceMerge: ['yAxis'],不然会出现 y 轴没有隐藏的问题,就像这样:

动态增删y轴问题.gif

这个问题的根源在于:echart 对于 setOption 是默认增量合并的,也就是说第二次的 setOption 不会覆盖第一次的 y 轴配置项,而是把他俩“融合起来”,所以就要使用配置项 replaceMerge 来指定需要覆盖哪个属性。

replaceMerge 的介绍和 setOption 的配置合并见这里:Documentation - Apache ECharts要稍微往下翻一点 )。

第二个问题是 dispatchAction 时的事件类型是一一对应的,例如 legendToggleSelect 就对应着监听时的 legendselectchanged 事件,官方文档里可以找到:

image.png

这个别填错了,不对应的话就没法触发你绑定的事件。比如我一开始发射的是 legendUnSelect,还在纳闷为啥没有触发我绑定的 legendselectchanged 回调。

第三个问题是,每次事件触发都要检查一下,如果点灭的是最后一个系列就不要隐藏 y 轴了,不然一个 y 轴都没有的话会报错。

完整用例

下面这个例子新建个 html 文件复制进去就可以跑:

<!DOCTYPE html>
<html lang="zh-CN" style="height: 100%">
<head>
    <meta charset="utf-8">
    <script type="text/javascript" src="https://fastly.jsdelivr.net/npm/echarts@5.3.3/dist/echarts.min.js"></script>
</head>

<body style="height: 100%; margin: 0">
    <div id="container" style="height: 100%"></div>
</body>

<script type="text/javascript">
/**
 * 使用的图表系列色
 */
const CHART_COLORS = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']

/**
 * 后端传来的数据格式
 */
const MOCK_DATA = {
    xAxis: ['2022-06-10', '2022-06-11', '2022-06-12', '2022-06-13', '2022-06-14', '2022-06-15', '2022-06-16'],
    series: [
        { data: [150, 230, 224, 218, 135, 147, 260], name: '温度', unit: '℃' },
        { data: [153, 321, 251, 666, 322, 612, 111], name: '湿度', unit: 'RH' },
        { data: [153, 312, 111, 126, 242, 112, 111], name: '测试1', unit: 'RH' },
        { data: [154, 314, 144, 122, 243, 122, 114], name: '测试2', unit: 'RH' },
        { data: [254, 314, 244, 222, 343, 222, 214], name: '测试3', unit: 'RH' },
        { data: [554, 514, 544, 522, 543, 522, 514], name: '测试4', unit: 'RH' }
    ]
}

/**
 * 从系列数据中生成 y 轴配置项
 *
 * @param {array} series 从后端返回的系列数据
 * @returns { yAxis, series } y 轴配置项和绑定了 y 轴索引的系列配置项
 */
const withMultipleYaxis = (series) => {
    const yAxisList = series.map(serie => {
        if (serie.hideYaxis) return false;

        const name = `${serie.name} ${serie.unit}`
        return [
            name,
            { serieName: serie.name, unit: serie.unit, name }
        ]
    }).filter(Boolean);
    // 去重
    const yAxisData = Array.from(new Map(yAxisList)).map(item => item[1]);

    // 填充其他 y 轴配置项
    const yAxis = yAxisData.map((yAxisItem, index) => {
        return {
            ...yAxisItem,
            alignTicks: true,
            position: index <= 0 ? 'left' : 'right',
            offset: index <= 0 ? 0 : 80 * (index - 1),
        }
    })

    // 给系列绑定对应的 y 轴
    const newSeries = series.map(serie => {
        // 找到对应的 yAxis 索引
        const yAxisIndex = yAxisData.findIndex(yAxisItem => yAxisItem.serieName === serie.name);
        const newSeries = {
            ...serie,
            // 这个必须设置,找不到 y 轴就不设置的话会报错
            yAxisIndex: yAxisIndex >= 0 ? yAxisIndex : 0
        }
        return newSeries
    })

    return {
        yAxis,
        series: newSeries
    }
}

/**
 * 从后端数据生成 echart 配置项
 */
const getChartOptions = (data) => {
    const newData = withMultipleYaxis(data.series)

    const options = {
        xAxis: {
            type: 'category',
            data: data.xAxis
        },
        yAxis: newData.yAxis.map((item, index) => ({
            ...item,
            axisLine: {
                show: true,
                lineStyle: {
                    color: CHART_COLORS[index]
                }
            }
        })),
        dataZoom: [
            {
                type: 'inside',
                realtime: true,
                start: 0,
                end: 100,
                xAxisIndex: [0, 1]
            }
        ],
        tooltip: {
            trigger: 'axis',
            axisPointer: {
                type: 'cross'
            },
            formatter: params => {
                const html = params[0].name + '<br>' + params.map(serie => {
                    let content = (serie.value || '-') + ' ' + data.series[serie.seriesIndex].unit;

                    const html = serie.marker
                        + (serie.seriesName ? serie.seriesName + ':' : '')
                        + `<span style="float: right;font-weight: 900;margin-left: 24px;">${content}</span>`;
                    return html;
                }).join('<br />');

                return html;
            }
        },
        grid: {
            top: 50,
            left: 60,
            // 根据 y 轴数量留足显示空间
            right: newData.yAxis.length <= 1 ? 20 : 80 * (newData.yAxis.length - 1),
        },
        legend: {
            bottom: 10,
        },
        series: newData.series.map(serie => ({ ...serie, type: 'line' }))
    };

    return options;
}

const initChart = (data) => {
    const myChart = echarts.init(document.getElementById('container'));
    myChart.setOption(getChartOptions(data));

    // 注册一下图例点击事件,用于在隐藏系列数据时同步隐藏对应的 y 轴
    myChart.off('legendselectchanged');
    myChart.on('legendselectchanged', params => {
        const selected = { ...params.selected };

        // 这里检查一下,如果全都没选中的话,就至少保留一个,以防止一个 y 轴都没有
        if (!Object.values(selected).includes(true)) {
            selected[params.name] = true;
        }

        const newData = {
            ...data,
            series: data.series.map(serie => {
                // 找到该系列是否选中
                return { ...serie, hideYaxis: !selected[serie.name] }
            })
        };

        myChart.setOption(getChartOptions(newData), { replaceMerge: ['yAxis'] });
    });

    // 触发一下图例点击事件,做到默认只显示前四个系列
    data.series.slice(4).forEach(serie => {
        myChart.dispatchAction({ type: 'legendToggleSelect', name: serie.name });
    })
}

initChart(MOCK_DATA)
</script>
</html>