feat(edge): add edge base class and line edge element (#5385)

* feat(edge): add base edge(keyShape,label and halo)

* feat: add edge arrow

* feat: update ci

* refactor: make render function clear

* feat(edge): add edge animation

* refactor: perf ts type and fix ci

* feat(edge): update edge animation

* chore: fix code review

* fix: ci

* test: add unit test

* fix: export circle demo

* chore: update point format

* fix: fix code preview

* chore: update ts type

* test: perf coverage

---------

Co-authored-by: yvonneyx <banxuan.zyx@antgroup.com>
This commit is contained in:
Yuxin 2024-01-31 13:02:15 +08:00 committed by GitHub
parent 896fe1499d
commit 5f41eaea15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1341 additions and 21 deletions

View File

@ -0,0 +1,44 @@
import { Line } from '../../../src/elements/edges';
import type { AnimationTestCase } from '../types';
export const edgeLine: AnimationTestCase = async (context) => {
const { canvas } = context;
const line = new Line({
style: {
sourcePoint: [100, 50],
targetPoint: [300, 50],
lineWidth: 2,
lineDash: [10, 10],
stroke: '#1890FF',
halo: true,
haloOpacity: 0.25,
haloLineWidth: 12,
label: true,
labelText: 'line-edge',
labelFontSize: 12,
labelFill: '#000',
labelPadding: 0,
startArrow: true,
startArrowType: 'circle',
endArrow: true,
endArrowFill: 'red',
},
});
await canvas.init();
canvas.appendChild(line);
const result = line.animate(
[
{ sourcePoint: [100, 150], targetPoint: [300, 200], haloOpacity: 0 },
{ sourcePoint: [100, 150], targetPoint: [450, 350], haloOpacity: 0.25 },
],
{ duration: 1000, fill: 'both' },
);
return result;
};
edgeLine.times = [0, 500, 1000];

View File

@ -0,0 +1,92 @@
import { Circle, Image } from '@antv/g';
import { Line } from '../../../src/elements/edges';
import type { StaticTestCase } from '../types';
export const edgeLine: StaticTestCase = async (context) => {
const { canvas } = context;
const line1 = new Line({
style: {
// key shape
sourcePoint: [100, 50],
targetPoint: [300, 50],
stroke: '#1890FF',
lineWidth: 2,
cursor: 'pointer',
// halo
halo: false,
haloOpacity: 0.25,
haloLineWidth: 12,
// label
label: true,
labelText: '🌲line-edge',
labelFontSize: 12,
labelFill: '#1890FF',
// start arrow
startArrow: true,
startArrowType: 'diamond',
// end arrow
endArrow: false,
},
});
const line2 = new Line({
style: {
sourcePoint: [100, 150],
targetPoint: [300, 200],
lineWidth: 2,
lineDash: [10, 10],
stroke: '#1890FF',
cursor: 'pointer',
halo: true,
haloOpacity: 0.25,
haloLineWidth: 12,
label: true,
labelText: 'line-edge',
labelFontSize: 12,
labelFill: '#000',
labelPadding: 0,
startArrow: true,
startArrowType: 'circle',
endArrow: true,
endArrowFill: 'red',
},
});
const line3 = new Line({
style: {
sourcePoint: [300, 300],
targetPoint: [100, 250],
lineWidth: 2,
lineDash: [10, 10],
stroke: '#1890FF',
cursor: 'pointer',
halo: true,
haloOpacity: 0.25,
label: true,
labelPosition: 'start',
labelOffsetX: 25,
labelText: 'reverted-line-edge',
labelFontSize: 12,
labelFill: '#000',
labelPadding: 0,
startArrow: true,
startArrowCtor: Image,
startArrowWidth: 50,
startArrowHeight: 50,
startArrowSrc: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ',
startArrowTransform: 'rotate(90deg)',
endArrow: true,
endArrowCtor: Circle,
endArrowR: 25,
endArrowStroke: '#1890FF',
endArrowLineWidth: 2,
},
});
await canvas.init();
canvas.appendChild(line1);
canvas.appendChild(line2);
canvas.appendChild(line3);
};

View File

@ -1,3 +1,4 @@
export * from './edge-line';
export * from './layered-canvas';
export * from './node-circle';
export * from './shape-badge';

View File

@ -0,0 +1,135 @@
<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(24,144,255,1)"
stroke-width="2px"
marker-start="true"
marker-end="true"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,0,0)"
>
<g
opacity="0"
pointer-events="none"
stroke-width="12px"
stroke-dasharray="0px,0px"
transform="matrix(1,0,0,1,100,150)"
>
<line
id="halo"
fill="none"
x1="0"
y1="0"
x2="200"
y2="50"
stroke="rgba(24,144,255,1)"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,100,150)"
>
<line
id="key"
fill="none"
x1="0"
y1="0"
x2="200"
y2="50"
stroke="rgba(24,144,255,1)"
/>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,0,0)"
>
<path
id="g-svg-8"
fill="rgba(24,144,255,1)"
d="M 0,5 A 5 5 0 1 0 10 5 A 5 5 0 1 0 0 5 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(-0.970143,-0.242536,0.242536,-0.970143,200,50)"
>
<path
id="g-svg-10"
fill="rgba(255,0,0,1)"
d="M 0,5 L 10,0 L 10,10 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
</g>
<g
id="label"
fill="rgba(0,0,0,1)"
stroke="none"
font-size="12px"
text-anchor="middle"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,205.335785,170.149292)"
>
<g transform="matrix(1,0,0,1,-33.187069,-28.430592)">
<path
id="background"
fill="none"
d="M 0,0 l 68.37411720861238,0 l 0,50.86117678317855 l-68.37411720861238 0 z"
stroke="none"
width="68.37411720861238px"
height="50.86117678317855px"
/>
</g>
<g
font-size="12px"
text-anchor="middle"
transform="matrix(1,0,0,1,0,0)"
>
<text
id="text"
fill="rgba(0,0,0,1)"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="1"
stroke="none"
>
line-edge
</text>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,135 @@
<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(24,144,255,1)"
stroke-width="2px"
marker-start="true"
marker-end="true"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,0,0)"
>
<g
opacity="0.25"
pointer-events="none"
stroke-width="12px"
stroke-dasharray="0px,0px"
transform="matrix(1,0,0,1,100,150)"
>
<line
id="halo"
fill="none"
x1="0"
y1="0"
x2="350"
y2="200"
stroke="rgba(24,144,255,1)"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,100,150)"
>
<line
id="key"
fill="none"
x1="0"
y1="0"
x2="350"
y2="200"
stroke="rgba(24,144,255,1)"
/>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(0.868243,0.496139,-0.496139,0.868243,0,0)"
>
<path
id="g-svg-8"
fill="rgba(24,144,255,1)"
d="M 0,5 A 5 5 0 1 0 10 5 A 5 5 0 1 0 0 5 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(-0.868243,-0.496139,0.496139,-0.868243,350,200)"
>
<path
id="g-svg-10"
fill="rgba(255,0,0,1)"
d="M 0,5 L 10,0 L 10,10 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
</g>
<g
id="label"
fill="rgba(0,0,0,1)"
stroke="none"
font-size="12px"
text-anchor="middle"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,205.335785,170.149292)"
>
<g transform="matrix(1,0,0,1,-33.187069,-28.430592)">
<path
id="background"
fill="none"
d="M 0,0 l 68.37411720861238,0 l 0,50.86117678317855 l-68.37411720861238 0 z"
stroke="none"
width="68.37411720861238px"
height="50.86117678317855px"
/>
</g>
<g
font-size="12px"
text-anchor="middle"
transform="matrix(1,0,0,1,0,0)"
>
<text
id="text"
fill="rgba(0,0,0,1)"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="1"
stroke="none"
>
line-edge
</text>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,315 @@
<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(24,144,255,1)"
stroke-width="2px"
marker-start="true"
marker-end="false"
transform="matrix(1,0,0,1,0,0)"
>
<g stroke-width="2px" transform="matrix(1,0,0,1,100,50)">
<line
id="key"
fill="none"
x1="0"
y1="0"
x2="200"
y2="0"
stroke="rgba(24,144,255,1)"
/>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(1,0,0,1,0,0)"
>
<path
id="g-svg-8"
fill="rgba(24,144,255,1)"
d="M 0,5 L 5,0 L 10,5 L 5,10 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
</g>
<g
id="label"
fill="rgba(24,144,255,1)"
stroke="none"
font-size="12px"
text-anchor="middle"
transform="matrix(1,0,0,1,204,44)"
>
<g transform="matrix(1,0,0,1,-44.540001,-20)">
<path
id="background"
fill="none"
d="M 0,0 l 91.08,0 l 0,34 l-91.08 0 z"
stroke="none"
width="91.08px"
height="34px"
/>
</g>
<g
font-size="12px"
text-anchor="middle"
transform="matrix(1,0,0,1,0,0)"
>
<text
id="text"
fill="rgba(24,144,255,1)"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="1"
stroke="none"
>
🌲line-edge
</text>
</g>
</g>
</g>
<g
id="g-svg-12"
fill="none"
stroke="rgba(24,144,255,1)"
stroke-width="2px"
marker-start="true"
marker-end="true"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,0,0)"
>
<g
opacity="0.25"
pointer-events="none"
stroke-width="12px"
stroke-dasharray="0px,0px"
transform="matrix(1,0,0,1,100,150)"
>
<line
id="halo"
fill="none"
x1="0"
y1="0"
x2="200"
y2="50"
stroke="rgba(24,144,255,1)"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,100,150)"
>
<line
id="key"
fill="none"
x1="0"
y1="0"
x2="200"
y2="50"
stroke="rgba(24,144,255,1)"
/>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,0,0)"
>
<path
id="g-svg-15"
fill="rgba(24,144,255,1)"
d="M 0,5 A 5 5 0 1 0 10 5 A 5 5 0 1 0 0 5 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(-0.970143,-0.242536,0.242536,-0.970143,200,50)"
>
<path
id="g-svg-17"
fill="rgba(255,0,0,1)"
d="M 0,5 L 10,0 L 10,10 Z"
transform="translate(-5,-5)"
stroke="none"
width="10px"
height="10px"
/>
</g>
</g>
<g
id="label"
fill="rgba(0,0,0,1)"
stroke="none"
font-size="12px"
text-anchor="middle"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,205.335785,170.149292)"
>
<g transform="matrix(1,0,0,1,-33.187069,-28.430592)">
<path
id="background"
fill="none"
d="M 0,0 l 68.37411720861238,0 l 0,50.86117678317855 l-68.37411720861238 0 z"
stroke="none"
width="68.37411720861238px"
height="50.86117678317855px"
/>
</g>
<g
font-size="12px"
text-anchor="middle"
transform="matrix(1,0,0,1,0,0)"
>
<text
id="text"
fill="rgba(0,0,0,1)"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="1"
stroke="none"
>
line-edge
</text>
</g>
</g>
</g>
<g
id="g-svg-22"
fill="none"
stroke="rgba(24,144,255,1)"
stroke-width="2px"
marker-start="true"
marker-end="true"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,0,0)"
>
<g
opacity="0.25"
pointer-events="none"
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(1,0,0,1,100,250)"
>
<line
id="halo"
fill="none"
x1="200"
y1="50"
x2="0"
y2="0"
stroke="rgba(24,144,255,1)"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="10px,10px"
transform="matrix(1,0,0,1,100,250)"
>
<line
id="key"
fill="none"
x1="200"
y1="50"
x2="0"
y2="0"
stroke="rgba(24,144,255,1)"
/>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(0.242536,-0.970143,0.970143,0.242536,200,50)"
>
<image
id="g-svg-25"
fill="none"
preserveAspectRatio="none"
x="0"
y="0"
href="https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ"
transform="translate(-25,-25)"
stroke="none"
width="50px"
height="50px"
/>
</g>
<g
stroke-width="2px"
stroke-dasharray="0px,0px"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,0,0)"
>
<circle
id="g-svg-27"
fill="none"
transform="translate(-25,-25)"
cx="25"
cy="25"
stroke="rgba(24,144,255,1)"
r="25px"
width="10px"
height="10px"
/>
</g>
</g>
<g
id="label"
fill="rgba(0,0,0,1)"
stroke="none"
font-size="12px"
text-anchor="end"
transform="matrix(0.970143,0.242536,-0.242536,0.970143,277.201660,288.115753)"
>
<g transform="matrix(1,0,0,1,-115.447052,-41.305889)">
<path
id="background"
fill="none"
d="M 0,0 l 123.09411654497103,0 l 0,76.6117655971751 l-123.09411654497103 0 z"
stroke="none"
width="123.09411654497103px"
height="76.6117655971751px"
/>
</g>
<g font-size="12px" text-anchor="end" transform="matrix(1,0,0,1,0,0)">
<text
id="text"
fill="rgba(0,0,0,1)"
dominant-baseline="alphabetic"
paint-order="stroke"
dx="1"
stroke="none"
>
reverted-line-edge
</text>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -0,0 +1,81 @@
import { Line } from '@antv/g';
import { getLabelPositionStyle } from '../../../src/utils/edge';
describe('edge', () => {
// horizontal line
const line = new Line({
style: {
x1: 0,
y1: 100,
x2: 100,
y2: 100,
},
});
// with rotation angle below Math.PI
const line1 = new Line({
style: {
x1: 0,
y1: 100,
x2: 100,
y2: 200,
},
});
// with rotation angle over Math.PI
const line2 = new Line({
style: {
x1: 0,
y1: 200,
x2: 100,
y2: 100,
},
});
it('getLabelPositionStyle', () => {
const labelPosition = getLabelPositionStyle(line, 'center', false);
expect(labelPosition.textAlign).toEqual('center');
expect(labelPosition.x).toEqual(50);
expect(labelPosition.y).toEqual(100);
const labelPosition2 = getLabelPositionStyle(line, 'center', true, 5, 5);
expect(labelPosition2.textAlign).toEqual('center');
expect(labelPosition2.x).toEqual(55);
expect(labelPosition2.y).toEqual(105);
const labelPosition3 = getLabelPositionStyle(line, 'start', true, 5, 5);
expect(labelPosition3.textAlign).toEqual('left');
expect(labelPosition3.x).toEqual(5);
expect(labelPosition3.y).toEqual(105);
const labelPosition4 = getLabelPositionStyle(line, 'end', true, 5, 5);
expect(labelPosition4.textAlign).toEqual('right');
expect(labelPosition4.x).toBeCloseTo(100 * 0.99 + 5);
expect(labelPosition4.y).toEqual(105);
const labelPosition5 = getLabelPositionStyle(line, 0.5, true, 5, 5);
expect(labelPosition5.textAlign).toEqual('center');
expect(labelPosition5.x).toEqual(55);
expect(labelPosition5.y).toEqual(105);
// with rotation angle below Math.PI
const labelPosition6 = getLabelPositionStyle(line1, 'center', true, 5, 5);
expect(labelPosition6.textAlign).toEqual('center');
expect(labelPosition6.transform).toEqual('rotate(45deg)');
expect(labelPosition6.x).toEqual(50 + 5 * Math.cos(Math.PI / 4) - 5 * Math.sin(Math.PI / 4));
expect(labelPosition6.y).toEqual(150 + 5 * Math.sin(Math.PI / 4) + 5 * Math.cos(Math.PI / 4));
const labelPosition7 = getLabelPositionStyle(line1, 'start', true, 5, 5);
expect(labelPosition7.textAlign).toEqual('left');
const labelPosition8 = getLabelPositionStyle(line1, 'end', true, 5, 5);
expect(labelPosition8.textAlign).toEqual('right');
// with rotation angle over Math.PI
const labelPosition9 = getLabelPositionStyle(line2, 'center', true, 5, 5);
expect(labelPosition9.textAlign).toEqual('center');
expect(labelPosition9.transform).toEqual('rotate(-45deg)');
expect(labelPosition9.x).toEqual(50 + 5 * Math.cos(-Math.PI / 4) - 5 * Math.sin(-Math.PI / 4));
expect(labelPosition9.y).toEqual(150 + 5 * Math.sin(-Math.PI / 4) + 5 * Math.cos(-Math.PI / 4));
});
});

