Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions __tests__/unit/scales/linear-breaks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Linear } from '../../../src';

describe('Linear Scale with Breaks', () => {
test('single break: values before, inside, and after break', () => {
const scale = new Linear({
domain: [0, 200],
breaks: [{ start: 40, end: 100, gap: 0.1 }],
});

const { domain, range, round, tickCount, nice, clamp, unknown, tickMethod } = scale.getOptions() as Record<
string,
any
>;
expect(domain).toStrictEqual([0, 40, 100, 150, 200]);
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.25, 0]);
expect(round).toBeFalsy();
expect(tickCount).toStrictEqual(5);
expect(nice).toBeFalsy();
expect(clamp).toBeFalsy();
expect(unknown).toBeUndefined();
expect(tickMethod(0, 200, 5)).toEqual(domain);
});

test('multiple breaks should compress multiple regions', () => {
const scale = new Linear({
domain: [0, 200],
breaks: [
{ start: 40, end: 100, gap: 0.1 },
{ start: 120, end: 160, gap: 0.1 },
],
});

const { domain, range } = scale.getOptions();
expect(domain).toStrictEqual([0, 40, 100, 120, 160, 200]);
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.35, 0.25, 0]);
});

test('multiple breaks should compress multiple regions with out of order', () => {
const scale = new Linear({
domain: [0, 200],
breaks: [
{ start: 120, end: 160, gap: 0.1 },
{ start: 40, end: 100, gap: 0.1 },
],
});

const { domain, range } = scale.getOptions();
expect(domain).toStrictEqual([0, 40, 100, 120, 160, 200]);
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.35, 0.25, 0]);
});

test('multiple breaks should compress multiple regions with reverse', () => {
const scale = new Linear({
domain: [0, 980],
range: [0, 1],
breaks: [
{ start: 300, end: 500, gap: 0.1 },
{ start: 600, end: 800, gap: 0.05 },
],
});

const { domain, range } = scale.getOptions();
expect(domain).toStrictEqual([0, 200, 300, 500, 600, 800, 980]);
expect(range).toStrictEqual([
0, 0.20408163265306123, 0.35816326530612247, 0.45816326530612245, 0.6892857142857143, 0.7392857142857143, 1,
]);
});

test('single break: update', () => {
const scale = new Linear({
domain: [0, 200],
breaks: [{ start: 40, end: 100, gap: 0.1 }],
});

const { domain, range } = scale.getOptions();
expect(domain).toStrictEqual([0, 40, 100, 150, 200]);
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.25, 0]);
scale.update({
domain: [0, 200],
breaks: [],
});
const scaleOptions = scale.getOptions();
expect(scaleOptions.domain).toStrictEqual([0, 50, 100, 150, 200]);
expect(scaleOptions.range).toStrictEqual([1, 0.75, 0.5, 0.25, 0]);
scale.update({
domain: [0, 200],
breaks: undefined,
});
const scaleOptions2 = scale.getOptions();
expect(scaleOptions2.domain).toStrictEqual([0, 200]);
});

test('no breaks should behave like normal linear scale', () => {
const scale = new Linear({
domain: [0, 200],
});

const { domain, range } = scale.getOptions();
expect(domain).toStrictEqual([0, 200]);
expect(range).toStrictEqual([0, 1]);
});

