最近做了一个图表的新需求,涉及到 y 轴的增删管理。最终效果如下:
需求细化一下是这样的:
- 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 轴上,不可以为空! 不然在点击图例隐藏系列数据时有可能会弹出来下面这个报错:
这个报错完全没法定位到问题,废了老大劲才解决的。
除此之外,这个配置项还使用了 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 轴没有隐藏的问题,就像这样:
这个问题的根源在于:echart 对于 setOption 是默认增量合并的,也就是说第二次的 setOption 不会覆盖第一次的 y 轴配置项,而是把他俩“融合起来”,所以就要使用配置项 replaceMerge 来指定需要覆盖哪个属性。
replaceMerge 的介绍和 setOption 的配置合并见这里:Documentation - Apache ECharts (要稍微往下翻一点 )。
第二个问题是 dispatchAction 时的事件类型是一一对应的,例如 legendToggleSelect 就对应着监听时的 legendselectchanged 事件,官方文档里可以找到:
这个别填错了,不对应的话就没法触发你绑定的事件。比如我一开始发射的是 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>