View File

@ -0,0 +1,8 @@
import { isHorizontal } from '../../../src/utils/point';
describe('Point Functions', () => {
it('isHorizontal function', () => {
expect(isHorizontal({ x: 100, y: 100 }, { x: 50, y: 100 })).toEqual(true);
expect(isHorizontal({ x: 100, y: 100 }, { x: 50, y: 150 })).toEqual(false);
});
});

View File

@ -0,0 +1,66 @@
import { circle, diamond, rect, simple, triangle, triangleRect, vee } from '../../../src/utils/symbol';
describe('Symbol Functions', () => {
describe('circle', () => {
it('should return the correct path for a circle', () => {
const path = circle(10, 10);
expect(path).toEqual([['M', 0, 0], ['A', 5, 5, 0, 1, 0, 10, 0], ['A', 5, 5, 0, 1, 0, 0, 0], ['Z']]);
});
});
describe('triangle', () => {
it('should return the correct path for a triangle', () => {
const path = triangle(10, 10);
expect(path).toEqual([['M', 0, 0], ['L', 10, -5], ['L', 10, 5], ['Z']]);
});
});
describe('diamond', () => {
it('should return the correct path for a diamond', () => {
const path = diamond(10, 10);
expect(path).toEqual([['M', 0, 0], ['L', 5, -5], ['L', 10, 0], ['L', 5, 5], ['Z']]);
});
});
describe('vee', () => {
it('should return the correct path for a vee', () => {
const path = vee(10, 10);
expect(path).toEqual([['M', 0, 0], ['L', 10, -5], ['L', 6.666666666666667, 0], ['L', 10, 5], ['Z']]);
});
});
describe('rect', () => {
it('should return the correct path for a rectangle', () => {
const path = rect(10, 10);
expect(path).toEqual([['M', 0, -5], ['L', 10, -5], ['L', 10, 5], ['L', 0, 5], ['Z']]);
});
});
describe('triangleRect', () => {
it('should return the correct path for a triangleRect', () => {
const path = triangleRect(10, 10);
expect(path).toEqual([
['M', 0, 0],
['L', 5, -5],
['L', 5, 5],
['Z'],
['M', 8.571428571428571, -5],
['L', 10, -5],
['L', 10, 5],
['L', 8.571428571428571, 5],
['Z'],
]);
});
});
describe('simple', () => {
it('should return the correct path for a simple shape', () => {
const path = simple(10, 10);
expect(path).toEqual([
['M', 10, -5],
['L', 0, 0],
['L', 10, 5],
]);
});
});
});

