坐標(biāo)軸刻度取值算法之源于echarts的y軸刻度計(jì)算需求
前言
因?qū)嵙?xí)的公司是做大數(shù)據(jù)的,而我的工作剛好又是需要繪制一些數(shù)據(jù)圖表的。繪制圖表有許多現(xiàn)成的組件可以使用,但是要想達(dá)到產(chǎn)品所需要的效果,只靠組件內(nèi)部的一些功能是不太夠的。一些細(xì)膩的要求必須在掌握組件原理方法的情況下,自己去寫(xiě)算法來(lái)完成。例如,本文要說(shuō)的這個(gè)刻度計(jì)算算法,開(kāi)始正文之前,我先描述遇到的問(wèn)題。
echarts自身的刻度計(jì)算有時(shí)候并不好用,例如有時(shí)候你希望讓圖表只有5條刻度線,即分成4段,echarts提供了一個(gè)參數(shù)叫splitNumber,把splitNumber設(shè)為4可以讓圖表盡量地分成4段,然而當(dāng)數(shù)據(jù)波動(dòng)較大時(shí),echarts會(huì)自動(dòng)增加分割的段數(shù),即即使大部分?jǐn)?shù)據(jù)都能正常分出4段刻度,但仍有少部分?jǐn)?shù)據(jù)實(shí)際分出的段數(shù)可能不止4段。如下面這樣:

因此我們得出一個(gè)結(jié)論,echarts的splitNumber只是預(yù)估的分割段數(shù)。如果我們需要強(qiáng)制把刻度區(qū)間分為4段,則需要我們自己去寫(xiě)算法計(jì)算刻度。另外,即使段數(shù)正確的情況下,echarts自動(dòng)計(jì)算出的刻度也可能存在區(qū)間過(guò)大,數(shù)據(jù)差異不明顯的情況,如下面的圖片,因?yàn)榭潭葏^(qū)間太大,導(dǎo)致各個(gè)數(shù)據(jù)看起來(lái)像是差不多大的,看不出差異:

