Skip to content

Commit

Permalink
Merge pull request #198 from 666haiwen/feat/wikinson_extended
Browse files Browse the repository at this point in the history
Feat/wikinson extended: Add wilkinson extended method to generate tick for linear scale
  • Loading branch information
xile611 authored Aug 15, 2024
2 parents 67ef8e5 + 4a66a61 commit bcd8113
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 4 deletions.
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;
};

0 comments on commit bcd8113

Please sign in to comment.