View File

@ -0,0 +1,189 @@
import type {
BaseStyleProps,
DisplayObject,
DisplayObjectConfig,
Group,
LineStyleProps,
PathStyleProps,
} from '@antv/g';
import { Path } from '@antv/g';
import { deepMix, isFunction } from '@antv/util';
import type { Point, PrefixObject } from '../../types';
import type { EdgeKey, EdgeLabelStyleProps } from '../../types/edge';
import { getLabelPositionStyle } from '../../utils/edge';
import { omitStyleProps, subStyleProps } from '../../utils/prefix';
import * as Symbol from '../../utils/symbol';
import { SymbolFactor } from '../../utils/symbol';
import type { LabelStyleProps } from '../shapes';
import { Label } from '../shapes';
import type { BaseShapeStyleProps } from '../shapes/base-shape';
import { BaseShape } from '../shapes/base-shape';
type SymbolName = 'triangle' | 'circle' | 'diamond' | 'vee' | 'rect' | 'triangleRect' | 'simple';
type EdgeArrowStyleProps = {
ctor?: { new (...args: any[]): DisplayObject };
type?: SymbolName | SymbolFactor;
width?: number;
height?: number;
} & PathStyleProps &
Record<string, unknown>;
export type BaseEdgeStyleProps<KT extends object> = BaseShapeStyleProps &
KT & {
sourcePoint?: Point;
targetPoint?: Point;
label?: boolean;
halo?: boolean;
startArrow?: boolean;
endArrow?: boolean;
startArrowOffset?: number;
endArrowOffset?: number;
} & PrefixObject<EdgeLabelStyleProps, 'label'> &
PrefixObject<KT, 'halo'> &
PrefixObject<EdgeArrowStyleProps, 'startArrow'> &
PrefixObject<EdgeArrowStyleProps, 'endArrow'>;
export type BaseEdgeOptions<KT extends object> = DisplayObjectConfig<BaseEdgeStyleProps<KT>>;
type ParsedBaseEdgeStyleProps<KT extends object> = Required<BaseEdgeStyleProps<KT>>;
export abstract class BaseEdge<KT extends object, KS extends DisplayObject> extends BaseShape<BaseEdgeStyleProps<KT>> {
static defaultStyleProps: BaseEdgeStyleProps<Record<string, unknown>> = {
label: true,
labelPosition: 'center',
labelOffsetX: 4,
labelOffsetY: -6,
labelIsBillboard: true,
labelAutoRotate: true,
halo: false,
haloLineDash: 0,
haloPointerEvents: 'none',
haloZIndex: -1,
haloDroppable: false,
startArrow: false,
startArrowCtor: Path,
startArrowType: 'triangle',
startArrowWidth: 10,
startArrowHeight: 10,
startArrowAnchor: '0.5 0.5',
startArrowTransformOrigin: 'center',
startArrowLineDash: 0,
endArrow: false,
endArrowCtor: Path,
endArrowType: 'triangle',
endArrowWidth: 10,
endArrowHeight: 10,
endArrowAnchor: '0.5 0.5',
endArrowTransformOrigin: 'center',
endArrowLineDash: 0,
};
constructor(options: BaseEdgeOptions<KT>) {
super(deepMix({}, { style: BaseEdge.defaultStyleProps }, options));
}
protected getKeyStyle(attributes: ParsedBaseEdgeStyleProps<KT>): KT {
return omitStyleProps(this.getGraphicStyle(attributes), ['halo', 'label', 'startArrow', 'endArrow']);
}
protected getHaloStyle(attributes: ParsedBaseEdgeStyleProps<KT>): false | KT {
if (attributes.halo === false) return false;
const keyStyle = this.getKeyStyle(attributes);
const haloStyle = subStyleProps<LineStyleProps>(this.getGraphicStyle(attributes), 'halo');
return { ...keyStyle, ...haloStyle };
}
protected getLabelStyle(attributes: ParsedBaseEdgeStyleProps<KT>): false | LabelStyleProps {
if (attributes.label === false) return false;
const labelStyle = subStyleProps<Required<EdgeLabelStyleProps>>(this.getGraphicStyle(attributes), 'label');
const { position, offsetX, offsetY, autoRotate, ...restStyle } = labelStyle;
const labelPositionStyle = getLabelPositionStyle(
this.shapeMap.key as EdgeKey,
position,
autoRotate,
offsetX,
offsetY,
);
return { ...labelPositionStyle, ...restStyle } as LabelStyleProps;
}
protected drawArrow(attributes: ParsedBaseEdgeStyleProps<KT>, isStart: boolean) {
const arrowType = isStart ? 'startArrow' : 'endArrow';
const arrowPresence = attributes[arrowType];
if (arrowPresence) {
const { ctor } = subStyleProps<Required<EdgeArrowStyleProps>>(this.getGraphicStyle(attributes), arrowType);
const arrowStyle = this.getArrowStyle(attributes, isStart);
this.shapeMap.key.style[isStart ? 'markerStart' : 'markerEnd'] = new ctor({ style: arrowStyle });
this.shapeMap.key.style[isStart ? 'markerStartOffset' : 'markerEndOffset'] = isStart
? attributes.startArrowOffset
: attributes.endArrowOffset;
} else {
this.shapeMap.key.style[isStart ? 'markerStart' : 'markerEnd'] = undefined;
}
}
private getArrowStyle(attributes: ParsedBaseEdgeStyleProps<KT>, isStart: boolean) {
const { stroke, ...keyStyle } = this.getKeyStyle(attributes) as BaseStyleProps;
const arrowType = isStart ? 'startArrow' : 'endArrow';
const { width, height, type, ctor, ...arrowStyle } = subStyleProps<Required<EdgeArrowStyleProps>>(
this.getGraphicStyle(attributes),
arrowType,
);
let path;
if (ctor === Path) {
const arrowFn = isFunction(type) ? type : Symbol[type] || Symbol.triangle;
path = arrowFn(width, height);
}
return {
...keyStyle,
...(path && { path, fill: type === 'simple' ? '' : stroke }),
width,
height,
...arrowStyle,
};
}
protected drawLabelShape(attributes: ParsedBaseEdgeStyleProps<KT>, container: Group) {
this.upsert('label', Label, this.getLabelStyle(attributes), container);
}
protected abstract drawKeyShape(attributes: ParsedBaseEdgeStyleProps<KT>, container: Group): KS | undefined;
public render(attributes = this.parsedAttributes, container: Group = this): void {
// 1. key shape
const keyShape = this.drawKeyShape(attributes, container);
if (!keyShape) return;
// 2. arrows
this.drawArrow(attributes, true);
this.drawArrow(attributes, false);
// 3. label
this.drawLabelShape(attributes, container);
// 4. halo
this.upsert(
'halo',
this.shapeMap.key.constructor as new (...args: any[]) => KS,
this.getHaloStyle(attributes),
container,
);
}
animate(keyframes: Keyframe[] | PropertyIndexedKeyframes, options?: number | KeyframeAnimationOptions) {
const result = super.animate(keyframes, options);
result.onframe = () => {
this.drawLabelShape(this.parsedAttributes, this);
};
return result;
}
}

