From be53cca456f49c6f8c4919a92bf3bc4257cab870 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 12 Oct 2023 17:28:56 +0800 Subject: [PATCH 1/2] feat: add fish eye effect of scale --- packages/vscale/__tests__/fish-eye.test.ts | 41 +++++++++++++++++ packages/vscale/src/band-scale.ts | 18 +++++++- packages/vscale/src/base-scale.ts | 51 ++++++++++++++++++++-- packages/vscale/src/continuous-scale.ts | 21 ++++++++- packages/vscale/src/interface.ts | 11 ++++- packages/vscale/src/ordinal-scale.ts | 4 +- packages/vscale/src/type.ts | 15 +++++++ 7 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 packages/vscale/__tests__/fish-eye.test.ts diff --git a/packages/vscale/__tests__/fish-eye.test.ts b/packages/vscale/__tests__/fish-eye.test.ts new file mode 100644 index 0000000..10dc39d --- /dev/null +++ b/packages/vscale/__tests__/fish-eye.test.ts @@ -0,0 +1,41 @@ +import { LinearScale } from '../src/linear-scale'; +import { BandScale } from '../src/band-scale'; + +test('fishEye of band scale', function () { + const s = new BandScale(true).domain(['A', 'B', 'C', 'D', 'E']).range([0, 100]); + + expect(s.scale('A')).toBeCloseTo(0); + expect(s.scale('B')).toBeCloseTo(20); + expect(s.scale('C')).toBeCloseTo(40); + expect(s.scale('D')).toBeCloseTo(60); + expect(s.scale('E')).toBeCloseTo(80); + + s.fishEye({ focus: 50, radius: 20 }); + expect(s.scale('A')).toBeCloseTo(0); + expect(s.scale('B')).toBeCloseTo(20); + expect(s.scale('C')).toBeCloseTo(36.53412132054993); + expect(s.scale('D')).toBeCloseTo(63.46587867945007); + expect(s.scale('E')).toBeCloseTo(80); +}); + +test('fishEye of linear scale', function () { + const s = new LinearScale().domain([20, 60]).range([0, 100]); + + expect(s.scale(20)).toBeCloseTo(0); + expect(s.scale(35)).toBeCloseTo(37.5); + expect(s.scale(40)).toBeCloseTo(50); + expect(s.scale(45)).toBeCloseTo(62.5); + expect(s.scale(60)).toBeCloseTo(100); + + s.fishEye({ focus: 50, radius: 20 }); + + expect(s.scale(20)).toBeCloseTo(0); + expect(s.scale(35)).toBeCloseTo(34.4974531432733); + expect(s.scale(40)).toBeCloseTo(50); + expect(s.scale(45)).toBeCloseTo(65.5025468567267); + expect(s.scale(60)).toBeCloseTo(100); + + s.fishEye(null, false, true); + expect(s.scale(35)).toBeCloseTo(37.5); + expect(s.scale(45)).toBeCloseTo(62.5); +}); diff --git a/packages/vscale/src/band-scale.ts b/packages/vscale/src/band-scale.ts index 20ddea8..96f7199 100644 --- a/packages/vscale/src/band-scale.ts +++ b/packages/vscale/src/band-scale.ts @@ -3,7 +3,7 @@ import { OrdinalScale } from './ordinal-scale'; import { bandSpace, calculateBandwidthFromWholeRangeSize, scaleWholeRangeSize } from './utils/utils'; import { ScaleEnum } from './type'; import { stepTicks, ticks } from './utils/tick-sample-int'; -import type { DiscreteScaleType, IBandLikeScale, TickData } from './interface'; +import type { DiscreteScaleType, IBandLikeScale, ScaleFishEyeOptions, TickData } from './interface'; // band scale 各参数参考图示 https://raw.githubusercontent.com/d3/d3-scale/master/img/band.png export class BandScale extends OrdinalScale implements IBandLikeScale { @@ -68,6 +68,9 @@ export class BandScale extends OrdinalScale implements IBandLikeScale { return start + this._step * i; }); super.range(reverse ? values.reverse() : values); + + this.generateFishEyeTransform(); + return this; } @@ -374,6 +377,19 @@ export class BandScale extends OrdinalScale implements IBandLikeScale { return this.rescale(slience); } + fishEye(): ScaleFishEyeOptions; + fishEye(options: ScaleFishEyeOptions, slience?: boolean, clear?: boolean): this; + fishEye(options?: ScaleFishEyeOptions, slience?: boolean, clear?: boolean): this | ScaleFishEyeOptions { + if (options || clear) { + this._fishEyeOptions = options; + this._fishEyeTransform = null; + + return this.rescale(slience); + } + + return this._fishEyeOptions; + } + isBandwidthFixed() { return this._isFixed && !!this._bandwidth; } diff --git a/packages/vscale/src/base-scale.ts b/packages/vscale/src/base-scale.ts index dd6e947..b193e6a 100644 --- a/packages/vscale/src/base-scale.ts +++ b/packages/vscale/src/base-scale.ts @@ -1,9 +1,12 @@ -import type { IRangeFactor } from './interface'; +import { clamp, isNil } from '@visactor/vutils'; +import type { IRangeFactor, ScaleFishEyeOptions } from './interface'; export abstract class BaseScale implements IRangeFactor { protected _wholeRange: any[]; protected _rangeFactor?: number[]; protected _unknown: any; + protected _fishEyeOptions?: ScaleFishEyeOptions; + protected _fishEyeTransform?: (output: number) => number; abstract range(): any[]; abstract domain(): any[]; @@ -29,19 +32,59 @@ export abstract class BaseScale implements IRangeFactor { abstract calculateVisibleDomain(range: any[]): any[]; rangeFactor(): [number, number]; - rangeFactor(_: [number, number], slience?: boolean): this; - rangeFactor(_?: [number, number], slience?: boolean): this | any[] { + rangeFactor(_: [number, number], slience?: boolean, clear?: boolean): this; + rangeFactor(_?: [number, number], slience?: boolean, clear?: boolean): this | any[] { if (!_) { + if (clear) { + this._wholeRange = null; + this._rangeFactor = null; + return this; + } + return this._rangeFactor; } if (_.length === 2 && _.every(r => r >= 0 && r <= 1)) { this._wholeRange = null; - this._rangeFactor = _; + this._rangeFactor = _[0] === 0 && _[1] === 1 ? null : _; } return this; } + protected generateFishEyeTransform() { + if (!this._fishEyeOptions) { + this._fishEyeTransform = null; + + return; + } + const { distortion = 2, radiusRatio = 0.1, radius } = this._fishEyeOptions; + const range = this.range(); + const first = range[0]; + const last = range[range.length - 1]; + const min = Math.min(first, last); + const max = Math.max(first, last); + const focus = clamp(this._fishEyeOptions.focus ?? 0, min, max); + const rangeRadius = isNil(radius) ? (max - min) * radiusRatio : radius; + let k0 = Math.exp(distortion); + k0 = (k0 / (k0 - 1)) * rangeRadius; + const k1 = distortion / rangeRadius; + + this._fishEyeTransform = (output: number) => { + const delta = Math.abs(output - focus); + + if (delta >= rangeRadius) { + return output; + } + + if (delta <= 1e-6) { + return focus; + } + const k = ((k0 * (1 - Math.exp(-delta * k1))) / delta) * 0.75 + 0.25; + + return focus + (output - focus) * k; + }; + } + unknown(): any[]; unknown(_: any): this; unknown(_?: any): this | any { diff --git a/packages/vscale/src/continuous-scale.ts b/packages/vscale/src/continuous-scale.ts index aa5153d..4d6547d 100644 --- a/packages/vscale/src/continuous-scale.ts +++ b/packages/vscale/src/continuous-scale.ts @@ -7,7 +7,8 @@ import type { IContinuousScale, ContinuousScaleType, TickData, - NiceType + NiceType, + ScaleFishEyeOptions } from './interface'; import { interpolate } from './utils/interpolate'; import { bimap, identity, polymap } from './utils/utils'; @@ -57,6 +58,19 @@ export class ContinuousScale extends BaseScale implements IContinuousScale { return this._niceDomain ?? this._domain; } + fishEye(): ScaleFishEyeOptions; + fishEye(options: ScaleFishEyeOptions, slience?: boolean, clear?: boolean): this; + fishEye(options?: ScaleFishEyeOptions, slience?: boolean, clear?: boolean): this | ScaleFishEyeOptions { + if (options || clear) { + this._fishEyeOptions = options; + this._fishEyeTransform = null; + + return this.rescale(slience); + } + + return this._fishEyeOptions; + } + scale(x: any): any { x = Number(x); if (Number.isNaN(x) || (this._domainValidator && !this._domainValidator(x))) { @@ -69,8 +83,9 @@ export class ContinuousScale extends BaseScale implements IContinuousScale { this._interpolate ); } + const output = this._output(this.transformer(this._clamp(x))); - return this._output(this.transformer(this._clamp(x))); + return this._fishEyeTransform ? this._fishEyeTransform(output) : output; } invert(y: any): any { @@ -145,6 +160,8 @@ export class ContinuousScale extends BaseScale implements IContinuousScale { this._piecewise = n > 2 ? polymap : bimap; this._output = this._input = null; this._wholeRange = null; + + this.generateFishEyeTransform(); return this; } diff --git a/packages/vscale/src/interface.ts b/packages/vscale/src/interface.ts index 1a8cb75..f6b167a 100644 --- a/packages/vscale/src/interface.ts +++ b/packages/vscale/src/interface.ts @@ -44,7 +44,7 @@ export type ScaleType = DiscreteScaleType | ContinuousScaleType | DiscretizingSc export interface IRangeFactor { calculateVisibleDomain: (range: any[]) => any; - rangeFactor: (_?: [number, number], slience?: boolean) => this | any; + rangeFactor: (_?: [number, number], slience?: boolean, clear?: boolean) => this | any; unknown: (_?: any) => this | any; } @@ -101,6 +101,7 @@ export interface IBandLikeScale extends IOrdinalScale, IRangeFactor { round: (_?: boolean, slience?: boolean) => this | boolean; align: (_?: number, slience?: boolean) => this | number; clone: () => IBandLikeScale; + fishEye: (options?: ScaleFishEyeOptions, slience?: boolean, clear?: boolean) => this | ScaleFishEyeOptions; } export interface IContinuousScale extends IBaseScale, IRangeFactor { @@ -111,6 +112,7 @@ export interface IContinuousScale extends IBaseScale, IRangeFactor { interpolate: (_?: InterpolateType, slience?: boolean) => this | InterpolateType; clone?: () => IContinuousScale; rescale: () => this; + fishEye: (options?: ScaleFishEyeOptions, slience?: boolean, clear?: boolean) => this | ScaleFishEyeOptions; } export type ILinearScale = IContinuousScale & IContinuesScaleTicks; @@ -166,3 +168,10 @@ export interface NiceOptions { } export type NiceType = 'all' | 'min' | 'max'; + +export interface ScaleFishEyeOptions { + distortion?: number; + focus?: number; + radius?: number; + radiusRatio?: number; +} diff --git a/packages/vscale/src/ordinal-scale.ts b/packages/vscale/src/ordinal-scale.ts index 8017b6d..52a5493 100644 --- a/packages/vscale/src/ordinal-scale.ts +++ b/packages/vscale/src/ordinal-scale.ts @@ -67,7 +67,9 @@ export class OrdinalScale extends BaseScale implements IBaseScale { i = this._domain.push(d); this._index.set(key, i); } - return this._ordinalRange[(i - 1) % this._ordinalRange.length]; + const output = this._ordinalRange[(i - 1) % this._ordinalRange.length]; + + return this._fishEyeTransform ? this._fishEyeTransform(output) : output; } // d3-scale里没有对ordinal-scale添加invert能力,这里只做简单的映射 diff --git a/packages/vscale/src/type.ts b/packages/vscale/src/type.ts index 4e665da..0bd9b93 100644 --- a/packages/vscale/src/type.ts +++ b/packages/vscale/src/type.ts @@ -72,3 +72,18 @@ export function isDiscretizing(type: string) { return false; } } +export function supportRangeFactor(type: string) { + switch (type) { + case ScaleEnum.Linear: + case ScaleEnum.Log: + case ScaleEnum.Pow: + case ScaleEnum.Sqrt: + case ScaleEnum.Symlog: + case ScaleEnum.Time: + case ScaleEnum.Ordinal: + case ScaleEnum.Point: + return true; + default: + return false; + } +} From 326e950fb045a9aae54169a110dc7ab9d17e6b91 Mon Sep 17 00:00:00 2001 From: xile611 Date: Thu, 12 Oct 2023 17:29:45 +0800 Subject: [PATCH 2/2] docs: update changlog of rush --- .../vscale/feat-fish-eye_2023-10-12-09-29.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@visactor/vscale/feat-fish-eye_2023-10-12-09-29.json diff --git a/common/changes/@visactor/vscale/feat-fish-eye_2023-10-12-09-29.json b/common/changes/@visactor/vscale/feat-fish-eye_2023-10-12-09-29.json new file mode 100644 index 0000000..93a7fc4 --- /dev/null +++ b/common/changes/@visactor/vscale/feat-fish-eye_2023-10-12-09-29.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "feat: add fish eye effect of scale\n\n", + "type": "none", + "packageName": "@visactor/vscale" + } + ], + "packageName": "@visactor/vscale", + "email": "dingling112@gmail.com" +} \ No newline at end of file