diff --git a/.vscode/settings.json b/.vscode/settings.json index afc6f21da94..7e3fca0370d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,10 @@ "afterelementzindexchange", "afterlayout", "afterrender", + "afterrotate", + "aftertranslate", "afterviewportanimate", + "afterzoom", "bbox", "beforeanimate", "beforedraw", @@ -25,7 +28,10 @@ "beforeelementzindexchange", "beforelayout", "beforerender", + "beforerotate", + "beforetranslate", "beforeviewportanimate", + "beforezoom", "cancelviewportanimate", "dendrogram", "elementstatechange", diff --git a/packages/g6/__tests__/demo/case/behavior-zoom-canvas.ts b/packages/g6/__tests__/demo/case/behavior-zoom-canvas.ts new file mode 100644 index 00000000000..eb29ec81d74 --- /dev/null +++ b/packages/g6/__tests__/demo/case/behavior-zoom-canvas.ts @@ -0,0 +1,81 @@ +import { Graph } from '@/src'; +import data from '@@/dataset/cluster.json'; +import type { STDTestCase } from '../types'; + +export const behaviorZoomCanvas: STDTestCase = async (context) => { + const { canvas, animation } = context; + const graph = new Graph({ + animation, + container: canvas, + data, + layout: { + type: 'd3force', + }, + node: { + style: { + size: 20, + }, + }, + zoomRange: [0.5, 5], + behaviors: [{ type: 'zoom-canvas' }], + }); + + await graph.render(); + + behaviorZoomCanvas.form = [ + { + label: 'Disable Zoom: ', + type: 'input', + onload: (input) => { + input.onchange = (e) => { + graph.setBehaviors((currBehaviors) => { + return currBehaviors.map((behavior, index) => { + const target = e.target as HTMLInputElement; + if (index === 0 && typeof behavior === 'object') { + return { ...behavior, enable: !target.checked }; + } + return behavior; + }); + }); + }; + }, + options: { type: 'checkbox' }, + }, + { + type: 'button', + onload: (button) => { + button.innerText = 'Add Shortcut Zoom'; + button.onclick = () => { + graph.setBehaviors((currBehaviors) => [ + ...currBehaviors, + { + key: 'shortcut-zoom-canvas', + type: 'zoom-canvas', + trigger: { + zoomIn: ['Control', '='], + zoomOut: ['Control', '-'], + reset: ['Control', '0'], + }, + }, + ]); + alert('Zoom behavior added'); + }; + }, + }, + + { + type: 'button', + onload: (button) => { + button.innerText = 'Remove Shortcut Zoom'; + button.onclick = () => { + graph.setBehaviors((currBehaviors) => { + return currBehaviors.slice(0, 1); + }); + alert('Zoom behavior removed'); + }; + }, + }, + ]; + + return graph; +}; diff --git a/packages/g6/__tests__/demo/case/index.ts b/packages/g6/__tests__/demo/case/index.ts new file mode 100644 index 00000000000..326f6c7c6fa --- /dev/null +++ b/packages/g6/__tests__/demo/case/index.ts @@ -0,0 +1 @@ +export * from './behavior-zoom-canvas'; diff --git a/packages/g6/__tests__/demo/index.ts b/packages/g6/__tests__/demo/index.ts index 26c827ffb52..62aa376ac42 100644 --- a/packages/g6/__tests__/demo/index.ts +++ b/packages/g6/__tests__/demo/index.ts @@ -1 +1,3 @@ -export * from './static'; +export * as animations from './animation'; +export * as cases from './case'; +export * as statics from './static'; diff --git a/packages/g6/__tests__/demo/types.ts b/packages/g6/__tests__/demo/types.ts index 8348e03bf2c..22e801d344b 100644 --- a/packages/g6/__tests__/demo/types.ts +++ b/packages/g6/__tests__/demo/types.ts @@ -1,3 +1,4 @@ +import { Graph } from '@/src'; import type { Canvas } from '@/src/runtime/canvas'; import type { IAnimation } from '@antv/g'; @@ -63,5 +64,22 @@ export interface BaseTestCase { * @returns */ postprocess?: () => Promise; - form?: { label?: string; type: string; options?: Record; onload?: (el: HTMLElement) => void }[]; + form?: PanelFormItem[]; } + +export interface STDTestCase { + form?: PanelFormItem[]; + (context: STDTestCaseContext): Promise; +} + +export interface STDTestCaseContext { + canvas: Canvas; + animation?: boolean; +} + +type PanelFormItem = { + label?: string; + type: string; + options?: Record; + onload?: (el: HTMLElement) => void; +}; diff --git a/packages/g6/__tests__/main.ts b/packages/g6/__tests__/main.ts index b64193024e3..817d229d178 100644 --- a/packages/g6/__tests__/main.ts +++ b/packages/g6/__tests__/main.ts @@ -1,13 +1,9 @@ import '../src/preset'; -import * as animations from './demo/animation'; -import * as statics from './demo/static'; +import * as demos from './demo'; import type { TestCase } from './demo/types'; import { createGraphCanvas } from './utils'; -const CASES = { - statics, - animations, -} as unknown as { [key: string]: Record }; +const CASES = demos as unknown as { [key: string]: Record }; const casesSelect = document.getElementById('demo-select') as HTMLSelectElement; const rendererSelect = document.getElementById('renderer-select') as HTMLSelectElement; @@ -71,7 +67,7 @@ function onchange(testCase: TestCase, renderer: string, animation: boolean, them return canvas.init().then(async () => { const result = await testCase({ canvas, animation, theme, env: 'dev' }); - if (result) setTimer(result.totalDuration); + if (result?.totalDuration) setTimer(result.totalDuration); else clearTimer(); }); } @@ -86,7 +82,7 @@ function initialize() { function syncParamsFromSearch() { const searchParams = new URLSearchParams(window.location.search); const type = searchParams.get('type') || 'statics'; - const testCase = searchParams.get('case') || Object.keys(statics)[0]; + const testCase = searchParams.get('case') || Object.keys(CASES.statics)[0]; const rendererName = searchParams.get('renderer') || 'canvas'; const animation = searchParams.get('animation') || 'true'; @@ -107,15 +103,21 @@ function mountCustomPanel(form: TestCase['form'] = []) { const customPanel = document.getElementById('custom-panel')!; form.forEach(({ label, type, options = {}, onload }) => { + const item = document.createElement('div'); + item.style.display = 'flex'; + item.style.alignItems = 'center'; + customPanel.appendChild(item); + if (label) { const labelEl = document.createElement('label'); labelEl.textContent = label; - customPanel.appendChild(labelEl); + item.appendChild(labelEl); } const element = document.createElement(type); + if (type === 'button') element.style.width = '100%'; Object.assign(element, options); - customPanel.appendChild(element); + item.appendChild(element); onload?.(element); }); diff --git a/packages/g6/__tests__/snapshots/behaviors/behavior-zoom-canvas/behavior-zoom-canvas.svg b/packages/g6/__tests__/snapshots/behaviors/behavior-zoom-canvas/behavior-zoom-canvas.svg new file mode 100644 index 00000000000..617439b7fe9 --- /dev/null +++ b/packages/g6/__tests__/snapshots/behaviors/behavior-zoom-canvas/behavior-zoom-canvas.svg @@ -0,0 +1,2511 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/g6/__tests__/unit/behaviors/behavior-zoom-canvas.spec.ts b/packages/g6/__tests__/unit/behaviors/behavior-zoom-canvas.spec.ts new file mode 100644 index 00000000000..48619ac04e9 --- /dev/null +++ b/packages/g6/__tests__/unit/behaviors/behavior-zoom-canvas.spec.ts @@ -0,0 +1,198 @@ +import type { Graph } from '@/src'; +import { CanvasEvent, CommonEvent, ContainerEvent } from '@/src'; +import type { ZoomCanvasOptions } from '@/src/behaviors/zoom-canvas'; +import { behaviorZoomCanvas } from '@@/demo/case'; +import { createDemoGraph } from '@@/utils'; + +describe('behavior zoom canvas', () => { + let graph: Graph; + + beforeAll(async () => { + graph = await createDemoGraph(behaviorZoomCanvas, { animation: false }); + }); + + it('default status', () => { + expect(graph.getZoom()).toBe(1); + expect(graph.getBehaviors()).toEqual([{ type: 'zoom-canvas' }]); + }); + + it('zoom in', async () => { + graph.emit(CommonEvent.WHEEL, { deltaY: -10 }); + + expect(graph.getZoom()).toBe(2); + + await expect(graph.getCanvas()).toMatchSnapshot(__filename); + }); + + it('zoom out', () => { + const currentZoom = graph.getZoom(); + + graph.emit(CommonEvent.WHEEL, { deltaY: 5 }); + + expect(graph.getZoom()).toBe(currentZoom - 0.5); + + graph.emit(CommonEvent.WHEEL, { deltaY: 5 }); + + expect(graph.getZoom()).toBe(currentZoom - 1); + }); + + const shortcutZoomCanvasOptions: ZoomCanvasOptions = { + key: 'shortcut-zoom-canvas', + type: 'zoom-canvas', + trigger: { + zoomIn: ['Control', '='], + zoomOut: ['Control', '-'], + reset: ['Control', '0'], + }, + }; + + it('add second zoom canvas', () => { + graph.setBehaviors((behavior) => [...behavior, shortcutZoomCanvasOptions]); + + expect(graph.getBehaviors()).toEqual([{ type: 'zoom-canvas' }, shortcutZoomCanvasOptions]); + }); + + it('zoom by shortcut', () => { + const currentZoom = graph.getZoom(); + + // zoom in + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.KEY_DOWN, { key: '=' }); + + expect(graph.getZoom()).toBe(currentZoom + 0.1); + + graph.emit(CommonEvent.KEY_UP, { key: 'Control' }); + graph.emit(CommonEvent.KEY_UP, { key: '=' }); + + // reset + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.KEY_DOWN, { key: '0' }); + + expect(graph.getZoom()).toBe(currentZoom); + + graph.emit(CommonEvent.KEY_UP, { key: 'Control' }); + graph.emit(CommonEvent.KEY_UP, { key: '0' }); + + // zoom out + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.KEY_DOWN, { key: '-' }); + + expect(graph.getZoom()).toBe(currentZoom - 0.1); + + graph.emit(CommonEvent.KEY_UP, { key: 'Control' }); + graph.emit(CommonEvent.KEY_UP, { key: '-' }); + }); + + it('disable', () => { + graph.setBehaviors((behaviors) => + behaviors.map((behavior) => { + if (typeof behavior === 'object') { + return { ...behavior, enable: false }; + } + return behavior; + }), + ); + + const currentZoom = graph.getZoom(); + + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.KEY_DOWN, { key: '=' }); + graph.emit(CommonEvent.KEY_UP, { key: 'Control' }); + graph.emit(CommonEvent.KEY_UP, { key: '=' }); + expect(graph.getZoom()).toBe(currentZoom); + }); + + it('remove behavior', () => { + graph.setBehaviors((behaviors) => behaviors.filter((_, index) => index === 1)); + expect(graph.getBehaviors()).toEqual([{ ...shortcutZoomCanvasOptions, enable: false }]); + }); + + it('condition enable', () => { + graph.setBehaviors((behaviors) => + behaviors.map((behavior) => { + if (typeof behavior === 'object') { + return { + ...behavior, + enable: (event) => event.targetType === 'canvas', + }; + } + return behavior; + }), + ); + + const currentZoom = graph.getZoom(); + + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.KEY_DOWN, { key: '=', targetType: 'node' }); + graph.emit(CommonEvent.KEY_UP, { key: 'Control' }); + graph.emit(CommonEvent.KEY_UP, { key: '=' }); + + expect(graph.getZoom()).toBe(currentZoom); + + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.KEY_DOWN, { key: '=', targetType: 'canvas' }); + graph.emit(CommonEvent.KEY_UP, { key: 'Control' }); + graph.emit(CommonEvent.KEY_UP, { key: '=' }); + + expect(graph.getZoom()).toBe(currentZoom + 0.1); + }); + + it('preconditionKey', () => { + graph.setBehaviors([{ type: 'zoom-canvas', trigger: ['Control'] }]); + + const currentZoom = graph.getZoom(); + + graph.emit(CommonEvent.WHEEL, { deltaY: -10 }); + expect(graph.getZoom()).toBe(currentZoom); + + graph.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); + graph.emit(CommonEvent.WHEEL, { deltaY: -10 }); + expect(graph.getZoom()).toBe(currentZoom + 1); + }); + + it('canvas event', () => { + const canvas = graph.getCanvas(); + + const pointermoveListener = jest.fn(); + const clickListener = jest.fn(); + const wheelListener = jest.fn(); + const dblclickListener = jest.fn(); + const contextmenuListener = jest.fn().mockImplementation((e) => e.preventDefault()); + + // pointerenter / pointerleave + graph.once('canvas:pointermove', pointermoveListener); + canvas.document.emit(CanvasEvent.POINTER_MOVE, {}); + expect(pointermoveListener).toHaveBeenCalledTimes(1); + + // common event + graph.once('canvas:click', clickListener); + graph.once('canvas:wheel', wheelListener); + canvas.document.emit(CanvasEvent.CLICK, {}); + canvas.document.emit(CanvasEvent.WHEEL, {}); + expect(clickListener).toHaveBeenCalledTimes(1); + expect(wheelListener).toHaveBeenCalledTimes(1); + + // double click + graph.once('canvas:dblclick', dblclickListener); + canvas.document.emit(CanvasEvent.CLICK, { detail: 2 }); + expect(dblclickListener).toHaveBeenCalledTimes(1); + + // contextmenu + graph.once('canvas:contextmenu', contextmenuListener); + canvas.document.emit(CanvasEvent.POINTER_DOWN, { button: 2 }); + expect(contextmenuListener).toHaveBeenCalledTimes(1); + }); + + it('container event', () => { + const container = graph.getCanvas().getContainer(); + + const keydownListener = jest.fn(); + graph.once(ContainerEvent.KEY_DOWN, keydownListener); + container?.dispatchEvent(new Event(ContainerEvent.KEY_DOWN)); + expect(keydownListener).toHaveBeenCalledTimes(1); + }); + + it('destroy', () => { + graph.destroy(); + }); +}); diff --git a/packages/g6/__tests__/unit/registry.spec.ts b/packages/g6/__tests__/unit/registry.spec.ts index a5ff7637350..b3d529f337a 100644 --- a/packages/g6/__tests__/unit/registry.spec.ts +++ b/packages/g6/__tests__/unit/registry.spec.ts @@ -47,7 +47,7 @@ describe('registry', () => { class Edge {} register('node', 'circle-node', CircleNode as any); register('node', 'rect-node', RectNode as any); - register('edge', 'line-edge', Edge); + register('edge', 'line-edge', Edge as any); expect(getPlugin('node', 'circle-node')).toEqual(CircleNode); expect(getPlugin('node', 'rect-node')).toEqual(RectNode); expect(getPlugin('node', 'diamond-node')).toEqual(undefined); diff --git a/packages/g6/__tests__/unit/utils/behavior.spec.ts b/packages/g6/__tests__/unit/utils/behavior.spec.ts new file mode 100644 index 00000000000..0ce7bb2a18b --- /dev/null +++ b/packages/g6/__tests__/unit/utils/behavior.spec.ts @@ -0,0 +1,26 @@ +import { BehaviorOptions } from '@/src'; +import { parseBehaviors } from '@/src/utils/behaviors'; + +describe('behavior', () => { + it('parseBehaviors', () => { + expect(parseBehaviors([])).toEqual([]); + + const options: BehaviorOptions = [ + 'drag-node', + { type: 'drag-canvas' }, + { type: 'shortcut', key: 'shortcut-zoom-in' }, + { type: 'shortcut', key: 'shortcut-zoom-out' }, + 'scroll-canvas', + 'scroll-canvas', + ]; + + expect(parseBehaviors(options)).toEqual([ + { type: 'drag-node', key: 'behavior-drag-node-0' }, + { type: 'drag-canvas', key: 'behavior-drag-canvas-0' }, + { type: 'shortcut', key: 'shortcut-zoom-in' }, + { type: 'shortcut', key: 'shortcut-zoom-out' }, + { type: 'scroll-canvas', key: 'behavior-scroll-canvas-0' }, + { type: 'scroll-canvas', key: 'behavior-scroll-canvas-1' }, + ]); + }); +}); diff --git a/packages/g6/__tests__/unit/utils/element.spec.ts b/packages/g6/__tests__/unit/utils/element.spec.ts index ce38cfb8f7b..6972ca14f60 100644 --- a/packages/g6/__tests__/unit/utils/element.spec.ts +++ b/packages/g6/__tests__/unit/utils/element.spec.ts @@ -1,3 +1,4 @@ +import { Polyline } from '@/src/elements/edges'; import { Circle } from '@/src/elements/nodes'; import { findPorts, @@ -10,11 +11,13 @@ import { getTrianglePoints, getTrianglePorts, getXYByPosition, + isEdge, + isNode, isSameNode, isVisible, updateStyle, } from '@/src/utils/element'; -import { AABB, Rect } from '@antv/g'; +import { AABB, Line, Rect } from '@antv/g'; describe('element', () => { const bbox = new AABB(); @@ -28,6 +31,19 @@ describe('element', () => { id: 'node-2', }); + const edge = new Polyline({ style: { sourceNode: node1, targetNode: node2 } }); + + it('isNode', () => { + expect(isNode(new Rect({ style: { width: 10, height: 10 } }))).toBe(false); + const node = new Circle({}); + expect(isNode(node)).toBe(true); + }); + + it('isEdge', () => { + expect(isEdge(new Line({ style: { x1: 0, y1: 0, x2: 10, y2: 10 } }))).toBe(false); + expect(isEdge(edge)).toBe(true); + }); + it('isSameNode', () => { expect(isSameNode(node1, node2)).toBeFalsy(); expect(isSameNode(node1, node1)).toBeTruthy(); diff --git a/packages/g6/__tests__/unit/utils/event.spec.ts b/packages/g6/__tests__/unit/utils/event.spec.ts new file mode 100644 index 00000000000..401c8f0fef6 --- /dev/null +++ b/packages/g6/__tests__/unit/utils/event.spec.ts @@ -0,0 +1,23 @@ +import { Polyline } from '@/src/elements/edges'; +import { Circle } from '@/src/elements/nodes'; +import { eventTargetOf } from '@/src/utils/event'; +import { Document, Rect } from '@antv/g'; + +describe('event', () => { + const node1 = new Circle({ + id: 'node-1', + }); + + const node2 = new Circle({ + id: 'node-2', + }); + + const edge = new Polyline({ style: { sourceNode: node1, targetNode: node2 } }); + + it('eventTargetOf', () => { + expect(eventTargetOf(node1)?.type).toEqual('node'); + expect(eventTargetOf(edge)?.type).toEqual('edge'); + expect(eventTargetOf(new Rect({ style: { width: 50, height: 50 } }))).toBeFalsy(); + expect(eventTargetOf(new Document())?.type).toBe('canvas'); + }); +}); diff --git a/packages/g6/__tests__/unit/utils/is.spec.ts b/packages/g6/__tests__/unit/utils/is.spec.ts index 64ba8d50c25..e2a75c11d01 100644 --- a/packages/g6/__tests__/unit/utils/is.spec.ts +++ b/packages/g6/__tests__/unit/utils/is.spec.ts @@ -1,5 +1,4 @@ -import { Circle } from '@/src/elements/nodes'; -import { isEdgeData, isNode, isPoint, isVector2, isVector3 } from '@/src/utils/is'; +import { isEdgeData, isPoint, isVector2, isVector3 } from '@/src/utils/is'; describe('is', () => { it('isEdgeData', () => { @@ -23,10 +22,4 @@ describe('is', () => { expect(isPoint(new Float32Array([1, 2, 3]))).toBe(true); expect(isPoint([1, '2'])).toBe(false); }); - - it('isNode', () => { - expect(isNode({})).toBe(false); - const node = new Circle({}); - expect(isNode(node)).toBe(true); - }); }); diff --git a/packages/g6/__tests__/unit/utils/size.spec.ts b/packages/g6/__tests__/unit/utils/size.spec.ts index 2a5fe49e96c..4b20d6aba64 100644 --- a/packages/g6/__tests__/unit/utils/size.spec.ts +++ b/packages/g6/__tests__/unit/utils/size.spec.ts @@ -1,4 +1,4 @@ -import { parseSize } from '../../../src/utils/size'; +import { parseSize } from '@/src/utils/size'; describe('size', () => { it('parseSize', () => { diff --git a/packages/g6/__tests__/utils/create.ts b/packages/g6/__tests__/utils/create.ts index 8e7a48f2016..86999db9b10 100644 --- a/packages/g6/__tests__/utils/create.ts +++ b/packages/g6/__tests__/utils/create.ts @@ -11,6 +11,7 @@ import { Plugin as PluginControl } from '@antv/g-plugin-control'; import { Plugin as DragAndDropPlugin } from '@antv/g-plugin-dragndrop'; import { Renderer as SVGRenderer } from '@antv/g-svg'; import { Renderer as WebGLRenderer } from '@antv/g-webgl'; +import type { STDTestCase, STDTestCaseContext } from '../demo/types'; import { OffscreenCanvasContext } from './offscreen-canvas-context'; /** @@ -103,3 +104,8 @@ export function createEdgeNode(point: Point): Node { }, }); } + +export async function createDemoGraph(demo: STDTestCase, context?: Partial): Promise { + const canvas = createGraphCanvas(document.getElementById('container')); + return demo({ ...context, canvas }); +} diff --git a/packages/g6/__tests__/utils/index.ts b/packages/g6/__tests__/utils/index.ts index de68c98e486..89aa64632fa 100644 --- a/packages/g6/__tests__/utils/index.ts +++ b/packages/g6/__tests__/utils/index.ts @@ -1,4 +1,4 @@ -export { createEdgeNode, createGraph, createGraphCanvas } from './create'; +export { createDemoGraph, createEdgeNode, createGraph, createGraphCanvas } from './create'; export { getCases } from './get-cases'; export { createDeterministicRandom } from './random'; export { sleep } from './sleep'; diff --git a/packages/g6/package.json b/packages/g6/package.json index 66539c4fd70..59202b9dcf3 100644 --- a/packages/g6/package.json +++ b/packages/g6/package.json @@ -36,6 +36,7 @@ "bundle-vis": "cross-env BUNDLE_VIS=1 npm run build:umd", "ci": "run-s lint build test", "coverage": "jest --coverage", + "coverage:open": "open coverage/lcov-report/index.html", "dev": "vite", "fix": "eslint ./src ./__tests__ --fix && prettier ./src __tests__ --write ", "jest": "node --expose-gc --max-old-space-size=4096 --unhandled-rejections=strict --experimental-vm-modules ../../node_modules/jest/bin/jest --coverage --logHeapUsage --detectOpenHandles", @@ -48,7 +49,6 @@ "test:unit": "npm run jest __tests__/unit" }, "dependencies": { - "@antv/algorithm": "^0.1.26", "@antv/event-emitter": "latest", "@antv/g": "^5.18.23", "@antv/g-canvas": "^1.11.25", diff --git a/packages/g6/src/behaviors/base-behavior.ts b/packages/g6/src/behaviors/base-behavior.ts new file mode 100644 index 00000000000..e9d3e70c338 --- /dev/null +++ b/packages/g6/src/behaviors/base-behavior.ts @@ -0,0 +1,49 @@ +import type EventEmitter from '@antv/event-emitter'; +import type { RuntimeContext } from '../runtime/types'; +import type { CustomBehaviorOption } from '../spec/behavior'; +import type { Listener } from '../types'; + +export type BaseBehaviorOptions = CustomBehaviorOption; + +export abstract class BaseBehavior { + protected context: RuntimeContext; + + protected options: Required; + + public events: [EventEmitter | HTMLElement, string, Listener][] = []; + + public destroyed = false; + + public get defaultOptions(): Partial { + return {}; + } + + constructor(context: RuntimeContext, options: T) { + this.context = context; + this.options = Object.assign({}, this.defaultOptions, options) as Required; + } + + public update(options: Partial) { + this.options = Object.assign(this.options, options); + } + + public addEventListener(emitter: EventEmitter | HTMLElement, eventName: string, listener: Listener) { + if (emitter instanceof HTMLElement) emitter.addEventListener(eventName, listener); + else emitter.on(eventName, listener); + this.events.push([emitter, eventName, listener]); + } + + public destroy() { + this.events.forEach(([emitter, event, listener]) => { + if (emitter instanceof HTMLElement) emitter.removeEventListener(event, listener); + else emitter.off(event, listener); + }); + + // @ts-expect-error force delete + delete this.context; + // @ts-expect-error force delete + delete this.options; + + this.destroyed = true; + } +} diff --git a/packages/g6/src/behaviors/index.ts b/packages/g6/src/behaviors/index.ts index 48d7dcb45a3..1b6078083c2 100644 --- a/packages/g6/src/behaviors/index.ts +++ b/packages/g6/src/behaviors/index.ts @@ -1,7 +1,7 @@ -/** - * 内置交互 - * - * Built-in behaviors - */ +import type { ZoomCanvasOptions } from './zoom-canvas'; -export {}; +export { BaseBehavior } from './base-behavior'; +export { ZoomCanvas } from './zoom-canvas'; + +export type { BaseBehaviorOptions } from './base-behavior'; +export type BuiltInBehaviorOptions = ZoomCanvasOptions; diff --git a/packages/g6/src/behaviors/types.ts b/packages/g6/src/behaviors/types.ts index 50990522a56..cc8f57dfad1 100644 --- a/packages/g6/src/behaviors/types.ts +++ b/packages/g6/src/behaviors/types.ts @@ -1 +1,4 @@ +import type { BaseBehavior } from './base-behavior'; + export type BuiltInBehaviorOptions = { type: 'unset' }; +export type Behavior = BaseBehavior; diff --git a/packages/g6/src/behaviors/zoom-canvas.ts b/packages/g6/src/behaviors/zoom-canvas.ts new file mode 100644 index 00000000000..cb1fb2b9c31 --- /dev/null +++ b/packages/g6/src/behaviors/zoom-canvas.ts @@ -0,0 +1,173 @@ +import { isArray, isEqual, isObject } from '@antv/util'; +import { CommonEvent } from '../constants'; +import type { RuntimeContext } from '../runtime/types'; +import type { BehaviorEvent, Loose, ViewportAnimationEffectTiming } from '../types'; +import type { BaseBehaviorOptions } from './base-behavior'; +import { BaseBehavior } from './base-behavior'; + +export interface ZoomCanvasOptions extends BaseBehaviorOptions { + /** + * 是否启用缩放动画 + * + * Whether to enable the animation of zooming + */ + animation?: ViewportAnimationEffectTiming; + /** + * 是否启用缩放画布的功能 + * + * Whether to enable the function of zooming the canvas + */ + enable?: boolean | ((event: BehaviorEvent) => boolean); + /** + * 触发缩放的方式 + * + * The way to trigger zoom + * @description + * + * - 'wheel':滚动鼠标滚轮或触摸板时触发缩放 + * - 数组:组合快捷键,例如 ['ctrl'] 表示按住 ctrl 键滚动鼠标滚轮时触发缩放 + * - 对象:缩放快捷键,例如 { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] } + * + * + * - 'wheel': Trigger zoom when scrolling the mouse wheel or touchpad + * - Array: Combination shortcut keys, such as ['ctrl'] means zooming when scrolling the mouse wheel while holding down the ctrl key + * - Object: Zoom shortcut keys, such as { zoomIn: ['ctrl', '+'], zoomOut: ['ctrl', '-'], reset: ['ctrl', '0'] } + */ + trigger?: Loose | string[] | CombinationKey; + /** + * 缩放灵敏度 + * + * Zoom sensitivity + */ + sensitivity?: number; + /** + * 完成缩放时的回调 + * + * Callback when zooming is completed + */ + onfinish?: () => void; +} + +type CombinationKey = { + zoomIn: string[]; + zoomOut: string[]; + reset: string[]; +}; + +export class ZoomCanvas extends BaseBehavior { + private preconditionKey?: string[]; + + private recordKey = new Set(); + + private combinationKey: CombinationKey = { + zoomIn: [], + zoomOut: [], + reset: [], + }; + + private get animation() { + return this.context.options.animation ? this.options.animation : false; + } + + public get defaultOptions(): Partial { + return { animation: { duration: 200 }, enable: true, sensitivity: 1, trigger: CommonEvent.WHEEL }; + } + + constructor(context: RuntimeContext, options: ZoomCanvasOptions) { + super(context, options); + + if (isArray(this.options.trigger)) { + this.preconditionKey = this.options.trigger; + } + + if (isObject(this.options.trigger)) { + this.combinationKey = this.options.trigger as CombinationKey; + } + + this.bindEvents(); + } + + private bindEvents() { + const { graph } = this.context; + + // wheel 触发和组合键触发需要监听 wheel 事件 / Combination key trigger and wheel trigger need to listen to the wheel event + if (this.options.trigger === CommonEvent.WHEEL || isArray(this.options.trigger)) { + this.preventDefault(CommonEvent.WHEEL); + this.addEventListener(graph, CommonEvent.WHEEL, this.onWheel.bind(this)); + } + + if (isObject(this.options.trigger)) { + this.addEventListener(graph, CommonEvent.KEY_DOWN, this.onKeydown.bind(this)); + this.addEventListener(graph, CommonEvent.KEY_UP, this.onKeyup.bind(this)); + } + } + + private onWheel(event: BehaviorEvent) { + const { deltaX, deltaY } = event; + const delta = -(deltaY || deltaX); + this.zoom(delta, event); + } + + private onKeydown(event: BehaviorEvent) { + const { key } = event; + this.recordKey.add(key); + + if (this.isTrigger(this.combinationKey.zoomIn)) { + this.zoom(1, event); + } else if (this.isTrigger(this.combinationKey.zoomOut)) { + this.zoom(-1, event); + } else if (this.isTrigger(this.combinationKey.reset)) { + this.reset(); + } + } + + private onKeyup(event: KeyboardEvent) { + const { key } = event; + this.recordKey.delete(key); + } + + private isTrigger(keys: string[]) { + return isEqual(Array.from(this.recordKey), keys); + } + + /** + * 缩放画布 + * + * Zoom canvas + * @param value - 缩放值, > 0 放大, < 0 缩小 | Zoom value, > 0 zoom in, < 0 zoom out + * @param event - 事件对象 | Event object + */ + private async zoom(value: number, event: BehaviorEvent | BehaviorEvent) { + if (!this.validate(event)) return; + const { viewport } = this.context; + if (!viewport) return; + + const { sensitivity, onfinish } = this.options; + const diff = (value * sensitivity) / 10; + const zoom = viewport.getZoom(); + await viewport.zoom({ mode: 'absolute', value: zoom + diff }, this.animation); + + onfinish?.(); + } + + private async reset() { + const { viewport } = this.context; + await viewport?.zoom({ mode: 'absolute', value: 1 }, this.animation); + } + + private validate(event: BehaviorEvent | BehaviorEvent) { + if (this.preconditionKey && !isEqual(this.preconditionKey, Array.from(this.recordKey))) return false; + + const { enable } = this.options; + if (typeof enable === 'function' && !enable(event)) return false; + if (enable === false) return false; + return true; + } + + private preventDefault(eventName: string) { + const listener = (e: Event) => e.preventDefault(); + const container = this.context.canvas.getContainer(); + if (!container) return; + this.addEventListener(container, eventName, listener); + } +} diff --git a/packages/g6/src/constants/events/animation.ts b/packages/g6/src/constants/events/animation.ts new file mode 100644 index 00000000000..675a33767f0 --- /dev/null +++ b/packages/g6/src/constants/events/animation.ts @@ -0,0 +1,10 @@ +export const enum AnimationType { + DRAW = 'draw', + ZOOM = 'zoom', + ROTATE = 'rotate', + TRANSLATE = 'translate', + // LAYOUT = 'layout', // 布局没有统一的动画对象,因此不抛出动画事件 | There is no unified animation object for layout, so no animation event is thrown + ELEMENT_VISIBILITY_CHANGE = 'elementvisibilitychange', + ELEMENT_STATE_CHANGE = 'elementstatechange', + ELEMENT_TRANSLATE = 'elementtranslate', +} diff --git a/packages/g6/src/constants/events/canvas.ts b/packages/g6/src/constants/events/canvas.ts new file mode 100644 index 00000000000..b1dfcddae14 --- /dev/null +++ b/packages/g6/src/constants/events/canvas.ts @@ -0,0 +1,38 @@ +export enum CanvasEvent { + /** 点击时触发 | Triggered when click */ + CLICK = 'click', + /** 双击时触发 | Triggered when double click */ + DBLCLICK = 'dblclick', + /** 指针移入时触发 | Triggered when the pointer enters */ + POINTER_OVER = 'pointerover', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_LEAVE = 'pointerleave', + /** 指针移入时或移入子元素时触发(不会冒泡) | Triggered when the pointer enters or enters a child element (does not bubble) */ + POINTER_ENTER = 'pointerenter', + /** 指针移动时触发 | Triggered when the pointer moves */ + POINTER_MOVE = 'pointermove', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_OUT = 'pointerout', + /** 指针按下时触发 | Triggered when the pointer is pressed */ + POINTER_DOWN = 'pointerdown', + /** 指针抬起时触发 | Triggered when the pointer is lifted */ + POINTER_UP = 'pointerup', + /** 打开上下文菜单时触发 | Triggered when the context menu is opened */ + CONTEXT_MENU = 'contextmenu', + /** 开始拖拽时触发 | Triggered when dragging starts */ + DRAG_START = 'dragstart', + /** 拖拽过程中触发 | Triggered when dragging */ + DRAG = 'drag', + /** 拖拽结束时触发 | Triggered when dragging ends */ + DRAG_END = 'dragend', + /** 拖拽进入时触发 | Triggered when dragging enters */ + DRAG_ENTER = 'dragenter', + /** 拖拽经过时触发 | Triggered when dragging passes */ + DRAG_OVER = 'dragover', + /** 拖拽离开时触发 | Triggered when dragging leaves */ + DRAG_LEAVE = 'dragleave', + /** 拖拽放下时触发 | Triggered when dragging is dropped */ + DROP = 'drop', + /** 滚动时触发 | Triggered when scrolling */ + WHEEL = 'wheel', +} diff --git a/packages/g6/src/constants/events/combo.ts b/packages/g6/src/constants/events/combo.ts new file mode 100644 index 00000000000..611e002dc6d --- /dev/null +++ b/packages/g6/src/constants/events/combo.ts @@ -0,0 +1,3 @@ +import { NodeEvent } from './node'; + +export { NodeEvent as ComboEvent }; diff --git a/packages/g6/src/constants/events/common.ts b/packages/g6/src/constants/events/common.ts new file mode 100644 index 00000000000..bf50608107e --- /dev/null +++ b/packages/g6/src/constants/events/common.ts @@ -0,0 +1,42 @@ +export enum CommonEvent { + /** 点击时触发 | Triggered when click */ + CLICK = 'click', + /** 双击时触发 | Triggered when double click */ + DBLCLICK = 'dblclick', + /** 指针移入时触发 | Triggered when the pointer enters */ + POINTER_OVER = 'pointerover', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_LEAVE = 'pointerleave', + /** 指针移入时或移入子元素时触发(不会冒泡) | Triggered when the pointer enters or enters a child element (does not bubble) */ + POINTER_ENTER = 'pointerenter', + /** 指针移动时触发 | Triggered when the pointer moves */ + POINTER_MOVE = 'pointermove', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_OUT = 'pointerout', + /** 指针按下时触发 | Triggered when the pointer is pressed */ + POINTER_DOWN = 'pointerdown', + /** 指针抬起时触发 | Triggered when the pointer is lifted */ + POINTER_UP = 'pointerup', + /** 打开上下文菜单时触发 | Triggered when the context menu is opened */ + CONTEXT_MENU = 'contextmenu', + /** 开始拖拽时触发 | Triggered when dragging starts */ + DRAG_START = 'dragstart', + /** 拖拽过程中触发 | Triggered when dragging */ + DRAG = 'drag', + /** 拖拽结束时触发 | Triggered when dragging ends */ + DRAG_END = 'dragend', + /** 拖拽进入时触发 | Triggered when dragging enters */ + DRAG_ENTER = 'dragenter', + /** 拖拽经过时触发 | Triggered when dragging passes */ + DRAG_OVER = 'dragover', + /** 拖拽离开时触发 | Triggered when dragging leaves */ + DRAG_LEAVE = 'dragleave', + /** 拖拽放下时触发 | Triggered when dragging is dropped */ + DROP = 'drop', + /** 按下键盘时触发 | Triggered when the keyboard is pressed */ + KEY_DOWN = 'keydown', + /** 抬起键盘时触发 | Triggered when the keyboard is lifted */ + KEY_UP = 'keyup', + /** 滚动时触发 | Triggered when scrolling */ + WHEEL = 'wheel', +} diff --git a/packages/g6/src/constants/events/container.ts b/packages/g6/src/constants/events/container.ts new file mode 100644 index 00000000000..99467d3d521 --- /dev/null +++ b/packages/g6/src/constants/events/container.ts @@ -0,0 +1,6 @@ +export enum ContainerEvent { + /** 按下键盘时触发 | Triggered when the keyboard is pressed */ + KEY_DOWN = 'keydown', + /** 抬起键盘时触发 | Triggered when the keyboard is lifted */ + KEY_UP = 'keyup', +} diff --git a/packages/g6/src/constants/events/edge.ts b/packages/g6/src/constants/events/edge.ts new file mode 100644 index 00000000000..b06bcb8c566 --- /dev/null +++ b/packages/g6/src/constants/events/edge.ts @@ -0,0 +1,30 @@ +export enum EdgeEvent { + /** 点击时触发 | Triggered when click */ + CLICK = 'click', + /** 双击时触发 | Triggered when double click */ + DBLCLICK = 'dblclick', + /** 指针移入时触发 | Triggered when the pointer enters */ + POINTER_OVER = 'pointerover', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_LEAVE = 'pointerleave', + /** 指针移入时或移入子元素时触发(不会冒泡) | Triggered when the pointer enters or enters a child element (does not bubble) */ + POINTER_ENTER = 'pointerenter', + /** 指针移动时触发 | Triggered when the pointer moves */ + POINTER_MOVE = 'pointermove', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_OUT = 'pointerout', + /** 指针按下时触发 | Triggered when the pointer is pressed */ + POINTER_DOWN = 'pointerdown', + /** 指针抬起时触发 | Triggered when the pointer is lifted */ + POINTER_UP = 'pointerup', + /** 打开上下文菜单时触发 | Triggered when the context menu is opened */ + CONTEXT_MENU = 'contextmenu', + /** 拖拽进入时触发 | Triggered when dragging enters */ + DRAG_ENTER = 'dragenter', + /** 拖拽经过时触发 | Triggered when dragging passes */ + DRAG_OVER = 'dragover', + /** 拖拽离开时触发 | Triggered when dragging leaves */ + DRAG_LEAVE = 'dragleave', + /** 拖拽放下时触发 | Triggered when dragging is dropped */ + DROP = 'drop', +} diff --git a/packages/g6/src/constants/events.ts b/packages/g6/src/constants/events/graph.ts similarity index 85% rename from packages/g6/src/constants/events.ts rename to packages/g6/src/constants/events/graph.ts index b7910faee32..9d5a0f033ce 100644 --- a/packages/g6/src/constants/events.ts +++ b/packages/g6/src/constants/events/graph.ts @@ -1,10 +1,4 @@ export const enum GraphEvent { - /** 视口动画开始之前 | Before the viewport animation starts */ - BEFORE_VIEWPORT_ANIMATE = 'beforeviewportanimate', - /** 视口动画结束之后 | After the viewport animation ends */ - AFTER_VIEWPORT_ANIMATE = 'afterviewportanimate', - /** 视口动画停止之前 | Before the viewport animation stops */ - CANCEL_VIEWPORT_ANIMATE = 'cancelviewportanimate', /** 元素创建之前 | Before creating element */ BEFORE_ELEMENT_CREATE = 'beforeelementcreate', /** 元素创建之后 | After creating element */ @@ -37,6 +31,18 @@ export const enum GraphEvent { BEFORE_LAYOUT = 'beforelayout', /** 布局结束之后 | After layout */ AFTER_LAYOUT = 'afterlayout', + /** 缩放之前 | Before zoom */ + BEFORE_ZOOM = 'beforezoom', + /** 缩放之后 | After zoom */ + AFTER_ZOOM = 'afterzoom', + /** 旋转之前 | Before rotate */ + BEFORE_ROTATE = 'beforerotate', + /** 旋转之后 | After rotate */ + AFTER_ROTATE = 'afterrotate', + /** 平移之前 | Before translate */ + BEFORE_TRANSLATE = 'beforetranslate', + /** 平移之后 | After translate */ + AFTER_TRANSLATE = 'aftertranslate', /** 元素可见性变化之前 | Before the visibility of the element changes */ BEFORE_ELEMENT_VISIBILITY_CHANGE = 'beforeelementvisibilitychange', /** 元素可见性变化之后 | After the visibility of the element changes */ diff --git a/packages/g6/src/constants/events/index.ts b/packages/g6/src/constants/events/index.ts new file mode 100644 index 00000000000..b49505cb00b --- /dev/null +++ b/packages/g6/src/constants/events/index.ts @@ -0,0 +1,8 @@ +export { AnimationType } from './animation'; +export { CanvasEvent } from './canvas'; +export { ComboEvent } from './combo'; +export { CommonEvent } from './common'; +export { ContainerEvent } from './container'; +export { EdgeEvent } from './edge'; +export { GraphEvent } from './graph'; +export { NodeEvent } from './node'; diff --git a/packages/g6/src/constants/events/node.ts b/packages/g6/src/constants/events/node.ts new file mode 100644 index 00000000000..9073f974c1d --- /dev/null +++ b/packages/g6/src/constants/events/node.ts @@ -0,0 +1,36 @@ +export enum NodeEvent { + /** 点击时触发 | Triggered when click */ + CLICK = 'click', + /** 双击时触发 | Triggered when double click */ + DBLCLICK = 'dblclick', + /** 指针移入时触发 | Triggered when the pointer enters */ + POINTER_OVER = 'pointerover', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_LEAVE = 'pointerleave', + /** 指针移入时或移入子元素时触发(不会冒泡) | Triggered when the pointer enters or enters a child element (does not bubble) */ + POINTER_ENTER = 'pointerenter', + /** 指针移动时触发 | Triggered when the pointer moves */ + POINTER_MOVE = 'pointermove', + /** 指针移出时触发 | Triggered when the pointer leaves */ + POINTER_OUT = 'pointerout', + /** 指针按下时触发 | Triggered when the pointer is pressed */ + POINTER_DOWN = 'pointerdown', + /** 指针抬起时触发 | Triggered when the pointer is lifted */ + POINTER_UP = 'pointerup', + /** 打开上下文菜单时触发 | Triggered when the context menu is opened */ + CONTEXT_MENU = 'contextmenu', + /** 开始拖拽时触发 | Triggered when dragging starts */ + DRAG_START = 'dragstart', + /** 拖拽过程中触发 | Triggered when dragging */ + DRAG = 'drag', + /** 拖拽结束时触发 | Triggered when dragging ends */ + DRAG_END = 'dragend', + /** 拖拽进入时触发 | Triggered when dragging enters */ + DRAG_ENTER = 'dragenter', + /** 拖拽经过时触发 | Triggered when dragging passes */ + DRAG_OVER = 'dragover', + /** 拖拽离开时触发 | Triggered when dragging leaves */ + DRAG_LEAVE = 'dragleave', + /** 拖拽放下时触发 | Triggered when dragging is dropped */ + DROP = 'drop', +} diff --git a/packages/g6/src/elements/edges/index.ts b/packages/g6/src/elements/edges/index.ts index 62f47dd69d8..ab04ba6effa 100644 --- a/packages/g6/src/elements/edges/index.ts +++ b/packages/g6/src/elements/edges/index.ts @@ -1,3 +1,4 @@ +export { BaseEdge } from './base-edge'; export { Cubic } from './cubic'; export { CubicHorizontal } from './cubic-horizontal'; export { CubicVertical } from './cubic-vertical'; diff --git a/packages/g6/src/index.ts b/packages/g6/src/index.ts index 38c1ef751f8..53cecf73074 100644 --- a/packages/g6/src/index.ts +++ b/packages/g6/src/index.ts @@ -1,8 +1,12 @@ import './preset'; export const version = '5.0.0'; + +export { BaseBehavior } from './behaviors'; +export { CanvasEvent, ComboEvent, CommonEvent, ContainerEvent, EdgeEvent, GraphEvent, NodeEvent } from './constants'; export { getPlugin, getPlugins, register } from './registry'; export { Graph } from './runtime/graph'; export { treeToGraphData } from './utils/tree'; +export type { BaseBehaviorOptions } from './behaviors'; export type * from './spec'; diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts index 88ca561de45..f74f963c6e4 100644 --- a/packages/g6/src/registry/build-in.ts +++ b/packages/g6/src/registry/build-in.ts @@ -1,4 +1,5 @@ import { fade, translate } from '../animations'; +import { ZoomCanvas } from '../behaviors'; import { Circle, Cubic, @@ -43,7 +44,9 @@ export const BUILT_IN_PLUGINS = { fade, translate, }, - behavior: {}, + behavior: { + 'zoom-canvas': ZoomCanvas, + }, combo: {}, edge: { cubic: Cubic, diff --git a/packages/g6/src/registry/types.ts b/packages/g6/src/registry/types.ts index 721d57a883c..0783a7ea779 100644 --- a/packages/g6/src/registry/types.ts +++ b/packages/g6/src/registry/types.ts @@ -1,13 +1,12 @@ import type { Layout } from '@antv/layout'; 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 { Node } from '../types'; +import type { Edge, Node } from '../types'; // TODO 待使用正式类型定义 / To be used formal type definition -declare type Edge = unknown; declare type Combo = unknown; -declare type Behavior = unknown; declare type Widget = unknown; /** diff --git a/packages/g6/src/runtime/behavior.ts b/packages/g6/src/runtime/behavior.ts new file mode 100644 index 00000000000..ca845e868bc --- /dev/null +++ b/packages/g6/src/runtime/behavior.ts @@ -0,0 +1,151 @@ +import type { DisplayObject, FederatedPointerEvent, FederatedWheelEvent } from '@antv/g'; +import type { BaseBehavior } from '../behaviors/base-behavior'; +import { CanvasEvent, ContainerEvent } from '../constants'; +import { getPlugin } from '../registry'; +import type { BehaviorOptions } from '../spec'; +import type { STDBehaviorOption } from '../spec/behavior'; +import type { Target } from '../types'; +import { parseBehaviors } from '../utils/behaviors'; +import { arrayDiff } from '../utils/diff'; +import { eventTargetOf } from '../utils/event'; +import type { RuntimeContext } from './types'; + +export class BehaviorController { + private context: RuntimeContext; + + private behaviors: STDBehaviorOption[] = []; + + private behaviorMap: Record> = {}; + + /** 当前事件的目标 | The current event target */ + private currentTarget: Target | null = null; + + constructor(context: RuntimeContext) { + this.context = context; + this.forwardEvents(); + this.setBehaviors(this.context.options?.behaviors || []); + } + + public setBehaviors(behaviors: BehaviorOptions) { + const newBehaviors = parseBehaviors(behaviors); + const { enter, update, exit, keep } = arrayDiff(this.behaviors, newBehaviors, (behavior) => behavior.key); + + this.createBehaviors(enter); + this.updateBehaviors([...update, ...keep]); + this.destroyBehaviors(exit); + + this.behaviors = newBehaviors; + } + + private createBehavior(behavior: STDBehaviorOption) { + const { key, type } = behavior; + const Ctor = getPlugin('behavior', type); + if (!Ctor) return; + + const instance = new Ctor(this.context, behavior); + this.behaviorMap[key] = instance; + } + + private createBehaviors(behaviors: STDBehaviorOption[]) { + behaviors.forEach((behavior) => this.createBehavior(behavior)); + } + + private updateBehaviors(behaviors: STDBehaviorOption[]) { + behaviors.forEach((behavior) => { + const { key } = behavior; + const instance = this.behaviorMap[key]; + if (instance) { + instance.update(behavior); + } + }); + } + + private destroyBehavior(key: string) { + const instance = this.behaviorMap[key]; + if (instance) { + instance.destroy(); + delete this.behaviorMap[key]; + } + } + + private destroyBehaviors(behaviors: STDBehaviorOption[]) { + behaviors.forEach(({ key }) => this.destroyBehavior(key)); + } + + private forwardEvents() { + const container = this.context.canvas.getContainer(); + if (container) { + Object.values(ContainerEvent).forEach((name) => { + container.addEventListener(name, this.forwardContainerEvents.bind(this)); + }); + } + + const canvas = this.context.canvas.document; + if (canvas) { + Object.values(CanvasEvent).forEach((name) => { + canvas.addEventListener(name, this.forwardCanvasEvents.bind(this)); + }); + } + } + + private forwardCanvasEvents(event: FederatedPointerEvent | FederatedWheelEvent) { + const target = eventTargetOf(event.target as DisplayObject); + if (!target) return; + const { graph, canvas } = this.context; + const { type: targetType, element: targetElement } = target; + const { type, detail, button } = event; + const stdEvent = { ...event, target: targetElement, targetType }; + + if (type === CanvasEvent.POINTER_MOVE) { + if (this.currentTarget !== targetElement) { + if (this.currentTarget) { + graph.emit(`${targetType}:${CanvasEvent.POINTER_LEAVE}`, { ...stdEvent, target: this.currentTarget }); + } + if (targetElement) { + graph.emit(`${targetType}:${CanvasEvent.POINTER_ENTER}`, stdEvent); + } + } + this.currentTarget = targetElement; + } + + // 非右键点击事件 / Click event except right click + if (!(type === CanvasEvent.CLICK && button === 2)) { + graph.emit(`${targetType}:${type}`, stdEvent); + graph.emit(type, stdEvent); + } + + // 双击事件 / Double click event + if (type === CanvasEvent.CLICK && detail === 2) { + graph.emit(`${targetType}:${CanvasEvent.DBLCLICK}`, stdEvent); + graph.emit(CanvasEvent.DBLCLICK, stdEvent); + } + + // 右键菜单 / ContextMenu + if (type === CanvasEvent.POINTER_DOWN && button === 2) { + const contextMenuEvent = { + ...stdEvent, + preventDefault: () => { + canvas.getContainer()?.addEventListener(CanvasEvent.CONTEXT_MENU, (e) => e.preventDefault(), { + once: true, + }); + }, + }; + graph.emit(`${targetType}:${CanvasEvent.CONTEXT_MENU}`, contextMenuEvent); + graph.emit(CanvasEvent.CONTEXT_MENU, contextMenuEvent); + } + } + + private forwardContainerEvents(event: FocusEvent | KeyboardEvent) { + this.context.graph.emit(event.type, event); + } + + public destroy() { + Object.keys(this.behaviorMap).forEach((key) => this.destroyBehavior(key)); + // @ts-expect-error force delete + delete this.context; + // @ts-expect-error force delete + delete this.behaviors; + // @ts-expect-error force delete + delete this.behaviorMap; + } +} diff --git a/packages/g6/src/runtime/canvas.ts b/packages/g6/src/runtime/canvas.ts index 03b94de79f0..9dc1e338553 100644 --- a/packages/g6/src/runtime/canvas.ts +++ b/packages/g6/src/runtime/canvas.ts @@ -40,6 +40,10 @@ export class Canvas { }; } + public get document() { + return this.main.document; + } + public renderers!: Record; constructor(config: CanvasConfig) { diff --git a/packages/g6/src/runtime/element.ts b/packages/g6/src/runtime/element.ts index 9fe4f8be670..9cec4ea38c2 100644 --- a/packages/g6/src/runtime/element.ts +++ b/packages/g6/src/runtime/element.ts @@ -6,7 +6,7 @@ import type { ID } from '@antv/graphlib'; import { groupBy, pick } from '@antv/util'; import { executor as animationExecutor } from '../animations'; import type { AnimationContext } from '../animations/types'; -import { AnimationTypeEnum, ChangeTypeEnum, GraphEvent } from '../constants'; +import { AnimationType, ChangeTypeEnum, GraphEvent } from '../constants'; import type { BaseEdge } from '../elements/edges/base-edge'; import type { BaseNode } from '../elements/nodes'; import type { BaseShape } from '../elements/shapes'; @@ -34,6 +34,7 @@ import { cacheStyle, getCachedStyle } from '../utils/cache'; import { reduceDataChanges } from '../utils/change'; import { isEmptyData } from '../utils/data'; import { isVisible, updateStyle } from '../utils/element'; +import type { BaseEvent } from '../utils/event'; import { AnimateEvent, DrawEvent, @@ -41,7 +42,6 @@ import { ElementTranslateEvent, ElementVisibilityChangeEvent, ElementZIndexChangeEvent, - type Event, } from '../utils/event'; import { idOf } from '../utils/id'; import { assignColorByPalette, parsePalette } from '../utils/palette'; @@ -91,7 +91,7 @@ export class ElementController { } } - private emit(event: Event) { + private emit(event: BaseEvent) { const { graph } = this.context; graph.emit(event.type, event); } @@ -238,13 +238,9 @@ export class ElementController { executeAnimatableTasks(tasks, { before: () => this.emit(new ElementStateChangeEvent(GraphEvent.BEFORE_ELEMENT_STATE_CHANGE, states)), beforeAnimate: (animation) => - this.emit( - new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationTypeEnum.ELEMENT_STATE_CHANGE, animation, states), - ), + this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.ELEMENT_STATE_CHANGE, animation, states)), afterAnimate: (animation) => - this.emit( - new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationTypeEnum.ELEMENT_STATE_CHANGE, animation, states), - ), + this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.ELEMENT_STATE_CHANGE, animation, states)), after: () => this.emit(new ElementStateChangeEvent(GraphEvent.AFTER_ELEMENT_STATE_CHANGE, states)), }); } @@ -543,9 +539,9 @@ export class ElementController { return executeAnimatableTasks([...destroyTasks, ...createTasks, ...updateTasks], { before: () => this.emit(new DrawEvent(GraphEvent.BEFORE_DRAW)), beforeAnimate: (animation) => - this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationTypeEnum.DRAW, animation, diffData)), + this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.DRAW, animation, diffData)), afterAnimate: (animation) => - this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationTypeEnum.DRAW, animation, diffData)), + this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.DRAW, animation, diffData)), after: () => this.emit(new DrawEvent(GraphEvent.AFTER_DRAW)), })?.finished.then(() => {}); } @@ -674,13 +670,9 @@ export class ElementController { return executeAnimatableTasks([...nodeTasks, ...edgeTasks], { before: () => this.emit(new ElementTranslateEvent(GraphEvent.BEFORE_ELEMENT_TRANSLATE, positions)), beforeAnimate: (animation) => - this.emit( - new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationTypeEnum.ELEMENT_TRANSLATE, animation, positions), - ), + this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.ELEMENT_TRANSLATE, animation, positions)), afterAnimate: (animation) => - this.emit( - new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationTypeEnum.ELEMENT_TRANSLATE, animation, positions), - ), + this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.ELEMENT_TRANSLATE, animation, positions)), after: () => this.emit(new ElementTranslateEvent(GraphEvent.AFTER_ELEMENT_TRANSLATE, positions)), }); } @@ -857,14 +849,14 @@ export class ElementController { this.emit(new ElementVisibilityChangeEvent(GraphEvent.BEFORE_ELEMENT_VISIBILITY_CHANGE, ids, visibility)), beforeAnimate: (animation) => this.emit( - new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationTypeEnum.ELEMENT_VISIBILITY_CHANGE, animation, { + new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.ELEMENT_VISIBILITY_CHANGE, animation, { ids, visibility, }), ), afterAnimate: (animation) => this.emit( - new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationTypeEnum.ELEMENT_VISIBILITY_CHANGE, animation, { + new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.ELEMENT_VISIBILITY_CHANGE, animation, { ids, visibility, }), diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts index 45bd8e87009..eb2bb7e1353 100644 --- a/packages/g6/src/runtime/graph.ts +++ b/packages/g6/src/runtime/graph.ts @@ -37,6 +37,7 @@ import { sizeOf } from '../utils/dom'; import { RenderEvent, emit } from '../utils/event'; import { parsePoint, toPointObject } from '../utils/point'; import { add } from '../utils/vector'; +import { BehaviorController } from './behavior'; import { Canvas } from './canvas'; import { DataController } from './data'; import { ElementController } from './element'; @@ -155,6 +156,7 @@ export class Graph extends EventEmitter { public setBehaviors(behaviors: CallableValue): void { this.options.behaviors = isFunction(behaviors) ? behaviors(this.getBehaviors()) : behaviors; + this.context.behavior?.setBehaviors(this.options.behaviors); } public getBehaviors(): BehaviorOptions { @@ -310,6 +312,7 @@ export class Graph extends EventEmitter { if (!this.context.viewport) this.context.viewport = new ViewportController(this.context); if (!this.context.element) this.context.element = new ElementController(this.context); if (!this.context.layout) this.context.layout = new LayoutController(this.context); + if (!this.context.behavior) this.context.behavior = new BehaviorController(this.context); } private async prepare(): Promise { @@ -363,11 +366,12 @@ export class Graph extends EventEmitter { } public destroy(): void { - const { layout, element, model, canvas } = this.context; + const { layout, element, model, canvas, behavior } = this.context; layout?.destroy(); element?.destroy(); model.destroy(); canvas?.destroy(); + behavior?.destroy(); this.options = {}; // @ts-expect-error force delete delete this.context; @@ -378,6 +382,10 @@ export class Graph extends EventEmitter { } // ---------- Runtime API ---------- + public getCanvas(): Canvas { + return this.context.canvas; + } + public resize(): void; public resize(width: number, height: number): void; public resize(width?: number, height?: number): void { diff --git a/packages/g6/src/runtime/types.ts b/packages/g6/src/runtime/types.ts index e494cc73b8c..c2bb2ba437a 100644 --- a/packages/g6/src/runtime/types.ts +++ b/packages/g6/src/runtime/types.ts @@ -1,4 +1,5 @@ import type { G6Spec } from '../spec'; +import type { BehaviorController } from './behavior'; import type { Canvas } from './canvas'; import type { DataController } from './data'; import type { ElementController } from './element'; @@ -48,9 +49,15 @@ export interface RuntimeContext { */ viewport?: ViewportController; /** - * 布局 + * 布局控制器 * - * Layout + * Layout controller */ layout?: LayoutController; + /** + * 行为控制器 + * + * Behavior controller + */ + behavior?: BehaviorController; } diff --git a/packages/g6/src/runtime/viewport.ts b/packages/g6/src/runtime/viewport.ts index c528f2ba276..895456b73b8 100644 --- a/packages/g6/src/runtime/viewport.ts +++ b/packages/g6/src/runtime/viewport.ts @@ -1,13 +1,15 @@ import { clamp } from '@antv/util'; -import { GraphEvent } from '../constants'; +import { AnimationType, GraphEvent } from '../constants'; import type { Point, RotateOptions, TranslateOptions, Vector2, + Vector3, ViewportAnimationEffectTiming, ZoomOptions, } from '../types'; +import { AnimateEvent, ViewportEvent, emit } from '../utils/event'; import type { RuntimeContext } from './types'; export class ViewportController { @@ -79,6 +81,7 @@ export class ViewportController { const currentZoom = this.getZoom(); this.cancelAnimation(); const { camera } = this; + const { graph } = this.context; const { mode, value: [x = 0, y = 0, z = 0], @@ -89,62 +92,67 @@ export class ViewportController { const [fx, fy, fz] = camera.getFocalPoint(); if (animation) { - this.context.graph.emit(GraphEvent.BEFORE_VIEWPORT_ANIMATE, options); + const value = + mode === 'relative' + ? { + position: [px - x, py - y, pz - z] as Vector3, + focalPoint: [fx - x, fy - y, fz - z] as Vector3, + } + : { + position: [ox - x, oy - y, z ?? pz - z] as Vector3, + focalPoint: [ox - x, oy - y, z ?? fz - z] as Vector3, + }; + + emit(graph, new ViewportEvent(GraphEvent.BEFORE_TRANSLATE, value)); + emit(graph, new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.TRANSLATE, null, value)); return new Promise((resolve) => { const onfinish = () => { - this.context.graph.emit(GraphEvent.AFTER_VIEWPORT_ANIMATE, options); + emit(graph, new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.TRANSLATE, null, value)); + emit(graph, new ViewportEvent(GraphEvent.AFTER_TRANSLATE, value)); resolve(); }; - this.camera.gotoLandmark( - this.createLandmark( - mode === 'relative' - ? { - position: [px - x, py - y, pz - z], - focalPoint: [fx - x, fy - y, fz - z], - } - : { - position: [ox - x, oy - y, z ?? pz - z], - focalPoint: [ox - x, oy - y, z ?? fz - z], - }, - ), - { ...animation, onfinish }, - ); + this.camera.gotoLandmark(this.createLandmark(value), { ...animation, onfinish }); }); } else { - const point: Vector2 = mode === 'relative' ? [-x / currentZoom, -y / currentZoom] : [-px + ox - x, -py + oy - y]; - camera.pan(...point); + const value: Vector2 = mode === 'relative' ? [-x / currentZoom, -y / currentZoom] : [-px + ox - x, -py + oy - y]; + emit(graph, new ViewportEvent(GraphEvent.BEFORE_TRANSLATE, value)); + camera.pan(...value); + emit(graph, new ViewportEvent(GraphEvent.BEFORE_TRANSLATE, value)); } } public rotate(options: RotateOptions, animation?: ViewportAnimationEffectTiming) { this.cancelAnimation(); const { camera } = this; + const { graph } = this.context; const { mode, value: angle, origin } = options; if (animation) { - this.context.graph.emit(GraphEvent.BEFORE_VIEWPORT_ANIMATE, options); + const roll = mode === 'relative' ? camera.getRoll() + angle : angle; + const value = { roll }; + emit(graph, new ViewportEvent(GraphEvent.BEFORE_ROTATE, value)); + emit(graph, new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.ROTATE, null, value)); return new Promise((resolve) => { const onfinish = () => { - this.context.graph.emit(GraphEvent.AFTER_VIEWPORT_ANIMATE, options); + emit(graph, new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.ROTATE, null, value)); + emit(graph, new ViewportEvent(GraphEvent.BEFORE_ROTATE, value)); resolve(); }; - this.camera.gotoLandmark( - this.createLandmark({ roll: mode === 'relative' ? camera.getRoll() + angle : angle }), - { ...animation, onfinish }, - ); + this.camera.gotoLandmark(this.createLandmark(value), { ...animation, onfinish }); }); } else { const [x, y] = camera.getFocalPoint(); - if (origin) camera.pan(origin[0] - x, origin[1] - y); + const value = mode === 'relative' ? angle : angle - camera.getRoll(); + emit(graph, new ViewportEvent(GraphEvent.BEFORE_ROTATE, value)); camera.rotate(0, 0, mode === 'relative' ? angle : angle - camera.getRoll()); - if (origin) camera.pan(x - origin[0], y - origin[1]); + emit(graph, new ViewportEvent(GraphEvent.AFTER_ROTATE, value)); } } @@ -153,33 +161,36 @@ export class ViewportController { this.cancelAnimation(); const { camera } = this; + const { graph } = this.context; const currentZoom = camera.getZoom(); const { mode, value: zoom, origin = this.getCanvasCenter() } = options; - const targetRatio = clamp(mode === 'relative' ? currentZoom * zoom : zoom, ...zoomRange); + const value = { zoom: targetRatio }; + emit(graph, new ViewportEvent(GraphEvent.BEFORE_ZOOM, value)); + if (animation) { - this.context.graph.emit(GraphEvent.BEFORE_VIEWPORT_ANIMATE, options); + emit(graph, new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.ZOOM, null, value)); return new Promise((resolve) => { const onfinish = () => { - this.context.graph.emit(GraphEvent.AFTER_VIEWPORT_ANIMATE, options); + emit(graph, new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.ZOOM, null, value)); + emit(graph, new ViewportEvent(GraphEvent.AFTER_ZOOM, value)); resolve(); }; - this.camera.gotoLandmark(this.createLandmark({ zoom: targetRatio }), { ...animation, onfinish }); + this.camera.gotoLandmark(this.createLandmark(value), { ...animation, onfinish }); }); } else { camera.setZoomByViewportPoint(targetRatio, [origin[0], origin[1]]); + emit(graph, new ViewportEvent(GraphEvent.AFTER_ZOOM, value)); } } public cancelAnimation() { - const { graph } = this.context; // @ts-expect-error landmarks is private if (this.camera.landmarks?.length) { this.camera.cancelLandmarkAnimation(); - graph.emit(GraphEvent.CANCEL_VIEWPORT_ANIMATE); } } } diff --git a/packages/g6/src/spec/behavior.ts b/packages/g6/src/spec/behavior.ts index 896d2fedbc8..a5e9c3d731b 100644 --- a/packages/g6/src/spec/behavior.ts +++ b/packages/g6/src/spec/behavior.ts @@ -1,12 +1,9 @@ -import type { BuiltInBehaviorOptions } from '../behaviors/types'; +import type { BuiltInBehaviorOptions } from '../behaviors'; -export type BehaviorOptions = Abbr[]; +export type BehaviorOptions = Abbr[]; -type CustomBehaviorOptions = STDBehaviorOptions; +export type STDBehaviorOption = { type: string; key: string; [key: string]: unknown }; -export interface STDBehaviorOptions { - type: string; - [key: string]: unknown; -} +export type CustomBehaviorOption = { type: string; key?: string; [key: string]: unknown }; -type Abbr = (R & { key?: string }) | R['type']; +type Abbr = (R & { key?: string }) | R['type']; diff --git a/packages/g6/src/types/behavior.ts b/packages/g6/src/types/behavior.ts new file mode 100644 index 00000000000..a8b15f54c11 --- /dev/null +++ b/packages/g6/src/types/behavior.ts @@ -0,0 +1,3 @@ +export type BehaviorEvent = T & { + targetType: 'canvas' | 'node' | 'edge' | 'combo'; +}; diff --git a/packages/g6/src/types/element.ts b/packages/g6/src/types/element.ts index 3decafc7ebf..5c3e72d2f77 100644 --- a/packages/g6/src/types/element.ts +++ b/packages/g6/src/types/element.ts @@ -1,5 +1,6 @@ import type { BaseStyleProps, DisplayObject, PathStyleProps } from '@antv/g'; -import { BaseNode } from '../elements/nodes'; +import type { BaseEdge } from '../elements/edges'; +import type { BaseNode } from '../elements/nodes'; import type { ComboOptions, EdgeOptions, NodeOptions } from '../spec'; import type { Size } from './size'; @@ -9,6 +10,8 @@ export type ElementOptions = NodeOptions | EdgeOptions | ComboOptions; export type Node = BaseNode; +export type Edge = BaseEdge; + export type BaseNodeProps = BaseStyleProps & { /** * x 坐标 diff --git a/packages/g6/src/types/event.ts b/packages/g6/src/types/event.ts new file mode 100644 index 00000000000..862f024b906 --- /dev/null +++ b/packages/g6/src/types/event.ts @@ -0,0 +1,6 @@ +import type { Document } from '@antv/g'; +import { Edge, Node } from './element'; + +export type Listener = (event: any) => void; + +export type Target = Document | Node | Edge | null; diff --git a/packages/g6/src/types/index.ts b/packages/g6/src/types/index.ts index 3ad751d3fb4..c79fd944f67 100644 --- a/packages/g6/src/types/index.ts +++ b/packages/g6/src/types/index.ts @@ -1,4 +1,5 @@ export type * from './animation'; +export type * from './behavior'; export type * from './callable'; export type * from './canvas'; export type * from './change'; @@ -6,6 +7,7 @@ export type * from './data'; export type * from './edge'; export type * from './element'; export type * from './enum'; +export type * from './event'; export type * from './graphlib'; export type * from './layout'; export type * from './node'; diff --git a/packages/g6/src/utils/animation.ts b/packages/g6/src/utils/animation.ts index b19d5f93ace..caa862022bc 100644 --- a/packages/g6/src/utils/animation.ts +++ b/packages/g6/src/utils/animation.ts @@ -1,7 +1,7 @@ import type { DisplayObject, IAnimation } from '@antv/g'; import { isEqual, isNil } from '@antv/util'; import type { AnimatableTask, Keyframe } from '../types'; -import { isNode } from './is'; +import { isNode } from './element'; import { getDescendantShapes } from './shape'; export function createAnimationsProxy(animations: IAnimation[]): IAnimation | null; diff --git a/packages/g6/src/utils/behaviors.ts b/packages/g6/src/utils/behaviors.ts new file mode 100644 index 00000000000..62647f4ba4e --- /dev/null +++ b/packages/g6/src/utils/behaviors.ts @@ -0,0 +1,24 @@ +import type { BehaviorOptions } from '../spec'; +import type { STDBehaviorOption } from '../spec/behavior'; + +/** + * 将行为配置项转换为标准格式 + * + * Convert behavior options to standard format + * @param behaviors - 行为配置项 behavior options + * @returns 标准行为配置项 Standard behavior options + */ +export function parseBehaviors(behaviors: BehaviorOptions): STDBehaviorOption[] { + const counter: Record = {}; + const getKey = (type: string) => { + if (!(type in counter)) counter[type] = 0; + return `behavior-${type}-${counter[type]++}`; + }; + return behaviors.map((behavior) => { + if (typeof behavior === 'string') { + return { type: behavior, key: getKey(behavior) }; + } + if (behavior.key) return behavior as STDBehaviorOption; + return { ...behavior, key: getKey(behavior.type) }; + }); +} diff --git a/packages/g6/src/utils/element.ts b/packages/g6/src/utils/element.ts index dacdc226a55..65841433258 100644 --- a/packages/g6/src/utils/element.ts +++ b/packages/g6/src/utils/element.ts @@ -1,12 +1,36 @@ import type { AABB, DisplayObject, TextStyleProps } from '@antv/g'; import { get, isString } from '@antv/util'; +import { BaseEdge } from '../elements/edges/base-edge'; +import { BaseNode } from '../elements/nodes'; import type { TriangleDirection } from '../elements/nodes/triangle'; -import type { Node, Point, Position } from '../types'; +import type { Edge, Node, Point, Position } from '../types'; import type { LabelPosition, Port, RelativePosition } from '../types/node'; import { getBBoxHeight, getBBoxWidth } from './bbox'; -import { isNode, isPoint } from './is'; +import { isPoint } from './is'; import { findNearestPoints, getEllipseIntersectPoint } from './point'; +/** + * 判断是否是 BaseNode 的实例 + * + * Judge whether the instance is BaseNode + * @param shape - 实例 | instance + * @returns 是否是 BaseNode 的实例 | whether the instance is BaseNode + */ +export function isNode(shape: DisplayObject): shape is Node { + return shape instanceof BaseNode; +} + +/** + * 判断是否是 BaseEdge 的实例 + * + * Judge whether the instance is BaseEdge + * @param shape - 实例 | instance + * @returns 是否是 BaseEdge 的实例 | whether the instance is BaseEdge + */ +export function isEdge(shape: DisplayObject): shape is Edge { + return shape instanceof BaseEdge; +} + /** * 判断两个节点是否相同 * diff --git a/packages/g6/src/utils/event.ts b/packages/g6/src/utils/event/events.ts similarity index 56% rename from packages/g6/src/utils/event.ts rename to packages/g6/src/utils/event/events.ts index ce7e71dd6a9..abcdc221dab 100644 --- a/packages/g6/src/utils/event.ts +++ b/packages/g6/src/utils/event/events.ts @@ -1,47 +1,37 @@ -import type EventEmitter from '@antv/event-emitter'; -import type { BaseStyleProps, IAnimation } from '@antv/g'; +import type { BaseStyleProps, IAnimation, ICamera } from '@antv/g'; import type { ID } from '@antv/graphlib'; -import type { AnimationTypeEnum, GraphEvent } from '../constants'; -import type { GraphData } from '../spec'; -import type { Positions, States, ZIndex } from '../types'; +import type { AnimationType, GraphEvent } from '../../constants'; +import type { GraphData } from '../../spec'; +import type { Positions, States, Vector2, ZIndex } from '../../types'; -/** - * - * @param target - * @param event - */ -export function emit(target: EventEmitter, event: Event) { - target.emit(event.type, event); -} - -export class Event { +export class BaseEvent { constructor(public type: string) {} } -export class RenderEvent extends Event { +export class RenderEvent extends BaseEvent { constructor(type: GraphEvent.BEFORE_RENDER | GraphEvent.AFTER_RENDER) { super(type); } } -export class DrawEvent extends Event { +export class DrawEvent extends BaseEvent { constructor(type: GraphEvent.BEFORE_DRAW | GraphEvent.AFTER_DRAW) { super(type); } } -export class AnimateEvent extends Event { +export class AnimateEvent extends BaseEvent { constructor( type: GraphEvent.BEFORE_ANIMATE | GraphEvent.AFTER_ANIMATE, - public animationType: AnimationTypeEnum, - public animation: IAnimation, + public animationType: AnimationType, + public animation: IAnimation | null, public data?: any, ) { super(type); } } -export class ElementLifeCycleEvent extends Event { +export class ElementLifeCycleEvent extends BaseEvent { constructor( type: | GraphEvent.BEFORE_ELEMENT_CREATE @@ -56,7 +46,22 @@ export class ElementLifeCycleEvent extends Event { } } -export class ElementStateChangeEvent extends Event { +export class ViewportEvent extends BaseEvent { + constructor( + type: + | GraphEvent.BEFORE_ZOOM + | GraphEvent.AFTER_ZOOM + | GraphEvent.BEFORE_ROTATE + | GraphEvent.AFTER_ROTATE + | GraphEvent.BEFORE_TRANSLATE + | GraphEvent.AFTER_TRANSLATE, + public data: Parameters[1] | number | Vector2, + ) { + super(type); + } +} + +export class ElementStateChangeEvent extends BaseEvent { constructor( type: GraphEvent.BEFORE_ELEMENT_STATE_CHANGE | GraphEvent.AFTER_ELEMENT_STATE_CHANGE, public states: States, @@ -65,7 +70,7 @@ export class ElementStateChangeEvent extends Event { } } -export class ElementTranslateEvent extends Event { +export class ElementTranslateEvent extends BaseEvent { constructor( type: GraphEvent.BEFORE_ELEMENT_TRANSLATE | GraphEvent.AFTER_ELEMENT_TRANSLATE, public positions: Positions, @@ -74,7 +79,7 @@ export class ElementTranslateEvent extends Event { } } -export class ElementVisibilityChangeEvent extends Event { +export class ElementVisibilityChangeEvent extends BaseEvent { constructor( type: GraphEvent.BEFORE_ELEMENT_VISIBILITY_CHANGE | GraphEvent.AFTER_ELEMENT_VISIBILITY_CHANGE, public ids: ID[], @@ -84,7 +89,7 @@ export class ElementVisibilityChangeEvent extends Event { } } -export class ElementZIndexChangeEvent extends Event { +export class ElementZIndexChangeEvent extends BaseEvent { constructor( type: GraphEvent.BEFORE_ELEMENT_Z_INDEX_CHANGE | GraphEvent.AFTER_ELEMENT_Z_INDEX_CHANGE, public id: ID, diff --git a/packages/g6/src/utils/event/index.ts b/packages/g6/src/utils/event/index.ts new file mode 100644 index 00000000000..3bcfae8e542 --- /dev/null +++ b/packages/g6/src/utils/event/index.ts @@ -0,0 +1,51 @@ +import type EventEmitter from '@antv/event-emitter'; +import type { DisplayObject } from '@antv/g'; +import { Document } from '@antv/g'; +import { Target } from '../../types'; +import { isEdge, isNode } from '../element'; +import type { BaseEvent } from './events'; + +export * from './events'; + +/** + * 基于 Event 对象触发事件 + * + * Trigger event based on Event object + * @param emitter - 事件目标 | event target + * @param event - 事件对象 | event object + */ +export function emit(emitter: EventEmitter, event: BaseEvent) { + emitter.emit(event.type, event); +} + +/** + * 获取事件目标元素 + * + * Get the event target element + * @param shape - 事件图形 | event shape + * @returns 目标元素 | target element + * @description + * 事件响应大多数情况会命中元素的内部图形,通过该方法可以获取到其所属元素 + * + * Most of the event responses will hit the internal graphics of the element, and this method can be used to get the element to which it belongs + */ +export function eventTargetOf(shape?: DisplayObject | Document): { type: string; element: Target } | null { + if (!shape) return null; + + if (shape instanceof Document) { + return { type: 'canvas', element: shape }; + } + + let element: DisplayObject | null = shape; + while (element) { + // 此判断条件不适用于 label 和 节点分开渲染的情况 + // This condition is not applicable to the case where the label and node are rendered separately + if (isNode(element)) return { type: 'node', element }; + if (isEdge(element)) return { type: 'edge', element }; + // TODO is not combo + // if (isCombo(element)) return { type: 'combo', element }; + element = element.parentElement as DisplayObject | null; + } + + return null; +} diff --git a/packages/g6/src/utils/is.ts b/packages/g6/src/utils/is.ts index f6bed1bb677..da79a8ecc3d 100644 --- a/packages/g6/src/utils/is.ts +++ b/packages/g6/src/utils/is.ts @@ -1,6 +1,5 @@ -import { BaseNode } from '../elements/nodes'; import type { EdgeData } from '../spec'; -import type { ElementDatum, Node, Point, Vector2, Vector3 } from '../types'; +import type { ElementDatum, Point, Vector2, Vector3 } from '../types'; /** * 判断是否为边数据 @@ -52,14 +51,3 @@ export function isPoint(p: any): p is Point { return false; } - -/** - * 判断是否是 BaseNode 的实例 - * - * Judge whether the instance is BaseNode - * @param p - 实例 | instance - * @returns 是否是 BaseNode 的实例 | whether the instance is BaseNode - */ -export function isNode(p: any): p is Node { - return p instanceof BaseNode; -} diff --git a/packages/g6/src/utils/prefix.ts b/packages/g6/src/utils/prefix.ts index 7fabcfa63d6..6eb05ac7035 100644 --- a/packages/g6/src/utils/prefix.ts +++ b/packages/g6/src/utils/prefix.ts @@ -1,13 +1,13 @@ import { isString, lowerFirst, upperFirst } from '@antv/util'; -import type { PrefixObject, ReplacePrefix } from '../types'; +import type { ReplacePrefix } from '../types'; /** - * 是否以某个前缀开头 + * 是否以某个前缀开头 * - * Whether starts with prefix - * @param str - 字符串 | string - * @param prefix - 前缀 | prefix - * @returns 是否以某个前缀开头 | whether starts with prefix + * Whether starts with prefix + * @param str - 字符串 | string + * @param prefix - 前缀 | prefix + * @returns 是否以某个前缀开头 | whether starts with prefix */ export function startsWith(str: string, prefix: string) { if (!str.startsWith(prefix)) return false; @@ -16,25 +16,25 @@ export function startsWith(str: string, prefix: string) { } /** - * 添加前缀 + * 添加前缀 * - * Add prefix - * @param str - 字符串 | string - * @param prefix - 前缀 | prefix - * @returns 添加前缀后的字符串 | string with prefix + * Add prefix + * @param str - 字符串 | string + * @param prefix - 前缀 | prefix + * @returns 添加前缀后的字符串 | string with prefix */ export function addPrefix(str: string, prefix: string): string { return `${prefix}${upperFirst(str)}`; } /** - * 移除前缀 + * 移除前缀 * - * Remove prefix - * @param string - 字符串 | string - * @param prefix - 前缀 | prefix - * @param lowercaseFirstLetter - 是否小写首字母 | whether lowercase first letter - * @returns 移除前缀后的字符串 | string without prefix + * Remove prefix + * @param string - 字符串 | string + * @param prefix - 前缀 | prefix + * @param lowercaseFirstLetter - 是否小写首字母 | whether lowercase first letter + * @returns 移除前缀后的字符串 | string without prefix */ export function removePrefix(string: string, prefix?: string, lowercaseFirstLetter: boolean = true) { if (!prefix) return string; @@ -44,12 +44,12 @@ export function removePrefix(string: string, prefix?: string, lowercaseFirstLett } /** - * 从样式中提取子样式 + * 从样式中提取子样式 * - * Extract sub style from style - * @param style - 样式 | style - * @param prefix - 子样式前缀 | sub style prefix - * @returns 子样式 | sub style + * Extract sub style from style + * @param style - 样式 | style + * @param prefix - 子样式前缀 | sub style prefix + * @returns 子样式 | sub style */ export function subStyleProps(style: object, prefix: string) { return Object.entries(style).reduce((acc, [key, value]) => { @@ -102,32 +102,14 @@ export function omitStyleProps(style: object, prefix: string | }, {} as T); } -/** - * 创建前缀样式 - * - * Create prefix style - * @param style - 样式 | style - * @param prefix - 前缀 | prefix - * @returns 前缀样式 | prefix style - */ -export function superStyleProps(style: T, prefix: P): PrefixObject { - return Object.entries(style).reduce( - (acc, [key, value]) => { - acc[addPrefix(key, prefix) as keyof typeof acc] = value; - return acc; - }, - {} as PrefixObject, - ); -} - /** * 替换前缀 * - * Replace prefix - * @param style - 样式 | style - * @param oldPrefix - 旧前缀 | old prefix - * @param newPrefix - 新前缀 | new prefix - * @returns 替换前缀后的样式 | style with replaced prefix + * Replace prefix + * @param style - 样式 | style + * @param oldPrefix - 旧前缀 | old prefix + * @param newPrefix - 新前缀 | new prefix + * @returns 替换前缀后的样式 | style with replaced prefix */ export function replacePrefix(style: T, oldPrefix: string, newPrefix: string) { return Object.entries(style).reduce(