首頁 > 軟體

座標軸刻度取值演演算法之源於echarts的y軸刻度計算需求

2022-06-06 22:01:00

前言

因實習的公司是做巨量資料的,而我的工作剛好又是需要繪製一些資料圖表的。繪製圖表有許多現成的元件可以使用,但是要想達到產品所需要的效果,只靠元件內部的一些功能是不太夠的。一些細膩的要求必須在掌握元件原理方法的情況下,自己去寫演演算法來完成。例如,本文要說的這個刻度計算演演算法,開始正文之前,我先描述遇到的問題。

echarts自身的刻度計算有時候並不好用,例如有時候你希望讓圖表只有5條刻度線,即分成4段,echarts提供了一個引數叫splitNumber,把splitNumber設為4可以讓圖表儘量地分成4段,然而當資料波動較大時,echarts會自動增加分割的段數,即即使大部分資料都能正常分出4段刻度,但仍有少部分資料實際分出的段數可能不止4段。如下面這樣:

因此我們得出一個結論,echarts的splitNumber只是預估的分割段數。如果我們需要強制把刻度區間分為4段,則需要我們自己去寫演演算法計算刻度。另外,即使段數正確的情況下,echarts自動計算出的刻度也可能存在區間過大,資料差異不明顯的情況,如下面的圖片,因為刻度區間太大,導致各個資料看起來像是差不多大的,看不出差異:

另外,echarts自動計算出的刻度也有一些其他的問題,例如當圖表在柱狀圖和堆疊圖中切換時,堆疊圖可能出現刻度溢位問題。不過堆疊圖的刻度計算這裡就先不說明了,下面開始正文吧。

演演算法描述

刻度計算的演演算法之前我之前也寫了一版,解決了分割段數的問題,但是仍無法解決刻度區間過大的問題。之前那一版演演算法的主要思想是取近似值,分別取最大值和最小值的最小近似整值得到刻度,雖然不是最優的演演算法,但是在構思和調整演演算法的時候我也學到了不少東西,而這一版的演演算法是在我們技術老大的點撥下結合網上的一些文章和專案的需求而寫出來的,演演算法如下:

要求: 根據一組數的最大值、最小值來確定刻度值,確定刻度的最大值maxi、最小值mini和刻度間隔interval。當出現異號資料時可選擇正負兩邊刻度是否需要對稱,當存在異號資料時要求其中一條刻度分割線必須在0刻度上,可以選擇是否允許段數誤差。

  1. 確定分割段數splitNumber和魔數陣列magic = [10,15,20,25,30,40,50,60,70,80,90,100];
  2. 從目標陣列arr中算出最大值max和最小值min,確定初始間隔大小 tempGap = (max-min)/splitNumber;
  3. 設tempGap除以一個倍數multiple後,剛好處於魔數陣列的區間[10,100]中,記錄倍數multiple;
  4. 從魔數陣列中取出第一個大於tempGap縮放值的魔數,用 魔數*multiple當做理想刻度間隔(estep)進行第一次計算,計算出max和min的鄰近刻度maxi和mini,如果允許分割段數誤差,則直接結束運算,取interval=estep;
  5. 當刻度需要正負兩邊對稱且存在異號資料時,取maxi和mini中絕對值大的一方,將其相反數賦值給另外一方,計算interval=(maxi-mini)/splitNumber,結束運算;
  6. 當正負刻度不需要對稱或不存在異號資料時,判斷實際分割段數是否等於splitNumber,如果不相等,則重新取較大的魔數進行運算,當魔數取完或者分割段數相等時結束運算,得出interval=(maxi-mini)/splitNumber.

上程式碼

演演算法採用javascript語言描述,因為圖表的繪製在前端完成。

