diff --git a/__tests__/unit/ui/axis/arc-spec.ts b/__tests__/unit/ui/axis/arc-spec.ts
new file mode 100644
index 000000000..706871f3f
--- /dev/null
+++ b/__tests__/unit/ui/axis/arc-spec.ts
@@ -0,0 +1,140 @@
+import type { Path } from '@antv/g';
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Arc } from '../../../../src';
+import type { Marker } from '../../../../src';
+import { createDiv } from '../../../utils';
+import type { StyleState as State } from '../../../../src/types';
+import type { TickDatum } from '../../../../src/ui/axis/types';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+const div = createDiv();
+const canvas = new Canvas({
+ container: div,
+ width: 500,
+ height: 500,
+ renderer,
+});
+
+const arc = new Arc({
+ style: {
+ startAngle: -90,
+ endAngle: 180,
+ radius: 100,
+ center: [200, 200],
+ },
+});
+
+canvas.appendChild(arc);
+
+function createData(values: number[], texts?: string[], states?: State[], ids?: string[]): TickDatum[] {
+ return values.map((value, idx) => {
+ const datum: TickDatum = { value };
+ if (texts && texts.length > idx) datum.text = texts[idx];
+ if (states && states.length > idx) datum.state = states[idx];
+ if (ids && ids.length > idx) datum.id = ids[idx];
+ return datum;
+ });
+}
+
+describe('arc', () => {
+ test('basic', async () => {
+ arc.update({
+ title: {
+ content: '弧形轴',
+ position: 'center',
+ offset: [0, 50],
+ },
+ line: {
+ arrow: {
+ start: {
+ symbol: 'diamond',
+ size: 10,
+ },
+ end: {
+ symbol: 'axis-arrow',
+ size: 10,
+ },
+ },
+ },
+ ticks: createData([0, 0.2, 0.4, 0.6, 0.8], ['A', 'B', 'C', 'D', 'E']),
+ label: {
+ alignTick: false,
+ offset: [0, 20],
+ },
+ subTickLine: {
+ count: 0,
+ },
+ });
+ // @ts-ignore
+ const [CMD1, CMD2, CMD3] = (arc.getAxisLine('line') as Path).attr('path');
+
+ // 圆心
+ expect(CMD1).toStrictEqual(['M', 200, 200]);
+ // 起点
+ expect(CMD2).toStrictEqual(['L', 200, 100]);
+ // 曲线
+ expect(CMD3).toStrictEqual(['A', 100, 100, 0, 1, 1, 100, 200]);
+ });
+
+ test('arrow', async () => {
+ // @ts-ignore
+ expect((arc.getAxisLine('end') as Marker).getEulerAngles()).toBeCloseTo(-90);
+ });
+
+ test('ticks', async () => {
+ // @ts-ignore
+ const [[, x1, y1], [, x2, y2]] = arc.tickLinesGroup.children[0]!.attr('path');
+ expect(x2 - x1).toBeCloseTo(0);
+ expect(Math.abs(y2 - y1)).toBe(10);
+ });
+
+ test('autoRotate', async () => {
+ // 默认布局下应该不会有碰撞
+ arc.update({
+ label: {
+ formatter: () => {
+ return '这是一段很长的文本';
+ },
+ rotate: 10,
+ autoRotate: true,
+ autoEllipsis: false,
+ autoHide: false,
+ },
+ });
+ });
+
+ test('autoEllipsis', async () => {
+ arc.update({
+ label: {
+ type: 'text',
+ minLength: 20,
+ maxLength: 50,
+ autoRotate: false,
+ autoEllipsis: true,
+ autoHide: false,
+ },
+ });
+ // @ts-ignore
+ expect(arc.labelsGroup.children[0]!.attr('text')).toBe('这是一...');
+ });
+
+ test('autoHide', async () => {
+ // 默认布局下应该不会有碰撞
+ arc.update({
+ startAngle: 0,
+ endAngle: 360,
+ label: {
+ rotate: 0,
+ alignTick: true,
+ autoEllipsis: false,
+ autoRotate: false,
+ },
+ });
+ });
+});
diff --git a/__tests__/unit/ui/axis/helix-spec.ts b/__tests__/unit/ui/axis/helix-spec.ts
new file mode 100644
index 000000000..44afc9c4b
--- /dev/null
+++ b/__tests__/unit/ui/axis/helix-spec.ts
@@ -0,0 +1,36 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Helix } from '../../../../src';
+import { createDiv } from '../../../utils';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+describe('linear', () => {
+ test('basic', async () => {});
+
+ test('title', async () => {});
+
+ test('line', async () => {});
+
+ test('arrow', async () => {});
+
+ test('ticks', async () => {});
+
+ test('subTicks', async () => {});
+
+ test('label', async () => {});
+
+ test('autoRotate', async () => {});
+
+ test('autoEllipsis text', async () => {});
+
+ test('autoEllipsis time', async () => {});
+
+ test('autoEllipsis number', async () => {});
+
+ test('autoHide', async () => {});
+});
diff --git a/__tests__/unit/ui/axis/linear-spec.ts b/__tests__/unit/ui/axis/linear-spec.ts
new file mode 100644
index 000000000..a74c079da
--- /dev/null
+++ b/__tests__/unit/ui/axis/linear-spec.ts
@@ -0,0 +1,314 @@
+import type { Path, Text } from '@antv/g';
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Linear } from '../../../../src';
+import { createDiv } from '../../../utils';
+import type { Marker } from '../../../../src';
+import type { StyleState as State } from '../../../../src/types';
+import type { TickDatum } from '../../../../src/ui/axis/types';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+const div = createDiv();
+const canvas = new Canvas({
+ container: div,
+ width: 500,
+ height: 500,
+ renderer,
+});
+
+const linear = new Linear({
+ style: {
+ startPos: [50, 50],
+ endPos: [450, 50],
+ },
+});
+
+canvas.appendChild(linear);
+
+function createData(values: number[], texts?: string[], states?: State[], ids?: string[]): TickDatum[] {
+ return values.map((value, idx) => {
+ const datum: TickDatum = { value };
+ if (texts && texts.length > idx) datum.text = texts[idx];
+ if (states && states.length > idx) datum.state = states[idx];
+ if (ids && ids.length > idx) datum.id = ids[idx];
+ return datum;
+ });
+}
+
+describe('linear', () => {
+ test('basic', async () => {
+ linear.update({
+ title: {
+ content: '直线轴线',
+ offset: [0, -40],
+ position: 'start',
+ },
+ line: {
+ arrow: {
+ end: {
+ symbol: 'axis-arrow',
+ size: 10,
+ },
+ },
+ },
+ label: {
+ alignTick: false,
+ offset: [0, 20],
+ },
+ subTickLine: {
+ count: 4,
+ },
+ ticks: createData([0, 0.2, 0.4, 0.6, 0.8], ['A', 'B', 'C', 'D', 'E']),
+ });
+
+ // @ts-ignore
+ expect(linear.tickLinesGroup.children.length).toBe(5);
+ // @ts-ignore
+ expect(linear.labelsGroup.children.length).toBe(5);
+ // @ts-ignore
+ expect(linear.subTickLinesGroup.children.length).toBe(4 * 5);
+ // @ts-ignore
+ expect(linear.tickLinesGroup.children[0]!.attr('stroke')).toBe('black');
+
+ linear.update({
+ tickLine: {
+ appendTick: true,
+ style: {
+ default: {
+ stroke: 'red',
+ },
+ },
+ },
+ });
+ // @ts-ignore
+ expect(linear.tickLinesGroup.children.length).toBe(6);
+ // @ts-ignore
+ expect(linear.tickLinesGroup.children[0]!.attr('stroke')).toBe('red');
+ });
+
+ test('vertical', async () => {
+ linear.update({
+ startPos: [250, 50],
+ endPos: [250, 450],
+ title: {
+ offset: [0, 0],
+ },
+ });
+
+ // @ts-ignore
+ const axisLine = linear.getAxisLine('line') as Path;
+ const linePath = axisLine.attr('path');
+ expect(linePath[0]).toStrictEqual(['M', 250, 50]);
+ expect(linePath[1]).toStrictEqual(['L', 250, 450]);
+ });
+
+ test('oblique', async () => {
+ linear.update({
+ startPos: [50, 50],
+ endPos: [450, 450],
+ title: {
+ offset: [0, 0],
+ },
+ label: {
+ align: 'radial',
+ },
+ });
+ });
+
+ test('ticks', async () => {
+ // @ts-ignore
+ const [[, x1, y1], [, x2, y2]] = linear.tickLinesGroup.children[0]!.attr('path');
+ expect((y2 - y1) / (x2 - x1)).toBeCloseTo(-1);
+ });
+
+ test('title', async () => {
+ // @ts-ignore
+ expect(linear.titleShape.attr('text')).toBe('直线轴线');
+ });
+
+ test('line', async () => {
+ linear.update({
+ title: {
+ offset: [0, -40],
+ },
+ startPos: [50, 50],
+ endPos: [450, 50],
+ });
+ // @ts-ignore
+ const axisLine = linear.getAxisLine('line') as Path;
+ expect(axisLine.attr('x')).toBe(50);
+ expect(axisLine.attr('y')).toBe(50);
+ const linePath = axisLine.attr('path');
+ expect(linePath[0]).toStrictEqual(['M', 50, 50]);
+ expect(linePath[1]).toStrictEqual(['L', 450, 50]);
+ });
+
+ test('arrow', async () => {
+ // @ts-ignore
+ expect((linear.getAxisLine('end') as Marker).getEulerAngles()).toBeCloseTo(0);
+ });
+
+ // test('subTicks', async () => {});
+
+ test('label', async () => {
+ linear.update({
+ label: {
+ alignTick: true,
+ formatter: ({ value, text }: TickDatum) => {
+ return `${value}-${text}`;
+ },
+ },
+ tickLine: {
+ appendTick: false,
+ },
+ });
+ // @ts-ignore
+ expect(linear.labelsGroup.children[0]!.attr('text')).toBe('0-A');
+ // @ts-ignore
+ expect(linear.labelsGroup.children[1]!.attr('text')).toBe('0.2-B');
+ // @ts-ignore
+ expect(linear.labelsGroup.children[2]!.attr('text')).toBe('0.4-C');
+ // @ts-ignore
+ expect(linear.labelsGroup.children[3]!.attr('text')).toBe('0.6-D');
+ // @ts-ignore
+ expect(linear.labelsGroup.children[4]!.attr('text')).toBe('0.8-E');
+ });
+
+ test('autoRotate', async () => {
+ linear.update({
+ label: {
+ formatter: () => {
+ return '这是一段很长的文本';
+ },
+ autoRotate: true,
+ autoEllipsis: false,
+ autoHide: false,
+ },
+ });
+ // @ts-ignore
+ expect(linear.labelsGroup.children[0]!.getEulerAngles()).toBeCloseTo(15);
+ });
+
+ test('autoHide', async () => {
+ linear.update({
+ label: {
+ autoRotate: false,
+ autoEllipsis: false,
+ autoHide: true,
+ },
+ });
+
+ // @ts-ignore
+ const group = linear.labelsGroup.children! as Text[];
+ expect(group[0]!.attr('visibility')).toBe('visible');
+ expect(group[1]!.attr('visibility')).toBe('hidden');
+ expect(group[2]!.attr('visibility')).toBe('visible');
+ expect(group[3]!.attr('visibility')).toBe('hidden');
+ expect(group[4]!.attr('visibility')).toBe('visible');
+ });
+
+ test('autoEllipsis text', async () => {
+ // 没有现在最大长度
+ linear.update({
+ label: {
+ formatter: () => {
+ return '这是一段很长的文本';
+ },
+ type: 'text',
+ autoRotate: false,
+ autoEllipsis: true,
+ autoHide: false,
+ },
+ });
+ // @ts-ignore
+ expect(linear.labelsGroup.children[0]!.attr('text')).toBe('这是一段很长的文本');
+
+ linear.update({
+ label: {
+ minLength: 50,
+ maxLength: 100,
+ },
+ });
+ // @ts-ignore
+ const bounds = (linear.labelsGroup.children[0]! as Text).getBounds()!;
+ const [x1] = bounds.getMin();
+ const [x2] = bounds.getMax();
+ expect(x2 - x1).toBeGreaterThanOrEqual(50);
+ expect(x2 - x1).toBeLessThanOrEqual(100);
+ });
+
+ test('autoEllipsis time', async () => {
+ linear.update({
+ ticks: createData(
+ [0, 0.2, 0.4, 0.6, 0.8, 1],
+ ['2021-08-11', '2021-09-11', '2021-10-11', '2021-11-11', '2021-12-11', '2022-01-11']
+ ),
+ label: {
+ type: 'time',
+ autoRotate: false,
+ autoEllipsis: true,
+ autoHide: false,
+ style: {
+ default: {
+ fontSize: 20,
+ },
+ },
+ formatter: (tick) => tick.text!,
+ },
+ });
+
+ // @ts-ignore
+ const group = linear.labelsGroup.children! as Text[];
+ expect(group[0].attr('text')).toBe('2021-08-11');
+ expect(group[1].attr('text')).toBe('09-11');
+ });
+
+ test('autoEllipsis number', async () => {
+ linear.update({
+ ticks: createData([0, 0.2, 0.4, 0.6, 0.8, 1]),
+ label: {
+ type: 'number',
+ style: {
+ default: {
+ fontSize: 20,
+ },
+ },
+ formatter: ({ value }: TickDatum) => String(value * 5000),
+ },
+ });
+
+ // @ts-ignore
+ let group = linear.labelsGroup.children! as Text[];
+ expect(group[0].attr('text')).toBe('0');
+ expect(group[1].attr('text')).toBe('1,000');
+
+ linear.update({
+ label: {
+ minLength: 30,
+ maxLength: 100,
+ formatter: ({ value }: TickDatum) => String(value * 10000000),
+ },
+ });
+ // @ts-ignore
+ group = linear.labelsGroup.children! as Text[];
+ expect(group[1].attr('text')).toBe('2,000K');
+ expect(group[5].attr('text')).toBe('10,000K');
+
+ linear.update({
+ label: {
+ minLength: 30,
+ maxLength: 100,
+ formatter: ({ value }: TickDatum) => String(value * 1e10),
+ },
+ });
+ // @ts-ignore
+ group = linear.labelsGroup.children! as Text[];
+ expect(group[1].attr('text')).toBe('2e+9');
+ expect(group[5].attr('text')).toBe('1e+10');
+ });
+});
diff --git a/__tests__/unit/ui/axis/overlap/is-overlap-spec.ts b/__tests__/unit/ui/axis/overlap/is-overlap-spec.ts
new file mode 100644
index 000000000..db80903a5
--- /dev/null
+++ b/__tests__/unit/ui/axis/overlap/is-overlap-spec.ts
@@ -0,0 +1,245 @@
+import { Text, Rect } from '@antv/g';
+import { getBoundsCenter } from '../../../../../src/ui/axis/utils';
+import { getCollisionText, isTextOverlap } from '../../../../../src/ui/axis/overlap/is-overlap';
+
+type Margin = [number, number, number, number];
+
+describe('isOverlap', () => {
+ test('getCollisionText', () => {
+ const [x, y] = [100, 200];
+
+ const text = new Text({
+ attrs: {
+ x: 0,
+ y: 0,
+ text: '一二三',
+ fontSize: 10,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ },
+ });
+
+ const rect = new Rect({
+ attrs: {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ },
+ });
+
+ rect.appendChild(text);
+
+ const { width, height } = text.getBoundingClientRect();
+ let [top, right, bottom, left] = [10, 10, 10, 10];
+
+ rect.attr({
+ x: x - width / 2 - left,
+ y: y - height / 2 - top,
+ width: width + left + right,
+ height: height + top + bottom,
+ });
+ text.attr({
+ x: left + width / 2,
+ y: top + height / 2,
+ });
+
+ expect(getBoundsCenter(rect)).toStrictEqual(getCollisionText(text, [top, right, bottom, left]).getBounds().center);
+
+ rect.attr({
+ x: x - width / 2 - left,
+ y: y - height / 2 - top,
+ width: width + left + right,
+ height: height + top + bottom,
+ });
+ text.attr({
+ x: left + width / 2,
+ y: top + height / 2,
+ });
+ expect(getBoundsCenter(rect)).toStrictEqual(getCollisionText(text, [top, right, bottom, left]).getBounds().center);
+
+ rect.setOrigin(left + width / 2, top + height / 2);
+ rect.setLocalEulerAngles(45);
+ let [bx, by] = getBoundsCenter(rect);
+ let [cx, cy] = getCollisionText(text, [top, right, bottom, left]).getBounds().center;
+ expect(bx).toBeCloseTo(cx);
+ expect(by).toBeCloseTo(cy);
+
+ rect.setOrigin(left + width / 2, top + height / 2);
+ rect.setLocalEulerAngles(60);
+
+ [bx, by] = getBoundsCenter(rect);
+ [cx, cy] = getCollisionText(text, [top, right, bottom, left]).getBounds().center;
+ expect(bx).toBeCloseTo(cx);
+ expect(by).toBeCloseTo(cy);
+
+ [top, right, bottom, left] = [10, 20, 10, 20];
+ rect.attr({
+ x: x - width / 2 - left,
+ y: y - height / 2 - top,
+ width: width + left + right,
+ height: height + top + bottom,
+ });
+ text.attr({
+ x: left + width / 2,
+ y: top + height / 2,
+ });
+
+ rect.setOrigin(left + width / 2, top + height / 2);
+ rect.setLocalEulerAngles(45);
+ [bx, by] = getBoundsCenter(rect);
+ [cx, cy] = getCollisionText(text, [top, right, bottom, left]).getBounds().center;
+ expect(bx).toBeCloseTo(cx);
+ expect(by).toBeCloseTo(cy);
+
+ rect.setOrigin(left + width / 2, top + height / 2);
+ rect.setLocalEulerAngles(60);
+
+ [bx, by] = getBoundsCenter(rect);
+ [cx, cy] = getCollisionText(text, [top, right, bottom, left]).getBounds().center;
+ expect(bx).toBeCloseTo(cx);
+ expect(by).toBeCloseTo(cy);
+
+ [top, right, bottom, left] = [10, 20, 10, 30];
+ rect.attr({
+ x: x - width / 2 - left,
+ y: y - height / 2 - top,
+ width: width + left + right,
+ height: height + top + bottom,
+ });
+ text.attr({
+ x: left + width / 2,
+ y: top + height / 2,
+ });
+
+ rect.setOrigin(left + width / 2, top + height / 2);
+ rect.setLocalEulerAngles(60);
+ [bx, by] = getBoundsCenter(rect);
+ [cx, cy] = getCollisionText(text, [top, right, bottom, left]).getBounds().center;
+
+ expect(bx).toBeCloseTo(cx);
+ expect(by).toBeCloseTo(cy);
+
+ [top, right, bottom, left] = [10, 20, 30, 40];
+ rect.attr({
+ x: x - width / 2 - left,
+ y: y - height / 2 - top,
+ width: width + left + right,
+ height: height + top + bottom,
+ });
+ text.attr({
+ x: left + width / 2,
+ y: top + height / 2,
+ });
+
+ rect.setOrigin(left + width / 2, top + height / 2);
+ rect.setLocalEulerAngles(60);
+ [bx, by] = getBoundsCenter(rect);
+ [cx, cy] = getCollisionText(text, [top, right, bottom, left]).getBounds().center;
+
+ expect(bx).toBeCloseTo(cx);
+ expect(by).toBeCloseTo(cy);
+ });
+
+ test('collision', () => {
+ const text1 = new Text({
+ attrs: {
+ x: 0,
+ y: 0,
+ text: 'text1',
+ fontSize: 10,
+ textBaseline: 'middle',
+ textAlign: 'start',
+ },
+ });
+ const text2 = new Text({
+ attrs: {
+ x: 0,
+ y: 0,
+ text: 'text2',
+ fontSize: 10,
+ textBaseline: 'middle',
+ textAlign: 'end',
+ },
+ });
+
+ const margin = [0, 0, 0, 0] as Margin;
+
+ expect(isTextOverlap(text2, text1, margin)).toBe(false);
+
+ // 把文字右移一点点,应当发生碰撞
+ text2.attr('x', 1);
+
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+
+ text2.attr({ textAlign: 'start', x: 0 });
+
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+ text1.destroy();
+ text2.destroy();
+ });
+ test('rotate', () => {
+ // 测试旋转情况下的碰撞
+ const text1 = new Text({
+ attrs: {
+ x: 0,
+ y: 0,
+ text: 'text',
+ fontSize: 10,
+ textBaseline: 'middle',
+ textAlign: 'center',
+ },
+ });
+ const text2 = new Text({
+ attrs: {
+ x: 0,
+ y: 0,
+ text: 'text',
+ fontSize: 10,
+ textBaseline: 'middle',
+ textAlign: 'center',
+ },
+ });
+
+ const margin = [0, 0, 0, 0] as Margin;
+
+ // 获取当前文本宽高,text2 同 text1
+ const { width, height } = text1.getBoundingClientRect();
+ // 把 text2 右移一半宽度
+ text2.attr('x', width * 0.75);
+
+ text1.setEulerAngles(30);
+ text2.setEulerAngles(30);
+
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+
+ text1.setEulerAngles(45);
+ text2.setEulerAngles(45);
+
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+
+ // 当前配置,旋转角大于65度就不会重叠了
+ // 该数据通过绘图测算得出
+ text1.setEulerAngles(65);
+ text2.setEulerAngles(65);
+ // 这一段在本地和服务器结果不一致 本地 false 服务器 true
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+
+ text1.setEulerAngles(90);
+ text2.setEulerAngles(90);
+
+ // 这一段在本地和服务器结果不一致 本地 false 服务器 true
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+
+ text1.setEulerAngles(115);
+ text2.setEulerAngles(115);
+
+ // 这一段在本地和服务器结果不一致 本地 false 服务器 true
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+
+ text1.setEulerAngles(120);
+ text2.setEulerAngles(120);
+
+ expect(isTextOverlap(text2, text1, margin)).toBe(true);
+ });
+});
diff --git a/__tests__/unit/util/number-spec.ts b/__tests__/unit/util/number-spec.ts
index d4fc6f812..d6a0bf3d9 100644
--- a/__tests__/unit/util/number-spec.ts
+++ b/__tests__/unit/util/number-spec.ts
@@ -1,8 +1,40 @@
-import { toPrecision } from '../../../src';
+import { toPrecision, toThousands, toScientificNotation, toKNotation } from '../../../src';
describe('number', () => {
test('toPrecision', async () => {
expect(toPrecision(0.12345, 2)).toBe(0.12);
expect(toPrecision(0.12345, 3)).toBe(0.123);
+ expect(toPrecision(123.456, 1)).toBe(123.4);
+ });
+
+ test('toThousands', async () => {
+ expect(toThousands(0.123)).toBe('0.123');
+ expect(toThousands(123)).toBe('123');
+ expect(toThousands(123456)).toBe('123,456');
+ });
+
+ test('toScientificNotation', async () => {
+ expect(toScientificNotation(0.00001)).toBe('1e-5');
+ expect(toScientificNotation(0)).toBe('0e+0');
+ expect(toScientificNotation(123456)).toBe('1.23456e+5');
+ expect(toScientificNotation(100000000)).toBe('1e+8');
+ expect(toScientificNotation(-0)).toBe('0e+0');
+ expect(toScientificNotation(-123456)).toBe('-1.23456e+5');
+ expect(toScientificNotation(-100000000)).toBe('-1e+8');
+ });
+
+ test('toKNotation', async () => {
+ expect(toKNotation(0.0001)).toBe('0.0001');
+ expect(toKNotation(123)).toBe('123');
+ expect(toKNotation(123456)).toBe('123K');
+ expect(toKNotation(123456, 1)).toBe('123.4K');
+ expect(toKNotation(1234567, 1)).toBe('1,234.5K');
+ expect(toKNotation(100000000)).toBe('100,000K');
+
+ expect(toKNotation(-123)).toBe('-123');
+ expect(toKNotation(-123456)).toBe('-123K');
+ expect(toKNotation(-123456, 1)).toBe('-123.4K');
+ expect(toKNotation(-1234567, 1)).toBe('-1,234.5K');
+ expect(toKNotation(-100000000)).toBe('-100,000K');
});
});
diff --git a/__tests__/unit/util/time-spec.ts b/__tests__/unit/util/time-spec.ts
new file mode 100644
index 000000000..4ba6493ba
--- /dev/null
+++ b/__tests__/unit/util/time-spec.ts
@@ -0,0 +1,56 @@
+import { getTimeDiff, getTimeScale, formatTime, getMask, getTimeStart } from '../../../src/util';
+
+// const testDate = ['2021-01-11', '2021-01-12', '2021-01-12 12:13:02', '2022-01-20'];
+const testDate = [
+ ['2021-01-01', '2021-01-01'],
+ ['2021-01-01', '2021-12-31'],
+ ['2021-01-01', '2022-01-01'],
+ ['2021-01-01 00:00:00', '2021-01-02 00:00:00'],
+ ['2021-01-01 00:01:00', '2021-01-01 00:50:00'],
+];
+const second = 1000;
+const minute = second * 60;
+const hour = minute * 60;
+const day = hour * 24;
+
+describe('time', () => {
+ test('getTimeDiff', async () => {
+ expect(getTimeDiff(testDate[0][0], testDate[0][1])).toBe(0);
+ expect(getTimeDiff(testDate[1][0], testDate[1][1])).toBe(-day * 364);
+ expect(getTimeDiff(testDate[2][0], testDate[2][1])).toBe(-day * 365);
+ expect(getTimeDiff(testDate[3][0], testDate[3][1])).toBe(-day);
+ });
+
+ test('getTimeScale', async () => {
+ expect(getTimeScale(testDate[0][0], testDate[0][1])).toBe('second');
+ expect(getTimeScale(testDate[1][0], testDate[1][1])).toBe('month');
+ expect(getTimeScale(testDate[2][0], testDate[2][1])).toBe('year');
+ expect(getTimeScale(testDate[3][0], testDate[3][1])).toBe('day');
+ expect(getTimeScale(testDate[4][0], testDate[4][1])).toBe('minute');
+ });
+
+ test('formatTime', async () => {
+ const time = new Date('2021-08-07 12:23:34');
+ expect(formatTime(time, 'YYYY')).toBe('2021');
+ expect(formatTime(time, 'YYYY-MM')).toBe('2021-08');
+ expect(formatTime(time, 'YYYY-MM-DD')).toBe('2021-08-07');
+ expect(formatTime(time, 'YYYY-MM-DD hh:mm:ss')).toBe('2021-08-07 12:23:34');
+ expect(formatTime(time, 'hh:mm:ss')).toBe('12:23:34');
+ });
+
+ test('getMask', async () => {
+ expect(getMask(['year', 'day'])).toBe('YYYY-MM-DD');
+ expect(getMask(['hour', 'second'])).toBe('hh:mm:ss');
+ expect(getMask(['year', 'second'])).toBe('YYYY-MM-DD hh:mm:ss');
+ });
+
+ test('getTimeStart', async () => {
+ const time = new Date('2021-08-07 12:23:34');
+ expect(getTimeStart(time, 'year')).toBe('2021');
+ expect(getTimeStart(time, 'month')).toBe('2021-08');
+ expect(getTimeStart(time, 'day')).toBe('2021-08-07');
+ expect(getTimeStart(time, 'hour')).toBe('2021-08-07 12');
+ expect(getTimeStart(time, 'minute')).toBe('2021-08-07 12:23');
+ expect(getTimeStart(time, 'second')).toBe('2021-08-07 12:23:34');
+ });
+});
diff --git a/docs/api/ui/axis.en.md b/docs/api/ui/axis.en.md
new file mode 100644
index 000000000..1131195ad
--- /dev/null
+++ b/docs/api/ui/axis.en.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/axis.zh.md`
diff --git a/docs/api/ui/axis.zh.md b/docs/api/ui/axis.zh.md
new file mode 100644
index 000000000..4171c5756
--- /dev/null
+++ b/docs/api/ui/axis.zh.md
@@ -0,0 +1,120 @@
+--
+title: Axis
+order: 6
+--
+
+# 轴
+
+> 坐标轴指二维空间中统计图表中的轴,它用来定义坐标系中数据在方向和值的映射关系的图表组件
+
+## 引入
+
+```
+import { Linear, Arc, Helix } from '@antv/gui';
+```
+
+### 基本配置
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| -------------- | ------------------------------------ | ----------------------------------------------------- | ---------- |
+| title | false \| AxisTitleCfg
| 标题 | `` |
+| line | false \| AxisLineCfg
| 轴线 | `` |
+| ticks | TickDatum[]
| 刻度数据 | `` |
+| ticksThreshold | false \| number
| 刻度数量阈值 | `` |
+| tickLine | false \| AxisTickCfg
| 刻度线配置 | `` |
+| label | false \| AxisLabelCfg
| 标签文本配置 | `` |
+| subTickLine | false \| AxisSubTickCfg
| 子刻度线配置 | `` |
+| verticalFactor | -1 \| 1
| 刻度与标签在轴线向量的位置,-1: 向量右侧, 1: 向量左侧 | `` |
+
+### 直线坐标轴
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ----------------------------- | ------------ | ---------- |
+| startPos | [number, number]
| 轴线起点坐标 | `` |
+| endPos | [number, number]
| 轴线终点坐标 | `` |
+
+### 圆弧坐标轴
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ----------------------------- | ---------------------- | ---------- |
+| startAngle | number
| 起始角,弧度、角度均可 | `` |
+| endAngle | number
| 结束角 | `` |
+| radius | number
| 半径 | `` |
+| center | [number, number]
| 圆心位置 | `` |
+
+### 螺旋坐标轴
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ------------------- | ---------------------- | ---------- |
+| a | number
| 参数 a | `` |
+| b | number
| 参数 b | `` |
+| startAngle | number
| 起始角 | `` |
+| endAngle | number
| 结束角 | `` |
+| precision | number
| 精度,影响螺旋线的绘制 | `0.1` |
+
+### 标题 AxisTitleCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ----------------------------------------- | -------- | ---------- |
+| content | string
| 内容 | `` |
+| style | TextProps
| 样式 | `` |
+| position | 'start' \| 'center' \| 'end'
| 位置 | `'start'` |
+| offset | [number, number]
| 偏移量 | `[0,0]` |
+| rotate | number
| 旋转角度 | `0` |
+
+### 轴线 AxisLineCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | ----------------------------------------------- | ------------ | ---------- |
+| style | LineProps
| 线条样式 | `` |
+| arrow | {start: MarkerCfg, end: MarkerCfg}
| 轴线两端箭头 | `` |
+
+### 刻度线 AxisTickLineCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | --------------------- | ------------------------------------------------------- | ---------- |
+| length | number
| 长度 | `5` |
+| style | MixAttrs
| 带状态的线条样式 | `` |
+| offset | number
| 在轴线方向的偏移量 | `0` |
+| appendTick | boolean
| 末尾追加 tick,一般用于 label alignTick 为 false 的情况 | `false` |
+
+### 标签 AxisLabelCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | -------------- |
+| type | 'text' \| 'number' \| 'time'
| 标签文本类型,会影响缩略策略 | `text` |
+| style | MixAttrs
| 带状态的文本样式 | `` |
+| alignTick | boolean
| label 是否与 Tick 对齐 | `` |
+| align | 'normal' \| 'tangential' \| 'radial'
| 标签文本与轴线的对齐方式,normal-水平,tangential-切向 radial-径向 | `'normal'` |
+| formatter | (tick: TickDatum) => string
| 标签格式化 | `` |
+| offset | [number, number]
| 偏移量 | `` |
+| overlapOrder | 'autoRotate' \| 'autoEllipsis' \| 'autoHide'[]
| 处理 label 重叠的优先级 | `` |
+| margin | [number, number, number, number]
| 标签外边距,在进行自动避免重叠时的额外间隔 | `[0, 0, 0, 0]` |
+| autoRotate | boolean
| 旋转度数,默认垂直或平行于刻度线 | `true` |
+| rotateRange | [number, number]
| 自动旋转的范围 | `[0, 90]` |
+| rotate | number
| 范围[-90, 90] 手动指定旋转角度,配置后自动旋转失效 | `` |
+| autoHide | boolean
| label 过多时隐藏部分 | `true` |
+| autoHideTickLine | boolean
| 隐藏 label 时,同时隐藏掉其对应的 tickLine | `true` |
+| minLabel | number
| 最小 label 数量 | `` |
+| autoEllipsis | boolean
| label 过长时缩略 | `true` |
+| ellipsisStep | number \| string
| 缩略步长,传入 string 时将计算其长度(下同) | `` |
+| minLength | number \| string
| label 的最小长度 | `` |
+| maxLength | number \| string
| label 的最大长度,无穷大表示不限制长度 | `Infinity` |
+
+### 子刻度线 AxisSubTickLineCfg
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | --------------------- | ---------------------- | ---------- |
+| length | number
| 长度 | `2` |
+| count | number
| 两个刻度之间的子刻度数 | `4` |
+| style | MixAttrs
| 带状态的线条样式 | `` |
+| offset | number
| 在轴线方向的偏移量 | `0` |
+
+### 数据 TickDatum
+
+| **属性名** | **类型** | **描述** | **默认值** |
+| ---------- | -------------------------------- | ------------------------------- | ----------- |
+| value | number
| 范围 [0, 1], 表示在轴线上的位置 | `` |
+| text | string
| 显示的标签内容 | `value` |
+| state | 'default'\|'active'
| 状态 | `'default'` |
+| id | string
| id | `index` |
diff --git a/examples/chart-ui/axis/API.en.md b/examples/chart-ui/axis/API.en.md
new file mode 100644
index 000000000..c38c9d453
--- /dev/null
+++ b/examples/chart-ui/axis/API.en.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/axis.en.md`
diff --git a/examples/chart-ui/axis/API.zh.md b/examples/chart-ui/axis/API.zh.md
new file mode 100644
index 000000000..1131195ad
--- /dev/null
+++ b/examples/chart-ui/axis/API.zh.md
@@ -0,0 +1 @@
+`markdown:docs/api/ui/axis.zh.md`
diff --git a/examples/chart-ui/axis/demo/arc.ts b/examples/chart-ui/axis/demo/arc.ts
new file mode 100644
index 000000000..570d6f72d
--- /dev/null
+++ b/examples/chart-ui/axis/demo/arc.ts
@@ -0,0 +1,53 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Arc } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+const canvas = new Canvas({
+ container: 'container',
+ width: 600,
+ height: 600,
+ renderer,
+});
+
+const arc = new Arc({
+ style: {
+ startAngle: -90,
+ endAngle: 270,
+ radius: 150,
+ center: [200, 200],
+ verticalFactor: -1,
+ title: {
+ content: '圆弧坐标轴',
+ rotate: 0,
+ position: 'center',
+ offset: [0, -140],
+ },
+ ticks: new Array(60).fill(0).map((tick, idx) => {
+ const step = 1 / 60;
+ return {
+ value: idx * step,
+ text: String(idx),
+ };
+ }),
+ label: {
+ offset: [0, 20],
+ autoHideTickLine: false,
+ },
+ subTickLine: {
+ count: 1,
+ style: {
+ default: {
+ stroke: 'red',
+ },
+ },
+ },
+ },
+});
+
+canvas.appendChild(arc);
diff --git a/examples/chart-ui/axis/demo/helix.ts b/examples/chart-ui/axis/demo/helix.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/examples/chart-ui/axis/demo/linear.ts b/examples/chart-ui/axis/demo/linear.ts
new file mode 100644
index 000000000..e1ee280b7
--- /dev/null
+++ b/examples/chart-ui/axis/demo/linear.ts
@@ -0,0 +1,76 @@
+import { Canvas } from '@antv/g';
+import { Renderer as CanvasRenderer } from '@antv/g-canvas';
+import { Linear } from '@antv/gui';
+
+const renderer = new CanvasRenderer({
+ enableDirtyRectangleRenderingDebug: false,
+ enableAutoRendering: true,
+ enableDirtyRectangleRendering: true,
+});
+
+const canvas = new Canvas({
+ container: 'container',
+ width: 1000,
+ height: 600,
+ renderer,
+});
+
+const data = [
+ '蚂蚁技术研究院',
+ '智能资金',
+ '蚂蚁消金',
+ '合规线',
+ '战略线',
+ '商业智能线',
+ 'CFO线',
+ 'CTO线',
+ '投资线',
+ 'GR线',
+ '社会公益及绿色发展事业群',
+ '阿里妈妈事业群',
+ 'CMO线',
+ '大安全',
+ '天猫事业线',
+ '影业',
+ 'OceanBase',
+ '投资基金线',
+ '阿里体育',
+ '智能科技事业群',
+];
+
+const tickData = data.map((d, idx) => {
+ const step = 1 / data.length;
+ return {
+ value: step * idx,
+ text: d,
+ state: 'default',
+ id: String(idx),
+ };
+});
+
+const linear = new Linear({
+ style: {
+ startPos: [50, 50],
+ endPos: [800, 50],
+ ticks: tickData,
+ title: {
+ content: '直线坐标轴',
+ offset: [0, -20],
+ },
+ label: {
+ offset: [0, 15],
+ minLength: 20,
+ maxLength: 80,
+ rotateRange: [0, 85],
+ rotateStep: 1,
+ padding: [0, 0, 0, 0],
+ autoHide: false,
+ },
+ tickLine: {
+ appendTick: true,
+ },
+ subTickLine: false,
+ },
+});
+
+canvas.appendChild(linear);
diff --git a/examples/chart-ui/axis/demo/meta.json b/examples/chart-ui/axis/demo/meta.json
new file mode 100644
index 000000000..652df0837
--- /dev/null
+++ b/examples/chart-ui/axis/demo/meta.json
@@ -0,0 +1,32 @@
+{
+ "title": {
+ "zh": "中文分类",
+ "en": "Category"
+ },
+ "demos": [
+ {
+ "filename": "linear.ts",
+ "title": {
+ "zh": "直线坐标轴",
+ "en": "Linear axis"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "arc.ts",
+ "title": {
+ "zh": "圆弧坐标轴",
+ "en": "Arc axis"
+ },
+ "screenshot": ""
+ },
+ {
+ "filename": "helix.ts",
+ "title": {
+ "zh": "螺旋坐标轴",
+ "en": "Helix axis"
+ },
+ "screenshot": ""
+ }
+ ]
+}
diff --git a/examples/chart-ui/axis/index.en.md b/examples/chart-ui/axis/index.en.md
new file mode 100644
index 000000000..6520c32c3
--- /dev/null
+++ b/examples/chart-ui/axis/index.en.md
@@ -0,0 +1,4 @@
+---
+title: Axis
+order: 6
+---
diff --git a/examples/chart-ui/axis/index.zh.md b/examples/chart-ui/axis/index.zh.md
new file mode 100644
index 000000000..38f490295
--- /dev/null
+++ b/examples/chart-ui/axis/index.zh.md
@@ -0,0 +1,4 @@
+---
+title: 轴
+order: 6
+---
diff --git a/examples/chart-ui/legend/API.en.md b/examples/chart-ui/legend/API.en.md
index 8a9d2b4d5..e9bd691e2 100644
--- a/examples/chart-ui/legend/API.en.md
+++ b/examples/chart-ui/legend/API.en.md
@@ -1 +1 @@
-`markdown:docs/api/ui/continuous.en.md`
+`markdown:docs/api/ui/legend.en.md`
diff --git a/examples/chart-ui/legend/API.zh.md b/examples/chart-ui/legend/API.zh.md
index 83dc99388..431b7458a 100644
--- a/examples/chart-ui/legend/API.zh.md
+++ b/examples/chart-ui/legend/API.zh.md
@@ -1 +1 @@
-`markdown:docs/api/ui/continuous.zh.md`
+`markdown:docs/api/ui/legend.zh.md`
diff --git a/package.json b/package.json
index e247ff3d5..08fa2cd3f 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@antv/g": "^1.0.0-alpha.9",
"@antv/g-canvas": "^1.0.0-alpha.10",
"@antv/g-svg": "^1.0.0-alpha.10",
+ "@antv/matrix-util": "^3.0.4",
"@antv/path-util": "^2.0.9",
"@antv/scale": "^0.4.3",
"@antv/util": "^2.0.13",
diff --git a/src/index.ts b/src/index.ts
index 7c1d7ca2e..c7e66d288 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,4 +2,4 @@
export * from './ui';
// 方法
-export { svg2marker, getEllipsisText, measureTextWidth, toPrecision } from './util';
+export * from './util';
diff --git a/src/ui/axis/arc.ts b/src/ui/axis/arc.ts
new file mode 100644
index 000000000..57fdccb69
--- /dev/null
+++ b/src/ui/axis/arc.ts
@@ -0,0 +1,154 @@
+import { deepMix } from '@antv/util';
+import { vec2 } from '@antv/matrix-util';
+import type { PathCommand } from '@antv/g';
+import type { ArcCfg, ArcOptions, AxisLabelCfg, Point, Position } from './types';
+import { AxisBase } from './base';
+import { getVerticalVector, getVectorsAngle } from './utils';
+import { ARC_DEFAULT_OPTIONS } from './constant';
+import { toPrecision } from '../../util';
+
+const { PI, abs, cos, sin } = Math;
+const [PI2] = [PI * 2];
+
+export class Arc extends AxisBase {
+ public static tag = 'arc';
+
+ protected static defaultOptions = {
+ type: Arc.tag,
+ ...ARC_DEFAULT_OPTIONS,
+ };
+
+ constructor(options: ArcOptions) {
+ super(deepMix({}, Arc.defaultOptions, options));
+ super.init();
+ }
+
+ protected getAxisLinePath() {
+ const {
+ radius: rx,
+ center: [cx, cy],
+ } = this.attributes;
+ const { startPos, endPos } = this.getTerminals();
+ const { startAngle, endAngle } = this.getAngles();
+ const diffAngle = abs(endAngle - startAngle);
+ const ry = rx;
+ if (diffAngle === PI2) {
+ // 绘制两个半圆
+ return [
+ ['M', cx, cy - ry],
+ ['A', rx, ry, 0, 1, 1, cx, cy + ry],
+ ['A', rx, ry, 0, 1, 1, cx, cy - ry],
+ ['Z'],
+ ] as PathCommand[];
+ }
+
+ // 大小弧
+ const large = diffAngle > PI ? 1 : 0;
+ // 1-顺时针 0-逆时针
+ const sweep = startAngle > endAngle ? 0 : 1;
+ return [['M', cx, cy], ['L', ...startPos], ['A', rx, ry, 0, large, sweep, ...endPos], ['Z']] as PathCommand[];
+ }
+
+ protected getTangentVector(value: number) {
+ const { verticalFactor } = this.attributes;
+ const verVec = this.getVerticalVector(value);
+ return vec2.normalize([0, 0], getVerticalVector(verVec, verticalFactor));
+ }
+
+ protected getVerticalVector(value: number) {
+ const {
+ center: [cx, cy],
+ } = this.attributes;
+ const [x, y] = this.getValuePoint(value);
+ const [v1, v2] = vec2.normalize([0, 0], [x - cx, y - cy]);
+ const { verticalFactor } = this.attributes;
+ return vec2.scale([0, 0], [v1, v2], verticalFactor);
+ }
+
+ protected getValuePoint(value: number) {
+ const {
+ center: [cx, cy],
+ radius,
+ } = this.attributes;
+ const { startAngle, endAngle } = this.getAngles();
+ const angle = (endAngle - startAngle) * value + startAngle;
+ const rx = radius * cos(angle);
+ const ry = radius * sin(angle);
+ return [cx + rx, cy + ry] as Point;
+ }
+
+ protected getTerminals() {
+ const {
+ center: [cx, cy],
+ radius,
+ } = this.attributes;
+ const { startAngle, endAngle } = this.getAngles();
+ const startPos = [cx + radius * cos(startAngle), cy + radius * sin(startAngle)] as Point;
+ const endPos = [cx + radius * cos(endAngle), cy + radius * sin(endAngle)] as Point;
+ return { startPos, endPos };
+ }
+
+ protected getLabelLayout(labelVal: number, tickAngle: number, angle: number) {
+ // 精度
+ const approxTickAngle = toPrecision(tickAngle, 0);
+ const { label } = this.attributes;
+ const { align } = label as AxisLabelCfg;
+ let rotate = angle;
+ let textAlign = 'center' as Position;
+ if (align === 'tangential') {
+ rotate = getVectorsAngle([1, 0], this.getTangentVector(labelVal));
+ } else {
+ // 非径向垂直于刻度的情况下(水平、径向),调整锚点
+
+ const absAngle = Math.abs(approxTickAngle);
+ if (absAngle < 90) textAlign = 'start';
+ else if (absAngle > 90) textAlign = 'end';
+
+ // 暂时有点问题
+ // const alpha = approxTickAngle;
+ // const flag = alpha > 270 || alpha < 90;
+ // if (angle > alpha - 90 && angle < alpha + 90) {
+ // if (flag) textAlign = 'start';
+ // else textAlign = 'end';
+ // } else if (flag) textAlign = 'end';
+ // else textAlign = 'start';
+
+ if (angle !== 0 || [-90, 90].includes(approxTickAngle)) {
+ const sign = angle > 0 ? 0 : 1;
+ if (absAngle < 90) {
+ textAlign = ['end', 'start'][sign] as Position;
+ } else if (absAngle > 90) {
+ textAlign = ['start', 'end'][sign] as Position;
+ }
+ }
+
+ // 超过旋转超过 90 度时,文本会倒置,这里将其正置
+ if (align === 'radial') {
+ rotate = approxTickAngle;
+ if (Math.abs(rotate) > 90) rotate -= 180;
+ }
+ }
+ return {
+ rotate,
+ textAlign,
+ };
+ }
+
+ /**
+ * 获得弧度数值
+ */
+ private getAngles() {
+ const { startAngle, endAngle } = this.attributes;
+ // 判断角度还是弧度
+ if (abs(startAngle) < PI2 && abs(endAngle) < PI2) {
+ // 弧度
+ return { startAngle, endAngle };
+ }
+ // 角度
+
+ return {
+ startAngle: (startAngle * PI) / 180,
+ endAngle: (endAngle * PI) / 180,
+ };
+ }
+}
diff --git a/src/ui/axis/base.ts b/src/ui/axis/base.ts
new file mode 100644
index 000000000..437f7f8e0
--- /dev/null
+++ b/src/ui/axis/base.ts
@@ -0,0 +1,849 @@
+import { Group, Path, Text } from '@antv/g';
+import { clone, deepMix, minBy, maxBy, get, pick, sortBy, isString, isNumber } from '@antv/util';
+import { vec2 } from '@antv/matrix-util';
+import type { vec2 as Vector } from '@antv/matrix-util';
+import type { PathCommand } from '@antv/g';
+import type { MarkerCfg } from '../marker';
+import type {
+ Point,
+ AxisType,
+ TickDatum,
+ AxisBaseCfg,
+ AxisBaseOptions,
+ AxisTitleCfg,
+ AxisLineCfg,
+ AxisTickLineCfg,
+ AxisSubTickLineCfg,
+ AxisLabelCfg,
+ OverlapType,
+} from './types';
+import type { ShapeAttrs, StyleState as State, TextProps, PathProps } from '../../types';
+import type { TimeScale } from '../../util';
+import { GUI } from '../core/gui';
+import { Marker } from '../marker';
+import {
+ getStateStyle,
+ measureTextWidth,
+ getEllipsisText,
+ toThousands,
+ toKNotation,
+ toScientificNotation,
+ getTimeScale,
+ getMask,
+ formatTime,
+ getTimeStart,
+ scale as timeScale,
+} from '../../util';
+import { getVectorsAngle, centerRotate, formatAngle } from './utils';
+import { AXIS_BASE_DEFAULT_OPTIONS, NULL_ARROW, NULL_TEXT, COMMON_TIME_MAP } from './constant';
+import { isLabelsOverlap, isTextOverlap } from './overlap/is-overlap';
+
+interface IAxisLineCfg {
+ style: ShapeAttrs;
+ arrow: {
+ start: MarkerCfg & {
+ rotate: number;
+ };
+ end: MarkerCfg & {
+ rotate: number;
+ };
+ };
+ line: PathProps;
+}
+
+interface ITicksCfg {
+ tickLines: PathProps[];
+ subTickLines: PathProps[];
+ labels: TextProps[];
+}
+
+// 注册轴箭头
+// ->
+Marker.registerSymbol('axis-arrow', (x: number, y: number, r: number) => {
+ return [['M', x, y], ['L', x - r, y - r], ['L', x + r, y], ['L', x - r, y + r], ['Z']];
+});
+
+export abstract class AxisBase extends GUI> {
+ public static tag = 'axisBase';
+
+ // 标题
+ protected titleShape!: Text;
+
+ // 轴线
+ protected axisLineShape!: Group;
+
+ // 刻度
+ // protected ticksShape: Ticks;
+
+ private tickLinesGroup!: Group;
+
+ private labelsGroup!: Group;
+
+ private subTickLinesGroup!: Group;
+
+ protected static defaultOptions = {
+ type: AxisBase.tag,
+ ...AXIS_BASE_DEFAULT_OPTIONS,
+ };
+
+ constructor(options: AxisBaseOptions) {
+ super(deepMix({}, AxisBase.defaultOptions, options));
+ }
+
+ public init() {
+ this.initShape();
+ // 绘制title
+ this.updateTitleShape();
+ // 绘制轴线
+ this.updateAxisLineShape();
+ // 绘制刻度与子刻度以及label
+ this.updateTicksShape();
+ }
+
+ public update(cfg: Partial) {
+ this.attr(deepMix({}, this.attributes, cfg));
+ // 更新title
+ this.updateTitleShape();
+ // 更新轴线
+ this.updateAxisLineShape();
+ // 更新刻度与子刻度\刻度文本
+ this.updateTicksShape();
+ }
+
+ public clear() {}
+
+ /**
+ * 设置value对应的tick的状态样式
+ */
+ public setTickState(value: number): void {}
+
+ /**
+ * 设置label旋转角度
+ */
+ public setLabelEulerAngles(angle: number): void {
+ this.getLabels().forEach((label) => {
+ const labelVal = label.attr('value');
+ const tickAngle = getVectorsAngle([1, 0], this.getVerticalVector(labelVal));
+ const { rotate, textAlign } = this.getLabelLayout(labelVal, tickAngle, formatAngle(angle));
+ label.attr({ textAlign });
+ label.setEulerAngles(rotate);
+ });
+ }
+
+ /**
+ * 获取label旋转角度
+ */
+ public getLabelEulerAngles() {
+ return this.getLabels()[0]?.getEulerAngles() || 0;
+ }
+
+ /**
+ * 生成轴线path
+ */
+ protected abstract getAxisLinePath(): PathCommand[];
+
+ /**
+ * 获取给定 value 在轴上的切线向量
+ */
+ protected abstract getTangentVector(value: number): Vector;
+
+ /**
+ * 获取给定 value 在轴上刻度的向量
+ */
+ protected abstract getVerticalVector(value: number): Vector;
+
+ /**
+ * 获取value值对应的位置
+ */
+ protected abstract getValuePoint(value: number): Point;
+
+ /**
+ * 获取线条两端点及其方向向量
+ */
+ protected abstract getTerminals(): { startPos: Point; endPos: Point };
+
+ /**
+ * 获取不同位置的 label 的对齐方式和旋转角度
+ * @param labelVal {number} label的值
+ * @param tickAngle {number} label对应的刻度角度
+ * @param angle {number} label的旋转角度
+ */
+ protected abstract getLabelLayout(labelVal: number, tickAngle: number, angle: number): ShapeAttrs;
+
+ /**
+ * 获得带状态样式
+ */
+ protected getStyle(name: string | string[], state: State = 'default') {
+ return getStateStyle(get(this.attributes, name), state);
+ }
+
+ private initShape() {
+ // 初始化group
+ // 标题
+ this.titleShape = new Text({
+ name: 'title',
+ style: {
+ text: AxisBase.defaultOptions.style.title.content,
+ },
+ });
+ this.appendChild(this.titleShape);
+ /** ------------轴线分组-------------------- */
+ this.axisLineShape = new Group({
+ name: 'axis',
+ });
+ this.appendChild(this.axisLineShape);
+ // 轴线
+ const axisLine = new Path({
+ name: 'line',
+ style: {
+ path: [],
+ },
+ });
+ this.axisLineShape.appendChild(axisLine);
+ // start arrow
+ const startArrow = new Marker({
+ name: 'arrow',
+ style: {
+ ...NULL_ARROW,
+ identity: 'start',
+ },
+ });
+ // end arrow
+ const endArrow = new Marker({
+ name: 'arrow',
+ style: {
+ ...NULL_ARROW,
+ identity: 'end',
+ },
+ });
+ this.axisLineShape.appendChild(startArrow);
+ this.axisLineShape.appendChild(endArrow);
+
+ /** ------------刻度分组-------------------- */
+ this.tickLinesGroup = new Group({
+ name: 'tickLinesGroup',
+ });
+ this.appendChild(this.tickLinesGroup);
+ // 子刻度分组
+ this.subTickLinesGroup = new Group({
+ name: 'subTickLinesGroup',
+ });
+ this.appendChild(this.subTickLinesGroup);
+ // 标题分组
+ this.labelsGroup = new Group({
+ name: 'labelsGroup',
+ });
+ this.appendChild(this.labelsGroup);
+ }
+
+ /**
+ * 获得title属性
+ */
+ private getTitleCfg(): TextProps {
+ const { title } = this.attributes;
+ if (!title) return NULL_TEXT;
+ const {
+ content,
+ style,
+ position,
+ offset: [ox, oy],
+ rotate,
+ } = title as Required;
+ // 根据 position 确定位置和对齐方式
+ let titleVal: number;
+ const alignAttrs = {
+ textAlign: 'center' as 'center' | 'start' | 'end',
+ textBaseline: 'middle' as 'middle',
+ };
+
+ if (position === 'start') {
+ alignAttrs.textAlign = 'start';
+ titleVal = 0;
+ } else if (position === 'center') {
+ alignAttrs.textAlign = 'center';
+ titleVal = 0.5;
+ } else {
+ // position === 'end'
+ alignAttrs.textAlign = 'end';
+ titleVal = 1;
+ }
+
+ // 获取title
+ const [x, y] = this.getValuePoint(titleVal);
+ return {
+ x: x + ox,
+ y: y + oy,
+ text: content,
+ fillOpacity: 1,
+ ...alignAttrs,
+ ...style,
+ rotate: rotate !== undefined ? rotate : getVectorsAngle([1, 0], this.getTangentVector(titleVal)),
+ };
+ }
+
+ /**
+ * 创建title
+ */
+ private updateTitleShape() {
+ const { rotate, ...style } = this.getTitleCfg();
+ this.titleShape.attr(style);
+ centerRotate(this.titleShape, rotate);
+ }
+
+ /**
+ * 获得轴线属性
+ */
+ private getAxisLineCfg(): IAxisLineCfg {
+ const { type, line } = this.attributes;
+ if (!line) {
+ // 返回空line
+ return {
+ style: {},
+ arrow: {
+ start: { ...NULL_ARROW, rotate: 0 },
+ end: { ...NULL_ARROW, rotate: 0 },
+ },
+ line: {
+ path: [],
+ },
+ };
+ }
+ const { style, arrow } = line as Required;
+ const { start, end } = arrow!;
+ const {
+ startPos: [x1, y1],
+ endPos: [x2, y2],
+ } = this.getTerminals();
+
+ const getArrowAngle = (val: number) => {
+ if (type === 'linear') return getVectorsAngle([1, 0], this.getTangentVector(val));
+ return getVectorsAngle([0, -1], this.getVerticalVector(val));
+ };
+
+ return {
+ style,
+ arrow: {
+ start: {
+ ...(!start ? NULL_ARROW : start),
+ x: x1,
+ y: y1,
+ rotate: getArrowAngle(0),
+ },
+ end: {
+ ...(!end ? NULL_ARROW : end),
+ x: x2,
+ y: y2,
+ rotate: getArrowAngle(1),
+ },
+ },
+ line: {
+ path: this.getAxisLinePath(),
+ },
+ };
+ }
+
+ private getAxisLine(subNode?: 'line' | 'start' | 'end') {
+ if (!subNode) return this.axisLineShape;
+ if (subNode === 'line') return this.axisLineShape.getElementsByName('line')[0]! as Path;
+ let arrow!: Marker;
+ (this.axisLineShape.getElementsByName('arrow')! as [Marker, Marker]).forEach((arw) => {
+ if (arw.attr('identity') === subNode) arrow = arw;
+ });
+ return arrow;
+ }
+
+ /**
+ * 更新轴线和箭头
+ */
+ private updateAxisLineShape() {
+ const { arrow, line, style: lineStyle } = this.getAxisLineCfg();
+ // 更新 line
+ (this.getAxisLine('line') as Path).attr({
+ ...line,
+ ...lineStyle,
+ fillOpacity: 0,
+ });
+
+ Object.entries(arrow).forEach(([key, { rotate: angle, ...style }]) => {
+ const arw = this.getAxisLine(key as 'start' | 'end') as Marker;
+ arw.update({
+ identity: key,
+ ...lineStyle,
+ ...style,
+ });
+ arw.setLocalEulerAngles(angle);
+ });
+ }
+
+ /**
+ * 获取对应的tick
+ */
+ private getTickLineShape(idx: number) {
+ return this.tickLinesGroup.getElementsByName('tickLine').filter((tickLine) => {
+ if (tickLine.attr('identity') === idx) return true;
+ return false;
+ })[0] as Path;
+ }
+
+ /** ------------------------------绘制刻度线与label------------------------------------------ */
+ /**
+ * 获取刻度数据
+ */
+ private getTicksData(): Required[] {
+ const { ticks, ticksThreshold } = this.attributes;
+ let ticksCopy = clone(ticks) as TickDatum[];
+ const len = ticksCopy.length;
+ sortBy(ticksCopy, (tick: TickDatum) => {
+ return tick.value;
+ });
+
+ if (isNumber(ticksThreshold) && ticksThreshold < len) {
+ // 对ticks进行采样
+ const page = Math.ceil(len / ticksThreshold!);
+ ticksCopy = ticksCopy.filter((tick: TickDatum, idx: number) => idx % page === 0);
+ }
+
+ // 完善字段
+ return ticksCopy.map((datum, idx) => {
+ const { value, text = undefined, state = 'default', id = String(idx) } = datum;
+ return {
+ id,
+ value,
+ state,
+ text: text === undefined ? String(value) : text,
+ } as Required;
+ });
+ }
+
+ /**
+ * 计算刻度起始位置
+ */
+ private calcTick(value: number, len: number, offset: number): [Point, Point] {
+ const [s1, s2] = this.getValuePoint(value);
+ const v = this.getVerticalVector(value);
+ const [v1, v2] = vec2.scale([0, 0], v, len);
+ // 偏移量
+ const [o1, o2] = vec2.scale([0, 0], v, offset);
+ return [
+ [s1 + o1, s2 + o2],
+ [s1 + v1 + o1, s2 + v2 + o2],
+ ];
+ }
+
+ /**
+ * 获得绘制刻度线的属性
+ */
+ private getTicksCfg(): ITicksCfg {
+ const { tickLine, subTickLine, label } = this.attributes;
+ const style = {
+ tickLines: [],
+ subTickLines: [],
+ labels: [],
+ // labelsCfg: label,
+ } as ITicksCfg;
+ const ticks = this.getTicksData();
+ // 不绘制刻度
+ if (!tickLine) {
+ return style;
+ }
+
+ const { length, offset, appendTick } = tickLine as Required;
+ if (appendTick) {
+ const { value, state, id } = ticks[ticks.length - 1];
+ if (value !== 1) {
+ ticks.push({
+ value: 1,
+ text: ' ',
+ state,
+ id: String(Number(id) + 1),
+ });
+ }
+ }
+ const isCreateSubTickLines = subTickLine && (subTickLine as Required).count >= 0;
+
+ ticks.forEach((tick: TickDatum, idx: number) => {
+ const nextTickValue = idx === ticks.length - 1 ? 1 : ticks[idx + 1].value;
+ const { value: currTickValue } = tick;
+ const [st, end] = this.calcTick(currTickValue, length, offset);
+ style.tickLines.push({
+ path: [
+ ['M', ...st],
+ ['L', ...end],
+ ],
+ ...this.getStyle(['tickLine', 'style']),
+ });
+ if (label) {
+ const {
+ formatter,
+ alignTick,
+ // TODO 暂时只支持平行于刻度方向的偏移量
+ offset: [, o2],
+ } = label as Required;
+ const labelVal = alignTick ? currTickValue : (currTickValue + nextTickValue) / 2;
+ const [st] = this.calcTick(labelVal, length, o2);
+ const formattedText = formatter(tick);
+ style.labels.push({
+ x: st[0],
+ y: st[1],
+ value: labelVal,
+ text: formattedText,
+ rawText: formattedText, // 缩略时保留原始文本
+ ...this.getStyle(['label', 'style']),
+ });
+ }
+ // 子刻度属性
+ if (isCreateSubTickLines && idx >= 0 && currTickValue < 1) {
+ // 子刻度只在两个刻度之间绘制
+ const { count, length, offset } = subTickLine as Required;
+ const subStep = (nextTickValue - currTickValue) / (count + 1);
+ for (let i = 1; i <= count; i += 1) {
+ const [st, end] = this.calcTick(currTickValue + i * subStep, length, offset);
+ style.subTickLines.push({
+ path: [
+ ['M', ...st],
+ ['L', ...end],
+ ],
+ ...this.getStyle(['subTickLine', 'style']),
+ });
+ }
+ }
+ });
+ return style;
+ }
+
+ /**
+ * 创建刻度线、子刻度线和label
+ */
+ private updateTicksShape() {
+ this.tickLinesGroup.removeChildren(true);
+ this.subTickLinesGroup.removeChildren(true);
+ this.labelsGroup.removeChildren(true);
+
+ const { tickLines, labels, subTickLines } = this.getTicksCfg();
+ // 刻度
+ tickLines.forEach((style, idx) => {
+ this.tickLinesGroup.appendChild(
+ new Path({
+ name: 'tickLine',
+ style: {
+ identity: idx,
+ ...style,
+ },
+ })
+ );
+ });
+ // label
+ labels.forEach(({ ...style }) => {
+ this.labelsGroup.appendChild(
+ new Text({
+ name: 'label',
+ style,
+ })
+ );
+ });
+
+ // 子刻度
+ subTickLines.forEach((style) => {
+ this.subTickLinesGroup.appendChild(
+ new Path({
+ name: 'subTickLine',
+ style,
+ })
+ );
+ });
+
+ this.adjustLabels();
+ }
+
+ private getLabels(): Text[] {
+ return this.labelsGroup.children;
+ }
+
+ /**
+ * 获得 label 配置项
+ * 前提是确保 label 不为false
+ */
+ private getLabelsCfg() {
+ const { label } = this.attributes;
+ return label as Required;
+ }
+
+ /** ------------------------------label 调整------------------------------------------ */
+
+ /**
+ * 对label应用各种策略
+ * 设置 label 的状态
+ * 旋转成功则返回对应度数
+ */
+ private adjustLabels(): void {
+ const { label } = this.attributes;
+ if (!label) return;
+ const {
+ rotate,
+ overlapOrder = [],
+ autoEllipsis,
+ autoHide,
+ autoRotate,
+ } = label as Pick;
+ if (typeof rotate === 'number') this.setLabelEulerAngles(rotate);
+
+ const doNothing = () => {};
+ const autoMap: {
+ [key in OverlapType]: Function;
+ } = {
+ autoHide: autoHide ? this.autoHideLabel : doNothing,
+ autoEllipsis: autoEllipsis ? this.autoEllipsisLabel : doNothing,
+ autoRotate: autoRotate ? this.autoRotateLabel : doNothing,
+ };
+ overlapOrder.forEach((item) => {
+ autoMap[item].call(this);
+ });
+ }
+
+ /** ------------------------------自动旋转相关------------------------------------------ */
+
+ /**
+ * 判断标签是否发生碰撞
+ */
+ private isLabelsOverlap() {
+ const { margin } = this.getLabelsCfg();
+ return isLabelsOverlap(this.getLabels(), margin);
+ }
+
+ /**
+ * 自动旋转
+ * 返回最终旋转的角度
+ */
+ private autoRotateLabel(): number {
+ const {
+ rotate,
+ rotateRange: [min, max],
+ rotateStep: step,
+ } = this.getLabelsCfg();
+ if (rotate !== undefined) {
+ return rotate;
+ }
+ const prevAngles = this.getLabelEulerAngles();
+ for (let angle = min; angle < max; angle += step) {
+ this.setLabelEulerAngles(angle);
+ // 判断 label 是否发生碰撞
+ if (!this.isLabelsOverlap()) {
+ return angle;
+ }
+ }
+ // 未能通过旋转避免重叠
+ // 重置rotate
+ this.setLabelEulerAngles(prevAngles);
+ return prevAngles;
+ }
+
+ /** ------------------------------自动隐藏相关------------------------------------------ */
+
+ /**
+ * 自动隐藏
+ */
+ private autoHideLabel() {
+ const { margin, autoHideTickLine } = this.getLabelsCfg();
+ const labels = this.getLabels();
+ // 确定采样频率
+ let seq = 1;
+
+ while (seq < labels.length - 2) {
+ if (
+ !isLabelsOverlap(
+ // eslint-disable-next-line no-loop-func
+ labels.filter((label, idx) => {
+ if (idx % seq === 0) {
+ return true;
+ }
+ return false;
+ }),
+ margin
+ )
+ ) {
+ break;
+ }
+ seq += 1;
+ }
+ // 根据seq进行hide show
+ labels.forEach((label, idx) => {
+ if (idx === 0 || idx === labels.length || idx % seq === 0) {
+ label.show();
+ this.getTickLineShape(idx).show();
+ } else {
+ label.hide();
+ if (autoHideTickLine) this.getTickLineShape(idx).hide();
+ }
+ });
+ }
+
+ /** ------------------------------自动缩略相关------------------------------------------ */
+ /**
+ * 获取 缩短、缩写 label 的策略
+ * @param text {String} 当前要缩略的label
+ * @param width {number} 限制的宽度
+ * @param idx {number} label 索引
+ */
+ public getLabelEllipsisStrategy(width: number) {
+ const { type } = this.getLabelsCfg();
+ if (type === 'text') {
+ const font = this.getLabelFont();
+ return (...args: [string, number]) => getEllipsisText(args[0], width, font);
+ }
+ if (type === 'number') {
+ return this.getNumberSimplifyStrategy(width);
+ }
+ if (type === 'time') {
+ // 时间缩略
+ return this.getTimeSimplifyStrategy(width);
+ }
+ // 默认策略,不做任何处理
+ return (...args: [string, number]) => args[0];
+ }
+
+ /**
+ * 取在label属性
+ */
+ private getLabelFont() {
+ return pick(
+ this.getLabels()[0]?.attr(),
+ ['fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'fontVariant'] || []
+ );
+ }
+
+ /**
+ * 获得文本以label字体下的宽度
+ */
+ private getTextWidthByLabelFont(text: string) {
+ return measureTextWidth(text, this.getLabelFont());
+ }
+
+ /**
+ * 宽度为 width 时采取何种数字缩写缩略
+ */
+ private getNumberSimplifyStrategy(width: number) {
+ // 确定最长的数字使用的计数方法
+ // 其余数字都采用该方法
+ const { labels } = this.getTicksCfg();
+ const num = Number(maxBy(labels, ({ text }) => text.length).text);
+ const font = this.getLabelFont();
+ /**
+ * 输入: 100000000, 宽度x
+ * 1. 原始数值 100,000,000
+ * 2. K表达 100,000K
+ * 3. 科学计数 1e+8
+ */
+ let result = toThousands(num);
+ if (measureTextWidth(result, font) <= width) {
+ return (...args: [string, number]) => toThousands(Number(args[0]));
+ }
+ result = toKNotation(num);
+ if (measureTextWidth(result, font) <= width) {
+ return (...args: [string, number]) => toKNotation(Number(args[0]), 1);
+ }
+ // 如果都不行,只能用科学计数法了
+ return (...args: [string, number]) => toScientificNotation(Number(args[0]));
+ }
+
+ /**
+ * 时间缩略
+ */
+ private getTimeSimplifyStrategy(width: number) {
+ const ticks = this.getTicksData();
+ const { text: startTime } = minBy(ticks, ({ text }) => new Date(text).getTime());
+ const { text: endTime } = maxBy(ticks, ({ text }) => new Date(text).getTime());
+ const scale = getTimeScale(startTime, endTime);
+ /**
+ * 以下定义了时间显示的规则
+ * keyTimeMap 为关键节点的时间显示,第一个时间、每scale时间,关键节点不受width限制,但最小单位与非关键节点一致
+ * 例如2021-01-01 - 2022-12-31 中的关键时间节点为2021-01-01, 2022-01-01
+ * commonTimeMap 为非关键节点的显示,在空间充足的情况下会优先显示信息更多(靠前)的选项
+ * 如在空间充足的情况下,2021-01-01 - 2022-12-31 会显示为:2021-01-01 2021-01-02 ... 形势
+ * 空间略微不足时:2021-01-01 01-02 01-03 ... 2022-01-01 01-02 ...
+ * 空间较为不足时:2021-01 02 ... 2022-01 02 ...
+ */
+
+ const baseTime = new Date('1970-01-01 00:00:00');
+ const font = this.getLabelFont();
+
+ /**
+ * 非关键节点mask
+ */
+ let commonTimeMask!: [TimeScale, TimeScale];
+ for (let idx = 0; idx < COMMON_TIME_MAP[scale].length; idx += 1) {
+ const scheme = COMMON_TIME_MAP[scale][idx] as [TimeScale, TimeScale];
+ if (measureTextWidth(formatTime(baseTime, getMask(scheme)), font) < width) {
+ commonTimeMask = scheme;
+ break;
+ }
+ // 最后一个是备选方案
+ commonTimeMask = scheme;
+ }
+
+ let keyTimeMask: [TimeScale, TimeScale];
+ // 选择关键节点mask
+ const [, minUnit] = commonTimeMask;
+ for (let idx = 0; idx < timeScale.length; idx += 1) {
+ if (timeScale.indexOf(minUnit) >= idx) {
+ const scheme = [timeScale[0], minUnit] as [TimeScale, TimeScale];
+ if (measureTextWidth(formatTime(baseTime, getMask(scheme)), font) < width) {
+ keyTimeMask = scheme;
+ break;
+ }
+ keyTimeMask = scheme;
+ }
+ }
+
+ return (...args: [string, number]) => {
+ const text = args[0];
+ const idx = args[1];
+ let prevText = text;
+ if (idx !== 0) prevText = ticks[idx - 1].text;
+ let mask = commonTimeMask;
+ if (idx === 0 || getTimeStart(new Date(prevText), scale) !== getTimeStart(new Date(text), scale))
+ mask = keyTimeMask;
+ return formatTime(new Date(text), getMask(mask));
+ };
+ }
+
+ /**
+ * 对label应用各种策略
+ * 设置 label 的状态
+ * 旋转成功则返回对应度数
+ */
+
+ /**
+ * 缩略 labels 到指定长度内
+ */
+ private labelsEllipsis(width: number) {
+ const strategy = this.getLabelEllipsisStrategy(width);
+ this.getLabels().forEach((label, idx) => {
+ const rawText = label.attr('rawText');
+ label.attr('text', strategy.call(this, rawText, idx));
+ });
+ }
+
+ private parseLength(length: string | number) {
+ return isString(length) ? this.getTextWidthByLabelFont(length) : length;
+ }
+
+ private autoEllipsisLabel() {
+ const { ellipsisStep, minLength, maxLength, margin } = this.getLabelsCfg();
+ const labels = this.getLabels();
+ const step = this.parseLength(ellipsisStep);
+ const max = this.parseLength(maxLength);
+ // 不限制长度
+ if (max === Infinity) return Infinity;
+ const min = this.parseLength(minLength);
+ for (let allowedLength = max; allowedLength > min; allowedLength -= step) {
+ // 缩短文本
+ this.labelsEllipsis(allowedLength);
+ // 碰撞检测
+ if (!isLabelsOverlap(labels, margin)) {
+ return allowedLength;
+ }
+ }
+ // 缩短失败,使用minLength作为最终长度
+ return min;
+ }
+}
diff --git a/src/ui/axis/constant.ts b/src/ui/axis/constant.ts
new file mode 100644
index 000000000..3e6c8ba31
--- /dev/null
+++ b/src/ui/axis/constant.ts
@@ -0,0 +1,154 @@
+import { deepMix } from '@antv/util';
+import type { TickDatum } from './types';
+
+export const AXIS_BASE_DEFAULT_OPTIONS = {
+ style: {
+ title: {
+ content: '',
+ style: {
+ fill: 'black',
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ position: 'start',
+ offset: [0, 0],
+ rotate: undefined,
+ },
+ line: {
+ style: {
+ fill: 'black',
+ stroke: 'black',
+ lineWidth: 2,
+ },
+ arrow: {
+ start: {
+ symbol: 'axis-arrow',
+ size: 0,
+ },
+ end: {
+ symbol: 'axis-arrow',
+ size: 0,
+ },
+ },
+ },
+ ticks: [],
+ ticksThreshold: 400,
+ tickLine: {
+ length: 10,
+ style: {
+ default: {
+ stroke: 'black',
+ lineWidth: 2,
+ },
+ },
+ offset: 0,
+ appendTick: false,
+ },
+ label: {
+ type: 'text',
+ style: {
+ default: {
+ fill: 'black',
+ textAlign: 'center',
+ textBaseline: 'middle',
+ },
+ },
+ alignTick: true,
+ formatter: (tick: Required) => tick?.text || String(tick?.value || ''),
+ offset: [0, 0],
+ overlapOrder: ['autoRotate', 'autoEllipsis', 'autoHide'],
+ margin: [1, 1, 1, 1],
+ autoRotate: true,
+ rotateRange: [0, 90],
+ rotateStep: 5,
+ autoHide: true,
+ autoHideTickLine: true,
+ minLabel: 0,
+ autoEllipsis: true,
+ ellipsisStep: ' ',
+ minLength: 10,
+ maxLength: Infinity,
+ },
+ subTickLine: {
+ length: 6,
+ count: 9,
+ style: {
+ default: {
+ stroke: 'black',
+ lineWidth: 2,
+ },
+ },
+ offset: 0,
+ },
+ verticalFactor: 1,
+ },
+};
+
+export const LINEAR_DEFAULT_OPTIONS = deepMix({}, AXIS_BASE_DEFAULT_OPTIONS, {
+ style: {
+ type: 'linear',
+ },
+});
+
+export const ARC_DEFAULT_OPTIONS = deepMix({}, AXIS_BASE_DEFAULT_OPTIONS, {
+ style: {
+ type: 'arc',
+ startAngle: 0,
+ endAngle: 360,
+ center: [0, 0],
+ label: {
+ ...LINEAR_DEFAULT_OPTIONS.style.label,
+ align: 'normal',
+ },
+ },
+});
+
+export const HELIX_DEFAULT_OPTIONS = deepMix({}, AXIS_BASE_DEFAULT_OPTIONS, {
+ style: {},
+});
+
+/**
+ * 空箭头配置
+ */
+export const NULL_ARROW = {
+ symbol: 'circle',
+ size: 0,
+};
+
+/**
+ * 空文本配置
+ */
+export const NULL_TEXT = {
+ text: '',
+ fillOpacity: 0,
+};
+
+/**
+ * 非关键节点规则
+ */
+export const COMMON_TIME_MAP = {
+ year: [
+ ['year', 'second'], // YYYY-MM-DD hh:mm:ss
+ ['year', 'day'], // YYYY-MM-DD
+ ['month', 'day'], // MM-DD
+ ['month', 'month'], // MM
+ ],
+ month: [
+ ['month', 'day'], // MM-DD
+ ['day', 'day'], // MM
+ ],
+ day: [
+ ['month', 'day'], // MM-DD
+ ['day', 'day'], // DD
+ ],
+ hour: [
+ ['hour', 'second'], // hh:mm:ss
+ ['hour', 'minute'], // hh:mm
+ ['hour', 'hour'], // hh
+ ],
+ minute: [
+ ['minute', 'second'], // mm:ss
+ ['second', 'second'], // ss
+ ],
+ second: [['second', 'second']],
+} as const;
diff --git a/src/ui/axis/helix.ts b/src/ui/axis/helix.ts
new file mode 100644
index 000000000..84918a176
--- /dev/null
+++ b/src/ui/axis/helix.ts
@@ -0,0 +1 @@
+export class Helix {}
diff --git a/src/ui/axis/index.ts b/src/ui/axis/index.ts
index 9fb0a3c24..6af95aab8 100644
--- a/src/ui/axis/index.ts
+++ b/src/ui/axis/index.ts
@@ -1,22 +1,16 @@
-import { GUI } from '../core/gui';
-import type { AxisOptions } from './types';
+import type {
+ AxisBaseCfg,
+ AxisBaseOptions,
+ LinearCfg,
+ LinearOptions,
+ ArcCfg,
+ ArcOptions,
+ HelixCfg,
+ HelixOptions,
+} from './types';
-export type { AxisOptions };
+export type { AxisBaseCfg, AxisBaseOptions, LinearCfg, LinearOptions, ArcCfg, ArcOptions, HelixCfg, HelixOptions };
-export class Axis extends GUI {
- attributeChangedCallback(name: string, value: any) {
- throw new Error('Method not implemented.');
- }
-
- public init() {
- throw new Error('Method not implemented.');
- }
-
- public update() {
- throw new Error('Method not implemented.');
- }
-
- public clear() {
- throw new Error('Method not implemented.');
- }
-}
+export { Linear } from './linear';
+export { Arc } from './arc';
+export { Helix } from './helix';
diff --git a/src/ui/axis/linear.ts b/src/ui/axis/linear.ts
new file mode 100644
index 000000000..0021dd35d
--- /dev/null
+++ b/src/ui/axis/linear.ts
@@ -0,0 +1,78 @@
+import { deepMix } from '@antv/util';
+import { vec2 } from '@antv/matrix-util';
+import type { PathCommand } from '@antv/g';
+import type { LinearCfg, LinearOptions, Point, Position } from './types';
+import { AxisBase } from './base';
+import { getVerticalVector } from './utils';
+import { LINEAR_DEFAULT_OPTIONS } from './constant';
+
+export class Linear extends AxisBase {
+ public static tag = 'linear';
+
+ protected static defaultOptions = {
+ type: Linear.tag,
+ ...LINEAR_DEFAULT_OPTIONS,
+ };
+
+ constructor(options: LinearOptions) {
+ super(deepMix({}, Linear.defaultOptions, options));
+ super.init();
+ }
+
+ protected getAxisLinePath() {
+ const {
+ startPos: [x1, y1],
+ endPos: [x2, y2],
+ } = this.getTerminals();
+ return [['M', x1, y1], ['L', x2, y2], ['Z']] as PathCommand[];
+ }
+
+ protected getTangentVector() {
+ const {
+ startPos: [x1, y1],
+ endPos: [x2, y2],
+ } = this.getTerminals();
+ return vec2.normalize([0, 0], [x2 - x1, y2 - y1]);
+ }
+
+ protected getVerticalVector(value: number) {
+ const { verticalFactor } = this.attributes;
+ const axisVector = this.getTangentVector();
+ return vec2.scale([0, 0], getVerticalVector(axisVector), verticalFactor);
+ }
+
+ protected getValuePoint(value: number) {
+ const {
+ startPos: [x1, y1],
+ endPos: [x2, y2],
+ } = this.getTerminals();
+ // const ticks = this.getTicks();
+ // const range = ticks[ticks.length - 1].value - ticks[0].value;
+ // range 设定为0-1
+ const range = 1;
+ const ratio = value / range;
+ return [ratio * (x2 - x1) + x1, ratio * (y2 - y1) + y1] as Point;
+ }
+
+ protected getTerminals() {
+ const { startPos, endPos } = this.attributes;
+ return { startPos, endPos };
+ }
+
+ protected getLabelLayout(labelVal: number, tickAngle: number, angle: number) {
+ const { verticalFactor } = this.attributes;
+ const precision = 1;
+ const sign = verticalFactor === 1 ? 0 : 1;
+ let rotate = angle;
+ let textAlign = 'center' as Position;
+ if (angle > 90) rotate = (rotate - 180) % 360;
+ else if (angle < -90) rotate = (rotate + 180) % 360;
+ // 由于精度问题, 取 -precision precision
+ if (rotate < -precision) textAlign = ['end', 'start'][sign] as Position;
+ else if (rotate > precision) textAlign = ['start', 'end'][sign] as Position;
+ return {
+ rotate,
+ textAlign,
+ };
+ }
+}
diff --git a/src/ui/axis/overlap/is-overlap.ts b/src/ui/axis/overlap/is-overlap.ts
new file mode 100644
index 000000000..e41e4158c
--- /dev/null
+++ b/src/ui/axis/overlap/is-overlap.ts
@@ -0,0 +1,160 @@
+import type { DisplayObject } from '@antv/g';
+import { Text } from '@antv/g';
+import { getBoundsCenter } from '../utils';
+
+const { abs, atan2, cos, PI, sin, sqrt } = Math;
+
+type Vector = [number, number];
+type Margin = [number, number, number, number];
+type CollisionRectOptions = {
+ // 中心
+ center: [number, number];
+ width: number;
+ height: number;
+ // 旋转的角度
+ angle: number;
+};
+
+/**
+ * 基于分离轴定律(SAT)进行碰撞检测
+ * TODO 目前只能应用于中心旋转
+ */
+class CollisionRect {
+ public axisX!: Vector;
+
+ public axisY!: Vector;
+
+ private halfWidth: number;
+
+ private halfHeight: number;
+
+ private centerPoint: [number, number];
+
+ constructor(options: CollisionRectOptions) {
+ const { center, height, width, angle } = options;
+ this.centerPoint = center;
+ this.halfHeight = height / 2;
+ this.halfWidth = width / 2;
+ this.setRotation(angle);
+ }
+
+ public getProjectionRadius(axis: Vector) {
+ // 计算半径投影
+ const projectionAxisX = this.dot(axis, this.axisX);
+ const projectionAxisY = this.dot(axis, this.axisY);
+ return this.halfWidth * projectionAxisX + this.halfHeight * projectionAxisY;
+ }
+
+ public dot(vectorA: Vector, vectorB: Vector) {
+ // 向量点积
+ return abs(vectorA[0] * vectorB[0] + vectorA[1] * vectorB[1]);
+ }
+
+ public setRotation(angle: number) {
+ // 计算两个检测轴的单位向量
+ const deg = (angle / 180) * PI;
+ this.axisX = [cos(deg), -sin(deg)];
+ this.axisY = [sin(deg), cos(deg)];
+ return this;
+ }
+
+ public isCollision(check: CollisionRect) {
+ const centerDistanceVector = [
+ this.centerPoint[0] - check.centerPoint[0],
+ this.centerPoint[1] - check.centerPoint[1],
+ ] as Vector;
+
+ const axes = [
+ // 两个矩形一共4条检测轴
+ this.axisX,
+ this.axisY,
+ check.axisX,
+ check.axisY,
+ ];
+ for (let i = 0, len = axes.length; i < len; i += 1) {
+ if (
+ this.getProjectionRadius(axes[i]) + check.getProjectionRadius(axes[i]) <=
+ this.dot(centerDistanceVector, axes[i])
+ ) {
+ return false; // 任意一条轴没碰上,就是没碰撞
+ }
+ }
+
+ return true;
+ }
+
+ public getBounds() {
+ return {
+ width: this.halfWidth * 2,
+ height: this.halfHeight * 2,
+ center: this.centerPoint,
+ };
+ }
+}
+
+/**
+ * 获得图形在水平状态下的尺寸位置
+ */
+export function getHorizontalShape(shape: DisplayObject) {
+ const currEulerAngles = shape.getEulerAngles();
+ shape.setEulerAngles(0);
+ const bound = shape.getBoundingClientRect();
+ shape.setEulerAngles(currEulerAngles);
+ return bound;
+}
+
+/**
+ * 获得 DisplayObject 的碰撞 Text
+ */
+export function getCollisionText(shape: Text, [top, right, bottom, left]: Margin) {
+ // TODO 目前需要确保文本的 textBaseline 为 middle
+ const [x, y] = getBoundsCenter(shape);
+
+ // 水平状态下文本的宽高
+ const { width, height } = getHorizontalShape(shape);
+ const [boxWidth, boxHeight] = [left + width + right, top + height + bottom];
+ // 加上边距的包围盒中心
+ const [boxX, boxY] = [x - width / 2 - left + boxWidth / 2, y - height / 2 - top + boxHeight / 2];
+ const angle = shape.getEulerAngles();
+ let [deltaX, deltaY] = [0, 0];
+ const [diffX, diffY] = [boxX - x, boxY - y];
+ if (angle === 0 || diffX === 0) {
+ [deltaX, deltaY] = [diffX, diffY];
+ } else {
+ const alpha = atan2(diffY, diffX) + (angle / 180) * PI;
+ // 文本中心到包围盒中心的距离
+ const distance = sqrt(diffX ** 2 + diffY ** 2);
+ // 计算包围盒绕 x,y 旋转后的中心位置
+ // boxX - x = 0 时,会导致alpha为null
+ [deltaX, deltaY] = [distance * cos(alpha), distance * sin(alpha)];
+ }
+ return new CollisionRect({
+ angle,
+ center: [x + deltaX, y + deltaY],
+ width: boxWidth,
+ height: boxHeight,
+ });
+}
+
+/**
+ * 判断两个 Text 是否重叠
+ */
+export function isTextOverlap(A: Text, B: Text, margin: Margin): boolean {
+ const collisionA = getCollisionText(A, margin);
+ const collisionB = getCollisionText(B, margin);
+ return collisionA.isCollision(collisionB);
+}
+
+/**
+ * 判断 labels 是否发生重叠
+ */
+export function isLabelsOverlap(labels: Text[], margin: Margin): boolean {
+ for (let index = 1; index < labels.length; index += 1) {
+ const prevLabel = labels[index - 1];
+ const currLabel = labels[index];
+ if (isTextOverlap(prevLabel, currLabel, margin)) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/src/ui/axis/types.ts b/src/ui/axis/types.ts
index 967363e11..c6f3e680e 100644
--- a/src/ui/axis/types.ts
+++ b/src/ui/axis/types.ts
@@ -1 +1,138 @@
-export type AxisOptions = {};
+import type { MarkerCfg } from '../marker';
+import type { MixAttrs, DisplayObjectConfig, LineProps, ShapeAttrs, StyleState as State, TextProps } from '../../types';
+
+export type LabelType = 'text' | 'number' | 'time';
+export type Position = 'start' | 'center' | 'end';
+export type AxisType = 'linear' | 'arc' | 'helix';
+export type OverlapType = 'autoRotate' | 'autoEllipsis' | 'autoHide';
+
+export type TickDatum = {
+ value: number;
+ text?: string;
+ state?: State;
+ id?: string;
+};
+
+export type AxisTitleCfg = {
+ content?: string;
+ style?: ShapeAttrs;
+ // 标题位置
+ position?: Position;
+ // 标题偏移量,分别为平行与轴线方向和垂直于轴线方向的偏移量
+ offset?: [number, number];
+ // 旋转角度,值为0时,标题平行于轴线方向
+ rotate?: number;
+};
+
+export type AxisLineCfg = {
+ style?: ShapeAttrs;
+ arrow?: {
+ start?: false | MarkerCfg;
+ end?: false | MarkerCfg;
+ };
+};
+
+export type AxisTickLineCfg = {
+ // 刻度线长度
+ length?: number;
+ style?: MixAttrs>;
+ // 刻度线在其方向上的偏移量
+ offset?: number;
+ // 末尾追加tick,一般用于label alignTick 为 false 的情况
+ appendTick?: boolean;
+};
+
+export type AxisSubTickLineCfg = {
+ // 刻度线长度
+ length?: number;
+ // 两个刻度之间的子刻度数
+ count?: number;
+ style?: MixAttrs>;
+ // 偏移量
+ offset?: number;
+};
+
+export type AxisLabelCfg = {
+ type?: LabelType;
+ style?: MixAttrs>;
+ // label是否与Tick对齐
+ alignTick?: boolean;
+ // 标签文本与轴线的对齐方式,normal-水平,tangential-切向 radial-径向
+ align?: 'normal' | 'tangential' | 'radial';
+ formatter?: (tick: TickDatum) => string;
+ offset?: [number, number];
+ // 处理label重叠的优先级
+ overlapOrder?: OverlapType[];
+ // 标签外边距,在进行自动避免重叠时的额外间隔
+ margin?: [number, number, number, number];
+ // 自动旋转
+ autoRotate?: boolean;
+ // 自动旋转范围
+ // 自动旋转时,将会尝试从 min 旋转到 max
+ rotateRange?: [number, number];
+ // 旋转更新步长
+ rotateStep?: number;
+ // 手动指定旋转角度
+ rotate?: number;
+ // 自动隐藏
+ autoHide?: boolean;
+ // 隐藏 label 时,同时隐藏掉其对应的 tickLine
+ autoHideTickLine?: boolean;
+ // 最小显示 label 数量
+ minLabel?: number;
+ // 自动缩略
+ autoEllipsis?: boolean;
+ // 缩略步长,字符串长度或数值长度
+ ellipsisStep?: string | number;
+ // 最小缩略长度
+ minLength?: string | number;
+ // 单个 label 的最大长度,如果是字符串,则计算其长度
+ maxLength?: string | number;
+};
+
+export type AxisBaseCfg = {
+ type?: AxisType;
+ // 标题
+ title?: false | AxisTitleCfg;
+ // 轴线
+ line?: false | AxisLineCfg;
+ // 刻度数据
+ ticks?: TickDatum[];
+ // 刻度数量阈值,超过则进行重新采样
+ ticksThreshold?: false | number;
+ // 刻度线
+ tickLine?: false | AxisTickLineCfg;
+ // 刻度文本
+ label?: false | AxisLabelCfg;
+ // 子刻度线
+ subTickLine?: false | AxisSubTickLineCfg;
+ // label 和 tick 在轴线向量的位置,-1: 向量右侧, 1: 向量左侧
+ verticalFactor?: -1 | 1;
+};
+
+export type AxisBaseOptions = DisplayObjectConfig;
+
+export type Point = [number, number];
+
+export type LinearCfg = AxisBaseCfg & {
+ startPos: Point;
+ endPos: Point;
+};
+export type LinearOptions = DisplayObjectConfig;
+
+export type ArcCfg = AxisBaseCfg & {
+ startAngle?: number;
+ endAngle?: number;
+ radius: number;
+ center: Point;
+};
+export type ArcOptions = DisplayObjectConfig;
+
+export type HelixCfg = AxisBaseCfg & {
+ a?: number;
+ b?: number;
+ startAngle?: number;
+ endAngle?: number;
+ precision?: number;
+};
+export type HelixOptions = DisplayObjectConfig;
diff --git a/src/ui/axis/utils.ts b/src/ui/axis/utils.ts
new file mode 100644
index 000000000..90cc13749
--- /dev/null
+++ b/src/ui/axis/utils.ts
@@ -0,0 +1,77 @@
+import { vec2 } from '@antv/matrix-util';
+import type { DisplayObject } from '@antv/g';
+import type { vec2 as Vector } from '@antv/matrix-util';
+import type { Point } from './types';
+
+const { abs, acos, PI, sqrt } = Math;
+
+/**
+ * 计算2范数
+ */
+export function norm2([x1, y1]: Vector) {
+ return sqrt(x1 ** 2 + y1 ** 2);
+}
+
+/**
+ * 获得给定向量的垂直向量
+ */
+export function getVerticalVector([x, y]: Vector, factor = 1): Vector {
+ if (x === 0 && y === 0) return [x, y];
+ let vec: Vector;
+ const f = (v: number) => {
+ const a = abs(v);
+ if (factor === 1) return a;
+ return -a;
+ };
+ if (x === 0 || y === 0) vec = [f(y), f(x)];
+ else vec = [1, -(x / y)];
+ return vec2.normalize([0, 0], vec);
+}
+
+/**
+ * 计算向量v1到v2的夹角
+ */
+export function getVectorsAngle([x1, y1]: Vector, [x2, y2]: Vector): number {
+ const sign = x1 * y2 - y1 * x2;
+ const degree = (acos((x1 * x2 + y1 * y2) / (norm2([x1, y1]) * norm2([x2, y2]))) / PI) * 180;
+ return sign > 0 ? degree : -degree;
+}
+
+/**
+ * 获取包围盒中心
+ */
+export function getBoundsCenter(shape: DisplayObject): Point {
+ const bounds = shape.getBounds()!;
+ const [[x1, y1], [x2, y2]] = [bounds.getMin(), bounds.getMax()];
+ return [(x1 + x2) / 2, (y1 + y2) / 2];
+}
+
+/**
+ * 对shape进行中心旋转
+ * 适用于 origin 与包围盒中心不重叠的情况
+ * 主要用于 标题 旋转
+ */
+export function centerRotate(shape: DisplayObject, angle: number) {
+ const currX = shape.attr('x');
+ const currY = shape.attr('y');
+ // 记录旋转前位置
+ const [beforeX, beforeY] = getBoundsCenter(shape);
+ // 旋转
+ shape.setLocalEulerAngles(angle);
+ // 旋转后位置
+ const [afterX, afterY] = getBoundsCenter(shape);
+ // 重新调整位置
+ shape.attr({
+ x: currX + beforeX - afterX,
+ y: currY + beforeY - afterY,
+ });
+}
+
+export function formatAngle(angle: number) {
+ let formatAngle = angle;
+ if (formatAngle < 0) {
+ formatAngle += Math.ceil(formatAngle / -360) * 360;
+ }
+ formatAngle %= 360;
+ return formatAngle;
+}
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 48689eadd..51086bdbd 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -28,8 +28,17 @@ export type { SliderOptions } from './slider';
export { Scrollbar } from './scrollbar';
export type { ScrollbarOptions, ScrollbarCfg } from './scrollbar';
// author by [Aarebecca](https://github.com/Aarebecca)
-export { Axis } from './axis';
-export type { AxisOptions } from './axis';
+export { Arc, Linear, Helix } from './axis';
+export type {
+ AxisBaseCfg,
+ AxisBaseOptions,
+ LinearCfg,
+ LinearOptions,
+ ArcCfg,
+ ArcOptions,
+ HelixCfg,
+ HelixOptions,
+} from './axis';
export { Sheet } from './sheet';
export type { SheetOptions } from './sheet';
export { Timeline } from './timeline';
diff --git a/src/util/index.ts b/src/util/index.ts
index 00f2d76d7..158f7eb76 100644
--- a/src/util/index.ts
+++ b/src/util/index.ts
@@ -5,3 +5,4 @@ export * from './padding';
export * from './style';
export * from './event';
export * from './shape';
+export * from './time';
diff --git a/src/util/number.ts b/src/util/number.ts
index f471f822a..08fe36092 100644
--- a/src/util/number.ts
+++ b/src/util/number.ts
@@ -3,5 +3,32 @@
*/
export function toPrecision(num: number, precision: number) {
const _ = 10 ** precision;
- return Number(Math.round(num * _).toFixed(0)) / _;
+ // eslint-disable-next-line
+ return ~~(num * _) / _;
+}
+
+/**
+ * 千分位
+ * 100000 -> 10,000
+ */
+export function toThousands(num: number) {
+ return num.toLocaleString();
+}
+
+/**
+ * 获得数字科学计数
+ * 1000000 = 1e6
+ */
+export function toScientificNotation(num: number) {
+ return num.toExponential();
+}
+
+/**
+ * 用k的方式表达
+ * 1234 -> 1K
+ * 12345 -> 12K
+ */
+export function toKNotation(num: number, precision: number = 0) {
+ if (Math.abs(num) < 1000) return String(num);
+ return `${toPrecision(num / 1000, precision).toLocaleString()}K`;
}
diff --git a/src/util/time.ts b/src/util/time.ts
new file mode 100644
index 000000000..7d4c704eb
--- /dev/null
+++ b/src/util/time.ts
@@ -0,0 +1,93 @@
+export const scale = ['year', 'month', 'day', 'hour', 'minute', 'second'] as const;
+const masks = ['YYYY', 'MM', 'DD', 'hh', 'mm', 'ss'];
+export type TimeScale = typeof scale[number];
+export function parseDate(date: Date | string) {
+ return date instanceof Date ? date : new Date(date);
+}
+
+/**
+ * 生成时间格式化
+ * @param maxUnit 最大时间单位
+ * @param minUnit 最小时间单位
+ */
+export function getMask([maxUnit, minUnit]: [TimeScale, TimeScale]) {
+ const startIndex = scale.indexOf(maxUnit);
+ const endIndex = scale.indexOf(minUnit);
+ let format = '';
+ for (let i = startIndex; i <= endIndex; i += 1) {
+ format += masks[i];
+ if (i < endIndex) {
+ let connect = '-';
+ if (i === 2) connect = ' ';
+ else if (i > 2) connect = ':';
+ format += connect;
+ }
+ }
+ return format;
+}
+
+/**
+ * 格式化时间
+ */
+export function formatTime(date: Date, mask: string) {
+ type TimeMapKeys = 'YYYY' | 'MM' | 'DD' | 'hh' | 'mm' | 'ss';
+ const timeMap: {
+ [keys in TimeMapKeys]: number;
+ } = {
+ YYYY: date.getFullYear(),
+ MM: date.getMonth() + 1,
+ DD: date.getDate(),
+ hh: date.getHours(),
+ mm: date.getMinutes(),
+ ss: date.getSeconds(),
+ };
+ let strftime = mask;
+ (Object.keys(timeMap) as TimeMapKeys[]).forEach((key) => {
+ const val = timeMap[key];
+ strftime = strftime.replace(key, key === 'YYYY' ? `${val}` : `0${val}`.slice(-2));
+ });
+ return strftime;
+}
+
+/**
+ * 获取两个时间的差值,单位毫秒
+ */
+export function getTimeDiff(a: Date | string, b: Date | string) {
+ return parseDate(a).getTime() - parseDate(b).getTime();
+}
+
+/**
+ * 获取时间跨度
+ */
+export function getTimeScale(a: Date | string, b: Date | string): TimeScale {
+ const [ma, mb] = [parseDate(a), parseDate(b)];
+ if (ma.getFullYear() !== mb.getFullYear()) return 'year';
+ if (ma.getMonth() !== mb.getMonth()) return 'month';
+ if (ma.getDay() !== mb.getDay()) return 'day';
+ if (ma.getHours() !== mb.getHours()) return 'hour';
+ if (ma.getMinutes() !== mb.getMinutes()) return 'minute';
+ return 'second';
+}
+
+/**
+ * 获取给定时间的开始时间
+ */
+export function getTimeStart(date: Date, scale: TimeScale) {
+ const result = new Date(date);
+ const timeMap = {
+ year: (d: Date) => {
+ d.setMonth(0);
+ d.setHours(0, 0, 0, 0);
+ },
+ month: (d: Date) => {
+ d.setDate(1);
+ d.setHours(0, 0, 0, 0);
+ },
+ day: (d: Date) => d.setHours(0, 0, 0, 0),
+ hour: (d: Date) => d.setMinutes(0, 0, 0),
+ minute: (d: Date) => d.setSeconds(0, 0),
+ second: (d: Date) => d.setMilliseconds(0),
+ };
+ timeMap[scale](result);
+ return formatTime(result, getMask(['year', scale]));
+}