Skip to content

Commit 7e2be05

Browse files
committed
feat: add the broken function to the linear scale
1 parent 885eb1e commit 7e2be05

5 files changed

Lines changed: 198 additions & 6 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Linear } from '../../../src';
2+
3+
describe('Linear Scale with Breaks', () => {
4+
test('single break: values before, inside, and after break', () => {
5+
const scale = new Linear({
6+
domain: [0, 200],
7+
breaks: [{ start: 40, end: 100, gap: 0.1 }],
8+
});
9+
10+
const { domain, range, round, tickCount, nice, clamp, unknown, tickMethod } = scale.getOptions() as Record<
11+
string,
12+
any
13+
>;
14+
expect(domain).toStrictEqual([0, 40, 100, 150, 200]);
15+
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.25, 0]);
16+
expect(round).toBeFalsy();
17+
expect(tickCount).toStrictEqual(5);
18+
expect(nice).toBeFalsy();
19+
expect(clamp).toBeFalsy();
20+
expect(unknown).toBeUndefined();
21+
expect(tickMethod(0, 200, 5)).toEqual(domain);
22+
});
23+
24+
test('multiple breaks should compress multiple regions', () => {
25+
const scale = new Linear({
26+
domain: [0, 200],
27+
breaks: [
28+
{ start: 40, end: 100, gap: 0.1 },
29+
{ start: 120, end: 160, gap: 0.1 },
30+
],
31+
});
32+
33+
const { domain, range } = scale.getOptions();
34+
expect(domain).toStrictEqual([0, 40, 100, 120, 160, 200]);
35+
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.35, 0.25, 0]);
36+
});
37+
38+
test('multiple breaks should compress multiple regions with out of order', () => {
39+
const scale = new Linear({
40+
domain: [0, 200],
41+
breaks: [
42+
{ start: 120, end: 160, gap: 0.1 },
43+
{ start: 40, end: 100, gap: 0.1 },
44+
],
45+
});
46+
47+
const { domain, range } = scale.getOptions();
48+
expect(domain).toStrictEqual([0, 40, 100, 120, 160, 200]);
49+
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.35, 0.25, 0]);
50+
});
51+
52+
test('multiple breaks should compress multiple regions with reverse', () => {
53+
const scale = new Linear({
54+
domain: [0, 980],
55+
range: [0, 1],
56+
breaks: [
57+
{ start: 300, end: 500, gap: 0.1 },
58+
{ start: 600, end: 800, gap: 0.05 },
59+
],
60+
});
61+
62+
const { domain, range } = scale.getOptions();
63+
expect(domain).toStrictEqual([0, 200, 300, 500, 600, 800, 980]);
64+
expect(range).toStrictEqual([
65+
0, 0.20408163265306123, 0.35816326530612247, 0.45816326530612245, 0.6892857142857143, 0.7392857142857143, 1,
66+
]);
67+
});
68+
69+
test('single break: update', () => {
70+
const scale = new Linear({
71+
domain: [0, 200],
72+
breaks: [{ start: 40, end: 100, gap: 0.1 }],
73+
});
74+
75+
const { domain, range } = scale.getOptions();
76+
expect(domain).toStrictEqual([0, 40, 100, 150, 200]);
77+
expect(range).toStrictEqual([1, 0.7000000000000001, 0.6, 0.25, 0]);
78+
scale.update({
79+
domain: [0, 200],
80+
breaks: [],
81+
});
82+
const scaleOptions = scale.getOptions();
83+
expect(scaleOptions.domain).toStrictEqual([0, 50, 100, 150, 200]);
84+
expect(scaleOptions.range).toStrictEqual([1, 0.75, 0.5, 0.25, 0]);
85+
scale.update({
86+
domain: [0, 200],
87+
breaks: undefined,
88+
});
89+
const scaleOptions2 = scale.getOptions();
90+
expect(scaleOptions2.domain).toStrictEqual([0, 200]);
91+
});
92+
93+
test('no breaks should behave like normal linear scale', () => {
94+
const scale = new Linear({
95+
domain: [0, 200],
96+
});
97+
98+
const { domain, range } = scale.getOptions();
99+
expect(domain).toStrictEqual([0, 200]);
100+
expect(range).toStrictEqual([0, 1]);
101+
});
102+
103+
test('linear scale with clone', () => {
104+
const scale = new Linear({
105+
domain: [0, 200],
106+
});
107+
108+
const scale2 = scale.clone();
109+
expect(scale2).toBeInstanceOf(Linear);
110+
expect(scale2.getOptions()).toEqual(scale.getOptions());
111+
});
112+
});

