diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d36542e0d1..5395ca2ca46 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,13 +31,19 @@ jobs: run: | npm run ci + - name: Run Playwright tests + run: | + pnpm exec playwright install chromium + pnpm exec playwright test + - name: Upload blob report to GitHub Actions Artifacts if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: snapshots + name: report path: | packages/g6/__tests__/snapshots/**/*-actual.svg + playwright-report/ retention-days: 1 - name: Coveralls GitHub Action diff --git a/.gitignore b/.gitignore index abe4bc6e984..ab956c19dac 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ stats.html # Tools .turbo **/tmp/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/package.json b/package.json index e234b397e8d..61f07b7f80c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "turbo build --filter=!@antv/g6-site", "ci": "turbo run ci --filter=!@antv/g6-site", "contribute": "node ./scripts/contribute.mjs", + "dev:g6": "cd ./packages/g6 && npm run dev", "postinstall": "husky install", "prepare": "husky install", "publish": "pnpm publish -r --publish-branch v5", @@ -30,6 +31,7 @@ "@changesets/cli": "^2.27.7", "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.3", + "@playwright/test": "^1.45.3", "@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", diff --git a/packages/g6/__tests__/demos/index.ts b/packages/g6/__tests__/demos/index.ts index 6a390c24ce2..8433f1dbfe9 100644 --- a/packages/g6/__tests__/demos/index.ts +++ b/packages/g6/__tests__/demos/index.ts @@ -112,6 +112,7 @@ export { pluginGridLine } from './plugin-grid-line'; export { pluginHistory } from './plugin-history'; export { pluginHull } from './plugin-hull'; export { pluginLegend } from './plugin-legend'; +export { pluginMinimap } from './plugin-minimap'; 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-minimap.ts b/packages/g6/__tests__/demos/plugin-minimap.ts new file mode 100644 index 00000000000..5e603d95a0b --- /dev/null +++ b/packages/g6/__tests__/demos/plugin-minimap.ts @@ -0,0 +1,27 @@ +import { Graph } from '@/src'; +import { Renderer } from '@antv/g-svg'; + +export const pluginMinimap: TestCase = async (context) => { + const graph = new Graph({ + ...context, + data: { nodes: Array.from({ length: 20 }).map((_, i) => ({ id: `node${i}` })) }, + behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'], + plugins: [ + { + key: 'minimap', + type: 'minimap', + size: [240, 160], + renderer: new Renderer(), + }, + ], + node: { + palette: 'spectral', + }, + layout: { type: 'circular' }, + autoFit: 'view', + }); + + await graph.render(); + + return graph; +}; diff --git a/packages/g6/__tests__/unit/runtime/graph/event.spec.ts b/packages/g6/__tests__/unit/runtime/graph/event.spec.ts index 91321bbb5f8..afc1187fe15 100644 --- a/packages/g6/__tests__/unit/runtime/graph/event.spec.ts +++ b/packages/g6/__tests__/unit/runtime/graph/event.spec.ts @@ -183,4 +183,42 @@ describe('event', () => { expect(beforeRendererChange).toHaveBeenCalledTimes(1); expect(afterRendererChange).toHaveBeenCalledTimes(1); }); + + it('draw event', async () => { + const graph = createGraph({ + data: { + nodes: [{ id: 'node-1' }, { id: 'node-2' }], + edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], + }, + }); + + const beforeDraw = jest.fn(); + const afterDraw = jest.fn(); + + graph.on(GraphEvent.BEFORE_DRAW, (event: any) => { + beforeDraw(event.data.render); + }); + graph.on(GraphEvent.AFTER_DRAW, (event: any) => { + afterDraw(event.data.render); + }); + + await graph.render(); + + expect(beforeDraw).toHaveBeenCalledTimes(1); + expect(beforeDraw.mock.calls[0][0]).toBe(true); + expect(afterDraw).toHaveBeenCalledTimes(1); + expect(afterDraw.mock.calls[0][0]).toBe(true); + + beforeDraw.mockClear(); + afterDraw.mockClear(); + + graph.addNodeData([{ id: 'node-3' }]); + + await graph.draw(); + + expect(beforeDraw).toHaveBeenCalledTimes(1); + expect(beforeDraw.mock.calls[0][0]).toBe(false); + expect(afterDraw).toHaveBeenCalledTimes(1); + expect(afterDraw.mock.calls[0][0]).toBe(false); + }); }); diff --git a/packages/g6/jest.config.js b/packages/g6/jest.config.js index b283adbbce2..4ecf9952329 100644 --- a/packages/g6/jest.config.js +++ b/packages/g6/jest.config.js @@ -21,7 +21,7 @@ module.exports = { '^.+\\.svg$': ['/__tests__/utils/svg-transformer.js'], }, collectCoverageFrom: ['src/**/*.ts'], - coveragePathIgnorePatterns: ['/src/elements/nodes/html.ts'], + coveragePathIgnorePatterns: ['/src/elements/nodes/html.ts', '/src/plugins/minimap'], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], collectCoverage: true, testRegex: '(/__tests__/.*\\.(test|spec))\\.(ts|tsx|js)$', diff --git a/packages/g6/src/exports.ts b/packages/g6/src/exports.ts index d75c7405946..c73077ce131 100644 --- a/packages/g6/src/exports.ts +++ b/packages/g6/src/exports.ts @@ -72,6 +72,7 @@ export { History, Hull, Legend, + Minimap, Timebar, Toolbar, Tooltip, @@ -168,6 +169,7 @@ export type { HistoryOptions, HullOptions, LegendOptions, + MinimapOptions, TimebarOptions, ToolbarOptions, TooltipOptions, diff --git a/packages/g6/src/plugins/index.ts b/packages/g6/src/plugins/index.ts index d76e5fe39d4..7b90388106f 100644 --- a/packages/g6/src/plugins/index.ts +++ b/packages/g6/src/plugins/index.ts @@ -8,6 +8,7 @@ export { GridLine } from './grid-line'; export { History } from './history'; export { Hull } from './hull'; export { Legend } from './legend'; +export { Minimap } from './minimap'; export { Timebar } from './timebar'; export { Toolbar } from './toolbar'; export { Tooltip } from './tooltip'; @@ -23,6 +24,7 @@ export type { GridLineOptions } from './grid-line'; export type { HistoryOptions } from './history'; export type { HullOptions } from './hull'; export type { LegendOptions } from './legend'; +export type { MinimapOptions } from './minimap'; export type { TimebarOptions } from './timebar'; export type { ToolbarOptions } from './toolbar'; export type { TooltipOptions } from './tooltip'; diff --git a/packages/g6/src/plugins/minimap/index.ts b/packages/g6/src/plugins/minimap/index.ts new file mode 100644 index 00000000000..6cbd7af6d66 --- /dev/null +++ b/packages/g6/src/plugins/minimap/index.ts @@ -0,0 +1,512 @@ +import { Canvas, DisplayObject, IRenderer } from '@antv/g'; +import { Renderer } from '@antv/g-canvas'; +import { throttle } from '@antv/util'; +import { GraphEvent } from '../../constants'; +import type { RuntimeContext } from '../../runtime/types'; +import { GraphData } from '../../spec'; +import type { ElementDatum, ElementType, ID, IGraphLifeCycleEvent, Padding, Placement } from '../../types'; +import { idOf } from '../../utils/id'; +import { parsePadding } from '../../utils/padding'; +import { parsePlacement } from '../../utils/placement'; +import { toPointObject } from '../../utils/point'; +import type { BasePluginOptions } from '../base-plugin'; +import { BasePlugin } from '../base-plugin'; + +/** + * 缩略图插件的配置项 + * + * The configuration item of the Minimap plugin + */ +export interface MinimapOptions extends BasePluginOptions { + /** + * 宽度和高度 + * + * Width and height + */ + size?: [number, number]; + /** + * 内边距 + * + * Padding + * @defaultValue 10 + */ + padding?: Padding; + /** + * 缩略图相对于画布的位置 + * + * The position of the minimap relative to the canvas + * @defaultValue 'right-bottom' + */ + position?: Placement; + /** + * 过滤器,用于过滤不必显示的元素 + * + * Filter, used to filter elements that do not need to be displayed + * @param id - 元素的 id | The id of the element + * @param elementType - 元素的类型 | The type of the element + * @returns 是否显示 | Whether to display + */ + filter?: (id: string, elementType: ElementType) => boolean; + /** + * 元素缩略图形的生成方法 + * + * The method of generating the thumbnail of the element + * @defaultValue 'key' + * @remarks + * + * - 'key' 使用元素的主图形作为缩略图形 + * - 也可以传入一个函数,接收元素的 id 和类型,返回一个图形 + * + * + * - 'key' uses the key shape of the element as the thumbnail graphic + * - You can also pass in a function that receives the id and type of the element and returns a shape + */ + shape?: 'key' | ((id: string, elementType: ElementType) => DisplayObject); + /** + * 缩略图画布类名 + * + * The class name of the minimap canvas + */ + className?: string; + /** + * 缩略图挂载的容器,无则挂载到 Graph 所在容器 + * + * The container where the minimap is mounted, if not, it will be mounted to the container where the Graph is located + */ + container?: HTMLElement | string; + /** + * 缩略图的容器样式,传入外置容器时不生效 + * + * The style of the minimap container, which does not take effect when an external container is passed in + */ + containerStyle?: Partial; + /** + * 遮罩的样式 + * + * The style of the mask + */ + maskStyle?: Partial; + /** + * 渲染器,默认使用 Canvas 渲染器 + * + * Renderer, default to use Canvas renderer + */ + renderer?: IRenderer; +} + +export class Minimap extends BasePlugin { + static defaultOptions: Partial = { + size: [240, 160], + shape: 'key', + padding: 10, + position: 'right-bottom', + maskStyle: { + border: '1px solid #ddd', + background: 'rgba(0, 0, 0, 0.1)', + }, + containerStyle: { + border: '1px solid #ddd', + background: '#fff', + }, + }; + + private canvas!: Canvas; + + constructor(context: RuntimeContext, options: MinimapOptions) { + super(context, Object.assign({}, Minimap.defaultOptions, options)); + this.bindEvents(); + } + + private bindEvents() { + const { graph } = this.context; + graph.on(GraphEvent.AFTER_DRAW, this.onDraw); + graph.on(GraphEvent.AFTER_RENDER, this.onRender); + graph.on(GraphEvent.AFTER_TRANSFORM, this.onTransform); + } + + private unbindEvents() { + const { graph } = this.context; + graph.off(GraphEvent.AFTER_DRAW, this.onDraw); + graph.off(GraphEvent.AFTER_RENDER, this.onRender); + graph.off(GraphEvent.AFTER_TRANSFORM, this.onTransform); + } + + private onDraw = (event: IGraphLifeCycleEvent) => { + if (event?.data?.render) return; + this.onRender(); + }; + + private onRender = throttle( + () => { + this.renderMinimap(); + this.renderMask(); + }, + 32, + { leading: true }, + ) as () => void; + + private shapes = new Map(); + + /** + * 创建或更新缩略图 + * + * Create or update the minimap + */ + private renderMinimap() { + const data = this.getElements(); + const canvas = this.initCanvas(); + this.setShapes(canvas, data); + } + + private getElements(): Required { + const { filter } = this.options; + const { model } = this.context; + const data = model.getData(); + + if (!filter) return data; + + const { nodes, edges, combos } = data; + + return { + nodes: nodes.filter((node) => filter(idOf(node), 'node')), + edges: edges.filter((edge) => filter(idOf(edge), 'edge')), + combos: combos.filter((combo) => filter(idOf(combo), 'combo')), + }; + } + + private setShapes(canvas: Canvas, data: Required) { + const { nodes, edges, combos } = data; + + const { shape } = this.options; + const { element } = this.context; + + if (shape === 'key') { + const ids = new Set(); + + const iterate = (datum: ElementDatum) => { + const id = idOf(datum); + ids.add(id); + + const target = element!.getElement(id)!; + const shape = target.getShape('key'); + const cloneShape = this.shapes.get(id) || shape.cloneNode(); + + cloneShape.setPosition(shape.getPosition()); + // keep zIndex / id + if (target.style.zIndex) cloneShape.style.zIndex = target.style.zIndex; + cloneShape.id = target.id; + + if (!this.shapes.has(id)) { + canvas.appendChild(cloneShape); + this.shapes.set(id, cloneShape); + } else this.shapes.get(id)!.attr(shape.attributes); + }; + + // 注意执行顺序 / Note the execution order + edges.forEach(iterate); + combos.forEach(iterate); + nodes.forEach(iterate); + + this.shapes.forEach((shape, id) => { + if (!ids.has(id)) { + canvas.removeChild(shape); + this.shapes.delete(id); + } + }); + + return; + } + + const setPosition = (id: ID, shape: DisplayObject) => { + const target = element!.getElement(id)!; + const position = target.getPosition(); + shape.setPosition(position); + return shape; + }; + + canvas.removeChildren(); + + edges.forEach((datum) => canvas.appendChild(shape(idOf(datum), 'edge'))); + combos.forEach((datum) => { + canvas.appendChild(setPosition(idOf(datum), shape(idOf(datum), 'combo'))); + }); + nodes.forEach((datum) => { + canvas.appendChild(setPosition(idOf(datum), shape(idOf(datum), 'node'))); + }); + } + + private container!: HTMLElement; + + private calculatePosition(): [number, number] { + const { + position, + size: [w, h], + } = this.options; + + const { canvas } = this.context; + const [width, height] = canvas.getSize(); + const [x, y] = parsePlacement(position); + return [x * (width - w), y * (height - h)]; + } + + private createContainer(): HTMLElement { + const { + container, + className, + size: [width, height], + containerStyle, + } = this.options; + if (container) { + return typeof container === 'string' ? document.querySelector(container)! : container; + } + + const $container = document.createElement('div'); + $container.classList.add('g6-minimap'); + if (className) $container.classList.add(className); + + const [x, y] = this.calculatePosition(); + Object.assign($container.style, { + position: 'absolute', + left: x + 'px', + top: y + 'px', + width: width + 'px', + height: height + 'px', + ...containerStyle, + }); + + return this.context.canvas.getContainer()!.appendChild($container); + } + + private initCanvas() { + const { + renderer, + size: [width, height], + } = this.options; + + if (this.canvas) { + this.canvas.resize(width, height); + if (renderer) this.canvas.setRenderer(renderer); + } else { + const dom = document.createElement('div'); + + const container = this.createContainer(); + this.container = container; + container.appendChild(dom); + + this.canvas = new Canvas({ + width, + height, + container: dom, + renderer: renderer || new Renderer(), + }); + } + + this.setCamera(); + + return this.canvas; + } + + private setCamera() { + const { canvas } = this.context; + + const camera = this.canvas?.getCamera(); + if (!camera) return; + + const { + size: [minimapWidth, minimapHeight], + padding, + } = this.options; + const [top, right, bottom, left] = parsePadding(padding); + const { min: boundsMin, max: boundsMax, center } = canvas.getBounds(); + const boundsWidth = boundsMax[0] - boundsMin[0]; + const boundsHeight = boundsMax[1] - boundsMin[1]; + + const availableWidth = minimapWidth - left - right; + const availableHeight = minimapHeight - top - bottom; + + const scaleX = availableWidth / boundsWidth; + const scaleY = availableHeight / boundsHeight; + const scale = Math.min(scaleX, scaleY); + + camera.setPosition(center); + camera.setFocalPoint(center); + camera.setZoom(scale); + } + + private mask: HTMLElement | null = null; + + private get maskBBox(): [number, number, number, number] { + const { canvas: graphCanvas } = this.context; + const canvasSize = graphCanvas.getSize(); + const canvasMin = graphCanvas.getCanvasByViewport([0, 0]); + const canvasMax = graphCanvas.getCanvasByViewport(canvasSize); + + const maskMin = this.canvas.canvas2Viewport(toPointObject(canvasMin)); + const maskMax = this.canvas.canvas2Viewport(toPointObject(canvasMax)); + + const width = maskMax.x - maskMin.x; + const height = maskMax.y - maskMin.y; + + const zoom = this.context.canvas.getCamera().getZoom(); + + // magic number + const ratio = zoom * 0.5; + const x = maskMin.x - width * ratio; + const y = maskMin.y - height * ratio; + + return [x, y, width, height]; + } + + /** + * 计算遮罩包围盒 + * + * Calculate the bounding box of the mask + * @returns 遮罩包围盒 | Mask bounding box + */ + private calculateMaskBBox(): [number, number, number, number] { + const { + size: [minimapWidth, minimapHeight], + } = this.options; + + let [x, y, width, height] = this.maskBBox; + + // clamp x, y, width, height + if (x < 0) (width = upper(width + x, minimapWidth)), (x = 0); + if (y < 0) (height = upper(height + y, minimapHeight)), (y = 0); + if (x + width > minimapWidth) width = lower(minimapWidth - x, 0); + if (y + height > minimapHeight) height = lower(minimapHeight - y, 0); + + return [upper(x, minimapWidth), upper(y, minimapHeight), lower(width, 0), lower(height, 0)]; + } + + /** + * 创建或更新遮罩 + * + * Create or update the mask + */ + private renderMask() { + const { maskStyle } = this.options; + + if (!this.mask) { + this.mask = document.createElement('div'); + this.mask.addEventListener('pointerdown', this.onMaskDragStart); + } + + this.container.appendChild(this.mask); + + Object.assign(this.mask.style, { + ...maskStyle, + cursor: 'move', + position: 'absolute', + pointerEvents: 'auto', + }); + + this.updateMask(); + } + + private isMaskDragging = false; + + private onMaskDragStart = (event: PointerEvent) => { + if (!this.mask) return; + this.isMaskDragging = true; + this.mask.setPointerCapture(event.pointerId); + this.mask.addEventListener('pointermove', this.onMaskDrag); + this.mask.addEventListener('pointerup', this.onMaskDragEnd); + this.mask.addEventListener('pointercancel', this.onMaskDragEnd); + }; + + private onMaskDrag = (event: PointerEvent) => { + if (!this.mask || !this.isMaskDragging) return; + const { + size: [minimapWidth, minimapHeight], + } = this.options; + const { movementX, movementY } = event; + + const { left, top, width: w, height: h } = this.mask.style; + const [, , fullWidth, fullHeight] = this.maskBBox; + + let x = parseInt(left) + movementX; + let y = parseInt(top) + movementY; + let width = parseInt(w); + let height = parseInt(h); + + // 确保 mask 在 minimap 内部 + // Ensure that the mask is inside the minimap + if (x < 0) x = 0; + if (y < 0) y = 0; + if (x + width > minimapWidth) x = lower(minimapWidth - width, 0); + if (y + height > minimapHeight) y = lower(minimapHeight - height, 0); + + // 当拖拽画布导致 mask 缩小时,拖拽 mask 时,能够恢复到实际大小 + // When dragging the canvas causes the mask to shrink, dragging the mask will restore it to its actual size + if (width < fullWidth) { + if (movementX > 0) (x = lower(x - movementX, 0)), (width = upper(width + movementX, minimapWidth)); + else if (movementX < 0) width = upper(width - movementX, minimapWidth); + } + if (height < fullHeight) { + if (movementY > 0) (y = lower(y - movementY, 0)), (height = upper(height + movementY, minimapHeight)); + else if (movementY < 0) height = upper(height - movementY, minimapHeight); + } + + Object.assign(this.mask.style, { + left: x + 'px', + top: y + 'px', + width: width + 'px', + height: height + 'px', + }); + + // 基于 movement 进行相对移动 + // Move relative to movement + const deltaX = parseInt(left) - x; + const deltaY = parseInt(top) - y; + if (deltaX === 0 && deltaY === 0) return; + + const zoom1 = this.context.canvas.getCamera().getZoom(); + const zoom2 = this.canvas.getCamera().getZoom(); + const ratio = zoom1 / zoom2; + + this.context.graph.translateBy([deltaX * ratio, deltaY * ratio], false); + }; + + private onMaskDragEnd = (event: PointerEvent) => { + if (!this.mask) return; + this.isMaskDragging = false; + this.mask.releasePointerCapture(event.pointerId); + this.mask.removeEventListener('pointermove', this.onMaskDrag); + this.mask.removeEventListener('pointerup', this.onMaskDragEnd); + this.mask.removeEventListener('pointercancel', this.onMaskDragEnd); + }; + + private onTransform = throttle( + () => { + if (this.isMaskDragging) return; + this.updateMask(); + this.setCamera(); + }, + 32, + { leading: true }, + ) as () => void; + + private updateMask() { + if (!this.mask) return; + const [x, y, width, height] = this.calculateMaskBBox(); + + Object.assign(this.mask.style, { + top: y + 'px', + left: x + 'px', + width: width + 'px', + height: height + 'px', + }); + } + + public destroy(): void { + this.unbindEvents(); + this.canvas.destroy(); + this.mask?.remove(); + super.destroy(); + } +} + +const upper = (value: number, max: number) => Math.min(value, max); + +const lower = (value: number, min: number) => Math.max(value, min); diff --git a/packages/g6/src/registry/build-in.ts b/packages/g6/src/registry/build-in.ts index db05397f325..ba9f9fe8a16 100644 --- a/packages/g6/src/registry/build-in.ts +++ b/packages/g6/src/registry/build-in.ts @@ -64,6 +64,7 @@ import { History, Hull, Legend, + Minimap, Timebar, Toolbar, Tooltip, @@ -178,6 +179,7 @@ export const BUILT_IN_EXTENSIONS: ExtensionRegistry = { toolbar: Toolbar, tooltip: Tooltip, watermark: Watermark, + minimap: Minimap, }, transform: { 'update-related-edges': UpdateRelatedEdge, diff --git a/packages/g6/src/runtime/element.ts b/packages/g6/src/runtime/element.ts index 7dbc06e1d0a..850d07fac34 100644 --- a/packages/g6/src/runtime/element.ts +++ b/packages/g6/src/runtime/element.ts @@ -285,18 +285,28 @@ export class ElementController { const { animation, silence } = context; + const { type = 'draw' } = context; + const willRender = type === 'render'; + return this.context.animation!.animate( animation, silence ? {} : { before: () => - this.emit(new GraphLifeCycleEvent(GraphEvent.BEFORE_DRAW, { dataChanges, animation }), context), + this.emit( + new GraphLifeCycleEvent(GraphEvent.BEFORE_DRAW, { dataChanges, animation, render: willRender }), + context, + ), beforeAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.DRAW, animation, drawData), context), afterAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.DRAW, animation, drawData), context), - after: () => this.emit(new GraphLifeCycleEvent(GraphEvent.AFTER_DRAW, { dataChanges, animation }), context), + after: () => + this.emit( + new GraphLifeCycleEvent(GraphEvent.AFTER_DRAW, { dataChanges, animation, render: willRender }), + context, + ), }, ); } @@ -793,11 +803,13 @@ export class ElementController { export interface DrawContext { /** 是否使用动画,默认为 true | Whether to use animation, default is true */ - animation: boolean; + animation?: boolean; /** 当前绘制阶段 | Current draw stage */ stage?: AnimationStage; /** 是否不抛出事件 | Whether not to dispatch events */ silence?: boolean; /** 收起/展开的对象 ID | ID of the object to collapse/expand */ collapseExpandTarget?: ID; + /** 绘制类型 | Draw type */ + type?: 'render' | 'draw'; } diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts index 884174f2692..5da05e3040f 100644 --- a/packages/g6/src/runtime/graph.ts +++ b/packages/g6/src/runtime/graph.ts @@ -1078,7 +1078,7 @@ export class Graph extends EventEmitter { public async render(): Promise { await this.prepare(); emit(this, new GraphLifeCycleEvent(GraphEvent.BEFORE_RENDER)); - const animation = this.context.element!.draw(); + const animation = this.context.element!.draw({ type: 'render' }); await Promise.all([animation?.finished, this.context.layout!.layout()]); await this.autoFit(); emit(this, new GraphLifeCycleEvent(GraphEvent.AFTER_RENDER)); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000000..c1ee7dd59e3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 5 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:8080', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev:g6', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/g6/plugins/plugins-minimap.spec.ts b/tests/g6/plugins/plugins-minimap.spec.ts new file mode 100644 index 00000000000..7825f6e49ff --- /dev/null +++ b/tests/g6/plugins/plugins-minimap.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; + +test.describe('plugin minimap', () => { + test('default', async ({ page }) => { + page.goto('/?Demo=pluginMinimap&Renderer=canvas&GridLine=true&Theme=light&Animation=false'); + + await page.waitForSelector('.g6-minimap'); + + const clip = { x: 0, y: 0, width: 500, height: 500 }; + + await expect(page).toHaveScreenshot({ clip }); + + // wheel on to zoom + await page.mouse.move(250, 250); + + // 在 github action 中执行时,滚动的距离需要更大 + // In github action, the scroll distance needs to be larger + await page.mouse.wheel(0, process.env.CI ? 20 : 10); + + await expect(page).toHaveScreenshot({ clip, maxDiffPixels: 100 }); + + await page.mouse.wheel(0, process.env.CI ? -40 : -20); + + await expect(page).toHaveScreenshot({ clip, maxDiffPixels: 100 }); + + // drag minimap mask + await page.mouse.move(400, 425); + await page.mouse.down(); + await page.mouse.move(425, 450, { steps: 10 }); + await page.mouse.up(); + await expect(page).toHaveScreenshot({ clip, maxDiffPixels: 100 }); + + // drag mask overflow + await page.mouse.move(425, 450); + await page.mouse.down(); + await page.mouse.move(550, 550, { steps: 10 }); + await page.mouse.up(); + await expect(page).toHaveScreenshot({ clip, maxDiffPixels: 100 }); + + // drag canvas + // playwright mouse 模拟操作无法正常触发 g canvas 的 drag 事件 + // 因此这里直接调用 graph 实例的方法 + // playwright mouse simulation operation cannot trigger the drag event of g canvas normally + // So here directly call the method of the graph instance + await page.evaluate(() => (window as any).graph.translateTo([100, 100])); + + await expect(page).toHaveScreenshot({ clip, maxDiffPixels: 100 }); + }); +}); diff --git a/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-1-chromium-darwin.png b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-1-chromium-darwin.png new file mode 100644 index 00000000000..6271b79d8df Binary files /dev/null and b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-1-chromium-darwin.png differ diff --git a/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-2-chromium-darwin.png b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-2-chromium-darwin.png new file mode 100644 index 00000000000..77b5441aaec Binary files /dev/null and b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-2-chromium-darwin.png differ diff --git a/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-3-chromium-darwin.png b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-3-chromium-darwin.png new file mode 100644 index 00000000000..5f43d0f4112 Binary files /dev/null and b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-3-chromium-darwin.png differ diff --git a/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-4-chromium-darwin.png b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-4-chromium-darwin.png new file mode 100644 index 00000000000..908a3ab62a1 Binary files /dev/null and b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-4-chromium-darwin.png differ diff --git a/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-5-chromium-darwin.png b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-5-chromium-darwin.png new file mode 100644 index 00000000000..c792d816fbe Binary files /dev/null and b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-5-chromium-darwin.png differ diff --git a/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-6-chromium-darwin.png b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-6-chromium-darwin.png new file mode 100644 index 00000000000..1096bdf05ce Binary files /dev/null and b/tests/g6/plugins/plugins-minimap.spec.ts-snapshots/plugin-minimap-default-6-chromium-darwin.png differ