另外,echarts自動(dòng)計(jì)算出的刻度也有一些其他的問(wèn)題,例如當(dāng)圖表在柱狀圖和堆疊圖中切換時(shí),堆疊圖可能出現(xiàn)刻度溢出問(wèn)題。不過(guò)堆疊圖的刻度計(jì)算這里就先不說(shuō)明了,下面開(kāi)始正文吧。
算法描述
刻度計(jì)算的算法之前我之前也寫(xiě)了一版,解決了分割段數(shù)的問(wèn)題,但是仍無(wú)法解決刻度區(qū)間過(guò)大的問(wèn)題。之前那一版算法的主要思想是取近似值,分別取最大值和最小值的最小近似整值得到刻度,雖然不是最優(yōu)的算法,但是在構(gòu)思和調(diào)整算法的時(shí)候我也學(xué)到了不少東西,而這一版的算法是在我們技術(shù)老大的點(diǎn)撥下結(jié)合網(wǎng)上的一些文章和項(xiàng)目的需求而寫(xiě)出來(lái)的,算法如下:
要求: 根據(jù)一組數(shù)的最大值、最小值來(lái)確定刻度值,確定刻度的最大值maxi、最小值mini和刻度間隔interval。當(dāng)出現(xiàn)異號(hào)數(shù)據(jù)時(shí)可選擇正負(fù)兩邊刻度是否需要對(duì)稱(chēng),當(dāng)存在異號(hào)數(shù)據(jù)時(shí)要求其中一條刻度分割線必須在0刻度上,可以選擇是否允許段數(shù)誤差。
- 確定分割段數(shù)splitNumber和魔數(shù)數(shù)組magic = [10,15,20,25,30,40,50,60,70,80,90,100];
- 從目標(biāo)數(shù)組arr中算出最大值max和最小值min,確定初始間隔大小 tempGap = (max-min)/splitNumber;
- 設(shè)tempGap除以一個(gè)倍數(shù)multiple后,剛好處于魔數(shù)數(shù)組的區(qū)間[10,100]中,記錄倍數(shù)multiple;
- 從魔數(shù)數(shù)組中取出第一個(gè)大于tempGap縮放值的魔數(shù),用 魔數(shù)*multiple當(dāng)做理想刻度間隔(estep)進(jìn)行第一次計(jì)算,計(jì)算出max和min的鄰近刻度maxi和mini,如果允許分割段數(shù)誤差,則直接結(jié)束運(yùn)算,取interval=estep;
- 當(dāng)刻度需要正負(fù)兩邊對(duì)稱(chēng)且存在異號(hào)數(shù)據(jù)時(shí),取maxi和mini中絕對(duì)值大的一方,將其相反數(shù)賦值給另外一方,計(jì)算interval=(maxi-mini)/splitNumber,結(jié)束運(yùn)算;
- 當(dāng)正負(fù)刻度不需要對(duì)稱(chēng)或不存在異號(hào)數(shù)據(jù)時(shí),判斷實(shí)際分割段數(shù)是否等于splitNumber,如果不相等,則重新取較大的魔數(shù)進(jìn)行運(yùn)算,當(dāng)魔數(shù)取完或者分割段數(shù)相等時(shí)結(jié)束運(yùn)算,得出interval=(maxi-mini)/splitNumber.
上代碼
算法采用javascript語(yǔ)言描述,因?yàn)閳D表的繪制在前端完成。
/*
刻度計(jì)算算法,基于魔術(shù)數(shù)組 [10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100];
解釋?zhuān)耗?shù)數(shù)組是理想間隔數(shù)組,即我們希望每個(gè)刻度的間隔都是魔數(shù)數(shù)組中某個(gè)數(shù)的整數(shù)倍。(準(zhǔn)確的來(lái)說(shuō)是整10倍)
*/
//新增,解決js的浮點(diǎn)數(shù)存在精度問(wèn)題,在計(jì)算出最后結(jié)果時(shí)可以四舍五入一次,因?yàn)榭潭忍∫矝](méi)有意義,所以這里忽略設(shè)置精度為8位
function fixedNum(num){
if((""+num).indexOf('.')>=0) num = parseFloat(num.toFixed(8));
return num;
}
//1.初始化
var symmetrical = false;//是否要求正負(fù)刻度對(duì)稱(chēng)。默認(rèn)為false,需要時(shí)請(qǐng)?jiān)O(shè)置為true
var deviation = false;//是否允許誤差,即實(shí)際分出的段數(shù)不等于splitNumber
var magic = [10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100];//魔數(shù)數(shù)組經(jīng)過(guò)擴(kuò)充,放寬魔數(shù)限制避免出現(xiàn)取不到魔數(shù)的情況。
var arr = [1230, 320, 20, 304, 102, 234];//測(cè)試數(shù)據(jù)
var max,min,splitNumber;
splitNumber = 4;//理想的刻度間隔段數(shù),即希望刻度區(qū)間有多少段
max = Math.max.apply(null,arr);//調(diào)用js已有函數(shù)計(jì)算出最大值
min = Math.min.apply(null,arr);//計(jì)算出最小值
//2.計(jì)算出初始間隔tempGap和縮放比例multiple
var tempGap = (max - min) / splitNumber;//初始刻度間隔的大小。
//設(shè)tempGap除以multiple后剛剛處于魔數(shù)區(qū)間內(nèi),先求multiple的冪10指數(shù),例如當(dāng)tempGap為120,想要把tempGap映射到魔數(shù)數(shù)組(即處理為10到100之間的數(shù)),則倍數(shù)為10,即10的1次方。
var multiple = Math.floor(Math.log10(tempGap)-1);//這里使用Math.floor的原因是,當(dāng)Math.log10(tempGap)-1無(wú)論是正負(fù)數(shù)都需要向下取整。不能使用parseInt或其他取整邏輯代替。
multiple = Math.pow(10,multiple);//剛才是求出指數(shù),這里求出multiple的實(shí)際值。分開(kāi)兩行代碼避免有人看不懂
//3.取出鄰近較大的魔數(shù)執(zhí)行第一次計(jì)算
var tempStep = tempGap / multiple;//映射后的間隔大小
var estep;//期望得到的間隔
var lastIndex = -1;//記錄上一次取到的魔數(shù)下標(biāo),避免出現(xiàn)死循環(huán)
for(var i = 0; i < magic.length;i++){
if(magic[i]>tempStep){
estep = magic[i]*multiple;//取出第一個(gè)大于tempStep的魔數(shù),并乘以multiple作為期望得到的最佳間隔
break;
}
}
//4.求出期望的最大刻度和最小刻度,為estep的整數(shù)倍
var maxi,mini;
function countDegree(estep){
//這里的parseInt是我無(wú)意中寫(xiě)出來(lái)的,本來(lái)我是想對(duì)maxi使用Math.floor,對(duì)mini使用Math.ceil的。這樣能向下取到鄰近的一格,不過(guò)后面發(fā)現(xiàn)用parseInt好像畫(huà)出來(lái)圖的比較好看
maxi = parseInt(max/estep+1) * estep;//最終效果是當(dāng)max/estep屬于(-1,Infinity)區(qū)間時(shí),向上取1格,否則取2格。
mini = parseInt(min/estep-1) * estep;//當(dāng)min/estep屬于(-Infinity,1)區(qū)間時(shí),向下取1格,否則取2格。
//如果max和min剛好在刻度線的話,則按照上面的邏輯會(huì)向上或向下多取一格
if(max===0) maxi = 0;//這里進(jìn)行了一次矯正,優(yōu)先取到0刻度
if(min===0) mini = 0;
if(symmetrical&&maxi*mini<0){//如果需要正負(fù)刻度對(duì)稱(chēng)且存在異號(hào)數(shù)據(jù)
var tm = Math.max(Math.abs(maxi),Math.abs(mini));//取絕對(duì)值較大的一方
maxi = tm;
mini = -tm;
}
}
countDegree(estep);
if(deviation){//如果允許誤差,即實(shí)際分段數(shù)可以不等于splitNumber,則直接結(jié)束
var interval = fixedNum(estep);
console.log(maxi,mini,interval);
return;
}
//5.當(dāng)正負(fù)刻度不對(duì)稱(chēng)且0刻度不在刻度線上時(shí),重新取魔數(shù)進(jìn)行計(jì)算//確保其中一條分割線剛好在0刻度上。
else if(!symmetrical||maxi*mini>0){
outter:do{
//計(jì)算模擬的實(shí)際分段數(shù)
var tempSplitNumber = Math.round((maxi-mini)/estep);
//當(dāng)趨勢(shì)單調(diào)性發(fā)生變化時(shí)可能出現(xiàn)死循環(huán),需要進(jìn)行校正
if((i-lastIndex)*(tempSplitNumber-splitNumber)<0){//此處檢查單調(diào)性變化且未取到理想分段數(shù)
//此處的校正基于合理的均勻的魔數(shù)數(shù)組,即tempSplitNumber和splitNumber的差值較小如1和2,始終取大刻度
while(tempSplitNumber<splitNumber){//讓maxi或mini增大或減少一個(gè)estep直到取到理想分段數(shù)
if((mini-min)<=(maxi-max)&&mini!=0||maxi==0){//在盡量保留0刻度的前提下,讓更接近最值的一邊擴(kuò)展一個(gè)刻度
mini-=estep;
}else{
maxi+=estep;
}
tempSplitNumber++;
if(tempSplitNumber==splitNumber)
break outter;
}
}
//當(dāng)魔數(shù)下標(biāo)越界或取到理想分段數(shù)時(shí)退出循環(huán)
if(i>=magic.length-1|| i<=0 || tempSplitNumber==splitNumber) break;
//記錄上一次的魔數(shù)下標(biāo)
lastIndex = i;
//嘗試取符合趨勢(shì)的鄰近魔數(shù)
if(tempSplitNumber>splitNumber) estep = magic[++i]*multiple;
else estep = magic[--i]*multiple;
//重新計(jì)算刻度
countDegree(estep);
}while(tempSplitNumber!=splitNumber);
}
//6.無(wú)論計(jì)算始終把maxi-mini分成splitNumber段,得到間隔interval。不過(guò)前面的算法已經(jīng)盡量的保證刻度最優(yōu)了,即interval接近或等于理想刻度estep。
maxi = fixedNum(maxi);
mini = fixedNum(mini);
var interval = fixedNum((maxi-mini)/splitNumber);
console.log(maxi,mini,interval);代碼運(yùn)行效果
1.如果不處理小數(shù)誤差,且強(qiáng)制分為4段,出來(lái)的效果是這樣的(20190722版):