/*
	刻度計算演演算法,基於魔術陣列 [10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100];
	解釋:魔數陣列是理想間隔陣列,即我們希望每個刻度的間隔都是魔數陣列中某個數的整數倍。(準確的來說是整10倍)
*/
//新增,解決js的浮點數存在精度問題,在計算出最後結果時可以四捨五入一次,因為刻度太小也沒有意義,所以這裡忽略設定精度為8位元
function fixedNum(num){
	if((""+num).indexOf('.')>=0) num = parseFloat(num.toFixed(8));
	return num;
}
//1.初始化
var symmetrical = false;//是否要求正負刻度對稱。預設為false,需要時請設定為true
var deviation = false;//是否允許誤差,即實際分出的段數不等於splitNumber
var magic = [10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100];//魔數陣列經過擴充,放寬魔數限制避免出現取不到魔數的情況。
var arr = [1230, 320, 20, 304, 102, 234];//測試資料
var max,min,splitNumber;
splitNumber = 4;//理想的刻度間隔段數,即希望刻度區間有多少段
max = Math.max.apply(null,arr);//呼叫js已有函數計算出最大值
min = Math.min.apply(null,arr);//計算出最小值
//2.計算出初始間隔tempGap和縮放比例multiple
var tempGap = (max - min) / splitNumber;//初始刻度間隔的大小。
//設tempGap除以multiple後剛剛處於魔數區間內,先求multiple的冪10指數,例如當tempGap為120,想要把tempGap對映到魔數陣列(即處理為10到100之間的數),則倍數為10,即10的1次方。
var multiple = Math.floor(Math.log10(tempGap)-1);//這裡使用Math.floor的原因是,當Math.log10(tempGap)-1無論是正負數都需要向下取整。不能使用parseInt或其他取整邏輯代替。
multiple = Math.pow(10,multiple);//剛才是求出指數,這裡求出multiple的實際值。分開兩行程式碼避免有人看不懂
//3.取出鄰近較大的魔數執行第一次計算
var tempStep = tempGap / multiple;//對映後的間隔大小
var estep;//期望得到的間隔
var lastIndex = -1;//記錄上一次取到的魔數下標,避免出現死迴圈
for(var i = 0; i < magic.length;i++){
	if(magic[i]>tempStep){
		estep = magic[i]*multiple;//取出第一個大於tempStep的魔數,並乘以multiple作為期望得到的最佳間隔
		break;
	}
}
//4.求出期望的最大刻度和最小刻度,為estep的整數倍
var maxi,mini;
function countDegree(estep){
	//這裡的parseInt是我無意中寫出來的,本來我是想對maxi使用Math.floor,對mini使用Math.ceil的。這樣能向下取到鄰近的一格,不過後面發現用parseInt好像畫出來圖的比較好看
	maxi = parseInt(max/estep+1) * estep;//最終效果是當max/estep屬於(-1,Infinity)區間時,向上取1格,否則取2格。
	mini = parseInt(min/estep-1) * estep;//當min/estep屬於(-Infinity,1)區間時,向下取1格,否則取2格。
	//如果max和min剛好在刻度線的話,則按照上面的邏輯會向上或向下多取一格
	if(max===0) maxi = 0;//這裡進行了一次矯正,優先取到0刻度
	if(min===0) mini = 0;
	if(symmetrical&&maxi*mini<0){//如果需要正負刻度對稱且存在異號資料
		var tm = Math.max(Math.abs(maxi),Math.abs(mini));//取絕對值較大的一方
		maxi = tm;
		mini = -tm;
	}
}
countDegree(estep);
if(deviation){//如果允許誤差,即實際分段數可以不等於splitNumber,則直接結束
	var interval = fixedNum(estep);
	console.log(maxi,mini,interval);
	return;
}
//5.當正負刻度不對稱且0刻度不在刻度線上時,重新取魔數進行計算//確保其中一條分割線剛好在0刻度上。
else if(!symmetrical||maxi*mini>0){
	outter:do{
		//計算模擬的實際分段數
		var tempSplitNumber = Math.round((maxi-mini)/estep);
		//當趨勢單調性發生變化時可能出現死迴圈,需要進行校正
		if((i-lastIndex)*(tempSplitNumber-splitNumber)<0){//此處檢查單調性變化且未取到理想分段數
			//此處的校正基於合理的均勻的魔數陣列,即tempSplitNumber和splitNumber的差值較小如1和2,始終取大刻度
			while(tempSplitNumber<splitNumber){//讓maxi或mini增大或減少一個estep直到取到理想分段數
				if((mini-min)<=(maxi-max)&&mini!=0||maxi==0){//在儘量保留0刻度的前提下,讓更接近最值的一邊擴充套件一個刻度
					mini-=estep;
				}else{
					maxi+=estep;
				}
				tempSplitNumber++;
				if(tempSplitNumber==splitNumber)
					break outter;
			}
		}
		//當魔數下標越界或取到理想分段數時退出迴圈
		if(i>=magic.length-1|| i<=0 || tempSplitNumber==splitNumber) break;
		//記錄上一次的魔數下標
		lastIndex = i;
		//嘗試取符合趨勢的鄰近魔數
		if(tempSplitNumber>splitNumber) estep = magic[++i]*multiple;
		else estep = magic[--i]*multiple;
		//重新計算刻度
		countDegree(estep);
	}while(tempSplitNumber!=splitNumber);
}
//6.無論計算始終把maxi-mini分成splitNumber段,得到間隔interval。不過前面的演演算法已經儘量的保證刻度最優了,即interval接近或等於理想刻度estep。
maxi = fixedNum(maxi);
mini = fixedNum(mini);
var interval = fixedNum((maxi-mini)/splitNumber);
console.log(maxi,mini,interval);