View File

@ -0,0 +1,3 @@
export { Line } from './line';
export type { LineStyleProps } from './line';

View File

@ -0,0 +1,34 @@
import type { DisplayObjectConfig, LineStyleProps as GLineStyleProps, Group } from '@antv/g';
import { Line as GLine } from '@antv/g';
import type { Point } from '../../types';
import type { BaseEdgeStyleProps } from './base-edge';
import { BaseEdge } from './base-edge';
export type LineStyleProps = BaseEdgeStyleProps<LineKeyStyleProps>;
type LineKeyStyleProps = Omit<GLineStyleProps, 'x1' | 'y1' | 'x2' | 'y2'>;
type ParsedLineStyleProps = Required<LineStyleProps>;
type LineOptions = DisplayObjectConfig<LineStyleProps>;
/**
* Draw line based on BaseEdge, override drawKeyShape
*/
export class Line extends BaseEdge<LineKeyStyleProps, GLine> {
constructor(options: LineOptions) {
super(options);
}
protected drawKeyShape(attributes: ParsedLineStyleProps, container: Group): GLine | undefined {
return this.upsert('key', GLine, this.getKeyStyle(attributes), container);
}
protected getKeyStyle(attributes: ParsedLineStyleProps): GLineStyleProps {
const { sourcePoint, targetPoint, ...keyShape } = super.getKeyStyle(attributes) as unknown as GLineStyleProps & {
sourcePoint: Point;
targetPoint: Point;
};
return { ...keyShape, x1: sourcePoint[0], y1: sourcePoint[1], x2: targetPoint[0], y2: targetPoint[1] };
}
}

