diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5395ca2ca46..04c142eb9df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,13 @@ jobs: with: node-version: 18 + - name: Install Dependencies + run: | + brew update + brew install python3 || : # python doesn't need to be linked + brew install pkg-config cairo pango libpng jpeg giflib librsvg + pip install setuptools + - uses: pnpm/action-setup@v4 name: Install pnpm with: diff --git a/package.json b/package.json index 778405918c8..1c5e92c063a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@commitlint/config-conventional": "^18.6.3", "@playwright/test": "^1.48.2", "@rollup/plugin-commonjs": "^25.0.8", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", diff --git a/packages/g6-ssr/README.md b/packages/g6-ssr/README.md new file mode 100644 index 00000000000..c8b4e7687c9 --- /dev/null +++ b/packages/g6-ssr/README.md @@ -0,0 +1,63 @@ +## SSR extension for G6 5.0 + +This extension package provides SSR support for G6 5.0, which supports canvas rendering in server side. + +## Usage + +### Install + +```bash +npm install @antv/g6-ssr +``` + +### Render in JavaScript API + +> For complete options, please refer to [G6 Graph Options](https://g6.antv.antgroup.com/api/graph/option) + +```js +import { createGraph } from '@antv/g6-ssr'; + +const graph = await createGraph({ + width: 500, + height: 500, + data: { + // data + }, + // other options +}); + +graph.exportToFile('image'); +// -> image.png +``` + +### Render in CLI + +```bash +npx g6-ssr export -i [graph-options].json -o ./image +``` + +### Export SVG / PDF + +When render in JavaScript API, you can pass `outputType` option to export SVG or PDF. + +```js +const graph = await createGraph({ + width: 500, + height: 500, + data: { + // data + }, + outputType: 'svg', // or 'pdf' + // other options +}); +``` + +When render in CLI, you can pass `-t` or `--type` option to export SVG or PDF. + +```bash +npx g6-ssr export -i [graph-options].json -o ./file -t pdf +``` + +## License + +MIT diff --git a/packages/g6-ssr/__tests__/assets/file.pdf b/packages/g6-ssr/__tests__/assets/file.pdf new file mode 100644 index 00000000000..64d3c2f1b19 Binary files /dev/null and b/packages/g6-ssr/__tests__/assets/file.pdf differ diff --git a/packages/g6-ssr/__tests__/assets/file.svg b/packages/g6-ssr/__tests__/assets/file.svg new file mode 100644 index 00000000000..67972aa3d4d --- /dev/null +++ b/packages/g6-ssr/__tests__/assets/file.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/g6-ssr/__tests__/assets/image.png b/packages/g6-ssr/__tests__/assets/image.png new file mode 100644 index 00000000000..797b51982a8 Binary files /dev/null and b/packages/g6-ssr/__tests__/assets/image.png differ diff --git a/packages/g6-ssr/__tests__/graph-options.json b/packages/g6-ssr/__tests__/graph-options.json new file mode 100644 index 00000000000..b747588dc0d --- /dev/null +++ b/packages/g6-ssr/__tests__/graph-options.json @@ -0,0 +1,113 @@ +{ + "width": 500, + "height": 500, + "autoFit": "view", + "background": "rgba(100, 80, 180, 0.4)", + "data": { + "nodes": [ + { "id": "0" }, + { "id": "1" }, + { "id": "2" }, + { "id": "3" }, + { "id": "4" }, + { "id": "5" }, + { "id": "6" }, + { "id": "7" }, + { "id": "8" }, + { "id": "9" }, + { "id": "10" }, + { "id": "11" }, + { "id": "12" }, + { "id": "13" }, + { "id": "14" }, + { "id": "15" }, + { "id": "16" }, + { "id": "17" }, + { "id": "18" }, + { "id": "19" }, + { "id": "20" }, + { "id": "21" }, + { "id": "22" }, + { "id": "23" }, + { "id": "24" }, + { "id": "25" }, + { "id": "26" }, + { "id": "27" }, + { "id": "28" }, + { "id": "29" }, + { "id": "30" }, + { "id": "31" }, + { "id": "32" }, + { "id": "33" } + ], + "edges": [ + { "source": "0", "target": "1" }, + { "source": "0", "target": "2" }, + { "source": "0", "target": "3" }, + { "source": "0", "target": "4" }, + { "source": "0", "target": "5" }, + { "source": "0", "target": "7" }, + { "source": "0", "target": "8" }, + { "source": "0", "target": "9" }, + { "source": "0", "target": "10" }, + { "source": "0", "target": "11" }, + { "source": "0", "target": "13" }, + { "source": "0", "target": "14" }, + { "source": "0", "target": "15" }, + { "source": "0", "target": "16" }, + { "source": "2", "target": "3" }, + { "source": "4", "target": "5" }, + { "source": "4", "target": "6" }, + { "source": "5", "target": "6" }, + { "source": "7", "target": "13" }, + { "source": "8", "target": "14" }, + { "source": "9", "target": "10" }, + { "source": "10", "target": "22" }, + { "source": "10", "target": "14" }, + { "source": "10", "target": "12" }, + { "source": "10", "target": "24" }, + { "source": "10", "target": "21" }, + { "source": "10", "target": "20" }, + { "source": "11", "target": "24" }, + { "source": "11", "target": "22" }, + { "source": "11", "target": "14" }, + { "source": "12", "target": "13" }, + { "source": "16", "target": "17" }, + { "source": "16", "target": "18" }, + { "source": "16", "target": "21" }, + { "source": "16", "target": "22" }, + { "source": "17", "target": "18" }, + { "source": "17", "target": "20" }, + { "source": "18", "target": "19" }, + { "source": "19", "target": "20" }, + { "source": "19", "target": "33" }, + { "source": "19", "target": "22" }, + { "source": "19", "target": "23" }, + { "source": "20", "target": "21" }, + { "source": "21", "target": "22" }, + { "source": "22", "target": "24" }, + { "source": "22", "target": "25" }, + { "source": "22", "target": "26" }, + { "source": "22", "target": "23" }, + { "source": "22", "target": "28" }, + { "source": "22", "target": "30" }, + { "source": "22", "target": "31" }, + { "source": "22", "target": "32" }, + { "source": "22", "target": "33" }, + { "source": "23", "target": "28" }, + { "source": "23", "target": "27" }, + { "source": "23", "target": "29" }, + { "source": "23", "target": "30" }, + { "source": "23", "target": "31" }, + { "source": "23", "target": "33" }, + { "source": "32", "target": "33" } + ] + }, + "node": { + "style": { + "labelFill": "#fff", + "labelPlacement": "center" + } + }, + "layout": { "type": "circular" } +} diff --git a/packages/g6-ssr/__tests__/test.spec.ts b/packages/g6-ssr/__tests__/test.spec.ts new file mode 100644 index 00000000000..ae56bf7349d --- /dev/null +++ b/packages/g6-ssr/__tests__/test.spec.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import type { Graph } from '../src'; +import { createGraph } from '../src'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toMatchFile(path: string): R; + } + } +} + +describe('createGraph', () => { + const fn = async (outputType?: any) => { + const data = (await fetch('https://assets.antv.antgroup.com/g6/circular.json').then((res) => res.json())) as any; + + return await createGraph({ + width: 500, + height: 500, + outputType, + autoFit: 'view', + background: 'rgba(100, 80, 180, 0.4)', + data, + node: { + style: { + labelText: (d) => d.id, + labelFill: '#fff', + labelPlacement: 'center', + }, + }, + layout: { + type: 'circular', + }, + }); + }; + + expect.extend({ + toMatchFile: (received: Graph, path: string) => { + const pass = existsSync(path) ? received.toBuffer().equals(readFileSync(path)) : true; + if (pass) { + return { + message: () => 'passed', + pass: true, + }; + } else { + return { + message: () => 'expected files are equal', + pass: false, + }; + } + }, + }); + + it('image image', async () => { + const graph = await fn(); + + expect(graph).toMatchFile('./assets/image.png'); + + graph.exportToFile(join(__dirname, './assets/image')); + + graph.destroy(); + }); + + it('file pdf', async () => { + const graph = await fn('pdf'); + + graph.exportToFile(join(__dirname, '/assets/file')); + + graph.destroy(); + }); + + it('file svg', async () => { + const graph = await fn('svg'); + + expect(graph).toMatchFile('./assets/file.svg'); + + graph.exportToFile(join(__dirname, './assets/file')); + + graph.destroy(); + }); +}); diff --git a/packages/g6-ssr/bin/g6-ssr.js b/packages/g6-ssr/bin/g6-ssr.js new file mode 100755 index 00000000000..3b0e023f8bc --- /dev/null +++ b/packages/g6-ssr/bin/g6-ssr.js @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable no-console */ +const cac = require('cac'); +const fs = require('fs'); +const { createGraph } = require('../dist/g6-ssr.cjs'); +const { version } = require('../package.json'); + +const cli = cac(); + +cli.version(version); + +cli.command('version', 'Show version').action(() => { + console.log(version); +}); + +cli + .command('export', 'Export G6 Options to Image, PDF or SVG') + .option('-i, --input ', 'Path to the G6 options file') + .option('-o, --output ', 'Path to the export file') + .option('-t, --type [type]', 'File type, default is image') + .action(async (options) => { + const { input, output, type } = options; + + if (!input) { + console.log('\x1b[31m%s\x1b[0m', 'Please provide a path to the G6 options file'); + process.exit(1); + } + + if (!fs.existsSync(input)) { + console.log('\x1b[31m%s\x1b[0m', 'File does not exist: ', input); + process.exit(1); + } + + let graphOptions; + + try { + graphOptions = JSON.parse(fs.readFileSync(input, 'utf-8')); + } catch (e) { + console.log('\x1b[31m%s\x1b[0m', 'Invalid JSON file'); + process.exit(1); + } + + if (!graphOptions.outputType) { + if (type === 'svg' || type === 'pdf') { + graphOptions.outputType = type; + } + } + + console.log(`Exporting to ${type || 'image'}...`); + + const graph = await createGraph(graphOptions); + + graph.exportToFile(output, type); + + console.log('\x1b[32m%s\x1b[0m', 'Exported successfully!'); + + process.exit(0); + }); + +cli.help(); + +cli.parse(); diff --git a/packages/g6-ssr/jest.config.js b/packages/g6-ssr/jest.config.js new file mode 100644 index 00000000000..be420483aa6 --- /dev/null +++ b/packages/g6-ssr/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + transform: { + '^.+\\.[tj]s$': ['@swc/jest'], + }, + collectCoverageFrom: ['src/**/*.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + transformIgnorePatterns: [`/node_modules/.pnpm/(?!(d3-*))`], + moduleNameMapper: { + '@antv/g6': '/../g6/src', + }, +}; diff --git a/packages/g6-ssr/package.json b/packages/g6-ssr/package.json new file mode 100644 index 00000000000..7490e97b30f --- /dev/null +++ b/packages/g6-ssr/package.json @@ -0,0 +1,41 @@ +{ + "name": "@antv/g6-ssr", + "version": "0.0.1", + "description": "", + "keywords": [ + "antv", + "g6", + "ssr" + ], + "license": "MIT", + "author": "Aarebecca", + "main": "./dist/g6-ssr.cjs", + "types": "./dist/lib/index.d.ts", + "bin": "./bin/g6-ssr.cjs", + "files": [ + "dist", + "bin" + ], + "scripts": { + "build": "rimraf ./dist && rollup -c", + "ci": "run-s lint type-check build test", + "dev": "tsx ./src/index.ts", + "lint": "eslint ./src __tests__ --quiet && prettier ./src __tests__ --check", + "prepublishOnly": "npm run ci", + "test": "jest", + "type-check": "tsc --noEmit -p tsconfig.test.json" + }, + "dependencies": { + "@antv/g": "^6.1.3", + "@antv/g-canvas": "^2.0.12", + "@antv/g6": "5.0.28-alpha.1", + "cac": "^6.7.14", + "canvas": "^2.11.2", + "webpack-cli": "^5.1.4" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "type11": "module" +} diff --git a/packages/g6-ssr/rollup.config.mjs b/packages/g6-ssr/rollup.config.mjs new file mode 100644 index 00000000000..caa46baec6e --- /dev/null +++ b/packages/g6-ssr/rollup.config.mjs @@ -0,0 +1,31 @@ +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; +import nodePolyfills from 'rollup-plugin-polyfill-node'; + +export default [ + { + input: 'src/index.ts', + output: { + file: 'dist/g6-ssr.cjs', + format: 'cjs', + exports: 'named', + // format: 'umd', + // name: 'G6SSR', + sourcemap: true, + }, + plugins: [ + nodePolyfills(), + resolve(), + commonjs(), + json(), + typescript({ + tsconfig: 'tsconfig.build.json', + }), + terser(), + ], + external: ['fs', 'path', 'canvas'], + }, +]; diff --git a/packages/g6-ssr/src/canvas.ts b/packages/g6-ssr/src/canvas.ts new file mode 100644 index 00000000000..eb213c363da --- /dev/null +++ b/packages/g6-ssr/src/canvas.ts @@ -0,0 +1,38 @@ +import { Renderer } from '@antv/g-canvas'; +import { Canvas as G6Canvas } from '@antv/g6'; +import type { Canvas as NodeCanvas } from 'canvas'; +import { createCanvas as createNodeCanvas } from 'canvas'; +import type { Options } from './types'; + +/** + * 创建画布 + * + * create canvas + * @param options options 画布配置 | options canvas configuration + * @returns [G6 画布, NodeCanvas 画布] | [G6Canvas, NodeCanvas] + */ +export function createCanvas(options: Options): [G6Canvas, NodeCanvas] { + const { width, height, background, outputType } = options; + const nodeCanvas = createNodeCanvas(width, height, outputType as any); + const offscreenNodeCanvas = createNodeCanvas(1, 1); + + const g6Canvas = new G6Canvas({ + width, + height, + background, + // @ts-expect-error missing types + canvas: nodeCanvas as any, + offscreenCanvas: offscreenNodeCanvas as any, + enableMultiLayer: false, + renderer: () => { + const renderer = new Renderer(); + const htmlRendererPlugin = renderer.getPlugin('html-renderer'); + const domInteractionPlugin = renderer.getPlugin('dom-interaction'); + renderer.unregisterPlugin(htmlRendererPlugin); + renderer.unregisterPlugin(domInteractionPlugin); + return renderer; + }, + }); + + return [g6Canvas, nodeCanvas]; +} diff --git a/packages/g6-ssr/src/graph.ts b/packages/g6-ssr/src/graph.ts new file mode 100644 index 00000000000..8466b257ea3 --- /dev/null +++ b/packages/g6-ssr/src/graph.ts @@ -0,0 +1,55 @@ +import { Graph as G6Graph } from '@antv/g6'; +import { existsSync, lstatSync, writeFileSync } from 'fs'; +import { createCanvas } from './canvas'; +import type { Graph, Options } from './types'; + +/** + * 获取输出文件的扩展名 + * + * Get the extension name of the output file + * @param options - 配置项 | options + * @returns 输出文件的扩展名 | The extension name of the output file + */ +function getExtendNameOf(options: Options) { + const { outputType } = options; + if (outputType === 'pdf') return '.pdf'; + if (outputType === 'svg') return '.svg'; + return '.png'; +} + +/** + * 创建图并等待渲染完成 + * + * Create a graph and wait for the rendering to complete + * @param options - 图配置项 | Graph options + * @returns 扩展图实例 | Extended graph instance + */ +export async function createGraph(options: Options) { + const [g6Canvas, nodeCanvas] = createCanvas(options); + + const { outputType, ...restOptions } = options; + const graph = new G6Graph({ + animation: false, + ...restOptions, + container: g6Canvas, + }); + + // @ts-expect-error extend Graph + graph.exportToFile = (file: string) => { + const extendName = getExtendNameOf(options); + if (!file.endsWith(extendName)) { + if (!existsSync(file)) file += extendName; + else if (lstatSync(file).isDirectory()) file = `${file}/image${extendName}`; + else file += extendName; + } + + writeFileSync(file, nodeCanvas.toBuffer()); + }; + + // @ts-expect-error extend Graph + graph.toBuffer = () => nodeCanvas.toBuffer(); + + await graph.render(); + + return graph as Graph; +} diff --git a/packages/g6-ssr/src/index.ts b/packages/g6-ssr/src/index.ts new file mode 100644 index 00000000000..7cbe2d27f1b --- /dev/null +++ b/packages/g6-ssr/src/index.ts @@ -0,0 +1,3 @@ +export { createCanvas } from './canvas'; +export { createGraph } from './graph'; +export type { Graph, Options } from './types'; diff --git a/packages/g6-ssr/src/types.ts b/packages/g6-ssr/src/types.ts new file mode 100644 index 00000000000..647d40c4843 --- /dev/null +++ b/packages/g6-ssr/src/types.ts @@ -0,0 +1,19 @@ +import type { GraphOptions } from '@antv/g6'; +import { Graph as G6Graph } from '@antv/g6'; + +export interface Options extends Omit { + width: number; + height: number; + /** + * 输出文件类型,默认导出为图片 + * + * output file type, default export as image + * @defaultValue 'image' + */ + outputType?: 'image' | 'pdf' | 'svg'; +} + +export interface Graph extends G6Graph { + exportToFile: (file: string) => void; + toBuffer: () => Buffer; +} diff --git a/packages/g6-ssr/tsconfig.build.json b/packages/g6-ssr/tsconfig.build.json new file mode 100644 index 00000000000..5790ea55085 --- /dev/null +++ b/packages/g6-ssr/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": {} + }, + "include": ["src/**/*"], + "extends": "./tsconfig.json" +} diff --git a/packages/g6-ssr/tsconfig.json b/packages/g6-ssr/tsconfig.json new file mode 100644 index 00000000000..62743c6198e --- /dev/null +++ b/packages/g6-ssr/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "lib", + "target": "ESNext", + "module": "CommonJS", + "lib": ["ESNext"], + "paths": { + "@antv/g6": ["../g6/src/index.ts"] + } + }, + "extends": "../../tsconfig.json", + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/packages/g6-ssr/tsconfig.test.json b/packages/g6-ssr/tsconfig.test.json new file mode 100644 index 00000000000..74e04566a17 --- /dev/null +++ b/packages/g6-ssr/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": {} + }, + "include": ["src/**/*", "__tests__/**/*"], + "extends": "./tsconfig.json" +} diff --git a/packages/g6/__tests__/main.ts b/packages/g6/__tests__/main.ts index 66340d7e670..896b1082be0 100644 --- a/packages/g6/__tests__/main.ts +++ b/packages/g6/__tests__/main.ts @@ -10,6 +10,7 @@ type Options = { Renderer: string; Theme: string; Animation: boolean; + MultiLayers: boolean; [keys: string]: any; }; @@ -20,6 +21,7 @@ const options: Options = { GridLine: true, Theme: 'light', Animation: true, + MultiLayers: true, interval: 0, Reload: () => {}, forms: [], @@ -48,8 +50,9 @@ function initPanel() { applyGridLine(); }); const Animation = panel.add(options, 'Animation').onChange(render); + const MultiLayers = panel.add(options, 'MultiLayers').onChange(render); const reload = panel.add(options, 'Reload').onChange(render); - return { panel, Demo, Search, Renderer, GridLine, Theme, Animation, reload }; + return { panel, Demo, Search, Renderer, GridLine, Theme, Animation, MultiLayers, reload }; } async function render() { @@ -62,13 +65,21 @@ async function render() { applyGridLine(); // render - const { Renderer, Demo, Animation, Theme } = options; - const canvas = createGraphCanvas($container, 500, 500, Renderer); + const { Renderer, Demo, Animation, Theme, MultiLayers } = options; + + const canvasOptions = { enableMultiLayer: MultiLayers }; + + const canvas = createGraphCanvas($container, 500, 500, Renderer, canvasOptions); await canvas.ready; const testCase = demos[Demo as keyof typeof demos]; if (!testCase) return; - const graph = await testCase({ container: canvas, animation: Animation, theme: Theme }); + const graph = await testCase({ + container: canvas, + animation: Animation, + theme: Theme, + canvas: canvasOptions, + }); Object.assign(window, { graph, __g_instances__: Object.values(graph.getCanvas().getLayers()) }); diff --git a/packages/g6/__tests__/unit/runtime/canvas.spec.ts b/packages/g6/__tests__/unit/runtime/canvas.spec.ts index fffe7ba6274..665a811c4f7 100644 --- a/packages/g6/__tests__/unit/runtime/canvas.spec.ts +++ b/packages/g6/__tests__/unit/runtime/canvas.spec.ts @@ -98,4 +98,14 @@ describe('Canvas', () => { expect(graph.getCanvas().getConfig().cursor).toEqual('progress'); }); + + it('layers', () => { + const singleLayerCanvas = createGraphCanvas(document.getElementById('container'), 500, 500, 'svg', { + enableMultiLayer: false, + }); + expect(Object.keys(singleLayerCanvas.getLayers())).toEqual(['main']); + + const multiLayerCanvas = createGraphCanvas(document.getElementById('container'), 500, 500, 'svg'); + expect(Object.keys(multiLayerCanvas.getLayers())).toEqual(['background', 'main', 'label', 'transient']); + }); }); diff --git a/packages/g6/__tests__/utils/create.ts b/packages/g6/__tests__/utils/create.ts index 9235bae63a8..386623dd1f3 100644 --- a/packages/g6/__tests__/utils/create.ts +++ b/packages/g6/__tests__/utils/create.ts @@ -1,12 +1,9 @@ -import type { GraphOptions } from '@/src'; -import { Graph } from '@/src'; -import { Circle } from '@/src/elements'; -import { Canvas } from '@/src/runtime/canvas'; -import type { Node, Point } from '@/src/types'; import { resetEntityCounter } from '@antv/g'; import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Renderer as SVGRenderer } from '@antv/g-svg'; import { Renderer as WebGLRenderer } from '@antv/g-webgl'; +import type { CanvasConfig, GraphOptions, Node, Point } from '@antv/g6'; +import { Canvas, Circle, Graph } from '@antv/g6'; import { OffscreenCanvasContext } from './offscreen-canvas-context'; function getRenderer(renderer: string) { @@ -28,6 +25,7 @@ function getRenderer(renderer: string) { * @param width - width * @param height - height * @param renderer - render + * @param options - options * @returns instance */ export function createGraphCanvas( @@ -35,6 +33,7 @@ export function createGraphCanvas( width: number = 500, height: number = 500, renderer: string = 'svg', + options?: Partial, ) { const container = dom || document.createElement('div'); container.style.width = `${width}px`; @@ -42,7 +41,9 @@ export function createGraphCanvas( resetEntityCounter(); - let extraOptions = {}; + const extraOptions: Record = { + ...options, + }; if (globalThis.process) { const offscreenNodeCanvas = { @@ -50,10 +51,10 @@ export function createGraphCanvas( } as unknown as HTMLCanvasElement; const context = new OffscreenCanvasContext(offscreenNodeCanvas); // 下列参数仅在 node 环境下需要传入 / These parameters only need to be passed in the node environment - extraOptions = { + Object.assign(extraOptions, { document: container.ownerDocument, offscreenCanvas: offscreenNodeCanvas, - }; + }); } const offscreenNodeCanvas = { diff --git a/packages/g6/src/exports.ts b/packages/g6/src/exports.ts index 5658e080eaf..5e90cb767af 100644 --- a/packages/g6/src/exports.ts +++ b/packages/g6/src/exports.ts @@ -95,6 +95,7 @@ export { } from './plugins'; export { getExtension, getExtensions } from './registry/get'; export { register } from './registry/register'; +export { Canvas } from './runtime/canvas'; export { Graph } from './runtime/graph'; export { BaseTransform, MapNodeSize, PlaceRadialLabels, ProcessParallelEdges } from './transforms'; export { isCollapsed } from './utils/collapsibility'; @@ -200,7 +201,7 @@ export type { TooltipOptions, WatermarkOptions, } from './plugins'; -export type { DataURLOptions } from './runtime/canvas'; +export type { CanvasConfig, DataURLOptions } from './runtime/canvas'; export type { RuntimeContext } from './runtime/types'; export type { BehaviorOptions, diff --git a/packages/g6/src/plugins/fullscreen/index.ts b/packages/g6/src/plugins/fullscreen/index.ts index 3b7078b96b2..d3dee049ee0 100644 --- a/packages/g6/src/plugins/fullscreen/index.ts +++ b/packages/g6/src/plugins/fullscreen/index.ts @@ -105,8 +105,8 @@ export class Fullscreen extends BasePlugin { private setGraphSize(fullScreen = true) { let width, height; if (fullScreen) { - width = window.screen.width; - height = window.screen.height; + width = globalThis.screen?.width || 0; + height = globalThis.screen?.height || 0; this.graphSize = this.context.graph.getSize(); } else { [width, height] = this.graphSize; diff --git a/packages/g6/src/runtime/canvas.ts b/packages/g6/src/runtime/canvas.ts index 6def2386783..c4c4b1bbd5b 100644 --- a/packages/g6/src/runtime/canvas.ts +++ b/packages/g6/src/runtime/canvas.ts @@ -10,7 +10,23 @@ import { parsePoint, toPointObject } from '../utils/point'; export interface CanvasConfig extends Pick { + /** + * 渲染器 + * + * renderer + */ renderer?: CanvasOptions['renderer']; + /** + * 是否启用多图层 + * + * Whether to enable multiple layers + * @defaultValue true + * @remarks + * 非动态参数,仅在初始化时生效 + * + * Non-dynamic parameters, only take effect during initialization + */ + enableMultiLayer?: boolean; } export interface DataURLOptions { @@ -39,7 +55,19 @@ export interface DataURLOptions { encoderOptions: number; } -const layersName: CanvasLayer[] = ['background', 'main', 'label', 'transient']; +const SINGLE_LAYER_NAME: CanvasLayer[] = ['main']; +const MULTI_LAYER_NAME: CanvasLayer[] = ['background', 'main', 'label', 'transient']; + +/** + * 获取主画布图层 + * + * Get the main canvas layer + * @param layers - 画布图层 | Canvas layer + * @returns 主画布图层 | Main canvas layer + */ +function getMainLayerOf(layers: Record) { + return layers.main; +} export class Canvas { private extends: { @@ -49,14 +77,16 @@ export class Canvas { layers: Record; }; - private config: CanvasConfig; + private config: CanvasConfig = { + enableMultiLayer: true, + }; public getConfig() { return this.config; } public getLayer(layer: CanvasLayer = 'main') { - return this.extends.layers[layer]; + return this.extends.layers[layer] || getMainLayerOf(this.getLayers()); } /** @@ -113,18 +143,18 @@ export class Canvas { } constructor(config: CanvasConfig) { - this.config = config; - - const { renderer, background, cursor, ...restConfig } = config; - const renderers = createRenderers(renderer); + Object.assign(this.config, config); + const { renderer, background, cursor, enableMultiLayer, ...restConfig } = this.config; + const layersName = enableMultiLayer ? MULTI_LAYER_NAME : SINGLE_LAYER_NAME; + const renderers = createRenderers(renderer, layersName); const layers = Object.fromEntries( layersName.map((layer) => { const canvas = new GCanvas({ ...restConfig, - supportsMutipleCanvasesInOneContainer: true, + supportsMutipleCanvasesInOneContainer: enableMultiLayer, renderer: renderers[layer], - background: layer === 'background' ? background : undefined, + background: enableMultiLayer ? (layer === 'background' ? background : undefined) : background, }); return [layer, canvas]; @@ -134,7 +164,7 @@ export class Canvas { configCanvasDom(layers); this.extends = { - config, + config: this.config, renderer, renderers, layers, @@ -207,7 +237,7 @@ export class Canvas { public setRenderer(renderer: CanvasOptions['renderer']) { if (renderer === this.extends.renderer) return; - const renderers = createRenderers(renderer); + const renderers = createRenderers(renderer, this.config.enableMultiLayer ? MULTI_LAYER_NAME : SINGLE_LAYER_NAME); this.extends.renderers = renderers; Object.entries(renderers).forEach(([layer, instance]) => this.getLayer(layer as CanvasLayer).setRenderer(instance)); configCanvasDom(this.getLayers()); @@ -240,7 +270,7 @@ export class Canvas { } public async toDataURL(options: Partial = {}) { - const devicePixelRatio = window.devicePixelRatio || 1; + const devicePixelRatio = globalThis.devicePixelRatio || 1; const { mode = 'viewport', ...restOptions } = options; let [startX, startY, width, height] = [0, 0, 0, 0]; @@ -322,9 +352,10 @@ export class Canvas { * * Create renderers * @param renderer - 渲染器创建器 Renderer creator + * @param layersName - 图层名称 Layer name * @returns 渲染器 Renderer */ -function createRenderers(renderer: CanvasConfig['renderer']) { +function createRenderers(renderer: CanvasConfig['renderer'], layersName: CanvasLayer[]) { return Object.fromEntries( layersName.map((layer) => { const instance = renderer?.(layer) || new CanvasRenderer(); @@ -357,10 +388,14 @@ function configCanvasDom(layers: Record) { Object.entries(layers).forEach(([layer, canvas]) => { const domElement = canvas.getContextService().getDomElement() as unknown as HTMLElement; - domElement.style.position = 'absolute'; - domElement.style.outline = 'none'; - domElement.tabIndex = 1; + // 浏览器环境下,设置画布样式 + // Set canvas style in browser environment + if (domElement?.style) { + domElement.style.position = 'absolute'; + domElement.style.outline = 'none'; + domElement.tabIndex = 1; - if (layer !== 'main') domElement.style.pointerEvents = 'none'; + if (layer !== 'main') domElement.style.pointerEvents = 'none'; + } }); } diff --git a/packages/g6/src/runtime/graph.ts b/packages/g6/src/runtime/graph.ts index 4f77867a603..36df5904ae0 100644 --- a/packages/g6/src/runtime/graph.ts +++ b/packages/g6/src/runtime/graph.ts @@ -91,8 +91,8 @@ export class Graph extends EventEmitter { this._setOptions(this.options, true); this.context.graph = this; - // Listening window.resize to autoResize. - this.options.autoResize && window.addEventListener('resize', this.onResize); + // Listening resize to autoResize. + this.options.autoResize && globalThis.addEventListener?.('resize', this.onResize); } /** @@ -1031,7 +1031,8 @@ export class Graph extends EventEmitter { renderer, cursor, background, - devicePixelRatio = window.devicePixelRatio ?? 1, + canvas: canvasOptions, + devicePixelRatio = globalThis.devicePixelRatio ?? 1, } = this.options; if (container instanceof Canvas) { this.context.canvas = container; @@ -1043,8 +1044,8 @@ export class Graph extends EventEmitter { const containerSize = sizeOf($container!); this.emit(GraphEvent.BEFORE_CANVAS_INIT, { container: $container, width, height }); - - const canvas = new Canvas({ + const options = { + ...canvasOptions, container: $container!, width: width || containerSize[0], height: height || containerSize[1], @@ -1052,7 +1053,9 @@ export class Graph extends EventEmitter { renderer, cursor, devicePixelRatio, - }); + }; + + const canvas = new Canvas(options); this.context.canvas = canvas; await canvas.ready; @@ -1204,7 +1207,7 @@ export class Graph extends EventEmitter { this.context = {}; this.off(); - window.removeEventListener('resize', this.onResize); + globalThis.removeEventListener?.('resize', this.onResize); this.destroyed = true; diff --git a/packages/g6/src/spec/canvas.ts b/packages/g6/src/spec/canvas.ts index 30acd8972c0..95c8e154c17 100644 --- a/packages/g6/src/spec/canvas.ts +++ b/packages/g6/src/spec/canvas.ts @@ -1,5 +1,5 @@ import type { Cursor, IRenderer } from '@antv/g'; -import type { Canvas } from '../runtime/canvas'; +import type { Canvas, CanvasConfig } from '../runtime/canvas'; /** * 画布配置项 @@ -57,4 +57,14 @@ export interface CanvasOptions { * cursor style */ cursor?: Cursor; + /** + * 画布配置 + * + * canvas config + * @remarks + * GraphOptions 下相关配置项为快捷配置项,会被转换为 canvas 配置项 + * + * The related configuration items under GraphOptions are shortcut configuration items, which will be converted to canvas configuration items + */ + canvas?: CanvasConfig; } diff --git a/packages/g6/src/utils/shortcut.ts b/packages/g6/src/utils/shortcut.ts index f7dbe24713f..c19c998d548 100644 --- a/packages/g6/src/utils/shortcut.ts +++ b/packages/g6/src/utils/shortcut.ts @@ -57,7 +57,7 @@ export class Shortcut { // 窗口重新获得焦点后清空按键,避免按键状态异常 // Clear the keys when the window regains focus to avoid abnormal key states - window.addEventListener('focus', this.onFocus); + globalThis.addEventListener?.('focus', this.onFocus); } private onKeyDown = (event: KeyboardEvent) => { @@ -117,6 +117,6 @@ export class Shortcut { this.emitter.off(CommonEvent.KEY_UP, this.onKeyUp); this.emitter.off(CommonEvent.WHEEL, this.onWheel); this.emitter.off(CommonEvent.DRAG, this.onDrag); - window.removeEventListener('blur', this.onFocus); + globalThis.removeEventListener?.('blur', this.onFocus); } }