src/scales/base.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export abstract class Base<O extends BaseOptions> {
2525
*/
2626
protected abstract getDefaultOptions(): Partial<O>;
2727

28+
/**
29+
* 将用户传入的选项和默认选项合并,生成当前比例尺的选项
30+
*/
31+
protected transformBreaks(options: O): O {
32+
return options;
33+
}
34+
2835
/**
2936
* 比例尺的选项,用于配置数据映射的规则和 ticks 的生成方式
3037
*/
@@ -36,7 +43,7 @@ export abstract class Base<O extends BaseOptions> {
3643
*/
3744
constructor(options?: O) {
3845
this.options = deepMix({}, this.getDefaultOptions());
39-
this.update(options);
46+
this.update(options?.breaks?.length ? this.transformBreaks(options) : options);
4047
}
4148

4249
/**
@@ -52,8 +59,9 @@ export abstract class Base<O extends BaseOptions> {
5259
* @param updateOptions 需要更新的选项
5360
*/
5461
public update(updateOptions: Partial<O> = {}): void {
55-
this.options = deepMix({}, this.options, updateOptions);
56-
this.rescale(updateOptions);
62+
const options = updateOptions.breaks ? this.transformBreaks(updateOptions as O) : updateOptions;
63+
this.options = deepMix({}, this.options, options);
64+
this.rescale(options);
5765
}
5866

5967
/**

src/scales/linear.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { identity } from '@antv/util';
1+
import { identity, isArray, last } from '@antv/util';
22
import { Continuous } from './continuous';
33
import { LinearOptions, Transform } from '../types';
44
import { Base } from './base';
@@ -25,6 +25,54 @@ export class Linear extends Continuous<LinearOptions> {
2525
};
2626
}
2727

28+
protected transformDomain(options: LinearOptions): { breaksDomain: number[]; breaksRange: number[] } {
29+
const { domain, range = [1, 0], breaks = [], tickCount = 5 } = options;
30+
const [domainMin, domainMax] = [Math.min(...domain), Math.max(...domain)];
31+
const sortedBreaks = breaks.filter(({ end }) => end < domainMax).sort((a, b) => a.start - b.start);
32+
const breaksDomain = d3Ticks(domainMin, domainMax, tickCount, sortedBreaks);
33+
if (last(breaksDomain) < domainMax) {
34+
breaksDomain.push(domainMax);
35+
}
36+
const [r0, r1] = [range[0], last(range)] as number[];
37+
const diffDomain = domainMax - domainMin;
38+
const diffRange = Math.abs(r1 - r0);
39+
const reverse = r0 > r1;
40+
// Calculate the new range based on breaks.
41+
const breaksRange = breaksDomain.map((d) => {
42+
const ratio = (d - domainMin) / diffDomain;
43+
return reverse ? r0 - ratio * diffRange : r0 + ratio * diffRange;
44+
});
45+
// Compress the range scale according to breaks.
46+
sortedBreaks.forEach(({ start, end, gap = 0.05 }) => {
47+
const startIndex = breaksDomain.indexOf(start);
48+
const endIndex = breaksDomain.indexOf(end);
49+
const center = (breaksRange[startIndex] + breaksRange[endIndex]) / 2;
50+
const scaledSpan = gap * diffRange;
51+
// Calculate the new start and end values based on the center and scaled span.
52+
const startValue = reverse ? center + scaledSpan / 2 : center - scaledSpan / 2;
53+
const endValue = reverse ? center - scaledSpan / 2 : center + scaledSpan / 2;
54+
breaksRange[startIndex] = startValue;
55+
breaksRange[endIndex] = endValue;
56+
});
57+
return { breaksDomain, breaksRange };
58+
}
59+
60+
protected transformBreaks(options: LinearOptions): LinearOptions {
61+
const { domain, breaks = [] } = options;
62+
if (!isArray(options.breaks)) return options;
63+
const domainMax = Math.max(...domain);
64+
const filteredBreaks = breaks.filter(({ end }) => end < domainMax);
65+
const optWithFilteredBreaks = { ...options, breaks: filteredBreaks };
66+
const { breaksDomain, breaksRange } = this.transformDomain(optWithFilteredBreaks);
67+
return {
68+
...options,
69+
domain: breaksDomain,
70+
range: breaksRange,
71+
breaks: filteredBreaks,
72+
tickMethod: () => [...breaksDomain],
73+
};
74+
}
75+
2876
protected chooseTransforms(): Transform[] {
2977
return [identity, identity];
3078
}

src/tick-methods/d3-ticks.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import { TickMethod } from '../types';
22
import { tickIncrement } from '../utils';
3+
import type { BreakOptions } from '../types';
34

4-
export const d3Ticks: TickMethod = (begin: number, end: number, count: number) => {
5+
/**
6+
* Insert breaks into ticks and delete the ticks covered by breaks.
7+
*/
8+
export const insertBreaksToTicks = (ticks: number[], breaks?: BreakOptions[]): number[] => {
9+
if (!breaks?.length) return ticks;
10+
const edgePoints = [...ticks, ...breaks.flatMap((b) => [b.start, b.end])];
11+
const uniqueSortedTicks = Array.from(new Set(edgePoints)).sort((a, b) => a - b);
12+
const filteredTicks = uniqueSortedTicks.filter(
13+
(tick) => !breaks.some(({ start, end }) => tick > start && tick < end)
14+
);
15+
return filteredTicks.length ? filteredTicks : ticks;
16+
};
17+
18+
export const d3Ticks: TickMethod = (begin: number, end: number, count: number, breaks?: BreakOptions[]) => {
519
let n;
620
let ticks;
721

@@ -34,5 +48,6 @@ export const d3Ticks: TickMethod = (begin: number, end: number, count: number) =
3448
ticks[i] = (start + i) / step;
3549
}
3650
}
37-
return ticks;
51+
52+
return insertBreaksToTicks(ticks, breaks);
3853
};

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export type Transform = (x: any) => any;
2525
/** 柯里化后的函数的工厂函数类型 */
2626
export type CreateTransform = (...args: any[]) => Transform;
2727

28+
export interface BreakOptions {
29+
start: number; // 断轴开始
30+
end: number; // 断轴结束
31+
gap: number; // 在可视 range 中保留的间隔长度(0 ~ 1),默认为 0.05,表示 5% 的间隔
32+
}
33+
2834
/** 通用的配置 */
2935
export type BaseOptions = {
3036
/** 当需要映射的值不合法的时候,返回的值 */
@@ -33,6 +39,7 @@ export type BaseOptions = {
3339
range?: any[];
3440
/** 定义域,默认为 [0, 1] */
3541
domain?: any[];
42+
breaks?: BreakOptions[];
3643
};
3744

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

121130
/** Pow 比例尺的选项 */

0 commit comments

Comments
 (0)