程式碼執行效果

1.如果不處理小數誤差,且強制分為4段,出來的效果是這樣的(20190722版):

2.處理了小數誤差,並允許刻度誤差出來的效果是這樣的(20190723版)

可以看出:

  1. 採用基於魔數陣列的新演演算法計算出的刻度區間是緊挨著最大值和最小值的,算是差強人意。
  2. js中浮點數的精度問題是我們在設計一些通用性的演演算法時需要注意的,圖片右邊可以看到,當資料幅度較小時,算出的min和interval是存在誤差的。
  3. 圖片左邊的刻度的精確度處理是我寫的邏輯,當資料為非純小數時最多隻精確到3位小數,純小數時精確到8位元,避免出現刻度過長,echarts並沒有自帶這個功能。
    再附上一張存在異號資料時的效果和一張需要正負刻度對稱的效果

可以看出:

  • 正負刻度不對稱且其中一條分割線剛好在0刻度上
  • y軸刻度的40K其實是40000的縮寫,演演算法也是要自己寫的,echarts沒有提供這個功能。

ts版本(2021/3/10補充)

export interface ScaleOption {
    /**
     * 資料最大值
     *
     * @type {(number | null)}
     * @memberof ScaleOption
     */
    max: number | null;
    /**
     * 資料最小值
     *
     * @type {(number | null)}
     * @memberof ScaleOption
     */
    min: number | null;
    /**
     * 預期分成幾個區間
     *
     * @type {number}
     * @memberof ScaleOption
     */
    splitNumber?: number;
    /**
     * 存在異號資料時正負區間是否需要對稱
     *
     * @type {boolean}
     * @memberof ScaleOption
     */
    symmetrical?: boolean;
    /**
     * 是否允許實際分成的區間數有誤差
     *
     * @type {boolean}
     * @memberof ScaleOption
     */
    deviation?: boolean;
    /**
     * 是否優先取到0刻度
     *
     * @type {boolean}
     * @memberof ScaleOption
     */
    preferZero?: boolean;
}
export interface ScaleResult {
    max?: number;
    min?: number;
    interval?: number;
    splitNumber?: number;
}

// 雙精度浮點數有效數位為15位
const maxDecimal = 15;
/**
 * 解決js的浮點數存在精度問題,在計算出最後結果時可以四捨五入一次,刻度太小也沒有意義
 *
 * @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);
}

/**
 * 計算理想的刻度值,刻度區間大小一般是[10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100]中某個數位的整10倍
 *
 * @export
 * @param {ScaleOption} option
 * @returns {ScaleResult}
 */
export function scaleCompute(option: ScaleOption): ScaleResult {
    option = {
        max: null,
        min: null,
        splitNumber: 4, // splitNumber建議取4或者5等這種容易被整除的數位
        symmetrical: false,
        deviation: false,
        preferZero: true,
        ...option,
    };
    const magics: number[] = [10, 15, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 150]; // 加入150形成閉環
    // 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); // 指數
    multiple = Math.pow(10, multiple);
    const tempStep: number = tempGap / multiple;
    let expectedStep: number = magics[0] * multiple;
    let storedMagicsIndex: number = -1;
    let index: number; // 當前魔數下標
    for (index = 0; index < magics.length; index++) {
        if (magics[index] > tempStep) {
            expectedStep = magics[index] * multiple; // 取出第一個大於tempStep的魔數,並乘以multiple作為期望得到的最佳間隔
            break;
        }
    }
    let axisMax: number = dataMax;
    let axisMin: number = dataMin;
    function countDegree(step: number): void {
        axisMax = parseInt("" + (dataMax / step + 1)) * step; // parseInt令小數去尾 -1.8 -> -1
        axisMin = parseInt("" + (dataMin / step - 1)) * step;
        if (dataMax === 0) axisMax = 0; // 優先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) { // 出現死迴圈
                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,
    };
}

結語

好久沒有寫部落格,可能是最近比較忙,如果有空的話其實前端有很多東西都可以寫一下,今天寫這個部落格是因為文章篇幅較小,而且我剛好在檢查演演算法,另外這個演演算法比較有參考意義,就忙裡偷閒寫出來給大家看看,也方便我自己以後查閱。目前我參與的專案還在開發完善中,關於echarts我也踩了很多坑,針對專案需求編寫了一些東西,有機會再寫出來大家討論吧。

總結

到此這篇關於座標軸刻度取值演演算法之源於echarts的y軸刻度計算需求的文章就介紹到這了,更多相關座標軸刻度取值演演算法內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com