feat: add basic shape (#5377)

* feat(utils): add parsePadding util

* feat(utils): add prefix utils

* feat: config dev env

* refactor(test): update test case context

* refactor(test): update layeredCanvas test case

* refactor(test): add background grid to help verifying

* feat(elements): add label shape

* refactor(tests): adjust integration test types definition

* test(label): add integraion of label

* test(animation): add animation test case
This commit is contained in:
Aaron 2024-01-26 15:58:55 +08:00 committed by GitHub
parent 4e11baeb51
commit 4be279b522
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1003 additions and 42 deletions

View File

@ -36,7 +36,7 @@ module.exports = {
'linebreak-style': ['error', 'unix'],
quotes: ['error', 'single', { allowTemplateLiterals: true, avoidEscape: true }],
semi: ['error', 'always'],
'@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }],
'jsdoc/require-param-type': 0,
'@typescript-eslint/no-this-alias': 'off',
@ -52,8 +52,6 @@ module.exports = {
'jsdoc/require-returns-type': 0,
'jsdoc/require-returns-description': 1,
'@typescript-eslint/no-unused-vars': 1,
// TODO: rules below are not recommended, and will be removed in the future
'@typescript-eslint/no-explicit-any': 1,
'@typescript-eslint/ban-types': 1,

View File

@ -0,0 +1 @@
export * from './label';

View File

@ -0,0 +1,34 @@
import { Label } from '../../../src/elements/shapes/label';
import type { AnimationTestCase } from '../types';
export const labelShape: AnimationTestCase = async (context) => {
const { canvas } = context;
const label = new Label({
style: {
text: 'label text',
fontSize: 20,
x: 50,
y: 50,
stroke: 'pink',
backgroundLineWidth: 2,
backgroundStroke: 'pink',
},
});
await canvas.init();
canvas.appendChild(label);
const result = label.animate(
[
{ x: 50, y: 50 },
{ x: 200, y: 200 },
],
{ duration: 1000, fill: 'both' },
);
return result;
};
labelShape.times = [0, 1000];

View File

@ -1 +1,2 @@
export * from './canvas';
export * from './label';
export * from './layered-canvas';

View File

@ -0,0 +1,22 @@
import { Label } from '../../../src/elements/shapes/label';
import type { StaticTestCase } from '../types';
export const labelShape: StaticTestCase = async (context) => {
const { canvas } = context;
const label = new Label({
style: {
text: 'label text',
fontSize: 20,
x: 50,
y: 50,
stroke: 'pink',
backgroundLineWidth: 2,
backgroundStroke: 'pink',
},
});
await canvas.init();
canvas.appendChild(label);
};

View File

@ -1,20 +1,8 @@
import { Circle, Rect } from '@antv/g';
import { Canvas } from '../../../src/runtime/canvas';
import type { TestCase } from '../types';
import type { StaticTestCase } from '../types';
export const layeredCanvas: TestCase = async (context) => {
const {
container,
width,
height,
renderer,
canvas = new Canvas({
width,
height,
container,
renderer,
}),
} = context;
export const layeredCanvas: StaticTestCase = async (context) => {
const { canvas } = context;
const circle = new Circle({
style: {

View File

@ -1,19 +1,39 @@
import type { IRenderer } from '@antv/g';
import type { IAnimation } from '@antv/g';
import type { Canvas } from '../../src/runtime/canvas';
import type { CanvasLayer } from '../../src/types/canvas';
type TestCaseContext = {
canvas?: Canvas;
container: HTMLElement;
renderer?: (layer: CanvasLayer) => IRenderer;
width: number;
height: number;
canvas: Canvas;
};
export type TestCase = {
export interface StaticTestCase extends BaseTestCase {
(context: TestCaseContext): Promise<void>;
}
export interface AnimationTestCase extends BaseTestCase {
(context: TestCaseContext): Promise<IAnimation>;
/**
* <zh/>
*
* <en/> Animation time to check
*/
times: number[];
}
export interface BaseTestCase {
only?: boolean;
skip?: boolean;
/**
* <zh/>
*
* <en/> Function to be executed before the test case is executed
* @returns
*/
preprocess?: () => Promise<void>;
/**
* <zh/>
*
* <en/> Function to be executed after the test case is executed
* @returns
*/
postprocess?: () => Promise<void>;
};
}

View File

@ -4,11 +4,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>G6: Preview</title>
<style>
#container {
width: 500px;
height: 500px;
background-image: linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
background-size: 25px 25px;
}
</style>
</head>
<body>
<body style="font-family: Arial, Helvetica, sans-serif">
<div id="app">
<div id="container" style="height: 500px; width: 100%"></div>
<label for="demo-select">Cases: </label>
<select id="demo-select" style="cursor: pointer; width: 180px"></select>
<label for="renderer-select">Renderer: </label>
<select id="renderer-select" style="cursor: pointer">
<option value="default" selected>Default</option>
<option value="canvas" selected>Canvas</option>
<option value="svg">SVG</option>
<option value="webgl">WebGL</option>
</select>
</div>
<script type="module" src="./main.ts"></script>
</body>

View File

@ -0,0 +1,39 @@
import * as staticCases from '../demo/animation';
import { createNodeGCanvas } from './utils/create-node-g-canvas';
import { getCases } from './utils/get-cases';
import { sleep } from './utils/sleep';
import './utils/use-snapshot-matchers';
describe('static', () => {
const cases = getCases(staticCases);
for (const [name, testCase] of cases) {
it(`[animation]: ${name}`, async () => {
const canvas = createNodeGCanvas();
try {
const { times = [], preprocess, postprocess } = testCase;
await preprocess?.();
const animationResult = await testCase({ canvas });
animationResult.pause();
for (const time of times) {
animationResult.currentTime = time;
await sleep(20);
await expect(canvas).toMatchSVGSnapshot(
`${__dirname}/snapshots/animation`,
// 命名示例label-1000(1_3)
// naming example: label-1000(1_3)
`${name}-${time}(${times.indexOf(time) + 1}_${times.length})`,
);
}
await postprocess?.();
} finally {
canvas.destroy();
await sleep(50);
}
});
}
});

View File

@ -0,0 +1,57 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="500"
height="500"
style="background: transparent; position: absolute; outline: none;"
color-interpolation-filters="sRGB"
tabindex="1"
>
<defs />
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
<g
id="g-root"
fill="none"
stroke="none"
visibility="visible"
font-size="16px"
font-family="sans-serif"
font-style="normal"
font-weight="normal"
font-variant="normal"
text-anchor="left"
stroke-dashoffset="0px"
transform="matrix(1,0,0,1,0,0)"
>
<g
id="g-svg-5"
fill="none"
stroke="rgba(255,192,203,1)"
font-size="20px"
transform="matrix(1,0,0,1,50,50)"
>
<g stroke-width="2px" transform="matrix(1,0,0,1,-5,-30)">
<path
id="background"
fill="none"
d="M 0,0 l 100.19999999999999,0 l 0,47 l-100.19999999999999 0 z"
stroke="rgba(255,192,203,1)"
width="100.19999999999999px"
height="47px"
/>
</g>
<g font-size="20px" transform="matrix(1,0,0,1,0,0)">
<text
id="text"
fill="none"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="0.5"
stroke="rgba(255,192,203,1)"
>
label text
</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,57 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="500"
height="500"
style="background: transparent; position: absolute; outline: none;"
color-interpolation-filters="sRGB"
tabindex="1"
>
<defs />
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
<g
id="g-root"
fill="none"
stroke="none"
visibility="visible"
font-size="16px"
font-family="sans-serif"
font-style="normal"
font-weight="normal"
font-variant="normal"
text-anchor="left"
stroke-dashoffset="0px"
transform="matrix(1,0,0,1,0,0)"
>
<g
id="g-svg-5"
fill="none"
stroke="rgba(255,192,203,1)"
font-size="20px"
transform="matrix(1,0,0,1,200,200)"
>
<g stroke-width="2px" transform="matrix(1,0,0,1,-5,-30)">
<path
id="background"
fill="none"
d="M 0,0 l 100.19999999999999,0 l 0,47 l-100.19999999999999 0 z"
stroke="rgba(255,192,203,1)"
width="100.19999999999999px"
height="47px"
/>
</g>
<g font-size="20px" transform="matrix(1,0,0,1,0,0)">
<text
id="text"
fill="none"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="0.5"
stroke="rgba(255,192,203,1)"
>
label text
</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,57 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="500"
height="500"
style="background: transparent; position: absolute; outline: none;"
color-interpolation-filters="sRGB"
tabindex="1"
>
<defs />
<g id="g-svg-camera" transform="matrix(1,0,0,1,0,0)">
<g
id="g-root"
fill="none"
stroke="none"
visibility="visible"
font-size="16px"
font-family="sans-serif"
font-style="normal"
font-weight="normal"
font-variant="normal"
text-anchor="left"
stroke-dashoffset="0px"
transform="matrix(1,0,0,1,0,0)"
>
<g
id="g-svg-5"
fill="none"
stroke="rgba(255,192,203,1)"
font-size="20px"
transform="matrix(1,0,0,1,50,50)"
>
<g stroke-width="2px" transform="matrix(1,0,0,1,-5,-30)">
<path
id="background"
fill="none"
d="M 0,0 l 100.19999999999999,0 l 0,47 l-100.19999999999999 0 z"
stroke="rgba(255,192,203,1)"
width="100.19999999999999px"
height="47px"
/>
</g>
<g font-size="20px" transform="matrix(1,0,0,1,0,0)">
<text
id="text"
fill="none"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="0.5"
stroke="rgba(255,192,203,1)"
>
label text
</text>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -13,17 +13,10 @@ describe('static', () => {
try {
const { preprocess, postprocess } = testCase;
await preprocess?.();
await testCase({
container: document.getElementById('container')!,
width: 500,
height: 500,
canvas,
});
await postprocess?.();
await testCase({ canvas });
await expect(canvas).toMatchSVGSnapshot(`${__dirname}/snapshots/static`, name);
await postprocess?.();
} finally {
canvas.destroy();
await sleep(50);

View File

@ -1,12 +1,12 @@
import type { TestCase } from '../../demo/types';
import type { BaseTestCase } from '../../demo/types';
export function getCases(module: Record<string, TestCase>) {
export function getCases<T extends BaseTestCase>(module: Record<string, T>) {
const cases = Object.entries(module);
const only = cases.filter(([, { only = false }]) => only);
if (only.length !== 0 && process.env.CI === 'true') throw new Error('Cannot run only tests in CI');
return (only.length !== 0 ? only : cases)
.filter(([, { skip = false }]) => !skip)
.map(([name, testCase]) => [kebabCase(name), testCase] as [string, TestCase]);
.map(([name, testCase]) => [kebabCase(name), testCase] as [string, T]);
}
const kebabCase = (str: string) => str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);

View File

@ -0,0 +1,100 @@
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Renderer as SVGRenderer } from '@antv/g-svg';
import { Renderer as WebGLRenderer } from '@antv/g-webgl';
import { Canvas } from '../src/runtime/canvas';
import * as animations from './demo/animation';
import * as statics from './demo/static';
type TestCase = (...args: unknown[]) => void;
const CASES = {
statics,
animations,
} as unknown as { [key: string]: Record<string, TestCase> };
window.onload = () => {
const casesSelect = document.getElementById('demo-select') as HTMLSelectElement;
const rendererSelect = document.getElementById('renderer-select') as HTMLSelectElement;
function handleChange() {
initialize();
const [type, testCase] = casesSelect.value.split('-');
const renderer = rendererSelect.value;
setParamsToSearch({ type, case: testCase, renderer });
onchange(CASES[type][testCase], renderer);
}
casesSelect.onchange = handleChange;
rendererSelect.onchange = handleChange;
loadCasesList(casesSelect);
getParamsFromSearch();
handleChange();
};
function loadCasesList(select: HTMLSelectElement) {
Object.entries(CASES).forEach(([type, cases]) => {
const optgroup = document.createElement('optgroup');
optgroup.label = type;
select.appendChild(optgroup);
Object.keys(cases).forEach((key) => {
const option = document.createElement('option');
option.value = `${type}-${key}`;
option.text = key;
optgroup.appendChild(option);
});
});
}
function onchange(testCase: TestCase, rendererName: string) {
const renderer = getRenderer(rendererName);
testCase({
canvas: new Canvas({
width: 500,
height: 500,
container: document.getElementById('container')!,
renderer,
}),
});
}
function getRenderer(rendererName: string) {
switch (rendererName) {
case 'webgl':
return () => new WebGLRenderer();
case 'svg':
return () => new SVGRenderer();
case 'canvas':
return () => new CanvasRenderer();
default:
return undefined;
}
}
function initialize() {
document.getElementById('container')?.remove();
const container = document.createElement('div');
container.id = 'container';
document.getElementById('app')?.appendChild(container);
}
function getParamsFromSearch() {
const searchParams = new URLSearchParams(window.location.search);
const type = searchParams.get('type') || 'statics';
const testCase = searchParams.get('case') || Object.keys(statics)[0];
const rendererName = searchParams.get('renderer') || 'canvas';
const casesSelect = document.getElementById('demo-select') as HTMLSelectElement;
const rendererSelect = document.getElementById('renderer-select') as HTMLSelectElement;
casesSelect.value = `${type}-${testCase}`;
rendererSelect.value = rendererName;
}
function setParamsToSearch(options: { type: string; case: string; renderer: string }) {
const { type, case: testCase, renderer } = options;
const searchParams = new URLSearchParams(window.location.search);
searchParams.set('type', type);
searchParams.set('case', testCase);
searchParams.set('renderer', renderer);
window.history.replaceState(null, '', `?${searchParams.toString()}`);
}

View File

@ -0,0 +1,31 @@
import type { IAnimation } from '@antv/g';
import { createAnimationsProxy } from '../../../src/utils/animation';
describe('animation', () => {
it('createAnimationsProxy', () => {
const sourcePause = jest.fn();
const targetPause = jest.fn();
const source = {
currentTime: 0,
pause: () => sourcePause(),
} as IAnimation;
const targets = [
{ currentTime: 0, pause: () => targetPause() },
{ currentTime: 0, pause: () => targetPause() },
] as IAnimation[];
const proxy = createAnimationsProxy(source, targets);
expect(proxy.currentTime).toBe(0);
proxy.currentTime = 100;
expect(source.currentTime).toBe(100);
expect(targets[0].currentTime).toBe(100);
expect(targets[1].currentTime).toBe(100);
proxy.pause();
expect(sourcePause).toHaveBeenCalledTimes(1);
expect(targetPause).toHaveBeenCalledTimes(2);
});
});

View File

@ -0,0 +1,15 @@
import { parsePadding } from '../../../src/utils/padding';
describe('padding', () => {
it('parsePadding', () => {
expect(parsePadding()).toEqual([0, 0, 0, 0]);
expect(parsePadding(10)).toEqual([10, 10, 10, 10]);
expect(parsePadding([10, 20])).toEqual([10, 20, 10, 20]);
expect(parsePadding([10, 20, 30])).toEqual([10, 20, 30, 20]);
expect(parsePadding([10, 20, 30, 40])).toEqual([10, 20, 30, 40]);
});
});

View File

@ -0,0 +1,71 @@
import {
addPrefix,
removePrefix,
replacePrefix,
startsWith,
subStyleProps,
superStyleProps,
toLowercaseFirstLetter,
toUppercaseFirstLetter,
} from '../../../src/utils/prefix';
describe('prefix', () => {
it('toUppercaseFirstLetter', () => {
expect(toUppercaseFirstLetter('abc')).toBe('Abc');
expect(toUppercaseFirstLetter('Abc')).toBe('Abc');
expect(toUppercaseFirstLetter('')).toBe('');
});
it('toLowercaseFirstLetter', () => {
expect(toLowercaseFirstLetter('abc')).toBe('abc');
expect(toLowercaseFirstLetter('Abc')).toBe('abc');
expect(toLowercaseFirstLetter('')).toBe('');
});
it('addPrefix', () => {
expect(addPrefix('abc', 'prefix')).toBe('prefixAbc');
expect(addPrefix('Abc', 'prefix')).toBe('prefixAbc');
expect(addPrefix('', 'prefix')).toBe('prefix');
});
it('removePrefix', () => {
expect(removePrefix('prefixAbc', 'prefix')).toBe('abc');
expect(removePrefix('prefixAbc', 'prefix', false)).toBe('Abc');
expect(removePrefix('Abc', 'prefix')).toBe('Abc');
expect(removePrefix('', 'prefix')).toBe('');
expect(removePrefix('prefix', '')).toBe('prefix');
expect(removePrefix('prefixAbc', '')).toBe('prefixAbc');
});
it('startsWith', () => {
expect(startsWith('prefixAbc', 'prefix')).toBe(true);
expect(startsWith('prefixAbc', 'prefix')).toBe(true);
expect(startsWith('Abc', 'prefix')).toBe(false);
expect(startsWith('', 'prefix')).toBe(false);
expect(startsWith('prefix', 'prefix')).toBe(false);
});
it('subStyleProps', () => {
expect(subStyleProps({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix')).toEqual({ abc: 1, def: 2 });
expect(subStyleProps({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix')).toEqual({ abc: 1, def: 2 });
expect(subStyleProps({ Abc: 1, Def: 2 }, 'prefix')).toEqual({});
expect(subStyleProps({}, 'prefix')).toEqual({});
});
it('superStyleProps', () => {
expect(superStyleProps({ abc: 1, def: 2 }, 'prefix')).toEqual({ prefixAbc: 1, prefixDef: 2 });
expect(superStyleProps({ abc: 1, def: 2 }, 'prefix')).toEqual({ prefixAbc: 1, prefixDef: 2 });
expect(superStyleProps({ Abc: 1, Def: 2 }, 'prefix')).toEqual({ prefixAbc: 1, prefixDef: 2 });
expect(superStyleProps({}, 'prefix')).toEqual({});
});
it('replacePrefix', () => {
expect(replacePrefix({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix', 'newPrefix')).toEqual({
newPrefixAbc: 1,
newPrefixDef: 2,
Abc: 3,
});
expect(replacePrefix({ Abc: 1, Def: 2 }, 'prefix', 'newPrefix')).toEqual({ Abc: 1, Def: 2 });
expect(replacePrefix({}, 'prefix', 'newPrefix')).toEqual({});
});
});

View File

@ -0,0 +1,186 @@
import type { DisplayObject, DisplayObjectConfig, Group, GroupStyleProps, IAnimation } from '@antv/g';
import { CustomElement } from '@antv/g';
import { deepMix, isBoolean, isNil } from '@antv/util';
import { createAnimationsProxy } from '../../utils/animation';
export interface BaseShapeStyleProps extends GroupStyleProps {}
export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomElement<T> {
constructor(options: DisplayObjectConfig<T>) {
super(options);
this.render(this.attributes as Required<T>, this);
this.bindEvents();
}
/**
* <zh>
*
* <en> shape instance map
*/
protected shapeMap: Record<string, DisplayObject> = {};
/**
* <zh>
*
* <en> animation instance map
*/
protected animateMap: Record<string, IAnimation> = {};
/**
* <zh>
*
* <en> create, update or remove shape
* @param key - <zh> | <en> shape name
* @param Ctor - <zh> | <en> shape type
* @param style - <zh> | <en> shape style
* @param container - <zh> | <en> container
* @returns <zh> | <en> shape instance
*/
protected upsert<P extends DisplayObject>(
key: string,
Ctor: { new (...args: unknown[]): P },
style: P['attributes'],
container: DisplayObject,
) {
const target = this.shapeMap[key];
// remove
// 如果 style 为 false则删除图形 / remove shape if style is false
if (target && isBoolean(style) && !style) {
container.removeChild(target);
delete this.shapeMap[key];
return;
}
// create
if (!target) {
const instance = new Ctor({ style });
instance.id = key;
container.appendChild(instance);
this.shapeMap[key] = instance;
return instance;
}
// update
// 如果图形实例存在 update 方法,则调用 update 方法,否则调用 attr 方法
// if shape instance has update method, call update method, otherwise call attr method
if ('update' in target) (target.update as (...args: unknown[]) => unknown)(style);
else target.attr(style);
return target;
}
/**
* <zh>
*
* <en> create animation proxy, synchronize animation to child shape instance
* @example
* ```ts
* const result = shape.animate(keyframes, options);
* const proxy = shape.proxyAnimate(result);
* ```
* @param result - <zh> | <en> main animation instance
* @returns <zh> | <en> animation proxy
*/
protected proxyAnimate(result: IAnimation) {
return createAnimationsProxy(result, Object.values(this.animateMap));
}
public update(attr: Partial<T> = {}): void {
this.attr(deepMix({}, this.attributes, attr));
return this.render(this.attributes as Required<T>, this);
}
/**
* <zh>
*
* <en> will be called automatically when initializing
* @param attributes
* @param container
*/
public abstract render(attributes: Required<T>, container: Group): void;
public bindEvents() {}
/**
* <zh/>
*
* <en/> Extracts the graphic style properties from a given attribute object.
* Removes specific properties like position, transformation, and class name.
* @param attributes - <zh/> | <en/> attribute object
* @returns <zh/> | <en/> An object containing only the style properties.
*/
public getGraphicStyle<T extends Record<string, any>>(
attributes: T,
): Omit<T, 'x' | 'y' | 'transform' | 'transformOrigin' | 'className'> {
const { x, y, className, transform, transformOrigin, ...style } = attributes;
return style;
}
public animate(
keyframes: PropertyIndexedKeyframes | Keyframe[],
options?: number | KeyframeAnimationOptions,
): IAnimation {
if (!keyframes) return null;
this.animateMap = {};
const result = super.animate(keyframes, options);
if (Array.isArray(keyframes) && keyframes.length > 0) {
Object.entries(this.shapeMap).forEach(([key, shape]) => {
// 如果存在方法名为 `get${key}Style` 的方法,则使用该方法获取样式,并自动为该图形实例创建动画
// if there is a method named `get${key}Style`, use this method to get style and automatically create animation for the shape instance
const methodName = `get${key[0].toUpperCase()}${key.slice(1)}Style`;
if (typeof this[methodName] === 'function') {
const subKeyframes: Keyframe[] = keyframes.map((style) => this[methodName](style));
// 转化为 PropertyIndexedKeyframes 格式方便后续处理
// convert to PropertyIndexedKeyframes format for subsequent processing
const propertyIndexedKeyframes = subKeyframes.reduce(
(acc, kf) => {
Object.entries(kf).forEach(([key, value]) => {
if (acc[key] === undefined) acc[key] = [value];
else acc[key].push(value);
});
return acc;
},
{} as Record<string, any[]>,
);
// 过滤掉无用动画的属性(属性值为 undefined、或者值完全一致
// filter out useless animation properties (property value is undefined, or value is exactly the same)
Object.entries(propertyIndexedKeyframes).forEach(([key, values]) => {
if (
// 属性值必须在每一帧都存在 / property value must exist in every frame
values.length !== subKeyframes.length ||
// 属性值不能为空 / property value cannot be empty
values.some((value) => isNil(value)) ||
// 属性值必须不完全一致 / property value must not be exactly the same
values.every((value) => value === values[0])
) {
delete propertyIndexedKeyframes[key];
}
});
this.animateMap[key] = shape.animate(
// 将 PropertyIndexedKeyframes 转化为 Keyframe 格式
// convert PropertyIndexedKeyframes to Keyframe format
Object.entries(propertyIndexedKeyframes).reduce((acc, [key, values]) => {
values.forEach((value, index) => {
if (!acc[index]) acc[index] = { [key]: value };
else acc[index][key] = value;
});
return acc;
}, [] as Keyframe[]),
options,
);
}
});
} else {
// TODO: support PropertyIndexedKeyframes
}
return this.proxyAnimate(result);
}
}

View File

@ -0,0 +1,81 @@
import { DisplayObjectConfig, Group, Rect, RectStyleProps, Text, TextStyleProps } from '@antv/g';
import { deepMix } from '@antv/util';
import type { Padding } from '../../types/padding';
import type { PrefixObject } from '../../types/prefix';
import { parsePadding } from '../../utils/padding';
import { omitStyleProps, startsWith, subStyleProps } from '../../utils/prefix';
import type { BaseShapeStyleProps } from './base-shape';
import { BaseShape } from './base-shape';
export type LabelStyleProps = BaseShapeStyleProps &
TextStyleProps &
PrefixObject<RectStyleProps, 'background'> & {
padding?: Padding;
};
type ParsedLabelStyleProps = Required<LabelStyleProps>;
export type LabelOptions = DisplayObjectConfig<LabelStyleProps>;
export class Label extends BaseShape<LabelStyleProps> {
static defaultStyleProps: Partial<LabelStyleProps> = {
padding: 5,
backgroundZIndex: -1,
};
constructor(options: LabelOptions) {
super(deepMix({}, { style: Label.defaultStyleProps }, options));
}
protected isTextStyle(key: string) {
return startsWith(key, 'label');
}
protected isBackgroundStyle(key: string) {
return startsWith(key, 'background');
}
protected getTextStyle(attributes: LabelStyleProps = this.attributes) {
const { padding, ...style } = this.getGraphicStyle(attributes);
return omitStyleProps<TextStyleProps>(style, 'background');
}
protected getBackgroundStyle(attributes: LabelStyleProps = this.attributes) {
const style = this.getGraphicStyle(attributes);
const { wordWrapWidth, padding } = style;
const backgroundStyle = subStyleProps<RectStyleProps>(style, 'background');
const {
min: [minX, minY],
halfExtents: [halfWidth, halfHeight],
} = this.shapeMap.text.getLocalBounds();
const [top, right, bottom, left] = parsePadding(padding);
Object.assign(backgroundStyle, {
x: minX - left,
y: minY - top,
width: (wordWrapWidth || halfWidth * 2) + left + right,
height: halfHeight * 2 + top + bottom,
});
// parse percentage radius
const { radius } = backgroundStyle;
// if radius look like '10%', convert it to number
if (typeof radius === 'string' && radius.endsWith('%')) {
const percentage = Number(radius.replace('%', '')) / 100;
backgroundStyle.radius = Math.min(+backgroundStyle.width, +backgroundStyle.height) * percentage;
}
return backgroundStyle;
}
public render(attributes = this.attributes as ParsedLabelStyleProps, container: Group = this): void {
this.upsert('text', Text, this.getTextStyle(attributes), container);
this.upsert('background', Rect, this.getBackgroundStyle(attributes), container);
}
connectedCallback() {
this.upsert('background', Rect, this.getBackgroundStyle(this.attributes), this);
}
}

View File

@ -0,0 +1,9 @@
export type PrefixKey<P extends string = string, K extends string = string> = `${P}${Capitalize<K>}`;
export type PrefixObject<T extends object, P extends string> = {
[K in keyof T as K extends string ? PrefixKey<P, K> : never]?: T[K];
};
export type ReplacePrefix<T, OldPrefix extends string, NewPrefix extends string> = {
[K in keyof T as K extends `${OldPrefix}${infer Rest}` ? `${NewPrefix}${Rest}` : K]: T[K];
};

View File

@ -0,0 +1,27 @@
import type { IAnimation } from '@antv/g';
/**
* <zh/>
*
* <en/> create animation proxy, synchronize animation to multiple animation instances
* @param sourceAnimation - <zh/> | <en/> source animation instance
* @param targetAnimations - <zh/> | <en/> target animation instance
* @returns <zh/> | <en/> animation proxy
*/
export function createAnimationsProxy(sourceAnimation: IAnimation, targetAnimations: IAnimation[]): IAnimation {
return new Proxy(sourceAnimation, {
get(target, propKey) {
if (typeof target[propKey] === 'function') {
return (...args: unknown[]) => {
target[propKey](...args);
targetAnimations.forEach((animation) => animation[propKey]?.(...args));
};
}
return Reflect.get(target, propKey);
},
set(target, propKey, value) {
targetAnimations.forEach((animation) => (animation[propKey] = value));
return Reflect.set(target, propKey, value);
},
});
}

View File

@ -0,0 +1,16 @@
import type { Padding, STDPadding } from '../types/padding';
/**
* <zh> padding
*
* <en> parse padding
* @param padding - <zh> padding | <en> padding
* @returns <zh> padding | <en> standard padding
*/
export function parsePadding(padding: Padding = 0): STDPadding {
if (Array.isArray(padding)) {
const [top, right = top, bottom = top, left = right] = padding;
return [top, right, bottom, left];
}
return [padding, padding, padding, padding];
}

View File

@ -0,0 +1,141 @@
import type { PrefixKey, PrefixObject, ReplacePrefix } from '../types/prefix';
/**
* <zh>
*
* <en> Uppercase first letter
* @param str - <zh> | <en> string
* @returns <zh> | <en> Uppercase first letter string
*/
export function toUppercaseFirstLetter<T extends string>(str: T) {
return (str.toString().charAt(0).toUpperCase() + str.toString().slice(1)) as Capitalize<T>;
}
/**
* <zh>
*
* <en> Lowercase first letter
* @param str - <zh> | <en> string
* @returns <zh> | <en> Lowercase first letter string
*/
export function toLowercaseFirstLetter(str: string) {
return str.toString().charAt(0).toLowerCase() + str.toString().slice(1);
}
/**
* <zh>
*
* <en> Whether starts with prefix
* @param str - <zh> | <en> string
* @param prefix - <zh> | <en> prefix
* @returns <zh> | <en> whether starts with prefix
*/
export function startsWith(str: string, prefix: string) {
if (!str.startsWith(prefix)) return false;
const nextChart = str[prefix.length];
return nextChart >= 'A' && nextChart <= 'Z';
}
/**
* <zh>
*
* <en> Add prefix
* @param str - <zh> | <en> string
* @param prefix - <zh> | <en> prefix
* @returns <zh> | <en> string with prefix
*/
export function addPrefix(str: string, prefix: string): PrefixKey {
return `${prefix}${toUppercaseFirstLetter(str)}`;
}
/**
* <zh>
*
* <en> Remove prefix
* @param string - <zh> | <en> string
* @param prefix - <zh> | <en> prefix
* @param lowercaseFirstLetter - <zh> | <en> whether lowercase first letter
* @returns <zh> | <en> string without prefix
*/
export function removePrefix(string: string, prefix?: string, lowercaseFirstLetter: boolean = true) {
if (!prefix) return string;
if (!startsWith(string, prefix)) return string;
const str = string.slice(prefix.length);
return lowercaseFirstLetter ? toLowercaseFirstLetter(str) : str;
}
/**
* <zh>
*
* <en> Extract sub style from style
* @param style - <zh> | <en> style
* @param prefix - <zh> | <en> sub style prefix
* @returns <zh> | <en> sub style
*/
export function subStyleProps<T extends object>(style: object, prefix: string) {
return Object.entries(style).reduce((acc, [key, value]) => {
if (key === 'className' || key === 'class') return acc;
if (startsWith(key, prefix)) {
acc[removePrefix(key, prefix)] = value;
}
return acc;
}, {} as T);
}
/**
* <zh/>
*
* <en/> Omit sub style from style
* @param style - <zh/> | <en/> style
* @param prefix - <zh/> | <en/> sub style prefix
* @returns <zh/> | <en/> style without sub style
*/
export function omitStyleProps<T extends object>(style: object, prefix: string) {
return Object.entries(style).reduce((acc, [key, value]) => {
if (!startsWith(key, prefix)) {
acc[key] = value;
}
return acc;
}, {} as T);
}
/**
* <zh>
*
* <en> Create prefix style
* @param style - <zh> | <en> style
* @param prefix - <zh> | <en> prefix
* @returns <zh> | <en> prefix style
*/
export function superStyleProps<T extends object, P extends string>(style: T, prefix: P): PrefixObject<T, P> {
return Object.entries(style).reduce(
(acc, [key, value]) => {
acc[addPrefix(key, prefix)] = value;
return acc;
},
{} as PrefixObject<T, P>,
);
}
/**
* <zh>
*
* <en> Replace prefix
* @param style - <zh> | <en> style
* @param oldPrefix - <zh> | <en> old prefix
* @param newPrefix - <zh> | <en> new prefix
* @returns <zh> | <en> style with replaced prefix
*/
export function replacePrefix<T extends object>(style: T, oldPrefix: string, newPrefix: string) {
return Object.entries(style).reduce(
(acc, [key, value]) => {
if (startsWith(key, oldPrefix)) {
acc[addPrefix(removePrefix(key, oldPrefix, false), newPrefix)] = value;
} else {
acc[key] = value;
}
return acc;
},
{} as ReplacePrefix<T, typeof oldPrefix, typeof newPrefix>,
);
}