From 049b2b8c1f53e01087f436c212f5c6d0ad3659bb Mon Sep 17 00:00:00 2001 From: lxfu1 <954055752@qq.com> Date: Tue, 19 Mar 2024 21:14:53 +0800 Subject: [PATCH] feat: tooltip plugin --- packages/g6/__tests__/demo/case/index.ts | 1 + .../g6/__tests__/demo/case/plugin-tooltip.ts | 50 ++++ packages/g6/package.json | 5 +- packages/g6/src/plugins/index.ts | 2 + packages/g6/src/plugins/tooltip.ts | 252 ++++++++++++++++++ packages/g6/src/registry/build-in.ts | 3 +- 6 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 packages/g6/__tests__/demo/case/plugin-tooltip.ts create mode 100644 packages/g6/src/plugins/tooltip.ts diff --git a/packages/g6/__tests__/demo/case/index.ts b/packages/g6/__tests__/demo/case/index.ts index b4edf0cb217..73152964b47 100644 --- a/packages/g6/__tests__/demo/case/index.ts +++ b/packages/g6/__tests__/demo/case/index.ts @@ -43,5 +43,6 @@ export * from './layout-radial-prevent-overlap'; export * from './layout-radial-prevent-overlap-unstrict'; export * from './layout-radial-sort'; export * from './plugin-grid-line'; +export * from './plugin-tooltip'; export * from './theme'; export * from './viewport-fit'; diff --git a/packages/g6/__tests__/demo/case/plugin-tooltip.ts b/packages/g6/__tests__/demo/case/plugin-tooltip.ts new file mode 100644 index 00000000000..baa8e0c73c9 --- /dev/null +++ b/packages/g6/__tests__/demo/case/plugin-tooltip.ts @@ -0,0 +1,50 @@ +import { Graph } from '@/src'; +import data from '@@/dataset/cluster.json'; +import { isObject } from '@antv/util'; +import type { STDTestCase } from '../types'; + +export const pluginTooltip: STDTestCase = async (context) => { + const graph = new Graph({ + ...context, + autoResize: true, + data, + layout: { type: 'd3force' }, + behaviors: ['drag-canvas', 'drag-element'], + node: { + style: { + labelText: (d) => d.id, + }, + }, + plugins: [ + { + type: 'tooltip', + getContent: (evt: any, { items }: { items: any[] }) => { + return `
${items[0].id}
`; + }, + }, + ], + }); + + await graph.render(); + + pluginTooltip.form = (panel) => { + const config = { + trigger: 'pointerenter', + }; + return [ + panel + .add(config, 'trigger', ['pointerenter', 'click']) + .name('Change Trigger Method') + .onChange((trigger: string) => { + graph.setPlugins((plugins) => + plugins.map((plugin) => { + if (isObject(plugin) && plugin.type === 'tooltip') return { ...plugin, trigger }; + return plugin; + }), + ); + }), + ]; + }; + + return graph; +}; diff --git a/packages/g6/package.json b/packages/g6/package.json index 2abc130a748..f2af24c7e57 100644 --- a/packages/g6/package.json +++ b/packages/g6/package.json @@ -34,7 +34,6 @@ "build:esm:watch": "rimraf ./esm && tsc --module ESNext --outDir esm --watch -p tsconfig.build.json", "build:umd": "rimraf ./dist && rollup -c && npm run size", "bundle-vis": "cross-env BUNDLE_VIS=1 npm run build:umd", - "type-check": "tsc --noEmit", "ci": "run-s lint type-check build test", "coverage": "jest --coverage", "coverage:open": "open coverage/lcov-report/index.html", @@ -47,9 +46,11 @@ "start": "rimraf ./lib && tsc --module commonjs --outDir lib --watch", "test": "npm run jest __tests__", "test:integration": "npm run jest __tests__/integration", - "test:unit": "npm run jest __tests__/unit" + "test:unit": "npm run jest __tests__/unit", + "type-check": "tsc --noEmit" }, "dependencies": { + "@antv/component": "^1.0.2", "@antv/event-emitter": "latest", "@antv/g": "^5.18.25", "@antv/g-canvas": "^1.11.27", diff --git a/packages/g6/src/plugins/index.ts b/packages/g6/src/plugins/index.ts index c5093cc5f74..22254cf5b94 100644 --- a/packages/g6/src/plugins/index.ts +++ b/packages/g6/src/plugins/index.ts @@ -1,5 +1,7 @@ export { BasePlugin } from './base-plugin'; export { GridLine } from './grid-line'; +export { Tooltip } from './tooltip'; export type { BasePluginOptions } from './base-plugin'; export type { GridLineOptions } from './grid-line'; +export type { TooltipOptions } from './tooltip'; diff --git a/packages/g6/src/plugins/tooltip.ts b/packages/g6/src/plugins/tooltip.ts new file mode 100644 index 00000000000..52f83dee245 --- /dev/null +++ b/packages/g6/src/plugins/tooltip.ts @@ -0,0 +1,252 @@ +import type { TooltipStyleProps } from '@antv/component'; +import { Tooltip as TooltipComponent } from '@antv/component'; +import type { FederatedMouseEvent } from '@antv/g'; +import type { RuntimeContext } from '../runtime/types'; +import type { BehaviorEvent } from '../types'; +import type { BasePluginOptions } from './base-plugin'; +import { BasePlugin } from './base-plugin'; + +interface IG6GraphEvent extends BehaviorEvent { + targetType: 'node' | 'edge' | 'combo'; +} + +export type ContentModel = { + items?: { [key: string]: unknown }[]; +}; + +export interface TooltipOptions + extends BasePluginOptions, + Pick { + /** 触发方式 | Event type that triggers display of tooltip */ + trigger?: 'pointerenter' | 'click'; + /** 自定义内容 | Function for getting tooltip content */ + getContent?: (evt: IG6GraphEvent, item: ContentModel) => HTMLElement | string; + /** 触发类型 | Types of items for which tooltip is allowed to be displayed */ + itemTypes?: ('node' | 'edge' | 'combo')[]; +} + +export class Tooltip extends BasePlugin { + static defaultOptions: Partial = { + trigger: 'pointerenter', + position: 'top-right', + enterable: false, + itemTypes: ['node', 'edge', 'combo'], + style: { + '.tooltip': { + visibility: 'hidden', + }, + }, + container: { + x: 0, + y: 0, + }, + }; + private currentTarget: string | null = null; + private tooltipElement: TooltipComponent | null = null; + private $element: HTMLElement = document.createElement('div'); + + constructor(context: RuntimeContext, options: TooltipOptions) { + super(context, Object.assign({}, Tooltip.defaultOptions, options)); + this.render(); + this.bindEvents(); + } + + public getEvents(trigger: 'pointerenter' | 'click'): { [key: string]: Function } { + if (trigger === 'click') { + return { + 'node:click': this.onClick, + 'edge:click': this.onClick, + 'combo:click': this.onClick, + 'canvas:click': this.onPointerLeave, + afterremoveitem: this.onPointerLeave, + contextmenu: this.onPointerLeave, + drag: this.onPointerLeave, + }; + } + + return { + 'node:pointerenter': this.onPointerEnter, + 'node:pointermove': this.onPointerMove, + 'canvas:pointermove': this.onCanvasMove, + 'edge:pointerenter': this.onPointerEnter, + 'edge:pointermove': this.onPointerMove, + 'combo:pointerenter': this.onPointerEnter, + 'combo:pointermove': this.onPointerMove, + contextmenu: this.onPointerLeave, + 'node:drag': this.onPointerLeave, + }; + } + + public update(options: Partial) { + super.update(options); + this.unbundEvents(true); + if (this.tooltipElement) { + this.$element.removeChild(this.tooltipElement.HTMLTooltipElement); + } + this.tooltipElement = this.initTooltip(); + } + + private render() { + const { canvas } = this.context; + const $container = canvas.getContainer(); + if (!$container) return; + this.$element.className = 'g6-tooltip'; + $container.appendChild(this.$element); + this.tooltipElement = this.initTooltip(); + } + + private unbundEvents(isUpdate?: boolean) { + const { graph } = this.context; + const { trigger } = this.options; + /** The previous event binding needs to be removed when updating the trigger. */ + const events = this.getEvents(!isUpdate ? trigger : trigger === 'click' ? 'pointerenter' : 'click'); + Object.keys(events).forEach((eventName) => { + graph.off(eventName, events[eventName]); + }); + } + + private bindEvents() { + const { graph } = this.context; + const { trigger } = this.options; + const events = this.getEvents(trigger); + Object.keys(events).forEach((eventName) => { + graph.on(eventName, events[eventName]); + }); + } + + public onClick = (e: IG6GraphEvent) => { + const { + targetType, + target: { id }, + } = e; + if (this.options.itemTypes.indexOf(targetType) === -1) return; + // click the same item twice, tooltip will be hidden + if (this.currentTarget === id) { + this.currentTarget = null; + this.hideTooltip(e); + } else { + this.currentTarget = id; + this.showTooltip(e); + } + }; + + public onPointerMove = (e: IG6GraphEvent) => { + const { targetType, target } = e; + if (this.options.itemTypes.indexOf(targetType) === -1) return; + if (!this.currentTarget || target.id === this.currentTarget) { + return; + } + this.showTooltip(e); + }; + + public onPointerLeave = (e: IG6GraphEvent) => { + this.hideTooltip(e); + this.currentTarget = null; + }; + + public onCanvasMove = (e: IG6GraphEvent) => { + this.hideTooltip(e); + this.currentTarget = null; + }; + + private onPointerEnter = (e: IG6GraphEvent) => { + const { targetType } = e; + if (this.options.itemTypes.indexOf(targetType) === -1) return; + this.showTooltip(e); + }; + + public showTooltip(e: IG6GraphEvent) { + const { + targetType, + client: { x, y }, + target: { id, attributes }, + } = e; + if (!this.tooltipElement) return; + const { getContent } = this.options; + const { model } = this.context; + const { color, stroke } = attributes; + this.currentTarget = id; + let items: { [key: string]: unknown }[] = []; + switch (targetType) { + case 'node': + items = model.getNodeData([id]); + break; + case 'edge': + items = model.getEdgeData([id]); + break; + case 'combo': + items = model.getComboData([id]); + break; + default: + break; + } + let tooltipContent: { [key: string]: unknown } = {}; + if (getContent) { + tooltipContent.content = getContent(e, { items }); + } else { + tooltipContent = { + title: targetType, + data: items.map((item) => { + return { + name: 'ID', + value: item.id || `${item.source} -> ${item.target}`, + color: color || stroke, + }; + }), + }; + } + this.tooltipElement.update({ + x, + y, + style: { + '.tooltip': { + visibility: 'visible', + }, + }, + ...tooltipContent, + }); + } + + public hideTooltip(e: IG6GraphEvent) { + const { + client: { x, y }, + } = e; + if (!this.tooltipElement) return; + this.tooltipElement.hide(x, y); + } + + private initTooltip = () => { + const { style, position, enterable, container } = this.options; + const { canvas } = this.context; + const { center } = this.context.canvas.getBounds(); + const [x, y] = center; + const [width, height] = canvas.getSize(); + const tooltipElement = new TooltipComponent({ + className: 'tooltip', + style: { + x, + y, + container, + bounding: { + x: 0, + y: 0, + width, + height, + }, + position, + enterable, + title: '', + offset: [10, 10], + style, + }, + }); + this.$element.appendChild(tooltipElement.HTMLTooltipElement); + return tooltipElement; + }; + + public destroy(): void { + this.unbundEvents(); + this.$element.remove(); + super.destroy(); + } +} diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts index d12b62c95ec..ed5a2c7ecd1 100644 --- a/packages/g6/src/registry/build-in.ts +++ b/packages/g6/src/registry/build-in.ts @@ -36,7 +36,7 @@ import { mindmap, } from '../layouts'; import { blues, greens, oranges, spectral } from '../palettes'; -import { GridLine } from '../plugins'; +import { GridLine, Tooltip } from '../plugins'; import { dark, light } from '../themes'; import type { ExtensionRegistry } from './types'; @@ -107,5 +107,6 @@ export const BUILT_IN_EXTENSIONS: ExtensionRegistry = { }, plugin: { 'grid-line': GridLine, + tooltip: Tooltip, }, };