Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/wikinson extended: Add wilkinson extended method to generate tick for linear scale #198

Merged
merged 10 commits into from
Aug 15, 2024
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"
}
25 changes: 25 additions & 0 deletions packages/vscale/__tests__/linear.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { wilkinsonExtended } from '../src';
import { LinearScale } from '../src/linear-scale';

function roundEpsilon(x: number) {
Expand Down Expand Up @@ -823,3 +824,27 @@ test('linear.ticks(count) will filter ticks when niceMax is true', function () {
expect(newTicks).toEqual([0, 10, 20, 30, 40, 50]);
expect(s.domain()).toEqual([originDomain[0], newTicks[newTicks.length - 1]]);
});

test('linear.customTicks with wilkinson', async () => {
const s = new LinearScale().domain([0, 100]).range([500, 1000]);
expect(
s.ticks(5, {
customTicks: (scale, count) => {
const d = scale.calculateVisibleDomain(scale.get('_range'));
return wilkinsonExtended(d[0], d[1], count);
}
})
).toStrictEqual([0, 25, 50, 75, 100]);
});

test('linear.customTicks with wilkinson in interval option', async () => {
const s = new LinearScale().domain([0, 100]).range([500, 1000]);
expect(
s.ticks(10, {
customTicks: (scale, count) => {
const d = scale.calculateVisibleDomain(scale.get('_range'));
return wilkinsonExtended(d[0], d[1], count);
}
})
).toStrictEqual([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]);
});
64 changes: 64 additions & 0 deletions packages/vscale/__tests__/wilkinson-extended.test.ts
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]);
});
});
5 changes: 5 additions & 0 deletions packages/vscale/src/base-scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,9 @@ export abstract class BaseScale implements IRangeFactor {
this._unknown = _;
return this;
}

/** 内部变量对外抛出方法 */
get(key: string, defaultValue?: any) {
return (this as any)?.[key] ?? defaultValue;
}
}
23 changes: 22 additions & 1 deletion packages/vscale/src/interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BaseScale } from './base-scale';
import type { ScaleEnum } from './type';

export type Tuple<TItem, TLength extends number> = [TItem, ...TItem[]] & { length: TLength };
Expand Down Expand Up @@ -165,9 +166,29 @@ export type ContinuousTicksFunc = (
start: number,
stop: number,
count: number,
options?: { noDecimals?: boolean }
options?: {
noDecimals?: boolean;
}
) => number[];

/** wilkinson 生成ticks */
export type WilkinsonExtendedTicksFunc = (
start: number,
stop: number,
count?: number,
options?: {
/** 是否允许扩展min、max,不绝对强制,例如[3, 97] */
onlyLoose?: boolean;
/** nice numbers集合 */
Q?: number[];
/** 四个优化组件的权重 */
w?: [number, number, number, number];
}
) => number[];

/** 自定义ticks方法 */
export type CustomTicksFunc<T extends BaseScale> = (scale: T, count: number) => number[];

export interface NiceOptions {
forceMin?: number;
forceMax?: number;
Expand Down
12 changes: 9 additions & 3 deletions packages/vscale/src/linear-scale.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ScaleEnum } from './type';
import { d3Ticks, forceTicks, niceLinear, parseNiceOptions, stepTicks, ticks } from './utils/tick-sample';
import { ContinuousScale } from './continuous-scale';
import type { ContinuousScaleType, NiceOptions } from './interface';
import { isValid } from '@visactor/vutils';
import type { ContinuousScaleType, CustomTicksFunc, NiceOptions } from './interface';
import { isFunction, isValid } from '@visactor/vutils';

/**
* TODO:
Expand Down Expand Up @@ -37,7 +37,13 @@ export class LinearScale extends ContinuousScale {
* the kind of algorithms will generate ticks that is smaller than the min or greater than the max
* if we don't update niceDomain, the ticks will exceed the domain
*/
ticks(count: number = 10, options?: { noDecimals?: boolean }) {
ticks(
count: number = 10,
options?: { noDecimals?: boolean; customTicks?: CustomTicksFunc<ContinuousScale> }
): number[] {
if (isFunction(options?.customTicks)) {
return options.customTicks(this, count);
}
if (
(isValid(this._rangeFactorStart) &&
isValid(this._rangeFactorEnd) &&
Expand Down
1 change: 1 addition & 0 deletions packages/vscale/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { scaleWholeRangeSize } from './utils';
export { wilkinsonExtended } from './tick-wilkinson-extended';
181 changes: 181 additions & 0 deletions packages/vscale/src/utils/tick-wilkinson-extended.ts
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;
};
Loading