diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9f5a6faad33..93f4205f48a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -58,6 +58,7 @@
"pointset",
"Polyline",
"ranksep",
+ "Snapline",
"Timebar"
],
"javascript.preferences.importModuleSpecifier": "relative",
diff --git a/packages/g6/__tests__/demos/index.ts b/packages/g6/__tests__/demos/index.ts
index 62a60412dd2..48f190d2962 100644
--- a/packages/g6/__tests__/demos/index.ts
+++ b/packages/g6/__tests__/demos/index.ts
@@ -119,6 +119,7 @@ export { pluginHistory } from './plugin-history';
export { pluginHull } from './plugin-hull';
export { pluginLegend } from './plugin-legend';
export { pluginMinimap } from './plugin-minimap';
+export { pluginSnapline } from './plugin-snapline';
export { pluginTimebar } from './plugin-timebar';
export { pluginToolbarBuildIn } from './plugin-toolbar-build-in';
export { pluginToolbarIconfont } from './plugin-toolbar-iconfont';
diff --git a/packages/g6/__tests__/demos/plugin-snapline.ts b/packages/g6/__tests__/demos/plugin-snapline.ts
new file mode 100644
index 00000000000..e31af8feb6b
--- /dev/null
+++ b/packages/g6/__tests__/demos/plugin-snapline.ts
@@ -0,0 +1,66 @@
+import { Graph, Node } from '@antv/g6';
+
+export const pluginSnapline: TestCase = async (context) => {
+ const graph = new Graph({
+ ...context,
+ data: {
+ nodes: [
+ { id: 'node1', style: { x: 100, y: 100 } },
+ { id: 'node2', style: { x: 300, y: 300 } },
+ { id: 'node3', style: { x: 120, y: 200 } },
+ ],
+ },
+ node: {
+ type: (datum) => (datum.id === 'node3' ? 'circle' : 'rect'),
+ style: {
+ size: (datum) => (datum.id === 'node3' ? 40 : [60, 30]),
+ fill: 'transparent',
+ lineWidth: 2,
+ labelText: (datum) => datum.id,
+ },
+ },
+ behaviors: ['drag-element', 'drag-canvas'],
+ plugins: [
+ {
+ type: 'snapline',
+ key: 'snapline',
+ verticalLineStyle: { stroke: '#F08F56', lineWidth: 2 },
+ horizontalLineStyle: { stroke: '#17C76F', lineWidth: 2 },
+ autoSnap: false,
+ },
+ ],
+ });
+
+ await graph.render();
+
+ const config = {
+ filter: false,
+ offset: 20,
+ };
+
+ pluginSnapline.form = (panel) => {
+ return [
+ panel
+ .add(config, 'filter')
+ .name('Add Filter(exclude circle)')
+ .onChange((filter: boolean) => {
+ graph.updatePlugin({
+ key: 'snapline',
+ filter: (node: Node) => (filter ? node.id !== 'node3' : true),
+ });
+ graph.render();
+ }),
+ panel
+ .add(config, 'offset', [0, 20, Infinity])
+ .name('Offset')
+ .onChange((offset: string) => {
+ graph.updatePlugin({
+ key: 'snapline',
+ offset,
+ });
+ graph.render();
+ }),
+ ];
+ };
+ return graph;
+};
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/auto-snap.svg b/packages/g6/__tests__/snapshots/plugins/snapline/auto-snap.svg
new file mode 100644
index 00000000000..8bbbca5f8a4
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/auto-snap.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/default.svg b/packages/g6/__tests__/snapshots/plugins/snapline/default.svg
new file mode 100644
index 00000000000..7741cd50f9d
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/default.svg
@@ -0,0 +1,45 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-0.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-0.svg
new file mode 100644
index 00000000000..ccc0f301e1b
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-0.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-1.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-1.svg
new file mode 100644
index 00000000000..b963fc020d2
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-1.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-10.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-10.svg
new file mode 100644
index 00000000000..5e7eb06e1cc
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-10.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-2.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-2.svg
new file mode 100644
index 00000000000..66a857601c0
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-2.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-3.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-3.svg
new file mode 100644
index 00000000000..a0b0e71e418
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-3.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-4.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-4.svg
new file mode 100644
index 00000000000..7f8c4a7fe8e
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-4.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-5.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-5.svg
new file mode 100644
index 00000000000..64272a52346
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-5.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-6.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-6.svg
new file mode 100644
index 00000000000..71a0cc8f5cc
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-6.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-7.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-7.svg
new file mode 100644
index 00000000000..b306d6d0263
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-7.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-8.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-8.svg
new file mode 100644
index 00000000000..dead91d7db2
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-8.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-9.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-9.svg
new file mode 100644
index 00000000000..3cb84584330
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-horizontal-9.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-0.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-0.svg
new file mode 100644
index 00000000000..e111ba9d64f
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-0.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-1.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-1.svg
new file mode 100644
index 00000000000..13ddea3d98f
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-1.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-10.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-10.svg
new file mode 100644
index 00000000000..ab7a9731a28
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-10.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-11.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-11.svg
new file mode 100644
index 00000000000..57bc90cc489
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-11.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-2.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-2.svg
new file mode 100644
index 00000000000..8f5b29c3cbb
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-2.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-3.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-3.svg
new file mode 100644
index 00000000000..33b2db10a0e
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-3.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-4.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-4.svg
new file mode 100644
index 00000000000..bf8dabf4744
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-4.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-5.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-5.svg
new file mode 100644
index 00000000000..36d5de3efe3
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-5.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-6.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-6.svg
new file mode 100644
index 00000000000..5f7038ba3c8
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-6.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-7.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-7.svg
new file mode 100644
index 00000000000..1d5066c8a61
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-7.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-8.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-8.svg
new file mode 100644
index 00000000000..0e206345973
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-8.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-9.svg b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-9.svg
new file mode 100644
index 00000000000..d1adad9e97f
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/drag-node3-vertical-9.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/filter-node2.svg b/packages/g6/__tests__/snapshots/plugins/snapline/filter-node2.svg
new file mode 100644
index 00000000000..aebc4f32a04
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/filter-node2.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/snapshots/plugins/snapline/offset-infinity.svg b/packages/g6/__tests__/snapshots/plugins/snapline/offset-infinity.svg
new file mode 100644
index 00000000000..8bbbca5f8a4
--- /dev/null
+++ b/packages/g6/__tests__/snapshots/plugins/snapline/offset-infinity.svg
@@ -0,0 +1,51 @@
+
\ No newline at end of file
diff --git a/packages/g6/__tests__/unit/plugins/snapline.spec.ts b/packages/g6/__tests__/unit/plugins/snapline.spec.ts
new file mode 100644
index 00000000000..4f988aa22d3
--- /dev/null
+++ b/packages/g6/__tests__/unit/plugins/snapline.spec.ts
@@ -0,0 +1,83 @@
+import { Node, NodeEvent, type Graph } from '@/src';
+import { pluginSnapline } from '@@/demos';
+import { createDemoGraph } from '../../utils';
+
+describe('plugin snapline', () => {
+ let graph: Graph;
+
+ beforeAll(async () => {
+ graph = await createDemoGraph(pluginSnapline);
+ });
+
+ it('snapline', async () => {
+ await expect(graph).toMatchSnapshot(__filename);
+
+ // @ts-expect-error access private property
+ const node = graph.context.element?.getElement('node3');
+
+ let i = 0;
+
+ const moveNodeAndCreateSnapshot = async (x: number, y: number, prefix: string, reset = false) => {
+ graph.updateNodeData([{ id: 'node3', style: { x, y } }]);
+ graph.render();
+ graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' });
+ graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 });
+ if (reset) i = 0;
+ await expect(graph).toMatchSnapshot(__filename, `drag-node3-${prefix}-${i}`);
+ graph.emit(NodeEvent.DRAG_END, { target: node });
+ i++;
+ };
+
+ await moveNodeAndCreateSnapshot(50, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(90, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(100, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(110, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(150, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(200, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(250, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(290, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(300, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(310, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(350, 300, 'vertical');
+ await moveNodeAndCreateSnapshot(400, 300, 'vertical');
+
+ await moveNodeAndCreateSnapshot(200, 65, 'horizontal', true);
+ await moveNodeAndCreateSnapshot(200, 95, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 100, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 105, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 135, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 150, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 265, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 295, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 300, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 305, 'horizontal');
+ await moveNodeAndCreateSnapshot(200, 335, 'horizontal');
+
+ graph.updatePlugin({ key: 'snapline', offset: Infinity });
+ graph.updateNodeData([{ id: 'node3', style: { x: 100, y: 300 } }]);
+ graph.render();
+ graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' });
+ graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 });
+ await expect(graph).toMatchSnapshot(__filename, `offset-infinity`);
+ graph.emit(NodeEvent.DRAG_END, { target: node });
+
+ graph.updatePlugin({ key: 'snapline', filter: (node: Node) => node.id !== 'node2' });
+ graph.render();
+ graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' });
+ graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 });
+ await expect(graph).toMatchSnapshot(__filename, `filter-node2`);
+ graph.emit(NodeEvent.DRAG_END, { target: node });
+
+ graph.updatePlugin({ key: 'snapline', filter: () => true, autoSnap: true });
+ graph.updateNodeData([{ id: 'node3', style: { x: 96, y: 304 } }]);
+ graph.render();
+ graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' });
+ graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 });
+ await expect(graph).toMatchSnapshot(__filename, `auto-snap`);
+ graph.emit(NodeEvent.DRAG_END, { target: node });
+ });
+
+ afterAll(() => {
+ graph.destroy();
+ });
+});
diff --git a/packages/g6/__tests__/unit/runtime/viewport.spec.ts b/packages/g6/__tests__/unit/runtime/viewport.spec.ts
index c3ab42c77f5..6d744f8f2fd 100644
--- a/packages/g6/__tests__/unit/runtime/viewport.spec.ts
+++ b/packages/g6/__tests__/unit/runtime/viewport.spec.ts
@@ -109,6 +109,18 @@ describe('ViewportController', () => {
// @ts-expect-error
expect(graph.context.viewport.getBBoxInViewport(bbox).halfExtents).toBeCloseTo([100, 100, 0]);
});
+
+ it('isInViewport', async () => {
+ await graph.translateTo([100, 100]);
+ // @ts-expect-error
+ expect(graph.context.viewport?.isInViewport([0, 0])).toBe(false);
+ // @ts-expect-error
+ expect(graph.context.viewport?.isInViewport([100, 100])).toBe(true);
+ const bbox = new AABB();
+ bbox.setMinMax([0, 0, 0], [100, 100, 0]);
+ // @ts-expect-error
+ expect(graph.context.viewport?.isInViewport(bbox)).toBe(true);
+ });
});
describe('Viewport Fit without Animation', () => {
diff --git a/packages/g6/src/plugins/index.ts b/packages/g6/src/plugins/index.ts
index 7b90388106f..2035982c097 100644
--- a/packages/g6/src/plugins/index.ts
+++ b/packages/g6/src/plugins/index.ts
@@ -9,6 +9,7 @@ export { History } from './history';
export { Hull } from './hull';
export { Legend } from './legend';
export { Minimap } from './minimap';
+export { Snapline } from './snapline';
export { Timebar } from './timebar';
export { Toolbar } from './toolbar';
export { Tooltip } from './tooltip';
@@ -25,6 +26,7 @@ export type { HistoryOptions } from './history';
export type { HullOptions } from './hull';
export type { LegendOptions } from './legend';
export type { MinimapOptions } from './minimap';
+export type { SnaplineOptions } from './snapline';
export type { TimebarOptions } from './timebar';
export type { ToolbarOptions } from './toolbar';
export type { TooltipOptions } from './tooltip';
diff --git a/packages/g6/src/plugins/snapline/index.ts b/packages/g6/src/plugins/snapline/index.ts
new file mode 100644
index 00000000000..7ff3ef40a7c
--- /dev/null
+++ b/packages/g6/src/plugins/snapline/index.ts
@@ -0,0 +1,359 @@
+import { AABB, BaseStyleProps, DisplayObject, Line, LineStyleProps } from '@antv/g';
+import { isEqual } from '@antv/util';
+import { NodeEvent } from '../../constants';
+import type { RuntimeContext } from '../../runtime/types';
+import type { ID, IDragEvent, Node } from '../../types';
+import { isVisible } from '../../utils/element';
+import type { BasePluginOptions } from '../base-plugin';
+import { BasePlugin } from '../base-plugin';
+
+export interface SnaplineOptions extends BasePluginOptions {
+ /**
+ * 对齐精度,即移动节点时与目标位置的距离小于 tolerance 时触发显示对齐线
+ *
+ * The alignment accuracy, that is, when the distance between the moved node and the target position is less than tolerance, the alignment line is displayed
+ * @defaultValue 5
+ */
+ tolerance?: number;
+ /**
+ * 对齐线头尾的延伸距离。取值范围:[0, Infinity]
+ *
+ * The extension distance of the snapline. The value range is [0, Infinity]
+ * @defaultValue 20
+ */
+ offset?: number;
+ /**
+ * 是否启用自动吸附
+ *
+ * Whether to enable automatic adsorption
+ * @defaultValue true
+ */
+ autoSnap?: boolean;
+ /**
+ * 指定元素上的哪个图形作为参照图形
+ *
+ * Specifies which shape on the element to use as the reference shape
+ * @defaultValue `'key'`
+ * @remarks
+ *
+ * - 'key' 使用元素的主图形作为参照图形
+ * - 也可以传入一个函数,接收元素对象,返回一个图形
+ *
+ *
+ * - `'key'` uses the key shape of the element as the reference shape
+ * - You can also pass in a function that receives the element and returns a shape
+ */
+ shape?: string | ((node: Node) => DisplayObject);
+ /**
+ * 垂直对齐线样式
+ *
+ * Vertical snapline style
+ * @defaultValue `{ stroke: '#1783FF' }`
+ */
+ verticalLineStyle?: BaseStyleProps;
+ /**
+ * 水平对齐线样式
+ *
+ * Horizontal snapline style
+ * @defaultValue `{ stroke: '#1783FF' }`
+ */
+ horizontalLineStyle?: BaseStyleProps;
+ /**
+ * 过滤器,用于过滤不需要作为参考的节点
+ *
+ * Filter, used to filter nodes that do not need to be used as references
+ * @defaultValue `() => true`
+ */
+ filter?: (node: Node) => boolean;
+}
+
+const defaultLineStyle: LineStyleProps = { x1: 0, y1: 0, x2: 0, y2: 0, visibility: 'hidden' };
+
+type Metadata = {
+ verticalX: number | null;
+ verticalMinY: number | null;
+ verticalMaxY: number | null;
+ horizontalY: number | null;
+ horizontalMinX: number | null;
+ horizontalMaxX: number | null;
+};
+
+export class Snapline extends BasePlugin {
+ static defaultOptions: Partial = {
+ tolerance: 5,
+ offset: 20,
+ autoSnap: true,
+ shape: 'key',
+ verticalLineStyle: { stroke: '#1783FF' },
+ horizontalLineStyle: { stroke: '#1783FF' },
+ filter: () => true,
+ };
+
+ private horizontalLine!: Line;
+ private verticalLine!: Line;
+
+ constructor(context: RuntimeContext, options: SnaplineOptions) {
+ super(context, Object.assign({}, Snapline.defaultOptions, options));
+ this.bindEvents();
+ }
+
+ private initSnapline = () => {
+ const canvas = this.context.canvas.getLayer('transient');
+
+ if (!this.horizontalLine) {
+ this.horizontalLine = canvas.appendChild(
+ new Line({ style: { ...defaultLineStyle, ...this.options.horizontalLineStyle } }),
+ );
+ }
+
+ if (!this.verticalLine) {
+ this.verticalLine = canvas.appendChild(
+ new Line({ style: { ...defaultLineStyle, ...this.options.verticalLineStyle } }),
+ );
+ }
+ };
+
+ private getNodes(): Node[] {
+ const { filter } = this.options;
+ const allNodes = this.context.element?.getNodes() || [];
+
+ // 不考虑超出画布视口范围、不可见的节点
+ // Nodes that are out of the canvas viewport range, invisible are not considered
+ const nodes = allNodes.filter((node) => {
+ return isVisible(node) && this.context.viewport?.isInViewport(node.getRenderBounds());
+ });
+
+ if (!filter) return nodes;
+
+ return nodes.filter((node) => filter(node));
+ }
+
+ private hideSnapline() {
+ this.horizontalLine.style.visibility = 'hidden';
+ this.verticalLine.style.visibility = 'hidden';
+ }
+
+ private updateSnapline(metadata: Metadata) {
+ const { verticalX, verticalMinY, verticalMaxY, horizontalY, horizontalMinX, horizontalMaxX } = metadata;
+ const [canvasWidth, canvasHeight] = this.context.canvas.getSize();
+ const { offset } = this.options;
+
+ if (horizontalY !== null) {
+ this.horizontalLine.style.x1 = offset === Infinity ? 0 : horizontalMinX! - offset;
+ this.horizontalLine.style.y1 = horizontalY;
+ this.horizontalLine.style.x2 = offset === Infinity ? canvasWidth : horizontalMaxX! + offset;
+ this.horizontalLine.style.y2 = horizontalY;
+ this.horizontalLine.style.visibility = 'visible';
+ } else {
+ this.horizontalLine.style.visibility = 'hidden';
+ }
+
+ if (verticalX !== null) {
+ this.verticalLine.style.x1 = verticalX;
+ this.verticalLine.style.y1 = offset === Infinity ? 0 : verticalMinY! - offset;
+ this.verticalLine.style.x2 = verticalX;
+ this.verticalLine.style.y2 = offset === Infinity ? canvasHeight : verticalMaxY! + offset;
+ this.verticalLine.style.visibility = 'visible';
+ } else {
+ this.verticalLine.style.visibility = 'hidden';
+ }
+ }
+
+ private isHorizontalSticking = false;
+ private isVerticalSticking = false;
+ private enableStick = true;
+
+ private autoSnapToLine = async (nodeId: ID, bbox: AABB, metadata: Metadata) => {
+ const { verticalX, horizontalY } = metadata;
+ const { tolerance } = this.options;
+ const {
+ min: [nodeMinX, nodeMinY],
+ max: [nodeMaxX, nodeMaxY],
+ center: [nodeCenterX, nodeCenterY],
+ } = bbox;
+
+ let dx = 0;
+ let dy = 0;
+ if (verticalX !== null) {
+ if (distance(nodeMaxX, verticalX) < tolerance) dx = verticalX - nodeMaxX;
+ if (distance(nodeMinX, verticalX) < tolerance) dx = verticalX - nodeMinX;
+ if (distance(nodeCenterX, verticalX) < tolerance) dx = verticalX - nodeCenterX;
+
+ if (dx !== 0) this.isVerticalSticking = true;
+ }
+ if (horizontalY !== null) {
+ if (distance(nodeMaxY, horizontalY) < tolerance) dy = horizontalY - nodeMaxY;
+ if (distance(nodeMinY, horizontalY) < tolerance) dy = horizontalY - nodeMinY;
+ if (distance(nodeCenterY, horizontalY) < tolerance) dy = horizontalY - nodeCenterY;
+
+ if (dy !== 0) this.isHorizontalSticking = true;
+ }
+ if (dx !== 0 || dy !== 0) {
+ // Stick to the line
+ await this.context.graph.translateElementBy({ [nodeId]: [dx, dy] }, false);
+ }
+ };
+
+ private enableSnap = (event: IDragEvent) => {
+ const { target } = event;
+
+ const threshold = 0.5;
+
+ if (this.isHorizontalSticking || this.isVerticalSticking) {
+ if (
+ this.isHorizontalSticking &&
+ this.isVerticalSticking &&
+ Math.abs(event.dx) <= threshold &&
+ Math.abs(event.dy) <= threshold
+ ) {
+ this.context.graph.translateElementBy({ [target.id]: [-event.dx, -event.dy] }, false);
+ return false;
+ } else if (this.isHorizontalSticking && Math.abs(event.dy) <= threshold) {
+ this.context.graph.translateElementBy({ [target.id]: [0, -event.dy] }, false);
+ return false;
+ } else if (this.isVerticalSticking && Math.abs(event.dx) <= threshold) {
+ this.context.graph.translateElementBy({ [target.id]: [-event.dx, 0] }, false);
+ return false;
+ } else {
+ this.isHorizontalSticking = false;
+ this.isVerticalSticking = false;
+ this.enableStick = false;
+ setTimeout(() => {
+ this.enableStick = true;
+ }, 200);
+ }
+ }
+
+ return this.enableStick;
+ };
+
+ private calcSnaplineMetadata = (target: Node, nodeBBox: AABB): Metadata => {
+ const { tolerance, shape } = this.options;
+
+ const {
+ min: [nodeMinX, nodeMinY],
+ max: [nodeMaxX, nodeMaxY],
+ center: [nodeCenterX, nodeCenterY],
+ } = nodeBBox;
+
+ let verticalX: number | null = null;
+ let verticalMinY: number | null = null;
+ let verticalMaxY: number | null = null;
+ let horizontalY: number | null = null;
+ let horizontalMinX: number | null = null;
+ let horizontalMaxX: number | null = null;
+
+ this.getNodes().some((snapNode: Node) => {
+ if (isEqual(target.id, snapNode.id)) return false;
+
+ const snapBBox = getShape(snapNode, shape).getRenderBounds();
+ const {
+ min: [snapMinX, snapMinY],
+ max: [snapMaxX, snapMaxY],
+ center: [snapCenterX, snapCenterY],
+ } = snapBBox;
+
+ if (verticalX === null) {
+ if (distance(snapCenterX, nodeCenterX) < tolerance) {
+ verticalX = snapCenterX;
+ } else if (distance(snapMinX, nodeMinX) < tolerance) {
+ verticalX = snapMinX;
+ } else if (distance(snapMinX, nodeMaxX) < tolerance) {
+ verticalX = snapMinX;
+ } else if (distance(snapMaxX, nodeMaxX) < tolerance) {
+ verticalX = snapMaxX;
+ } else if (distance(snapMaxX, nodeMinX) < tolerance) {
+ verticalX = snapMaxX;
+ }
+
+ if (verticalX !== null) {
+ verticalMinY = Math.min(snapMinY, nodeMinY);
+ verticalMaxY = Math.max(snapMaxY, nodeMaxY);
+ }
+ }
+
+ if (horizontalY === null) {
+ if (distance(snapCenterY, nodeCenterY) < tolerance) {
+ horizontalY = snapCenterY;
+ } else if (distance(snapMinY, nodeMinY) < tolerance) {
+ horizontalY = snapMinY;
+ } else if (distance(snapMinY, nodeMaxY) < tolerance) {
+ horizontalY = snapMinY;
+ } else if (distance(snapMaxY, nodeMaxY) < tolerance) {
+ horizontalY = snapMaxY;
+ } else if (distance(snapMaxY, nodeMinY) < tolerance) {
+ horizontalY = snapMaxY;
+ }
+
+ if (horizontalY !== null) {
+ horizontalMinX = Math.min(snapMinX, nodeMinX);
+ horizontalMaxX = Math.max(snapMaxX, nodeMaxX);
+ }
+ }
+
+ return verticalX !== null && horizontalY !== null;
+ });
+ return { verticalX, verticalMinY, verticalMaxY, horizontalY, horizontalMinX, horizontalMaxX };
+ };
+
+ protected onDragStart = () => {
+ this.initSnapline();
+ };
+
+ protected onDrag = async (event: IDragEvent) => {
+ const { target } = event;
+
+ if (this.options.autoSnap) {
+ const enable = this.enableSnap(event);
+ if (!enable) return;
+ }
+
+ const nodeBBox = getShape(target, this.options.shape).getRenderBounds();
+ const metadata = this.calcSnaplineMetadata(target, nodeBBox);
+
+ this.hideSnapline();
+
+ if (metadata.verticalX !== null || metadata.horizontalY !== null) {
+ this.updateSnapline(metadata);
+ }
+
+ if (this.options.autoSnap) {
+ await this.autoSnapToLine(target.id, nodeBBox, metadata);
+ }
+ };
+
+ protected onDragEnd = () => {
+ this.hideSnapline();
+ };
+
+ private async bindEvents() {
+ const { graph } = this.context;
+ graph.on(NodeEvent.DRAG_START, this.onDragStart);
+ graph.on(NodeEvent.DRAG, this.onDrag);
+ graph.on(NodeEvent.DRAG_END, this.onDragEnd);
+ }
+
+ private unbindEvents() {
+ const { graph } = this.context;
+ graph.off(NodeEvent.DRAG_START, this.onDragStart);
+ graph.off(NodeEvent.DRAG, this.onDrag);
+ graph.off(NodeEvent.DRAG_END, this.onDragEnd);
+ }
+
+ private destroyElements() {
+ this.horizontalLine.destroy();
+ this.verticalLine.destroy();
+ }
+
+ public destroy() {
+ this.destroyElements();
+ this.unbindEvents();
+ super.destroy();
+ }
+}
+
+const distance = (a: number, b: number) => Math.abs(a - b);
+
+const getShape = (node: Node, shapeFilter: string | ((node: Node) => DisplayObject)) => {
+ return typeof shapeFilter === 'function' ? shapeFilter(node) : node.getShape(shapeFilter);
+};
diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts
index 4aaac181140..b093c53c803 100644
--- a/packages/g6/src/registry/build-in.ts
+++ b/packages/g6/src/registry/build-in.ts
@@ -65,6 +65,7 @@ import {
Hull,
Legend,
Minimap,
+ Snapline,
Timebar,
Toolbar,
Tooltip,
@@ -175,11 +176,12 @@ const BUILT_IN_EXTENSIONS: ExtensionRegistry = {
history: History,
hull: Hull,
legend: Legend,
+ minimap: Minimap,
+ snapline: Snapline,
timebar: Timebar,
toolbar: Toolbar,
tooltip: Tooltip,
watermark: Watermark,
- minimap: Minimap,
},
transform: {
'update-related-edges': UpdateRelatedEdge,
diff --git a/packages/g6/src/runtime/viewport.ts b/packages/g6/src/runtime/viewport.ts
index 908373b231e..5a354944c08 100644
--- a/packages/g6/src/runtime/viewport.ts
+++ b/packages/g6/src/runtime/viewport.ts
@@ -1,10 +1,11 @@
-import { AABB } from '@antv/g';
+import { AABB, ICamera } from '@antv/g';
import { clamp, isNumber, pick } from '@antv/util';
import { AnimationType, GraphEvent } from '../constants';
import type { FitViewOptions, ID, Point, TransformOptions, Vector2, ViewportAnimationEffectTiming } from '../types';
import { getAnimationOptions } from '../utils/animation';
-import { getBBoxSize, getCombinedBBox } from '../utils/bbox';
+import { getBBoxSize, getCombinedBBox, isPointInBBox } from '../utils/bbox';
import { AnimateEvent, ViewportEvent, emit } from '../utils/event';
+import { isPoint } from '../utils/is';
import { parsePadding } from '../utils/padding';
import { add, divide, subtract } from '../utils/vector';
import type { RuntimeContext } from './types';
@@ -30,7 +31,20 @@ export class ViewportController {
}
private get camera() {
- return this.context.canvas.getCamera();
+ const { canvas } = this.context;
+ return new Proxy(canvas.getCamera(), {
+ get: (target, prop: keyof ICamera) => {
+ const transientCamera = canvas.getLayer('transient').getCamera();
+ const value = target[prop];
+ if (typeof value === 'function') {
+ return (...args: any[]) => {
+ const result = (value as (...args: any[]) => any).apply(target, args);
+ (transientCamera[prop] as (...args: any[]) => any).apply(transientCamera, args);
+ return result;
+ };
+ }
+ },
+ });
}
private landmarkCounter = 0;
@@ -255,6 +269,26 @@ export class ViewportController {
return bboxInViewport;
}
+ /**
+ * 判断点或包围盒是否在视口中
+ *
+ * Determine whether the point or bounding box is in the viewport
+ * @param target - 点或包围盒 | Point or bounding box
+ * @returns - 是否在视口中 | Whether it is in the viewport
+ */
+ public isInViewport(target: Point | AABB) {
+ const { graph } = this.context;
+ const size = this.getCanvasSize();
+
+ const [x1, y1] = graph.getCanvasByViewport([0, 0]);
+ const [x2, y2] = graph.getCanvasByViewport(size);
+
+ const viewportBBox = new AABB();
+ viewportBBox.setMinMax([x1, y1, 0], [x2, y2, 0]);
+
+ return isPoint(target) ? isPointInBBox(target, viewportBBox) : viewportBBox.intersects(target);
+ }
+
public cancelAnimation() {
// @ts-expect-error landmarks is private
if (this.camera.landmarks?.length) {