View File

@ -36,7 +36,7 @@ export type BaseNodeStyleProps<KT extends object> = BaseShapeStyleProps &
'anchor'
>;
// type ParsedCircleStyleProps = Required<BaseNodeStyleProps>;
type ParsedBaseNodeStyleProps<KT extends object> = Required<BaseNodeStyleProps<KT>>;
type BaseNodeOptions<KT extends object> = DisplayObjectConfig<BaseNodeStyleProps<KT>>;
@ -54,12 +54,12 @@ export abstract class BaseNode<KT extends object, KS> extends BaseShape<BaseNode
super(options);
}
protected getKeyStyle(attributes: BaseNodeStyleProps<KT>): KT {
protected getKeyStyle(attributes: ParsedBaseNodeStyleProps<KT>): KT {
const style = this.getGraphicStyle(attributes);
return omitStyleProps(style, ['label', 'halo', 'icon', 'badge', 'anchor']);
}
protected getLabelStyle(attributes: BaseNodeStyleProps<KT>) {
protected getLabelStyle(attributes: ParsedBaseNodeStyleProps<KT>) {
const { position, ...labelStyle } = subStyleProps<NodeLabelStyleProps>(
this.getGraphicStyle(attributes),
'label',
@ -72,9 +72,9 @@ export abstract class BaseNode<KT extends object, KS> extends BaseShape<BaseNode
} as NodeLabelStyleProps;
}
protected abstract getHaloStyle(attributes: BaseNodeStyleProps<KT>): KT;
protected abstract getHaloStyle(attributes: ParsedBaseNodeStyleProps<KT>): KT;
protected getIconStyle(attributes: BaseNodeStyleProps<KT>) {
protected getIconStyle(attributes: ParsedBaseNodeStyleProps<KT>) {
const iconStyle = subStyleProps(this.getGraphicStyle(attributes), 'icon');
const keyShape = this.shapeMap.key;
const [x, y] = getXYByPosition(keyShape.getLocalBounds(), 'center');
@ -86,7 +86,7 @@ export abstract class BaseNode<KT extends object, KS> extends BaseShape<BaseNode
} as IconStyleProps;
}
protected getBadgesStyle(attributes: BaseNodeStyleProps<KT>): NodeBadgeStyleProps[] {
protected getBadgesStyle(attributes: ParsedBaseNodeStyleProps<KT>): NodeBadgeStyleProps[] {
const badgesStyle = this.getGraphicStyle(attributes).badgeOptions || [];
const keyShape = this.shapeMap.key;
@ -97,7 +97,7 @@ export abstract class BaseNode<KT extends object, KS> extends BaseShape<BaseNode
});
}
protected getAnchorsStyle(attributes: BaseNodeStyleProps<KT>): NodeAnchorStyleProps[] {
protected getAnchorsStyle(attributes: ParsedBaseNodeStyleProps<KT>): NodeAnchorStyleProps[] {
const anchorStyle = this.getGraphicStyle(attributes).anchorOptions || [];
const keyShape = this.shapeMap.key;
@ -108,9 +108,9 @@ export abstract class BaseNode<KT extends object, KS> extends BaseShape<BaseNode
});
}
protected abstract drawKeyShape(attributes: BaseNodeStyleProps<KT>, container: Group): KS;
protected abstract drawKeyShape(attributes: ParsedBaseNodeStyleProps<KT>, container: Group): KS;
public render(attributes = this.attributes as BaseNodeStyleProps<KT>, container: Group = this) {
public render(attributes = this.parsedAttributes, container: Group = this) {
// 1. key shape
const keyShape = this.drawKeyShape(attributes, container);
if (!keyShape) return;

View File

@ -6,17 +6,19 @@ import { BaseNode } from './base-node';
export type CircleStyleProps = BaseNodeStyleProps<GCircleStyleProps>;
type ParsedCircleStyleProps = Required<CircleStyleProps>;
type CircleOptions = DisplayObjectConfig<CircleStyleProps>;
/**
* Draw circle based on BaseNode, override drawKeyShape.
*/
export class Circle extends BaseNode<CircleStyleProps, GCircle> {
export class Circle extends BaseNode<GCircleStyleProps, GCircle> {
constructor(options: CircleOptions) {
super(options);
}
protected getHaloStyle(attributes: CircleStyleProps): GCircleStyleProps {
protected getHaloStyle(attributes: ParsedCircleStyleProps): GCircleStyleProps {
const haloStyle = subStyleProps(this.getGraphicStyle(attributes), 'halo') as Partial<GCircleStyleProps>;
const keyStyle = this.getKeyStyle(attributes);
@ -32,7 +34,7 @@ export class Circle extends BaseNode<CircleStyleProps, GCircle> {
} as GCircleStyleProps;
}
protected drawKeyShape(attributes: CircleStyleProps, container: Group): GCircle {
protected drawKeyShape(attributes: ParsedCircleStyleProps, container: Group): GCircle {
return this.upsert('key', GCircle, this.getKeyStyle(attributes), container) as GCircle;
}

View File

@ -13,6 +13,10 @@ export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomEle
this.bindEvents();
}
get parsedAttributes() {
return this.attributes as Required<T>;
}
/**
* <zh>
*
@ -40,15 +44,17 @@ export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomEle
protected upsert<P extends DisplayObject>(
key: string,
Ctor: { new (...args: any[]): P },
style: P['attributes'],
style: P['attributes'] | false,
container: DisplayObject,
) {
): P | undefined {
const target = this.shapeMap[key];
// remove
// 如果 style 为 false则删除图形 / remove shape if style is false
if (target && style === false) {
container.removeChild(target);
delete this.shapeMap[key];
if (style === false) {
if (target) {
container.removeChild(target);
delete this.shapeMap[key];
}
return;
}
@ -67,7 +73,7 @@ export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomEle
if ('update' in target) (target.update as (...args: unknown[]) => unknown)(style);
else target.attr(style);
return target;
return target as P;
}
/**
@ -98,7 +104,7 @@ export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomEle
* @param attributes
* @param container
*/
public abstract render(attributes: T, container: Group): void;
public abstract render(attributes: Required<T>, container: Group): void;
public bindEvents() {}
@ -112,8 +118,8 @@ export abstract class BaseShape<T extends BaseShapeStyleProps> extends CustomEle
*/
public getGraphicStyle<T extends Record<string, any>>(
attributes: T,
): Omit<T, 'x' | 'y' | 'transform' | 'transformOrigin' | 'className'> {
const { x, y, className, transform, transformOrigin, ...style } = attributes;
): Omit<T, 'x' | 'y' | 'transform' | 'transformOrigin' | 'className' | 'anchor'> {
const { x, y, className, transform, transformOrigin, anchor, ...style } = attributes;
return style;
}

View File

@ -1 +1,31 @@
import { Line, Path, Polyline } from '@antv/g';
import type { LabelStyleProps } from '../elements/shapes';
export type EdgeDirection = 'in' | 'out' | 'both';
export type EdgeKey = Line | Path | Polyline;
export type EdgeLabelPosition = 'start' | 'center' | 'end' | number;
export type EdgeLabelStyleProps = {
/**
* <zh/> 'start''center''end' 0-1
* <en/> The position of the label relative to the edge. Can be 'start', 'center', 'end', or a specific ratio (number)
*/
position?: EdgeLabelPosition;
/**
* <zh/>
* <en/> The horizontal offset of the label parallel to the edge
*/
offsetX?: number;
/**
* <zh/>
* <en/> The vertical offset of the label perpendicular to the edge
*/
offsetY?: number;
/**
* <zh/>
* <en/> Indicates whether the label should automatically rotate to align with the edge's direction
*/
autoRotate?: boolean;
} & LabelStyleProps;

View File

@ -0,0 +1,94 @@
import { pick } from '@antv/util';
import type { EdgeKey, EdgeLabelPosition, EdgeLabelStyleProps } from '../types/edge';
import { isHorizontal } from './point';
/**
* <zh/>
*
* <en/> Get the style of the label's position
* @param key - <zh/> | <en/> The edge object
* @param position - <zh/> | <en/> Position of the label
* @param autoRotate - <zh/> | <en/> Whether to auto-rotate
* @param offsetX - <zh/> | <en/> Horizontal offset of the label relative to the edge
* @param offsetY - <zh/> | <en/> Vertical offset of the label relative to the edge
* @returns <zh/> | <en/> Returns the style of the label's position
*/
export function getLabelPositionStyle(
key: EdgeKey,
position: EdgeLabelPosition,
autoRotate: boolean,
offsetX?: number,
offsetY?: number,
): Partial<EdgeLabelStyleProps> {
const START_RATIO = 0;
const MIDDLE_RATIO = 0.5;
const END_RATIO = 0.99;
let ratio = typeof position === 'number' ? position : MIDDLE_RATIO;
if (position === 'start') ratio = START_RATIO;
if (position === 'end') ratio = END_RATIO;
const positionStyle: Partial<EdgeLabelStyleProps> = {
textAlign: position === 'start' ? 'left' : position === 'end' ? 'right' : 'center',
offsetX,
offsetY,
};
adjustLabelPosition(key, positionStyle, ratio);
if (autoRotate) applyAutoRotation(key, positionStyle, ratio);
return pick(positionStyle, ['x', 'y', 'textAlign', 'transform']);
}
/**
* <zh/>
*
* <en/> Calculate the precise position of the label based on the edge body, position style, ratio, and angle
* @param key - <zh/> | <en/> The edge object
* @param positionStyle - <zh/> | <en/> The style of the label's position
* @param ratio - <zh/> 沿 | <en/> Ratio along the edge
* @param angle - <zh/> | <en/> Rotation angle
*/
function adjustLabelPosition(key: EdgeKey, positionStyle: Partial<EdgeLabelStyleProps>, ratio: number, angle?: number) {
const { x: pointX, y: pointY } = key.getPoint(ratio);
const { offsetX = 0, offsetY = 0 } = positionStyle;
let actualOffsetX = offsetX;
let actualOffsetY = offsetY;
if (angle) {
actualOffsetX = offsetX * Math.cos(angle) - offsetY * Math.sin(angle);
actualOffsetY = offsetX * Math.sin(angle) + offsetY * Math.cos(angle);
}
positionStyle.x = pointX + actualOffsetX;
positionStyle.y = pointY + actualOffsetY;
}
/**
* <zh/>
*
* <en/> Calculate and apply the rotation angle of the label based on the direction of the edge
* @param key - <zh/> | <en/> The edge object
* @param positionStyle - <zh/> | <en/> The style of the label's position
* @param ratio - <zh/> 沿 | <en/> ratio along the edge
*/
function applyAutoRotation(key: EdgeKey, positionStyle: Partial<EdgeLabelStyleProps>, ratio: number) {
const { textAlign } = positionStyle;
const point = key.getPoint(ratio);
const pointOffset = key.getPoint(ratio + 0.01);
if (isHorizontal(point, pointOffset)) return;
let angle = Math.atan2(pointOffset.y - point.y, pointOffset.x - point.x);
const isRevert = pointOffset.x < point.x;
if (isRevert) {
positionStyle.textAlign = textAlign === 'center' ? textAlign : textAlign === 'left' ? 'right' : 'left';
positionStyle.offsetX! *= -1;
angle += Math.PI;
}
adjustLabelPosition(key, positionStyle, ratio, angle);
positionStyle.transform = `rotate(${(angle / Math.PI) * 180}deg)`;
}

View File

@ -0,0 +1,13 @@
import { Point } from '@antv/util';
/**
* <zh/> 线
*
* <en/> whether two points are on the same horizontal line
* @param p1 - <zh/> | <en/> the first point
* @param p2 - <zh/> | <en/> the second point
* @returns <zh/> 线 | <en/> is horizontal or not
*/
export function isHorizontal(p1: Point, p2: Point): boolean {
return p1.y === p2.y;
}

View File

@ -0,0 +1,72 @@
/* eslint-disable jsdoc/require-returns */
/* eslint-disable jsdoc/require-param */
import type { PathArray } from '@antv/util';
export type SymbolFactor = (width: number, height: number) => PathArray;
/**
*
*/
export const circle: SymbolFactor = (width: number, height: number) => {
const r = Math.max(width, height) / 2;
return [['M', 0, 0], ['A', r, r, 0, 1, 0, 2 * r, 0], ['A', r, r, 0, 1, 0, 0, 0], ['Z']];
};
/**
*
*/
export const triangle: SymbolFactor = (width: number, height: number) => {
return [['M', 0, 0], ['L', width, -height / 2], ['L', width, height / 2], ['Z']];
};
/**
*
*/
export const diamond: SymbolFactor = (width: number, height: number) => {
return [['M', 0, 0], ['L', width / 2, -height / 2], ['L', width, 0], ['L', width / 2, height / 2], ['Z']];
};
/**
* >>
*/
export const vee: SymbolFactor = (width: number, height: number) => {
return [['M', 0, 0], ['L', width, -height / 2], ['L', (2 * width) / 3, 0], ['L', width, height / 2], ['Z']];
};
/**
*
*/
export const rect: SymbolFactor = (width: number, height: number) => {
return [['M', 0, -height / 2], ['L', width, -height / 2], ['L', width, height / 2], ['L', 0, height / 2], ['Z']];
};
/**
*
*/
export const triangleRect: SymbolFactor = (width: number, height: number) => {
const tWidth = width / 2;
const rWidth = width / 7;
const rBeginX = width - rWidth;
return [
['M', 0, 0],
['L', tWidth, -height / 2],
['L', tWidth, height / 2],
['Z'],
['M', rBeginX, -height / 2],
['L', rBeginX + rWidth, -height / 2],
['L', rBeginX + rWidth, height / 2],
['L', rBeginX, height / 2],
['Z'],
];
};
/**
* >
*/
export const simple: SymbolFactor = (width: number, height: number) => {
return [
['M', width, -height / 2],
['L', 0, 0],
['L', width, height / 2],
];
};