test('linear scale with clone', () => {
const scale = new Linear({
domain: [0, 200],
});

const scale2 = scale.clone();
expect(scale2).toBeInstanceOf(Linear);
expect(scale2.getOptions()).toEqual(scale.getOptions());
});
});
14 changes: 11 additions & 3 deletions src/scales/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export abstract class Base<O extends BaseOptions> {
*/
protected abstract getDefaultOptions(): Partial<O>;

/**
* 将用户传入的选项和默认选项合并,生成当前比例尺的选项
*/
protected transformBreaks(options: O): O {
return options;
}

/**
* 比例尺的选项,用于配置数据映射的规则和 ticks 的生成方式
*/
Expand All @@ -36,7 +43,7 @@ export abstract class Base<O extends BaseOptions> {
*/
constructor(options?: O) {
this.options = deepMix({}, this.getDefaultOptions());
this.update(options);
this.update(options?.breaks?.length ? this.transformBreaks(options) : options);
}

/**
Expand All @@ -52,8 +59,9 @@ export abstract class Base<O extends BaseOptions> {
* @param updateOptions 需要更新的选项
*/
public update(updateOptions: Partial<O> = {}): void {
this.options = deepMix({}, this.options, updateOptions);
this.rescale(updateOptions);
const options = updateOptions.breaks ? this.transformBreaks(updateOptions as O) : updateOptions;
this.options = deepMix({}, this.options, options);
this.rescale(options);
}

/**
Expand Down
50 changes: 49 additions & 1 deletion src/scales/linear.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { identity } from '@antv/util';
import { identity, isArray, last } from '@antv/util';
import { Continuous } from './continuous';
import { LinearOptions, Transform } from '../types';
import { Base } from './base';
Expand All @@ -25,6 +25,54 @@ export class Linear extends Continuous<LinearOptions> {
};
}

protected transformDomain(options: LinearOptions): { breaksDomain: number[]; breaksRange: number[] } {
const { domain, range = [1, 0], breaks = [], tickCount = 5 } = options;
const [domainMin, domainMax] = [Math.min(...domain), Math.max(...domain)];
const sortedBreaks = breaks.filter(({ end }) => end < domainMax).sort((a, b) => a.start - b.start);
const breaksDomain = d3Ticks(domainMin, domainMax, tickCount, sortedBreaks);
if (last(breaksDomain) < domainMax) {
breaksDomain.push(domainMax);
}
const [r0, r1] = [range[0], last(range)] as number[];
const diffDomain = domainMax - domainMin;
const diffRange = Math.abs(r1 - r0);
const reverse = r0 > r1;
// Calculate the new range based on breaks.
const breaksRange = breaksDomain.map((d) => {
const ratio = (d - domainMin) / diffDomain;
return reverse ? r0 - ratio * diffRange : r0 + ratio * diffRange;
});
// Compress the range scale according to breaks.
sortedBreaks.forEach(({ start, end, gap = 0.05 }) => {
const startIndex = breaksDomain.indexOf(start);
const endIndex = breaksDomain.indexOf(end);
const center = (breaksRange[startIndex] + breaksRange[endIndex]) / 2;
const scaledSpan = gap * diffRange;
// Calculate the new start and end values based on the center and scaled span.
const startValue = reverse ? center + scaledSpan / 2 : center - scaledSpan / 2;
const endValue = reverse ? center - scaledSpan / 2 : center + scaledSpan / 2;
breaksRange[startIndex] = startValue;
breaksRange[endIndex] = endValue;
});
return { breaksDomain, breaksRange };
}

protected transformBreaks(options: LinearOptions): LinearOptions {
const { domain, breaks = [] } = options;
if (!isArray(options.breaks)) return options;
const domainMax = Math.max(...domain);
const filteredBreaks = breaks.filter(({ end }) => end < domainMax);
const optWithFilteredBreaks = { ...options, breaks: filteredBreaks };
const { breaksDomain, breaksRange } = this.transformDomain(optWithFilteredBreaks);
return {
...options,
domain: breaksDomain,
range: breaksRange,
breaks: filteredBreaks,
tickMethod: () => [...breaksDomain],
};
}

protected chooseTransforms(): Transform[] {
return [identity, identity];
}
Expand Down
19 changes: 17 additions & 2 deletions src/tick-methods/d3-ticks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { TickMethod } from '../types';
import { tickIncrement } from '../utils';
import type { BreakOptions } from '../types';

export const d3Ticks: TickMethod = (begin: number, end: number, count: number) => {
/**
* Insert breaks into ticks and delete the ticks covered by breaks.
*/
export const insertBreaksToTicks = (ticks: number[], breaks?: BreakOptions[]): number[] => {
if (!breaks?.length) return ticks;
const edgePoints = [...ticks, ...breaks.flatMap((b) => [b.start, b.end])];
const uniqueSortedTicks = Array.from(new Set(edgePoints)).sort((a, b) => a - b);
const filteredTicks = uniqueSortedTicks.filter(
(tick) => !breaks.some(({ start, end }) => tick > start && tick < end)
);
return filteredTicks.length ? filteredTicks : ticks;
};

export const d3Ticks: TickMethod = (begin: number, end: number, count: number, breaks?: BreakOptions[]) => {
let n;
let ticks;

Expand Down Expand Up @@ -34,5 +48,6 @@ export const d3Ticks: TickMethod = (begin: number, end: number, count: number) =
ticks[i] = (start + i) / step;
}
}
return ticks;

return insertBreaksToTicks(ticks, breaks);
};
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export type Transform = (x: any) => any;
/** 柯里化后的函数的工厂函数类型 */
export type CreateTransform = (...args: any[]) => Transform;

export interface BreakOptions {
start: number; // 断轴开始
end: number; // 断轴结束
gap: number; // 在可视 range 中保留的间隔长度(0 ~ 1),默认为 0.05,表示 5% 的间隔
}

/** 通用的配置 */
export type BaseOptions = {
/** 当需要映射的值不合法的时候,返回的值 */
Expand All @@ -33,6 +39,7 @@ export type BaseOptions = {
range?: any[];
/** 定义域,默认为 [0, 1] */
domain?: any[];
breaks?: BreakOptions[];
};

/** 获得比例尺选项中定义域元素的类型 */
Expand Down Expand Up @@ -116,6 +123,8 @@ export type LinearOptions = {
round?: boolean;
/** 插值器的工厂函数,返回一个对归一化后的输入在值域指定范围内插值的函数 */
interpolate?: Interpolates;
/** 断轴选项 */
breaks?: BreakOptions[];
};

/** Pow 比例尺的选项 */
Expand Down