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])); +}