-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #198 from 666haiwen/feat/wikinson_extended
Feat/wikinson extended: Add wilkinson extended method to generate tick for linear scale
- Loading branch information
Showing
8 changed files
with
317 additions
and
4 deletions.
There are no files selected for viewing
10 changes: 10 additions & 0 deletions
10
common/changes/@visactor/vscale/feat-wikinson_extended_2024-08-12-11-48.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"changes": [ | ||
{ | ||
"packageName": "@visactor/vscale", | ||
"comment": "feat: add customTicksFunc in linearScale and support a new ticks function called wilkinson extended", | ||
"type": "none" | ||
} | ||
], | ||
"packageName": "@visactor/vscale" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { wilkinsonExtended } from '../src/utils/tick-wilkinson-extended'; | ||
|
||
describe('wilkinson-extended test', () => { | ||
test('invalid data for dMin and dMax', () => { | ||
const res1 = wilkinsonExtended(NaN, NaN); | ||
expect(res1).toStrictEqual([]); | ||
|
||
// @ts-ignore | ||
const res2 = wilkinsonExtended('666666', '66666'); | ||
expect(res2).toStrictEqual([]); | ||
}); | ||
|
||
test('domain delta is smaller than 1e-15', () => { | ||
const res1 = wilkinsonExtended(0, 1e-16); | ||
expect(res1).toStrictEqual([0]); | ||
}); | ||
|
||
test('m is to equal to ', () => { | ||
const res1 = wilkinsonExtended(0, 100, 1); | ||
expect(res1).toStrictEqual([0]); | ||
}); | ||
|
||
test('common usage', () => { | ||
const res1 = wilkinsonExtended(0, 100, 10); | ||
expect(res1).toStrictEqual([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]); | ||
}); | ||
|
||
test('tiny number', () => { | ||
expect(wilkinsonExtended(0, 0.1, 5)).toStrictEqual([0, 0.025, 0.05, 0.075, 0.1]); | ||
expect(wilkinsonExtended(0, 0.01, 5)).toStrictEqual([0, 0.0025, 0.005, 0.0075, 0.01]); | ||
expect(wilkinsonExtended(0, 0.001, 5)).toStrictEqual([0, 0.00025, 0.0005, 0.00075, 0.001]); | ||
expect(wilkinsonExtended(0, 0.0001, 6)).toStrictEqual([0, 0.00002, 0.00004, 0.00006, 0.00008, 0.0001, 0.00012]); | ||
expect(wilkinsonExtended(0, 0.00001, 6)).toStrictEqual([ | ||
0, 0.000002, 0.000004, 0.000006, 0.000008, 0.00001, 0.000012 | ||
]); | ||
expect(wilkinsonExtended(0, 0.000001, 6)).toStrictEqual([0, 0.0000002, 0.0000004, 0.0000006, 0.0000008, 0.000001]); | ||
expect(wilkinsonExtended(0, 1e-15, 6)).toStrictEqual([0, 2e-16, 4e-16, 6e-16, 8e-16, 1e-15]); | ||
}); | ||
|
||
test('precision', () => { | ||
expect(wilkinsonExtended(0, 1.2, 5)).toStrictEqual([0, 0.3, 0.6, 0.9, 1.2]); | ||
}); | ||
|
||
test('handle decimal tickCount', () => { | ||
expect(wilkinsonExtended(0, 5, 0.4)).toStrictEqual(wilkinsonExtended(0, 5, 0)); | ||
expect(wilkinsonExtended(0, 5, 0.5)).toStrictEqual(wilkinsonExtended(0, 5, 1)); | ||
}); | ||
|
||
test('handle negative tickCount', () => { | ||
expect(wilkinsonExtended(0, 5, -1)).toStrictEqual(wilkinsonExtended(0, 5, 0)); | ||
expect(wilkinsonExtended(0, 5, -1.2)).toStrictEqual(wilkinsonExtended(0, 5, 0)); | ||
}); | ||
|
||
test('handle negative tickValue', () => { | ||
expect(wilkinsonExtended(-0.4, 0)).toStrictEqual([-0.4, -0.3, -0.2, -0.1, 0]); | ||
}); | ||
|
||
test('handle special numbers', () => { | ||
expect(wilkinsonExtended(0.94, 1, 5)).toEqual([0.93, 0.94, 0.95, 0.96, 0.97, 0.98, 0.99, 1]); | ||
expect(wilkinsonExtended(-1.11660058, 3.16329506, 5)).toEqual([-1.2, 0, 1.2, 2.4, 3.6]); | ||
expect(wilkinsonExtended(-3.01805882, 1.407252466, 5)).toEqual([-3.2, -2.4, -1.6, -0.8, 0, 0.8, 1.6]); | ||
expect(wilkinsonExtended(-1.02835066, 3.25839303, 5)).toEqual([-1.2, 0, 1.2, 2.4, 3.6]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export { scaleWholeRangeSize } from './utils'; | ||
export { wilkinsonExtended } from './tick-wilkinson-extended'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
import type { WilkinsonExtendedTicksFunc } from '../interface'; | ||
|
||
export const DEFAULT_Q = [1, 5, 2, 2.5, 4, 3]; | ||
|
||
const DEFAULT_W = [0.25, 0.2, 0.5, 0.05]; | ||
|
||
export const ALL_Q = [1, 5, 2, 2.5, 4, 3, 1.5, 7, 6, 8, 9]; | ||
|
||
const eps = Number.EPSILON * 100; | ||
|
||
// 为了解决 js 运算的精度问题 | ||
function prettyNumber(n: number) { | ||
return Math.abs(n) < 1e-14 ? n : parseFloat(n.toFixed(14)); | ||
} | ||
|
||
function mod(n: number, m: number) { | ||
return ((n % m) + m) % m; | ||
} | ||
|
||
function round(n: number) { | ||
return Math.round(n * 1e12) / 1e12; | ||
} | ||
|
||
function simplicity(q: number, Q: number[], j: number, lmin: number, lmax: number, lstep: number) { | ||
const n = Q.length; | ||
const i = Q.indexOf(q); | ||
let v = 0; | ||
const m = mod(lmin, lstep); | ||
if ((m < eps || lstep - m < eps) && lmin <= 0 && lmax >= 0) { | ||
v = 1; | ||
} | ||
return 1 - i / (n - 1) - j + v; | ||
} | ||
|
||
function simplicityMax(q: number, Q: number[], j: number) { | ||
const n = Q.length; | ||
const i = Q.indexOf(q); | ||
const v = 1; | ||
return 1 - i / (n - 1) - j + v; | ||
} | ||
|
||
function density(k: number, m: number, dMin: number, dMax: number, lMin: number, lMax: number) { | ||
const r = (k - 1) / (lMax - lMin); | ||
const rt = (m - 1) / (Math.max(lMax, dMax) - Math.min(dMin, lMin)); | ||
return 2 - Math.max(r / rt, rt / r); | ||
} | ||
|
||
function densityMax(k: number, m: number) { | ||
if (k >= m) { | ||
return 2 - (k - 1) / (m - 1); | ||
} | ||
return 1; | ||
} | ||
|
||
function coverage(dMin: number, dMax: number, lMin: number, lMax: number) { | ||
const range = dMax - dMin; | ||
return 1 - (0.5 * ((dMax - lMax) ** 2 + (dMin - lMin) ** 2)) / (0.1 * range) ** 2; | ||
} | ||
|
||
function coverageMax(dMin: number, dMax: number, span: number) { | ||
const range = dMax - dMin; | ||
if (span > range) { | ||
const half = (span - range) / 2; | ||
return 1 - half ** 2 / (0.1 * range) ** 2; | ||
} | ||
return 1; | ||
} | ||
|
||
function legibility() { | ||
return 1; | ||
} | ||
|
||
/** | ||
* An Extension of Wilkinson's Algorithm for Position Tick Labels on Axes | ||
* https://www.yuque.com/preview/yuque/0/2019/pdf/185317/1546999150858-45c3b9c2-4e86-4223-bf1a-8a732e8195ed.pdf | ||
* @param dMin 最小值 | ||
* @param dMax 最大值 | ||
* @param n tick个数 | ||
* @param onlyLoose 是否允许扩展min、max,不绝对强制,例如[3, 97] | ||
* @param Q nice numbers集合 | ||
* @param w 四个优化组件的权重 | ||
*/ | ||
export const wilkinsonExtended: WilkinsonExtendedTicksFunc = (dMin: number, dMax: number, n: number = 5, options) => { | ||
const { onlyLoose = true, Q = DEFAULT_Q, w = DEFAULT_W } = options || {}; | ||
const m = n < 0 ? 0 : Math.round(n); | ||
// nan 也会导致异常 | ||
if (Number.isNaN(dMin) || Number.isNaN(dMax) || typeof dMin !== 'number' || typeof dMax !== 'number' || !m) { | ||
return []; | ||
} | ||
|
||
// js 极大值极小值问题,差值小于 1e-15 会导致计算出错 | ||
if (dMax - dMin < 1e-15 || m === 1) { | ||
return [dMin]; | ||
} | ||
|
||
const best = { | ||
score: -2, | ||
lmin: 0, | ||
lmax: 0, | ||
lstep: 0 | ||
}; | ||
|
||
let j = 1; | ||
while (j < Infinity) { | ||
// for (const q of Q) | ||
for (let i = 0; i < Q.length; i += 1) { | ||
const q = Q[i]; | ||
const sm = simplicityMax(q, Q, j); | ||
if (w[0] * sm + w[1] + w[2] + w[3] < best.score) { | ||
j = Infinity; | ||
break; | ||
} | ||
let k = 2; | ||
while (k < Infinity) { | ||
const dm = densityMax(k, m); | ||
if (w[0] * sm + w[1] + w[2] * dm + w[3] < best.score) { | ||
break; | ||
} | ||
|
||
const delta = (dMax - dMin) / (k + 1) / j / q; | ||
let z = Math.ceil(Math.log10(delta)); | ||
|
||
while (z < Infinity) { | ||
const step = j * q * 10 ** z; | ||
const cm = coverageMax(dMin, dMax, step * (k - 1)); | ||
|
||
if (w[0] * sm + w[1] * cm + w[2] * dm + w[3] < best.score) { | ||
break; | ||
} | ||
|
||
const minStart = Math.floor(dMax / step) * j - (k - 1) * j; | ||
const maxStart = Math.ceil(dMin / step) * j; | ||
|
||
if (minStart <= maxStart) { | ||
const count = maxStart - minStart; | ||
for (let i = 0; i <= count; i += 1) { | ||
const start = minStart + i; | ||
const lMin = start * (step / j); | ||
const lMax = lMin + step * (k - 1); | ||
const lStep = step; | ||
|
||
const s = simplicity(q, Q, j, lMin, lMax, lStep); | ||
const c = coverage(dMin, dMax, lMin, lMax); | ||
const g = density(k, m, dMin, dMax, lMin, lMax); | ||
const l = legibility(); | ||
|
||
const score = w[0] * s + w[1] * c + w[2] * g + w[3] * l; | ||
if (score > best.score && (!onlyLoose || (lMin <= dMin && lMax >= dMax))) { | ||
best.lmin = lMin; | ||
best.lmax = lMax; | ||
best.lstep = lStep; | ||
best.score = score; | ||
} | ||
} | ||
} | ||
z += 1; | ||
} | ||
k += 1; | ||
} | ||
} | ||
j += 1; | ||
} | ||
|
||
// 处理精度问题,保证这三个数没有精度问题 | ||
const lmax = prettyNumber(best.lmax); | ||
const lmin = prettyNumber(best.lmin); | ||
const lstep = prettyNumber(best.lstep); | ||
|
||
// 加 round 是为处理 extended(0.94, 1, 5) | ||
// 保证生成的 tickCount 没有精度问题 | ||
const tickCount = Math.floor(round((lmax - lmin) / lstep)) + 1; | ||
const ticks = new Array(tickCount); | ||
|
||
// 少用乘法:防止出现 -1.2 + 1.2 * 3 = 2.3999999999999995 的情况 | ||
ticks[0] = prettyNumber(lmin); | ||
for (let i = 1; i < tickCount; i += 1) { | ||
ticks[i] = prettyNumber(ticks[i - 1] + lstep); | ||
} | ||
|
||
return ticks; | ||
}; |