2.處理了小數(shù)誤差,并允許刻度誤差出來(lái)的效果是這樣的(20190723版)

可以看出:
- 采用基于魔數(shù)數(shù)組的新算法計(jì)算出的刻度區(qū)間是緊挨著最大值和最小值的,算是差強(qiáng)人意。
- js中浮點(diǎn)數(shù)的精度問(wèn)題是我們?cè)谠O(shè)計(jì)一些通用性的算法時(shí)需要注意的,圖片右邊可以看到,當(dāng)數(shù)據(jù)幅度較小時(shí),算出的min和interval是存在誤差的。
- 圖片左邊的刻度的精確度處理是我寫(xiě)的邏輯,當(dāng)數(shù)據(jù)為非純小數(shù)時(shí)最多只精確到3位小數(shù),純小數(shù)時(shí)精確到8位,避免出現(xiàn)刻度過(guò)長(zhǎng),echarts并沒(méi)有自帶這個(gè)功能。
再附上一張存在異號(hào)數(shù)據(jù)時(shí)的效果和一張需要正負(fù)刻度對(duì)稱(chēng)的效果

可以看出:
- 正負(fù)刻度不對(duì)稱(chēng)且其中一條分割線剛好在0刻度上
- y軸刻度的40K其實(shí)是40000的縮寫(xiě),算法也是要自己寫(xiě)的,echarts沒(méi)有提供這個(gè)功能。

