From 7e2be0507f8962ffa004dfd4aa274cafb821b5d7 Mon Sep 17 00:00:00 2001 From: lxfu1 <954055752@qq.com> Date: Tue, 19 Aug 2025 21:23:23 +0800 Subject: [PATCH] feat: add the broken function to the linear scale --- __tests__/unit/scales/linear-breaks.spec.ts | 112 ++++++++++++++++++++ src/scales/base.ts | 14 ++- src/scales/linear.ts | 50 ++++++++- src/tick-methods/d3-ticks.ts | 19 +++- src/types.ts | 9 ++ 5 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 __tests__/unit/scales/linear-breaks.spec.ts diff --git a/__tests__/unit/scales/linear-breaks.spec.ts b/__tests__/unit/scales/linear-breaks.spec.ts new file mode 100644 index 00000000..121a502d --- /dev/null +++ b/__tests__/unit/scales/linear-breaks.spec.ts @@ -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()); + }); +}); diff --git a/src/scales/base.ts b/src/scales/base.ts index 890e9c01..3a453aa7 100644 --- a/src/scales/base.ts +++ b/src/scales/base.ts @@ -25,6 +25,13 @@ export abstract class Base { */ protected abstract getDefaultOptions(): Partial; + /** + * 将用户传入的选项和默认选项合并,生成当前比例尺的选项 + */ + protected transformBreaks(options: O): O { + return options; + } + /** * 比例尺的选项,用于配置数据映射的规则和 ticks 的生成方式 */ @@ -36,7 +43,7 @@ export abstract class Base { */ constructor(options?: O) { this.options = deepMix({}, this.getDefaultOptions()); - this.update(options); + this.update(options?.breaks?.length ? this.transformBreaks(options) : options); } /** @@ -52,8 +59,9 @@ export abstract class Base { * @param updateOptions 需要更新的选项 */ public update(updateOptions: Partial = {}): 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); } /** diff --git a/src/scales/linear.ts b/src/scales/linear.ts index 6753b8cf..99a17448 100644 --- a/src/scales/linear.ts +++ b/src/scales/linear.ts @@ -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'; @@ -25,6 +25,54 @@ export class Linear extends Continuous { }; } + 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]; } diff --git a/src/tick-methods/d3-ticks.ts b/src/tick-methods/d3-ticks.ts index 16f3b07a..857ccf3d 100644 --- a/src/tick-methods/d3-ticks.ts +++ b/src/tick-methods/d3-ticks.ts @@ -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; @@ -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); }; diff --git a/src/types.ts b/src/types.ts index 430f2f42..88b08c89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { /** 当需要映射的值不合法的时候,返回的值 */ @@ -33,6 +39,7 @@ export type BaseOptions = { range?: any[]; /** 定义域,默认为 [0, 1] */ domain?: any[]; + breaks?: BreakOptions[]; }; /** 获得比例尺选项中定义域元素的类型 */ @@ -116,6 +123,8 @@ export type LinearOptions = { round?: boolean; /** 插值器的工厂函数,返回一个对归一化后的输入在值域指定范围内插值的函数 */ interpolate?: Interpolates; + /** 断轴选项 */ + breaks?: BreakOptions[]; }; /** Pow 比例尺的选项 */