diff --git a/packages/g6/__tests__/demo/case/combo-circle.ts b/packages/g6/__tests__/demo/case/combo-circle.ts
new file mode 100644
index 00000000000..5094934bd9d
--- /dev/null
+++ b/packages/g6/__tests__/demo/case/combo-circle.ts
@@ -0,0 +1,104 @@
+import { Graph } from '@/src';
+import type { STDTestCase } from '../types';
+
+export const comboCircle: STDTestCase = async (context) => {
+ const data = {
+ nodes: [
+ { id: 'node-1', data: {}, style: { parentId: 'combo-2', x: 100, y: 100 } },
+ { id: 'node-2', data: {}, style: { parentId: 'combo-1', x: 300, y: 200 } },
+ { id: 'node-3', data: {}, style: { parentId: 'combo-1', x: 200, y: 300 } },
+ ],
+ edges: [
+ { id: 'edge-1', source: 'node-1', target: 'node-2' },
+ { id: 'edge-2', source: 'node-2', target: 'node-3' },
+ ],
+ combos: [
+ {
+ id: 'combo-1',
+ style: { parentId: 'combo-2' },
+ },
+ {
+ id: 'combo-2',
+ style: {
+ zIndex: -10, // TODO: zIndex?
+ },
+ },
+ ],
+ };
+
+ const graph = new Graph({
+ ...context,
+ data,
+ node: {
+ style: {
+ labelText: (d: any) => d.id,
+ },
+ },
+ combo: {
+ style: {
+ padding: 0,
+ labelText: (d: any) => d.id,
+ collapsedLineDash: [5, 5],
+ },
+ },
+ });
+
+ await graph.render();
+
+ const COLLAPSED_ORIGIN = ['top', 'bottom', 'left', 'right', 'center'];
+ const COLLAPSED_MARKER_TYPE = ['child-count', 'descendant-count', 'node-count'];
+
+ comboCircle.form = (panel) => {
+ const config = {
+ collapsedOrigin: 'top',
+ collapsedMarker: true,
+ collapsedMarkerType: 'child-count',
+ collapseCombo2: () => {
+ graph.updateComboData((data) => [
+ ...data,
+ {
+ id: 'combo-2',
+ style: {
+ collapsed: true,
+ collapsedOrigin: config.collapsedOrigin,
+ collapsedMarker: config.collapsedMarker,
+ collapsedMarkerType: config.collapsedMarkerType,
+ },
+ },
+ ]);
+ graph.render();
+ },
+ expandCombo2: () => {
+ graph.updateComboData((data) => [
+ ...data,
+ {
+ id: 'combo-2',
+ style: {
+ collapsed: false,
+ collapsedOrigin: config.collapsedOrigin,
+ collapsedMarker: config.collapsedMarker,
+ collapsedMarkerType: config.collapsedMarkerType,
+ },
+ },
+ ]);
+ graph.render();
+ },
+ };
+
+ return [
+ panel.add(config, 'collapsedOrigin', COLLAPSED_ORIGIN).onChange((collapsedOrigin: string) => {
+ config.collapsedOrigin = collapsedOrigin;
+ }),
+ panel.add(config, 'collapsedMarker').onChange((collapsedMarker: boolean) => {
+ config.collapsedMarker = collapsedMarker;
+ }),
+ panel.add(config, 'collapsedMarkerType', COLLAPSED_MARKER_TYPE).onChange((collapsedMarkerType: string) => {
+ config.collapsedMarkerType = collapsedMarkerType;
+ }),
+ panel.add(config, 'collapseCombo2'),
+ panel.add(config, 'expandCombo2'),
+ ];
+ };
+
+ return graph;
+};
diff --git a/packages/g6/__tests__/demo/case/index.ts b/packages/g6/__tests__/demo/case/index.ts
index 772279c737d..37f14ceef94 100644
--- a/packages/g6/__tests__/demo/case/index.ts
+++ b/packages/g6/__tests__/demo/case/index.ts
@@ -1,3 +1,4 @@
export * from './behavior-drag-canvas';
export * from './behavior-zoom-canvas';
+export * from './combo-circle';
export * from './common-graph';
diff --git a/packages/g6/__tests__/integration/snapshots/static/graph-element.svg b/packages/g6/__tests__/integration/snapshots/static/graph-element.svg
index 8c1384affb7..2a66c4ac274 100644
--- a/packages/g6/__tests__/integration/snapshots/static/graph-element.svg
+++ b/packages/g6/__tests__/integration/snapshots/static/graph-element.svg
@@ -9,7 +9,22 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ combo-2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ combo-1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ node-1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ node-2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ node-3
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_bottom.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_bottom.svg
new file mode 100644
index 00000000000..461316e53b7
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_bottom.svg
@@ -0,0 +1,298 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_left.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_left.svg
new file mode 100644
index 00000000000..43a7f54f9fe
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_left.svg
@@ -0,0 +1,298 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_right.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_right.svg
new file mode 100644
index 00000000000..a8d93860440
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_right.svg
@@ -0,0 +1,298 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_top.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_top.svg
new file mode 100644
index 00000000000..9daae6f74a6
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__collapse_top.svg
@@ -0,0 +1,298 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__marker_childCount.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__marker_childCount.svg
new file mode 100644
index 00000000000..d7fa8d19252
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__marker_childCount.svg
@@ -0,0 +1,313 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__marker_descendantCount.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__marker_descendantCount.svg
new file mode 100644
index 00000000000..12b8579c4b9
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__marker_descendantCount.svg
@@ -0,0 +1,313 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/elements/combo/combo__marker_nodeCount.svg b/packages/g6/__tests__/snapshots/elements/combo/combo__marker_nodeCount.svg
new file mode 100644
index 00000000000..0aa83c28094
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/elements/combo/combo__marker_nodeCount.svg
@@ -0,0 +1,313 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/runtime/element/element.svg b/packages/g6/__tests__/snapshots/runtime/element/element.svg
index 8c1384affb7..2a66c4ac274 100644
--- a/packages/g6/__tests__/snapshots/runtime/element/element.svg
+++ b/packages/g6/__tests__/snapshots/runtime/element/element.svg
@@ -9,7 +9,22 @@
-
+
+
+
+
+
+
+
{
+ let graph: Graph;
+
+ beforeAll(async () => {
+ graph = await createDemoGraph(comboCircle, { animation: false });
+ });
+
+ it('default status', async () => {
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename);
+ });
+
+ it('collapse combo', async () => {
+ const expandCombo = () => {
+ graph.updateComboData((data) => [
+ ...data,
+ {
+ id: 'combo-2',
+ style: {
+ collapsed: false,
+ },
+ },
+ ]);
+ graph.render();
+ };
+ const collapseCombo = (collapsedOrigin: string) => {
+ graph.updateComboData((data) => [
+ ...data,
+ {
+ id: 'combo-2',
+ style: {
+ collapsed: true,
+ collapsedOrigin,
+ collapsedMarker: false,
+ },
+ },
+ ]);
+ graph.render();
+ };
+ collapseCombo('top');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__collapse_top');
+ expandCombo();
+ collapseCombo('right');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__collapse_right');
+ collapseCombo('left');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__collapse_left');
+ expandCombo();
+ collapseCombo('bottom');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__collapse_bottom');
+ expandCombo();
+ });
+
+ it('collapse combo with collapsed marker', async () => {
+ const expandCombo = () => {
+ graph.updateComboData((data) => [
+ ...data,
+ {
+ id: 'combo-2',
+ style: {
+ collapsed: false,
+ },
+ },
+ ]);
+ graph.render();
+ };
+ const collapseCombo = (type: string) => {
+ graph.updateComboData((data) => [
+ ...data,
+ {
+ id: 'combo-2',
+ style: {
+ collapsed: true,
+ collapsedOrigin: 'top',
+ collapsedMarker: true,
+ collapsedMarkerType: type,
+ },
+ },
+ ]);
+ graph.render();
+ };
+ collapseCombo('child-count');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__marker_childCount');
+ expandCombo();
+ collapseCombo('descendant-count');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__marker_descendantCount');
+ expandCombo();
+ collapseCombo('node-count');
+ await expect(graph.getCanvas()).toMatchSnapshot(__filename, '{name}__marker_nodeCount');
+ });
+});
diff --git a/packages/g6/__tests__/unit/registry.spec.ts b/packages/g6/__tests__/unit/registry.spec.ts
index b3d529f337a..fe200b402af 100644
--- a/packages/g6/__tests__/unit/registry.spec.ts
+++ b/packages/g6/__tests__/unit/registry.spec.ts
@@ -1,5 +1,6 @@
import {
Circle,
+ CircleCombo,
Cubic,
CubicHorizontal,
CubicVertical,
@@ -34,7 +35,9 @@ describe('registry', () => {
'cubic-horizontal': CubicHorizontal,
'cubic-vertical': CubicVertical,
});
- expect(getPlugins('combo')).toEqual({});
+ expect(getPlugins('combo')).toEqual({
+ circle: CircleCombo,
+ });
expect(getPlugins('theme')).toEqual({
dark,
light,
diff --git a/packages/g6/__tests__/unit/runtime/element.spec.ts b/packages/g6/__tests__/unit/runtime/element.spec.ts
index 7084172b7bd..2b6a44b5f6d 100644
--- a/packages/g6/__tests__/unit/runtime/element.spec.ts
+++ b/packages/g6/__tests__/unit/runtime/element.spec.ts
@@ -132,11 +132,12 @@ describe('ElementController', () => {
const comboStyle = elementController.getElementComputedStyle('combo', 'combo-1');
+ expect(comboStyle.children[0].id).toEqual('node-3');
+
expect(omit(comboStyle, ['children'])).toEqual({
...LIGHT_THEME.combo?.style,
color: BUILT_IN_PALETTES.blues[0],
});
- expect(Object.keys(comboStyle.children)).toEqual(['node-3']);
});
it('runtime', async () => {
@@ -160,7 +161,6 @@ describe('ElementController', () => {
expect(elementController.getNodes().length).toBe(3);
expect(elementController.getEdges().length).toBe(2);
- // TODO 目前暂未提供 combo 图形,因此无法渲染 / Currently, combo graphics are not provided, so they cannot be rendered
- expect(elementController.getCombos().length).toBe(0);
+ expect(elementController.getCombos().length).toBe(1);
});
});
diff --git a/packages/g6/__tests__/unit/utils/anchor.spec.ts b/packages/g6/__tests__/unit/utils/anchor.spec.ts
new file mode 100644
index 00000000000..60159bd3f6a
--- /dev/null
+++ b/packages/g6/__tests__/unit/utils/anchor.spec.ts
@@ -0,0 +1,18 @@
+import { getXYByAnchor, parseAnchor } from '@/src/utils/anchor';
+import { AABB } from '@antv/g';
+
+describe('anchor', () => {
+ it('parseAnchor', () => {
+ expect(parseAnchor([0.5, 0.5])).toEqual([0.5, 0.5]);
+ expect(parseAnchor('0.5 0.5')).toEqual([0.5, 0.5]);
+ expect(parseAnchor('1.8 1.8')).toEqual([0.5, 0.5]);
+ });
+
+ it('getXYByAnchor', () => {
+ const bbox = new AABB();
+ bbox.setMinMax([0, 0, 0], [100, 100, 0]);
+ expect(getXYByAnchor(bbox, [0.5, 0.5])).toEqual([50, 50]);
+ expect(getXYByAnchor(bbox, '0.5 0.5')).toEqual([50, 50]);
+ expect(getXYByAnchor(bbox, [0.25, 0.25])).toEqual([25, 25]);
+ });
+});
diff --git a/packages/g6/__tests__/unit/utils/bbox.spec.ts b/packages/g6/__tests__/unit/utils/bbox.spec.ts
index bfe9879b915..de098cc48df 100644
--- a/packages/g6/__tests__/unit/utils/bbox.spec.ts
+++ b/packages/g6/__tests__/unit/utils/bbox.spec.ts
@@ -1,6 +1,8 @@
+import { Circle } from '@/src/elements';
import {
getBBoxHeight,
getBBoxWidth,
+ getElementsBBox,
getExpandedBBox,
getIncircleRadius,
getNearestPointToPoint,
@@ -32,6 +34,25 @@ describe('bbox', () => {
expect(getNodeBBox([10, 10, 0])).toEqual(bbox);
});
+ it('getElementsBBox', () => {
+ expect(getElementsBBox([])).toEqual(new AABB());
+ const node1 = new Circle({
+ style: {
+ x: 100,
+ y: 100,
+ },
+ });
+ const node2 = new Circle({
+ style: {
+ x: 200,
+ y: 200,
+ },
+ });
+ const bbox = new AABB();
+ bbox.setMinMax([75, 75, 0], [225, 225, 0]);
+ expect(getElementsBBox([node1, node2])).toEqual(bbox);
+ });
+
it('getPointBBox', () => {
const pointBBox = new AABB();
pointBBox.setMinMax([10, 10, 0], [10, 10, 0]);
diff --git a/packages/g6/__tests__/unit/utils/combo.spec.ts b/packages/g6/__tests__/unit/utils/combo.spec.ts
new file mode 100644
index 00000000000..bd82db3e514
--- /dev/null
+++ b/packages/g6/__tests__/unit/utils/combo.spec.ts
@@ -0,0 +1,41 @@
+import { Circle, CircleCombo } from '@/src/elements';
+import {
+ calculateCollapsedOrigin,
+ getCollapsedMarkerText,
+ getDescendantCount,
+ getXYByCollapsedOrigin,
+} from '@/src/utils/combo';
+
+describe('combo', () => {
+ it('calculateCollapsedOrigin', () => {
+ expect(calculateCollapsedOrigin('top', [100, 100], [200, 200])).toEqual([0.5, 0.25]);
+ expect(calculateCollapsedOrigin('bottom', [100, 100], [200, 200])).toEqual([0.5, 0.75]);
+ expect(calculateCollapsedOrigin('left', [100, 100], [200, 200])).toEqual([0.25, 0.5]);
+ expect(calculateCollapsedOrigin('right', [100, 100], [200, 200])).toEqual([0.75, 0.5]);
+ expect(calculateCollapsedOrigin('center', [100, 100], [200, 200])).toEqual([0.5, 0.5]);
+ expect(calculateCollapsedOrigin([0.5, 0.5], [100, 100], [200, 200])).toEqual([0.5, 0.5]);
+ });
+
+ it('getXYByCollapsedOrigin', () => {
+ expect(getXYByCollapsedOrigin('top', [100, 100], [100, 100], [200, 200])).toEqual([100, 50]);
+ expect(getXYByCollapsedOrigin('bottom', [100, 100], [100, 100], [200, 200])).toEqual([100, 150]);
+ expect(getXYByCollapsedOrigin('left', [100, 100], [100, 100], [200, 200])).toEqual([50, 100]);
+ expect(getXYByCollapsedOrigin('right', [100, 100], [100, 100], [200, 200])).toEqual([150, 100]);
+ expect(getXYByCollapsedOrigin('center', [100, 100], [100, 100], [200, 200])).toEqual([100, 100]);
+ expect(getXYByCollapsedOrigin([0.5, 0.5], [100, 100], [100, 100], [200, 200])).toEqual([100, 100]);
+ });
+
+ it('getCollapsedMarkerText', () => {
+ const children = [new CircleCombo({ style: { children: [new Circle({})] } })];
+ expect(getCollapsedMarkerText('child-count', children)).toEqual('1');
+ expect(getCollapsedMarkerText('descendant-count', children)).toEqual('2');
+ expect(getCollapsedMarkerText('node-count', children)).toEqual('1');
+ expect(getCollapsedMarkerText(undefined, children)).toEqual('');
+ });
+
+ it('getDescendantCount', () => {
+ expect(getDescendantCount([new Circle({}), new Circle({})])).toEqual(2);
+ expect(getDescendantCount([new CircleCombo({ style: { children: [new Circle({})] } })])).toEqual(2);
+ expect(getDescendantCount([new CircleCombo({ style: { children: [new Circle({})] } })], true)).toEqual(1);
+ });
+});
diff --git a/packages/g6/__tests__/unit/utils/edge.spec.ts b/packages/g6/__tests__/unit/utils/edge.spec.ts
index 31358b7ed40..83fd1793a0c 100644
--- a/packages/g6/__tests__/unit/utils/edge.spec.ts
+++ b/packages/g6/__tests__/unit/utils/edge.spec.ts
@@ -1,7 +1,9 @@
+import { Rect } from '@/src/elements';
import {
getCubicPath,
getCurveControlPoint,
getLabelPositionStyle,
+ getPolylineLoopControlPoints,
getPolylinePath,
getQuadraticPath,
getRadians,
@@ -157,6 +159,17 @@ describe('edge', () => {
true,
),
).toEqual([['M', 0, 10], ['L', 20, 20], ['L', 50, 50], ['L', 100, 100], ['Z']]);
+ expect(
+ getPolylinePath(
+ [
+ [0, 10],
+ [20, 20],
+ [50, 50],
+ [100, 100],
+ ],
+ 10,
+ )[1][1],
+ ).toBeCloseTo(13.33);
});
it('getRadians', () => {
@@ -166,4 +179,49 @@ describe('edge', () => {
expect(getRadians(bbox).bottom[0]).toBeCloseTo(EIGHTH_PI * 3);
expect(getRadians(bbox).top[0]).toBeCloseTo(-EIGHTH_PI * 5);
});
+
+ it('getPolylineLoopControlPoints', () => {
+ const node = new Rect({ style: { x: 100, y: 100, size: 100 } });
+ expect(getPolylineLoopControlPoints(node, [150, 100], [150, 100], 10)).toEqual([
+ [160, 100],
+ [160, 110],
+ [150, 110],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [100, 150], [100, 150], 10)).toEqual([
+ [100, 160],
+ [110, 160],
+ [110, 150],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [50, 100], [50, 100], 10)).toEqual([
+ [40, 100],
+ [40, 110],
+ [50, 110],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [100, 50], [100, 50], 10)).toEqual([
+ [100, 40],
+ [110, 40],
+ [110, 50],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [150, 150], [100, 150], 10)).toEqual([
+ [160, 150],
+ [160, 160],
+ [100, 160],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [150, 150], [150, 100], 10)).toEqual([
+ [160, 150],
+ [160, 100],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [120, 50], [140, 50], 10)).toEqual([
+ [120, 40],
+ [140, 40],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [150, 120], [150, 140], 10)).toEqual([
+ [160, 120],
+ [160, 140],
+ ]);
+ expect(getPolylineLoopControlPoints(node, [50, 120], [50, 140], 10)).toEqual([
+ [40, 120],
+ [40, 140],
+ ]);
+ });
});
diff --git a/packages/g6/__tests__/unit/utils/element.spec.ts b/packages/g6/__tests__/unit/utils/element.spec.ts
index 6972ca14f60..51d578681f5 100644
--- a/packages/g6/__tests__/unit/utils/element.spec.ts
+++ b/packages/g6/__tests__/unit/utils/element.spec.ts
@@ -45,6 +45,7 @@ describe('element', () => {
});
it('isSameNode', () => {
+ expect(isSameNode(node1, undefined!)).toBeFalsy();
expect(isSameNode(node1, node2)).toBeFalsy();
expect(isSameNode(node1, node1)).toBeTruthy();
});
diff --git a/packages/g6/src/elements/combos/base-combo.ts b/packages/g6/src/elements/combos/base-combo.ts
new file mode 100644
index 00000000000..9abbb600d98
--- /dev/null
+++ b/packages/g6/src/elements/combos/base-combo.ts
@@ -0,0 +1,136 @@
+import type { AABB, BaseStyleProps, DisplayObject, DisplayObjectConfig, Group } from '@antv/g';
+import { deepMix, isEmpty } from '@antv/util';
+import type { BaseComboProps, Position, PrefixObject, STDSize } from '../../types';
+import { getElementsBBox, getExpandedBBox } from '../../utils/bbox';
+import { getCollapsedMarkerText, getXYByCollapsedOrigin } from '../../utils/combo';
+import { getXYByPosition } from '../../utils/element';
+import { subStyleProps } from '../../utils/prefix';
+import { parseSize } from '../../utils/size';
+import type { BaseNodeStyleProps } from '../nodes';
+import { BaseNode } from '../nodes';
+import { Icon, IconStyleProps } from '../shapes';
+
+export type CollapsedMarkerStyleProps = IconStyleProps & {
+ /**
+ * 标记类型,childCount 表示子元素数量,descendantCount 表示后代元素数量, node-count 表示后代节点数量
+ * Marker type, child-count means the number of child elements, descendant-count means the number of descendant elements, node-count means the number of descendant nodes
+ */
+ type?: 'child-count' | 'descendant-count' | 'node-count';
+};
+export type BaseComboStyleProps = BaseComboProps &
+ PrefixObject & {
+ collapsedMarker?: boolean;
+ } & PrefixObject &
+ BaseNodeStyleProps;
+export type ParsedBaseComboStyleProps = Required<
+ BaseComboStyleProps
+>;
+
+export abstract class BaseCombo<
+ KeyShape extends DisplayObject,
+ KeyStyleProps extends BaseStyleProps = BaseStyleProps,
+> extends BaseNode {
+ public type = 'combo';
+
+ static defaultStyleProps: BaseComboStyleProps = {
+ size: 0,
+ collapsed: false,
+ collapsedSize: 32,
+ collapsedOrigin: [0.5, 0.5],
+ padding: 0,
+ children: [],
+ collapsedMarker: true,
+ collapsedMarkerType: 'child-count',
+ collapsedMarkerFontSize: 12,
+ collapsedMarkerTextBaseline: 'middle',
+ collapsedMarkerTextAlign: 'center',
+ };
+ constructor(options: DisplayObjectConfig>) {
+ super(deepMix({}, { style: BaseCombo.defaultStyleProps }, options));
+ }
+
+ /**
+ * Draw the key shape of combo
+ */
+ protected abstract drawKeyShape(
+ attributes: ParsedBaseComboStyleProps,
+ container: Group,
+ ): KeyShape | undefined;
+
+ protected calculatePosition(attributes: ParsedBaseComboStyleProps): Position {
+ const { x: comboX, y: comboY, collapsed, collapsedOrigin } = attributes;
+ if (!isEmpty(comboX) && !isEmpty(comboY)) return [comboX, comboY, 0] as Position;
+
+ const contentBBox = this.getContentBBox(attributes);
+ let position: Position = contentBBox.center;
+ const computedExpandedSize = this.getExpandedKeySize(attributes);
+ const computedCollapsedSize = this.getCollapsedKeySize(attributes);
+
+ if (collapsed) {
+ position = getXYByCollapsedOrigin(
+ collapsedOrigin!,
+ contentBBox.center,
+ computedCollapsedSize,
+ computedExpandedSize,
+ );
+ }
+
+ return position;
+ }
+
+ protected getKeySize(attributes: ParsedBaseComboStyleProps): STDSize {
+ const { size, collapsed, collapsedSize } = attributes;
+
+ if (collapsed && !isEmpty(collapsedSize)) return parseSize(collapsedSize);
+
+ if (!collapsed && !isEmpty(size)) return parseSize(size);
+
+ return collapsed ? this.getCollapsedKeySize(attributes) : this.getExpandedKeySize(attributes);
+ }
+
+ protected abstract getCollapsedKeySize(attributes: ParsedBaseComboStyleProps): STDSize;
+
+ protected abstract getExpandedKeySize(attributes: ParsedBaseComboStyleProps): STDSize;
+
+ protected getContentBBox(attributes: ParsedBaseComboStyleProps): AABB {
+ const { children, padding } = attributes;
+ let childrenBBox = getElementsBBox(children!);
+ if (padding) {
+ childrenBBox = getExpandedBBox(childrenBBox, padding);
+ }
+ return childrenBBox;
+ }
+
+ protected drawCollapsedMarkerShape(attributes: ParsedBaseComboStyleProps, container: Group): void {
+ this.upsert('collapsed-marker', Icon, this.getCollapsedMarkerStyle(attributes), container);
+ }
+
+ protected getCollapsedMarkerStyle(attributes: ParsedBaseComboStyleProps): IconStyleProps | false {
+ if (!attributes.collapsed || !attributes.collapsedMarker) return false;
+
+ const { type, ...collapsedMarkerStyle } = subStyleProps(
+ this.getGraphicStyle(attributes),
+ 'collapsedMarker',
+ );
+ const keyShape = this.getKey();
+ const [x, y] = getXYByPosition(keyShape.getLocalBounds(), 'center');
+
+ if (type) {
+ const text = getCollapsedMarkerText(type, attributes.children!);
+ return { ...collapsedMarkerStyle, x, y, text };
+ }
+
+ return { ...collapsedMarkerStyle, x, y };
+ }
+
+ public render(attributes: ParsedBaseComboStyleProps, container: Group = this) {
+ super.render(attributes, container);
+
+ const [x, y] = this.calculatePosition(attributes);
+ this.style.x = x;
+ this.style.y = y;
+
+ // collapsed marker
+ this.drawCollapsedMarkerShape(attributes, container);
+ }
+}
diff --git a/packages/g6/src/elements/combos/circle.ts b/packages/g6/src/elements/combos/circle.ts
new file mode 100644
index 00000000000..3c9b0a08fdb
--- /dev/null
+++ b/packages/g6/src/elements/combos/circle.ts
@@ -0,0 +1,51 @@
+import type { DisplayObjectConfig, CircleStyleProps as GCircleStyleProps } from '@antv/g';
+import { Circle as GCircle, Group } from '@antv/g';
+import type { STDSize } from '../../types';
+import { getBBoxHeight, getBBoxWidth } from '../../utils/bbox';
+import { subStyleProps } from '../../utils/prefix';
+import { parseSize } from '../../utils/size';
+import type { BaseComboStyleProps, ParsedBaseComboStyleProps } from './base-combo';
+import { BaseCombo } from './base-combo';
+
+type KeyStyleProps = GCircleStyleProps;
+export type CircleComboStyleProps = BaseComboStyleProps;
+type ParsedCircleComboStyleProps = ParsedBaseComboStyleProps;
+type CircleComboOptions = DisplayObjectConfig;
+
+export class CircleCombo extends BaseCombo {
+ constructor(options: CircleComboOptions) {
+ super(options);
+ }
+
+ protected drawKeyShape(attributes: ParsedCircleComboStyleProps, container: Group): GCircle | undefined {
+ return this.upsert('key', GCircle, this.getKeyStyle(attributes), container);
+ }
+
+ protected getKeyStyle(attributes: ParsedCircleComboStyleProps): GCircleStyleProps {
+ const { collapsed } = attributes;
+ const keyStyle = super.getKeyStyle(attributes);
+ const collapsedStyle = subStyleProps(keyStyle, 'collapsed');
+
+ const [width] = this.getKeySize(attributes);
+ return {
+ ...keyStyle,
+ ...(collapsed && collapsedStyle),
+ r: width / 2,
+ };
+ }
+
+ protected getCollapsedKeySize(attributes: ParsedCircleComboStyleProps): STDSize {
+ const [collapsedWidth, collapsedHeight] = parseSize(attributes.collapsedSize);
+ const collapsedR = Math.max(collapsedWidth, collapsedHeight) / 2;
+ return [collapsedR * 2, collapsedR * 2, 0];
+ }
+
+ protected getExpandedKeySize(attributes: ParsedCircleComboStyleProps): STDSize {
+ const [expandedWidth, expandedHeight] = parseSize(attributes.size);
+ const contentBBox = this.getContentBBox(attributes);
+ const width = expandedWidth || getBBoxWidth(contentBBox);
+ const height = expandedHeight || getBBoxHeight(contentBBox);
+ const expandedR = Math.sqrt(width ** 2 + height ** 2) / 2;
+ return [expandedR * 2, expandedR * 2, 0];
+ }
+}
diff --git a/packages/g6/src/elements/combos/index.ts b/packages/g6/src/elements/combos/index.ts
new file mode 100644
index 00000000000..ea57749fd2c
--- /dev/null
+++ b/packages/g6/src/elements/combos/index.ts
@@ -0,0 +1,5 @@
+export { BaseCombo } from './base-combo';
+export { CircleCombo } from './circle';
+
+export type { BaseComboStyleProps } from './base-combo';
+export type { CircleComboStyleProps } from './circle';
diff --git a/packages/g6/src/elements/edges/base-edge.ts b/packages/g6/src/elements/edges/base-edge.ts
index 83bd96b3dd9..62166b3c355 100644
--- a/packages/g6/src/elements/edges/base-edge.ts
+++ b/packages/g6/src/elements/edges/base-edge.ts
@@ -42,6 +42,8 @@ export type BaseEdgeStyleProps = BaseEdgeProps &
type ParsedBaseEdgeStyleProps = Required;
export abstract class BaseEdge extends BaseShape {
+ public type = 'edge';
+
static defaultStyleProps: Partial = {
isBillboard: true,
label: true,
diff --git a/packages/g6/src/elements/index.ts b/packages/g6/src/elements/index.ts
index 0d1086885fd..802f1ae20f7 100644
--- a/packages/g6/src/elements/index.ts
+++ b/packages/g6/src/elements/index.ts
@@ -1,2 +1,3 @@
+export { CircleCombo } from './combos';
export { Cubic, CubicHorizontal, CubicVertical, Line, Polyline, Quadratic } from './edges';
export { Circle, Ellipse, Image, Rect, Star, Triangle } from './nodes';
diff --git a/packages/g6/src/elements/nodes/base-node.ts b/packages/g6/src/elements/nodes/base-node.ts
index c18aa767559..dcfcdda09ba 100644
--- a/packages/g6/src/elements/nodes/base-node.ts
+++ b/packages/g6/src/elements/nodes/base-node.ts
@@ -35,8 +35,8 @@ export type BaseNodeStyleProps 背景色板
- * Background color palette
+ * 徽标的背景色板
+ * Badge background color palette
*/
badgePalette?: string[] | CategoricalPalette;
} & PrefixObject &
@@ -58,6 +58,8 @@ export type ParsedBaseNodeStyleProps = Req
export abstract class BaseNode extends BaseShape<
BaseNodeStyleProps
> {
+ public type = 'node';
+
static defaultStyleProps: BaseNodeStyleProps = {
x: 0,
y: 0,
diff --git a/packages/g6/src/elements/nodes/rect.ts b/packages/g6/src/elements/nodes/rect.ts
index bc285e44458..9475697352a 100644
--- a/packages/g6/src/elements/nodes/rect.ts
+++ b/packages/g6/src/elements/nodes/rect.ts
@@ -16,6 +16,7 @@ type KeyStyleProps = GRectStyleProps;
export class Rect extends BaseNode {
static defaultStyleProps: Partial = {
size: [100, 30],
+ anchor: [0.5, 0.5],
};
constructor(options: DisplayObjectConfig) {
@@ -28,7 +29,6 @@ export class Rect extends BaseNode {
...(super.getKeyStyle(attributes) as KeyStyleProps),
width,
height,
- anchor: [0.5, 0.5], // !!! It cannot be set to default values because G.CustomElement cannot handle it properly.
};
}
diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts
index 60117413afd..423e9758de9 100644
--- a/packages/g6/src/registry/build-in.ts
+++ b/packages/g6/src/registry/build-in.ts
@@ -2,6 +2,7 @@ import { fade, translate } from '../animations';
import { DragCanvas, ZoomCanvas } from '../behaviors';
import {
Circle,
+ CircleCombo,
Cubic,
CubicHorizontal,
CubicVertical,
@@ -48,7 +49,9 @@ export const BUILT_IN_PLUGINS = {
'zoom-canvas': ZoomCanvas,
'drag-canvas': DragCanvas,
},
- combo: {},
+ combo: {
+ circle: CircleCombo,
+ },
edge: {
cubic: Cubic,
line: Line,
diff --git a/packages/g6/src/registry/types.ts b/packages/g6/src/registry/types.ts
index fcde8a621e2..b9713f12f27 100644
--- a/packages/g6/src/registry/types.ts
+++ b/packages/g6/src/registry/types.ts
@@ -3,12 +3,9 @@ import type { STDAnimation } from '../animations/types';
import type { Behavior } from '../behaviors/types';
import type { STDPalette } from '../palettes/types';
import type { Theme } from '../themes/types';
-import type { Edge, Node } from '../types';
+import type { Combo, Edge, Node } from '../types';
import type { Widget } from '../widgets/types';
-// TODO 待使用正式类型定义 / To be used formal type definition
-declare type Combo = unknown;
-
/**
* 插件注册表
*
diff --git a/packages/g6/src/runtime/element.ts b/packages/g6/src/runtime/element.ts
index 9cec4ea38c2..5a39f8507b8 100644
--- a/packages/g6/src/runtime/element.ts
+++ b/packages/g6/src/runtime/element.ts
@@ -172,9 +172,9 @@ export class ElementController {
const datum = this.getElementData(elementType, [id])?.[0];
if (!datum) return {};
- // `data.style` 中一些样式例如 parentId, collapsed, type 并非直接给元素使用,因此需要过滤掉这些字段
- // Some styles in `data.style`, such as parentId, collapsed, type, are not directly used by the element, so these fields need to be filtered out
- const { parentId, collapsed, type, states, ...style } = datum.style || {};
+ // `data.style` 中一些样式例如 parentId, type 并非直接给元素使用,因此需要过滤掉这些字段
+ // Some styles in `data.style`, such as parentId, type, are not directly used by the element, so these fields need to be filtered out
+ const { parentId, type, states, ...style } = datum.style || {};
return style;
}
@@ -415,9 +415,8 @@ export class ElementController {
private getComboChildren(id: ID) {
const { model } = this.context;
- return Object.fromEntries(
- model.getComboChildrenData(id).map((datum) => [idOf(datum), this.getElement(idOf(datum))]),
- );
+
+ return model.getComboChildrenData(id).map((datum) => this.getElement(idOf(datum))!);
}
public getElementComputedStyle(elementType: ElementType, id: ID) {
@@ -498,7 +497,7 @@ export class ElementController {
const edgesToRemove = dataOf(EdgeRemoved);
const combosToRemove = dataOf(ComboRemoved);
- // 如果更新了节点,需要更新连接的边和所处的 combo
+ // 如果更新了节点,需要更新连接的边
// If the node is updated, the connected edge and the combo it is in need to be updated
// TODO 待优化,仅考虑影响边更新的属性,如 x, y, size 等
nodesToUpdate
@@ -506,9 +505,18 @@ export class ElementController {
.flat()
.forEach((edge) => edgesToUpdate.push(edge));
+ // 如果操作(新增/更新/移除)了节点或 combo,需要更新相对应的 combo
+ // If nodes or combos are operated (added/updated/removed), the related combo needs to be updated
model
.getComboData(
- [...nodesToUpdate, ...nodesToRemove, ...combosToUpdate, ...combosToRemove].reduce((acc, curr) => {
+ [
+ ...nodesToAdd,
+ ...nodesToUpdate,
+ ...nodesToRemove,
+ ...combosToAdd,
+ ...combosToUpdate,
+ ...combosToRemove,
+ ].reduce((acc, curr) => {
const parentId = curr?.style?.parentId;
if (parentId) acc.push(parentId);
return acc;
@@ -570,7 +578,6 @@ export class ElementController {
const Ctor = getPlugin(elementType, shapeType);
if (!Ctor) return () => null;
const shape = this.container[elementType].appendChild(
- // @ts-expect-error TODO fix type
new Ctor({
id,
style: {
diff --git a/packages/g6/src/themes/dark.ts b/packages/g6/src/themes/dark.ts
index 82d4665c285..fefc487e3b3 100644
--- a/packages/g6/src/themes/dark.ts
+++ b/packages/g6/src/themes/dark.ts
@@ -133,10 +133,10 @@ export const dark: Theme = {
},
combo: {
style: {
+ collapsedSize: 32,
fill: COMBO_FILL,
haloLineWidth: 12,
haloStrokeOpacity: 0.25,
- iconContentType: 'childCount',
iconFill: COMBO_STROKE,
iconFontSize: 12,
labelBackgroundFill: BG_COLOR,
@@ -147,8 +147,8 @@ export const dark: Theme = {
labelFontSize: 12,
labelMaxLines: 1,
lineWidth: 1,
- padding: [25, 20, 15, 20],
- size: 10,
+ padding: 10,
+ size: 0,
stroke: COMBO_STROKE,
},
state: {
@@ -179,5 +179,15 @@ export const dark: Theme = {
labelOpacity: 0.25,
},
},
+ animation: {
+ enter: 'fade',
+ exit: 'fade',
+ hide: 'fade',
+ show: 'fade',
+ update: [
+ { fields: ['cx', 'cy', 'r', 'x', 'y', 'width', 'height'], shape: 'key' },
+ { fields: ['x', 'y'], shape: 'label' },
+ ],
+ },
},
};
diff --git a/packages/g6/src/themes/light.ts b/packages/g6/src/themes/light.ts
index fd8676f3af2..da5ee8f4a05 100644
--- a/packages/g6/src/themes/light.ts
+++ b/packages/g6/src/themes/light.ts
@@ -132,10 +132,10 @@ export const light: Theme = {
},
combo: {
style: {
+ collapsedSize: 32,
fill: COMBO_FILL,
haloLineWidth: 12,
haloStrokeOpacity: 0.25,
- iconContentType: 'childCount',
iconFill: COMBO_STROKE,
iconFontSize: 12,
labelBackgroundFill: BG_COLOR,
@@ -146,8 +146,8 @@ export const light: Theme = {
labelFontSize: 12,
labelMaxLines: 1,
lineWidth: 1,
- padding: [25, 20, 15, 20],
- size: 10,
+ padding: 10,
+ size: 0,
stroke: COMBO_STROKE,
},
state: {
@@ -175,5 +175,12 @@ export const light: Theme = {
stroke: COMBO_STROKE_DISABLED,
},
},
+ animation: {
+ enter: 'fade',
+ exit: 'fade',
+ hide: 'fade',
+ show: 'fade',
+ update: [{ fields: ['x', 'y'] }, { fields: ['r', 'width', 'height'], shape: 'key' }],
+ },
},
};
diff --git a/packages/g6/src/types/anchor.ts b/packages/g6/src/types/anchor.ts
new file mode 100644
index 00000000000..4a90c653058
--- /dev/null
+++ b/packages/g6/src/types/anchor.ts
@@ -0,0 +1,5 @@
+import type { Vector2, Vector3 } from './vector';
+
+export type Anchor = string | Vector2 | Vector3;
+
+export type STDAnchor = Vector2;
diff --git a/packages/g6/src/types/element.ts b/packages/g6/src/types/element.ts
index 5c3e72d2f77..7dad6145a48 100644
--- a/packages/g6/src/types/element.ts
+++ b/packages/g6/src/types/element.ts
@@ -1,7 +1,9 @@
import type { BaseStyleProps, DisplayObject, PathStyleProps } from '@antv/g';
+import type { BaseCombo } from '../elements/combos';
import type { BaseEdge } from '../elements/edges';
import type { BaseNode } from '../elements/nodes';
import type { ComboOptions, EdgeOptions, NodeOptions } from '../spec';
+import type { Padding } from './padding';
import type { Size } from './size';
export type ElementType = 'node' | 'edge' | 'combo';
@@ -12,6 +14,10 @@ export type Node = BaseNode;
export type Edge = BaseEdge;
+export type Combo = BaseCombo;
+
+export type Element = Node | Edge | Combo;
+
export type BaseNodeProps = BaseStyleProps & {
/**
* x 坐标
@@ -44,6 +50,11 @@ export type BaseNodeProps = BaseStyleProps & {
* @ignore
*/
points?: ([number, number] | [number, number, number])[];
+ /**
+ * 父节点 id
+ * The id of the parent node/combo
+ */
+ parentId?: string;
};
export type BaseEdgeProps = BaseStyleProps &
@@ -77,3 +88,36 @@ export type BaseEdgeProps = BaseStyleProps &
*/
targetPort?: string;
};
+
+export type BaseComboProps = {
+ /**
+ * Combo 展开后的默认大小
+ * The default size of combo when expanded
+ */
+ size?: Size;
+ /**
+ * Combo 是否收起
+ * Indicates whether combo is collapsed
+ */
+ collapsed?: boolean;
+ /**
+ * Combo 收起后的默认大小
+ * The default size of combo when collapsed
+ */
+ collapsedSize?: Size;
+ /**
+ * Combo 收起后的原点
+ * The origin of combo when collapsed
+ */
+ collapsedOrigin?: string | [number, number];
+ /**
+ * Combo 的子元素,可以是节点或者 Combo
+ * The children of combo, which can be nodes or combos
+ */
+ children?: (Node | Combo)[];
+ /**
+ * Combo 的内边距,只在展开状态下生效
+ * The padding of combo, only effective when expanded
+ */
+ padding?: Padding;
+};
diff --git a/packages/g6/src/utils/anchor.ts b/packages/g6/src/utils/anchor.ts
new file mode 100644
index 00000000000..4c27b96bae9
--- /dev/null
+++ b/packages/g6/src/utils/anchor.ts
@@ -0,0 +1,35 @@
+import type { AABB } from '@antv/g';
+import type { Position } from '../types';
+import type { Anchor, STDAnchor } from '../types/anchor';
+import { isBetween } from './math';
+
+/**
+ * 解析原点(锚点)
+ *
+ * Parse the origin/anchor
+ * @param anchor - 原点 | Anchor
+ * @returns 标准原点 | Standard anchor
+ */
+export function parseAnchor(anchor: Anchor): STDAnchor {
+ const parsedAnchor = (
+ typeof anchor === 'string' ? anchor.split(' ').map((v) => parseFloat(v)) : anchor.slice(0, 2)
+ ) as [number, number];
+ if (!isBetween(parsedAnchor[0], 0, 1) || !isBetween(parsedAnchor[1], 0, 1)) {
+ return [0.5, 0.5];
+ }
+ return parsedAnchor;
+}
+
+/**
+ * 获取锚点在 Canvas 坐标系下的位置
+ *
+ * Get the position of the anchor in the Canvas coordinate system
+ * @param bbox - 包围盒 | Bounding box
+ * @param anchor - 锚点 | Anchor
+ * @returns 在画布上的位置 | The position on the canvas
+ */
+export function getXYByAnchor(bbox: AABB, anchor: Anchor): Position {
+ const [anchorX, anchorY] = parseAnchor(anchor);
+ const { min, max } = bbox;
+ return [min[0] + anchorX * (max[0] - min[0]), min[1] + anchorY * (max[1] - min[1])];
+}
diff --git a/packages/g6/src/utils/bbox.ts b/packages/g6/src/utils/bbox.ts
index 9228746cc56..f1d13a1d702 100644
--- a/packages/g6/src/utils/bbox.ts
+++ b/packages/g6/src/utils/bbox.ts
@@ -1,7 +1,7 @@
import { AABB } from '@antv/g';
import { clone } from '@antv/util';
import { TriangleDirection } from '../elements/nodes/triangle';
-import type { Node, Padding, Point } from '../types';
+import type { Element, Node, Padding, Point } from '../types';
import { isPoint } from './is';
import { isBetween } from './math';
import { parsePadding } from './padding';
@@ -41,6 +41,27 @@ export function getNodeBBox(node: Point | Node, padding?: Padding): AABB {
return padding ? getExpandedBBox(bbox, padding) : bbox;
}
+/**
+ * 获取多个元素的联合包围盒
+ *
+ * Get the union bounding box of multiple elements
+ * @param elements - 元素数组 | Array of elements
+ * @returns 包围盒 | Bounding box
+ */
+export function getElementsBBox(elements: Element[]): AABB {
+ let resBBox: AABB = new AABB(); // Initialize resBBox with an empty AABB object
+
+ if (!elements.length) return resBBox;
+
+ elements.forEach((element, i) => {
+ const bbox = element.getBounds();
+ if (i === 0) resBBox = bbox;
+ else resBBox = union(resBBox, bbox);
+ });
+
+ return resBBox;
+}
+
/**
* 获取单点的包围盒
*
diff --git a/packages/g6/src/utils/combo.ts b/packages/g6/src/utils/combo.ts
new file mode 100644
index 00000000000..fa1fd474c55
--- /dev/null
+++ b/packages/g6/src/utils/combo.ts
@@ -0,0 +1,99 @@
+import { AABB } from '@antv/g';
+import type { CollapsedMarkerStyleProps } from '../elements/combos/base-combo';
+import type { Combo, Node, Point, Position, Size } from '../types';
+import { getXYByAnchor } from './anchor';
+import { isNode } from './element';
+import { parseSize } from './size';
+
+/**
+ * 计算 Combo 收起后原点的相对位置
+ *
+ * Calculate the relative position of the origin after the Combo is collapsed
+ * @param collapsedOrigin - 收起时的原点 | origin when collapsed
+ * @param collapsedSize - 折叠尺寸 | folding size
+ * @param expandedSize - 展开尺寸 | expanded size
+ * @returns 折叠后的原点 | origin after folding
+ */
+export function calculateCollapsedOrigin(
+ collapsedOrigin: string | [number, number],
+ collapsedSize: Size,
+ expandedSize: Size,
+): Position {
+ if (Array.isArray(collapsedOrigin)) return collapsedOrigin;
+ const [expandedWidth, expandedHeight] = parseSize(expandedSize);
+ const [collapsedWidth, collapsedHeight] = parseSize(collapsedSize);
+ const map: Record = {
+ top: [0.5, collapsedHeight / 2 / expandedHeight],
+ bottom: [0.5, 1 - collapsedHeight / 2 / expandedHeight],
+ left: [collapsedWidth / 2 / expandedWidth, 0.5],
+ right: [1 - collapsedWidth / 2 / expandedWidth, 0.5],
+ center: [0.5, 0.5],
+ };
+ return map[collapsedOrigin] || map.center;
+}
+
+/**
+ * 计算 Combo 收起后原点的实际位置
+ *
+ * Calculate the actual position of the origin after the Combo is collapsed
+ * @param collapsedOrigin - 收起时的原点 | origin when collapsed
+ * @param center - 中心点 | center
+ * @param collapsedSize - 折叠尺寸 | folding size
+ * @param expandedSize - 展开尺寸 | expanded size
+ * @returns 原点实际位置 | actual position of the origin
+ */
+export function getXYByCollapsedOrigin(
+ collapsedOrigin: string | [number, number],
+ center: Point,
+ collapsedSize: Size,
+ expandedSize: Size,
+): Position {
+ const origin = calculateCollapsedOrigin(collapsedOrigin, collapsedSize, expandedSize);
+ const [expandedWidth, expandedHeight] = parseSize(expandedSize);
+ const expandedBBox = new AABB();
+ expandedBBox.setMinMax(
+ [center[0] - expandedWidth / 2, center[1] - expandedHeight / 2, 0],
+ [center[0] + expandedWidth / 2, center[1] + expandedHeight / 2, 0],
+ );
+ return getXYByAnchor(expandedBBox, origin);
+}
+
+/**
+ * 获取收起时标记的文本
+ *
+ * Get the text of the collapsed marker
+ * @param type - 收起时标记类型 | type of the collapsed marker
+ * @param children - 子元素 | children
+ * @returns 收起时标记文本 | text of the collapsed marker
+ */
+export function getCollapsedMarkerText(type: CollapsedMarkerStyleProps['type'], children: (Node | Combo)[]) {
+ if (type === 'descendant-count') {
+ return getDescendantCount(children).toString();
+ } else if (type === 'child-count') {
+ return children.length.toString();
+ } else if (type === 'node-count') {
+ return getDescendantCount(children, true).toString();
+ }
+ return '';
+}
+
+/**
+ * 获取子孙节点数量
+ *
+ * Get the number of descendant nodes
+ * @param children - 子元素 | children
+ * @param onlyNode - 是否只统计 Node 类型的子孙节点| Whether to only count the descendant nodes of the Node type
+ * @returns 子孙节点数量 | number of descendant nodes
+ */
+export function getDescendantCount(children: (Node | Combo)[], onlyNode = false): number {
+ let count = 0;
+ for (const child of children) {
+ if (!onlyNode || isNode(child)) {
+ count += 1;
+ }
+ if ('children' in child.attributes) {
+ count += getDescendantCount(child.attributes.children as (Node | Combo)[], onlyNode);
+ }
+ }
+ return count;
+}
diff --git a/packages/g6/src/utils/edge.ts b/packages/g6/src/utils/edge.ts
index 5afd7ac8ec3..20baf3642d1 100644
--- a/packages/g6/src/utils/edge.ts
+++ b/packages/g6/src/utils/edge.ts
@@ -451,7 +451,7 @@ export function getPolylineLoopPath(
* @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop
* @returns 控制点 | Control points
*/
-function getPolylineLoopControlPoints(node: Node, sourcePoint: Point, targetPoint: Point, dist: number) {
+export function getPolylineLoopControlPoints(node: Node, sourcePoint: Point, targetPoint: Point, dist: number) {
const controlPoints: Point[] = [];
const bbox = getNodeBBox(node);
diff --git a/packages/g6/src/utils/element.ts b/packages/g6/src/utils/element.ts
index 65841433258..4515afa01fe 100644
--- a/packages/g6/src/utils/element.ts
+++ b/packages/g6/src/utils/element.ts
@@ -17,7 +17,7 @@ import { findNearestPoints, getEllipseIntersectPoint } from './point';
* @returns 是否是 BaseNode 的实例 | whether the instance is BaseNode
*/
export function isNode(shape: DisplayObject): shape is Node {
- return shape instanceof BaseNode;
+ return shape instanceof BaseNode && shape.type === 'node';
}
/**