ts版本(2021/3/10補(bǔ)充)
export interface ScaleOption {
/**
* 數(shù)據(jù)最大值
*
* @type {(number | null)}
* @memberof ScaleOption
*/
max: number | null;
/**
* 數(shù)據(jù)最小值
*
* @type {(number | null)}
* @memberof ScaleOption
*/
min: number | null;
/**
* 預(yù)期分成幾個(gè)區(qū)間
*
* @type {number}
* @memberof ScaleOption
*/
splitNumber?: number;
/**
* 存在異號(hào)數(shù)據(jù)時(shí)正負(fù)區(qū)間是否需要對(duì)稱(chēng)
*
* @type {boolean}
* @memberof ScaleOption
*/
symmetrical?: boolean;
/**
* 是否允許實(shí)際分成的區(qū)間數(shù)有誤差
*
* @type {boolean}
* @memberof ScaleOption
*/
deviation?: boolean;
/**
* 是否優(yōu)先取到0刻度
*
* @type {boolean}
* @memberof ScaleOption
*/
preferZero?: boolean;
}
export interface ScaleResult {
max?: number;
min?: number;
interval?: number;
splitNumber?: number;
}
// 雙精度浮點(diǎn)數(shù)有效數(shù)字為15位
const maxDecimal = 15;
/**
* 解決js的浮點(diǎn)數(shù)存在精度問(wèn)題,在計(jì)算出最后結(jié)果時(shí)可以四舍五入一次,刻度太小也沒(méi)有意義
*
* @export
* @param {(number | string)} num
* @param {number} [decimal=8]
* @returns {number}
*/
export function fixedNum(num: number | string, decimal: number = maxDecimal): number {
let str: string = "" + num;
if (str.indexOf(".") >= 0) str = Number.parseFloat(str).toFixed(decimal);
return Number.parseFloat(str);
}
/**
* 判斷非Infinity非NaN的number
*
* @export
* @param {*} num
* @returns {num is number}
*/
export function numberValid(num: any): num is number {
return typeof num === "number" && Number.isFinite(num);
}
/**
* 計(jì)算理想的刻度值,刻度區(qū)間大小一般是[10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100]中某個(gè)數(shù)字的整10倍
*
* @export
* @param {ScaleOption} option
* @returns {ScaleResult}
*/
export function scaleCompute(option: ScaleOption): ScaleResult {
option = {
max: null,
min: null,
splitNumber: 4, // splitNumber建議取4或者5等這種容易被整除的數(shù)字
symmetrical: false,
deviation: false,
preferZero: true,
...option,
};
const magics: number[] = [10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 150]; // 加入150形成閉環(huán)
// tslint:disable-next-line: prefer-const
let { max : dataMax, min: dataMin, splitNumber, symmetrical, deviation, preferZero } = option;
if (!numberValid(dataMax) || !numberValid(dataMin) || dataMax < dataMin) {
return { splitNumber };
} else if (dataMax === dataMin && dataMax === 0) {
return {
max: fixedNum(magics[0] * splitNumber),
min: dataMin,
interval: magics[0],
splitNumber,
};
} else if (dataMax === dataMin) {
preferZero = true;
}
if (!numberValid(splitNumber) || splitNumber <= 0) splitNumber = 4;
if (preferZero && dataMax * dataMin > 0) {
if (dataMax < 0) dataMax = 0;
else dataMin = 0;
}
const tempGap: number = (dataMax - dataMin) / splitNumber;
let multiple: number = Math.floor(Math.log10(tempGap) - 1); // 指數(shù)
multiple = Math.pow(10, multiple);
const tempStep: number = tempGap / multiple;
let expectedStep: number = magics[0] * multiple;
let storedMagicsIndex: number = -1;
let index: number; // 當(dāng)前魔數(shù)下標(biāo)
for (index = 0; index < magics.length; index++) {
if (magics[index] > tempStep) {
expectedStep = magics[index] * multiple; // 取出第一個(gè)大于tempStep的魔數(shù),并乘以multiple作為期望得到的最佳間隔
break;
}
}
let axisMax: number = dataMax;
let axisMin: number = dataMin;
function countDegree(step: number): void {
axisMax = parseInt("" + (dataMax / step + 1)) * step; // parseInt令小數(shù)去尾 -1.8 -> -1
axisMin = parseInt("" + (dataMin / step - 1)) * step;
if (dataMax === 0) axisMax = 0; // 優(yōu)先0刻度
if (dataMin === 0) axisMin = 0;
if (symmetrical && axisMax * axisMin < 0) {
const tm: number = Math.max(Math.abs(axisMax), Math.abs(axisMin));
axisMax = tm;
axisMin = -tm;
}
}
countDegree(expectedStep);
if (deviation) {
return {
max: fixedNum(axisMax),
min: fixedNum(axisMin),
interval: fixedNum(expectedStep),
splitNumber: Math.round((axisMax - axisMin) / expectedStep),
};
} else if (!symmetrical || axisMax * axisMin > 0) {
let tempSplitNumber: number;
out: do {
tempSplitNumber = Math.round((axisMax - axisMin) / expectedStep);
if ((index - storedMagicsIndex) * (tempSplitNumber - splitNumber) < 0) { // 出現(xiàn)死循環(huán)
while (tempSplitNumber < splitNumber) {
if ((axisMin - dataMin <= axisMax - dataMax && axisMin !== 0) || axisMax === 0) {
axisMin -= expectedStep;
} else {
axisMax += expectedStep;
}
tempSplitNumber++;
if (tempSplitNumber === splitNumber) break out;
}
}
if (index >= magics.length - 1 || index <= 0 || tempSplitNumber === splitNumber) break;
storedMagicsIndex = index;
if (tempSplitNumber > splitNumber) expectedStep = magics[++index] * multiple;
else expectedStep = magics[--index] * multiple;
countDegree(expectedStep);
} while (tempSplitNumber !== splitNumber);
}
axisMax = fixedNum(axisMax);
axisMin = fixedNum(axisMin);
const interval: number = fixedNum((axisMax - axisMin) / splitNumber);
return {
max: axisMax,
min: axisMin,
interval,
splitNumber,
};
}結(jié)語(yǔ)
好久沒(méi)有寫(xiě)博客,可能是最近比較忙,如果有空的話其實(shí)前端有很多東西都可以寫(xiě)一下,今天寫(xiě)這個(gè)博客是因?yàn)槲恼缕^小,而且我剛好在檢查算法,另外這個(gè)算法比較有參考意義,就忙里偷閑寫(xiě)出來(lái)給大家看看,也方便我自己以后查閱。目前我參與的項(xiàng)目還在開(kāi)發(fā)完善中,關(guān)于echarts我也踩了很多坑,針對(duì)項(xiàng)目需求編寫(xiě)了一些東西,有機(jī)會(huì)再寫(xiě)出來(lái)大家討論吧。
總結(jié)
到此這篇關(guān)于坐標(biāo)軸刻度取值算法之源于echarts的y軸刻度計(jì)算需求的文章就介紹到這了,更多相關(guān)坐標(biāo)軸刻度取值算法內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Firefox/Chrome/Safari的中可直接使用$/$$函數(shù)進(jìn)行調(diào)試
偶然發(fā)現(xiàn)的,頁(yè)面中沒(méi)有引入Prototype和jQuery??刂婆_(tái)中敲$卻發(fā)現(xiàn)是一個(gè)函數(shù)。又試著敲$$,也是個(gè)function2012-02-02
詳解JavaScript原型對(duì)象的this指向問(wèn)題
這篇文章主要為大家介紹了JavaScript原型對(duì)象的this指向問(wèn)題,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下,希望能夠給你帶來(lái)幫助2021-11-11
詳解JS WebSocket斷開(kāi)原因和心跳機(jī)制
這篇文章主要介紹了JS WebSocket斷開(kāi)原因和心跳機(jī)制,對(duì)websocket感興趣的同學(xué),可以參考下2021-05-05
基于javascript實(shí)現(xiàn)九九乘法表
這篇文章主要為大家詳細(xì)介紹了基于javascript實(shí)現(xiàn)九九乘法表的相關(guān)資料,感興趣的小伙伴們可以參考一下2016-03-03
利用js制作html table分頁(yè)示例(js實(shí)現(xiàn)分頁(yè))
這篇文章主要介紹了利用js制作html table的分頁(yè)示例(js實(shí)現(xiàn)分頁(yè)),需要的朋友可以參考下2014-04-04
JS使用for循環(huán)遍歷Table的所有單元格內(nèi)容
JS遍歷Table的所有單元格內(nèi)容思路是遍歷Table的所有Row,遍歷Row中的每一列,獲取Table中單元格的內(nèi)容2014-08-08
js+html5通過(guò)canvas指定開(kāi)始和結(jié)束點(diǎn)繪制線條的方法
這篇文章主要介紹了js+html5通過(guò)canvas指定開(kāi)始和結(jié)束點(diǎn)繪制線條的方法,涉及html5屬性的操作技巧,需要的朋友可以參考下2015-06-06

