问题背景
如上图所示,项目的图表会出现有明显波动的曲线,但是Y轴的标签是一样的情况,给用户带来了极大的困扰,多次有用户反馈该问题。
问题归因
出现该问题的根因是原始数据精度和Y轴标签精度不匹配。原始数据是后端返回的,可能有多位小数,Y轴标签是原始数据经过一定的转换后得到的,通常会保留到某一位精度,参考下列伪代码:
ts
// 原始数据数组
const originValues = [1.1123, 1.093434, 1.1334132, 1.123434, 1.08134];
// 根据原始数据获取对应标签
const getLabel = (originValue) => {
return originValue.toFixed(1);
};
// 标签数组
const YAxisLabels = originValues.map(originValue => {
return getLabel(originValue);
});
// [1.1, 1.1, 1.1, 1.1, 1.1]
console.log(YAxisLabels);
默认情况下,echarts会绘制为如下图所示:
但是天问的图表大部分都开启了YAxis的scale选项,该选项会导致Y轴范围缩小,参考YAxis.scale,便会出现如下图所示情况:
至此,我们便成功复现出该问题。
问题解决
关闭Y轴scale选项
跟产品同学及后端同学沟通,如果不开启scale选项,将有效缓解该问题。后端同学认可该方案,产品同学认可,但仍想保留之前的scale效果,因此最终决定将scale设置为用户自定义配置项,在服务监控JVM页面试水,默认关闭scale,观察用户反馈情况。
2023.09.13上线后至今(2023.11.30),未有用户反馈过JVM页面关闭scale后对他们造成困扰,优化效果良好。
但是,该方案并未解决启用scale情况下的标签重复问题,并且该方案在某些场景下,仍然会出现标签重复的问题,如下列伪代码所示:
ts
// 原始数据数组
const originValues = [0.1123, 0.093434, 0.1334132, 0.123434, 0.08134];
// 根据原始数据获取对应标签
const getLabel = (originValue) => {
return originValue.toFixed(1);
};
// 标签数组
const YAxisLabels = originValues.map(originValue => {
return getLabel(originValue);
});
// [0.1, 0.1, 0.1, 0.1, 0.1]
console.log(YAxisLabels);
这组数据即使关闭了scale选项,仍然会出现标签重叠问题:
因此,需要实现一套更加优秀的解决方案。
动态Y轴范围算法
重新审视该问题。之所以会产生这个问题,其实是因为Y轴范围太小,导致Y轴标签的差距太小,乃至于小于标签的精度,从而引起了标签重复的问题。那我们是不是只需要将Y轴范围拉大,让相邻两个Y轴标签的差距大于标签的精度,就可以解决这个问题了呢?
动手试一试。首先,定义需要实现的函数getYAxisRange:
ts
/**
*
* @param min 数据最小值
* @param max 数据最大值
* @param unit 数据单位
* @param decimals 标签精度
* @returns [Y轴最小值, Y轴最大值],如果最大值或最小值为undefined则表示不特殊需要设置
*/
const getYAxisRange(min: number, max: number, unit?: string, decimals?: number): (number | undefined)[] => {
// implementation
};
然后在函数的具体实现中,需要获取到最大值和最小值对应的Y轴标签:
ts
const minVal = unitFormatter(min, unit, decimals).toString();
const maxVal = unitFormatter(max, unit, decimals).toString();
其中unitFormatter函数就是数据转换函数,参数是原始数据、单位、保留精度,返回值是对应的标签。然后我们需要获取标签中的整数、小数、单位、百分号,用正则表达式进行匹配:
ts
// 匹配[123423, 2343.2343, 3432.23434mb, 343.34gb, 243mb, 98.34%]等,匹配格式为[整数, 小数, 单位, 百分号]
const regex = /^(-?\d+)(?:.(\d+))?(\D*)(%)?$/;
const matchMin = regex.exec(minVal);
const matchMax = regex.exec(maxVal);
然后针对一些不用处理的情况,我们直接返回:
ts
// 没匹配上
if (!matchMin || !matchMax) {
return [undefined, undefined];
}
// 单位不同
if (matchMin[3] !== matchMax[3]) {
return [undefined, undefined];
}
接着我们处理只有整数,没有小数的情况,如果最大值与最小值之间的差值大于等于10,那么是不会出现标签重复问题的,不用特殊处理,因为一个图表一般不会有超过10个标签(如果有,你要考虑一下你的标签是否放得下,会不会重叠,如果非得超过10个,你自己把这个值拉大一点就好,或者可以考虑作为一个参数传入,但我的场景不需要了)。如果小于10,我们需要放大Y轴的范围,这里的处理比较巧妙,对于最小值,先把数值除10后取整再减去0.1,然后再向下取整,是为了保证所得到的值一定比当前标签值小,因为存在这种情况,比如原始数据为0.99,经过转换四舍五入后是1,如果不减去0.1直接向下取整就会得到Y轴最小值为1,那么图表中就不会展示0.99的点,同理你就可以理解最大值的处理方式,另外需要注意,在我的场景中,所有数值均是非负数,因此最小值不能小于0:
ts
// 没有小数
if (!matchMin[2] && !matchMax[2]) {
const minNum = Number(matchMin[1]);
const maxNum = Number(matchMax[1]);
// 差值大于等于10返回
if (maxNum - minNum >= 10) {
return [undefined, undefined];
}
// 否则特殊处理
const floorMin = Math.max(Math.floor(parseInt((minNum / 10).toString(), 10) - 0.1), 0) * 10 + matchMin[3] + (matchMin[4] ?? '');
const ceilMax = Math.ceil(parseInt((maxNum / 10).toString(), 10) + 0.1) * 10 + matchMax[3] + (matchMax[4] ?? '');
const resMin = transformLabel(floorMin, unit);
const resMax = transformLabel(ceilMax, unit);
return [resMin, resMax];
}
注意到上述代码中有一个transformLabel函数,这个函数是unitFormatter的反函数,unitFormatter用于将原始数据转换为标签,而transformLabel函数用于将标签转换为原始数据,至于这两个函数的具体实现,是个体力活,不过多赘述,只举一个最简单的例子帮助理解:
ts
const unitFormatter = (value: number, unit?: string, decimals = 1) => {
if (unit === 'SHORT') {
if (value >= 1000) {
return (value / 1000).toFixed(decimals);
}
return value.toFixed(decimals);
}
return value;
}
const transformLabel = (label: string, unit?: string) => {
if (unit === 'SHORT') {
const regex = /^(-?\d+(?:.\d+)?)(\D*)$/;
const match = regex.exec(label);
if (!match || match.length < 3) {
return 0;
}
if (match[2] === 'k') {
return Number(match[1]) * k;
}
return Number(match[1]);
}
return Number(match[1]);
}
处理完只有整数的情况,接下来我们处理具有小数的情况。首先,鉴于存在小数位数对不齐的情况,我们先填充到相同位数:
ts
// 填充位数
const length = Math.max(matchMin[2]?.length ?? 0, matchMax[2]?.length ?? 0);
if (!matchMin[2]) {
matchMin[2] = '';
}
if (!matchMax[2]) {
matchMax[2] = '';
}
matchMin[2] = matchMin[2].padEnd(length, '0');
matchMax[2] = matchMax[2].padEnd(length, '0');
接着继续处理,其实和处理整数的思路差不多,无非多了一个逻辑,需要把小数先左移几位,方便Math.floor和Math.ceil处理,然后右移回来(注意判断你的场景先左移的操作是否会导致整型溢出,我的场景不用考虑溢出问题,因此直接暴力乘上去再除就行):
ts
// 如果最大值和最小值相差大于等于精度的10倍,则直接返回
const minNum = Number(matchMin[1] + '.' + matchMin[2]);
const maxNum = Number(matchMax[1] + '.' + matchMax[2]);
if (maxNum - minNum >= Math.pow(10, -(length - 1))) {
return [undefined, undefined];
}
// 否则特殊处理
const floorMin = Math.max(Math.floor(Number(matchMin[1] + matchMin[2].slice(0, length - 1)) - 0.1) / Math.pow(10, length - 1), 0) + matchMin[3] + (matchMin[4] ?? '');
const ceilMax = Math.ceil(Number(matchMax[1] + matchMax[2].slice(0, length - 1)) + 0.1) / Math.pow(10, length - 1) + matchMax[3] + (matchMax[4] ?? '');
const resMin = transformLabel(floorMin, unit);
const resMax = transformLabel(ceilMax, unit);
return [resMin, resMax];
至此,getYAxisRange函数封装完毕。接下来看看如何使用,首先确认传参,其中unit和decimals都是后端返回的,直接传就好,但是minValue和maxValue就要自己处理一下了,主要有下列几种情况:
-
单曲线,只需拿到该曲线的最大值、最小值即可。
-
多曲线,需要拿到所有曲线中的最大值、最小值。
-
堆叠图,相同时间的数据需要累加,从而获取最大值、最小值。
然后调用getYAxisRange函数,拿到返回值分别赋值给Y轴的最大值和最小值即可,都懂,无需多言。
效果对比
优化前:
优化后: