Repository: antvis/G6 Branch: v5 Commit: 91eb1950a56c Files: 1692 Total size: 5.4 MB Directory structure: gitextract_9ptgz9cu/ ├── .changeset/ │ └── config.json ├── .codecov.yml ├── .commitlintrc.js ├── .cursor/ │ └── rules/ │ └── translation.mdc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 1.bug_report.yml │ │ ├── 2.feature_request.yml │ │ ├── 3.docs_feedback.yml │ │ ├── 4.oscp.yml │ │ ├── config.yml │ │ └── oscp.yml │ └── workflows/ │ ├── build.yml │ ├── deploy.yml │ ├── ensure-triage-label.yml │ ├── issue-automated.yml │ ├── issue_translate.yml │ ├── manage-labeled.yml │ ├── mark-duplicate.yml │ ├── no-response.yml │ ├── publish.yml │ ├── resolved-pending-release.yml │ ├── scripts/ │ │ ├── closeOnRelease.js │ │ ├── issue-automated.js │ │ └── updateYuque.js │ └── update-yuque.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── .vscode/ │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── PUBLISH.md ├── README.md ├── README.zh-CN.md ├── SECURITY.md ├── package.json ├── packages/ │ ├── bundle/ │ │ ├── index.html │ │ ├── package.json │ │ ├── rollup.config.mjs │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── vite.config.js │ │ └── webpack.config.js │ ├── cli/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── build.config.ts │ │ ├── index.js │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── template-extension/ │ │ │ ├── .commitlintrc.js │ │ │ ├── .editorconfig │ │ │ ├── .eslintignore │ │ │ ├── .eslintrc.js │ │ │ ├── .gitignore │ │ │ ├── .prettierignore │ │ │ ├── .prettierrc.js │ │ │ ├── __tests__/ │ │ │ │ ├── demos/ │ │ │ │ │ ├── element-node-extend.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.html │ │ │ │ ├── main.ts │ │ │ │ ├── setup.ts │ │ │ │ ├── types.d.ts │ │ │ │ ├── unit/ │ │ │ │ │ ├── default.spec.ts │ │ │ │ │ └── elements/ │ │ │ │ │ └── nodes/ │ │ │ │ │ └── extend.spec.ts │ │ │ │ └── utils/ │ │ │ │ ├── create.ts │ │ │ │ ├── dir.ts │ │ │ │ ├── index.ts │ │ │ │ ├── offscreen-canvas-context.ts │ │ │ │ ├── sleep.ts │ │ │ │ ├── svg-transformer.js │ │ │ │ ├── to-match-svg-snapshot.ts │ │ │ │ └── use-snapshot-matchers.ts │ │ │ ├── jest.config.js │ │ │ ├── package.json │ │ │ ├── rollup.config.mjs │ │ │ ├── src/ │ │ │ │ ├── elements/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── nodes/ │ │ │ │ │ ├── extend-node.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── exports.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.build.json │ │ │ ├── tsconfig.json │ │ │ └── vite.config.js │ │ └── tsconfig.json │ ├── g6/ │ │ ├── .gitignore │ │ ├── __tests__/ │ │ │ ├── .eslintrc │ │ │ ├── bugs/ │ │ │ │ ├── api-expand-element-z-index.spec.ts │ │ │ │ ├── api-focus-element-edge.spec.ts │ │ │ │ ├── behaviors-click-select-drag-node.spec.ts │ │ │ │ ├── behaviors-click-select.spec.ts │ │ │ │ ├── behaviors-collapse-expand.spec.ts │ │ │ │ ├── behaviors-drag-element-combo.spec.ts │ │ │ │ ├── behaviors-drag-rotated-canvas.spec.ts │ │ │ │ ├── behaviors-multiple-conflict.spec.ts │ │ │ │ ├── brush-select.spec.ts │ │ │ │ ├── continuous-invoke.spec.ts │ │ │ │ ├── element-combo-drag.spec.ts │ │ │ │ ├── element-custom-state-switch.spec.ts │ │ │ │ ├── element-edge-update-arrow.spec.ts │ │ │ │ ├── element-node-collapse.spec.ts │ │ │ │ ├── element-node-icon-switch.spec.ts │ │ │ │ ├── element-node-update-badge.spec.ts │ │ │ │ ├── element-orth-router.spec.ts │ │ │ │ ├── element-port-rotate.spec.ts │ │ │ │ ├── element-remove-combo.spec.ts │ │ │ │ ├── element-set-position-to-origin.spec.ts │ │ │ │ ├── fit-view.spec.ts │ │ │ │ ├── focus-element.spec.ts │ │ │ │ ├── graph-draw-after-clear.spec.ts │ │ │ │ ├── model-add-edge-in-combo.spec.ts │ │ │ │ ├── model-remove-parent.spec.ts │ │ │ │ ├── plugin-history-align-fields.spec.ts │ │ │ │ ├── plugin-hull-three-collinear-dots.spec.ts │ │ │ │ ├── plugin-minimap-combo-collapsed.spec.ts │ │ │ │ ├── render-change-combo.spec.ts │ │ │ │ ├── render-deleted-data.spec.ts │ │ │ │ ├── tree-update-collapsed-node.spec.ts │ │ │ │ └── utils-set-visibility.spec.ts │ │ │ ├── dataset/ │ │ │ │ ├── algorithm-category.json │ │ │ │ ├── circular.json │ │ │ │ ├── cluster.json │ │ │ │ ├── combo.json │ │ │ │ ├── dagre-combo.json │ │ │ │ ├── dagre.json │ │ │ │ ├── decision-tree.json │ │ │ │ ├── element-edges.json │ │ │ │ ├── element-nodes.json │ │ │ │ ├── file-system.json │ │ │ │ ├── flare.json │ │ │ │ ├── force.json │ │ │ │ ├── gene.json │ │ │ │ ├── language-tree.json │ │ │ │ ├── organization-chart.json │ │ │ │ ├── parallel-edges.json │ │ │ │ ├── radial.json │ │ │ │ ├── relations.json │ │ │ │ └── soccer.json │ │ │ ├── demos/ │ │ │ │ ├── animation-element-edge-cubic.ts │ │ │ │ ├── animation-element-edge-line.ts │ │ │ │ ├── animation-element-edge-quadratic.ts │ │ │ │ ├── animation-element-position.ts │ │ │ │ ├── animation-element-state-switch.ts │ │ │ │ ├── animation-element-state.ts │ │ │ │ ├── animation-element-style-position.ts │ │ │ │ ├── behavior-auto-adapt-label.ts │ │ │ │ ├── behavior-brush-select.ts │ │ │ │ ├── behavior-click-select.ts │ │ │ │ ├── behavior-create-edge.ts │ │ │ │ ├── behavior-drag-canvas.ts │ │ │ │ ├── behavior-drag-element.ts │ │ │ │ ├── behavior-expand-collapse-combo.ts │ │ │ │ ├── behavior-expand-collapse-node.ts │ │ │ │ ├── behavior-fix-element-size.ts │ │ │ │ ├── behavior-focus-element.ts │ │ │ │ ├── behavior-hover-activate.ts │ │ │ │ ├── behavior-lasso-select.ts │ │ │ │ ├── behavior-optimize-viewport-transform.ts │ │ │ │ ├── behavior-scroll-canvas.ts │ │ │ │ ├── behavior-zoom-canvas.ts │ │ │ │ ├── bug-drag-rotated-canvas.ts │ │ │ │ ├── bug-drag-rotated-element-force.ts │ │ │ │ ├── bug-process-parallel-edges-combo-fixed.ts │ │ │ │ ├── bug-tooltip-resize.ts │ │ │ │ ├── canvas-cursor.ts │ │ │ │ ├── case-fishbone.ts │ │ │ │ ├── case-fund-flow.ts │ │ │ │ ├── case-indented-tree.ts │ │ │ │ ├── case-language-tree.ts │ │ │ │ ├── case-mindmap.ts │ │ │ │ ├── case-org-chart.ts │ │ │ │ ├── case-radial-dendrogram.ts │ │ │ │ ├── case-unicorns-investors.ts │ │ │ │ ├── case-why-do-cats.ts │ │ │ │ ├── common-graph.ts │ │ │ │ ├── controller-viewport.ts │ │ │ │ ├── demo-autosize-element-label.ts │ │ │ │ ├── demo-found-flow.ts │ │ │ │ ├── demo-supply-chains.ts │ │ │ │ ├── element-change-type.ts │ │ │ │ ├── element-combo.ts │ │ │ │ ├── element-edge-arrow.ts │ │ │ │ ├── element-edge-cubic-horizontal.ts │ │ │ │ ├── element-edge-cubic-radial.ts │ │ │ │ ├── element-edge-cubic-vertical.ts │ │ │ │ ├── element-edge-cubic.ts │ │ │ │ ├── element-edge-custom-arrow.ts │ │ │ │ ├── element-edge-line.ts │ │ │ │ ├── element-edge-loop-curve.ts │ │ │ │ ├── element-edge-loop-polyline.ts │ │ │ │ ├── element-edge-polyline-animation.ts │ │ │ │ ├── element-edge-polyline-astar.ts │ │ │ │ ├── element-edge-polyline-orth.ts │ │ │ │ ├── element-edge-polyline.ts │ │ │ │ ├── element-edge-port.ts │ │ │ │ ├── element-edge-quadratic.ts │ │ │ │ ├── element-edge-size.ts │ │ │ │ ├── element-html-sub-graph.ts │ │ │ │ ├── element-label-background.ts │ │ │ │ ├── element-label-oversized.ts │ │ │ │ ├── element-node-avatar.ts │ │ │ │ ├── element-node-badges.ts │ │ │ │ ├── element-node-circle.ts │ │ │ │ ├── element-node-diamond.ts │ │ │ │ ├── element-node-donut.ts │ │ │ │ ├── element-node-ellipse.ts │ │ │ │ ├── element-node-hexagon.ts │ │ │ │ ├── element-node-html-2.ts │ │ │ │ ├── element-node-html.ts │ │ │ │ ├── element-node-image.ts │ │ │ │ ├── element-node-rect.ts │ │ │ │ ├── element-node-star.ts │ │ │ │ ├── element-node-svg-icon.ts │ │ │ │ ├── element-node-triangle.ts │ │ │ │ ├── element-port.ts │ │ │ │ ├── element-position-combo.ts │ │ │ │ ├── element-position.ts │ │ │ │ ├── element-state.ts │ │ │ │ ├── element-visibility-part.ts │ │ │ │ ├── element-visibility.ts │ │ │ │ ├── element-z-index.ts │ │ │ │ ├── graph-to-data-url.ts │ │ │ │ ├── image-node-halo-test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout-antv-dagre-flow-combo.ts │ │ │ │ ├── layout-antv-dagre-flow.ts │ │ │ │ ├── layout-circular-basic.ts │ │ │ │ ├── layout-circular-configuration-translate.ts │ │ │ │ ├── layout-circular-degree.ts │ │ │ │ ├── layout-circular-division.ts │ │ │ │ ├── layout-circular-spiral.ts │ │ │ │ ├── layout-combo-combined.ts │ │ │ │ ├── layout-compact-box-basic.ts │ │ │ │ ├── layout-compact-box-left-align.ts │ │ │ │ ├── layout-compact-box-top-to-bottom.ts │ │ │ │ ├── layout-concentric.ts │ │ │ │ ├── layout-custom-dagre.ts │ │ │ │ ├── layout-custom-horizontal.ts │ │ │ │ ├── layout-custom-iterative.ts │ │ │ │ ├── layout-d3-force.ts │ │ │ │ ├── layout-dagre.ts │ │ │ │ ├── layout-dendrogram-basic.ts │ │ │ │ ├── layout-dendrogram-radial.ts │ │ │ │ ├── layout-dendrogram-tb.ts │ │ │ │ ├── layout-fishbone.ts │ │ │ │ ├── layout-force-collision.ts │ │ │ │ ├── layout-force-lattice.ts │ │ │ │ ├── layout-force.ts │ │ │ │ ├── layout-forceatlas2-wasm.ts │ │ │ │ ├── layout-fruchterman-basic.ts │ │ │ │ ├── layout-fruchterman-cluster.ts │ │ │ │ ├── layout-fruchterman-fix.ts │ │ │ │ ├── layout-fruchterman-gpu.ts │ │ │ │ ├── layout-fruchterman-wasm.ts │ │ │ │ ├── layout-grid.ts │ │ │ │ ├── layout-indented.ts │ │ │ │ ├── layout-mds.ts │ │ │ │ ├── layout-mindmap-h-custom-side.ts │ │ │ │ ├── layout-mindmap-h-left.ts │ │ │ │ ├── layout-mindmap-h-right.ts │ │ │ │ ├── layout-mindmap-h.ts │ │ │ │ ├── layout-pipeline-mds-force.ts │ │ │ │ ├── layout-radial-basic.ts │ │ │ │ ├── layout-radial-configuration-translate.ts │ │ │ │ ├── layout-radial-prevent-overlap-unstrict.ts │ │ │ │ ├── layout-radial-prevent-overlap.ts │ │ │ │ ├── layout-radial-sort.ts │ │ │ │ ├── layout-snake.ts │ │ │ │ ├── perf-20000-elements.ts │ │ │ │ ├── perf-fcp.ts │ │ │ │ ├── plugin-background.ts │ │ │ │ ├── plugin-bubble-sets.ts │ │ │ │ ├── plugin-camera-setting.ts │ │ │ │ ├── plugin-contextmenu.ts │ │ │ │ ├── plugin-edge-bundling.ts │ │ │ │ ├── plugin-edge-filter-lens.ts │ │ │ │ ├── plugin-fisheye.ts │ │ │ │ ├── plugin-fullscreen.ts │ │ │ │ ├── plugin-grid-line.ts │ │ │ │ ├── plugin-history.ts │ │ │ │ ├── plugin-hull.ts │ │ │ │ ├── plugin-legend.ts │ │ │ │ ├── plugin-minimap-edge-arrow.ts │ │ │ │ ├── plugin-minimap.ts │ │ │ │ ├── plugin-snapline.ts │ │ │ │ ├── plugin-timebar.ts │ │ │ │ ├── plugin-title.ts │ │ │ │ ├── plugin-toolbar-build-in.ts │ │ │ │ ├── plugin-toolbar-iconfont.ts │ │ │ │ ├── plugin-tooltip-async.ts │ │ │ │ ├── plugin-tooltip-dual.ts │ │ │ │ ├── plugin-tooltip-enable.ts │ │ │ │ ├── plugin-tooltip-with-custom-node.ts │ │ │ │ ├── plugin-tooltip.ts │ │ │ │ ├── plugin-watermark-image.ts │ │ │ │ ├── plugin-watermark.ts │ │ │ │ ├── theme.ts │ │ │ │ ├── transform-map-node-size.ts │ │ │ │ ├── transform-place-radial-labels.ts │ │ │ │ ├── transform-process-parallel-edges.ts │ │ │ │ └── viewport-fit.ts │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── perf/ │ │ │ │ ├── data.perf.ts │ │ │ │ ├── draw.perf.ts │ │ │ │ ├── massive-element.perf.ts │ │ │ │ └── update-state.perf.ts │ │ │ ├── perf-report/ │ │ │ │ ├── 9821ed36_2024-08-22_20-39-12.json │ │ │ │ ├── 9821ed36_2024-08-29_11-11-17.json │ │ │ │ ├── 9821ed36_2024-08-29_13-24-51.json │ │ │ │ ├── 9821ed36_2024-09-03_10-33-27.json │ │ │ │ └── 9821ed36_2024-09-03_11-28-42.json │ │ │ ├── setup.ts │ │ │ ├── types.d.ts │ │ │ ├── unit/ │ │ │ │ ├── animations/ │ │ │ │ │ ├── element-position.spec.ts │ │ │ │ │ ├── element-state-switch.spec.ts │ │ │ │ │ └── element-style-position.spec.ts │ │ │ │ ├── behaviors/ │ │ │ │ │ ├── auto-adapt-label.spec.ts │ │ │ │ │ ├── behavior-create-edge-click.spec.ts │ │ │ │ │ ├── behavior-create-edge-drag.spec.ts │ │ │ │ │ ├── brush-select.spec.ts │ │ │ │ │ ├── click-select-catch.spec.ts │ │ │ │ │ ├── click-select.spec.ts │ │ │ │ │ ├── collapse-expand-combo.spec.ts │ │ │ │ │ ├── collapse-expand-node.spec.ts │ │ │ │ │ ├── collapse-expand.spec.ts │ │ │ │ │ ├── drag-canvas.spec.ts │ │ │ │ │ ├── drag-element-bug.spec.ts │ │ │ │ │ ├── drag-element-combo.spec.ts │ │ │ │ │ ├── drag-element.spec.ts │ │ │ │ │ ├── fix-element-size.spec.ts │ │ │ │ │ ├── focus-element.spec.ts │ │ │ │ │ ├── hover-activate.spec.ts │ │ │ │ │ ├── lasso-select.spec.ts │ │ │ │ │ ├── optimize-viewport-transform.spec.ts │ │ │ │ │ ├── scroll-canvas.spec.ts │ │ │ │ │ └── zoom-canvas.spec.ts │ │ │ │ ├── default.spec.ts │ │ │ │ ├── elements/ │ │ │ │ │ ├── change-type.spec.ts │ │ │ │ │ ├── combo.spec.ts │ │ │ │ │ ├── edges/ │ │ │ │ │ │ ├── arrow.spec.ts │ │ │ │ │ │ ├── cubic-horizontal.spec.ts │ │ │ │ │ │ ├── cubic-radial.spec.ts │ │ │ │ │ │ ├── cubic-vertical.spec.ts │ │ │ │ │ │ ├── cubic.spec.ts │ │ │ │ │ │ ├── custom-arrow.spec.ts │ │ │ │ │ │ ├── line.spec.ts │ │ │ │ │ │ ├── loop-curve.spec.ts │ │ │ │ │ │ ├── loop-polyline.spec.ts │ │ │ │ │ │ ├── polyline-animation.spec.ts │ │ │ │ │ │ ├── polyline-astar.spec.ts │ │ │ │ │ │ ├── polyline-orth.spec.ts │ │ │ │ │ │ ├── polyline.spec.ts │ │ │ │ │ │ ├── port.spec.ts │ │ │ │ │ │ ├── quadratic.spec.ts │ │ │ │ │ │ └── size.spec.ts │ │ │ │ │ ├── label-background.spec.ts │ │ │ │ │ ├── label-oversized.spec.ts │ │ │ │ │ ├── nodes/ │ │ │ │ │ │ ├── avatar.spec.ts │ │ │ │ │ │ ├── circle.spec.ts │ │ │ │ │ │ ├── diamond.spec.ts │ │ │ │ │ │ ├── donut.spec.ts │ │ │ │ │ │ ├── ellipse.spec.ts │ │ │ │ │ │ ├── hexagon.spec.ts │ │ │ │ │ │ ├── image.spec.ts │ │ │ │ │ │ ├── rect.spec.ts │ │ │ │ │ │ ├── star.spec.ts │ │ │ │ │ │ └── triangle.spec.ts │ │ │ │ │ ├── override-methods.spec.ts │ │ │ │ │ ├── port.spec.ts │ │ │ │ │ ├── position-combo.spec.ts │ │ │ │ │ ├── position.spec.ts │ │ │ │ │ ├── shape.spec.ts │ │ │ │ │ ├── state.spec.ts │ │ │ │ │ ├── visibility.spec.ts │ │ │ │ │ └── z-index.spec.ts │ │ │ │ ├── import.spec.ts │ │ │ │ ├── layouts/ │ │ │ │ │ ├── circular.spec.ts │ │ │ │ │ ├── combo-layout.spec.ts │ │ │ │ │ ├── compact-box.spec.ts │ │ │ │ │ ├── concentric.spec.ts │ │ │ │ │ ├── custom-dagre.spec.ts │ │ │ │ │ ├── custom-layout-horizontal.spec.ts │ │ │ │ │ ├── d3-force-collision.spec.ts │ │ │ │ │ ├── d3-force-lattice.spec.ts │ │ │ │ │ ├── d3-force.spec.ts │ │ │ │ │ ├── dagre.spec.ts │ │ │ │ │ ├── dendrogram.spec.ts │ │ │ │ │ ├── fishbone.spec.ts │ │ │ │ │ ├── fruchterman.spec.ts │ │ │ │ │ ├── grid.spec.ts │ │ │ │ │ ├── indented.spec.ts │ │ │ │ │ ├── mds.spec.ts │ │ │ │ │ ├── mindmap.spec.ts │ │ │ │ │ ├── pipeline.spec.ts │ │ │ │ │ ├── radial-layout.spec.ts │ │ │ │ │ └── snake.spec.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── background.spec.ts │ │ │ │ │ ├── bubble-sets.spec.ts │ │ │ │ │ ├── camera-setting.spec.ts │ │ │ │ │ ├── contextmenu.spec.ts │ │ │ │ │ ├── edge-bundling.spec.ts │ │ │ │ │ ├── edge-filter-lens.spec.ts │ │ │ │ │ ├── fisheye.spec.ts │ │ │ │ │ ├── grid-line.spec.ts │ │ │ │ │ ├── history/ │ │ │ │ │ │ ├── plugin-history.spec.ts │ │ │ │ │ │ └── utils.spec.ts │ │ │ │ │ ├── hull/ │ │ │ │ │ │ ├── plugin-hull.spec.ts │ │ │ │ │ │ └── util.spec.ts │ │ │ │ │ ├── legend.spec.ts │ │ │ │ │ ├── snapline.spec.ts │ │ │ │ │ ├── timebar.spec.ts │ │ │ │ │ ├── title.spec.ts │ │ │ │ │ ├── toolbar/ │ │ │ │ │ │ ├── plugin-toolbar.spec.ts │ │ │ │ │ │ └── util.spec.ts │ │ │ │ │ ├── tooltip.spec.ts │ │ │ │ │ ├── utils/ │ │ │ │ │ │ └── dom.spec.ts │ │ │ │ │ └── watermark.spec.ts │ │ │ │ ├── registry.spec.ts │ │ │ │ ├── runtime/ │ │ │ │ │ ├── canvas.spec.ts │ │ │ │ │ ├── data.spec.ts │ │ │ │ │ ├── element/ │ │ │ │ │ │ ├── event.spec.ts │ │ │ │ │ │ ├── state.spec.ts │ │ │ │ │ │ ├── visibility.spec.ts │ │ │ │ │ │ └── z-index.spec.ts │ │ │ │ │ ├── element.spec.ts │ │ │ │ │ ├── graph/ │ │ │ │ │ │ ├── add-children-data.spec.ts │ │ │ │ │ │ ├── auto-resize.spec.ts │ │ │ │ │ │ ├── event.spec.ts │ │ │ │ │ │ ├── get-plugin-instantce.spec.ts │ │ │ │ │ │ ├── graph.spec.ts │ │ │ │ │ │ └── this.spec.ts │ │ │ │ │ ├── layout.spec.ts │ │ │ │ │ └── viewport.spec.ts │ │ │ │ ├── spec/ │ │ │ │ │ ├── behavior.spec.ts │ │ │ │ │ ├── canvas.spec.ts │ │ │ │ │ ├── data.spec.ts │ │ │ │ │ ├── element/ │ │ │ │ │ │ ├── combo.spec.ts │ │ │ │ │ │ ├── edge.spec.ts │ │ │ │ │ │ └── node.spec.ts │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ ├── layout.spec.ts │ │ │ │ │ ├── optimize.spec.ts │ │ │ │ │ ├── plugin.spec.ts │ │ │ │ │ ├── theme.spec.ts │ │ │ │ │ └── viewport.spec.ts │ │ │ │ ├── themes/ │ │ │ │ │ └── base.spec.ts │ │ │ │ ├── transforms/ │ │ │ │ │ ├── base-transform.spec.ts │ │ │ │ │ ├── transform-map-node-size.spec.ts │ │ │ │ │ ├── transform-position-radial-labels.spec.ts │ │ │ │ │ └── transform-process-parallel-edges.spec.ts │ │ │ │ ├── utils/ │ │ │ │ │ ├── anchor.spec.ts │ │ │ │ │ ├── animation.spec.ts │ │ │ │ │ ├── array.spec.ts │ │ │ │ │ ├── bbox.spec.ts │ │ │ │ │ ├── cache.spec.ts │ │ │ │ │ ├── change.spec.ts │ │ │ │ │ ├── collapsibility.spec.ts │ │ │ │ │ ├── contextmenu.spec.ts │ │ │ │ │ ├── data.spec.ts │ │ │ │ │ ├── diff.spec.ts │ │ │ │ │ ├── dom.spec.ts │ │ │ │ │ ├── edge.spec.ts │ │ │ │ │ ├── element.spec.ts │ │ │ │ │ ├── event.spec.ts │ │ │ │ │ ├── extension.spec.ts │ │ │ │ │ ├── graphlib.spec.ts │ │ │ │ │ ├── id.spec.ts │ │ │ │ │ ├── is.spec.ts │ │ │ │ │ ├── layout.spec.ts │ │ │ │ │ ├── line.spec.ts │ │ │ │ │ ├── math.spec.ts │ │ │ │ │ ├── padding.spec.ts │ │ │ │ │ ├── palette.spec.ts │ │ │ │ │ ├── path.spec.ts │ │ │ │ │ ├── placement.spec.ts │ │ │ │ │ ├── point.spec.ts │ │ │ │ │ ├── polygon.spec.ts │ │ │ │ │ ├── position.spec.ts │ │ │ │ │ ├── prefix.spec.ts │ │ │ │ │ ├── print.spec.ts │ │ │ │ │ ├── random.spec.ts │ │ │ │ │ ├── relation.spec.ts │ │ │ │ │ ├── router.spec.ts │ │ │ │ │ ├── scale.spec.ts │ │ │ │ │ ├── shape.spec.ts │ │ │ │ │ ├── shortcut.spec.ts │ │ │ │ │ ├── size.spec.ts │ │ │ │ │ ├── state.spec.ts │ │ │ │ │ ├── style.spec.ts │ │ │ │ │ ├── symbol.spec.ts │ │ │ │ │ ├── text.spec.ts │ │ │ │ │ ├── theme.spec.ts │ │ │ │ │ ├── traverse.spec.ts │ │ │ │ │ ├── tree.spec.ts │ │ │ │ │ ├── vector.spec.ts │ │ │ │ │ ├── visibility.spec.ts │ │ │ │ │ └── z-index.spec.ts │ │ │ │ └── version.spec.ts │ │ │ └── utils/ │ │ │ ├── canvas.ts │ │ │ ├── create.ts │ │ │ ├── dir.ts │ │ │ ├── dom.ts │ │ │ ├── index.ts │ │ │ ├── offscreen-canvas-context.ts │ │ │ ├── random.ts │ │ │ ├── sleep.ts │ │ │ ├── svg-transformer.js │ │ │ ├── to-be-close-to.ts │ │ │ ├── to-match-svg-snapshot.ts │ │ │ └── use-snapshot-matchers.ts │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── perf.config.js │ │ ├── rollup.config.mjs │ │ ├── scripts/ │ │ │ ├── tag.mjs │ │ │ └── version.mjs │ │ ├── src/ │ │ │ ├── animations/ │ │ │ │ ├── executor.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── behaviors/ │ │ │ │ ├── auto-adapt-label.ts │ │ │ │ ├── base-behavior.ts │ │ │ │ ├── brush-select.ts │ │ │ │ ├── click-select.ts │ │ │ │ ├── collapse-expand.ts │ │ │ │ ├── create-edge.ts │ │ │ │ ├── drag-canvas.ts │ │ │ │ ├── drag-element-force.ts │ │ │ │ ├── drag-element.ts │ │ │ │ ├── fix-element-size.ts │ │ │ │ ├── focus-element.ts │ │ │ │ ├── hover-activate.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lasso-select.ts │ │ │ │ ├── optimize-viewport-transform.ts │ │ │ │ ├── scroll-canvas.ts │ │ │ │ ├── types.ts │ │ │ │ └── zoom-canvas.ts │ │ │ ├── constants/ │ │ │ │ ├── animation.ts │ │ │ │ ├── change.ts │ │ │ │ ├── element.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── animation.ts │ │ │ │ │ ├── canvas.ts │ │ │ │ │ ├── combo.ts │ │ │ │ │ ├── common.ts │ │ │ │ │ ├── container.ts │ │ │ │ │ ├── edge.ts │ │ │ │ │ ├── graph.ts │ │ │ │ │ ├── history.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── node.ts │ │ │ │ ├── graphlib.ts │ │ │ │ ├── index.ts │ │ │ │ └── registry.ts │ │ │ ├── elements/ │ │ │ │ ├── base-element.ts │ │ │ │ ├── combos/ │ │ │ │ │ ├── base-combo.ts │ │ │ │ │ ├── circle.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rect.ts │ │ │ │ ├── edges/ │ │ │ │ │ ├── base-edge.ts │ │ │ │ │ ├── cubic-horizontal.ts │ │ │ │ │ ├── cubic-radial.ts │ │ │ │ │ ├── cubic-vertical.ts │ │ │ │ │ ├── cubic.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── line.ts │ │ │ │ │ ├── polyline.ts │ │ │ │ │ └── quadratic.ts │ │ │ │ ├── effect.ts │ │ │ │ ├── index.ts │ │ │ │ ├── nodes/ │ │ │ │ │ ├── base-node.ts │ │ │ │ │ ├── circle.ts │ │ │ │ │ ├── diamond.ts │ │ │ │ │ ├── donut.ts │ │ │ │ │ ├── ellipse.ts │ │ │ │ │ ├── hexagon.ts │ │ │ │ │ ├── html.ts │ │ │ │ │ ├── image.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rect.ts │ │ │ │ │ ├── star.ts │ │ │ │ │ └── triangle.ts │ │ │ │ └── shapes/ │ │ │ │ ├── badge.ts │ │ │ │ ├── base-shape.ts │ │ │ │ ├── contour.ts │ │ │ │ ├── icon.ts │ │ │ │ ├── image.ts │ │ │ │ ├── index.ts │ │ │ │ ├── label.ts │ │ │ │ └── polygon.ts │ │ │ ├── exports.ts │ │ │ ├── global.d.ts │ │ │ ├── index.ts │ │ │ ├── layouts/ │ │ │ │ ├── base-layout.ts │ │ │ │ ├── fishbone.ts │ │ │ │ ├── index.ts │ │ │ │ ├── snake.ts │ │ │ │ └── types.ts │ │ │ ├── palettes/ │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── plugins/ │ │ │ │ ├── background/ │ │ │ │ │ └── index.ts │ │ │ │ ├── base-plugin.ts │ │ │ │ ├── bubble-sets.ts │ │ │ │ ├── camera-setting.ts │ │ │ │ ├── contextmenu/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── edge-bundling/ │ │ │ │ │ └── index.ts │ │ │ │ ├── edge-filter-lens/ │ │ │ │ │ └── index.ts │ │ │ │ ├── fisheye/ │ │ │ │ │ └── index.ts │ │ │ │ ├── fullscreen/ │ │ │ │ │ └── index.ts │ │ │ │ ├── grid-line.ts │ │ │ │ ├── history/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── hull/ │ │ │ │ │ ├── hull/ │ │ │ │ │ │ ├── format.ts │ │ │ │ │ │ ├── grid_handle.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── monotone-convex-hull-2d.ts │ │ │ │ │ │ ├── robust-orientation.ts │ │ │ │ │ │ ├── robust-scale.ts │ │ │ │ │ │ ├── robust-segment-intersect.ts │ │ │ │ │ │ ├── robust-subtract.ts │ │ │ │ │ │ ├── robust-sum.ts │ │ │ │ │ │ ├── two-product.ts │ │ │ │ │ │ └── two-sum.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── index.ts │ │ │ │ ├── legend.ts │ │ │ │ ├── minimap/ │ │ │ │ │ └── index.ts │ │ │ │ ├── snapline/ │ │ │ │ │ └── index.ts │ │ │ │ ├── timebar.ts │ │ │ │ ├── title/ │ │ │ │ │ └── index.ts │ │ │ │ ├── toolbar/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── util.ts │ │ │ │ ├── tooltip.ts │ │ │ │ ├── types.ts │ │ │ │ ├── utils/ │ │ │ │ │ ├── canvas.ts │ │ │ │ │ └── dom.ts │ │ │ │ └── watermark/ │ │ │ │ ├── index.ts │ │ │ │ └── util.ts │ │ │ ├── preset.ts │ │ │ ├── registry/ │ │ │ │ ├── build-in.ts │ │ │ │ ├── extension/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── get.ts │ │ │ │ ├── register.ts │ │ │ │ ├── store.ts │ │ │ │ └── types.ts │ │ │ ├── runtime/ │ │ │ │ ├── animation.ts │ │ │ │ ├── batch.ts │ │ │ │ ├── behavior.ts │ │ │ │ ├── canvas.ts │ │ │ │ ├── data.ts │ │ │ │ ├── element.ts │ │ │ │ ├── graph.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── options.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── transform.ts │ │ │ │ ├── types.ts │ │ │ │ └── viewport.ts │ │ │ ├── spec/ │ │ │ │ ├── behavior.ts │ │ │ │ ├── canvas.ts │ │ │ │ ├── data.ts │ │ │ │ ├── element/ │ │ │ │ │ ├── animation.ts │ │ │ │ │ ├── combo.ts │ │ │ │ │ ├── edge.ts │ │ │ │ │ ├── node.ts │ │ │ │ │ └── palette.ts │ │ │ │ ├── graph.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── theme.ts │ │ │ │ ├── transform.ts │ │ │ │ └── viewport.ts │ │ │ ├── themes/ │ │ │ │ ├── base.ts │ │ │ │ ├── dark.ts │ │ │ │ ├── index.ts │ │ │ │ ├── light.ts │ │ │ │ └── types.ts │ │ │ ├── transforms/ │ │ │ │ ├── arrange-draw-order.ts │ │ │ │ ├── base-transform.ts │ │ │ │ ├── collapse-expand-combo.ts │ │ │ │ ├── collapse-expand-node.ts │ │ │ │ ├── get-edge-actual-ends.ts │ │ │ │ ├── index.ts │ │ │ │ ├── map-node-size.ts │ │ │ │ ├── place-radial-labels.ts │ │ │ │ ├── process-parallel-edges.ts │ │ │ │ ├── types.ts │ │ │ │ ├── update-related-edge.ts │ │ │ │ └── utils.ts │ │ │ ├── types/ │ │ │ │ ├── anchor.ts │ │ │ │ ├── animation.ts │ │ │ │ ├── canvas.ts │ │ │ │ ├── centrality.ts │ │ │ │ ├── change.ts │ │ │ │ ├── combo.ts │ │ │ │ ├── data.ts │ │ │ │ ├── edge.ts │ │ │ │ ├── element.ts │ │ │ │ ├── enum.ts │ │ │ │ ├── event.ts │ │ │ │ ├── graphlib.ts │ │ │ │ ├── history.ts │ │ │ │ ├── id.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── node.ts │ │ │ │ ├── padding.ts │ │ │ │ ├── placement.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── point.ts │ │ │ │ ├── prefix.ts │ │ │ │ ├── router.ts │ │ │ │ ├── size.ts │ │ │ │ ├── state.ts │ │ │ │ ├── style.ts │ │ │ │ ├── tree.ts │ │ │ │ ├── utility.ts │ │ │ │ ├── vector.ts │ │ │ │ └── viewport.ts │ │ │ ├── utils/ │ │ │ │ ├── anchor.ts │ │ │ │ ├── animation.ts │ │ │ │ ├── array.ts │ │ │ │ ├── bbox.ts │ │ │ │ ├── cache.ts │ │ │ │ ├── centrality.ts │ │ │ │ ├── change.ts │ │ │ │ ├── collapsibility.ts │ │ │ │ ├── data.ts │ │ │ │ ├── diff.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── edge.ts │ │ │ │ ├── element.ts │ │ │ │ ├── event/ │ │ │ │ │ ├── events.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── extension.ts │ │ │ │ ├── graphlib.ts │ │ │ │ ├── id.ts │ │ │ │ ├── is.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── line.ts │ │ │ │ ├── math.ts │ │ │ │ ├── node.ts │ │ │ │ ├── padding.ts │ │ │ │ ├── palette.ts │ │ │ │ ├── path.ts │ │ │ │ ├── pinch.ts │ │ │ │ ├── placement.ts │ │ │ │ ├── point.ts │ │ │ │ ├── polygon.ts │ │ │ │ ├── position.ts │ │ │ │ ├── prefix.ts │ │ │ │ ├── print.ts │ │ │ │ ├── relation.ts │ │ │ │ ├── router/ │ │ │ │ │ ├── orth.ts │ │ │ │ │ └── shortest-path.ts │ │ │ │ ├── scale.ts │ │ │ │ ├── shape.ts │ │ │ │ ├── shortcut.ts │ │ │ │ ├── size.ts │ │ │ │ ├── state.ts │ │ │ │ ├── style.ts │ │ │ │ ├── symbol.ts │ │ │ │ ├── text.ts │ │ │ │ ├── theme.ts │ │ │ │ ├── transform.ts │ │ │ │ ├── traverse.ts │ │ │ │ ├── tree.ts │ │ │ │ ├── vector.ts │ │ │ │ ├── visibility.ts │ │ │ │ └── z-index.ts │ │ │ └── version.ts │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ ├── tsdoc.json │ │ └── vite.config.js │ ├── g6-extension-3d/ │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── .eslintrc │ │ │ ├── dataset/ │ │ │ │ ├── cubic.json │ │ │ │ └── force-3d.json │ │ │ ├── demos/ │ │ │ │ ├── behavior-drag-canvas.ts │ │ │ │ ├── behavior-observe-canvas.ts │ │ │ │ ├── behavior-roll-canvas.ts │ │ │ │ ├── behavior-zoom-canvas.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layer-top.ts │ │ │ │ ├── layout-d3-force-3d.ts │ │ │ │ ├── massive-elements.ts │ │ │ │ ├── position.ts │ │ │ │ ├── shapes.ts │ │ │ │ ├── solar-system.ts │ │ │ │ └── switch-renderer.ts │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── types.d.ts │ │ │ └── unit/ │ │ │ ├── default.spec.ts │ │ │ └── utils/ │ │ │ ├── cache.spec.ts │ │ │ ├── geometry.spec.ts │ │ │ ├── map.spec.ts │ │ │ ├── material.spec.ts │ │ │ └── texture.spec.ts │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── rollup.config.mjs │ │ ├── src/ │ │ │ ├── behaviors/ │ │ │ │ ├── drag-canvas-3d.ts │ │ │ │ ├── index.ts │ │ │ │ ├── observe-canvas-3d.ts │ │ │ │ ├── roll-canvas-3d.ts │ │ │ │ └── zoom-canvas-3d.ts │ │ │ ├── elements/ │ │ │ │ ├── base-node-3d.ts │ │ │ │ ├── capsule.ts │ │ │ │ ├── cone.ts │ │ │ │ ├── cube.ts │ │ │ │ ├── cylinder.ts │ │ │ │ ├── index.ts │ │ │ │ ├── line-3d.ts │ │ │ │ ├── plane.ts │ │ │ │ ├── sphere.ts │ │ │ │ └── torus.ts │ │ │ ├── exports.ts │ │ │ ├── index.ts │ │ │ ├── plugins/ │ │ │ │ ├── index.ts │ │ │ │ └── light.ts │ │ │ ├── renderer.ts │ │ │ ├── types/ │ │ │ │ ├── index.ts │ │ │ │ └── material.ts │ │ │ └── utils/ │ │ │ ├── cache.ts │ │ │ ├── geometry.ts │ │ │ ├── map.ts │ │ │ ├── material.ts │ │ │ └── texture.ts │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── vite.config.js │ ├── g6-extension-react/ │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── .eslintrc │ │ │ ├── dataset/ │ │ │ │ └── euro-cup.json │ │ │ ├── demos/ │ │ │ │ ├── euro-cup.tsx │ │ │ │ ├── graph.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── performance-diagnosis.tsx │ │ │ │ └── react-node.tsx │ │ │ ├── graph.tsx │ │ │ ├── index.html │ │ │ ├── main.tsx │ │ │ └── unit/ │ │ │ ├── attribute-changed-callback.spec.tsx │ │ │ └── default.spec.ts │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── rollup.config.mjs │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── react-node/ │ │ │ ├── index.ts │ │ │ ├── node.tsx │ │ │ ├── render.ts │ │ │ ├── render16.ts │ │ │ └── render18.ts │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── vite.config.js │ ├── g6-ssr/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── __tests__/ │ │ │ ├── graph-options.json │ │ │ └── graph.spec.ts │ │ ├── bin/ │ │ │ └── g6-ssr.js │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── rollup.config.mjs │ │ ├── src/ │ │ │ ├── canvas.ts │ │ │ ├── graph.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ └── tsconfig.test.json │ └── site/ │ ├── .dumi/ │ │ ├── app.tsx │ │ ├── global.less │ │ ├── global.ts │ │ └── tsconfig.json │ ├── .dumirc.ts │ ├── .github/ │ │ └── workflows/ │ │ └── mirror.yml │ ├── .gitignore │ ├── CNAME │ ├── api-extractor.json │ ├── common/ │ │ ├── angular-snippet.md │ │ ├── api/ │ │ │ ├── behaviors/ │ │ │ │ ├── auto-adapt-label.md │ │ │ │ ├── brush-select.md │ │ │ │ ├── click-element.md │ │ │ │ ├── collapse-expand.md │ │ │ │ ├── create-edge.md │ │ │ │ ├── drag-canvas.md │ │ │ │ ├── drag-element.md │ │ │ │ ├── fix-element-size.md │ │ │ │ ├── focus-element.md │ │ │ │ ├── hover-activate.md │ │ │ │ ├── hover-element.md │ │ │ │ ├── lasso-select.md │ │ │ │ ├── scroll-canvas.md │ │ │ │ └── zoom-canvas.md │ │ │ ├── elements/ │ │ │ │ ├── combos/ │ │ │ │ │ ├── base-combo.md │ │ │ │ │ ├── circle-combo-interest.md │ │ │ │ │ ├── circle-combo.md │ │ │ │ │ ├── rect-combo-architecture.md │ │ │ │ │ └── rect-combo.md │ │ │ │ ├── edges/ │ │ │ │ │ ├── base-edge.md │ │ │ │ │ ├── cubic-horizontal.md │ │ │ │ │ ├── cubic-vertical.md │ │ │ │ │ ├── cubic.md │ │ │ │ │ ├── line.md │ │ │ │ │ ├── polyline.md │ │ │ │ │ └── quadratic.md │ │ │ │ └── nodes/ │ │ │ │ ├── base-node.md │ │ │ │ ├── circle.md │ │ │ │ ├── diamond.md │ │ │ │ ├── donut.md │ │ │ │ ├── ellipse.md │ │ │ │ ├── hexagon.md │ │ │ │ ├── html.md │ │ │ │ ├── image.md │ │ │ │ ├── rect.md │ │ │ │ ├── star.md │ │ │ │ └── triangle.md │ │ │ ├── layout/ │ │ │ │ ├── fishbone.md │ │ │ │ └── force-atlas2.md │ │ │ ├── layouts/ │ │ │ │ ├── radial.md │ │ │ │ └── snake.md │ │ │ ├── plugins/ │ │ │ │ ├── background.md │ │ │ │ ├── bubble-sets.md │ │ │ │ ├── contextmenu.md │ │ │ │ ├── edge-bundling.md │ │ │ │ ├── edge-filter-lens.md │ │ │ │ ├── fisheye.md │ │ │ │ ├── fullscreen.md │ │ │ │ ├── grid-line.md │ │ │ │ ├── history.md │ │ │ │ ├── hull.md │ │ │ │ ├── legend.md │ │ │ │ ├── minimap.md │ │ │ │ ├── snapline.md │ │ │ │ ├── timebar.md │ │ │ │ ├── toolbar.md │ │ │ │ ├── tooltip.md │ │ │ │ └── watermark.md │ │ │ └── transforms/ │ │ │ ├── map-node-size-centrality.md │ │ │ └── map-node-size-scale.md │ │ ├── manual/ │ │ │ ├── core-concept/ │ │ │ │ ├── animation/ │ │ │ │ │ ├── ant-line.md │ │ │ │ │ └── breathing-circle.md │ │ │ │ └── palette/ │ │ │ │ ├── continuous-palette.md │ │ │ │ ├── default-config.md │ │ │ │ └── standard-config.md │ │ │ ├── custom-extension/ │ │ │ │ ├── animation/ │ │ │ │ │ ├── composite-animation-1.md │ │ │ │ │ ├── composite-animation-2.md │ │ │ │ │ └── implement-animation.md │ │ │ │ ├── behavior/ │ │ │ │ │ └── implement-behaviors.md │ │ │ │ ├── layout/ │ │ │ │ │ ├── iterative-layout.md │ │ │ │ │ └── non-iterative-layout.md │ │ │ │ ├── plugin/ │ │ │ │ │ └── implement-plugin.md │ │ │ │ └── transform/ │ │ │ │ ├── circular-radial-labels.md │ │ │ │ └── hide-free-node.md │ │ │ ├── feature/ │ │ │ │ ├── treeToGraphData.md │ │ │ │ └── webpack4.md │ │ │ └── getting-started/ │ │ │ ├── extensions/ │ │ │ │ └── palettes.md │ │ │ ├── quick-start/ │ │ │ │ └── simple-graph.md │ │ │ └── step-by-step/ │ │ │ ├── behaviors.md │ │ │ ├── create-chart.md │ │ │ ├── elements-1.md │ │ │ ├── elements-2.md │ │ │ ├── layout.md │ │ │ ├── palette.md │ │ │ ├── plugins-1.md │ │ │ └── plugins-2.md │ │ ├── react-snippet-strict.md │ │ ├── react-snippet.md │ │ └── vue-snippet.md │ ├── docs/ │ │ ├── api/ │ │ │ ├── behavior.en.md │ │ │ ├── behavior.zh.md │ │ │ ├── canvas.en.md │ │ │ ├── canvas.zh.md │ │ │ ├── coordinate.en.md │ │ │ ├── coordinate.zh.md │ │ │ ├── data.en.md │ │ │ ├── data.zh.md │ │ │ ├── element.en.md │ │ │ ├── element.zh.md │ │ │ ├── event.en.md │ │ │ ├── event.zh.md │ │ │ ├── export-image.en.md │ │ │ ├── export-image.zh.md │ │ │ ├── graph.en.md │ │ │ ├── graph.zh.md │ │ │ ├── layout.en.md │ │ │ ├── layout.zh.md │ │ │ ├── option.en.md │ │ │ ├── option.zh.md │ │ │ ├── plugin.en.md │ │ │ ├── plugin.zh.md │ │ │ ├── render.en.md │ │ │ ├── render.zh.md │ │ │ ├── theme.en.md │ │ │ ├── theme.zh.md │ │ │ ├── transform.en.md │ │ │ ├── transform.zh.md │ │ │ ├── viewport.en.md │ │ │ └── viewport.zh.md │ │ ├── backup/ │ │ │ ├── CameraSetting.en.md │ │ │ └── CameraSetting.zh.md │ │ └── manual/ │ │ ├── animation/ │ │ │ ├── animation.en.md │ │ │ ├── animation.zh.md │ │ │ ├── custom-animation.en.md │ │ │ └── custom-animation.zh.md │ │ ├── behavior/ │ │ │ ├── AutoAdaptLabel.en.md │ │ │ ├── AutoAdaptLabel.zh.md │ │ │ ├── BrushSelect.en.md │ │ │ ├── BrushSelect.zh.md │ │ │ ├── ClickSelect.en.md │ │ │ ├── ClickSelect.zh.md │ │ │ ├── CollapseExpand.en.md │ │ │ ├── CollapseExpand.zh.md │ │ │ ├── CreateEdge.en.md │ │ │ ├── CreateEdge.zh.md │ │ │ ├── DragCanvas.en.md │ │ │ ├── DragCanvas.zh.md │ │ │ ├── DragElement.en.md │ │ │ ├── DragElement.zh.md │ │ │ ├── DragElementForce.en.md │ │ │ ├── DragElementForce.zh.md │ │ │ ├── FixElementSize.en.md │ │ │ ├── FixElementSize.zh.md │ │ │ ├── FocusElement.en.md │ │ │ ├── FocusElement.zh.md │ │ │ ├── HoverActivate.en.md │ │ │ ├── HoverActivate.zh.md │ │ │ ├── LassoSelect.en.md │ │ │ ├── LassoSelect.zh.md │ │ │ ├── OptimizeViewportTransform.en.md │ │ │ ├── OptimizeViewportTransform.zh.md │ │ │ ├── ScrollCanvas.en.md │ │ │ ├── ScrollCanvas.zh.md │ │ │ ├── ZoomCanvas.en.md │ │ │ ├── ZoomCanvas.zh.md │ │ │ ├── custom-behavior.en.md │ │ │ ├── custom-behavior.zh.md │ │ │ ├── overview.en.md │ │ │ └── overview.zh.md │ │ ├── contribute.en.md │ │ ├── contribute.zh.md │ │ ├── data.en.md │ │ ├── data.zh.md │ │ ├── element/ │ │ │ ├── combo/ │ │ │ │ ├── BaseCombo.en.md │ │ │ │ ├── BaseCombo.zh.md │ │ │ │ ├── CircleCombo.en.md │ │ │ │ ├── CircleCombo.zh.md │ │ │ │ ├── RectCombo.en.md │ │ │ │ ├── RectCombo.zh.md │ │ │ │ ├── custom-combo.en.md │ │ │ │ ├── custom-combo.zh.md │ │ │ │ ├── overview.en.md │ │ │ │ └── overview.zh.md │ │ │ ├── edge/ │ │ │ │ ├── BaseEdge.en.md │ │ │ │ ├── BaseEdge.zh.md │ │ │ │ ├── Cubic.en.md │ │ │ │ ├── Cubic.zh.md │ │ │ │ ├── CubicHorizontal.en.md │ │ │ │ ├── CubicHorizontal.zh.md │ │ │ │ ├── CubicVertical.en.md │ │ │ │ ├── CubicVertical.zh.md │ │ │ │ ├── Line.en.md │ │ │ │ ├── Line.zh.md │ │ │ │ ├── Polyline.en.md │ │ │ │ ├── Polyline.zh.md │ │ │ │ ├── Quadratic.en.md │ │ │ │ ├── Quadratic.zh.md │ │ │ │ ├── custom-edge.en.md │ │ │ │ ├── custom-edge.zh.md │ │ │ │ ├── overview.en.md │ │ │ │ └── overview.zh.md │ │ │ ├── node/ │ │ │ │ ├── BaseNode.en.md │ │ │ │ ├── BaseNode.zh.md │ │ │ │ ├── Circle.en.md │ │ │ │ ├── Circle.zh.md │ │ │ │ ├── Diamond.en.md │ │ │ │ ├── Diamond.zh.md │ │ │ │ ├── Donut.en.md │ │ │ │ ├── Donut.zh.md │ │ │ │ ├── Ellipse.en.md │ │ │ │ ├── Ellipse.zh.md │ │ │ │ ├── Hexagon.en.md │ │ │ │ ├── Hexagon.zh.md │ │ │ │ ├── Html.en.md │ │ │ │ ├── Html.zh.md │ │ │ │ ├── Image.en.md │ │ │ │ ├── Image.zh.md │ │ │ │ ├── Rect.en.md │ │ │ │ ├── Rect.zh.md │ │ │ │ ├── Star.en.md │ │ │ │ ├── Star.zh.md │ │ │ │ ├── Triangle.en.md │ │ │ │ ├── Triangle.zh.md │ │ │ │ ├── custom-node.en.md │ │ │ │ ├── custom-node.zh.md │ │ │ │ ├── overview.en.md │ │ │ │ ├── overview.zh.md │ │ │ │ ├── react-node.en.md │ │ │ │ ├── react-node.zh.md │ │ │ │ ├── vue-node.en.md │ │ │ │ └── vue-node.zh.md │ │ │ ├── overview.en.md │ │ │ ├── overview.zh.md │ │ │ ├── shape/ │ │ │ │ ├── label-shape.en.md │ │ │ │ ├── label-shape.zh.md │ │ │ │ ├── overview.en.md │ │ │ │ ├── overview.zh.md │ │ │ │ ├── properties.en.md │ │ │ │ └── properties.zh.md │ │ │ ├── state.en.md │ │ │ └── state.zh.md │ │ ├── extension/ │ │ │ ├── 3d.en.md │ │ │ └── 3d.zh.md │ │ ├── faq.en.md │ │ ├── faq.zh.md │ │ ├── further-reading/ │ │ │ ├── 3d.en.md │ │ │ ├── 3d.zh.md │ │ │ ├── bundle.en.md │ │ │ ├── bundle.zh.md │ │ │ ├── coordinate.en.md │ │ │ ├── coordinate.zh.md │ │ │ ├── download-image.en.md │ │ │ ├── download-image.zh.md │ │ │ ├── event.en.md │ │ │ ├── event.zh.md │ │ │ ├── iconfont.en.md │ │ │ ├── iconfont.zh.md │ │ │ ├── renderer.en.md │ │ │ └── renderer.zh.md │ │ ├── getting-started/ │ │ │ ├── installation.en.md │ │ │ ├── installation.zh.md │ │ │ ├── integration/ │ │ │ │ ├── angular.en.md │ │ │ │ ├── angular.zh.md │ │ │ │ ├── react.en.md │ │ │ │ ├── react.zh.md │ │ │ │ ├── vue.en.md │ │ │ │ └── vue.zh.md │ │ │ ├── quick-start.en.md │ │ │ ├── quick-start.zh.md │ │ │ ├── step-by-step.en.md │ │ │ └── step-by-step.zh.md │ │ ├── graph/ │ │ │ ├── extension.en.md │ │ │ ├── extension.zh.md │ │ │ ├── extensions.en.md │ │ │ ├── extensions.zh.md │ │ │ ├── graph.en.md │ │ │ ├── graph.zh.md │ │ │ ├── option.en.md │ │ │ └── option.zh.md │ │ ├── introduction.en.md │ │ ├── introduction.zh.md │ │ ├── layout/ │ │ │ ├── AntvDagreLayout.en.md │ │ │ ├── AntvDagreLayout.zh.md │ │ │ ├── BaseLayout.en.md │ │ │ ├── BaseLayout.zh.md │ │ │ ├── CircularLayout.en.md │ │ │ ├── CircularLayout.zh.md │ │ │ ├── ComboCombinedLayout.en.md │ │ │ ├── ComboCombinedLayout.zh.md │ │ │ ├── CompactBoxLayout.en.md │ │ │ ├── CompactBoxLayout.zh.md │ │ │ ├── ConcentricLayout.en.md │ │ │ ├── ConcentricLayout.zh.md │ │ │ ├── D3Force3DLayout.en.md │ │ │ ├── D3Force3DLayout.zh.md │ │ │ ├── D3ForceLayout.en.md │ │ │ ├── D3ForceLayout.zh.md │ │ │ ├── DagreLayout.en.md │ │ │ ├── DagreLayout.zh.md │ │ │ ├── DendrogramLayout.en.md │ │ │ ├── DendrogramLayout.zh.md │ │ │ ├── Fishbone.en.md │ │ │ ├── Fishbone.zh.md │ │ │ ├── ForceAtlas2Layout.en.md │ │ │ ├── ForceAtlas2Layout.zh.md │ │ │ ├── ForceLayout.en.md │ │ │ ├── ForceLayout.zh.md │ │ │ ├── FruchtermanLayout.en.md │ │ │ ├── FruchtermanLayout.zh.md │ │ │ ├── GridLayout.en.md │ │ │ ├── GridLayout.zh.md │ │ │ ├── IndentedLayout.en.md │ │ │ ├── IndentedLayout.zh.md │ │ │ ├── MdsLayout.en.md │ │ │ ├── MdsLayout.zh.md │ │ │ ├── MindmapLayout.en.md │ │ │ ├── MindmapLayout.zh.md │ │ │ ├── RadialLayout.en.md │ │ │ ├── RadialLayout.zh.md │ │ │ ├── RandomLayout.en.md │ │ │ ├── RandomLayout.zh.md │ │ │ ├── Snake.en.md │ │ │ ├── Snake.zh.md │ │ │ ├── custom-layout.en.md │ │ │ ├── custom-layout.zh.md │ │ │ ├── overview.en.md │ │ │ └── overview.zh.md │ │ ├── plugin/ │ │ │ ├── Background.en.md │ │ │ ├── Background.zh.md │ │ │ ├── BubbleSets.en.md │ │ │ ├── BubbleSets.zh.md │ │ │ ├── Contextmenu.en.md │ │ │ ├── Contextmenu.zh.md │ │ │ ├── EdgeBundling.en.md │ │ │ ├── EdgeBundling.zh.md │ │ │ ├── EdgeFilterLens.en.md │ │ │ ├── EdgeFilterLens.zh.md │ │ │ ├── Fisheye.en.md │ │ │ ├── Fisheye.zh.md │ │ │ ├── Fullscreen.en.md │ │ │ ├── Fullscreen.zh.md │ │ │ ├── GridLine.en.md │ │ │ ├── GridLine.zh.md │ │ │ ├── History.en.md │ │ │ ├── History.zh.md │ │ │ ├── Hull.en.md │ │ │ ├── Hull.zh.md │ │ │ ├── Legend.en.md │ │ │ ├── Legend.zh.md │ │ │ ├── Minimap.en.md │ │ │ ├── Minimap.zh.md │ │ │ ├── Snapline.en.md │ │ │ ├── Snapline.zh.md │ │ │ ├── Timebar.en.md │ │ │ ├── Timebar.zh.md │ │ │ ├── Title.en.md │ │ │ ├── Title.zh.md │ │ │ ├── Toolbar.en.md │ │ │ ├── Toolbar.zh.md │ │ │ ├── Tooltip.en.md │ │ │ ├── Tooltip.zh.md │ │ │ ├── Watermark.en.md │ │ │ ├── Watermark.zh.md │ │ │ ├── custom-plugin.en.md │ │ │ ├── custom-plugin.zh.md │ │ │ ├── overview.en.md │ │ │ └── overview.zh.md │ │ ├── theme/ │ │ │ ├── custom-palette.en.md │ │ │ ├── custom-palette.zh.md │ │ │ ├── custom-theme.en.md │ │ │ ├── custom-theme.zh.md │ │ │ ├── overview.en.md │ │ │ ├── overview.zh.md │ │ │ ├── palette.en.md │ │ │ └── palette.zh.md │ │ ├── transform/ │ │ │ ├── MapNodeSize.en.md │ │ │ ├── MapNodeSize.zh.md │ │ │ ├── PlaceRadialLabels.en.md │ │ │ ├── PlaceRadialLabels.zh.md │ │ │ ├── ProcessParallelEdges.en.md │ │ │ ├── ProcessParallelEdges.zh.md │ │ │ ├── custom-transform.en.md │ │ │ ├── custom-transform.zh.md │ │ │ ├── overview.en.md │ │ │ └── overview.zh.md │ │ └── whats-new/ │ │ ├── feature.en.md │ │ ├── feature.zh.md │ │ ├── upgrade.en.md │ │ └── upgrade.zh.md │ ├── examples/ │ │ ├── algorithm/ │ │ │ └── case/ │ │ │ ├── demo/ │ │ │ │ ├── label-propagation.js │ │ │ │ ├── louvain.js │ │ │ │ ├── meta.json │ │ │ │ ├── pattern-matching.js │ │ │ │ └── shortest-path.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── animation/ │ │ │ ├── basic/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── combo-collapse-expand.js │ │ │ │ │ ├── enter-edge-path-in.js │ │ │ │ │ ├── enter.js │ │ │ │ │ ├── exit.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── update.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── persistence/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── ant-line.js │ │ │ │ │ ├── breathing-circle.js │ │ │ │ │ ├── fly-marker.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── path-in.js │ │ │ │ │ └── ripple-circle.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ └── viewport/ │ │ │ ├── demo/ │ │ │ │ ├── meta.json │ │ │ │ ├── rotate.js │ │ │ │ ├── translate.js │ │ │ │ └── zoom.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── behavior/ │ │ │ ├── auto-adapt-label/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── canvas/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── drag.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── optimize.js │ │ │ │ │ ├── scroll-and-zoom.js │ │ │ │ │ ├── scroll-xy.js │ │ │ │ │ ├── scroll-y.js │ │ │ │ │ └── zoom.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── combo/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── collapse-expand.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── create-edge/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── between-combos.js │ │ │ │ │ ├── by-click.js │ │ │ │ │ ├── by-drag.js │ │ │ │ │ ├── custom-edge-style.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── fix-element-size/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── autosize-label.js │ │ │ │ │ ├── fix-font-size.js │ │ │ │ │ ├── fix-size.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── focus/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── highlight-element/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── activate-relations.js │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── config-params.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── inner-event/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── select/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── brush-combo.js │ │ │ │ │ ├── brush.js │ │ │ │ │ ├── click.js │ │ │ │ │ ├── lasso.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ └── update-label/ │ │ │ ├── demo/ │ │ │ │ ├── meta.json │ │ │ │ └── update.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── element/ │ │ │ ├── combo/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── circle.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── rect.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── custom-combo/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── extra-button.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── custom-edge/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── custom-arrow.js │ │ │ │ │ ├── custom-path.js │ │ │ │ │ ├── extra-label.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── custom-node/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── g2-activity-chart.js │ │ │ │ │ ├── g2-bar-chart.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── react-node.jsx │ │ │ │ │ └── reactnode-idcard.jsx │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── edge/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── arrows.js │ │ │ │ │ ├── cubic.js │ │ │ │ │ ├── horizontal-cubic.js │ │ │ │ │ ├── line.js │ │ │ │ │ ├── loop-curve.js │ │ │ │ │ ├── loop-polyline.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── polyline-orth-with-cps.js │ │ │ │ │ ├── polyline-orth.js │ │ │ │ │ ├── polyline.js │ │ │ │ │ ├── quadratic.js │ │ │ │ │ └── vertical-cubic.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── label/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── background.js │ │ │ │ │ ├── copy.js │ │ │ │ │ ├── ellipsis.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── word-wrap.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ └── node/ │ │ │ ├── demo/ │ │ │ │ ├── 3d-node.js │ │ │ │ ├── circle.js │ │ │ │ ├── diamond.js │ │ │ │ ├── donut.js │ │ │ │ ├── ellipse.js │ │ │ │ ├── hexagon.js │ │ │ │ ├── html.js │ │ │ │ ├── image.js │ │ │ │ ├── meta.json │ │ │ │ ├── port.js │ │ │ │ ├── rect.js │ │ │ │ ├── rounded-rect.js │ │ │ │ ├── star.js │ │ │ │ └── triangle.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── examples.md │ │ ├── feature/ │ │ │ └── default/ │ │ │ ├── demo/ │ │ │ │ ├── 3d-massive.js │ │ │ │ ├── lite-solar-system.js │ │ │ │ ├── meta.json │ │ │ │ ├── theme.js │ │ │ │ └── unicorns-investors.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── layout/ │ │ │ ├── circular/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── degree.js │ │ │ │ │ ├── division.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── spiral.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── combo-layout/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── combo-combined.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── compact-box/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── radial.js │ │ │ │ │ └── vertical.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── concentric/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── custom/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── arc.js │ │ │ │ │ ├── bi-graph.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── dagre/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── antv-dagre-combo.js │ │ │ │ │ ├── antv-dagre.js │ │ │ │ │ ├── dagre.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── dendrogram/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── radial.js │ │ │ │ │ └── vertical.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── fishbone/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── force-directed/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── 3d-force.js │ │ │ │ │ ├── atlas2.js │ │ │ │ │ ├── bubbles.js │ │ │ │ │ ├── collision.js │ │ │ │ │ ├── d3-force.js │ │ │ │ │ ├── drag-fixed.js │ │ │ │ │ ├── force.js │ │ │ │ │ ├── functional-params.js │ │ │ │ │ ├── mesh.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── prevent-overlap.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── fruchterman/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── cluster.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── run-in-gpu.js │ │ │ │ │ └── run-in-web-worker.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── grid/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── indented/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── auto-side.js │ │ │ │ │ ├── custom-side.js │ │ │ │ │ ├── left-side.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── no-drop-cap.js │ │ │ │ │ └── right-side.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── mds/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── mechanism/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── change-data.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── switch.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── mindmap/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── auto-side.js │ │ │ │ │ ├── custom-side.js │ │ │ │ │ ├── left-side.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── right-side.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── radial/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── cluster-sort.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── non-strict-prevent-overlap.js │ │ │ │ │ └── strict-prevent-overlap.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── snake/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── gutter.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ └── sub-graph/ │ │ │ ├── demo/ │ │ │ │ ├── basic.js │ │ │ │ └── meta.json │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── performance/ │ │ │ └── massive-data/ │ │ │ ├── demo/ │ │ │ │ ├── 20000.js │ │ │ │ ├── 5000.js │ │ │ │ ├── 60000.js │ │ │ │ └── meta.json │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── plugin/ │ │ │ ├── background/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── background.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── bubble-sets/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── contextMenu/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── edge-bundling/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── edge-filter-lens/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── fisheye/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── custom.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── fullscreen/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── grid-line/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── history/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── hull/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── legend/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── click.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── style.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── minimap/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── snapline/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── autoSnap.js │ │ │ │ │ ├── basic.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── timebar/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── chart.js │ │ │ │ │ ├── meta.json │ │ │ │ │ └── time.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── toolbar/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── custom.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ ├── tooltip/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── basic.js │ │ │ │ │ ├── click.js │ │ │ │ │ ├── dual.js │ │ │ │ │ └── meta.json │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ └── watermark/ │ │ │ ├── demo/ │ │ │ │ ├── meta.json │ │ │ │ ├── repeat.js │ │ │ │ └── text.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ ├── scene-case/ │ │ │ ├── default/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── battle-array.jsx │ │ │ │ │ ├── fund-flow.js │ │ │ │ │ ├── meta.json │ │ │ │ │ ├── music-festival.js │ │ │ │ │ ├── organization-chart.js │ │ │ │ │ ├── performance-diagnosis-flowchart.js │ │ │ │ │ ├── snake-flow-diagram.js │ │ │ │ │ ├── sub-graph.js │ │ │ │ │ └── why-do-cats.js │ │ │ │ ├── index.en.md │ │ │ │ └── index.zh.md │ │ │ └── tree-graph/ │ │ │ ├── demo/ │ │ │ │ ├── anti-procrastination-fishbone.js │ │ │ │ ├── indented-tree.js │ │ │ │ ├── meta.json │ │ │ │ ├── mindmap.js │ │ │ │ ├── product-fishbone.js │ │ │ │ ├── radial-compact-tree.js │ │ │ │ └── radial-dendrogram.js │ │ │ ├── index.en.md │ │ │ └── index.zh.md │ │ └── transform/ │ │ └── process-parallel-edges/ │ │ ├── demo/ │ │ │ ├── bundle.js │ │ │ ├── merge.js │ │ │ └── meta.json │ │ ├── index.en.md │ │ └── index.zh.md │ ├── mako.config.json │ ├── package.json │ ├── scripts/ │ │ ├── clear-doc.ts │ │ ├── doc-template.mjs │ │ ├── extract-playground.js │ │ ├── generate-api.ts │ │ ├── generate-doc.ts │ │ ├── rewrite-ob.ts │ │ └── sort-doc.ts │ ├── src/ │ │ ├── MarkdownDocumenter.ts │ │ ├── constants/ │ │ │ ├── index.ts │ │ │ ├── link.ts │ │ │ └── locales/ │ │ │ ├── api-category.json │ │ │ ├── element.json │ │ │ ├── enum.ts │ │ │ ├── helper.json │ │ │ ├── index.ts │ │ │ ├── keyword.json │ │ │ └── page-name.json │ │ ├── markdown/ │ │ │ ├── CustomMarkdownEmitter.ts │ │ │ └── MarkdownEmitter.ts │ │ ├── nodes/ │ │ │ ├── CustomDocNodeKind.ts │ │ │ ├── DocContainer.ts │ │ │ ├── DocDetails.ts │ │ │ ├── DocEmphasisSpan.ts │ │ │ ├── DocHeading.ts │ │ │ ├── DocNoteBox.ts │ │ │ ├── DocPageTitle.ts │ │ │ ├── DocTable.ts │ │ │ ├── DocTableCell.ts │ │ │ ├── DocTableRow.ts │ │ │ ├── DocText.ts │ │ │ └── DocUnorderedList.ts │ │ └── utils/ │ │ ├── IndentedWriter.ts │ │ ├── Utilities.ts │ │ ├── excerpt-token.ts │ │ ├── gitignore.ts │ │ └── parser.ts │ └── tsconfig.json ├── playwright.config.ts ├── pnpm-workspace.yaml ├── scripts/ │ ├── demo-to-test/ │ │ ├── core/ │ │ │ ├── global.js │ │ │ ├── index.js │ │ │ ├── parser.js │ │ │ └── utils.js │ │ ├── index.js │ │ └── template/ │ │ ├── it.js │ │ ├── spec.js │ │ └── test.js │ └── version.sh ├── tests/ │ └── g6/ │ ├── elements/ │ │ └── node-element.spec.ts │ └── plugins/ │ ├── plugins-minimap.spec.ts │ └── plugins-tooltip.spec.ts ├── tsconfig.json └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json", "changelog": false, "commit": false, "fixed": [], "linked": [], "access": "restricted", "baseBranch": "v5", "updateInternalDependencies": "patch", "ignore": ["@antv/g6-site", "bundle"] } ================================================ FILE: .codecov.yml ================================================ # Setting coverage targets per flag coverage: round: down range: 60..90 precision: 2 status: patch: off project: default: off g6: threshold: 1% flags: - g6 flags: g6: paths: # filter the folder(s) you wish to measure by that flag - packages/g6 comment: layout: "reach, diff, flags, files" behavior: default require_changes: true # only post the comment if coverage changes github_checks: annotations: false flag_management: default_rules: carryforward: false ================================================ FILE: .commitlintrc.js ================================================ module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ 2, 'always', ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'wip'], ], }, }; ================================================ FILE: .cursor/rules/translation.mdc ================================================ --- description: 翻译 globs: alwaysApply: false --- # Translation Guidelines for site/docs When translating files under the `site/docs` directory, please adhere to the following guidelines: 1. **Consistency in Terminology**: Ensure that terminology is consistent throughout the document. Use a glossary if available to maintain uniformity in terms. **Glossary**: - 画布 (Canvas) - 元素 (Element) - 节点 (Node) - 边 (Edge) - 组合 (Combo) - 交互 (Behavior) - 布局 (Layout) - 插件 (Plugin) - 动画 (Animation) - 数据处理 (Transform) - 色板 (Palette) - 配置项 (Option) - 图数据 (Graph Data) - 树图 (Tree Graph) - 属性 (Property) - 描述 (Description) - 类型 (Type) - 默认值 (Default Value) - 必选 (Required) 2. **Adjust Hyperlinks**: Review and adjust hyperlinks to ensure they point to the correct translated sections or documents. Verify that all links are functional and correctly formatted. - **Internal Links**: In the English version, all internal links should have a `/en` prefix, while the Chinese version should not have any prefix. Ensure this prefix is added to all internal links in English documents to avoid any oversight. - **Anchor Points**: For anchor points following a `#`, if they contain Chinese characters, they should be adjusted to match the corresponding title in the English version rather than being directly translated. - **External Links**: Convert external links appropriately to ensure they align with the language and context of the document. 3. **Direct Writing to Translated Documents**: Translations should be stored in corresponding `.en.md` or `.zh.md` files within the same directory. Ensure that the translated content is placed in the correct location within the document. - When translating from Chinese to English, create or update the `.en.md` file in the same directory. - When translating from English to Chinese, create or update the `.zh.md` file in the same directory. 4. **Support for Partial Content Translation**: Allow for the selection and translation of specific sections of content. Translated sections should be inserted into the appropriate location within the document, maintaining the logical flow and structure. - **Full Document Translation**: If the entire document is selected for translation, replace the entire content with the translated version. - **Partial Content Translation**: If only specific sections are selected, find the appropriate place to replace or insert the translated content, ensuring the document's logical flow and structure are maintained. 5. **Contextual Translation**: Avoid literal translations. Ensure that the translation fits the English context and conveys the intended meaning accurately. 6. **Direct Modification**: Translations should be directly modified in the corresponding `.en.md` or `.zh.md` files without returning the translated content separately. Ensure that the changes are saved in the correct file and location. 7. **Preserve Metadata Order**: Do not modify the `order` attribute in the page metadata during translation. This ensures that the document order remains consistent across different language versions. 8. **Add '/en' Prefix to Internal Links**: Ensure that all internal links in English documentation have the '/en' prefix to maintain consistency and correct navigation. By following these guidelines, translations will be more accurate and consistent, facilitating easier review and integration into the documentation. ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [Makefile] indent_style = tab ================================================ FILE: .eslintignore ================================================ dist es lib node_modules ================================================ FILE: .eslintrc.js ================================================ module.exports = { root: true, env: { browser: true, es2021: true, node: true, commonjs: true, jest: true, }, extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:jsdoc/recommended-error'], overrides: [ { env: { node: true, }, files: ['.eslintrc.{js,cjs}'], parserOptions: { sourceType: 'script', }, }, { files: ['**/__tests__/**', '*.js', '*.mjs'], rules: { 'jsdoc/require-jsdoc': 0, }, }, { files: ['./packages/g6/src/plugins/hull/!(index).ts', '*.js', '*.mjs', '*.ts'], rules: { 'jsdoc/require-jsdoc': 0, }, }, { files: ['**/demo-to-test/**'], rules: { '@typescript-eslint/no-var-requires': 'off', }, }, { files: ['./packages/site/**', './scripts/**'], rules: { 'no-console': 'off', }, }, ], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module', }, plugins: ['@typescript-eslint', 'jsdoc'], rules: { // indent: ['error', 2, { SwitchCase: 1 }], 'linebreak-style': ['error', 'unix'], quotes: ['error', 'single', { allowTemplateLiterals: true, avoidEscape: true }], semi: ['error', 'always'], '@typescript-eslint/no-unused-vars': ['warn', { ignoreRestSiblings: true }], 'jsdoc/require-param-type': 0, '@typescript-eslint/no-this-alias': 'off', 'no-console': 'error', // TODO: rules below will be set to 2 in the future 'jsdoc/require-jsdoc': 1, 'jsdoc/check-access': 1, 'jsdoc/valid-types': 0, /** * js plugin rules */ 'jsdoc/check-tag-names': [ 'error', { // Allow TSDoc tags @remarks, @defaultValue // Custom tags: @apiCategory for Graph API definedTags: ['remarks', 'defaultValue', 'apiCategory'], }, ], 'jsdoc/require-description': 1, 'jsdoc/require-param': 1, 'jsdoc/check-param-names': 1, 'jsdoc/require-param-description': 1, 'jsdoc/require-returns': 1, 'jsdoc/require-returns-type': 0, 'jsdoc/require-returns-description': 1, // TODO: rules below are not recommended, and will be removed in the future '@typescript-eslint/no-explicit-any': 1, '@typescript-eslint/ban-types': 1, '@typescript-eslint/ban-ts-comment': 1, }, }; ================================================ FILE: .github/ISSUE_TEMPLATE/1.bug_report.yml ================================================ name: '🐞 Bug Report' description: Create a report to help us improve, Ask questions in Discussions / 创建一个问题报告以帮助我们改进,提问请到 Discussions title: '[Bug]: ' labels: ['waiting for maintainer'] body: - type: markdown attributes: value: | Report errors and exceptions found in the project. Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already: --- 反馈在项目中发现的错误、异常。 在提交新 issue 之前,先通过以下链接检查是否存在相同问题: > [Issues](../issues) | [Closed Issues](../issues?q=is:issue+is:closed) | [Discussions](../discussions) - type: textarea id: description attributes: label: Describe the bug / 问题描述 placeholder: | If there is a code block, please use Markdown syntax, such as: 如包含代码块,请使用 Markdown 语法,如: ```javascript // Your code here ``` validations: required: true - type: input id: link attributes: label: Reproduction link / 复现链接 placeholder: | CodeSandbox / StackBlitz / ... validations: required: false - type: textarea id: steps attributes: label: Steps to Reproduce the Bug or Issue / 重现步骤 validations: required: false - type: dropdown id: version attributes: label: Version / 版本 options: - Please select / 请选择 - 🆕 5.x - 4.x - 3.x validations: required: true - type: checkboxes id: OS attributes: label: OS / 操作系统 options: - label: macOS - label: Windows - label: Linux - label: Others / 其他 validations: required: true - type: checkboxes id: Browser attributes: label: Browser / 浏览器 options: - label: Chrome - label: Edge - label: Firefox - label: Safari (Limited support / 有限支持) - label: IE (Nonsupport / 不支持) - label: Others / 其他 validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/2.feature_request.yml ================================================ name: '💡 Feature Request' description: I have a suggestion (and may want to implement it) / 我有一个建议(或者想参与贡献) title: '[Feat]: ' labels: ['waiting for maintainer'] body: - type: textarea id: description attributes: label: Describe the feature / 功能描述 description: 'What problem does this feature solve? / 这个功能解决什么问题?' placeholder: | I would like to see... because... 我希望能有... 因为... validations: required: true - type: dropdown attributes: label: Are you willing to contribute? / 是否愿意参与贡献? options: - Please select / 请选择 - ✅ Yes / 是 - ❌ No / 否 validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/3.docs_feedback.yml ================================================ name: '📖 Docs Feedback' description: 'Help us make our docs better! Share your thoughts and suggestions / 帮助我们改进文档!分享您的想法和建议' labels: ['waiting for maintainer'] title: '[Docs]: ' body: - type: markdown attributes: value: | ### 👋 Hello there! / 您好! Thank you for helping us improve our documentation! Your feedback is invaluable to us and will help make our docs better for everyone. 感谢您帮助我们改进文档!您的反馈对我们来说非常宝贵,这将帮助我们为所有人提供更好的文档体验。 - type: input id: page-url attributes: label: '📍 Which page are you reading?' description: "Please share the URL of the page you'd like to give feedback on / 请分享您想要反馈的页面链接" placeholder: 'https://docs.example.com/' validations: required: true - type: dropdown id: feedback-type attributes: label: "💭 What's on your mind?" description: 'What kind of feedback would you like to share? / 您想分享什么类型的反馈?' options: - 'Could be clearer / 需要更清晰的解释' - 'Missing information / 信息不完整' - 'Example needs fixing / 示例需要修复' - 'Content needs updating / 内容需要更新' - 'Other suggestions / 其他建议' validations: required: true - type: textarea id: description attributes: label: '🤔 Tell us more' description: | Let us know what went wrong when you were using this documentation and what we could do to improve it | 请告知您在使用此文档时遇到的问题以及我们可以改进的地方 placeholder: | Share your experience: - What confused you? - What were you looking for? - What would have helped you understand better? 分享您的体验: - 哪里让您感到困惑? - 您在寻找什么信息? - 什么样的改进能帮助您更好地理解? validations: required: true - type: textarea id: suggestion attributes: label: '💡 Got any suggestions?' description: | What are you trying to accomplish? Providing context helps us come up with a solution that is more useful in the real world | 您希望实现什么目标?提供上下文有助于我们提出更实用的解决方案 placeholder: | Some ideas you might share: - Adding more examples - Including screenshots - Providing step-by-step guides 您可以建议: - 添加更多示例 - 包含截图说明 - 提供步骤指南 validations: required: false - type: markdown attributes: value: | --- 💝 Thanks for taking the time to fill out this form! Your feedback helps make our documentation better for everyone. 感谢您抽出宝贵时间填写这份反馈!您的建议将帮助我们为所有人提供更好的文档。 ================================================ FILE: .github/ISSUE_TEMPLATE/4.oscp.yml ================================================ name: '✏️ OSCP Season of Docs' description: '通过开源社区的力量,共同打造更友好、更易上手的 AntV 文档 | Contribute to AntV G6 documentation with the power of open source community' labels: ['OSCP'] projects: ['2025 AntV OSCP Season of Docs'] title: '[Docs]: ' body: - type: textarea id: 'summary' attributes: label: 任务介绍 value: | > 此 ISSUE 为 [AntV 开源共建计划(AntV Open Source Contribution Plan,简称 AntV OSCP)Phase3 - 文档季](https://github.com/antvis/G6/issues/6882)的任务 ISSUE,欢迎社区开发者参与共建~ > - 更多任务,可查看 [GitHub Project - 2025 AntV OSCP Season of Docs](https://github.com/orgs/antvis/projects/31)。 > This ISSUE is one of the tasks of the [AntV Open Source Contribution Plan (referred to as AntV OSCP) Pharse3 - Season of Docs](https://github.com/antvis/G6/issues/6882) . Welcome to join us in building it together! > - For more tasks, you can check the [GitHub Project - 2025 AntV OSCP Season of Docs](https://github.com/orgs/antvis/projects/31). ## 改造文档「xxx」 ### 任务介绍 - 任务名称:改造 [xxx](https://g6.antv.antgroup.com/manual/xxx) 文档 - 技术方向:g6 / docs - 任务难度:新手友好 🌟 / 进阶 🌟🌟 / 专家 🌟🌟🌟 - 可获得积分:20分 / 30分 / 50分 ### 详细要求 - 文档规范: - 参考示例: - [插件 - 内置插件 - 背景](https://g6.antv.antgroup.com/manual/plugin/build-in/background) - [插件 - 内置插件 - 工具栏](https://g6.antv.antgroup.com/manual/plugin/build-in/toolbar) - 文档结构:「GridLine」部分的文档至少应该包括 **概述**、**使用场景**、**配置项**,**示例代码** 几个部分。 - 内容规范: - 属性表格需要包含【属性】【描述】【类型】【默认值】【必选】列,所有的配置项包括绘图属性需要罗列完整。 - 复杂类型单独解释说明 - 必要时可配上示意图 ### 能力要求 ``` - 对 G6 有一定了解,能阅读 G6 源码,编写示例。 ``` ### 执行路径 #### 1. 认领任务 选择感兴趣的且没有 Assignee 的任务,按格式回复,该任务 assign 给你后即为成功认领~ - 认领回复格式:【@GitHub ID + Give it to me】 - eg:【@yvonnyx Give it to me】 #### 2. 做任务 1. clone g6 代码 ```bash git clone https://github.com/antvis/G6.git ``` 2. 拉取所有线上分支 ```bash git fetch ``` 3. 切换到 v5 分支 ```bash git checkout v5 ``` 4. 安装依赖 ```bash pnpm install ``` 5. 进入 site 包 ```bash cd packages/site ``` 6. 本地启动 site 站点 ```bash pnpm run dev ``` 7. 优化文档并预览效果 对应文件位于 `packages/site/docs/manual/xxx.zh.md` #### 3. 提交 PR > 请保证文档语意通顺、格式正确、代码示例完整且能够正确编译,否则该 PR 将不会被 review 和 merge,此 issue 将被重新释放。 1. 提交 Pull Request,等待 Code Review - PR 标题参考 `docs: 任务名称` ,如 `docs: 改造点击选中交互文档` ,并关联 `OSCP` 标签,以便快速进入 PR review 阶段。 - PR 与对应任务 ISSUE 进行关联,方式:在 PR 正文中,通过 `- Fixed: #任务 ISSUE 号` 即可实现关联,eg: ![Image](https://github.com/user-attachments/assets/a05cc8f5-d42b-47fd-b3e2-be796a8b8017) 2. 根据(多次) Code Review 建议修改 3 等待合并入 v5 分支后,积分生效 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ # Ref: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#configuring-the-template-chooser blank_issues_enabled: false contact_links: - name: 📝 Question / 问题咨询 url: https://github.com/antvis/g6/discussions/new?category=q-a about: Discuss G6 usage / 讨论 G6 使用问题 - name: 💬 Join Discussion Group / 加入讨论群 url: https://qr.dingtalk.com/action/joingroup?code=v1,k1,rQHsK/OOTPX8ixM/DaXcL3goIYpnpKr/AFIonmA1SOM=&_dt_no_comment=1&origin=11? about: Join DingTalk discussion group / 加入钉钉讨论群 ================================================ FILE: .github/ISSUE_TEMPLATE/oscp.yml ================================================ # name: 'AntV OSCP 计划 / AntV OSCP Plan' # description: AntV 开源共建计划(仅供管理员使用) / AntV Open Source Contribution Plan(For administrators only) # body: # - type: checkboxes # id: AntV_OSCP_program # attributes: # label: AntV Open Source Contribution Plan(可选/Optional) # description: | # AntV 开源共建计划期望可以基于 AntV 的开源 Roadmap 开放具体开发任务到社区,以社区共建任务的形式推动“AntV” 的开源发展,也期望有更多社区伙伴各各种形式参与到 AntV 的开源共建中,共同参与数据可视化开源生态的持续建设。 # AntV Open Source Contribution Plan expects to open specific development tasks to the community based on AntV's open source roadmap, promote the open source development of "AntV" in the form of community co-construction tasks, and also hope that more community partners will participate in AntV's open source co-construction in various forms, and jointly participate in the continuous construction of the data visualization open source ecosystem. # 若有感兴趣想要认领的任务,可直接回复认领,如果你是首次认领可先完成初级入门任务。 # If you are interested in claiming a task, you can directly reply to claim it. If you are claiming for the first time, you can complete the primary entry task first. # options: # - label: 我同意将这个 Issue 参与 OSCP 计划 / I agree to participate in the OSCP plan # validations: # required: false # - type: dropdown # id: issue_oscp_difficulty # attributes: # label: Issue 类型 / Issue Type # options: # - 初级任务 / Primary Task # - 中级任务 / Intermediate Task # - 高级任务 / Advanced Task # - 专家任务 / Expert Task # validations: # required: false # - type: textarea # id: oscp_task_description # attributes: # label: 任务介绍 / Task Description # description: | # 简单描述任务背景信息,为了解决哪些问题 # Briefly describe the background information of the task and what problems to solve # validations: # required: false # - type: textarea # id: oscp_task_info_ref # attributes: # label: 参考说明 / Reference Description # description: | # 提供一些可参考的 demo,相关教程辅助用户解决问题 # Provide some demos and related tutorials for users to solve problems # validations: # required: false ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: push: paths-ignore: - '**/*.md' pull_request: paths-ignore: - '**/*.md' concurrency: group: ${{github.workflow}}-${{github.event_name}}-${{github.ref}} cancel-in-progress: true jobs: lint-and-build-g6: runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 20 - name: Install Dependencies run: | brew install python-setuptools pkg-config cairo pango libpng jpeg giflib librsvg - uses: pnpm/action-setup@v4 name: Install pnpm with: version: 9 run_install: false - name: Install Dependencies run: pnpm install --no-frozen-lockfile - name: Run CI 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@v4 with: name: report path: | packages/g6/__tests__/snapshots/**/*-actual.svg playwright-report/ retention-days: 1 - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: files: ./packages/g6/coverage/coverage-final.json flags: g6 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy on: workflow_dispatch: push: branches: - v5 jobs: deploy-site: runs-on: ubuntu-latest steps: - name: Setup node uses: actions/setup-node@v3 with: node-version: 18 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/checkout@v2 - run: pnpm install - run: pnpm build - run: | cd ./packages/site pnpm run build - run: cp ./packages/site/CNAME ./packages/site/dist/CNAME - run: | cd ./packages/site/dist git init git config --local user.name antv git config --local user.email antv@antfin.com git add . git commit -m "update by release action" - uses: ad-m/github-push-action@master with: github_token: ${{secrets.PERSONAL_ACCESS_TOKEN}} directory: ./packages/site/dist branch: gh-pages force: true ================================================ FILE: .github/workflows/ensure-triage-label.yml ================================================ name: Ensure Triage Label is Present on: label: types: - deleted issues: types: - opened permissions: {} jobs: label_issues: runs-on: ubuntu-latest permissions: issues: write steps: - uses: actions/github-script@v7.0.1 with: script: | const labelToTriage = 'waiting for maintainer'; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, }); if (labels.length <= 0) { await github.rest.issues.addLabels({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, labels: [labelToTriage] }) } ================================================ FILE: .github/workflows/issue-automated.yml ================================================ name: Issue Automated Processing on: issues: types: [opened, reopened, edited] workflow_dispatch: inputs: issue_number: description: 'Issue number to process' required: true type: number permissions: issues: write pull-requests: read contents: read jobs: issue-response: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: 20 - name: Install dependencies run: | yarn install yarn add openai @antv/mcp-server-antv -W - name: Process Issue uses: actions/github-script@v7 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | let issue = context.payload.issue; if (context.eventName === 'workflow_dispatch') { const issueNumber = context.payload.inputs.issue_number; const { data: issueData } = await github.rest.issues.get({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber }); issue = issueData; } const script = require('./.github/workflows/scripts/issue-automated.js'); await script({ github, core, context, issue }); ================================================ FILE: .github/workflows/issue_translate.yml ================================================ name: Translate Issue Title on: issues: types: [opened, edited] jobs: run: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: 20 - name: Translate uses: Aarebecca/issue-translator@1.0.0 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AZURE_TRANSLATE_KEY: ${{ secrets.AZURE_TRANSLATE_KEY }} AZURE_TRANSLATE_ENDPOINT: 'https://api.cognitive.microsofttranslator.com' AZURE_TRANSLATE_LOCATION: 'eastus' AZURE_TRANSLATE_TARGET: 'en' ================================================ FILE: .github/workflows/manage-labeled.yml ================================================ name: Manage Labeled Issue on: issues: types: [labeled] permissions: {} jobs: manage-labels: runs-on: ubuntu-latest permissions: issues: write steps: # 当添加分类标签时,移除 'waiting for maintainer' 标签 - name: Remove `waiting for maintainer` label when other triage labels are added uses: actions/github-script@v7.0.1 with: script: | const labelsToCheck = ['waiting for author', 'need improvement', 'bug 🐛', 'documentation 📖', 'feature 💡', 'question 💬', 'notabug', 'stale', 'wontfix', 'duplicate']; const labelToRemove = 'waiting for maintainer'; const newLabel = context.payload.label.name; if (labelsToCheck.includes(newLabel)) { const { data: labels } = await github.rest.issues.listLabelsOnIssue({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, }); if (labels.some(label => label.name === labelToRemove)) { await github.rest.issues.removeLabel({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, name: labelToRemove, }); } } # 当添加 'need improvement' 标签时,同时添加 'waiting for author' 标签 - name: Append label if `need improvement` is added if: github.event.label.name == 'need improvement' uses: actions-cool/issues-helper@v3.6.0 with: actions: "add-labels" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} labels: "waiting for author" # 当添加 'need improvement' 标签时,发送提醒评论 - name: Warn bad issue when `need improvement` label is added if: github.event.label.name == 'need improvement' uses: actions-cool/issues-helper@v3 with: actions: "create-comment" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | 📝 To help us better understand and address your issue, **please provide more information, or use the standard format**, otherwise we will not process this issue. Reference document: - [Creating an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-an-issue) - [Basic writing and formatting syntax](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) --- 📝 为了帮助我们更好地理解和解决你的问题,**请提供更多信息,或者使用规范的格式**,否则我们不会处理这个 issue。 参考文档: - [创建议题](https://docs.github.com/zh/issues/tracking-your-work-with-issues/using-issues/creating-an-issue) - [基本撰写和格式语法](https://docs.github.com/zh/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) emoji: "heart" # 处理 stale 标签 - name: Add stale issue comment before closing if: github.event.label.name == 'stale' uses: actions-cool/issues-helper@v3 with: actions: "create-comment" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | ⚠️ This issue has been automatically closed due to inactivity. - If the issue is still relevant and important to you, feel free to: 1. Reopen with additional information 2. Create a new issue with updated context 3. Reference any related issues or discussions We close inactive issues to keep our backlog manageable and focused on active issues. Your contribution makes our project better! 🌟 --- ⚠️ 由于长期无活动,此 issue 已被自动关闭。 - 如果这个问题对您来说仍然重要,您可以: 1. 重新打开并提供补充信息 2. 创建一个新的 issue 并更新相关背景 3. 关联相关的 issue 或讨论 为了更好地维护项目,我们需要定期清理不活跃的问题。 感谢您为开源添砖加瓦!🌟 emoji: "heart" - name: Close stale issue if: github.event.label.name == 'stale' uses: actions-cool/issues-helper@v3 with: actions: "close-issue" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} # 处理 wontfix 标签 - name: Add wontfix issue comment before closing if: github.event.label.name == 'wontfix' uses: actions-cool/issues-helper@v3 with: actions: "create-comment" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | 🚫 This issue has been marked as "Won't Fix". Here's why: - The described behavior is working as intended - The request falls outside our project scope/goals - The cost/benefit ratio doesn't justify the change If you have new information that might change this decision, feel free to: 1. Share your additional context 2. Propose alternative solutions 3. Start a discussion to explore different approaches Thank you for your understanding and engagement! 🙏 --- 🚫 此 issue 被标记为"不予修复",原因如下: - 当前行为符合设计预期 - 该请求超出项目范围/目标 - 投入产出比不足以支持此变更 如果您有任何新的想法或建议,欢迎: 1. 分享更多上下文 2. 提出替代方案 3. 发起讨论以探索不同思路 感谢理解与支持!🙏 emoji: "heart" - name: Close wontfix issue if: github.event.label.name == 'wontfix' uses: actions-cool/issues-helper@v3 with: actions: "close-issue" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} # 处理 notabug 标签 - name: Add notabug issue comment before closing if: github.event.label.name == 'notabug' uses: actions-cool/issues-helper@v3 with: actions: "create-comment" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | ✅ After careful review, we've determined this is not a bug. Here's why: - The current behavior is working as designed - This might be a misunderstanding of the feature - The issue cannot be reproduced with the provided information If you still believe this is a bug, please: 1. Provide a minimal reproduction 2. Share your expected behavior 3. Include more detailed environment information Thank you for helping us improve our project! 💫 --- ✅ 经过仔细核查,这并非一个 bug,原因如下: - 当前表现符合设计预期 - 可能是对功能理解有所偏差 - 基于已提供信息无法复现问题 如果您仍认为这是一个 bug,建议: 1. 提供最小复现示例 2. 说明您期望的表现 3. 补充更详细的环境信息 期待您的反馈!💫 emoji: "heart" - name: Close notabug issue if: github.event.label.name == 'notabug' uses: actions-cool/issues-helper@v3 with: actions: "close-issue" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} ================================================ FILE: .github/workflows/mark-duplicate.yml ================================================ name: Mark Duplicate Issue on: issue_comment: types: [created, edited] permissions: {} jobs: mark-duplicate: runs-on: ubuntu-latest permissions: contents: read issues: write steps: - name: Mark duplicate issue uses: actions-cool/issues-helper@v3.6.0 with: actions: "mark-duplicate" token: ${{ secrets.GITHUB_TOKEN }} duplicate-labels: "duplicate" remove-labels: "waiting for maintainer" close-issue: true ================================================ FILE: .github/workflows/no-response.yml ================================================ name: No Response # `issues`.`closed`, `issue_comment`.`created`, and `scheduled` event types are required for this Action # to work properly. on: issues: types: - closed issue_comment: types: - created schedule: # These runs in our repos are spread evenly throughout the day to avoid hitting rate limits. # If you change this schedule, consider changing the remaining repositories as well. # Runs at 12 am, 12 pm - cron: "0 0,12 * * *" permissions: {} jobs: noResponse: runs-on: ubuntu-latest permissions: contents: read issues: write steps: - uses: MBilalShafi/no-response-add-label@v0.0.6 with: token: ${{ secrets.GITHUB_TOKEN }} # Number of days of inactivity before an Issue is closed for lack of response daysUntilClose: 7 # Label requiring a response responseRequiredLabel: "waiting for author" # Label to add back when required label is removed optionalFollowupLabel: "waiting for maintainer" # Comment to post when closing an Issue for lack of response. Set to `false` to disable closeComment: | ⚠️ This issue has been automatically closed due to inactivity. - If the issue is still relevant and important to you, feel free to: 1. Reopen with additional information 2. Create a new issue with updated context 3. Reference any related issues or discussions We close inactive issues to keep our backlog manageable and focused on active issues. Your contribution makes our project better! 🌟 --- ⚠️ 由于长期无活动,此 issue 已被自动关闭。 - 如果这个问题对您来说仍然重要,您可以: 1. 重新打开并提供补充信息 2. 创建一个新的 issue 并更新相关背景 3. 关联相关的 issue 或讨论 为了更好地维护项目,我们需要定期清理不活跃的问题。 感谢您为开源添砖加瓦!🌟 ================================================ FILE: .github/workflows/publish.yml ================================================ # 当具有 publish 标签的 PR 被合并时,自动发布新版本 # Automatically publish a new version when a PR with the publish label is merged name: Auto Publish on: pull_request: types: [closed] branches: - v5 jobs: publish: runs-on: ubuntu-latest if: contains(github.event.pull_request.labels.*.name, 'publish') && github.event.pull_request.merged == true steps: - uses: actions/checkout@v3 - name: Install Node.js uses: actions/setup-node@v3 with: node-version: 18 - name: Install pnpm and dependencies uses: pnpm/action-setup@v4 with: version: 9 run_install: true - name: Build run: npm run build - name: Publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: pnpm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} & pnpm run publish ================================================ FILE: .github/workflows/resolved-pending-release.yml ================================================ name: Resolved Pending Release on: release: types: [published] permissions: {} jobs: comment-on-issues: runs-on: ubuntu-latest permissions: issues: write steps: - name: Checkout uses: actions/checkout@v4 with: # Check this repository out, otherwise the script won't be available, # as it otherwise checks out the repository where the workflow caller is located repository: antvis/github-config - name: Comment on issues uses: actions/github-script@v7.0.1 with: script: | const script = require('./.github/workflows/scripts/closeOnRelease.js'); await script({core, github, context}); ================================================ FILE: .github/workflows/scripts/closeOnRelease.js ================================================ /** * @param {Object} param * @param {import('@actions/core')} param.core * @param {ReturnType} param.github * @param {import('@actions/github').context} param.context */ module.exports = async ({ core, github, context }) => { try { const owner = context.repo.owner; const repo = context.repo.repo; const label = 'resolved pending release'; const resolvedLabel = 'resolved'; const issuesPendingRelease = ( await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', per_page: 100, }) ).filter((i) => i.pull_request === undefined && i.labels.map((l) => l.name).includes(label)); let failedIssues = 0; for (const issue of issuesPendingRelease) { const number = issue.number; // slow down how often we send requests if there are lots of issues. await new Promise((resolve) => setTimeout(resolve, 250)); const { data: releases } = await github.rest.repos.listReleases({ owner, repo, }); const release = releases.length > 0 ? releases[0] : undefined; if (release === undefined) { throw new Error('There is no release available'); } const message = `:tada: This issue has been resolved and is now available in the [${release.tag_name}](${release.html_url}) release! :tada:`; try { // Remove the `resolved pending release` label. await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: label, }); // Add the `resolved` label. await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [resolvedLabel], }); // Comment on the issue that we will close. await github.rest.issues.createComment({ owner, repo, issue_number: number, body: message, }); // Close the issue. await github.rest.issues.update({ owner, repo, issue_number: number, state: 'closed', }); } catch (error) { console.error(`Failed to comment on and/or close issue #${number}`, error); failedIssues++; } console.log(`Closed #${number}`); } if (failedIssues > 0) { core.setFailed(`Failed to comment on ${failedIssues} PRs`); } } catch (error) { console.error(error); core.setFailed(error.message); } }; ================================================ FILE: .github/workflows/scripts/issue-automated.js ================================================ const { OpenAI } = require("openai"); const { QueryAntVDocumentTool, ExtractAntVTopicTool }= require('@antv/mcp-server-antv/build/tools'); /** * @param {Object} param * @param {import('@actions/github').GitHub} param.github * @param {import('@actions/core')} param.core * @param {Object} param.context GitHub Action context */ module.exports = async ({ github, core, context, issue }) => { try { core.info('开始处理 issue...', context.repo.repo); const library = `g6`; if (!issue) { core.setFailed('找不到 issue 信息'); return; } const issueNumber = issue.number; const issueTitle = issue.title; core.info(`处理 issue #${issueNumber}: ${issueTitle}`); const combinedQuery = prepareAIPrompt(context, issue); const topicExtractionResult = await ExtractAntVTopicTool.run({ query: combinedQuery }); const aiResponse = await getAIResponse(core, topicExtractionResult.content[0].text); const jsonMatch = aiResponse.match(/```json\s*(\{[\s\S]*?\})\s*```/); const processedTopicContent = JSON.parse(jsonMatch[1]); const queryDocumentParams = { library, query: combinedQuery, topic: processedTopicContent.topic, intent: processedTopicContent.intent, tokens: 5000, ...(processedTopicContent.subTasks && { subTasks: processedTopicContent.subTasks }), }; const documentationResult = await QueryAntVDocumentTool.run(queryDocumentParams); const response = await getAIResponse(core, documentationResult.content[0].text); await github.rest.issues.createComment({ issue_number: issue.number, owner: context.repo.owner, repo: context.repo.repo, body: `@${issue.user.login} 您好!以下是关于您问题的自动回复:\n\n${response}\n\n---\n*此回复由 AI 助手自动生成。如有任何问题,我们的团队会尽快跟进。*` }); core.info('Issue 处理完成'); } catch (error) { core.setFailed(`处理 issue 失败: ${error.message}`); core.error(error.stack); } }; function prepareAIPrompt(context, issue) { return ` 你是 ${context.repo.repo} 项目的智能助手。这是一个处理 GitHub issue 的自动回复系统。 请分析以下 issue 并提供专业、有帮助的回复。 ## 当前 Issue - 标题: ${issue.title} - 内容: ${issue.body} 请提供完整、有帮助的回复,但不要过于冗长。回复应该条理清晰,使用适当的 Markdown 格式。 `; } /** * 调用 GitHub AI API 获取回复 */ async function getAIResponse(core, userQuestion) { try { core.info('正在调用 GitHub AI API...'); const token = process.env.GH_TOKEN; if (!token) { throw new Error('未找到 GH_TOKEN 环境变量'); } const endpoint = "https://models.github.ai/inference"; const model = "openai/gpt-4.1"; const client = new OpenAI({ baseURL: endpoint, apiKey: token }); const response = await client.chat.completions.create({ messages: [ { role: "user", content: userQuestion } ], temperature: 0.7, top_p: 1.0, model: model }); core.info('成功获取 AI 响应'); core.info(JSON.stringify(response)); return response.choices[0].message.content; } catch (error) { core.warning(`调用 GitHub AI API 失败: ${error.message}`); // 默认回复 return ` 感谢您提交这个 issue! 我们的团队会尽快查看您的问题。为了帮助我们更快地解决,请确保您提供了: - 问题的详细描述 - 复现步骤 (如果是 bug) - 预期行为和实际行为 - 使用的版本信息 谢谢您的理解与支持! `; } } ================================================ FILE: .github/workflows/scripts/updateYuque.js ================================================ const fs = require('fs'); const path = require('path'); /** * @param {Object} param * @param {import('@actions/core')} param.core * @param {import('@actions/core').InputOptions} param.inputs */ module.exports = async ({ core, inputs }) => { try { const API_BASE = 'https://www.yuque.com/api/v2'; const group_login = 'antv'; // 知识库所属组织 const { token, book_slug, site_slug } = inputs; // 存储创建的文档ID,用于后续更新目录 const createdDocIds = { tutorials: [], examples: [], }; core.info('开始更新语雀文档...'); // 删除知识库中的所有文档 async function clearAllDocs() { core.info('获取知识库文档列表...'); try { let allDocs = []; let offset = 0; const limit = 100; // 语雀API每页最大条数 let hasMore = true; // 循环获取所有文档 while (hasMore) { core.info(`获取文档列表,偏移量: ${offset}, 数量: ${limit}...`); const response = await fetch( `${API_BASE}/repos/${group_login}/${book_slug}/docs?offset=${offset}&limit=${limit}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, }, }, ); if (!response.ok) { throw new Error(`获取文档列表失败: ${response.statusText}`); } const data = await response.json(); const docs = data.data; if (docs && docs.length > 0) { allDocs = allDocs.concat(docs); offset += docs.length; // 如果返回的文档数量小于请求的限制,说明已经没有更多文档了 if (docs.length < limit) { hasMore = false; } } else { hasMore = false; } } core.info(`共找到 ${allDocs.length} 个文档,准备删除...`); // 删除所有文档 for (const doc of allDocs) { core.info(`删除文档: ${doc.title} (${doc.id})...`); const deleteResponse = await fetch(`${API_BASE}/repos/${group_login}/${book_slug}/docs/${doc.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, }, }); if (!deleteResponse.ok) { core.warning(`删除文档 ${doc.title} 失败: ${deleteResponse.statusText}`); } else { core.info(`已删除文档: ${doc.title}`); } } core.info('所有文档已删除'); } catch (error) { core.error('删除文档过程中出错:' + error.message); throw error; } } // 创建单个文档 async function createDoc(title, body, type) { try { core.info(`创建文档: ${title}...`); const response = await fetch(`${API_BASE}/repos/${group_login}/${book_slug}/docs`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, }, body: JSON.stringify({ title: title, public: 1, format: 'lake', body: body, }), }); if (!response.ok) { throw new Error(`创建文档失败: ${response.statusText}`); } const data = await response.json(); core.info(`文档已创建: ${title} (ID: ${data.data.id})`); // 存储文档ID用于后续更新目录 if (type === 'tutorial') { createdDocIds.tutorials.push(data.data.id); } else { createdDocIds.examples.push(data.data.id); } return data.data.id; } catch (error) { core.error(`创建文档 ${title} 时出错: ${error.message}`); return null; } } // 更新知识库目录 async function updateToc() { try { core.info('更新知识库目录...'); const response = await fetch(`${API_BASE}/repos/${group_login}/${book_slug}/toc`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token, }, body: JSON.stringify({ action: 'appendNode', action_mode: 'child', doc_ids: [...createdDocIds.tutorials, ...createdDocIds.examples], type: 'DOC', }), }); if (!response.ok) { throw new Error(`更新目录失败: ${response.statusText}`); } core.info('知识库目录已更新'); } catch (error) { core.error('更新目录时出错: ' + error.message); throw error; } } // 递归获取所有 .zh.md 文件并创建文档 async function processMarkdownFiles() { core.info('处理Markdown文档...'); function getAllMarkdownFiles(dir) { let results = []; if (!fs.existsSync(dir)) { core.warning(`目录不存在: ${dir}`); return results; } const list = fs.readdirSync(dir); list.forEach((file) => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat && stat.isDirectory()) { results = results.concat(getAllMarkdownFiles(filePath)); } else if (file.endsWith('.zh.md')) { results.push(filePath); } }); return results; } try { // 获取文档目录 const docsDir = path.join(process.cwd(), site_slug, 'docs'); core.info(`搜索文档目录: ${docsDir}`); const files = getAllMarkdownFiles(docsDir); core.info(`找到 ${files.length} 个中文 Markdown 文件`); // 为每个文件创建文档 for (const file of files) { let content = fs.readFileSync(file, 'utf-8'); const fileName = path.basename(file, '.zh.md'); const title = `教程-${fileName}`; await createDoc(title, content, 'tutorial'); } } catch (error) { core.error('处理 Markdown 文件时出错: ' + error.message); throw error; } } // 处理示例代码文件 async function processExampleFiles() { core.info('处理示例代码...'); function traverseDirectory(dir) { const metaFiles = []; if (!fs.existsSync(dir)) { core.warning(`目录不存在: ${dir}`); return metaFiles; } function findMetaFiles(directory) { try { fs.readdirSync(directory, { withFileTypes: true }).forEach((dirent) => { const fullPath = path.join(directory, dirent.name); if (dirent.isDirectory()) { findMetaFiles(fullPath); } else if (dirent.name === 'meta.json') { metaFiles.push(fullPath); } }); } catch (err) { core.warning(`读取目录 ${directory} 时出错: ${err.message}`); } } findMetaFiles(dir); return metaFiles; } async function processMetaJson(metaFilePath) { try { const dir = path.dirname(metaFilePath); const folderName = path.basename(dir); const metaContent = fs.readFileSync(metaFilePath, 'utf-8'); const metaJson = JSON.parse(metaContent); if (Array.isArray(metaJson.demos)) { for (const demo of metaJson.demos) { const demoFilePath = path.join(dir, demo.filename); if (fs.existsSync(demoFilePath) && fs.statSync(demoFilePath).isFile()) { await processDemoFile(demoFilePath, demo.title.zh, folderName); } } } } catch (error) { core.error(`处理 ${metaFilePath} 时出错: ${error.message}`); } } async function processDemoFile(filePath, title, category) { try { let content = fs.readFileSync(filePath, 'utf-8'); // 移除HTML标签 content = removeHtmlTags(content); const docTitle = `${category}-${title}`; await createDoc(docTitle, `// ${title}\n${content}`, 'example'); } catch (error) { core.error(`处理 ${filePath} 时出错: ${error.message}`); } } // 移除HTML标签的函数 function removeHtmlTags(code) { // 找到模板字符串中的HTML标签 const templateStringRegex = /`([\s\S]*?)`/g; return code.replace(templateStringRegex, (match, templateContent) => { // 移除模板字符串中的HTML标签 const cleanTemplate = templateContent .replace(/<[^>]*>[\s\S]*?<\/[^>]*>/g, '') .replace(/<[^>]*\/>/g, '') // 移除自闭合标签 .replace(/<[^>]*>/g, ''); // 移除单个开放标签 return '`' + cleanTemplate + '`'; }); } try { const rootDir = process.cwd(); core.info(`搜索示例文件,根目录: ${rootDir}`); const metaFiles = traverseDirectory(rootDir); core.info(`找到 ${metaFiles.length} 个 meta.json 文件`); for (const metaFile of metaFiles) { await processMetaJson(metaFile); } } catch (error) { core.error('处理示例文件时出错: ' + error.message); throw error; } } // 主执行函数 try { core.startGroup('清除现有文档'); await clearAllDocs(); core.endGroup(); core.startGroup('处理示例文件'); await processExampleFiles(); core.endGroup(); core.startGroup('处理文档文件'); await processMarkdownFiles(); core.endGroup(); core.startGroup('更新目录'); await updateToc(); core.endGroup(); core.info('文档更新处理完成'); } catch (error) { core.setFailed(`更新语雀文档失败: ${error.message}`); } } catch (error) { core.setFailed(`脚本执行失败: ${error.message}`); } }; ================================================ FILE: .github/workflows/update-yuque.yml ================================================ name: Update Documentation on Yuque on: pull_request: types: [closed] branches: - v5 paths: - '**/*.md' jobs: check-and-update: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: 20 - name: Run Yuque update script uses: actions/github-script@v7.0.1 with: script: | const script = require('./.github/workflows/scripts/updateYuque.js'); await script({ core, inputs: { token: '${{ secrets.YUQUE_TOKEN }}', // 语雀 Token book_slug: 'osbmvn', // 外网语雀知识库 site_slug: 'packages/site', // 文档所在位置 } }); ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* yarn.lock package-lock.json pnpm-lock.yaml # Sys .DS_Store .idea # Node node_modules/ .npmrc # Build dist lib esm # Test coverage # Bundle visualizer stats.html # Tools .turbo **/tmp/ /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ # IDE .history/ .lh/ ================================================ FILE: .husky/commit-msg ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx --no-install commitlint --edit "$1" ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" protected_branches="master v5" current_branch=$(git rev-parse --abbrev-ref HEAD) for branch in $protected_branches; do if [ "$current_branch" == "$branch" ]; then echo "\033[31mDirect commit to '$branch' branch are not allowed!\033[0m" exit 1 fi done npx lint-staged ================================================ FILE: .prettierignore ================================================ dist es lib node_modules ================================================ FILE: .prettierrc.js ================================================ module.exports = { plugins: [ require.resolve('prettier-plugin-organize-imports'), require.resolve('prettier-plugin-packagejson'), ], printWidth: 120, proseWrap: 'never', singleQuote: true, trailingComma: 'all', overrides: [ { files: '*.md', options: { proseWrap: 'preserve', }, }, ], }; ================================================ FILE: .vscode/settings.json ================================================ { "cSpell.words": [ "AABB", "afteranimate", "aftercanvasinit", "afterdestroy", "afterdraw", "afterelementcreate", "afterelementdestroy", "afterelementstatechange", "afterelementtranslate", "afterelementupdate", "afterlayout", "afterrender", "afterrendererchange", "aftersizechange", "afterstagelayout", "aftertransform", "afterviewportanimate", "antv", "autosize", "batchend", "batchstart", "bbox", "beforeanimate", "beforecanvasinit", "beforedestroy", "beforedraw", "beforeelementcreate", "beforeelementdestroy", "beforeelementstatechange", "beforeelementtranslate", "beforeelementupdate", "beforelayout", "beforerender", "beforerendererchange", "beforesizechange", "beforestagelayout", "beforetransform", "beforeviewportanimate", "betweenness", "Bezier", "bubblesets", "cancelviewportanimate", "convexhull", "Dagre", "dendrogram", "elementstatechange", "elementtranslate", "elementvisibilitychange", "Forceatlas", "Fruchterman", "Fullscreen", "gforce", "graphlib", "GSHAPE", "mindmap", "onframe", "pagerank", "Phong", "pinchend", "pinchmove", "pinchstart", "pointset", "Polyline", "ranksep", "Snapline", "Timebar" ], "javascript.preferences.importModuleSpecifier": "relative", "typescript.preferences.importModuleSpecifier": "relative", "svg.preview.background": "transparent" } ================================================ FILE: CHANGELOG.md ================================================ # ChangeLog ### 4.8.0 - fix: destroy graph and call layout problem, closes: #4126; - fix: remove duplicated event emit, closes: #4043; - fix: mousedown on other DOMs and mouseup on canvas, click is triggered unexpectly, closes: #2922; - fix: mousemove and mouseup are not triggered with drag and dragend, closes: #3086; - fix: replace DOMMouseScroll and mousewheel with wheel event, closes: #3256; - perf: refresh item when updateChild, updateChildren, addChild, removeChild for TreeGraph; ### 4.7.17 - fix: expandCombo and the edges of the children are not refreshed, closes: #3250; - fix: the item param of the afterremoveitem for combo should be data; - fix: add type to the parameter list of beforeremoveitem event; - fix: edge update with destroyed end items, closes: #3925; - perf: take the max value of padding array for circle combo, closes: #4113; - feat: support top-center for rect combo label position, closes: #3750; - feat: createCombo and uncombo support stack, closes: #3695, #3323; ### 4.7.16 - feat: allowDragOnItem config for scroll-canvas, closes: #3062; - feat: allow to setTextWaterMarker and setImageWaterMarker with an undefined parameter to remove the watermarker, closes: #3478; - feat: hideEdge config for minimap to enhance the performance, closes: #3158; - fix: minimap has incorrect shape zIndex with keyShape type and delegate type, closes: #3132; - fix: minimap viewport dragging problem in firefox and safari, closes: #2939; - docs: add sequence demo to site, closes: #3027; - perf: unify the formats of shouldBegin, shouldUpdate, and shouldEnd in behaviors, closes: #3028; - perf: fitView and fitCenter according to the corner ndoes insead of getCanvasBBox to avoid maximum call stack size exceeded, closes: #2447; - fix: treeGraph changeData with node properties lost, closes: #3215; - fix: error occurs while calling updateLayout from gpu layout to a cpu layout, closes: #3272; - fix: error occurs while calling changeData to remove a node in a combo, closes: #3293; ### 4.7.15 - fix: dagre layout for collapsed combos; - perf: give layout algorithm vedges; ### 4.7.14 - fix: error occurs while dragging combo with drag-node behavior; ### 4.7.13 - fix: unexpected move with fitCenter with animation; - fix: update modelRect with rendering error, closes: #4041; ### 4.7.12 - fix: drag-canvas incorrectly stopped by right click; - fix: createCombo with nodes which already has parent combos; - fix: setItemState on node, related edges's linking positions are not refreshed; - perf: combo animate inherit from graph's animate config; - perf: improve the performance of setItemState and active-relations again; - feat: graph supports optimizeThreshold to control the number threshold of nodes to enable the optimization on rendering and interaction, currently only affects the edges' refresh while the related node state style changed; ### 4.7.11 - perf: improve the performance of setItemState and active-relations; - perf: keyShape is hiden when a combo is collapsed with collapsedSubstituIcon; - fix: drag-node incorrectly stopped by right click; - fix: timebar plugin destroy problem, closes: #3998; - fix: controllerCfg does not take effect in timebar with tick type, closes: #3843; - feat: timebar plugin supports config the default time type; - feat: timebar with play and pause API; - chore: use addItem and removeItem instead of changeData in timebar; ### 4.7.10 - perf: force layout with animation calls graph refreshPositions instead positionsAnimate while refreshing; ### 4.7.9 - perf: init node positions when the node has no x and y in the origin data; ### 4.7.8 - feat: pointPadding config for loop edges with non-circle nodes, closes: #3974; - fix: image lost while updating the size for an image node, closes: #3938; ### 4.7.7 - feat: getContentPlaceholder and getTitlePlaceholder for Annotation plugin; ### 4.7.6 - fix: Annotation readData with inexistent item; - perf: improve the performance for updating; ### 4.7.5 - perf: Annotation support updating positions for outside cards by calling updateOutsideCards; ### 4.7.4 - perf: Annotation min-width and input width; ### 4.7.3 - feat: beforechangedata and afterchagnedata for graph changeData; - feat: Annotation supports icon events callbacks; - feat: Annotation supports defaultBegin position configuration for new annotation cards; - perf: Annotation updated automatically when graph data changed and graph item visibility changed; - fix: Destroy legend canvas when the plugin is destroyed, closes: #3931; ### 4.7.2 - feat: Annotation plugin supports configuring behaviors for collapse and close icon; - feat: Annotation plugin supports canvas annotation; - fix: gForce layout has animation by default; - fix: createCombo creates vedges asynchronously, closes: #3912; - fix: strange polyline path edge related to combo, closes: #3913; #### 4.7.1 - feat: Annotation plugin; - fix: combo and drag-node with heap maximum problem, closes: #3886; - fix: combo and graph re-read problem, closes: 3902; - fix: d3 force layout with default animate; - perf: bundling plugin ts problem, closes: #3904; #### 4.7.0 - fix: combo collapsed edge problems, closes: #3839; #### 4.7.0-beta - feat: force2 from graphin-force; - feat: preset for layout; - feat: tweak incremental layout init for force like layouts; #### 4.6.18 - feat: updateLayout from no pipes to pipes, closes: #3726; - fix: relayout with pipes; #### 4.6.17 - fix: legend changeData problem, closes: #3561; - fix: redo and undo with an image node, closes: #3782; - fix: call refreshPositions instead of positionsAnimate while there is no layout configuration; #### 4.6.16 - feat: ID check; - feat: fitView with animation; - feat: findAllByState with additional filter; - fix: wrong dropped position for drag-combo with enableDelegate, closes: #3810; - fix: stack for drag-combo with onlyChangeComboSize, closes: #3801; - fix: stack updateLayout, closes: #3765; - fix: drag-canvas and zoom-canvas with enableOptimize show a hidden shape which is controlled by state, closes: #3635; - fix: typing problem for react node; #### 4.6.15 - fix: fitView does not zoom the graph with animate true; #### 4.6.14 - perf: optimize the performance of combo graph; #### 4.6.12 - perf: optimize the performance of combo graph first rendering; #### 4.6.11 - fix: star node with leftBottom linkPoint show and hide problem; - fix: relayout does not execute onAllLayoutEnd problem; - fix: combo edge state update problem, closes: #3639; #### 4.6.10 - feat: maxLength for labelCfg; - fix: custom layout warning and layout failed problem; - fix: upgrade layout to fix DagreLayoutOptions type error; - fix: upgrade layout to fix comboCombined with original node infomations problem; #### 4.6.8 - fix: spelling error for 'nodeselectChange', closes: #3736; - fix: update node icon from show false to show true; - fix: afterrender should be emitted when the layout is not configured; - perf: update related edges while drag-combo; - feat: combo supports collapsedSubstituteIcon showing after collapsed; - feat: remove animations while first rendering with (collapsed)combos; - refactor: toolbar plugin functions; #### 4.6.6 - fix: destroyLayout error, closes: #3727; - fix: drag combo with stack problem, closes: #3699; - fix: updateLayout does not take effect if update layout with same type as graph instance configuration, closes: #3706; - fix: legendStateStyles typo, closes: #3705; - perf: zoom-canvas take the maximum and minimum values instead of return directly; - perf: minimap cursor move; - feat: fitView and fitCenter with animation; - feat: addItems to add multiple items into graph in the same time; - feat: enable edge selection in click-select; #### 4.6.4 - chore: improve the types of graph events; - fix: position animate considers origin attributes; #### 4.6.3 - feat: shouldDeselect param for lasso-select; - fix: initial collapsed combos with unexpected size; #### 4.6.1 - fix: layoutController is null problem; #### 4.6.0-beta - feat: comboCombined Layout from @antv/layout; - feat: combo supports position configurations for any situations; - fix: run layout promise only when the layout is configured; #### 4.5.5 - fix: tooltip with wrong duplicated child DOM nodes; #### 4.5.4 - feat: tooltip plugin supports dynamic dom configurations; - feat: context menu plugin supports mobile touch event; - feat: allow enabling stack operations at runtime; - fix: use origin data when changeData without data param, closes: #3459; - feat: shouldBegin for canvas click in click-select behavior; #### 4.5.3 - fix: import G6 in head and call getComputedStyle, the document body is not exist; #### 4.5.2 - fix: node update from no icon to iconfont icon failed; - fix: getUpdateType with type error; - fix: edge label background with clearItemStates problem; - fix: edge label with autoRotate false and padding problem; - fix: changeData in the process of create-edge behavior, an error occurs, closes: #3384; - fix: node update from no icon to iconfont icon failed; #### 4.5.1 - feat: translate graph with animation; - feat: zoom graph with animation; - feat: timebar supports filterItemTypes to configure the types of the graph items to be filtered; only nodes can be filtered before; - feat: timebar supports to configure the rotate of the tick labels by tickLabelStyle[dot]rotate; - feat: timebar supports container CSS configuration by containerCSS; - feat: timebar supports a function getDate to returns the date value according to each node or edge by user; - feat: timebar supports afunction getValue to returns the value (for trend line of trend timebar) according to each node or edge by user; - feat: timebar supports to configure a boolean changeData to control the filter way, true means filters by graph[dot]changeData, false means filters by graph[dot]showItem and graph[dot]hideItem; - feat: timebar supports to configure a function shouldIgnore to return true or false by user to decide whether the node or the edge should be ignored while filtering; - fix: simple timebar silder text position strategy and expand the lineAppendWidth for the slider; - fix: edge label padding bug, closes: #3346; - fix: update node with iconfont icon, the icon is updated to a wrong position, closes: #3348; #### 4.5.0 - fix: add item type to the parameter of afterremoveitem event; #### 4.4.1 - feat: zoom with animation, contributed by @Blakko; #### 4.4.0-beta.1 - fix: drag-combo and drag-node with wrongly calling shouldUpdate; #### 4.4.0-beta.0 - feat: better performance for item drawing; - fix: disable the capture of hull shape to enhance the performance of dragging canvas with hulls; - fix: uncombo an empty combo, fix: #3248; - fix: upgrade layout to beta 5 to solve proxy problem for IE; #### 4.3.11 #### 4.3.9 - fix: update edge to be horizontal and the label is on wrong position for min file; #### 4.3.9 - fix: addBehavior with behavior string name, closes: #3020; - fix: drag-node shouldEnd does not stop the dragging node behavior, closes: #3173; - fix: drag-combo fails to merge combo with enableDelegate, closes: #3137; - fix: uncombo does not trigger afterremoveitem event, closes: #3179; - fix: error label background position when the edge label has position start, closes: #3129; - fix: destroyed graph judgement, closes: #3203; - fix: edge click event will not be triggered when the contextmenu is configure with trigger click, closes: #3201; - feat: drag-combo with shouldEnd, closes: #3202; - chore: information for failing to download image, closes: #2980; #### 4.3.7 - fix: update edge to be horizontal and the label is on wrong position; #### 4.3.6 - fix: drag-node on mobile, closes: #3127; - fix: removeBehaviors drag-canvas cause canvas:drag event cannot be listened; - fix: drag-node with unexpected offseted edge end points, closes: #3118; - fix: delete node with combo, closes: #3141; - fix: update node position with wrong position; - feat: enableStack for drag-node behavior, closes: #3128; #### 4.3.5 - fix: drag a node without comboId by drag-node with onlyChangeComboSize; - fix: gpu layout with async; - fix: minimap with delegate type cannot reach the top of the canvas, closes: #2885; - feat: improve the performance for updating nodes; - feat: updateLayout with align and alignPoint; #### 4.3.4 - fix: when select a node with click-select, selected combos should be deselected; - fix: contextmenu with click trigger does not show the menu up, closes: #2982; - fix: layout with collapsed combo, closes: #2988; - fix: zoom-canvas with optimizeZoom, drag-canvas shows the node shapes hiden by zoom-canvas optimizeZoom, closes: #2996; #### 4.3.3 - fix: uncombo with id, closes: #2924; - fix: image node with state changing, closes: #2923; - fix: mouseentering tooltip DOM hides the DOM; - feat: moveTo with animate, closes: #2252; #### 4.3.2 - fix: upgrade the layout package to 0.1.14 to solve the different results from gpu and cpu problem in gForce layout, closes: #2902; - fix: auto fitting container without width and height for graph problem, closes: #2901; - fix: minimap with zoomingproblem, closes: #2863 - feat: fx and fy for fruchterman and gForce layout in both gpu and cpu version; - feat: barWidth for interval bar chart for TimeBar plugin, closes: #2989; - feat: click trigger for context munu, closes: #2686; #### 4.3.0 - fix: empty object for TreeGraph data; - fix: combo edge arrow error with state styles; - fix: depth problem for addItem with comboId, closes: #2888; - feat: focus edge item; - feat: legend plugin; - feat: allow to new a tree layout independently; #### 4.2.7 - fix: edges disappear when collapsing combo, closes: #2816; - fix: drag-node with edge key, closes: #2819; - fix: failed to update startArrow to be false, closes #2814; - fix: createCombo and add combId or parentId to the related nodes or combos, closes #2815; - feat: no animation when first rendering with collapsed combos, closes: #2826; #### 4.2.6 - feat: scroll-canvas behavior; - feat: iconfont for node icon; - feat: percentage of scalable range for drag-canvas; - fix: missing brushStyle in type ModeOption; - fix: the comboId remains in the node after uncombo(), closes #2801; - fix: disappearing edges when combos are expanded/collapsed, closes #2798; - fix: invisible nodes and edges should not be selected by brush-select and lasso-select, closes #2810; #### 4.2.5 - feat: donut node; - feat: downloadImage with watermarker; - fix: multiple layout calling error; - fix: combo collapse and related edges diappearing; - fix: forceAtlas2 with descrete node error; #### 4.2.4 - fix: change data with dulplicated name between nodes and combos; - fix: pixelRatio for graph types; #### 4.2.3 - fix: layout with fitView; #### 4.2.2 - feat: pipe layouts for subgraphs; #### 4.2.1 - fix: circle combo edge linking position problem; - fix: drag minimap viewport with forbidden icon in chrome on windows; - fix: show node without node position problem; - fix: addItem and getNodeDgree with wrong result problem; - fix: timebar data filtering problem; - fix: update endArrow to be false and set state problem; - feat: pass comb and comboEdge data for layout; - feat: tooltip with fixToItem to avoid following the mouse when moving; - feat: getViewPortCenterPoint and getGraphCenterPoint API; - feat: tooltip with trigger configuration, supports mouseenter and click; #### 4.2.0 #### 4.1.14 - fix: combo edge link position problem; - fix: activate-relations with combo and combo edges problem; - feat: support config TimeBar handler, background, foreground, tick label, tick line style; #### 4.1.16 - fix: webworker in dist; #### 4.1.15 - fix: cubic-x problem, closes: #2698; #### 4.1.14 - fix: gridSize for polyline; - fix: create-edge undo problem; - fix: tslib spreadArray problem; - fix: rect combo position with state problem; - feat: simple polyline for better performance; - fix: gridSize for polyline; - fix: cubic-x problem, closes: #2698; - fix: create-edge undo problem; - fix: tslib spreadArray problem; - fix: rect combo position with state problem; - feat: simple polyline for better performance; #### 4.1.13 - fix: getHulls with error type; - fix: createHull with destroyed hullMap problem; - fix: refining TimeBar minor problems; - fix: tooltip with display none to avoid enlarging graph container; - feat: TimeBar supports controller style configuration; - feat: TimeBar supports filtering edges; - feat: dagre with nested combo; #### 4.1.13-beta - chore: update layout and register in G6; - fix: performance problem in create-edge with polyline; - fix: performance for polyline; - fix: debounce updating the polyline edges in drag-node behavior; - fix: toolbar redo undo max clone in drag-node behavior; - feat: dagre layout with combo; - feat: cubic-vertical and cubic-horizontal with curveOffset and minCurveOffset #### 4.1.12 - chore: update layout with alpha gwebgpu; - chore: update algorithm with fixed publicPath problem; #### 4.1.11 - chore: link correct core; #### 4.1.10 - chore: update algorithm; #### 4.1.9 - feat: allowDragOnItem for drag-canvas behavior; - fix: drag-canvas with two fingers on mobile affects zoom-canvas; #### 4.1.8 - fix: shouldBegin false for zoom-canvas behavior; - fix: shouldBegin originScale from graph zoom; - fix: error in collapse-expand with touch on canvas; #### 4.1.7 - fix: polyline with negative endpoints; - fix: polyline direction when linkCenter; - fix: remove g6-core browser since it has no umd output; - feat: custom texts for the time range and time point text in timeBar plugin; - chore: types for strict mode; #### 4.1.6 - fix: webworker problem after removing broswer in pc and g6; #### 4.1.5 - fix: wrong style for modelRect after updating and state changing, closes: #2613; - fix: drag-canvas with shouldBegin false, closes: #2571; - fix: pack plugin with es module, closes: #2577; - feat: dijkstra with multiple shortest paths, closes: #2297; - fix: setMode while the delegates of brush-select and drag-node is on the canvas, closes: #2607; - docs: update the english TimeBar docs, closes: #2597; - fix: TimeBar time point switch text configurable, closes: #2597; #### 4.1.4 - fix: drag-canvas with touch on mobile; #### 4.1.2 - fix: registerBehavior export problem; - fix: shouldEnd of create-edge with groupByTypes as false; - fix: collapse and expand a combo with an empty sub combo error; - fix: update padding of rect combo; - fix: the graph in the minimap with circular layout is not centered, closes: #2555; - fix: edge background displays on a wrong place when autoRotate is true; #### 4.1.1 - fix: soomth-convex hull with one line nodes leads to unshift problem; - fix: zoom-canvas to optimizeZoom and hide the label, the label will not show up any more problem; - fix: the ts type for parameter of timing event listener, closes: #2499; #### 4.1.0 - chore: ts lint; - feat: getEdgeConfig for create-edge behavior; - fix: uniqueId with timestamp and random; - fix: fix zoom-canvas and drag-canvas with enableOptimize conflict problem shrink the settimeout; #### 4.1.0-beta.1 - chore: unpack the g6 into core, pc, element, plugin, mobile, and exported by g6; - feat: layout with onLayoutEnd and custom layout with tag; - feat: emit beforecollapseexpandcombo and aftercollapseexpandcombo; - fix: toolbar for firefox and other browsers; - fix: edge label position with state problem; - fix: set item state to false at the first time; - fix: hull with one node; - fix: combo state size problem; - fix: state with fontSize changed problem; - fix: edge label with background when the two end nodes are overlapped; - fix: text rasidual of timebar; - fix: maximum stack size problem for image node type, fix: #2383; #### 4.0.3 - fix: state style restore for non-circle shapes; #### 4.0.2 - fix: node and edge state style with update problem; - fix: import lib problem; - fix: import node module problem; - fix: hidden shapes show up after zoom-canvas or drag-canvas with enableOptimize; - fix: tooltip for combo; - fix: update edge with false endArrow and startArrow; #### 4.0.1 - fix: glslang problem; #### 4.0.0-beta.0 - feat: fruchterman and gforce layout with gpu; - feat: gforce; - feat: updateChildren API for TreeGraph; - feat: louvain clustering algorithm; - feat: container of plugins with dom id; - feat: label propagation clustering algorithm; - feat: get color sets by subject color array; - feat: canvas context menu; - feat: stop gforce; - feat: dark rules for colors; - fix: text redidual problem, closes: #2045 #2193; - fix: graph on callback parameter type problem, closes: #2250; - fix: combo zIndex problem; - fix: webworker updateLayoutCfg problem; - fix: drag-canvas and click node on mobile; #### 3.8.5 - fix: get fontFamily of the window in global leads to DOM depending when using bigfish; #### 3.8.4 - feat: new version of basic styles for light version; - feat: shortcuts-call behavior for calling a Graph function by shortcuts; - feat: color generate util function getColorsWithSubjectColor; - fix: drag-canvas on mobile problem; - fix: style update problem with stateStyles in the options of registerNode; #### 3.8.3 - feat: drag the viewport of the minimap out of the the view; - fix: extend modelRect with description problem, closes: #2235; #### 3.8.2 - feat: graph.setImageWaterMarker, graph.setTextWaterMarker API; - feat: zoom-canvas support mobile; - fix: drag-canvas behavior support scalable range, closes: #2136; - fix: TreeGraph changeData clear all states, closes: #2173; - chore: auto zoom tooltip & contextMenu component when zoom-canvas; - chore: upgrade @antv/g-canvas; - feat: destroyLayout API for graph, closes: #2140; - feat: clustering for force layout, closes: #2196; - fix: svg renderer minimap hidden elements probem, closes: #2174; - feat: add extra parameter graph for menu plugin, closes: #2204; - fix: tooltip plugin, crossing different shape cant execute the getContent function, closes: #2153; - feat: add edgeConfig for create-edge behavior, closes: #2195; - fix: remove the source node while creat-edge; - feat: create-edge for combo, closes: #2211; - fix: update the typings for G6Event; #### 3.8.1 - fix: update edge states with updateItem problem, closes: #2142; - fix: create-edge behavior with polyline problem, closes: #2165; - fix: console.warn show duplicate ID, closes: #2163; - feat: support the drag-canvas behavior on the mobile device, closes: #816; - chore: timeBar component docs; #### 3.8.0 - fix: treeGraph render with addItem and stack problem, closes: #2084; - feat: G6 Interactive Document GraphMarker; - feat: registerNode with jsx support afterDraw and setState; - feat: edge filter lens plugin; - feat: timebar plugin; #### 3.7.3 - fix: update G to fix the shape disappear when it has been dragged out of the view port problem, closes: #2078, #2030, #2007; - fix: redo undo with treeGraph problem; - fix: remove item with itemType problem, closes: #2096. #### 3.7.2 - fix: toolbar redo undo addItem with type problem, closes #2043; - fix: optimized drag-canvas with hidden items; - fix: state style with 0 value problem, closes: #2039; - fix: layout with webworker leads to twice beforelayout, closes: #2052; - fix: context menu with sibling doms of graph container leads to position problem, closes: #2053; - fix: changeData with combos problem, closes: #2064; - fix: improve the position of the context menu before showing up; - feat: fisheye allows user to config the trigger of scaling range(r) and magnify factor(d) by scaleRBy and scaleDBy respectively; - feat: add the percent text of magnify factor(d) for fisheye and users are allowed to configure it by show showDPercent. #### 3.7.1 - fix: hide the tooltip plugin when drag node and contextmenu, closes #1975; - fix: processParellelEdges without edge id problem; - fix: label background with left, right position problem, closes #1861; - fix: create-edge and redo undo problem, #1976; - fix: tooltip plugin with shouldBegin problem, closes #2006; - fix: tooltip behavior with shouldBegin problem, closes #2016; - fix: the position of grid plugins when there is something on the top of the canvas, closes: #2012; - fix: fisheye destroy and new problem, closes: #2018; - fix: node event with wrong canvasX and canvasY problem, closes: #2027; - fix: drag combo and drag node to drop on canvas/combo/node problem; - feat: improve the performance on the combos; - fix: redo and undo problem when update item after additem, closes #2019; - feat: hide shapes beside keyShape while zooming; - feat: improve the performance on the combos. #### 3.7.0 - feat: chart node; - feat: bubble set; - feat: custom node with JSX; - feat: minimum spanning tree algorithm; - feat: path finding algorithm; - feat: cycle finding algorithm; - chore: update antv/hierarchy to fix indented tree with dropCap problem. #### 3.6.2 - feat: find all paths and the shortest path between two nodes; - feat: fisheye with dragging; - feat: fisheye with scaling range and d; - feat: click and drag to create an edge by create-edge behavior; - feat: process multiple parallel edges to quadratic with proper curveOffset; - fix: polyline with rect and radius=0 problem; - fix: arrow state & linkpoint; - fix: the position of the tooltip plugin; - fix: drop a node onto a sub node of a combo; - chore: update hierarchy to solve the children ordering problem for indented tree layout; - chore: extract the public calculation to enhance the performance of fisheye. #### 3.6.1-beta - chore: update g-canvas to support quickHit and pruning the rendering of the graph outside the viewport; - feat: add statistical chart nodes; - feat: add hull for create smooth contour to include specific items; - fix: clear combos before render; - fix: menu plugin with clickHandler problem. #### 3.6.1 - feat: image minimap; - feat: visible can be controlled in the data; - feat: item type for tooltip plugin; - feat: menu plugin with shouldUpdate; - fix: tooltip plugin position and hidden by removeItem; - fix: tooltip behavior hidden by removeItem; - fix: menu plugin with clicking on canvas problem; - fix: menu plugin with clickHandler problem; - fix: createCombo with double nodes problem. #### 3.6.0 - feat: fisheye lens plugin; - feat: lasso-select behavior; - feat: TimeBar plugin; - feat: ToolBar plugin. #### 3.5.12 - fix: node:click is triggered twice while clicking a node; - fix: update combo edge when drag node out of it problem; - feat: animate configuration for combo, true by default; - fix: calling canvas.on('\*', ...) instead of origin way in event controller leads to malposition while dragging nodes with zoomed graph. #### 3.5.11 - feat: graph.priorityState api; - feat: graph.on support name:event mode. - fix: combo edge with uncorrect end points; - fix: combo polyline edge with wrong path; - fix: getViewCenter with padding problem; - fix: cannot read property 'getModel' of null problem on contextmenu when the target is not an item; - feat: allow user to configure the initial positions for empty combos; - feat: optimize by hiding edges and shapes which are not keyShape while dragging canvas; - feat: fix the initial positions by equably distributing for layout to produce similar result. #### 3.5.10 - fix: fitView and fitCenter with animate in the initial state; - fix: dulplicated edges in nodeselectchange event of brush-select; - fix: triple click and drag canvas problem; - fix: sync the minZoom and maxZoom in drag-canvas and graph; - fix: integrate getSourceNeighbors and getTargetNeighbors to getNeighbors(node, type); - feat: initial x and y for combo data; - feat: dagre layout supports sortByCombo; - feat: allow user to disable relayout in collapse-expand-combo behavior; - feat: dijkstra shortest path lenght algorithm. #### 3.5.9 - fix: multiple animate update shape for combo; - fix: removeItem from a combo. #### 3.5.8 - fix: combo edge problem, issues #1722; - feat: adjacency matrix algorithm; - feat: Floyd Warshall shortest path algorithm; - feat: built-in arrows; - feat: built-in markers; - fix: force layout with addItem and relayout; - fix: create combo with parentId problem; - feat: allow user to configure the pixelRatio for Canvas; - chore: update G to resolve the blur canvas problem. #### 3.5.7 - feat: shouldBegin for click-select behavior; - feat: graph.getGroup, graph.getContainer, graph.getMinZoom, graph.setMinZoom, graph.getMaxZoom, graph.setMaxZoom, graph.getWidth, graph.getHeight API; - fix: combo edge dashLine attribute; - fix: combo collapse and expand with edges problem; - fix: destroy the tooltip DOMs when destroy the graph; - fix: unify the shape names for custom node and extended node; - fix: update the edges after first render with collapsed combos. #### 3.5.6 - feat: dropCap for indented TreeGraph layout. #### 3.5.5 - fix: custom node with setState problem; - fix: validationCombo in drag-combo and drag-node. #### 3.5.3 - feat: focusItem with animation; - feat: generate the image url of the full graph by graph.toFullDataUrl; - fix: graph dispears after being dragged out of the canvas and back; - fix: the graph cannot be dragged back if it is already out of the view; - fix: size and radius of the linkPoints problem; - fix: combo graph with unused state name in comboStateStyles; - fix: preventDefault in drag-canvas behavior. #### 3.5.2 - feat: degree algorithm; - feat: graph.getNodeDegree; - fix: downloadFullImage changes the matrix of the graph problem; - fix: circular layout modifies the origin data with infinite hierarchy problem. #### 3.5.1 - feat: graph.fitCenter to align the graph center to canvas center; - fix: getType is not a function error occurs when addItem with point; - fix: checking comboTrees avaiability; - fix: error occurs when createCombo into the graph without any combos; - fix: endPoint and startPoint are missing in modelConfig type; - fix: edge background leads to empty canvas when the autoRotate is false; - fix: combo state style bug. #### 3.5.0 - feat: combo and combo layout; - feat: graph algorithms: DFS, BFS and circle detection; - feat: add `getNeighbors`, `getSourceNeighbors`, `getTargetNeighbors` methods on Graph and Node; - feat: add `getID` method on Item; - fix: All Configuration type declarations are migrated to types folder, refer [here](https://github.com/antvis/G6/commit/3691cb51264df8529f75222147ac3f248b71f2f6?diff=unified#diff-76cf0eb5e3d8032945f1ac79ffc5e815R6); - fix: Some configuration type declarations have removed the `I` prefix, refer [here](https://github.com/antvis/G6/commit/3691cb51264df8529f75222147ac3f248b71f2f6?diff=unified#diff-aa582974831cee2972b8c96cfcce503aR16); - feat: Util.getLetterWidth and Util.getTextSize. #### 3.4.10 - fix: TreeGraphData type with style and stateStyles; - fix: wrong controlpoint position for bezier curves with getControlPoint. #### 3.4.9 - fix: transplie d3-force to support IE11. #### 3.4.8 - feat: update the keyShape type minimap when the node or edge's style is updated; - fix: problem about switching to another applications or browser menu and then switch back, the drag-canvas does not take effect; - fix: fix the problem about fail to render the graph when the animate and fitView are true by turn off the animate for rendering temporary; - fix: curveOffset for arc, quadratic, cubic edge. #### 3.4.7 - feat: downloadFullImage when the (part of) graph is out of the screen; - feat: With pre-graph has no layout configurations and no positions in data, calling changeData to change into a new data with positions, results in show the node with positions in data; - feat: allow user to assign curveOffset and curvePostion for Bezier curves; - fix: moveTo wrong logic problem; - fix: removeItem to update the minimap. #### 3.4.6 - same as 3.4.5, published wrongly. #### 3.4.5 - feat: background of the label on node or edge; - feat: better performance of minimap; - fix: minimap viewport displace problem; - feat: offset of tooltip; - fix: the length of the node's name affects the tree layout; - fix: toFront does not work for svg renderer; - fix: error occurs when the fontSize is smaller than 12 with svg renderer; - fix: changeData clears states; - fix: state does not work when default labelCfg is not assigned. #### 3.4.4 - feat: background color for downloadImage and toDataURL; - feat: support configure image for grid plugin; - fix: initial position for fruchterman layout; - refactor: clip for image node. - fix: cubic with only one controlPoint error; - fix: polyline without L attributes. #### 3.4.3 - fix: support extends BehaviorOption; - fix: click-select Behavior support multiple selection using ctrl key. #### 3.4.2 - feat: zoom-canvas behavior supports hiding non-keyshape elements when scaling canvas; - refactor: when the second parameter is null, clearItemStates will clear all states of the item; - fix: [changeData bug](https://github.com/antvis/G6/issues/1323); - fix: update antv/hierarchy to fix fixedRoot for TreeGraph; - fix: problem of a graph has multiple polyline edges; - fix: problem of dagre with controlPoints and loop edges. #### 3.4.1 - feat: force layout clone original data model to allow the customized properties; - fix: BehaviorOptions type error; - fix: fitView the graph with data whose nodes and edges are empty arrays; - fix: rect node positions are changed after calling graph.changeData; - fix: drag behavior is disabled when the keys are released invalidly; - refactor: update G and the fill of custom arrow should be assigned by user. #### 3.4.0 - feat: SVG renderer; - refactor: new state mechanism with multiple values, sub graphics shape style settings. #### 3.3.7 - feat: beforeaddchild and afteraddchild emit for TreeGraph; - feat: built-in nodes' labels can be captured; - fix: drag shadow caused by localRefresh, update the g-canvas version; - fix: abnormal polyline bendding; - fix: collapse-expand trigger problem; - fix: update nodes with empty string label; - fix: abnormal rendering when a graph has image nodes and other type nodes. #### 3.3.6 - feat: support edge weight for dagre layout; - feat: automatically add draggable to keyShape, users do not need to assign it when custom a node or an edge; - fix: cannot read 0 or null problem in getPointByCanvas; - fix: brush-select bug; - fix: set autoDraw to canvas when graph's setAutoPaint is called; - fix: modify the usage of bbox in view controller since the interface is chagned by G; - fix: the shape.attr error in updateShapeStyle; - fix: local refresh influence on changeData; - refactor: upgrade g-canvas to 0.3.23 to solve lacking of removeChild function; - doc: update the demo fo custom behavior doc; - doc: add plugin demos and cases for site; - doc: fix shouldUpdate problem in treeWithLargeData demo on the site. #### 3.3.5 - fix: 3.3.4 is not published successfully; #### 3.3.4 - fix: 3.3.3 is not published successfully; - fix: delegate or keyShape type minimap does not display bug; - fix: dragging bug on minimap with a graph whose bbox is nagtive; - fix: null matrix bug, create a unit matrix for null. #### 3.3.3 - fix: delegate or keyShape type minimap does not display bug; - fix: null matrix in focus() and getLoopCfgs() bug. #### 3.3.2 - fix: ts type export problem; - fix: edge with endArrow and autoRotate label bug; - fix: code prettier; - fix: line with control points bug; - fix: matrix null bug. #### 3.3.1 - fix: resolve 3.3.0 compatibility problem. #### 3.3.0 - Graph API - refactor: delete removeEvent function, use off; - refactor: parameters of Shape animate changed, shape.animate(toAttrs, animateCfg) or shape.animate(onFrame, animateCfg); - feat: descriptionCfg for modelRect to define the style of description by user; - feat: update a node from without some shapes to with them, such as linkPoints, label, logo icon and state icon for modelRect; - feat: the callback paramter of event nodeselectchange is changed to { target, selectedItems, ... }; - feat: support stateStyles in node and edge data; - feat: calculate pixelRatio by G automatically, user do not need to assign it to graph instance; - chore: G 4.0 - refactor: refreshLayout of TreeGraph is renamed as layout - fix: no fan shape in G any more - feat: recommand to assign name for each shape when addShape - fix: do not support SVG renderer anymore. no renderer for graph configuration anymore - refactor: plugins usage is changed into new G6.PluginName() #### 3.2.7 - feat: supports create the group without nodes in node-group; - fix: supports destoryed properties and fix issue 1094; #### 3.2.6 - feat: supports sort the nodes on one circle according to the data ordering or some attribute in radial layout - fix: grid layout with cols and rows - feat: fix the nodes with position information in their original data and random the positions of others when the layout is not defined for graph #### 3.2.5 - fix: click-select trigger error - fix: solved position problem for minimap #### 3.2.4 - fix: typescript compile error - fix: delete sankey lib #### 3.2.3 - fix: group position error - fix: supports not set layout type #### 3.1.5 - feat: supports g6 types file - fix: set brush-select trigger param to ctrl not work - fix: when set fitView to true, drag-group Behavior not get desired positon #### 3.1.3 - feat: radial layout nonoverlap iterations can be controlled by user - feat: add lock, unlock and hasLocked function, supports lock and unlock node - fix: mds with discrete points problem - fix: fruchterman-group layout title position for rect groups #### 3.1.2 - feat: default behavior supports configuration trigger mode - feat: node combining supports configuration title - fix: update demo state styles #### 3.1.1 - fix: update node use custom config - fix: update demo - feat: default node implement getShapeStyle function #### 3.1.0 - feat: support for rich layouts:random, radial, mds, circular, fruchterman, force, dagre - feat: more flexible configuration for shape - feat: build-in rich default nodes - feat: cases that provide layout and default nodes #### 3.0.7-beta.1 `2019-09-11` - fix: zoom-canvas support IE and Firefox #### 3.0.6 `2019-09-11` - fix: group data util function use module.exports - feat: update @antv/hierarchy version #### 3.0.5 `2019-09-10` - feat: support add and remove group - feat: support collapse and expand group - feat: add graph api: collapseGroup and expandGroup #### 3.0.5-beta.12 - feat: add rect group - feat: add rect group demo - feat: add chart node --- #### 3.0.5-beta.10 - feat: add 5 chart node - feat: collapse-expand tree support click and dblclick by trigger option - fix: drag group bug fix #### 3.0.5-beta.10 - feat: support render group - feat: support drag group, collapse and expand group, drag node in/out group - feat: add drag-group、collapse-expand-group and drag-node-with-group behavior - feat: add drag-group and collapse-expand-group demo - feat: add register list node demo #### 3.0.5-beta.8 `2019-07-19` - feat: add five demos - refactor: update three behaviors #### 2.2.5 `2018-12-20` - feat: add saveimage limitRatio #### 2.2.4 `2018-12-20` - fix: bug fix #### 2.2.3 `2018-12-10` - fix: bug fix #### 2.2.2 `2018-11-30` - fix: tree remove guide will not getEdges.closes #521 #### 2.2.1 `2018-11-25` - fix: Compatible with MOUSEWHEEL - fix: fadeIn aniamtion - fix: fix wheelZoom behaviour by removing the deprecated mousewheel event #### 2.2.0 `2018-11-22` - fix: Graph read zIndex - refactor: Animation #### 2.1.5 `2018-10-26` - fix: svg pixelRatio bug - feat: add wheel event #### 2.1.4 `2018-10-06` - fix: custom math.sign to compatible with ie browser.Closes #516. - fix: legend component from @antv/component - feat: update svg minimap && fix svg dom event #### 2.1.3 `2018-09-27` - feat: add label rotate - feat: if there is no items the graph box equal canvas size #### 2.1.2 `2018-09-19` - fix: dom getShape bug.Closes #472 - fix: template.maxSpanningForest bug #### 2.1.1 `2018-09-17` - fix: tool.highlightSubgraph calculate box bug - fix: plugin.grid.Closes #479 - chore(dev): upgrade babel & torchjs #### 2.1.0 `2018-09-03` - feat: svg render - feat: plugin.layout.forceAtlas2 - feat: plugin.tool.fisheye - feat: plugin.tool.textDisplay - feat: plugin.tool.grid - feat: plugin.template.tableSankey - feat: plugin.edge.polyline #### 2.0.5 `2018-07-12` - improve: add g6 arrow #### 2.0.4 `2018-07-12` - feat: layout export group.Closes #355 - feat(plugin): add tool.tooltip. Closes #360. - style: change the calling way of forceAtlas2 on template.maxSpanningForest - fix: origin tree data collapsed is true tree edge visible bug.Closes #357 - fix: remove the forceAtlas.js in template.maxSpanningForest, use forceAtlas from layout.forceAtlas2 - fix: add demos: plugin-fisheye, plugin-forceAtlas2, gallery-graphanalyzer - fix: add demos: plugin-forceAtlas2, plugin-fisheye #### 2.0.3 `2018-06-29` - feat: update g to 3.0.x. Closes #346 - fix: group should use rect intersect box. Close #297 - fix(plugin): dagre edge controlpoints remove start point and end point - style: remove some annotations - chore: update torchjs && improve demo name #### 2.0.2 `2018-06-13` - chore(plugin): require g6 by src/index - chore(dev test): remove useless test script - fix(plugin) minimap destroy Closes #308 - fix(saveImage) saveImage bug - fix(event): fix dom coord. Closes #305 #### 2.0.1 `2018-06-11` - fix: reDraw edge after layout - feat: add quadraticCurve config cpd - feat: add beforelayout && afterlayout event - chore: .travis.yml add add Node.js - chore: .travis.yml cache node_modules #### 2.0.0 `2018-06-06` - refactor: refactor architecture && code #### 1.2.1 `2018-03-15` - feat: layout interface #### 1.2.0 `2018-01-15` - fix: nodeActivedBoxStyle spelling error - fix: error when deleting a circle - fix: trigger dragstart while right clicking and moveing - feat: Unify Layout mechanism - feat: Plugin mechanism - feat: Data filter mechanism - feat: Activated interface - feat: Action wheelZoomAutoLabel - feat: configuration of graph -- preciseAnchor - remove: Global.preciseAnchor - remove: Layout.Flow、Layout.Force - improve: html container strategy #### 1.1.6 `2017-10-15` - fix: pack problem in layout algorithm #### 1.1.5 `2017-09-15` - fix: dragCanvas is effective while mousemove, prevent it from affecting click events - fix: unactivate pick-up in activeRectBox of node #### 1.1.4 `2017-08-15` - feat: graph.invertPoint() - feat: third configuration of anchor to support style setting, float style, connection - feat: item.getGroup() - feat: events -- afteritemrender、itemremove、itemadd - feat: behaviourSignal - improve: mouseWheel is affective after focusing the canvas #### 1.1.3 `2017-08-8` - feat: Graph configuration -- useNodeSortGroup - feat: Global.nodeDelegationStyle, Global.edgeDelegationStyle, isolate the delegation of edge and node on graph - fix: itemremove is triggered before destroying a graph #### 1.1.2 `2017-08-01` - feat: dragBlankX dragBlankY #### 1.1.1 `2017-07-18` - improve: dragNode protect mechanism #### 1.1.0 `2017-07-05` - feat: HTML node - feat: mapper support callback function - feat: Graph interfaces -- updateMatrix、changeSize、showAnchor、hideAnchor、updataNodesPosition - feat: tool functions -- Util.isNode()、Util.isEdge() - feat: Shape polyLineFlow - feat: dragEdgeEndHideAnchor、dragNodeEndHideAnchor、hoverAnchorSetActived、hoverNodeShowAnchor #### 1.0.7 `2017-06-21` - fix: draw one more time in 16ms after first draw - improve: add zoom by scroll in edit mode #### 1.0.6 `2017-06-15` - fix: compatible in chrome in windows. triggering mousemove after first click leads to wrong click event. - feat: support fix size graphics - feat: analysis mode - feat: updateNodesPositon update a set of nodes' position - improve: change useAnchor to be a configuration of edge #### 1.0.5 `2017-06-01` - feat: downloadImage support saving with name - feat: automatically detect tooltip padding - improve: stop the action while mouse dragging out of the canvas #### 1.0.4 `2017-05-20` - fix: tree changeData Bug - fix: when getAnchorPoints returns auto, anchor is the intersection of edge and the bounding box - fix: generate node label according to isNull - feat: viewport parameters -- tl、tc、tr、rc、br、bc、bl、lc、cc - improve: reduce tolerance to improve the accuracy of interception - improve: improve tooltip event mechanisom to enhance performance #### 1.0.3 `2017-05-10` - feat: graph.guide().link() #### 1.0.2 `2017-05-10` - fix: Object.values => Util.getObjectValues - fix: when anchorPoints is auto, there is only anchorpoint on edge, it will also return the intersection - fix: tree update interface Bug - improve: represent positions information by group.transfrom() #### 1.0.1 `2017-04-22` - fix: copy and paste bug - feat: draw once in 16ms - feat: itemactived itemunactived itemhover itemupdate itemmouseenter itemmouseleave - improve: be clear the status of graphics before activating graphics by frame selection - improve: dragAddEdge, linkable to anchor - improve: performance of animation #### 1.0.0 `2017-03-31` - feat: fitView configurations - feat: graph.zoom() - feat: wheelZoomHideEdges hide the edges while zooming by wheel - feat: dragHideEdges hide the edge while dragging edge - feat: graph.filterBehaviour() - feat: graph.addBehaviour() - feat: graph.changeLayout() - feat: read interface, re-define save interface - feat: graph.snapshot, graph.downloadImage - feat: graph.autoSize() - feat: graph.focusPoint() - feat: tree graph、net graph - feat: interaction mechanism -- event => action => mode - feat: animation mechanism - feat: itemmouseleave、itemmouseenter - remove: graph.refresh() - remove: graph.changeNodes() - remove: graph attributes -- zoomable、draggable、resizeable、selectable - improve: anchor mechanism - improve: hide G6.GraphUtil functions, unified in G6.Util - improve: replace g-canvas-core to g-canvas to improve performance - improve: Global.nodeAcitveBoxStyle instead of Global.nodeBoxStyle - improve: afterAdd => afteradd - improve: G6.Graph to be an abstract class #### 0.2.3 `2017-03-2` - fix: draggable for controlling draggable under default mode - feat: graph.converPoint() - feat: graph.autoSize() - feat: rightmousedown leftmousedown wheeldown - improve: use try catch to prevent the length of getPoint of path equals zero #### 0.2.2 `2017-02-24` - fix: add px totooltip css padding - fix: tooltip mapping error - fix: accurate intersection - fix: zoom error on double accuracy screen - fix: buonding box extended from keyShape - feat: afterAdd - feat: dblclick - improve: width、height default null - improve: remove hovershape on node - improve: tooltip defense mechanism #### 0.2.1 `2017-02-14` - fix: rollback when add node - fix: apply tranformation of parent container while calculating bounding box - feat: waterPath - feat: tooltip tip information - feat: mouseover - feat: multiSelectable, default false - feat: set forceFit to true while width is undefined - improve: zoomable、draggable、resizeable、selectable default true #### 0.2.0 `2017-02-07` - feat: accurate anchor mechanism - feat: GraphUtil.getEllipsePath - feat: GraphUtil.pointsToPolygon - feat: GraphUtil.pointsToBezier - feat: GraphUtil.snapPreciseAnchor - feat: GraphUtil.arrowTo - feat: GraphUtil.drawEdge - feat: bezierQuadratic - feat: node.show - feat: node.hide - feat: node.getLinkNodes - feat: node.getUnLinkNodes - feat: node.getRelativeItems - feat: node.getUnRelativeItems - feat: edge.show - feat: edge.hide - feat: Shape afterDraw - improve: the controlling point positions of Bezier Curve 改进贝塞尔曲线控制点位置 - improve: grpah.delete => graph.del - improve: error when adding id #### 0.1.4 `2017-01-17` - fix: delegator of dragging a node is the center of bbox - fix: use cardinality sort for all the sorting algorithm - fix: random id on edges - feat: level sort on edges, edge labels on the top level - feat: while extending shape is undefined when register an edge, find the extending shaoe automatically #### 0.1.3 `2017-01-15` - fix: judge the existance of the object while operating assistGrid - feat: rollback judgement, default unactivate - feat: style mapping channel - feat: return the intersections while getAnchorPoints is null or returns false - feat: bezierHorizontal、bezierVertical - improve: 'eventEnd' #### 0.1.2 `2017-01-12` - fix: judge the configuration before updating grid - fix: the size of graphContainer in unsetable, setted by inner canvas - fix: will not add an edge if the target or source is undefined - fix: changeSize() maximum tolerance for error - feat: graph.get('el') to get canvas DOM - feat: event exposures shape #### 0.1.1 `2017-01-09` - feat: entrance of graph is G6.Graph #### 0.1.0 `2017-01-07` - feat: color calculation library - feat: hot key - feat: updo, redo - feat: copy, paste - feat: reset zoom, auto zoom - feat: tree graph, linear graph, sankey graph, flow laout - feat: flow chart package - feat: timing diagram package - feat: single selection, frame selection - feat: node deformation - feat: edge deformation - feat: drag node and edge - feat: link edge and node - feat: drag canvas - feat: zoom - feat: select mode - feat: integrate g-graph ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Alipay.inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: PUBLISH.md ================================================ This project uses changeset to manage version release, and the specific release process is as follows: 1. Complete related development work 2. Create a branch from v5 (any name you want) 3. Run `npm run version` command, fill in the information according to the prompt, and the version number will be updated automatically 4. Commit the changes to the remote repository 5. Create a PR on GitHub, add the `publish` label, and merge the branch to v5 6. After the branch is merged, GitHub Actions will be triggered automatically, and the package will be published to npm 7. After the release, the Release note needs to be updated. Execute "pnpm tag" in the packages/g6 8. Fill in the tag information on the newly opened Github link. First, select the previous tag, and then select the current tag to obtain the changes. After confirming that there are no issues, release it. --- 本项目通过 changeset 来管理版本发布,具体的发布流程如下: 1. 完成相关的开发工作 2. 从 v5 分支创建一个分支(任意分支名均可) 3. 根目录执行 `npm run version` 命令,根据提示填写相关信息,会自动更新版本号 4. 将变更提交到远程仓库 5. 在 GitHub 上创建一个 PR,并添加 `publish` 标签,将该分支合并到 v5 分支 6. 分支合并后,会自动触发 GitHub Actions,发布到 npm 7. 发布后,需更新 Release note,在 packages/g6 目录下执行 pnpm tag 8. 在新打开的 Github 链接填写 tag 信息,先选择前一个 tag, 然后选择当前 tag 后得到变更,确认没有问题后发布 ================================================ FILE: README.md ================================================ English | [简体中文](./README.zh-CN.md)

G6: A Graph Visualization Framework in TypeScript

![](https://user-images.githubusercontent.com/6113694/45008751-ea465300-b036-11e8-8e2a-166cbb338ce2.png)

antvis%2FG6 | Trendshift

[![npm Version](https://img.shields.io/npm/v/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) [![Build Status](https://github.com/antvis/G6/actions/workflows/build.yml/badge.svg)](https://github.com/antvis/G6/actions/workflows/build.yml) [![codecov](https://codecov.io/gh/antvis/G6/graph/badge.svg?token=OvIk06tCPa)](https://codecov.io/gh/antvis/G6) [![npm Download](https://img.shields.io/npm/dm/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) ![typescript](https://img.shields.io/badge/language-typescript-blue.svg) [![npm License](https://img.shields.io/npm/l/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/antvis/G6)

IntroductionExamplesQuick StartAPI

[G6](https://github.com/antvis/g6) is a graph visualization engine. It provides basic capabilities for graph visualization and analysis such as drawing, layout, analysis, interaction, animation, themes, and plugins. With G6, users can quickly build their own graph visualization and analysis applications, making relational data simple, transparent, and meaningful. ## ✨ Features G6, as a professional graph visualization engine, boasts the following features: - **Rich Elements**: It comes with a variety of built-in node, edge, and Combo UI elements with extensive style configurations, supports data callbacks, and has a flexible mechanism for extending custom elements. - **Controllable Interactions**: It includes more than 10 built-in interaction behaviors and offers a rich array of events, facilitating the expansion of custom interactive behaviors. - **High-Performance Layout**: The engine features more than 10 common graph layouts, some of which leverage GPU and Rust parallel computing for enhanced performance, and it supports custom layout development. - **Convenient Plugins**: Optimized built-in plugin functionality and performance, with flexible extensibility, making it easier to implement customized business capabilities. - **Multiple Theme and Palettes**: Provides two sets of built-in themes, light and dark, that integrate over 20 popular community color palettes based on the AntV new color scheme. - **Multi-Environment Rendering**: Harnessing the power of [G](https://github.com/antvis/g), it supports rendering in Canvas, SVG, and WebGL, as well as server-side rendering with Node.js; it also offers plugin packages that provide powerful 3D rendering and spatial interactions based on WebGL. - **React Ecosystem**: By utilizing the React front-end ecosystem, it supports React nodes, significantly enriching the presentational styles of G6 nodes. ## 🔨 Getting Started G6 is usually installed via a package manager such as npm or Yarn. ```bash $ npm install @antv/g6 ``` The `Graph` object then can be imported from G6. ```html
``` ```js import { Graph } from '@antv/g6'; // Get the Data. const data = { nodes: [ /* your nodes data */ ], edges: [ /* your edges data */ ], }; // Create the Graph instance. const graph = new Graph({ container: 'container', data, node: { palette: { type: 'group', field: 'cluster', }, }, layout: { type: 'force', }, behaviors: ['drag-canvas', 'drag-node'], }); // Render the Graph. graph.render(); ``` All goes well, you can get the following lovely graph! ## 🌍 Ecosystem - **Ant Design Charts**: A React chart library based on G2, G6, X6, L7. - **Graphin**: A simple React wrapper based on G6, as well as an SDK for developing graph visualization applications. For more ecosystem open-source projects, contributions are welcome. Please feel free to submit a PR for inclusion. ## 📮 Contributing This project exists thanks to all the people who contribute. And thank you to all our backers! 🙏 Contribution Leaderboard - **Issue Reporting**: If you encounter any issues with G6 during use, please feel free to submit an issue, along with the minimal sample code that can reproduce the problem. - **Contribution Guide**: Information on how to get involved in the [development and contribution](https://g6.antv.antgroup.com/en/manual/contribute) to G6. - **Ideas Discussion**: Discuss your ideas on GitHub Discussions or in the DingTalk group.
## 📄 License [MIT](./LICENSE). ================================================ FILE: README.zh-CN.md ================================================ [English](./README.md) | 简体中文

G6:图可视分析引擎

![](https://user-images.githubusercontent.com/6113694/45008751-ea465300-b036-11e8-8e2a-166cbb338ce2.png)

antvis%2FG6 | Trendshift

[![npm Version](https://img.shields.io/npm/v/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) [![Build Status](https://github.com/antvis/G6/actions/workflows/build.yml/badge.svg)](https://github.com/antvis/G6/actions/workflows/build.yml) [![codecov](https://codecov.io/gh/antvis/G6/graph/badge.svg?token=OvIk06tCPa)](https://codecov.io/gh/antvis/G6) [![npm Download](https://img.shields.io/npm/dm/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) ![typescript](https://img.shields.io/badge/language-typescript-blue.svg) [![npm License](https://img.shields.io/npm/l/@antv/g6.svg)](https://www.npmjs.com/package/@antv/g6) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/antvis/G6)

简介图表示例快速开始API

[G6](https://github.com/antvis/g6) 是一个图可视化引擎。它提供了图的绘制、布局、分析、交互、动画、主题、插件等图可视化和分析的基础能力。基于 G6,用户可以快速搭建自己的图可视化分析应用,让关系数据变得简单,透明,有意义。 ## ✨ 特性 G6 作为一款专业的图可视化引擎,具有以下特性: - **丰富的元素**:内置丰富的节点、边、Combo UI 元素,样式配置丰富,支持数据回调,且具备有灵活扩展自定义元素的机制。 - **可控的交互**:内置 10+ 交互行为,且提供丰富的各类事件,便于扩展自定义的交互行为。 - **高性能布局**:内置 10+ 常用的图布局,部分基于 GPU、Rust 并行计算提升性能,支持自定义布局。 - **便捷的组件**:优化内置组件功能及性能,且有灵活的扩展性,便于业务实现定制能力。 - **多主题色板**:提供了亮色、暗色两套内置主题,在 AntV 新色板前提下,融入 20+ 常用社区色板。 - **多环境渲染**:发挥 [G](https://github.com/antvis/g) 能力, 支持 Canvas、SVG 以及 WebGL,和 Node.js 服务端渲染;基于 WebGL 提供强大 3D 渲染和空间交互的插件包。 - **React 体系**:利用 React 前端生态,支持 React 节点,大大丰富 G6 的节点呈现样式。 ## 🔨 开始使用 可以通过 NPM 或 Yarn 等包管理器来安装。 ```bash $ npm install @antv/g6 ``` 成功安装之后,可以通过 import 导入 `Graph` 对象。 ```html
``` ```js import { Graph } from '@antv/g6'; // 准备数据 const data = { nodes: [ /* your nodes data */ ], edges: [ /* your edges data */ ], }; // 初始化图表实例 const graph = new Graph({ container: 'container', data, node: { palette: { type: 'group', field: 'cluster', }, }, layout: { type: 'force', }, behaviors: ['drag-canvas', 'drag-node'], }); // 渲染图 graph.render(); ``` 一切顺利,你可以得到下面的力导图! ## 🌍 生态 - **Ant Design Charts**: React 图表库,基于 G2、G6、X6、L7。 - **Graphin**:基于 G6 的 React 简单封装,以及图可视化应用研发的 SDK。 更多生态开源项目,欢迎 PR 收录进来。 ## 📮 贡献 感谢所有为这个项目做出贡献的人,感谢所有支持者!🙏 Contribution Leaderboard - **问题反馈**:使用过程遇到的 G6 的问题,欢迎提交 Issue,并附上可以复现问题的最小案例代码。 - **贡献指南**:如何参与到 G6 的[开发和贡献](https://g6.antv.antgroup.com/manual/contribute)。 - **想法讨论**:在 GitHub Discussion 上或者钉钉群里面讨论。
## 📄 License [MIT](./LICENSE). ================================================ FILE: SECURITY.md ================================================ # Security Policy Could the maintainers please create and publish a security.md with security policy that indicates the process for submitting vulnerabilities, tracking, and expectations for users of remediation of vulnerabilities? ## Supported Versions Use this section to tell people about which versions of your project are currently being supported with security updates. | Version | Supported | | ------- | ------------------ | | 5.1.x | :white_check_mark: | | 5.0.x | :x: | | 4.0.x | :white_check_mark: | | < 4.0 | :x: | ## Reporting a Vulnerability Use this section to tell people how to report a vulnerability. Tell them where to go, how often they can expect to get an update on a reported vulnerability, what to expect if the vulnerability is accepted or declined, etc. ================================================ FILE: package.json ================================================ { "name": "g6", "private": true, "repository": "https://github.com/antvis/G6.git", "scripts": { "build": "turbo build --filter=!@antv/g6-site", "ci": "turbo run ci --filter=!@antv/g6-site", "dev:g6": "cd ./packages/g6 && npm run dev", "postinstall": "husky install", "perf": "npm run perf", "prepare": "husky install", "publish": "pnpm publish -r --publish-branch v5", "site": "pnpm -r --stream --filter=./packages/site run dev", "version": "./scripts/version.sh", "watch": "pnpm -r --stream --filter=!./site run start" }, "commitlint": { "extends": [ "@commitlint/config-conventional" ] }, "lint-staged": { "*.{ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{json,md}": [ "prettier --write" ] }, "devDependencies": { "@antv/g-canvas": "^2.2.0", "@antv/g-plugin-rough-canvas-renderer": "^2.1.1", "@babel/core": "^7.28.6", "@babel/plugin-transform-typescript": "^7.28.6", "@changesets/cli": "^2.29.8", "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.3", "@playwright/test": "^1.58.0", "@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@swc/core": "^1.15.11", "@swc/jest": "^0.2.39", "@types/d3-hierarchy": "^3.1.7", "@types/jest": "^29.5.14", "@types/jsdom": "^21.1.7", "@types/node": "^20.19.30", "@types/stats.js": "^0.17.4", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "chalk": "^4.1.2", "d3-hierarchy": "^3.1.2", "eslint": "^8.57.1", "eslint-plugin-jsdoc": "^46.10.1", "husky": "^8.0.3", "iperf": "0.1.0-beta.14", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^23.2.0", "lil-gui": "^0.19.2", "limit-size": "^0.1.4", "lint-staged": "^15.5.2", "npm-run-all": "^4.1.5", "open": "^10.2.0", "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-packagejson": "^2.5.22", "rimraf": "^5.0.10", "rollup": "^4.57.0", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-visualizer": "^5.14.0", "stats.js": "^0.17.0", "svgo": "^3.3.2", "ts-node": "^10.9.2", "tslib": "^2.8.1", "turbo": "^1.13.4", "typescript": "^5.9.3", "vite": "^5.4.21" }, "pnpm": { "onlyBuiltDependencies": [ "canvas" ], "overrides": { "@umijs/mako": "0.9.2" }, "ignoredBuiltDependencies": [ "@parcel/watcher", "@swc/core", "core-js", "core-js-pure", "esbuild", "iperf" ] } } ================================================ FILE: packages/bundle/index.html ================================================ G6 Bundler Test
================================================ FILE: packages/bundle/package.json ================================================ { "name": "bundle", "private": true, "scripts": { "build": "run-s build:*", "build:rollup": "rollup -c", "build:vite": "vite build", "build:webpack": "webpack", "ci": "npm run build" }, "dependencies": { "@antv/g6": "workspace:*" }, "devDependencies": { "@rollup/plugin-commonjs": "^25.0.8", "@rollup/plugin-node-resolve": "^15.3.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "rollup": "^4.40.2", "rollup-plugin-polyfill-node": "^0.13.0", "swc": "^1.0.11", "vite": "^5.4.19", "webpack": "^5.99.8", "webpack-cli": "^5.1.4" } } ================================================ FILE: packages/bundle/rollup.config.mjs ================================================ import commonjs from '@rollup/plugin-commonjs'; 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/rollup/g6.umd.js', name: 'g6', format: 'umd', sourcemap: false, }, plugins: [nodePolyfills(), resolve(), commonjs(), typescript(), terser()], }; ================================================ FILE: packages/bundle/src/index.ts ================================================ import { Graph } from '@antv/g6'; const data = { nodes: [ { id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }, { id: '7' }, { id: '8' }, { id: '9' }, ], edges: [ { source: '0', target: '1' }, { source: '0', target: '2' }, { source: '1', target: '4' }, { source: '0', target: '3' }, { source: '3', target: '4' }, { source: '4', target: '5' }, { source: '4', target: '6' }, { source: '5', target: '7' }, { source: '5', target: '8' }, { source: '8', target: '9' }, { source: '2', target: '9' }, { source: '3', target: '9' }, ], }; const graph = new Graph({ container: 'container', autoFit: 'view', animation: false, data, layout: { type: 'antv-dagre', nodeSize: [60, 30], nodesep: 60, ranksep: 40, controlPoints: true, }, node: { type: 'rect', style: { size: [60, 30], radius: 8, labelText: (d) => d.id, labelBackground: true, }, }, edge: { type: 'polyline', }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas'], }); graph.render(); ================================================ FILE: packages/bundle/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "outDir": "lib", "paths": { "@antv/g6": ["../g6/src/index.ts"] } }, "extends": "../../tsconfig.json", "include": ["src/**/*"] } ================================================ FILE: packages/bundle/vite.config.js ================================================ import { defineConfig } from 'vite'; export default defineConfig({ build: { lib: { entry: 'src/index.ts', name: 'g6', fileName: 'g6', formats: ['umd'], }, outDir: 'dist/vite', }, }); ================================================ FILE: packages/bundle/webpack.config.js ================================================ const path = require('path'); module.exports = { entry: './src/index.ts', output: { filename: 'g6.umd.js', path: path.resolve(__dirname, 'dist/webpack'), }, }; ================================================ FILE: packages/cli/CHANGELOG.md ================================================ # @antv/g6-cli ## 0.0.2 ### Patch Changes - chore, feat, bugfix ================================================ FILE: packages/cli/README.md ================================================ # @antv/g6-cli `@antv/g6-cli` is a G6 template generation tool that comes with several templates. Currently, it owns a built-in template called `extension`. This template handles the boilerplate setup, which encompasses a seamless local development environment, linting, code formatting, Jest for snapshot testing and bundling with Rollup etc. `@antv/g6-cli` i ## Getting Started To start using `@antv/g6-cli`, you'll first need to install it globally. ```bash npm i @antv/g6-cli -g ``` Once installed, you can easily scaffold a new project: ```bash create-g6 ``` Then follow the prompts! ![prompts](https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*09BKQrIcZUMAAAAAAAAAAAAADmJ7AQ/original) You can also directly specify the project name and the template you want to use via additional command line options. For example, to scaffold a **G6 Extension** project, run: ```bash create-g6 g6-extension-test --template extension ``` ================================================ FILE: packages/cli/build.config.ts ================================================ import { defineBuildConfig } from 'unbuild'; export default defineBuildConfig({ entries: ['src/index'], clean: true, rollup: { inlineDependencies: true, esbuild: { target: 'node18', minify: true, }, }, }); ================================================ FILE: packages/cli/index.js ================================================ #!/usr/bin/env node import './dist/index.mjs'; ================================================ FILE: packages/cli/package.json ================================================ { "name": "@antv/g6-cli", "version": "0.0.3", "description": "Scaffolding Your Extension for G6", "keywords": [ "antv", "g6", "extension", "template" ], "repository": "https://github.com/antvis/G6.git", "license": "MIT", "author": "yvonneyx", "type": "module", "main": "index.js", "bin": { "create-g6": "index.js" }, "files": [ "index.js", "template-*", "dist" ], "scripts": { "build": "unbuild", "dev": "unbuild --stub", "prepublishOnly": "npm run build", "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/lodash": "^4.17.16", "@types/minimist": "^1.2.5", "@types/prompts": "^2.4.9", "kolorist": "^1.8.0", "minimist": "^1.2.8", "prompts": "^2.4.2", "unbuild": "^2.0.0" }, "engines": { "node": "^18.0.0 || >=20.0.0" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/cli/src/index.ts ================================================ /* eslint-disable jsdoc/require-jsdoc */ import { red, reset, yellow } from 'kolorist'; import minimist from 'minimist'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import prompts from 'prompts'; const argv = minimist<{ t?: string; template?: string; }>(process.argv.slice(2), { string: ['_'] }); const cwd = process.cwd(); const renameFiles: Record = {}; const defaultTargetDir = 'g6-extension-test'; const TEMPLATES = [ { name: 'extension', display: 'Extension', color: yellow, }, ]; const TEMPLATE_NAMES = TEMPLATES.map((template) => template.name); async function init() { const argTargetDir = formatTargetDir(argv._[0]); const argTemplate = argv.template || argv.t; let targetDir = argTargetDir || defaultTargetDir; const getProjectName = () => (targetDir === '.' ? path.basename(path.resolve()) : targetDir); let result: prompts.Answers<'template' | 'projectName' | 'overwrite' | 'author'>; prompts.override({ overwrite: argv.overwrite, }); try { result = await prompts( [ { type: argTemplate && TEMPLATE_NAMES.includes(argTemplate) ? null : 'select', name: 'template', message: typeof argTemplate === 'string' && !TEMPLATE_NAMES.includes(argTemplate) ? reset(`"${argTemplate}" isn't a valid template. Please choose from below: `) : reset('Select a template:'), initial: 0, choices: TEMPLATES.map((template) => { const templateColor = template.color; return { title: templateColor(template.display || template.name), value: template, }; }), }, { type: () => (!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'select'), name: 'overwrite', message: () => (targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`) + ` is not empty. Please choose how to proceed:`, initial: 0, choices: [ { title: 'Remove existing files and continue', value: 'yes', }, { title: 'Cancel operation', value: 'no', }, { title: 'Ignore files and continue', value: 'ignore', }, ], }, { type: (_, { overwrite }: { overwrite?: string }) => { if (overwrite === 'no') { throw new Error(red('✖') + ' Operation cancelled'); } return null; }, name: 'overwriteChecker', }, { type: argTargetDir ? null : 'text', name: 'projectName', message: reset('Project name:'), initial: defaultTargetDir, onState: (state) => { targetDir = formatTargetDir(state.value) || defaultTargetDir; }, }, { type: 'text', name: 'author', message: reset('Author'), }, ], { onCancel: () => { throw new Error(red('✖') + ' Operation cancelled'); }, }, ); } catch (cancelled: any) { console.log(cancelled.message); return; } // user choice associated with prompts const { template, overwrite, projectName = getProjectName(), author } = result; const variables = { '{{projectName}}': projectName, }; const root = path.join(cwd, targetDir); if (overwrite === 'yes') { emptyDir(root); } else if (!fs.existsSync(root)) { fs.mkdirSync(root, { recursive: true }); } const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent); const pkgManager = pkgInfo ? pkgInfo.name : 'npm'; console.log(`\nScaffolding project in ${root}...`); const templateDir = path.resolve(fileURLToPath(import.meta.url), '../..', `template-${template.name}`); const write = (file: string, variables: Record, content?: string) => { const targetPath = path.join(root, renameFiles[file] ?? file); if (content) { fs.writeFileSync(targetPath, content); } else { copy(path.join(templateDir, file), targetPath, variables); } }; const files = fs.readdirSync(templateDir); for (const file of files.filter((f) => f !== 'package.json')) { write(file, variables); } const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')); pkg.name = projectName; pkg.author = author; write('package.json', variables, JSON.stringify(pkg, null, 2) + '\n'); const cdProjectName = path.relative(cwd, root); console.log(`\nDone. Now run:\n`); if (root !== cwd) { console.log(` cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`); } switch (pkgManager) { case 'yarn': console.log(' yarn'); console.log(' yarn dev'); break; default: console.log(` ${pkgManager} install`); console.log(` ${pkgManager} run dev`); break; } console.log(); } function formatTargetDir(targetDir: string | undefined) { return targetDir?.trim().replace(/\/+$/g, ''); } function copy(src: string, dest: string, variables: Record) { const stat = fs.statSync(src); if (stat.isDirectory()) { copyDir(src, dest, variables); } else { const templateContent = fs.readFileSync(src, 'utf-8'); const content = replaceTemplateVariables(templateContent, variables); fs.writeFileSync(dest, content); } } function copyDir(srcDir: string, destDir: string, variables: Record) { fs.mkdirSync(destDir, { recursive: true }); for (const file of fs.readdirSync(srcDir)) { const srcFile = path.resolve(srcDir, file); const destFile = path.resolve(destDir, file); copy(srcFile, destFile, variables); } } function isEmpty(path: string) { const files = fs.readdirSync(path); return files.length === 0 || (files.length === 1 && files[0] === '.git'); } function emptyDir(dir: string) { if (!fs.existsSync(dir)) { return; } for (const file of fs.readdirSync(dir)) { if (file === '.git') { continue; } fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); } } function pkgFromUserAgent(userAgent: string | undefined) { if (!userAgent) return undefined; const pkgSpec = userAgent.split(' ')[0]; const pkgSpecArr = pkgSpec.split('/'); return { name: pkgSpecArr[0], version: pkgSpecArr[1], }; } function replaceTemplateVariables(content: string, variables: Record) { Object.keys(variables).forEach((key) => { const regex = new RegExp(key, 'g'); content = content.replace(regex, variables[key]); }); return content; } init().catch((e) => { console.error(e); }); ================================================ FILE: packages/cli/template-extension/.commitlintrc.js ================================================ module.exports = { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [ 2, 'always', ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test', 'wip'], ], }, }; ================================================ FILE: packages/cli/template-extension/.editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [Makefile] indent_style = tab ================================================ FILE: packages/cli/template-extension/.eslintignore ================================================ dist es lib node_modules ================================================ FILE: packages/cli/template-extension/.eslintrc.js ================================================ module.exports = { root: true, env: { browser: true, es2021: true, node: true, commonjs: true, jest: true, }, extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], overrides: [ { env: { node: true, }, files: ['.eslintrc.{js,cjs}'], parserOptions: { sourceType: 'script', }, }, ], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module', }, plugins: ['@typescript-eslint', 'jsdoc'], rules: { quotes: ['error', 'single', { allowTemplateLiterals: true, avoidEscape: true }], semi: ['error', 'always'], }, }; ================================================ FILE: packages/cli/template-extension/.gitignore ================================================ # Node node_modules/ # Build dist lib esm ================================================ FILE: packages/cli/template-extension/.prettierignore ================================================ dist es lib node_modules ================================================ FILE: packages/cli/template-extension/.prettierrc.js ================================================ module.exports = { plugins: [require.resolve('prettier-plugin-organize-imports'), require.resolve('prettier-plugin-packagejson')], printWidth: 120, proseWrap: 'never', singleQuote: true, trailingComma: 'all', }; ================================================ FILE: packages/cli/template-extension/__tests__/demos/element-node-extend.ts ================================================ import { ExtendNode } from '@/src'; import { ExtensionCategory, Graph, register } from '@antv/g6'; export const elementNodeExtend: TestCase = async (context) => { register(ExtensionCategory.NODE, 'extend-node', ExtendNode); const graph = new Graph({ ...context, data: { nodes: [{ id: 'node1', style: { x: 100, y: 100 } }], }, node: { type: 'extend-node' }, }); await graph.render(); return graph; }; ================================================ FILE: packages/cli/template-extension/__tests__/demos/index.ts ================================================ export * from './element-node-extend'; ================================================ FILE: packages/cli/template-extension/__tests__/index.html ================================================ {{projectName}}
================================================ FILE: packages/cli/template-extension/__tests__/main.ts ================================================ import type { Controller } from 'lil-gui'; import GUI from 'lil-gui'; import _ from 'lodash'; import * as demos from './demos'; const { toUpper, snakeCase } = _; const demoNames = Object.keys(demos); const options = { demo: '', }; const customForm: Controller[] = []; const panel = new GUI({ autoPlace: true }); const __STORAGE__ = `__` + toUpper(snakeCase('{{projectName}}')) + `_DEMO__`; const load = () => { const data = localStorage.getItem(__STORAGE__); if (data) panel.load(JSON.parse(data)); }; const save = () => { localStorage.setItem(__STORAGE__, JSON.stringify(panel.save())); }; panel .add(options, 'demo', demoNames) .name('Demo') .onChange((name: string) => { render(name); save(); }); load(); function initContainer() { const container = document.getElementById('container')!; container.innerHTML = ''; return container; } function initContext() { const container = initContainer(); return { container, width: 500, height: 500 }; } async function render(name: string) { destroyForm(); const context = initContext(); const demo = demos[name as keyof typeof demos]; const graph = await demo(context); customForm.push(...(demo?.form?.(panel) || [])); Object.assign(window, { graph }); } function destroyForm() { customForm.forEach((controller) => controller.destroy()); customForm.length = 0; } ================================================ FILE: packages/cli/template-extension/__tests__/setup.ts ================================================ import './utils/use-snapshot-matchers'; ================================================ FILE: packages/cli/template-extension/__tests__/types.d.ts ================================================ import type { Graph, GraphOptions } from '@antv/g6'; import type { Controller, GUI } from 'lil-gui'; declare global { export interface TestCase { (context: GraphOptions): Promise; form?: (gui: GUI) => Controller[]; } export type TestContext = GraphOptions; } ================================================ FILE: packages/cli/template-extension/__tests__/unit/default.spec.ts ================================================ describe('suite', () => { it('case', () => { expect(1).toBe(1); }); }); ================================================ FILE: packages/cli/template-extension/__tests__/unit/elements/nodes/extend.spec.ts ================================================ import { elementNodeExtend } from '@@/demos'; import { createDemoGraph } from '@@/utils/index'; import type { Graph } from '@antv/g6'; describe('element node circle', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementNodeExtend); }); afterAll(() => { graph.destroy(); }); it('should render an extended node', async () => { await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/cli/template-extension/__tests__/utils/create.ts ================================================ import { resetEntityCounter } from '@antv/g'; import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Renderer as SVGRenderer } from '@antv/g-svg'; import { Graph } from '@antv/g6'; import { OffscreenCanvasContext } from './offscreen-canvas-context'; function getRenderer(renderer: string) { switch (renderer) { case 'svg': return new SVGRenderer(); case 'webgl': case 'canvas': return new CanvasRenderer(); default: return new SVGRenderer(); } } /** * Create graph canvas with config. * @param dom - dom * @param width - width * @param height - height * @param renderer - render * @returns instance */ export function createGraphCanvas( dom?: null | HTMLElement, width: number = 500, height: number = 500, renderer: string = 'svg', ) { const container = dom || document.createElement('div'); container.style.width = `${width}px`; container.style.height = `${height}px`; resetEntityCounter(); const offscreenNodeCanvas = { getContext: () => context, } as unknown as HTMLCanvasElement; const context = new OffscreenCanvasContext(offscreenNodeCanvas); return { container, width, height, renderer: () => getRenderer(renderer), document: container.ownerDocument, offscreenCanvas: offscreenNodeCanvas, }; } export async function createDemoGraph(demo: TestCase, context?: Partial): Promise { const canvasOptions = createGraphCanvas(document.getElementById('container')); return demo({ animation: false, ...canvasOptions, theme: 'light', ...context }); } ================================================ FILE: packages/cli/template-extension/__tests__/utils/dir.ts ================================================ import path from 'path'; /** * 获取快照目录 * * Get snapshot directory * @param dir - __filename * @param detail - 快照详情 | snapshot detail * @returns 快照目录 | snapshot directory */ export function getSnapshotDir(dir: string, detail: string = 'default'): [string, string] { const root = process.cwd(); const subDir = dir.replace(root, '').replace('__tests__/unit/', '').replace('.spec.ts', ''); const outputDir = path.join(root, '__tests__', 'snapshots', subDir); return [outputDir, detail]; } ================================================ FILE: packages/cli/template-extension/__tests__/utils/index.ts ================================================ export { createDemoGraph } from './create'; ================================================ FILE: packages/cli/template-extension/__tests__/utils/offscreen-canvas-context.ts ================================================ // Computed as round(measureText(text).width * 10) at 10px system-ui. For // characters that are not represented in this map, we’d ideally want to use a // weighted average of what we expect to see. But since we don’t really know // what that is, using “e” seems reasonable. const defaultWidthMap: Record = { a: 56, b: 63, c: 57, d: 63, e: 58, f: 37, g: 62, h: 60, i: 26, j: 26, k: 55, l: 26, m: 88, n: 60, o: 60, p: 62, q: 62, r: 39, s: 54, t: 38, u: 60, v: 55, w: 79, x: 54, y: 55, z: 55, A: 69, B: 67, C: 73, D: 74, E: 61, F: 58, G: 76, H: 75, I: 28, J: 55, K: 67, L: 58, M: 89, N: 75, O: 78, P: 65, Q: 78, R: 67, S: 65, T: 65, U: 75, V: 69, W: 98, X: 69, Y: 67, Z: 67, 0: 64, 1: 48, 2: 62, 3: 64, 4: 66, 5: 63, 6: 65, 7: 58, 8: 65, 9: 65, ' ': 29, '!': 32, '"': 49, "'": 31, '(': 39, ')': 39, ',': 31, '-': 48, '.': 31, '/': 32, ':': 31, ';': 31, '?': 52, '‘': 31, '’': 31, '“': 47, '”': 47, '…': 82, }; export function measureText(text: string, fontSize: number) { let sum = 0; for (let i = 0; i < text.length; i++) { sum += ((defaultWidthMap[text[i]] ?? 100) * fontSize) / 100; } return sum; } export class OffscreenCanvasContext { private fontSize!: number; constructor(public canvas: HTMLCanvasElement) {} set font(font: string) { // `${fontStyle} ${fontVariant} ${fontWeight} ${fontSizeString} const [, , , fontSizeString] = font.split(' '); const fontSize = parseFloat(fontSizeString.replace('px', '')); this.fontSize = fontSize; } fillRect() {} fillText() {} getImageData(sx: number, sy: number, sw: number, sh: number) { return { // ignore ascent and descent data: new Uint8ClampedArray(sw * sh * 4).fill(0), }; } measureText(text: string) { return { width: measureText(text, this.fontSize), actualBoundingBoxAscent: 0, actualBoundingBoxDescent: 0, actualBoundingBoxLeft: 0, actualBoundingBoxRight: 0, fontBoundingBoxAscent: 0, fontBoundingBoxDescent: 0, }; } } ================================================ FILE: packages/cli/template-extension/__tests__/utils/sleep.ts ================================================ export function sleep(n: number) { return new Promise((resolve) => { setTimeout(resolve, n); }); } ================================================ FILE: packages/cli/template-extension/__tests__/utils/svg-transformer.js ================================================ module.exports = { process() { return { code: `module.exports = {};`, }; }, }; ================================================ FILE: packages/cli/template-extension/__tests__/utils/to-match-svg-snapshot.ts ================================================ import type { Canvas, IAnimation } from '@antv/g'; import type { Graph, IAnimateEvent } from '@antv/g6'; import chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import format from 'xml-formatter'; import xmlserializer from 'xmlserializer'; import { getSnapshotDir } from './dir'; import { sleep } from './sleep'; export type ToMatchSVGSnapshotOptions = { fileFormat?: string; keepSVGElementId?: boolean; }; const formatSVG = (svg: string, keepSVGElementId: boolean) => { return (keepSVGElementId ? svg : svg.replace(/ *id="[^"]*" */g, ' ').replace(/clip-path="[^"]*"/g, '')).replace( '\r\n', '\n', ); }; // @see https://jestjs.io/docs/26.x/expect#expectextendmatchers export async function toMatchSVGSnapshot( gCanvas: Canvas | Canvas[], dir: string, name: string, options: ToMatchSVGSnapshotOptions = {}, ): Promise<{ message: () => string; pass: boolean }> { await sleep(300); const { fileFormat = 'svg', keepSVGElementId = false } = options; const namePath = path.join(dir, name); const actualPath = path.join(dir, `${name}-actual.${fileFormat}`); const expectedPath = path.join(dir, `${name}.${fileFormat}`); const gCanvases = Array.isArray(gCanvas) ? gCanvas : [gCanvas]; let actual: string = ''; // Clone const svg = (gCanvases[0].getContextService().getDomElement() as unknown as SVGElement).cloneNode(true) as SVGElement; const gRoot = svg.querySelector('#g-root'); gCanvases.slice(1).forEach((gCanvas) => { const dom = (gCanvas.getContextService().getDomElement() as unknown as SVGElement).cloneNode(true) as SVGElement; // @ts-expect-error dom is SVGElement gRoot?.append(...(dom.querySelector('#g-root')?.childNodes || [])); }); actual += svg ? formatSVG(format(xmlserializer.serializeToString(svg as any), { indentation: ' ' }), keepSVGElementId) : ''; try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(expectedPath)) { if (process.env.CI === 'true') { throw new Error(`Please generate golden image for ${namePath}`); } console.warn(`! generate ${namePath}`); fs.writeFileSync(expectedPath, actual); return { message: () => `generate ${namePath}`, pass: true, }; } else { const expected = fs.readFileSync(expectedPath, { encoding: 'utf8', flag: 'r', }); if (actual === expected) { if (fs.existsSync(actualPath)) fs.unlinkSync(actualPath); return { message: () => `match ${namePath}`, pass: true, }; } // Perverse actual file. if (actual) fs.writeFileSync(actualPath, actual); const formatPath = (p: string) => p.split('/g6/')[1]; return { message: () => `mismatch: \n expected: ${chalk.green(formatPath(expectedPath))}\n received: ${chalk.red(formatPath(actualPath))}`, pass: false, }; } } catch (e) { return { message: () => `${e}`, pass: false, }; } } export async function toMatchSnapshot( graph: Graph, dir: string, detail?: string, options: ToMatchSVGSnapshotOptions = {}, ) { return await toMatchSVGSnapshot( Object.values(graph.getCanvas().getLayers()), ...getSnapshotDir(dir, detail), options, ); } export async function toMatchAnimation( graph: Graph, dir: string, frames: number[], operation: () => void | Promise, detail = 'default', options: ToMatchSVGSnapshotOptions = {}, ) { const animationPromise = new Promise((resolve) => { graph.once('beforeanimate', (e) => { resolve(e.animation!); }); }); await operation(); const animation = await animationPromise; animation.pause(); for (const frame of frames) { animation.currentTime = frame; await sleep(32); const result = await toMatchSVGSnapshot( Object.values(graph.getCanvas().getCanvases().canvas), ...getSnapshotDir(dir, `${detail}-${frame}`), options, ); if (!result.pass) { return result; } } return { message: () => `match ${detail}`, pass: true, }; } ================================================ FILE: packages/cli/template-extension/__tests__/utils/use-snapshot-matchers.ts ================================================ import { ToMatchSVGSnapshotOptions, toMatchAnimation, toMatchSVGSnapshot, toMatchSnapshot, } from './to-match-svg-snapshot'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface Matchers { toMatchSVGSnapshot(dir: string, name: string, options?: ToMatchSVGSnapshotOptions): Promise; toMatchSnapshot(dir: string, detail?: string, options?: ToMatchSVGSnapshotOptions): Promise; toMatchAnimation( dir: string, frames: number[], operation: () => void | Promise, detail?: string, options?: ToMatchSVGSnapshotOptions, ): Promise; } } } expect.extend({ toMatchSVGSnapshot, toMatchSnapshot, toMatchAnimation, }); ================================================ FILE: packages/cli/template-extension/jest.config.js ================================================ // Installing third-party modules by tnpm or cnpm will name modules with underscore as prefix. // In this case _{module} is also necessary. const esm = ['internmap', 'd3-*', 'lodash-es', 'chalk'].map((d) => `_${d}|${d}`).join('|'); module.exports = { testTimeout: 100000, testEnvironment: 'jsdom', setupFilesAfterEnv: ['./__tests__/setup.ts'], transform: { '^.+\\.[tj]s$': ['@swc/jest'], '^.+\\.svg$': ['/__tests__/utils/svg-transformer.js'], }, collectCoverageFrom: ['src/**/*.ts'], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], collectCoverage: false, testRegex: '(/__tests__/.*\\.(test|spec))\\.(ts|tsx|js)$', // Transform esm to cjs. transformIgnorePatterns: [`/node_modules/(?!(${esm}))`], testPathIgnorePatterns: ['/(lib|esm)/__tests__/'], moduleNameMapper: { '^@@/(.*)$': '/__tests__/$1', '^@/(.*)$': '/$1', }, }; ================================================ FILE: packages/cli/template-extension/package.json ================================================ { "name": "g6-extension-test", "version": "0.0.1", "description": "Extension for G6", "repository": { "type": "git", "url": "" }, "license": "MIT", "main": "lib/index.js", "module": "esm/index.js", "types": "lib/index.d.ts", "scripts": { "build:cjs": "rimraf ./lib && tsc --module commonjs --outDir lib -p tsconfig.build.json", "build:esm": "rimraf ./esm && tsc --module ESNext --outDir esm -p tsconfig.build.json", "build:umd": "rimraf ./dist && rollup -c", "build": "run-p build:*", "dev": "vite", "fix": "eslint ./src ./__tests__ --fix && prettier ./src __tests__ --write ", "lint": "eslint ./src __tests__ --quiet && prettier ./src __tests__ --check", "test": "jest" }, "commitlint": { "extends": [ "@commitlint/config-conventional" ] }, "lint-staged": { "*.{ts,tsx}": [ "eslint --fix", "prettier --write" ] }, "dependencies": { "@antv/g6": "^5.0.0" }, "devDependencies": { "@antv/g": "^6.1.2", "@antv/g-canvas": "^2.0.18", "@antv/g-svg": "^2.0.15", "@commitlint/config-conventional": "^19.2.2", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@swc/jest": "^0.2.36", "@types/jest": "^29.5.12", "@types/node": "^20.12.12", "@types/xmlserializer": "^0.6.6", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^7.9.0", "chalk": "^5.3.0", "eslint": "^8.57.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^23.2.0", "lil-gui": "^0.19.2", "lodash": "^4.17.21", "npm-run-all": "^4.1.5", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-packagejson": "^2.5.0", "rimraf": "^5.0.7", "rollup": "^4.17.2", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-visualizer": "^5.12.0", "ts-node": "^10.9.2", "typescript": "^5.4.5", "vite": "^5.2.11", "xmlserializer": "^0.6.1" }, "peerDependencies": { "@antv/g": "^6.1.2", "@antv/g-canvas": "^2.0.18" } } ================================================ FILE: packages/cli/template-extension/rollup.config.mjs ================================================ import commonjs from '@rollup/plugin-commonjs'; import resolve from '@rollup/plugin-node-resolve'; import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; import _ from 'lodash'; import nodePolyfills from 'rollup-plugin-polyfill-node'; import { visualizer } from 'rollup-plugin-visualizer'; const { camelCase, upperFirst } = _; const isBundleVis = !!process.env.BUNDLE_VIS; export default [ { input: 'src/index.ts', output: { file: 'dist/{{projectName}}.min.js', name: upperFirst(camelCase('{{projectName}}')), format: 'umd', sourcemap: false, }, plugins: [ nodePolyfills(), resolve(), commonjs(), typescript({ tsconfig: 'tsconfig.build.json', }), terser(), ...(isBundleVis ? [visualizer()] : []), ], }, ]; ================================================ FILE: packages/cli/template-extension/src/elements/index.ts ================================================ export * from './nodes'; ================================================ FILE: packages/cli/template-extension/src/elements/nodes/extend-node.ts ================================================ import type { CircleStyleProps } from '@antv/g6'; import { Circle } from '@antv/g6'; export interface ExtendNodeStyleProps extends CircleStyleProps {} export class ExtendNode extends Circle {} ================================================ FILE: packages/cli/template-extension/src/elements/nodes/index.ts ================================================ export { ExtendNode } from './extend-node'; export type { ExtendNodeStyleProps } from './extend-node'; ================================================ FILE: packages/cli/template-extension/src/exports.ts ================================================ export { ExtendNode } from './elements'; ================================================ FILE: packages/cli/template-extension/src/index.ts ================================================ export * from './exports'; ================================================ FILE: packages/cli/template-extension/tsconfig.build.json ================================================ { "compilerOptions": { "paths": {} }, "include": ["src/**/*"], "extends": "./tsconfig.json" } ================================================ FILE: packages/cli/template-extension/tsconfig.json ================================================ { "compilerOptions": { "allowSyntheticDefaultImports": true, "baseUrl": ".", "declaration": true, "esModuleInterop": true, "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "lib": ["DOM", "ESNext"], "module": "esnext", "moduleResolution": "Node", "outDir": "lib", "pretty": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "sourceRoot": "src", "strict": true, "target": "ES6", "types": ["@types/jest", "node"], "paths": { "@/*": ["./*"], "@@/*": ["__tests__/*"] } }, "exclude": ["node_modules", "dist", "lib", "esm"], "include": ["src/**/*", "__tests__/**/*"] } ================================================ FILE: packages/cli/template-extension/vite.config.js ================================================ import path from 'path'; import { defineConfig } from 'vite'; export default defineConfig({ root: './__tests__', server: { port: 8080, open: '/', }, plugins: [ { name: 'isolation', configureServer(server) { server.middlewares.use((_req, res, next) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'same-origin'); next(); }); }, }, ], resolve: { alias: { '@': path.resolve(__dirname, '.'), '@@': path.resolve(__dirname, './__tests__'), }, }, }); ================================================ FILE: packages/cli/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "include": ["build.config.ts", "src"], "compilerOptions": {} } ================================================ FILE: packages/g6/.gitignore ================================================ __tests__/**/*-actual.* README.* ================================================ FILE: packages/g6/__tests__/.eslintrc ================================================ { "rules": { "no-console": "off" } } ================================================ FILE: packages/g6/__tests__/bugs/api-expand-element-z-index.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('api expand element z-index', () => { it('when expand element, the z-index of descendant elements should be updated', async () => { const graph = createGraph({ animation: false, data: { nodes: [{ id: 'node-1' }, { id: 'node-2', combo: 'combo-2' }], combos: [ { id: 'combo-1', style: { collapsed: true } }, { id: 'combo-2', combo: 'combo-1', style: { collapsed: true } }, ], }, }); await graph.draw(); const getZIndexOf = (id: string): number => { // @ts-expect-error context is private const context = graph.context; return context.element!.getElement(id)!.style.zIndex; }; expect(getZIndexOf('combo-1')).toBe(0); expect(getZIndexOf('node-1')).toBe(0); expect(graph.getComboData('combo-2').style?.zIndex).toBe(1); expect(graph.getNodeData('node-2').style?.zIndex).toBe(2); graph.frontElement('node-1'); expect(getZIndexOf('node-1')).toBe(3); graph.frontElement('combo-1'); expect(getZIndexOf('combo-1')).toBe(4); await graph.expandElement('combo-1', false); await graph.expandElement('combo-2', false); expect(getZIndexOf('combo-1')).toBe(4); expect(getZIndexOf('combo-2')).toBe(5); expect(getZIndexOf('node-2')).toBe(6); }); }); ================================================ FILE: packages/g6/__tests__/bugs/api-focus-element-edge.spec.ts ================================================ import { behaviorDragNode } from '@@/demos'; import { createDemoGraph } from '@@/utils'; it('api focusElement edge', async () => { const graph = await createDemoGraph(behaviorDragNode, { animation: false }); graph.translateBy([100, 100]); graph.zoomBy(2); graph.focusElement('node-3'); await expect(graph).toMatchSnapshot(__filename); graph.focusElement('node-3-node-4'); await expect(graph).toMatchSnapshot(__filename, 'focusElement edge'); }); ================================================ FILE: packages/g6/__tests__/bugs/behaviors-click-select-drag-node.spec.ts ================================================ import { CommonEvent, NodeEvent } from '@/src'; import { createGraph } from '@@/utils'; describe('behavior drag-node with click select', () => { const createDemoGraph = async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 100, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 200, y: 100 } }, { id: 'node-3', style: { x: 100, y: 200 } }, { id: 'node-4', combo: 'combo-1', style: { x: 200, y: 200 } }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-4' }, { source: 'node-1', target: 'node-3' }, { source: 'node-3', target: 'node-4' }, ], combos: [{ id: 'combo-1' }], }, node: { style: { size: 20 } }, edge: { style: { endArrow: true }, }, behaviors: [{ type: 'drag-element' }, { type: 'click-select', multiple: true }], }); await graph.render(); return graph; }; it('drag unselected node', async () => { const graph = await createDemoGraph(); graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'click-node-1'); // drag node-2 graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-node-2'); }); it('drag selected node', async () => { const graph = await createDemoGraph(); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); await expect(graph).toMatchSnapshot(__filename, 'click-node-1-node-2'); // drag node-2 graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-node-2'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/behaviors-click-select.spec.ts ================================================ import { CommonEvent, NodeEvent } from '@/src'; import { behaviorClickSelect } from '@@/demos'; import { createDemoGraph, createGraph } from '@@/utils'; describe('behavior click-select', () => { it('multiple select with degree 1', async () => { const graph = await createDemoGraph(behaviorClickSelect, { animation: false }); graph.updateBehavior({ key: 'click-select', degree: 1, multiple: true }); graph.emit(NodeEvent.CLICK, { target: { id: '29' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: '6' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); await expect(graph).toMatchSnapshot(__filename, 'multiple-shift-degree-1'); graph.destroy(); }); it('update state by api', async () => { // 通过 api 更新状态导致 click-select 状态不同步 // State updated by api causes click-select state to be out of sync const graph = createGraph({ data: { nodes: [{ id: 'node-1', type: 'rect', style: { x: 50, y: 100 } }], }, behaviors: [{ key: 'click-select', type: 'click-select' }], }); await graph.draw(); graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'state-selected'); graph.setElementState({ 'node-1': [] }); graph.addNodeData([{ id: 'node-2', type: 'rect', style: { x: 200, y: 200 }, states: ['selected'] }]); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'add-node-2'); graph.emit(NodeEvent.CLICK, { target: { id: 'node-2' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'click-node-2'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/behaviors-collapse-expand.spec.ts ================================================ import { ComboEvent, GraphEvent } from '@/src'; import { layoutAntVDagreFlowCombo } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior collapse expand', () => { it('collapse expand with no change element', async () => { // https://github.com/antvis/G6/issues/5951 const graph = await createDemoGraph(layoutAntVDagreFlowCombo, { animation: true }); // @ts-expect-error private method const comboA = graph.context.element.getElement('A'); const click = jest.fn(async () => { await new Promise((resolve) => { graph.on(GraphEvent.AFTER_ANIMATE, () => { resolve(); }); graph.emit(ComboEvent.DBLCLICK, { target: comboA, targetType: 'combo' }); }); }); expect(click).not.toThrow(); }); }); ================================================ FILE: packages/g6/__tests__/bugs/behaviors-drag-element-combo.spec.ts ================================================ import { ComboEvent, Graph } from '@/src'; import { layoutAntVDagreFlowCombo } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior drag element combo', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(layoutAntVDagreFlowCombo, { animation: false }); }); it('drag combo A over C', async () => { graph.emit(ComboEvent.DRAG_START, { target: { id: 'A' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 100, dy: 0 }); graph.emit(ComboEvent.DRAG_END, { target: { id: 'C' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-A-over-C'); }); it('drag combo C over A', async () => { graph.emit(ComboEvent.DRAG_START, { target: { id: 'C' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: -10, dy: 0 }); graph.emit(ComboEvent.DRAG_END, { target: { id: 'A' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-C-over-A'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/behaviors-drag-rotated-canvas.spec.ts ================================================ import type { Graph } from '@/src'; import { CommonEvent, NodeEvent } from '@/src'; import { bugDragRotatedCanvas } from '@@/demos'; import { createDemoGraph, dispatchCanvasEvent } from '@@/utils'; const fixed2 = (num: number): number => { return parseFloat(num.toFixed(2)); }; describe('behavior drag rotated canvas', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(bugDragRotatedCanvas, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('drag 30 rotated canvas', async () => { await graph.rotateTo(30); const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getRotation()).toBe(30); expect(graph.getPosition()).toBeCloseTo([x + 3.66, y + 13.66]); }); it('drag 90 rotated canvas', async () => { await graph.rotateTo(90); const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 20 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getRotation()).toBe(90); expect(graph.getPosition()).toBeCloseTo([x - 20, y + 10]); }); it('drag 180 rotated canvas', async () => { await graph.rotateTo(180); const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 20 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getRotation()).toBe(180); expect(graph.getPosition()).toBeCloseTo([x - 10, y - 20]); }); it('drag 270 rotated canvas', async () => { await graph.rotateTo(270); const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 20 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getRotation()).toBe(270); expect(graph.getPosition()).toBeCloseTo([x + 20, y - 10]); }); it.each([ { name: 'element', id: 'node1', targetType: 'node' }, { name: 'combo', id: 'comboA', targetType: 'combo' }, ])('drag $name when 30 rotated canvas', async ({ id, targetType }) => { await graph.rotateTo(30); const [x, y] = graph.getElementPosition(id); graph.emit(NodeEvent.DRAG_START, { target: { id: id }, targetType }); graph.emit(NodeEvent.DRAG, { dx: 10, dy: 10 }); graph.emit(NodeEvent.DRAG_END, { target: { id: id }, targetType }); expect(graph.getRotation()).toBe(30); const [nextX, nextY] = graph.getElementPosition(id); expect(fixed2(nextX)).toBeCloseTo(fixed2(x + 3.66)); expect(fixed2(nextY)).toBeCloseTo(fixed2(y + 13.66)); }); }); ================================================ FILE: packages/g6/__tests__/bugs/behaviors-multiple-conflict.spec.ts ================================================ import { createGraph, dispatchCanvasEvent } from '@@/utils'; import { CommonEvent, NodeEvent } from '@antv/g6'; describe('bugs:multiple-conflict', () => { it('drag element, drag canvas', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1', style: { x: 50, y: 50, size: 20 } }], }, behaviors: ['drag-element', 'drag-canvas'], }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); // drag canvas dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-canvas'); // drag element graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 10, dy: 10 }); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-element'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/brush-select.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('BrushSelect clear states issue', () => { it('Should not clear states except selection state', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 250, y: 150 } }, { id: 'node-3', style: { x: 250, y: 300 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-3' }], }, behaviors: ['brush-select'], }); await graph.draw(); graph.setElementState({ 'edge-1': ['selected', 'active', 'custom-state'] }); const currentStates = graph.getElementState('edge-1'); expect(currentStates).toEqual(['selected', 'active', 'custom-state']); await graph.emit('canvas:click'); const newStates = graph.getElementState('edge-1'); console.log(newStates); expect(newStates).toEqual(['active', 'custom-state']); }); }); ================================================ FILE: packages/g6/__tests__/bugs/continuous-invoke.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bugs:continuous-invoke', () => { it('continuous invoke', () => { const graph = createGraph({}); const fn = jest.fn(async () => { graph.destroy(); await graph.render(); }); expect(fn).rejects.toThrow(); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-combo-drag.spec.ts ================================================ import { NodeEvent } from '@/src'; import { createGraph } from '@@/utils'; describe('bugs:element-combo-drag', () => { it('drag combo', async () => { const graph = createGraph({ animation: false, data: { nodes: [ { id: 'node-0', combo: 'combo-0', style: { x: 100, y: 100 } }, { id: 'node-1', combo: 'combo-0', style: { x: 150, y: 100 } }, { id: 'node-2', style: { x: 250, y: 100 } }, ], edges: [{ source: 'node-1', target: 'node-2' }], combos: [{ id: 'combo-0' }], }, behaviors: ['drag-element'], }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); await graph.collapseElement('combo-0'); await expect(graph).toMatchSnapshot(__filename, 'collapse-combo-0'); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 50, dy: 50 }); graph.emit(NodeEvent.DRAG_END, { target: { id: 'node-2' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-2'); graph.emit(NodeEvent.DRAG_START, { target: { id: 'combo-0' }, targetType: 'combo' }); graph.emit(NodeEvent.DRAG, { dx: 50, dy: 50 }); graph.emit(NodeEvent.DRAG_END, { target: { id: 'combo-0' }, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-0'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-custom-state-switch.spec.ts ================================================ import { createGraph } from '@@/utils'; import { CanvasEvent, CommonEvent, NodeEvent } from '@antv/g6'; describe('bug: element-custom-state-switch', () => { it('single select', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1', states: ['important'], style: { x: 100, y: 100 } }], }, node: { style: { fill: 'red', }, state: { important: { fill: 'green', }, }, }, behaviors: [ { type: 'click-select', }, ], }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'single'); expect(graph.getElementState('node-1')).toEqual(['important']); // click graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); expect(graph.getElementState('node-1')).toEqual(['important', 'selected']); await expect(graph).toMatchSnapshot(__filename, 'single-select'); // click again graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); expect(graph.getElementState('node-1')).toEqual(['important']); await expect(graph).toMatchSnapshot(__filename, 'single'); }); it('multiple select', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', states: ['important'], style: { x: 100, y: 100 } }, { id: 'node-2', style: { x: 150, y: 100 } }, ], }, node: { style: { fill: 'red', }, state: { important: { fill: 'green', }, }, }, behaviors: [ { type: 'click-select', multiple: true, }, ], }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'multiple'); graph.emit(NodeEvent.CLICK, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); // graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); expect(graph.getElementState('node-1')).toEqual(['important', 'selected']); expect(graph.getElementState('node-2')).toEqual(['selected']); await expect(graph).toMatchSnapshot(__filename, 'multiple-select'); // unselect graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); expect(graph.getElementState('node-1')).toEqual(['important']); graph.emit(NodeEvent.CLICK, { target: { id: 'node-2' }, targetType: 'node' }); expect(graph.getElementState('node-2')).toEqual([]); await expect(graph).toMatchSnapshot(__filename, 'multiple'); // reselect graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); // click canvas graph.emit(CanvasEvent.CLICK); expect(graph.getElementState('node-1')).toEqual(['important']); expect(graph.getElementState('node-2')).toEqual([]); await expect(graph).toMatchSnapshot(__filename, 'multiple-unselect'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-edge-update-arrow.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bug: element-edge-update-arrow', () => { it('should update edge arrow', async () => { const graph = createGraph({ animation: false, data: { nodes: [ { id: 'node-0', style: { x: 100, y: 100 } }, { id: 'node-1', style: { x: 200, y: 100 } }, ], edges: [ { source: 'node-0', target: 'node-1', style: { startArrow: true, startArrowFill: 'red', endArrow: true, endArrowFill: 'green' }, }, ], }, }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); graph.updateEdgeData([ { source: 'node-0', target: 'node-1', style: { startArrowFill: 'purple', startArrowStroke: 'blue', endArrowFill: 'pink', endArrowStroke: 'yellow' }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'update-arrow'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-node-collapse.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bugs:element-node-collapse', () => { it('collapse or expand a node should not throw error', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 250, y: 150 } }, { id: 'node2', combo: 'combo1', style: { x: 350, y: 150 } }, { id: 'node3', combo: 'combo2', style: { x: 250, y: 300 } }, ], combos: [ { id: 'combo1', combo: 'combo2' }, { id: 'combo2', style: {} }, ], }, combo: { style: { labelText: (d) => d.id, labelPadding: [1, 5], labelFill: '#fff', labelBackground: true, labelBackgroundRadius: 10, labelBackgroundFill: '#7863FF', }, }, }); await graph.render(); const fn = async () => { await graph.collapseElement('node1', false); await graph.expandElement('node2', false); }; expect(fn).not.toThrow(); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-node-icon-switch.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bug: element-node-icon-switch', () => { it('change node icon', async () => { const graph = createGraph({ animation: false, data: { nodes: [{ id: 'node-1', style: { x: 50, y: 50, iconText: 'Text' } }], }, node: { style: {}, }, }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'text-icon'); graph.updateNodeData([ { id: 'node-1', style: { iconText: '', iconSrc: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*AzSISZeq81IAAAAAAAAAAAAADmJ7AQ/original', }, }, ]); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'image-icon'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-node-update-badge.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bug: element-node-update-badge', () => { it('should update node badge', async () => { const graph = createGraph({ animation: false, node: { style: { badge: true, badges: [{ text: '1' }], badgeFill: 'white', badgeBackgroundFill: 'red', }, }, data: { nodes: [{ id: 'node-0', style: { x: 100, y: 100 }, states: ['inactive'] }], }, }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); graph.setElementState('node-0', []); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'update-node-badge'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-orth-router.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('element orth router', () => { it('test polyline orth', async () => { const graph = createGraph({ animation: true, data: { nodes: [ { id: 'node-1', style: { x: 310, y: 280, size: 80 }, }, { id: 'node-2', style: { x: 300, y: 175 }, }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, edge: { type: 'polyline', style: { router: { type: 'orth' }, }, }, }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-port-rotate.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('element port rotate', () => { it('default', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }], }, node: { type: 'rect', style: { size: [50, 150], port: true, portR: 3, ports: [ { key: 'port-1', placement: [0, 0.15] }, { key: 'port-2', placement: 'left' }, { key: 'port-3', placement: [0, 0.85] }, ], transform: [['rotate', 45]], }, }, }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-remove-combo.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('element remove combo', () => { it('remove combo', async () => { const graph = createGraph({ animation: true, data: { nodes: [ { id: 'node-1', data: {}, combo: 'combo-1' }, { id: 'node-2', data: {}, combo: 'combo-1' }, { id: 'node-3', data: {}, combo: 'combo-1' }, ], combos: [ { id: 'combo-1', data: {}, combo: 'combo-2' }, { id: 'combo-2', data: {}, style: {} }, ], }, layout: { type: 'force', }, }); await graph.draw(); graph.removeComboData(['combo-1']); const draw = jest.fn(async () => { await graph.draw(); }); expect(draw).not.toThrow(); }); }); ================================================ FILE: packages/g6/__tests__/bugs/element-set-position-to-origin.spec.ts ================================================ import type { ID } from '@/src'; import { createGraph } from '@@/utils'; describe('element set position to origin', () => { it('suite 1', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }], }, }); await graph.draw(); // @ts-expect-error Property 'context' is protected const getElementOf = (id: ID) => graph.context.element!.getElement(id)!; expect(graph.getNodeData('node-1').style).toEqual({ zIndex: 0 }); expect(getElementOf('node-1').style.transform).toEqual([['translate', 0, 0]]); graph.translateElementTo('node-1', [100, 100]); expect(graph.getNodeData('node-1').style).toEqual({ x: 100, y: 100, z: 0, zIndex: 0 }); expect(getElementOf('node-1').style.transform).toEqual([['translate', 100, 100]]); graph.translateElementTo('node-1', [0, 0]); expect(graph.getNodeData('node-1').style).toEqual({ x: 0, y: 0, z: 0, zIndex: 0 }); expect(getElementOf('node-1').style.transform).toEqual([['translate', 0, 0]]); }); }); ================================================ FILE: packages/g6/__tests__/bugs/fit-view.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('fit view', () => { it('suite 1', async () => { // https://github.com/antvis/G6/issues/5943 const graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 250, y: 150 } }, { id: 'node-2', style: { x: 350, y: 150 } }, { id: 'node-3', style: { x: 250, y: 300 } }, ], }, behaviors: ['zoom-canvas'], }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename); // wheel graph.emit('wheel', { deltaY: 5 }); graph.emit('wheel', { deltaY: 5 }); await expect(graph).toMatchSnapshot(__filename, 'after-wheel'); // fit center await graph.fitCenter(); await graph.fitCenter(); await expect(graph).toMatchSnapshot(__filename, 'after-fit-center'); // fit view await graph.fitView(); await expect(graph).toMatchSnapshot(__filename, 'after-fit-view'); // fit center again await graph.fitCenter(); await expect(graph).toMatchSnapshot(__filename, 'after-fit-center-again'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/focus-element.spec.ts ================================================ import { CommonEvent, NodeEvent } from '@/src'; import { createGraph, dispatchCanvasEvent } from '@@/utils'; describe('focus element', () => { it('focus after drag', async () => { // https://github.com/antvis/G6/issues/5955 const graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 250, y: 150 } }, { id: 'node-2', style: { x: 350, y: 150 } }, { id: 'node-3', style: { x: 250, y: 300 } }, ], }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.draw(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 100, y: 100 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'focus-before-drag'); await graph.focusElement('node-1'); await expect(graph).toMatchSnapshot(__filename, 'focus-after-drag'); }); it('hover after focus', async () => { // https://github.com/antvis/G6/issues/5925 const graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 250, y: 150 } }, { id: 'node-2', style: { x: 350, y: 150 } }, { id: 'node-3', style: { x: 250, y: 300 } }, ], }, behaviors: ['zoom-canvas', 'hover-activate'], }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'hover-before-focus'); await graph.focusElement('node-1'); graph.emit(NodeEvent.POINTER_ENTER, { target: { id: 'node-2' }, targetType: 'node', type: CommonEvent.POINTER_ENTER, }); await expect(graph).toMatchSnapshot(__filename, 'hover-after-focus'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/graph-draw-after-clear.spec.ts ================================================ import { elementEdgeLine } from '@@/demos'; import { createDemoGraph, sleep } from '@@/utils'; it('graph draw after clear', async () => { const graph = await createDemoGraph(elementEdgeLine); const data = graph.getData(); await graph.clear(); await expect(graph).toMatchSnapshot(__filename, 'blank'); await sleep(200); graph.addData(data); await graph.draw(); await expect(graph).toMatchSnapshot(__filename); }); ================================================ FILE: packages/g6/__tests__/bugs/model-add-edge-in-combo.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('add edge in combo', () => { it('add edge in combo without zIndex', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', combo: 'combo-1', style: { x: 100, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 200, y: 200 } }, ], combos: [{ id: 'combo-1' }], }, }); await graph.draw(); expect(graph.getComboData('combo-1').style?.zIndex).toBe(0); expect(graph.getNodeData('node-1').style?.zIndex).toBe(1); graph.addEdgeData([{ id: 'edge', source: 'node-1', target: 'node-2' }]); await graph.draw(); expect(graph.getEdgeData('edge').style?.zIndex).toBe(0); // @ts-expect-error skip the type check expect(graph.context.element?.getElement('edge')?.style.zIndex).toBe(0); }); it('add edge in combo with zIndex', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', combo: 'combo-1', style: { x: 100, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 200, y: 200 } }, { id: 'node-3', style: { x: 300, y: 300, zIndex: 5 } }, ], combos: [{ id: 'combo-1' }], }, }); await graph.draw(); expect(graph.getComboData('combo-1').style?.zIndex).toBe(0); await graph.frontElement('combo-1'); expect(graph.getComboData('combo-1').style?.zIndex).toBe(5 + 1); expect(graph.getNodeData('node-1').style?.zIndex).toBe(5 + 1 + 1); graph.addEdgeData([{ id: 'edge', source: 'node-1', target: 'node-2' }]); await graph.draw(); expect(graph.getEdgeData('edge').style?.zIndex).toBe(5 + 1); // @ts-expect-error skip the type check expect(graph.context.element?.getElement('edge')?.style.zIndex).toBe(5 + 1); }); }); ================================================ FILE: packages/g6/__tests__/bugs/model-remove-parent.spec.ts ================================================ import { createGraph } from '@@/utils'; it('model remove parent', async () => { const data = { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 250, y: 150 } }, { id: 'node2', combo: 'combo1', style: { x: 350, y: 150 } }, { id: 'node3', combo: 'combo1', style: { x: 250, y: 300 } }, ], edges: [], combos: [{ id: 'combo1' }], }; const graph = createGraph({ data, node: { style: { labelText: (d) => d.id, }, }, combo: { type: 'rect', }, }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); graph.updateNodeData([{ id: 'node3', combo: null }]); await graph.draw(); await expect(graph).toMatchSnapshot(__dirname, 'remove-parent'); }); ================================================ FILE: packages/g6/__tests__/bugs/plugin-history-align-fields.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bug: plugin-history-align-fields', () => { it('fix alignFields util', async () => { const graph = createGraph({ plugins: [{ type: 'history', key: 'history' }], data: { nodes: [ { id: 'node-1', type: 'rect', style: { x: 50, y: 100 }, data: { aaa: { bbb: false, ccc: true, ddd: '1234', }, }, }, ], }, }); await graph.render(); expect(graph.getNodeData('node-1').style).toEqual({ x: 50, y: 100, zIndex: 0 }); expect(graph.getNodeData('node-1').data!.aaa).toEqual({ bbb: false, ccc: true, ddd: '1234', }); await graph.translateElementBy('node-1', [100, 100]); expect(graph.getNodeData('node-1').style).toEqual({ x: 150, y: 200, z: 0, zIndex: 0 }); expect(graph.getNodeData('node-1').data!.aaa).toEqual({ bbb: false, ccc: true, ddd: '1234', }); }); }); ================================================ FILE: packages/g6/__tests__/bugs/plugin-hull-three-collinear-dots.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bug: plugin-hull-three-collinear-dots', () => { it('fully enclosed', async () => { const graph = createGraph({ animation: false, data: { nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }], }, layout: { type: 'grid', rows: 3, cols: 1, }, plugins: [ { key: 'hull', type: 'hull', members: ['node1', 'node2', 'node3'], }, ], }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/g6/__tests__/bugs/plugin-minimap-combo-collapsed.spec.ts ================================================ import { createGraph, sleep } from '@@/utils'; describe('bug: plugin-minimap-combo-collapsed', () => { it('should be collapsed', async () => { const graph = createGraph({ animation: false, data: { nodes: [{ id: 'node1', combo: 'combo1' }, { id: 'node2' }], edges: [{ source: 'node1', target: 'node2' }], combos: [{ id: 'combo1' }], }, layout: { type: 'grid', }, plugins: [ { key: 'minimap', type: 'minimap', size: [240, 160], }, ], }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); await sleep(1000); graph.collapseElement('combo1'); graph.translateElementBy('combo1', [100, 100]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'update-collapsed-combo'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/render-change-combo.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('render change combo', () => { it('bug', async () => { const graph = createGraph({}); await graph.render(); let count = 1; const operation = async () => { graph.setData({ nodes: [ { id: 'node1', combo: `${count}A`, style: { x: 250, y: 150 } }, { id: 'node2', combo: `${count}b`, style: { x: 350, y: 150 } }, { id: 'node3', style: { x: 250, y: 300 } }, ], edges: [], combos: [ { id: `${count}A`, style: { labelText: `${count}A` } }, { id: `${count}b`, style: { labelText: `${count}B` } }, ], }); await graph.render(); count++; }; await operation(); await operation(); }); }); ================================================ FILE: packages/g6/__tests__/bugs/render-deleted-data.spec.ts ================================================ import { layoutCompactBoxBasic } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('render deleted data', () => { it('bug', async () => { const graph = await createDemoGraph(layoutCompactBoxBasic); const render = jest.fn(async () => { graph.removeNodeData(['Classification']); await graph.render(); }); expect(render).not.toThrow(); }); }); ================================================ FILE: packages/g6/__tests__/bugs/tree-update-collapsed-node.spec.ts ================================================ import { idOf, treeToGraphData } from '@/src'; import { createGraph } from '@@/utils'; describe('bug: tree-update-collapsed-node', () => { it('should be collapsed', async () => { const graph = createGraph({ animation: false, x: 50, y: 50, data: treeToGraphData({ id: 'root', children: [ { id: '1-1', style: { collapsed: true }, children: [{ id: '1-1-1' }] }, { id: '1-2', children: [{ id: '1-2-1' }] }, ], }), layout: { type: 'indented', }, }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); // set parent of 1-2 to 1-1 const edges = graph .getEdgeData() .filter((edge) => edge.target === '1-2') .map(idOf); graph.removeEdgeData(edges); graph.updateNodeData([ { id: 'root', children: ['1-1'] }, { id: '1-1', children: ['1-1-1', '1-2'] }, ]); graph.addEdgeData([{ source: '1-1', target: '1-2' }]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'update collapsed node'); }); }); ================================================ FILE: packages/g6/__tests__/bugs/utils-set-visibility.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('bug: utils-set-visibility', () => { it('should set correct', async () => { const graph = createGraph({ animation: true, data: { nodes: [ { id: 'node-0', style: { x: 100, y: 100, labelText: 'label', iconText: 'icon', badges: [{ text: 'b1', placement: 'right-top' }], }, }, ], }, }); await graph.render(); await expect(graph).toMatchSnapshot(__filename); await graph.hideElement('node-0'); await expect(graph).toMatchSnapshot(__filename, 'hidden'); await graph.showElement('node-0'); await expect(graph).toMatchSnapshot(__filename, 'visible'); }); }); ================================================ FILE: packages/g6/__tests__/dataset/algorithm-category.json ================================================ { "id": "Modeling Methods", "children": [ { "id": "Classification", "children": [ { "id": "Logistic regression" }, { "id": "Linear discriminant analysis" }, { "id": "Rules" }, { "id": "Decision trees" }, { "id": "Naive Bayes" }, { "id": "K nearest neighbor" }, { "id": "Probabilistic neural network" }, { "id": "Support vector machine" } ] }, { "id": "Consensus", "children": [ { "id": "Models diversity", "children": [ { "id": "Different initializations" }, { "id": "Different parameter choices" }, { "id": "Different architectures" }, { "id": "Different modeling methods" }, { "id": "Different training sets" }, { "id": "Different feature sets" } ] }, { "id": "Methods", "children": [{ "id": "Classifier selection" }, { "id": "Classifier fusion" }] }, { "id": "Common", "children": [{ "id": "Bagging" }, { "id": "Boosting" }, { "id": "AdaBoost" }] } ] }, { "id": "Regression", "children": [ { "id": "Multiple linear regression" }, { "id": "Partial least squares" }, { "id": "Multi-layer feed forward neural network" }, { "id": "General regression neural network" }, { "id": "Support vector regression" } ] } ] } ================================================ FILE: packages/g6/__tests__/dataset/circular.json ================================================ { "nodes": [ { "id": "0", "label": "0" }, { "id": "1", "label": "1" }, { "id": "2", "label": "2" }, { "id": "3", "label": "3" }, { "id": "4", "label": "4" }, { "id": "5", "label": "5" }, { "id": "6", "label": "6" }, { "id": "7", "label": "7" }, { "id": "8", "label": "8" }, { "id": "9", "label": "9" }, { "id": "10", "label": "10" }, { "id": "11", "label": "11" }, { "id": "12", "label": "12" }, { "id": "13", "label": "13" }, { "id": "14", "label": "14" }, { "id": "15", "label": "15" }, { "id": "16", "label": "16" }, { "id": "17", "label": "17" }, { "id": "18", "label": "18" }, { "id": "19", "label": "19" }, { "id": "20", "label": "20" }, { "id": "21", "label": "21" }, { "id": "22", "label": "22" }, { "id": "23", "label": "23" }, { "id": "24", "label": "24" }, { "id": "25", "label": "25" }, { "id": "26", "label": "26" }, { "id": "27", "label": "27" }, { "id": "28", "label": "28" }, { "id": "29", "label": "29" }, { "id": "30", "label": "30" }, { "id": "31", "label": "31" }, { "id": "32", "label": "32" }, { "id": "33", "label": "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" } ] } ================================================ FILE: packages/g6/__tests__/dataset/cluster.json ================================================ { "nodes": [ { "id": "0", "data": { "cluster": "a" } }, { "id": "1", "data": { "cluster": "a" } }, { "id": "2", "data": { "cluster": "a" } }, { "id": "3", "data": { "cluster": "a" } }, { "id": "4", "data": { "cluster": "a" } }, { "id": "5", "data": { "cluster": "a" } }, { "id": "6", "data": { "cluster": "a" } }, { "id": "7", "data": { "cluster": "a" } }, { "id": "8", "data": { "cluster": "a" } }, { "id": "9", "data": { "cluster": "a" } }, { "id": "10", "data": { "cluster": "a" } }, { "id": "11", "data": { "cluster": "a" } }, { "id": "12", "data": { "cluster": "a" } }, { "id": "13", "data": { "cluster": "b" } }, { "id": "14", "data": { "cluster": "b" } }, { "id": "15", "data": { "cluster": "b" } }, { "id": "16", "data": { "cluster": "b" } }, { "id": "17", "data": { "cluster": "b" } }, { "id": "18", "data": { "cluster": "c" } }, { "id": "19", "data": { "cluster": "c" } }, { "id": "20", "data": { "cluster": "c" } }, { "id": "21", "data": { "cluster": "c" } }, { "id": "22", "data": { "cluster": "c" } }, { "id": "23", "data": { "cluster": "c" } }, { "id": "24", "data": { "cluster": "c" } }, { "id": "25", "data": { "cluster": "c" } }, { "id": "26", "data": { "cluster": "c" } }, { "id": "27", "data": { "cluster": "c" } }, { "id": "28", "data": { "cluster": "c" } }, { "id": "29", "data": { "cluster": "c" } }, { "id": "30", "data": { "cluster": "c" } }, { "id": "31", "data": { "cluster": "d" } }, { "id": "32", "data": { "cluster": "d" } }, { "id": "33", "data": { "cluster": "d" } } ], "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" } ] } ================================================ FILE: packages/g6/__tests__/dataset/combo.json ================================================ { "nodes": [ { "id": "0", "combo": "a" }, { "id": "1", "combo": "a" }, { "id": "2", "combo": "a" }, { "id": "3", "combo": "a" }, { "id": "4", "combo": "a" }, { "id": "5", "combo": "a" }, { "id": "6", "combo": "a" }, { "id": "7", "combo": "a" }, { "id": "8", "combo": "a" }, { "id": "9", "combo": "a" }, { "id": "10", "combo": "a" }, { "id": "11", "combo": "a" }, { "id": "12", "combo": "a" }, { "id": "13", "combo": "a" }, { "id": "14", "combo": "a" }, { "id": "15", "combo": "a" }, { "id": "16", "combo": "b" }, { "id": "17", "combo": "b" }, { "id": "18", "combo": "b" }, { "id": "19", "combo": "b" }, { "id": "20" }, { "id": "21" }, { "id": "22" }, { "id": "23", "combo": "c" }, { "id": "24", "combo": "a" }, { "id": "25" }, { "id": "26" }, { "id": "27", "combo": "c" }, { "id": "28", "combo": "c" }, { "id": "29", "combo": "c" }, { "id": "30", "combo": "c" }, { "id": "31", "combo": "c" }, { "id": "32", "combo": "d" }, { "id": "33", "combo": "d" } ], "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" } ], "combos": [ { "id": "a", "data": { "label": "Combo A" } }, { "id": "b", "data": { "label": "Combo B" } }, { "id": "c", "data": { "label": "Combo D" } }, { "id": "d", "combo": "b", "data": { "label": "Combo D" } } ] } ================================================ FILE: packages/g6/__tests__/dataset/dagre-combo.json ================================================ { "nodes": [ { "id": "0" }, { "id": "1" }, { "id": "2" }, { "id": "3" }, { "id": "4", "combo": "A" }, { "id": "5", "combo": "B" }, { "id": "6", "combo": "A" }, { "id": "7", "combo": "C" }, { "id": "8", "combo": "C" }, { "id": "9", "combo": "A" }, { "id": "10", "combo": "B" }, { "id": "11", "combo": "B" } ], "edges": [ { "id": "edge-102", "source": "0", "target": "1" }, { "id": "edge-161", "source": "0", "target": "2" }, { "id": "edge-237", "source": "1", "target": "4" }, { "id": "edge-253", "source": "0", "target": "3" }, { "id": "edge-133", "source": "3", "target": "4" }, { "id": "edge-320", "source": "2", "target": "5" }, { "id": "edge-355", "source": "1", "target": "6" }, { "id": "edge-823", "source": "1", "target": "7" }, { "id": "edge-665", "source": "3", "target": "8" }, { "id": "edge-884", "source": "3", "target": "9" }, { "id": "edge-536", "source": "5", "target": "10" }, { "id": "edge-401", "source": "5", "target": "11" } ], "combos": [ { "id": "A", "style": { "type": "rect" } }, { "id": "B", "style": { "type": "rect" } }, { "id": "C", "style": { "type": "rect" } } ] } ================================================ FILE: packages/g6/__tests__/dataset/dagre.json ================================================ { "nodes": [ { "id": "0", "data": { "label": "0" } }, { "id": "1", "data": { "label": "1" } }, { "id": "2", "data": { "label": "2" } }, { "id": "3", "data": { "label": "3" } }, { "id": "4", "data": { "label": "4" } }, { "id": "5", "data": { "label": "5" } }, { "id": "6", "data": { "label": "6" } }, { "id": "7", "data": { "label": "7" } }, { "id": "8", "data": { "label": "8" } }, { "id": "9", "data": { "label": "9" } } ], "edges": [ { "source": "0", "target": "1", "data": {} }, { "source": "0", "target": "2", "data": {} }, { "source": "1", "target": "4", "data": {} }, { "source": "0", "target": "3", "data": {} }, { "source": "3", "target": "4", "data": {} }, { "source": "4", "target": "5", "data": {} }, { "source": "4", "target": "6", "data": {} }, { "source": "5", "target": "7", "data": {} }, { "source": "5", "target": "8", "data": {} }, { "source": "8", "target": "9", "data": {} }, { "source": "2", "target": "9", "data": {} }, { "source": "3", "target": "9", "data": {} } ] } ================================================ FILE: packages/g6/__tests__/dataset/decision-tree.json ================================================ { "id": "g1", "name": "Name1", "count": 123456, "label": "538.90", "currency": "Yuan", "rate": 1.0, "status": "B", "variableName": "V1", "variableValue": 0.341, "variableUp": false, "children": [ { "id": "g12", "name": "Deal with LONG label LONG label LONG label LONG label", "count": 123456, "label": "338.00", "rate": 0.627, "status": "R", "currency": "Yuan", "variableName": "V2", "variableValue": 0.179, "variableUp": true, "children": [ { "id": "g121", "name": "Name3", "collapsed": true, "count": 123456, "label": "138.00", "rate": 0.123, "status": "B", "currency": "Yuan", "variableName": "V2", "variableValue": 0.27, "variableUp": true, "children": [ { "id": "g1211", "name": "Name4", "count": 123456, "label": "138.00", "rate": 1.0, "status": "B", "currency": "Yuan", "variableName": "V1", "variableValue": 0.164, "variableUp": false, "children": [] } ] }, { "id": "g122", "name": "Name5", "collapsed": true, "count": 123456, "label": "100.00", "rate": 0.296, "status": "G", "currency": "Yuan", "variableName": "V1", "variableValue": 0.259, "variableUp": true, "children": [ { "id": "g1221", "name": "Name6", "count": 123456, "label": "40.00", "rate": 0.4, "status": "G", "currency": "Yuan", "variableName": "V1", "variableValue": 0.135, "variableUp": true, "children": [ { "id": "g12211", "name": "Name6-1", "count": 123456, "label": "40.00", "rate": 1.0, "status": "R", "currency": "Yuan", "variableName": "V1", "variableValue": 0.181, "variableUp": true, "children": [] } ] }, { "id": "g1222", "name": "Name7", "count": 123456, "label": "60.00", "rate": 0.6, "status": "G", "currency": "Yuan", "variableName": "V1", "variableValue": 0.239, "variableUp": false, "children": [] } ] }, { "id": "g123", "name": "Name8", "collapsed": true, "count": 123456, "label": "100.00", "rate": 0.296, "status": "DI", "currency": "Yuan", "variableName": "V2", "variableValue": 0.131, "variableUp": false, "children": [ { "id": "g1231", "name": "Name8-1", "count": 123456, "label": "100.00", "rate": 1.0, "status": "DI", "currency": "Yuan", "variableName": "V2", "variableValue": 0.131, "variableUp": false, "children": [] } ] } ] }, { "id": "g13", "name": "Name9", "count": 123456, "label": "100.90", "rate": 0.187, "status": "B", "currency": "Yuan", "variableName": "V2", "variableValue": 0.221, "variableUp": true, "children": [ { "id": "g131", "name": "Name10", "count": 123456, "label": "33.90", "rate": 0.336, "status": "R", "currency": "Yuan", "variableName": "V1", "variableValue": 0.12, "variableUp": true, "children": [] }, { "id": "g132", "name": "Name11", "count": 123456, "label": "67.00", "rate": 0.664, "status": "G", "currency": "Yuan", "variableName": "V1", "variableValue": 0.241, "variableUp": false, "children": [] } ] }, { "id": "g14", "name": "Name12", "count": 123456, "label": "100.00", "rate": 0.186, "status": "G", "currency": "Yuan", "variableName": "V2", "variableValue": 0.531, "variableUp": true, "children": [] } ] } ================================================ FILE: packages/g6/__tests__/dataset/element-edges.json ================================================ { "nodes": [ { "id": "node1" }, { "id": "node2" }, { "id": "node3" }, { "id": "node4" }, { "id": "node5" }, { "id": "node6" } ], "edges": [ { "id": "line-default", "source": "node1", "target": "node2" }, { "id": "line-active", "source": "node1", "target": "node3", "states": ["active"] }, { "id": "line-selected", "source": "node1", "target": "node4", "states": ["selected"] }, { "id": "line-highlight", "source": "node1", "target": "node5", "states": ["highlight"] }, { "id": "line-inactive", "source": "node1", "target": "node6", "states": ["inactive"] } ] } ================================================ FILE: packages/g6/__tests__/dataset/element-nodes.json ================================================ { "nodes": [ { "id": "default" }, { "id": "halo", "style": { "halo": true } }, { "id": "badges", "style": { "badges": [ { "text": "\ue603", "fontFamily": "iconfont", "backgroundWidth": 12, "backgroundHeight": 12, "placement": "right-top", "offsetX": -3 }, { "text": "Important", "placement": "right" }, { "text": "Notice", "placement": "right-bottom" } ], "badgeFontSize": 8, "badgePadding": [1, 4] } }, { "id": "icon", "style": { "iconSrc": "https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg" } }, { "id": "ports", "style": { "portR": 3, "ports": [{ "placement": "left" }, { "placement": "right" }, { "placement": "top" }, { "placement": "bottom" }] } }, { "id": "active", "states": ["active"] }, { "id": "selected", "states": ["selected"] }, { "id": "highlight", "states": ["highlight"] }, { "id": "inactive", "states": ["inactive"] }, { "id": "disabled", "states": ["disabled"] } ] } ================================================ FILE: packages/g6/__tests__/dataset/file-system.json ================================================ { "id": "src", "children": [ { "id": "animations" }, { "id": "behaviors" }, { "id": "elements", "children": [{ "id": "nodes" }, { "id": "edges" }, { "id": "combos" }] }, { "id": "layouts" }, { "id": "runtime", "children": [ { "id": "canvas" }, { "id": "data" }, { "id": "element" }, { "id": "graph" }, { "id": "layout" }, { "id": "viewport" } ] }, { "id": "spec" }, { "id": "themes", "children": [{ "id": "dark" }, { "id": "light" }] } ] } ================================================ FILE: packages/g6/__tests__/dataset/flare.json ================================================ { "id": "flare", "children": [ { "id": "analytics", "children": [ { "id": "cluster", "children": [ { "id": "AgglomerativeCluster", "value": 3938 }, { "id": "CommunityStructure", "value": 3812 }, { "id": "HierarchicalCluster", "value": 6714 }, { "id": "MergeEdge", "value": 743 } ] }, { "id": "graph", "children": [ { "id": "BetweennessCentrality", "value": 3534 }, { "id": "LinkDistance", "value": 5731 }, { "id": "MaxFlowMinCut", "value": 7840 }, { "id": "ShortestPaths", "value": 5914 }, { "id": "SpanningTree", "value": 3416 } ] }, { "id": "optimization", "children": [{ "id": "AspectRatioBanker", "value": 7074 }] } ] }, { "id": "animate", "children": [ { "id": "Easing", "value": 17010 }, { "id": "FunctionSequence", "value": 5842 }, { "id": "interpolate", "children": [ { "id": "ArrayInterpolator", "value": 1983 }, { "id": "ColorInterpolator", "value": 2047 }, { "id": "DateInterpolator", "value": 1375 }, { "id": "Interpolator", "value": 8746 }, { "id": "MatrixInterpolator", "value": 2202 }, { "id": "NumberInterpolator", "value": 1382 }, { "id": "ObjectInterpolator", "value": 1629 }, { "id": "PointInterpolator", "value": 1675 }, { "id": "RectangleInterpolator", "value": 2042 } ] }, { "id": "ISchedulable", "value": 1041 }, { "id": "Parallel", "value": 5176 }, { "id": "Pause", "value": 449 }, { "id": "Scheduler", "value": 5593 }, { "id": "Sequence", "value": 5534 }, { "id": "Transition", "value": 9201 }, { "id": "Transitioner", "value": 19975 }, { "id": "TransitionEvent", "value": 1116 }, { "id": "Tween", "value": 6006 } ] }, { "id": "display", "children": [ { "id": "DirtySprite", "value": 8833 }, { "id": "LineSprite", "value": 1732 }, { "id": "RectSprite", "value": 3623 }, { "id": "TextSprite", "value": 10066 } ] }, { "id": "flex", "children": [{ "id": "FlareVis", "value": 4116 }] }, { "id": "physics", "children": [ { "id": "DragForce", "value": 1082 }, { "id": "GravityForce", "value": 1336 }, { "id": "IForce", "value": 319 }, { "id": "NBodyForce", "value": 10498 }, { "id": "Particle", "value": 2822 }, { "id": "Simulation", "value": 9983 }, { "id": "Spring", "value": 2213 }, { "id": "SpringForce", "value": 1681 } ] }, { "id": "query", "children": [ { "id": "AggregateExpression", "value": 1616 }, { "id": "And", "value": 1027 }, { "id": "Arithmetic", "value": 3891 }, { "id": "Average", "value": 891 }, { "id": "BinaryExpression", "value": 2893 }, { "id": "Comparison", "value": 5103 }, { "id": "CompositeExpression", "value": 3677 }, { "id": "Count", "value": 781 }, { "id": "DateUtil", "value": 4141 }, { "id": "Distinct", "value": 933 }, { "id": "Expression", "value": 5130 }, { "id": "ExpressionIterator", "value": 3617 }, { "id": "Fn", "value": 3240 }, { "id": "If", "value": 2732 }, { "id": "IsA", "value": 2039 }, { "id": "Literal", "value": 1214 }, { "id": "Match", "value": 3748 }, { "id": "Maximum", "value": 843 }, { "id": "methods", "children": [ { "id": "add", "value": 593 }, { "id": "and", "value": 330 }, { "id": "average", "value": 287 }, { "id": "count", "value": 277 }, { "id": "distinct", "value": 292 }, { "id": "div", "value": 595 }, { "id": "eq", "value": 594 }, { "id": "fn", "value": 460 }, { "id": "gt", "value": 603 }, { "id": "gte", "value": 625 }, { "id": "iff", "value": 748 }, { "id": "isa", "value": 461 }, { "id": "lt", "value": 597 }, { "id": "lte", "value": 619 }, { "id": "max", "value": 283 }, { "id": "min", "value": 283 }, { "id": "mod", "value": 591 }, { "id": "mul", "value": 603 }, { "id": "neq", "value": 599 }, { "id": "not", "value": 386 }, { "id": "or", "value": 323 }, { "id": "orderby", "value": 307 }, { "id": "range", "value": 772 }, { "id": "select", "value": 296 }, { "id": "stddev", "value": 363 }, { "id": "sub", "value": 600 }, { "id": "sum", "value": 280 }, { "id": "update", "value": 307 }, { "id": "variance", "value": 335 }, { "id": "where", "value": 299 }, { "id": "xor", "value": 354 }, { "id": "-", "value": 264 } ] }, { "id": "Minimum", "value": 843 }, { "id": "Not", "value": 1554 }, { "id": "Or", "value": 970 }, { "id": "Query", "value": 13896 }, { "id": "Range", "value": 1594 }, { "id": "StringUtil", "value": 4130 }, { "id": "Sum", "value": 791 }, { "id": "Variable", "value": 1124 }, { "id": "Variance", "value": 1876 }, { "id": "Xor", "value": 1101 } ] }, { "id": "scale", "children": [ { "id": "IScaleMap", "value": 2105 }, { "id": "LinearScale", "value": 1316 }, { "id": "LogScale", "value": 3151 }, { "id": "OrdinalScale", "value": 3770 }, { "id": "QuantileScale", "value": 2435 }, { "id": "QuantitativeScale", "value": 4839 }, { "id": "RootScale", "value": 1756 }, { "id": "Scale", "value": 4268 }, { "id": "ScaleType", "value": 1821 }, { "id": "TimeScale", "value": 5833 } ] }, { "id": "util", "children": [ { "id": "Arrays", "value": 8258 }, { "id": "Colors", "value": 10001 }, { "id": "Dates", "value": 8217 }, { "id": "Displays", "value": 12555 }, { "id": "Filter", "value": 2324 }, { "id": "Geometry", "value": 10993 }, { "id": "heap", "children": [ { "id": "FibonacciHeap", "value": 9354 }, { "id": "HeapNode", "value": 1233 } ] }, { "id": "IEvaluable", "value": 335 }, { "id": "IPredicate", "value": 383 }, { "id": "IValueProxy", "value": 874 }, { "id": "math", "children": [ { "id": "DenseMatrix", "value": 3165 }, { "id": "IMatrix", "value": 2815 }, { "id": "SparseMatrix", "value": 3366 } ] }, { "id": "Maths", "value": 17705 }, { "id": "Orientation", "value": 1486 }, { "id": "palette", "children": [ { "id": "ColorPalette", "value": 6367 }, { "id": "Palette", "value": 1229 }, { "id": "ShapePalette", "value": 2059 }, { "id": "SizePalette", "value": 2291 } ] }, { "id": "Property", "value": 5559 }, { "id": "Shapes", "value": 19118 }, { "id": "Sort", "value": 6887 }, { "id": "Stats", "value": 6557 }, { "id": "Strings", "value": 22026 } ] }, { "id": "vis", "children": [ { "id": "axis", "children": [ { "id": "Axes", "value": 1302 }, { "id": "Axis", "value": 24593 }, { "id": "AxisGridLine", "value": 652 }, { "id": "AxisLabel", "value": 636 }, { "id": "CartesianAxes", "value": 6703 } ] }, { "id": "controls", "children": [ { "id": "AnchorControl", "value": 2138 }, { "id": "ClickControl", "value": 3824 }, { "id": "Control", "value": 1353 }, { "id": "ControlList", "value": 4665 }, { "id": "DragControl", "value": 2649 }, { "id": "ExpandControl", "value": 2832 }, { "id": "HoverControl", "value": 4896 }, { "id": "IControl", "value": 763 }, { "id": "PanZoomControl", "value": 5222 }, { "id": "SelectionControl", "value": 7862 }, { "id": "TooltipControl", "value": 8435 } ] }, { "id": "data", "children": [ { "id": "Data", "value": 20544 }, { "id": "DataList", "value": 19788 }, { "id": "DataSprite", "value": 10349 }, { "id": "EdgeSprite", "value": 3301 }, { "id": "NodeSprite", "value": 19382 }, { "id": "render", "children": [ { "id": "ArrowType", "value": 698 }, { "id": "EdgeRenderer", "value": 5569 }, { "id": "IRenderer", "value": 353 }, { "id": "ShapeRenderer", "value": 2247 } ] }, { "id": "ScaleBinding", "value": 11275 }, { "id": "Tree", "value": 7147 }, { "id": "TreeBuilder", "value": 9930 } ] }, { "id": "events", "children": [ { "id": "DataEvent", "value": 2313 }, { "id": "SelectionEvent", "value": 1880 }, { "id": "TooltipEvent", "value": 1701 }, { "id": "VisualizationEvent", "value": 1117 } ] }, { "id": "legend", "children": [ { "id": "Legend", "value": 20859 }, { "id": "LegendItem", "value": 4614 }, { "id": "LegendRange", "value": 10530 } ] }, { "id": "operator", "children": [ { "id": "distortion", "children": [ { "id": "BifocalDistortion", "value": 4461 }, { "id": "Distortion", "value": 6314 }, { "id": "FisheyeDistortion", "value": 3444 } ] }, { "id": "encoder", "children": [ { "id": "ColorEncoder", "value": 3179 }, { "id": "Encoder", "value": 4060 }, { "id": "PropertyEncoder", "value": 4138 }, { "id": "ShapeEncoder", "value": 1690 }, { "id": "SizeEncoder", "value": 1830 } ] }, { "id": "filter", "children": [ { "id": "FisheyeTreeFilter", "value": 5219 }, { "id": "GraphDistanceFilter", "value": 3165 }, { "id": "VisibilityFilter", "value": 3509 } ] }, { "id": "IOperator", "value": 1286 }, { "id": "label", "children": [ { "id": "Labeler", "value": 9956 }, { "id": "RadialLabeler", "value": 3899 }, { "id": "StackedAreaLabeler", "value": 3202 } ] }, { "id": "layout", "children": [ { "id": "AxisLayout", "value": 6725 }, { "id": "BundledEdgeRouter", "value": 3727 }, { "id": "CircleLayout", "value": 9317 }, { "id": "CirclePackingLayout", "value": 12003 }, { "id": "DendrogramLayout", "value": 4853 }, { "id": "ForceDirectedLayout", "value": 8411 }, { "id": "IcicleTreeLayout", "value": 4864 }, { "id": "IndentedTreeLayout", "value": 3174 }, { "id": "Layout", "value": 7881 }, { "id": "NodeLinkTreeLayout", "value": 12870 }, { "id": "PieLayout", "value": 2728 }, { "id": "RadialTreeLayout", "value": 12348 }, { "id": "RandomLayout", "value": 870 }, { "id": "StackedAreaLayout", "value": 9121 }, { "id": "TreeMapLayout", "value": 9191 } ] }, { "id": "Operator", "value": 2490 }, { "id": "OperatorList", "value": 5248 }, { "id": "OperatorSequence", "value": 4190 }, { "id": "OperatorSwitch", "value": 2581 }, { "id": "SortOperator", "value": 2023 } ] }, { "id": "Visualization", "value": 16540 } ] } ] } ================================================ FILE: packages/g6/__tests__/dataset/force.json ================================================ { "nodes": [ { "id": "node0", "style": { "size": 50 }, "data": { "cluster": "node0" } }, { "id": "node1", "style": { "size": 30 }, "data": { "cluster": "node1" } }, { "id": "node2", "style": { "size": 30 }, "data": { "cluster": "node2" } }, { "id": "node3", "style": { "size": 30 }, "data": { "cluster": "node3" } }, { "id": "node4", "style": { "size": 30 }, "data": { "cluster": "node4" } }, { "id": "node5", "style": { "size": 30 }, "data": { "isLeaf": true, "cluster": "node5" } }, { "id": "node6", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node6" } }, { "id": "node7", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node7" } }, { "id": "node8", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node8" } }, { "id": "node9", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node9" } }, { "id": "node10", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node10" } }, { "id": "node11", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node11" } }, { "id": "node12", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node12" } }, { "id": "node13", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node13" } }, { "id": "node14", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node14" } }, { "id": "node15", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node15" } }, { "id": "node16", "style": { "size": 15 }, "data": { "isLeaf": true, "cluster": "node16" } } ], "edges": [ { "source": "node0", "target": "node1" }, { "source": "node0", "target": "node2" }, { "source": "node0", "target": "node3" }, { "source": "node0", "target": "node4" }, { "source": "node0", "target": "node5" }, { "source": "node1", "target": "node6" }, { "source": "node1", "target": "node7" }, { "source": "node2", "target": "node8" }, { "source": "node2", "target": "node9" }, { "source": "node2", "target": "node10" }, { "source": "node2", "target": "node11" }, { "source": "node2", "target": "node12" }, { "source": "node2", "target": "node13" }, { "source": "node3", "target": "node14" }, { "source": "node3", "target": "node15" }, { "source": "node3", "target": "node16" } ] } ================================================ FILE: packages/g6/__tests__/dataset/gene.json ================================================ { "nodes": [ { "id": "HIRA", "data": { "rank": 148 } }, { "id": "SERPINE1", "data": { "rank": 5 } }, { "id": "FAS", "data": { "rank": 38 } }, { "id": "H1F0", "data": { "rank": 179 } }, { "id": "CHEK2", "data": { "rank": 42 } }, { "id": "COL18A1", "data": { "rank": 157 } }, { "id": "CREBBP", "data": { "rank": 24 } }, { "id": "FDXR", "data": { "rank": 147 } }, { "id": "SMYD2", "data": { "rank": 175 } }, { "id": "ATR", "data": { "rank": 52 } }, { "id": "HGF", "data": { "rank": 4 } }, { "id": "ATM", "data": { "rank": 25 } }, { "id": "TP63", "data": { "rank": 37 } }, { "id": "GPX1", "data": { "rank": 13 } }, { "id": "TRIAP1", "data": { "rank": 183 } }, { "id": "HIST1H1E", "data": { "rank": 167 } }, { "id": "HIST1H1D", "data": { "rank": 184 } }, { "id": "HIST1H1C", "data": { "rank": 174 } }, { "id": "HIST1H1B", "data": { "rank": 96 } }, { "id": "HIST1H1A", "data": { "rank": 173 } }, { "id": "TP53", "data": { "rank": 0 } }, { "id": "GADD45A", "data": { "rank": 98 } }, { "id": "PML", "data": { "rank": 35 } }, { "id": "SUMO1", "data": { "rank": 36 } }, { "id": "PPP2CA", "data": { "rank": 146 } }, { "id": "PPP2CB", "data": { "rank": 185 } }, { "id": "PRKAA1", "data": { "rank": 78 } }, { "id": "PRKAA2", "data": { "rank": 29 } }, { "id": "MAPK11", "data": { "rank": 127 } }, { "id": "TP73", "data": { "rank": 116 } }, { "id": "CCNB1", "data": { "rank": 62 } }, { "id": "MAPK12", "data": { "rank": 131 } }, { "id": "MAPK13", "data": { "rank": 126 } }, { "id": "MAPK14", "data": { "rank": 23 } }, { "id": "PRKAB2", "data": { "rank": 189 } }, { "id": "PRKAB1", "data": { "rank": 73 } }, { "id": "EP300", "data": { "rank": 19 } }, { "id": "IGBP1", "data": { "rank": 125 } }, { "id": "FBXO11", "data": { "rank": 188 } }, { "id": "HMGB1", "data": { "rank": 54 } }, { "id": "NEDD8", "data": { "rank": 205 } }, { "id": "ASF1A", "data": { "rank": 161 } }, { "id": "KAT8", "data": { "rank": 199 } }, { "id": "HTT", "data": { "rank": 43 } }, { "id": "WRN", "data": { "rank": 53 } }, { "id": "CDKN1A", "data": { "rank": 9 } }, { "id": "PLK3", "data": { "rank": 115 } }, { "id": "DYRK1A", "data": { "rank": 128 } }, { "id": "PRKAG1", "data": { "rank": 177 } }, { "id": "CASP6", "data": { "rank": 104 } }, { "id": "MAX", "data": { "rank": 158 } }, { "id": "FOS", "data": { "rank": 61 } }, { "id": "TP53I3", "data": { "rank": 152 } }, { "id": "PRMT1", "data": { "rank": 206 } }, { "id": "PCBP4", "data": { "rank": 165 } }, { "id": "PRMT5", "data": { "rank": 118 } }, { "id": "E4F1", "data": { "rank": 150 } }, { "id": "CASP1", "data": { "rank": 64 } }, { "id": "PRKCA", "data": { "rank": 84 } }, { "id": "CDK1", "data": { "rank": 27 } }, { "id": "CDK9", "data": { "rank": 66 } }, { "id": "RB1", "data": { "rank": 15 } }, { "id": "CDK7", "data": { "rank": 72 } }, { "id": "HMGA2", "data": { "rank": 82 } }, { "id": "DAPK3", "data": { "rank": 112 } }, { "id": "HMGA1", "data": { "rank": 111 } }, { "id": "PRKCD", "data": { "rank": 122 } }, { "id": "UBN1", "data": { "rank": 180 } }, { "id": "CDK5", "data": { "rank": 44 } }, { "id": "CDK2", "data": { "rank": 34 } }, { "id": "DAPK1", "data": { "rank": 49 } }, { "id": "RFWD2", "data": { "rank": 143 } }, { "id": "PPM1J", "data": { "rank": 191 } }, { "id": "BTG2", "data": { "rank": 97 } }, { "id": "CD82", "data": { "rank": 69 } }, { "id": "HIPK2", "data": { "rank": 70 } }, { "id": "DDB2", "data": { "rank": 102 } }, { "id": "MDM2", "data": { "rank": 7 } }, { "id": "CTSD", "data": { "rank": 85 } }, { "id": "NGFR", "data": { "rank": 80 } }, { "id": "CARM1", "data": { "rank": 207 } }, { "id": "TYRP1", "data": { "rank": 87 } }, { "id": "PRKDC", "data": { "rank": 74 } }, { "id": "HIC1", "data": { "rank": 99 } }, { "id": "TAP1", "data": { "rank": 63 } }, { "id": "PYCARD", "data": { "rank": 89 } }, { "id": "APC", "data": { "rank": 22 } }, { "id": "RNF144B", "data": { "rank": 190 } }, { "id": "KAT2B", "data": { "rank": 55 } }, { "id": "MSH2", "data": { "rank": 21 } }, { "id": "PPP1R13L", "data": { "rank": 140 } }, { "id": "SIRT1", "data": { "rank": 46 } }, { "id": "CASP10", "data": { "rank": 100 } }, { "id": "SP1", "data": { "rank": 204 } }, { "id": "IRF5", "data": { "rank": 101 } }, { "id": "BAX", "data": { "rank": 51 } }, { "id": "CABIN1", "data": { "rank": 132 } }, { "id": "SETD7", "data": { "rank": 156 } }, { "id": "SETD8", "data": { "rank": 164 } }, { "id": "APAF1", "data": { "rank": 83 } }, { "id": "GDF15", "data": { "rank": 91 } } ], "edges": [ { "id": "TP53-controls-state-change-of-H1F0", "source": "TP53", "target": "H1F0" }, { "id": "TP53-controls-expression-of-IRF5", "source": "TP53", "target": "IRF5" }, { "id": "KAT8-controls-state-change-of-TP53", "source": "KAT8", "target": "TP53" }, { "id": "FBXO11-controls-state-change-of-TP53", "source": "FBXO11", "target": "TP53" }, { "id": "ATR-controls-state-change-of-TP53", "source": "ATR", "target": "TP53" }, { "id": "FBXO11-controls-state-change-of-NEDD8", "source": "FBXO11", "target": "NEDD8" }, { "id": "ATM-controls-state-change-of-TP53", "source": "ATM", "target": "TP53" }, { "id": "SMYD2-controls-state-change-of-TP53", "source": "SMYD2", "target": "TP53" }, { "id": "NGFR-controls-state-change-of-TP53", "source": "NGFR", "target": "TP53" }, { "id": "MAX-controls-expression-of-TP53", "source": "MAX", "target": "TP53" }, { "id": "TP53-controls-expression-of-PRKAB1", "source": "TP53", "target": "PRKAB1" }, { "id": "MDM2-controls-state-change-of-TP53", "source": "MDM2", "target": "TP53" }, { "id": "SETD8-controls-state-change-of-TP53", "source": "SETD8", "target": "TP53" }, { "id": "SETD7-controls-state-change-of-TP53", "source": "SETD7", "target": "TP53" }, { "id": "TP53-controls-expression-of-TYRP1", "source": "TP53", "target": "TYRP1" }, { "id": "ATR-controls-state-change-of-TP63", "source": "ATR", "target": "TP63" }, { "id": "PRKCA-controls-state-change-of-TP53", "source": "PRKCA", "target": "TP53" }, { "id": "ATM-controls-state-change-of-TP63", "source": "ATM", "target": "TP63" }, { "id": "CHEK2-controls-state-change-of-TP73", "source": "CHEK2", "target": "TP73" }, { "id": "TP53-controls-expression-of-DDB2", "source": "TP53", "target": "DDB2" }, { "id": "PRKCD-controls-state-change-of-TP53", "source": "PRKCD", "target": "TP53" }, { "id": "TP53-controls-expression-of-TP63", "source": "TP53", "target": "TP63" }, { "id": "TP53-controls-expression-of-PML", "source": "TP53", "target": "PML" }, { "id": "ATM-controls-state-change-of-TP73", "source": "ATM", "target": "TP73" }, { "id": "WRN-controls-state-change-of-TP63", "source": "WRN", "target": "TP63" }, { "id": "ATR-controls-state-change-of-TP73", "source": "ATR", "target": "TP73" }, { "id": "TP53-controls-expression-of-PCBP4", "source": "TP53", "target": "PCBP4" }, { "id": "TP53-controls-expression-of-GADD45A", "source": "TP53", "target": "GADD45A" }, { "id": "PRKDC-controls-state-change-of-TP53", "source": "PRKDC", "target": "TP53" }, { "id": "TP53-controls-expression-of-GDF15", "source": "TP53", "target": "GDF15" }, { "id": "WRN-controls-state-change-of-TP73", "source": "WRN", "target": "TP73" }, { "id": "TP53-controls-expression-of-SERPINE1", "source": "TP53", "target": "SERPINE1" }, { "id": "TP53-controls-expression-of-PPM1J", "source": "TP53", "target": "PPM1J" }, { "id": "HTT-controls-state-change-of-TP63", "source": "HTT", "target": "TP63" }, { "id": "TP53-controls-expression-of-CASP1", "source": "TP53", "target": "CASP1" }, { "id": "MAPK14-controls-state-change-of-TP63", "source": "MAPK14", "target": "TP63" }, { "id": "HMGB1-controls-state-change-of-TP73", "source": "HMGB1", "target": "TP73" }, { "id": "TP53-controls-expression-of-CASP6", "source": "TP53", "target": "CASP6" }, { "id": "MAPK13-controls-state-change-of-TP63", "source": "MAPK13", "target": "TP63" }, { "id": "HTT-controls-state-change-of-TP53", "source": "HTT", "target": "TP53" }, { "id": "MAPK12-controls-state-change-of-TP63", "source": "MAPK12", "target": "TP63" }, { "id": "MAPK11-controls-state-change-of-TP63", "source": "MAPK11", "target": "TP63" }, { "id": "SIRT1-controls-expression-of-CDKN1A", "source": "SIRT1", "target": "CDKN1A" }, { "id": "PML-controls-state-change-of-TP53", "source": "PML", "target": "TP53" }, { "id": "CREBBP-controls-state-change-of-TP53", "source": "CREBBP", "target": "TP53" }, { "id": "TP53-controls-expression-of-TAP1", "source": "TP53", "target": "TAP1" }, { "id": "MAPK13-controls-state-change-of-TP53", "source": "MAPK13", "target": "TP53" }, { "id": "MAPK12-controls-state-change-of-TP53", "source": "MAPK12", "target": "TP53" }, { "id": "MAPK14-controls-state-change-of-TP53", "source": "MAPK14", "target": "TP53" }, { "id": "MAPK11-controls-state-change-of-TP53", "source": "MAPK11", "target": "TP53" }, { "id": "TP53-controls-state-change-of-BAX", "source": "TP53", "target": "BAX" }, { "id": "HMGB1-controls-state-change-of-TP63", "source": "HMGB1", "target": "TP63" }, { "id": "TP53-controls-expression-of-TP73", "source": "TP53", "target": "TP73" }, { "id": "PML-controls-state-change-of-TP63", "source": "PML", "target": "TP63" }, { "id": "PPP2CA-controls-state-change-of-TP73", "source": "PPP2CA", "target": "TP73" }, { "id": "PPP2CB-controls-state-change-of-TP73", "source": "PPP2CB", "target": "TP73" }, { "id": "FOS-controls-expression-of-TP53", "source": "FOS", "target": "TP53" }, { "id": "CREBBP-controls-expression-of-GADD45A", "source": "CREBBP", "target": "GADD45A" }, { "id": "TP53-controls-expression-of-CDKN1A", "source": "TP53", "target": "CDKN1A" }, { "id": "PML-controls-state-change-of-TP73", "source": "PML", "target": "TP73" }, { "id": "HMGB1-controls-state-change-of-TP53", "source": "HMGB1", "target": "TP53" }, { "id": "IGBP1-controls-state-change-of-TP73", "source": "IGBP1", "target": "TP73" }, { "id": "HIPK2-controls-state-change-of-TP53", "source": "HIPK2", "target": "TP53" }, { "id": "TP53-controls-expression-of-RNF144B", "source": "TP53", "target": "RNF144B" }, { "id": "TP53-controls-expression-of-HIC1", "source": "TP53", "target": "HIC1" }, { "id": "TP53-controls-state-change-of-HIRA", "source": "TP53", "target": "HIRA" }, { "id": "MAPK14-controls-state-change-of-TP73", "source": "MAPK14", "target": "TP73" }, { "id": "TP53-controls-state-change-of-UBN1", "source": "TP53", "target": "UBN1" }, { "id": "TP53-controls-expression-of-RB1", "source": "TP53", "target": "RB1" }, { "id": "MAPK11-controls-state-change-of-TP73", "source": "MAPK11", "target": "TP73" }, { "id": "TP53-controls-state-change-of-HMGA2", "source": "TP53", "target": "HMGA2" }, { "id": "TP53-controls-state-change-of-HMGA1", "source": "TP53", "target": "HMGA1" }, { "id": "MAPK13-controls-state-change-of-TP73", "source": "MAPK13", "target": "TP73" }, { "id": "MAPK12-controls-state-change-of-TP73", "source": "MAPK12", "target": "TP73" }, { "id": "IGBP1-controls-state-change-of-TP63", "source": "IGBP1", "target": "TP63" }, { "id": "TP53-controls-expression-of-TP53I3", "source": "TP53", "target": "TP53I3" }, { "id": "DYRK1A-controls-state-change-of-TP53", "source": "DYRK1A", "target": "TP53" }, { "id": "SUMO1-controls-state-change-of-TP73", "source": "SUMO1", "target": "TP73" }, { "id": "PRKAG1-controls-state-change-of-TP63", "source": "PRKAG1", "target": "TP63" }, { "id": "SIRT1-controls-state-change-of-TP73", "source": "SIRT1", "target": "TP73" }, { "id": "PPP2CB-controls-state-change-of-TP63", "source": "PPP2CB", "target": "TP63" }, { "id": "CHEK2-controls-state-change-of-TP53", "source": "CHEK2", "target": "TP53" }, { "id": "PPP2CA-controls-state-change-of-TP63", "source": "PPP2CA", "target": "TP63" }, { "id": "CDK7-controls-state-change-of-TP53", "source": "CDK7", "target": "TP53" }, { "id": "CDK9-controls-state-change-of-TP53", "source": "CDK9", "target": "TP53" }, { "id": "CDK2-controls-state-change-of-TP53", "source": "CDK2", "target": "TP53" }, { "id": "CDK5-controls-state-change-of-TP53", "source": "CDK5", "target": "TP53" }, { "id": "IGBP1-controls-state-change-of-TP53", "source": "IGBP1", "target": "TP53" }, { "id": "TP53-controls-expression-of-BAX", "source": "TP53", "target": "BAX" }, { "id": "TP53-controls-expression-of-CCNB1", "source": "TP53", "target": "CCNB1" }, { "id": "SIRT1-controls-state-change-of-TP63", "source": "SIRT1", "target": "TP63" }, { "id": "PRKAG1-controls-state-change-of-TP73", "source": "PRKAG1", "target": "TP73" }, { "id": "TP53-controls-expression-of-MDM2", "source": "TP53", "target": "MDM2" }, { "id": "PPP2CB-controls-state-change-of-TP53", "source": "PPP2CB", "target": "TP53" }, { "id": "PPP2CA-controls-state-change-of-TP53", "source": "PPP2CA", "target": "TP53" }, { "id": "CHEK2-controls-state-change-of-TP63", "source": "CHEK2", "target": "TP63" }, { "id": "TP53-controls-expression-of-CD82", "source": "TP53", "target": "CD82" }, { "id": "TP53-controls-expression-of-FDXR", "source": "TP53", "target": "FDXR" }, { "id": "TP53-controls-expression-of-HTT", "source": "TP53", "target": "HTT" }, { "id": "TP53-controls-expression-of-CTSD", "source": "TP53", "target": "CTSD" }, { "id": "SIRT1-controls-state-change-of-TP53", "source": "SIRT1", "target": "TP53" }, { "id": "TP53-controls-expression-of-CASP10", "source": "TP53", "target": "CASP10" }, { "id": "TP53-controls-expression-of-APAF1", "source": "TP53", "target": "APAF1" }, { "id": "TP53-controls-expression-of-HGF", "source": "TP53", "target": "HGF" }, { "id": "PRMT5-controls-state-change-of-TP53", "source": "PRMT5", "target": "TP53" }, { "id": "TP53-controls-expression-of-APC", "source": "TP53", "target": "APC" }, { "id": "TP53-controls-expression-of-BTG2", "source": "TP53", "target": "BTG2" }, { "id": "PRMT1-controls-expression-of-GADD45A", "source": "PRMT1", "target": "GADD45A" }, { "id": "TP53-controls-state-change-of-ASF1A", "source": "TP53", "target": "ASF1A" }, { "id": "TP53-controls-expression-of-FAS", "source": "TP53", "target": "FAS" }, { "id": "HTT-controls-state-change-of-TP73", "source": "HTT", "target": "TP73" }, { "id": "PRKAG1-controls-state-change-of-TP53", "source": "PRKAG1", "target": "TP53" }, { "id": "TP53-controls-expression-of-TRIAP1", "source": "TP53", "target": "TRIAP1" }, { "id": "TP53-controls-expression-of-RFWD2", "source": "TP53", "target": "RFWD2" }, { "id": "EP300-controls-state-change-of-TP63", "source": "EP300", "target": "TP63" }, { "id": "TP53-controls-expression-of-COL18A1", "source": "TP53", "target": "COL18A1" }, { "id": "EP300-controls-expression-of-GADD45A", "source": "EP300", "target": "GADD45A" }, { "id": "PRKAB2-controls-state-change-of-TP73", "source": "PRKAB2", "target": "TP73" }, { "id": "EP300-controls-state-change-of-TP53", "source": "EP300", "target": "TP53" }, { "id": "PRKAB1-controls-state-change-of-TP73", "source": "PRKAB1", "target": "TP73" }, { "id": "TP53-controls-state-change-of-HIST1H1B", "source": "TP53", "target": "HIST1H1B" }, { "id": "EP300-controls-state-change-of-TP73", "source": "EP300", "target": "TP73" }, { "id": "TP53-controls-state-change-of-HIST1H1C", "source": "TP53", "target": "HIST1H1C" }, { "id": "TP53-controls-state-change-of-HIST1H1A", "source": "TP53", "target": "HIST1H1A" }, { "id": "CARM1-controls-expression-of-GADD45A", "source": "CARM1", "target": "GADD45A" }, { "id": "TP53-controls-state-change-of-HIST1H1D", "source": "TP53", "target": "HIST1H1D" }, { "id": "TP53-controls-state-change-of-HIST1H1E", "source": "TP53", "target": "HIST1H1E" }, { "id": "WRN-controls-state-change-of-TP53", "source": "WRN", "target": "TP53" }, { "id": "TP53-controls-expression-of-MSH2", "source": "TP53", "target": "MSH2" }, { "id": "KAT2B-controls-state-change-of-TP53", "source": "KAT2B", "target": "TP53" }, { "id": "TP53-controls-expression-of-PYCARD", "source": "TP53", "target": "PYCARD" }, { "id": "PRKAA2-controls-state-change-of-TP53", "source": "PRKAA2", "target": "TP53" }, { "id": "SP1-controls-expression-of-CCNB1", "source": "SP1", "target": "CCNB1" }, { "id": "PRKAA1-controls-state-change-of-TP53", "source": "PRKAA1", "target": "TP53" }, { "id": "TP53-controls-expression-of-GPX1", "source": "TP53", "target": "GPX1" }, { "id": "TP53-controls-state-change-of-CABIN1", "source": "TP53", "target": "CABIN1" }, { "id": "MDM2-controls-state-change-of-NEDD8", "source": "MDM2", "target": "NEDD8" }, { "id": "PLK3-controls-state-change-of-TP53", "source": "PLK3", "target": "TP53" }, { "id": "PRKAB1-controls-state-change-of-TP53", "source": "PRKAB1", "target": "TP53" }, { "id": "SUMO1-controls-state-change-of-TP53", "source": "SUMO1", "target": "TP53" }, { "id": "PRKAA1-controls-state-change-of-TP63", "source": "PRKAA1", "target": "TP63" }, { "id": "PPP1R13L-controls-state-change-of-TP53", "source": "PPP1R13L", "target": "TP53" }, { "id": "TP53-controls-expression-of-PLK3", "source": "TP53", "target": "PLK3" }, { "id": "PRKAA2-controls-state-change-of-TP63", "source": "PRKAA2", "target": "TP63" }, { "id": "PRKAB2-controls-state-change-of-TP53", "source": "PRKAB2", "target": "TP53" }, { "id": "DAPK1-controls-state-change-of-TP53", "source": "DAPK1", "target": "TP53" }, { "id": "CDK1-controls-state-change-of-TP53", "source": "CDK1", "target": "TP53" }, { "id": "DAPK3-controls-state-change-of-TP53", "source": "DAPK3", "target": "TP53" }, { "id": "PRKAB1-controls-state-change-of-TP63", "source": "PRKAB1", "target": "TP63" }, { "id": "SUMO1-controls-state-change-of-TP63", "source": "SUMO1", "target": "TP63" }, { "id": "PRKAA1-controls-state-change-of-TP73", "source": "PRKAA1", "target": "TP73" }, { "id": "E4F1-controls-state-change-of-TP53", "source": "E4F1", "target": "TP53" }, { "id": "PRKAA2-controls-state-change-of-TP73", "source": "PRKAA2", "target": "TP73" }, { "id": "PRKAB2-controls-state-change-of-TP63", "source": "PRKAB2", "target": "TP63" } ] } ================================================ FILE: packages/g6/__tests__/dataset/language-tree.json ================================================ { "nodes": [ { "id": "Proto Indo-European", "children": [ "Balto-Slavic", "Germanic", "Celtic", "Italic", "Hellenic", "Anatolian", "Indo-Iranian", "Tocharian", "Phrygian", "Armenian", "Albanian", "Thracian" ] }, { "id": "Balto-Slavic", "children": ["Baltic", "Slavic"] }, { "id": "Baltic", "children": ["Old Prussian", "Lithuanian", "Latvian"] }, { "id": "Old Prussian" }, { "id": "Lithuanian" }, { "id": "Latvian" }, { "id": "Slavic", "children": ["East Slavic", "West Slavic", "South Slavic"] }, { "id": "East Slavic", "children": ["Bulgarian", "Old Church Slavonic", "Macedonian", "Serbo-Croatian", "Slovene"] }, { "id": "Bulgarian" }, { "id": "Old Church Slavonic" }, { "id": "Macedonian" }, { "id": "Serbo-Croatian" }, { "id": "Slovene" }, { "id": "West Slavic", "children": ["Polish", "Slovak", "Czech", "Wendish"] }, { "id": "Polish" }, { "id": "Slovak" }, { "id": "Czech" }, { "id": "Wendish" }, { "id": "South Slavic", "children": ["Russian", "Ukrainian", "Belarusian", "Rusyn"] }, { "id": "Russian" }, { "id": "Ukrainian" }, { "id": "Belarusian" }, { "id": "Rusyn" }, { "id": "Germanic", "children": ["North Germanic", "West Germanic", "East Germanic"] }, { "id": "North Germanic", "children": ["Old Norse", "Old Swedish", "Old Danish"] }, { "id": "Old Norse", "children": ["Old Icelandic", "Old Norwegian", "Faroese"] }, { "id": "Old Icelandic", "children": ["Icelandic"] }, { "id": "Icelandic" }, { "id": "Old Norwegian", "children": ["Middle Norwegian"] }, { "id": "Middle Norwegian", "children": ["Norwegian"] }, { "id": "Norwegian" }, { "id": "Faroese" }, { "id": "Old Swedish", "children": ["Middle Swedish"] }, { "id": "Middle Swedish", "children": ["Swedish"] }, { "id": "Swedish" }, { "id": "Old Danish", "children": ["Middle Danish"] }, { "id": "Middle Danish", "children": ["Danish"] }, { "id": "Danish" }, { "id": "West Germanic", "children": ["Old English", "Old Frisian", "Old Dutch", "Old Low German", "Old High German"] }, { "id": "Old English", "children": ["Middle English"] }, { "id": "Middle English", "children": ["English"] }, { "id": "English" }, { "id": "Old Frisian", "children": ["Frisian"] }, { "id": "Frisian" }, { "id": "Old Dutch", "children": ["Middle Dutch"] }, { "id": "Middle Dutch", "children": ["Hollandic", "Flemish", "Dutch", "Limburgish", "Brabantian", "Rhinelandic"] }, { "id": "Hollandic" }, { "id": "Flemish" }, { "id": "Dutch" }, { "id": "Limburgish" }, { "id": "Brabantian" }, { "id": "Rhinelandic" }, { "id": "Old Low German", "children": ["Middle Low German"] }, { "id": "Middle Low German", "children": ["Low German"] }, { "id": "Low German" }, { "id": "Old High German", "children": ["Middle High German"] }, { "id": "Middle High German", "children": ["(High) German", "Yiddish"] }, { "id": "(High) German" }, { "id": "Yiddish" }, { "id": "East Germanic", "children": ["Gothic"] }, { "id": "Gothic" }, { "id": "Celtic", "children": ["Brythonic", "Goidelic"] }, { "id": "Brythonic", "children": ["Welsh", "Breton", "Cornish", "Cuymbric"] }, { "id": "Welsh" }, { "id": "Breton" }, { "id": "Cornish" }, { "id": "Cuymbric" }, { "id": "Goidelic", "children": ["Modern Irish", "Scottish Gaelic", "Manx"] }, { "id": "Modern Irish" }, { "id": "Scottish Gaelic" }, { "id": "Manx" }, { "id": "Italic", "children": ["Osco-Umbrian", "Latino-Faliscan"] }, { "id": "Osco-Umbrian", "children": ["Umbrian", "Oscan"] }, { "id": "Umbrian" }, { "id": "Oscan" }, { "id": "Latino-Faliscan", "children": ["Latin", "Faliscan"] }, { "id": "Latin", "children": [ "Portugese", "Spanish", "French", "Romanian", "Italian", "Catalan", "Franco-Provençal", "Rhaeto-Romance" ] }, { "id": "Portugese" }, { "id": "Spanish" }, { "id": "French" }, { "id": "Romanian" }, { "id": "Italian" }, { "id": "Catalan" }, { "id": "Franco-Provençal" }, { "id": "Rhaeto-Romance" }, { "id": "Faliscan" }, { "id": "Hellenic", "children": ["Greek"] }, { "id": "Greek" }, { "id": "Anatolian", "children": ["Hittite", "Palaic", "Luwic", "Lydian"] }, { "id": "Hittite" }, { "id": "Palaic" }, { "id": "Luwic" }, { "id": "Lydian" }, { "id": "Indo-Iranian", "children": ["Dardic", "Indic", "Iranian"] }, { "id": "Dardic", "children": ["Dard"] }, { "id": "Dard" }, { "id": "Indic", "children": ["Sanskrit"] }, { "id": "Sanskrit", "children": [ "Sindhi", "Romani", "Urdu", "Hindi", "Bihari", "Assamese", "Bengali", "Marathi", "Gujarati", "Punjabi", "Sinhalese" ] }, { "id": "Sindhi" }, { "id": "Romani" }, { "id": "Urdu" }, { "id": "Hindi" }, { "id": "Bihari" }, { "id": "Assamese" }, { "id": "Bengali" }, { "id": "Marathi" }, { "id": "Gujarati" }, { "id": "Punjabi" }, { "id": "Sinhalese" }, { "id": "Iranian", "children": ["Old Persian", "Balochi", "Kurdish", "Pashto", "Sogdian"] }, { "id": "Old Persian", "children": ["Middle Persian", "Pahlavi"] }, { "id": "Middle Persian", "children": ["Persian"] }, { "id": "Persian" }, { "id": "Pahlavi" }, { "id": "Balochi" }, { "id": "Kurdish" }, { "id": "Pashto" }, { "id": "Sogdian" }, { "id": "Tocharian", "children": ["Tocharian A", "Tocharian B"] }, { "id": "Tocharian A" }, { "id": "Tocharian B" }, { "id": "Phrygian" }, { "id": "Armenian" }, { "id": "Albanian" }, { "id": "Thracian" } ], "edges": [ { "source": "Proto Indo-European", "target": "Balto-Slavic" }, { "source": "Proto Indo-European", "target": "Germanic" }, { "source": "Proto Indo-European", "target": "Celtic" }, { "source": "Proto Indo-European", "target": "Italic" }, { "source": "Proto Indo-European", "target": "Hellenic" }, { "source": "Proto Indo-European", "target": "Anatolian" }, { "source": "Proto Indo-European", "target": "Indo-Iranian" }, { "source": "Proto Indo-European", "target": "Tocharian" }, { "source": "Proto Indo-European", "target": "Phrygian" }, { "source": "Proto Indo-European", "target": "Armenian" }, { "source": "Proto Indo-European", "target": "Albanian" }, { "source": "Proto Indo-European", "target": "Thracian" }, { "source": "Balto-Slavic", "target": "Baltic" }, { "source": "Balto-Slavic", "target": "Slavic" }, { "source": "Baltic", "target": "Old Prussian" }, { "source": "Baltic", "target": "Lithuanian" }, { "source": "Baltic", "target": "Latvian" }, { "source": "Slavic", "target": "East Slavic" }, { "source": "Slavic", "target": "West Slavic" }, { "source": "Slavic", "target": "South Slavic" }, { "source": "East Slavic", "target": "Bulgarian" }, { "source": "East Slavic", "target": "Old Church Slavonic" }, { "source": "East Slavic", "target": "Macedonian" }, { "source": "East Slavic", "target": "Serbo-Croatian" }, { "source": "East Slavic", "target": "Slovene" }, { "source": "West Slavic", "target": "Polish" }, { "source": "West Slavic", "target": "Slovak" }, { "source": "West Slavic", "target": "Czech" }, { "source": "West Slavic", "target": "Wendish" }, { "source": "South Slavic", "target": "Russian" }, { "source": "South Slavic", "target": "Ukrainian" }, { "source": "South Slavic", "target": "Belarusian" }, { "source": "South Slavic", "target": "Rusyn" }, { "source": "Germanic", "target": "North Germanic" }, { "source": "Germanic", "target": "West Germanic" }, { "source": "Germanic", "target": "East Germanic" }, { "source": "North Germanic", "target": "Old Norse" }, { "source": "North Germanic", "target": "Old Swedish" }, { "source": "North Germanic", "target": "Old Danish" }, { "source": "Old Norse", "target": "Old Icelandic" }, { "source": "Old Norse", "target": "Old Norwegian" }, { "source": "Old Norse", "target": "Faroese" }, { "source": "Old Icelandic", "target": "Icelandic" }, { "source": "Old Norwegian", "target": "Middle Norwegian" }, { "source": "Middle Norwegian", "target": "Norwegian" }, { "source": "Old Swedish", "target": "Middle Swedish" }, { "source": "Middle Swedish", "target": "Swedish" }, { "source": "Old Danish", "target": "Middle Danish" }, { "source": "Middle Danish", "target": "Danish" }, { "source": "West Germanic", "target": "Old English" }, { "source": "West Germanic", "target": "Old Frisian" }, { "source": "West Germanic", "target": "Old Dutch" }, { "source": "West Germanic", "target": "Old Low German" }, { "source": "West Germanic", "target": "Old High German" }, { "source": "Old English", "target": "Middle English" }, { "source": "Middle English", "target": "English" }, { "source": "Old Frisian", "target": "Frisian" }, { "source": "Old Dutch", "target": "Middle Dutch" }, { "source": "Middle Dutch", "target": "Hollandic" }, { "source": "Middle Dutch", "target": "Flemish" }, { "source": "Middle Dutch", "target": "Dutch" }, { "source": "Middle Dutch", "target": "Limburgish" }, { "source": "Middle Dutch", "target": "Brabantian" }, { "source": "Middle Dutch", "target": "Rhinelandic" }, { "source": "Old Low German", "target": "Middle Low German" }, { "source": "Middle Low German", "target": "Low German" }, { "source": "Old High German", "target": "Middle High German" }, { "source": "Middle High German", "target": "(High) German" }, { "source": "Middle High German", "target": "Yiddish" }, { "source": "East Germanic", "target": "Gothic" }, { "source": "Celtic", "target": "Brythonic" }, { "source": "Celtic", "target": "Goidelic" }, { "source": "Brythonic", "target": "Welsh" }, { "source": "Brythonic", "target": "Breton" }, { "source": "Brythonic", "target": "Cornish" }, { "source": "Brythonic", "target": "Cuymbric" }, { "source": "Goidelic", "target": "Modern Irish" }, { "source": "Goidelic", "target": "Scottish Gaelic" }, { "source": "Goidelic", "target": "Manx" }, { "source": "Italic", "target": "Osco-Umbrian" }, { "source": "Italic", "target": "Latino-Faliscan" }, { "source": "Osco-Umbrian", "target": "Umbrian" }, { "source": "Osco-Umbrian", "target": "Oscan" }, { "source": "Latino-Faliscan", "target": "Latin" }, { "source": "Latino-Faliscan", "target": "Faliscan" }, { "source": "Latin", "target": "Portugese" }, { "source": "Latin", "target": "Spanish" }, { "source": "Latin", "target": "French" }, { "source": "Latin", "target": "Romanian" }, { "source": "Latin", "target": "Italian" }, { "source": "Latin", "target": "Catalan" }, { "source": "Latin", "target": "Franco-Provençal" }, { "source": "Latin", "target": "Rhaeto-Romance" }, { "source": "Hellenic", "target": "Greek" }, { "source": "Anatolian", "target": "Hittite" }, { "source": "Anatolian", "target": "Palaic" }, { "source": "Anatolian", "target": "Luwic" }, { "source": "Anatolian", "target": "Lydian" }, { "source": "Indo-Iranian", "target": "Dardic" }, { "source": "Indo-Iranian", "target": "Indic" }, { "source": "Indo-Iranian", "target": "Iranian" }, { "source": "Dardic", "target": "Dard" }, { "source": "Indic", "target": "Sanskrit" }, { "source": "Sanskrit", "target": "Sindhi" }, { "source": "Sanskrit", "target": "Romani" }, { "source": "Sanskrit", "target": "Urdu" }, { "source": "Sanskrit", "target": "Hindi" }, { "source": "Sanskrit", "target": "Bihari" }, { "source": "Sanskrit", "target": "Assamese" }, { "source": "Sanskrit", "target": "Bengali" }, { "source": "Sanskrit", "target": "Marathi" }, { "source": "Sanskrit", "target": "Gujarati" }, { "source": "Sanskrit", "target": "Punjabi" }, { "source": "Sanskrit", "target": "Sinhalese" }, { "source": "Iranian", "target": "Old Persian" }, { "source": "Iranian", "target": "Balochi" }, { "source": "Iranian", "target": "Kurdish" }, { "source": "Iranian", "target": "Pashto" }, { "source": "Iranian", "target": "Sogdian" }, { "source": "Old Persian", "target": "Middle Persian" }, { "source": "Old Persian", "target": "Pahlavi" }, { "source": "Middle Persian", "target": "Persian" }, { "source": "Tocharian", "target": "Tocharian A" }, { "source": "Tocharian", "target": "Tocharian B" } ] } ================================================ FILE: packages/g6/__tests__/dataset/organization-chart.json ================================================ { "nodes": [ { "id": "0", "data": { "email": "ejoplin@yoyodyne.com", "fax": "555-0101", "name": "Eric Joplin", "phone": "555-0100", "position": "Chief Executive Officer", "status": "online" } }, { "id": "1", "data": { "email": "groberts@yoyodyne.com", "fax": "555-0101", "name": "Gary Roberts", "phone": "555-0100", "position": "Chief Executive Assistant", "status": "busy" } }, { "id": "2", "data": { "email": "aburns@yoyodyne.com", "fax": "555-0103", "name": "Alex Burns", "phone": "555-0102", "position": "Senior Executive Assistant", "status": "offline" } }, { "id": "4", "data": { "email": "msmith@yoyodyne.com", "fax": "555-0115", "name": "Mary Smith", "phone": "555-0114", "position": "Finance Manager", "status": "busy" } }, { "id": "5", "data": { "email": "bwhite@yoyodyne.com", "fax": "555-0117", "name": "Bob White", "phone": "555-0116", "position": "HR Manager", "status": "online" } }, { "id": "6", "data": { "email": "jjones@yoyodyne.com", "fax": "555-0119", "name": "John Jones", "phone": "555-0118", "position": "IT Manager", "status": "offline" } }, { "id": "7", "data": { "email": "klee@yoyodyne.com", "fax": "555-0121", "name": "Karen Lee", "phone": "555-0120", "position": "Marketing Manager", "status": "online" } }, { "id": "8", "data": { "email": "dmiller@yoyodyne.com", "fax": "555-0123", "name": "David Miller", "phone": "555-0122", "position": "Sales Manager", "status": "busy" } }, { "id": "9", "data": { "email": "rjoe@yoyodyne.com", "fax": "555-0125", "name": "Rachel Joe", "phone": "555-0124", "position": "Operations Manager", "status": "offline" } }, { "id": "10", "data": { "email": "tadams@yoyodyne.com", "fax": "555-0127", "name": "Tom Adams", "phone": "555-0126", "position": "Product Manager", "status": "online" } }, { "id": "11", "data": { "email": "wbrown@yoyodyne.com", "fax": "555-0129", "name": "Will Brown", "phone": "555-0128", "position": "Customer Support Manager", "status": "busy" } }, { "id": "12", "data": { "email": "dmartin@yoyodyne.com", "fax": "555-0131", "name": "Diana Martin", "phone": "555-0130", "position": "Compliance Officer", "status": "offline" } }, { "id": "13", "data": { "email": "jwilson@yoyodyne.com", "fax": "555-0133", "name": "Jim Wilson", "phone": "555-0132", "position": "Legal Counsel", "status": "online" } }, { "id": "14", "data": { "email": "charris@yoyodyne.com", "fax": "555-0135", "name": "Cathy Harris", "phone": "555-0134", "position": "Procurement Manager", "status": "busy" } }, { "id": "15", "data": { "email": "eblack@yoyodyne.com", "fax": "555-0137", "name": "Evan Black", "phone": "555-0136", "position": "Logistics Manager", "status": "offline" } }, { "id": "16", "data": { "email": "ywang@yoyodyne.com", "fax": "555-0139", "name": "Yvonne Wang", "phone": "555-0138", "position": "Research and Development Manager", "status": "online" } }, { "id": "17", "data": { "email": "jsanchez@yoyodyne.com", "fax": "555-0141", "name": "Juan Sanchez", "phone": "555-0140", "position": "Chief Technology Officer", "status": "busy" } }, { "id": "18", "data": { "email": "mjones@yoyodyne.com", "fax": "555-0143", "name": "Molly Jones", "phone": "555-0142", "position": "Chief Financial Officer", "status": "offline" } }, { "id": "19", "data": { "email": "rking@yoyodyne.com", "fax": "555-0145", "name": "Richard King", "phone": "555-0144", "position": "Chief Operating Officer", "status": "online" } } ], "edges": [ { "source": "0", "target": "1" }, { "source": "1", "target": "2" }, { "source": "0", "target": "17" }, { "source": "0", "target": "18" }, { "source": "0", "target": "19" }, { "source": "17", "target": "6" }, { "source": "17", "target": "16" }, { "source": "18", "target": "4" }, { "source": "18", "target": "12" }, { "source": "19", "target": "9" }, { "source": "19", "target": "15" }, { "source": "4", "target": "5" }, { "source": "4", "target": "10" }, { "source": "5", "target": "8" }, { "source": "6", "target": "11" }, { "source": "9", "target": "7" }, { "source": "15", "target": "13" }, { "source": "15", "target": "14" } ] } ================================================ FILE: packages/g6/__tests__/dataset/parallel-edges.json ================================================ { "nodes": [ { "id": "node1", "style": { "x": 260, "y": 220 } }, { "id": "node2", "style": { "x": 186, "y": 342 } }, { "id": "node3", "style": { "x": 131, "y": 194 } }, { "id": "node4", "style": { "x": 258, "y": 80 } }, { "id": "node5", "style": { "x": 395, "y": 186 } }, { "id": "node6", "style": { "x": 333, "y": 337 } } ], "edges": [ { "id": "edge1", "source": "node1", "target": "node4", "style": { "stroke": "#8576FF", "lineWidth": 2, "startArrow": true } }, { "id": "edge2", "source": "node4", "target": "node1", "style": { "endArrow": true } }, { "id": "edge3", "source": "node4", "target": "node1" }, { "id": "edge4", "source": "node1", "target": "node4" }, { "id": "edge5", "source": "node1", "target": "node2" }, { "id": "edge6", "source": "node1", "target": "node2" }, { "id": "edge7", "source": "node1", "target": "node3" }, { "id": "edge8", "source": "node1", "target": "node5" }, { "id": "edge9", "source": "node1", "target": "node6" }, { "id": "edge10", "source": "node1", "target": "node6" }, { "id": "edge11", "source": "node1", "target": "node6" }, { "id": "loop1", "source": "node5", "target": "node5", "style": { "stroke": "#8576FF", "lineWidth": 2 } }, { "id": "loop2", "source": "node5", "target": "node5" }, { "id": "loop3", "source": "node5", "target": "node5" }, { "id": "loop4", "source": "node5", "target": "node5" }, { "id": "loop5", "source": "node5", "target": "node5" }, { "id": "loop6", "source": "node5", "target": "node5" }, { "id": "loop7", "source": "node5", "target": "node5" }, { "id": "loop8", "source": "node5", "target": "node5" }, { "id": "loop9", "source": "node5", "target": "node5" }, { "id": "loop10", "source": "node5", "target": "node5" }, { "id": "loop11", "source": "node5", "target": "node5" }, { "id": "loop12", "source": "node5", "target": "node5" }, { "id": "loop13", "source": "node5", "target": "node5" }, { "id": "loop14", "source": "node5", "target": "node5" }, { "id": "loop15", "source": "node5", "target": "node5" }, { "id": "loop16", "source": "node5", "target": "node5" }, { "id": "loop17", "source": "node5", "target": "node5" } ] } ================================================ FILE: packages/g6/__tests__/dataset/radial.json ================================================ { "nodes": [ { "id": "0", "data": { "label": "0" } }, { "id": "1", "data": { "label": "1" } }, { "id": "2", "data": { "label": "2" } }, { "id": "3", "data": { "label": "3" } }, { "id": "4", "data": { "label": "4" } }, { "id": "5", "data": { "label": "5" } }, { "id": "6", "data": { "label": "6" } }, { "id": "7", "data": { "label": "7" } }, { "id": "8", "data": { "label": "8" } }, { "id": "9", "data": { "label": "9" } }, { "id": "10", "data": { "label": "10" } }, { "id": "11", "data": { "label": "11" } }, { "id": "12", "data": { "label": "12" } }, { "id": "13", "data": { "label": "13" } }, { "id": "14", "data": { "label": "14" } }, { "id": "15", "data": { "label": "15" } }, { "id": "16", "data": { "label": "16" } }, { "id": "17", "data": { "label": "17" } }, { "id": "18", "data": { "label": "18" } }, { "id": "19", "data": { "label": "19" } }, { "id": "20", "data": { "label": "20" } }, { "id": "21", "data": { "label": "21" } }, { "id": "22", "data": { "label": "22" } }, { "id": "23", "data": { "label": "23" } }, { "id": "24", "data": { "label": "24" } }, { "id": "25", "data": { "label": "25" } }, { "id": "26", "data": { "label": "26" } }, { "id": "27", "data": { "label": "27" } }, { "id": "28", "data": { "label": "28" } }, { "id": "29", "data": { "label": "29" } }, { "id": "30", "data": { "label": "30" } }, { "id": "31", "data": { "label": "31" } }, { "id": "32", "data": { "label": "32" } }, { "id": "33", "data": { "label": "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": "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": "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" } ] } ================================================ FILE: packages/g6/__tests__/dataset/relations.json ================================================ { "nodes": [ { "id": "Myriel", "style": { "x": 197.13154409979438, "y": 58.49567372045294 } }, { "id": "Napoleon", "style": { "x": 147.01896389692396, "y": 22.47017586685877 } }, { "id": "Mlle.Baptistine", "style": { "x": 225.53929622396657, "y": 141.52203994343503 } }, { "id": "Mme.Magloire", "style": { "x": 255.07906424356426, "y": 120.2538776202175 } }, { "id": "CountessdeLo", "style": { "x": 151.886941377147, "y": -3.5440526274605024 } }, { "id": "Geborand", "style": { "x": 136.99780912786676, "y": 41.74972346367764 } }, { "id": "Champtercier", "style": { "x": 227.06448529213904, "y": 8.803245731763797 } }, { "id": "Cravatte", "style": { "x": 172.28712104569624, "y": -10.28659385020346 } }, { "id": "Count", "style": { "x": 172.9776128536988, "y": 12.515280485950003 } }, { "id": "OldMan", "style": { "x": 198.7549153659034, "y": -6.143466139379697 } }, { "id": "Labarre", "style": { "x": 266.5746386228216, "y": 203.98384539788222 } }, { "id": "Valjean", "style": { "x": 322.22242753596396, "y": 221.58991461580462 } }, { "id": "Marguerite", "style": { "x": 265.1218339265034, "y": 171.59761511302105 } }, { "id": "Mme.deR", "style": { "x": 299.78639359854327, "y": 133.57398015667923 } }, { "id": "Isabeau", "style": { "x": 282.69786358028415, "y": 191.50678051232913 } }, { "id": "Gervais", "style": { "x": 334.4562033716733, "y": 148.86340203151713 } }, { "id": "Tholomyes", "style": { "x": 359.6758601570104, "y": 158.51932058679517 } }, { "id": "Listolier", "style": { "x": 308.6408107258377, "y": 80.08978211784734 } }, { "id": "Fameuil", "style": { "x": 329.1208783621155, "y": 89.50783923513406 } }, { "id": "Blacheville", "style": { "x": 351.31710942912247, "y": 95.62381874446997 } }, { "id": "Favourite", "style": { "x": 284.0990966456606, "y": 153.6649901350214 } }, { "id": "Dahlia", "style": { "x": 303.2794454950651, "y": 170.87469919068386 } }, { "id": "Zephine", "style": { "x": 286.9038607953858, "y": 94.82364610010669 } }, { "id": "Fantine", "style": { "x": 337.7295856292113, "y": 187.2760733153313 } }, { "id": "Mme.Thenardier", "style": { "x": 283.8431887426204, "y": 267.7101161193055 } }, { "id": "Thenardier", "style": { "x": 317.6539018281542, "y": 300.0586304481375 } }, { "id": "Cosette", "style": { "x": 343.4495217104461, "y": 248.14013534143953 } }, { "id": "Javert", "style": { "x": 368.6281356589531, "y": 263.5847126845181 } }, { "id": "Fauchelevent", "style": { "x": 377.3520676841103, "y": 176.72534157485532 } }, { "id": "Bamatabois", "style": { "x": 391.75313851634024, "y": 156.5212161097912 } }, { "id": "Perpetue", "style": { "x": 234.8199749437348, "y": 195.99976079362335 } }, { "id": "Simplice", "style": { "x": 286.4937544345336, "y": 227.73420851527578 } }, { "id": "Scaufflaire", "style": { "x": 250.02919011143416, "y": 231.2513211913802 } }, { "id": "Woman1", "style": { "x": 375.4668487891018, "y": 202.783515421686 } }, { "id": "Judge", "style": { "x": 370.1700307319093, "y": 139.4810861650384 } }, { "id": "Champmathieu", "style": { "x": 404.6422482933774, "y": 216.58364918349568 } }, { "id": "Brevet", "style": { "x": 399.2513775912632, "y": 183.03026453336724 } }, { "id": "Chenildieu", "style": { "x": 425.90996667472837, "y": 194.79658513642403 } }, { "id": "Cochepaille", "style": { "x": 419.38361105364334, "y": 148.69180823448008 } }, { "id": "Pontmercy", "style": { "x": 375.2946100421193, "y": 307.66682817782345 } }, { "id": "Boulatruelle", "style": { "x": 260.66757416917164, "y": 279.0949406815367 } }, { "id": "Eponine", "style": { "x": 268.68796660221636, "y": 365.8200533034293 } }, { "id": "Anzelma", "style": { "x": 234.53762633403787, "y": 303.08504254821366 } }, { "id": "Woman2", "style": { "x": 304.29126463264043, "y": 254.05392981470945 } }, { "id": "MotherInnocent", "style": { "x": 350.35613429759803, "y": 214.42252912270644 } }, { "id": "Gribier", "style": { "x": 437.51920169330805, "y": 160.14388411785757 } }, { "id": "Jondrette", "style": { "x": 510.1406569699257, "y": 327.7456828911454 } }, { "id": "Mme.Burgon", "style": { "x": 466.0856874797108, "y": 368.0210264990602 } }, { "id": "Gavroche", "style": { "x": 393.6973181801981, "y": 380.40382743216634 } }, { "id": "Gillenormand", "style": { "x": 338.1148595335302, "y": 286.4434006942807 } }, { "id": "Magnon", "style": { "x": 277.12320020410266, "y": 317.4384382481713 } }, { "id": "Mlle.Gillenormand", "style": { "x": 257.52167498720337, "y": 306.4604520400414 } }, { "id": "Mme.Pontmercy", "style": { "x": 307.71325168392366, "y": 318.0074114921048 } }, { "id": "Mlle.Vaubois", "style": { "x": 197.63137784390082, "y": 325.2999365859076 } }, { "id": "Lt.Gillenormand", "style": { "x": 294.4105849543593, "y": 296.53686533697186 } }, { "id": "Marius", "style": { "x": 336.3436812430268, "y": 350.8376519695578 } }, { "id": "BaronessT", "style": { "x": 390.6807729530675, "y": 322.9175698803163 } }, { "id": "Mabeuf", "style": { "x": 366.77554563642803, "y": 445.26666512175433 } }, { "id": "Enjolras", "style": { "x": 376.9421415192702, "y": 371.1750781444891 } }, { "id": "Combeferre", "style": { "x": 397.0516872015465, "y": 416.38478793328625 } }, { "id": "Prouvaire", "style": { "x": 309.0241345496318, "y": 426.44215271462605 } }, { "id": "Feuilly", "style": { "x": 314.71137563489117, "y": 456.80172690673896 } }, { "id": "Courfeyrac", "style": { "x": 332.8405296045364, "y": 435.8881866127797 } }, { "id": "Bahorel", "style": { "x": 343.1268360879219, "y": 466.9404473411801 } }, { "id": "Bossuet", "style": { "x": 305.84814130923144, "y": 382.89355947309724 } }, { "id": "Joly", "style": { "x": 371.447442010866, "y": 415.99688422022257 } }, { "id": "Grantaire", "style": { "x": 370.72651876919826, "y": 466.96671298340794 } }, { "id": "MotherPlutarch", "style": { "x": 424.04457501182867, "y": 461.9373924104361 } }, { "id": "Gueulemer", "style": { "x": 344.1315821958891, "y": 323.7890765583486 } }, { "id": "Babet", "style": { "x": 367.3969014122835, "y": 319.2359576043117 } }, { "id": "Claquesous", "style": { "x": 303.23885194199465, "y": 347.8041412708572 } }, { "id": "Montparnasse", "style": { "x": 322.6528688110919, "y": 330.01757397802925 } }, { "id": "Toussaint", "style": { "x": 306.6921797724685, "y": 277.05255454452566 } }, { "id": "Child1", "style": { "x": 361.1652068827243, "y": 387.9769951347244 } }, { "id": "Child2", "style": { "x": 415.98942162128606, "y": 432.37341762016945 } }, { "id": "Brujon", "style": { "x": 330.44198511493056, "y": 394.6025799878689 } }, { "id": "Mme.Hucheloup", "style": { "x": 394.43875881505835, "y": 450.4056149101193 } } ], "edges": [ { "id": "0", "source": "Napoleon", "target": "Myriel", "data": { "value": 1 } }, { "id": "1", "source": "Mlle.Baptistine", "target": "Myriel", "data": { "value": 8 } }, { "id": "2", "source": "Mme.Magloire", "target": "Myriel", "data": { "value": 10 } }, { "id": "3", "source": "Mme.Magloire", "target": "Mlle.Baptistine", "data": { "value": 6 } }, { "id": "4", "source": "CountessdeLo", "target": "Myriel", "data": { "value": 1 } }, { "id": "5", "source": "Geborand", "target": "Myriel", "data": { "value": 1 } }, { "id": "6", "source": "Champtercier", "target": "Myriel", "data": { "value": 1 } }, { "id": "7", "source": "Cravatte", "target": "Myriel", "data": { "value": 1 } }, { "id": "8", "source": "Count", "target": "Myriel", "data": { "value": 2 } }, { "id": "9", "source": "OldMan", "target": "Myriel", "data": { "value": 1 } }, { "id": "10", "source": "Valjean", "target": "Labarre", "data": { "value": 1 } }, { "id": "11", "source": "Valjean", "target": "Mme.Magloire", "data": { "value": 3 } }, { "id": "12", "source": "Valjean", "target": "Mlle.Baptistine", "data": { "value": 3 } }, { "id": "13", "source": "Valjean", "target": "Myriel", "data": { "value": 5 } }, { "id": "14", "source": "Marguerite", "target": "Valjean", "data": { "value": 1 } }, { "id": "15", "source": "Mme.deR", "target": "Valjean", "data": { "value": 1 } }, { "id": "16", "source": "Isabeau", "target": "Valjean", "data": { "value": 1 } }, { "id": "17", "source": "Gervais", "target": "Valjean", "data": { "value": 1 } }, { "id": "18", "source": "Listolier", "target": "Tholomyes", "data": { "value": 4 } }, { "id": "19", "source": "Fameuil", "target": "Tholomyes", "data": { "value": 4 } }, { "id": "20", "source": "Fameuil", "target": "Listolier", "data": { "value": 4 } }, { "id": "21", "source": "Blacheville", "target": "Tholomyes", "data": { "value": 4 } }, { "id": "22", "source": "Blacheville", "target": "Listolier", "data": { "value": 4 } }, { "id": "23", "source": "Blacheville", "target": "Fameuil", "data": { "value": 4 } }, { "id": "24", "source": "Favourite", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "25", "source": "Favourite", "target": "Listolier", "data": { "value": 3 } }, { "id": "26", "source": "Favourite", "target": "Fameuil", "data": { "value": 3 } }, { "id": "27", "source": "Favourite", "target": "Blacheville", "data": { "value": 4 } }, { "id": "28", "source": "Dahlia", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "29", "source": "Dahlia", "target": "Listolier", "data": { "value": 3 } }, { "id": "30", "source": "Dahlia", "target": "Fameuil", "data": { "value": 3 } }, { "id": "31", "source": "Dahlia", "target": "Blacheville", "data": { "value": 3 } }, { "id": "32", "source": "Dahlia", "target": "Favourite", "data": { "value": 5 } }, { "id": "33", "source": "Zephine", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "34", "source": "Zephine", "target": "Listolier", "data": { "value": 3 } }, { "id": "35", "source": "Zephine", "target": "Fameuil", "data": { "value": 3 } }, { "id": "36", "source": "Zephine", "target": "Blacheville", "data": { "value": 3 } }, { "id": "37", "source": "Zephine", "target": "Favourite", "data": { "value": 4 } }, { "id": "38", "source": "Zephine", "target": "Dahlia", "data": { "value": 4 } }, { "id": "39", "source": "Fantine", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "40", "source": "Fantine", "target": "Listolier", "data": { "value": 3 } }, { "id": "41", "source": "Fantine", "target": "Fameuil", "data": { "value": 3 } }, { "id": "42", "source": "Fantine", "target": "Blacheville", "data": { "value": 3 } }, { "id": "43", "source": "Fantine", "target": "Favourite", "data": { "value": 4 } }, { "id": "44", "source": "Fantine", "target": "Dahlia", "data": { "value": 4 } }, { "id": "45", "source": "Fantine", "target": "Zephine", "data": { "value": 4 } }, { "id": "46", "source": "Fantine", "target": "Marguerite", "data": { "value": 2 } }, { "id": "47", "source": "Fantine", "target": "Valjean", "data": { "value": 9 } }, { "id": "48", "source": "Mme.Thenardier", "target": "Fantine", "data": { "value": 2 } }, { "id": "49", "source": "Mme.Thenardier", "target": "Valjean", "data": { "value": 7 } }, { "id": "50", "source": "Thenardier", "target": "Mme.Thenardier", "data": { "value": 13 } }, { "id": "51", "source": "Thenardier", "target": "Fantine", "data": { "value": 1 } }, { "id": "52", "source": "Thenardier", "target": "Valjean", "data": { "value": 12 } }, { "id": "53", "source": "Cosette", "target": "Mme.Thenardier", "data": { "value": 4 } }, { "id": "54", "source": "Cosette", "target": "Valjean", "data": { "value": 31 } }, { "id": "55", "source": "Cosette", "target": "Tholomyes", "data": { "value": 1 } }, { "id": "56", "source": "Cosette", "target": "Thenardier", "data": { "value": 1 } }, { "id": "57", "source": "Javert", "target": "Valjean", "data": { "value": 17 } }, { "id": "58", "source": "Javert", "target": "Fantine", "data": { "value": 5 } }, { "id": "59", "source": "Javert", "target": "Thenardier", "data": { "value": 5 } }, { "id": "60", "source": "Javert", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "61", "source": "Javert", "target": "Cosette", "data": { "value": 1 } }, { "id": "62", "source": "Fauchelevent", "target": "Valjean", "data": { "value": 8 } }, { "id": "63", "source": "Fauchelevent", "target": "Javert", "data": { "value": 1 } }, { "id": "64", "source": "Bamatabois", "target": "Fantine", "data": { "value": 1 } }, { "id": "65", "source": "Bamatabois", "target": "Javert", "data": { "value": 1 } }, { "id": "66", "source": "Bamatabois", "target": "Valjean", "data": { "value": 2 } }, { "id": "67", "source": "Perpetue", "target": "Fantine", "data": { "value": 1 } }, { "id": "68", "source": "Simplice", "target": "Perpetue", "data": { "value": 2 } }, { "id": "69", "source": "Simplice", "target": "Valjean", "data": { "value": 3 } }, { "id": "70", "source": "Simplice", "target": "Fantine", "data": { "value": 2 } }, { "id": "71", "source": "Simplice", "target": "Javert", "data": { "value": 1 } }, { "id": "72", "source": "Scaufflaire", "target": "Valjean", "data": { "value": 1 } }, { "id": "73", "source": "Woman1", "target": "Valjean", "data": { "value": 2 } }, { "id": "74", "source": "Woman1", "target": "Javert", "data": { "value": 1 } }, { "id": "75", "source": "Judge", "target": "Valjean", "data": { "value": 3 } }, { "id": "76", "source": "Judge", "target": "Bamatabois", "data": { "value": 2 } }, { "id": "77", "source": "Champmathieu", "target": "Valjean", "data": { "value": 3 } }, { "id": "78", "source": "Champmathieu", "target": "Judge", "data": { "value": 3 } }, { "id": "79", "source": "Champmathieu", "target": "Bamatabois", "data": { "value": 2 } }, { "id": "80", "source": "Brevet", "target": "Judge", "data": { "value": 2 } }, { "id": "81", "source": "Brevet", "target": "Champmathieu", "data": { "value": 2 } }, { "id": "82", "source": "Brevet", "target": "Valjean", "data": { "value": 2 } }, { "id": "83", "source": "Brevet", "target": "Bamatabois", "data": { "value": 1 } }, { "id": "84", "source": "Chenildieu", "target": "Judge", "data": { "value": 2 } }, { "id": "85", "source": "Chenildieu", "target": "Champmathieu", "data": { "value": 2 } }, { "id": "86", "source": "Chenildieu", "target": "Brevet", "data": { "value": 2 } }, { "id": "87", "source": "Chenildieu", "target": "Valjean", "data": { "value": 2 } }, { "id": "88", "source": "Chenildieu", "target": "Bamatabois", "data": { "value": 1 } }, { "id": "89", "source": "Cochepaille", "target": "Judge", "data": { "value": 2 } }, { "id": "90", "source": "Cochepaille", "target": "Champmathieu", "data": { "value": 2 } }, { "id": "91", "source": "Cochepaille", "target": "Brevet", "data": { "value": 2 } }, { "id": "92", "source": "Cochepaille", "target": "Chenildieu", "data": { "value": 2 } }, { "id": "93", "source": "Cochepaille", "target": "Valjean", "data": { "value": 2 } }, { "id": "94", "source": "Cochepaille", "target": "Bamatabois", "data": { "value": 1 } }, { "id": "95", "source": "Pontmercy", "target": "Thenardier", "data": { "value": 1 } }, { "id": "96", "source": "Boulatruelle", "target": "Thenardier", "data": { "value": 1 } }, { "id": "97", "source": "Eponine", "target": "Mme.Thenardier", "data": { "value": 2 } }, { "id": "98", "source": "Eponine", "target": "Thenardier", "data": { "value": 3 } }, { "id": "99", "source": "Anzelma", "target": "Eponine", "data": { "value": 2 } }, { "id": "100", "source": "Anzelma", "target": "Thenardier", "data": { "value": 2 } }, { "id": "101", "source": "Anzelma", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "102", "source": "Woman2", "target": "Valjean", "data": { "value": 3 } }, { "id": "103", "source": "Woman2", "target": "Cosette", "data": { "value": 1 } }, { "id": "104", "source": "Woman2", "target": "Javert", "data": { "value": 1 } }, { "id": "105", "source": "MotherInnocent", "target": "Fauchelevent", "data": { "value": 3 } }, { "id": "106", "source": "MotherInnocent", "target": "Valjean", "data": { "value": 1 } }, { "id": "107", "source": "Gribier", "target": "Fauchelevent", "data": { "value": 2 } }, { "id": "108", "source": "Mme.Burgon", "target": "Jondrette", "data": { "value": 1 } }, { "id": "109", "source": "Gavroche", "target": "Mme.Burgon", "data": { "value": 2 } }, { "id": "110", "source": "Gavroche", "target": "Thenardier", "data": { "value": 1 } }, { "id": "111", "source": "Gavroche", "target": "Javert", "data": { "value": 1 } }, { "id": "112", "source": "Gavroche", "target": "Valjean", "data": { "value": 1 } }, { "id": "113", "source": "Gillenormand", "target": "Cosette", "data": { "value": 3 } }, { "id": "114", "source": "Gillenormand", "target": "Valjean", "data": { "value": 2 } }, { "id": "115", "source": "Magnon", "target": "Gillenormand", "data": { "value": 1 } }, { "id": "116", "source": "Magnon", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "117", "source": "Mlle.Gillenormand", "target": "Gillenormand", "data": { "value": 9 } }, { "id": "118", "source": "Mlle.Gillenormand", "target": "Cosette", "data": { "value": 2 } }, { "id": "119", "source": "Mlle.Gillenormand", "target": "Valjean", "data": { "value": 2 } }, { "id": "120", "source": "Mme.Pontmercy", "target": "Mlle.Gillenormand", "data": { "value": 1 } }, { "id": "121", "source": "Mme.Pontmercy", "target": "Pontmercy", "data": { "value": 1 } }, { "id": "122", "source": "Mlle.Vaubois", "target": "Mlle.Gillenormand", "data": { "value": 1 } }, { "id": "123", "source": "Lt.Gillenormand", "target": "Mlle.Gillenormand", "data": { "value": 2 } }, { "id": "124", "source": "Lt.Gillenormand", "target": "Gillenormand", "data": { "value": 1 } }, { "id": "125", "source": "Lt.Gillenormand", "target": "Cosette", "data": { "value": 1 } }, { "id": "126", "source": "Marius", "target": "Mlle.Gillenormand", "data": { "value": 6 } }, { "id": "127", "source": "Marius", "target": "Gillenormand", "data": { "value": 12 } }, { "id": "128", "source": "Marius", "target": "Pontmercy", "data": { "value": 1 } }, { "id": "129", "source": "Marius", "target": "Lt.Gillenormand", "data": { "value": 1 } }, { "id": "130", "source": "Marius", "target": "Cosette", "data": { "value": 21 } }, { "id": "131", "source": "Marius", "target": "Valjean", "data": { "value": 19 } }, { "id": "132", "source": "Marius", "target": "Tholomyes", "data": { "value": 1 } }, { "id": "133", "source": "Marius", "target": "Thenardier", "data": { "value": 2 } }, { "id": "134", "source": "Marius", "target": "Eponine", "data": { "value": 5 } }, { "id": "135", "source": "Marius", "target": "Gavroche", "data": { "value": 4 } }, { "id": "136", "source": "BaronessT", "target": "Gillenormand", "data": { "value": 1 } }, { "id": "137", "source": "BaronessT", "target": "Marius", "data": { "value": 1 } }, { "id": "138", "source": "Mabeuf", "target": "Marius", "data": { "value": 1 } }, { "id": "139", "source": "Mabeuf", "target": "Eponine", "data": { "value": 1 } }, { "id": "140", "source": "Mabeuf", "target": "Gavroche", "data": { "value": 1 } }, { "id": "141", "source": "Enjolras", "target": "Marius", "data": { "value": 7 } }, { "id": "142", "source": "Enjolras", "target": "Gavroche", "data": { "value": 7 } }, { "id": "143", "source": "Enjolras", "target": "Javert", "data": { "value": 6 } }, { "id": "144", "source": "Enjolras", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "145", "source": "Enjolras", "target": "Valjean", "data": { "value": 4 } }, { "id": "146", "source": "Combeferre", "target": "Enjolras", "data": { "value": 15 } }, { "id": "147", "source": "Combeferre", "target": "Marius", "data": { "value": 5 } }, { "id": "148", "source": "Combeferre", "target": "Gavroche", "data": { "value": 6 } }, { "id": "149", "source": "Combeferre", "target": "Mabeuf", "data": { "value": 2 } }, { "id": "150", "source": "Prouvaire", "target": "Gavroche", "data": { "value": 1 } }, { "id": "151", "source": "Prouvaire", "target": "Enjolras", "data": { "value": 4 } }, { "id": "152", "source": "Prouvaire", "target": "Combeferre", "data": { "value": 2 } }, { "id": "153", "source": "Feuilly", "target": "Gavroche", "data": { "value": 2 } }, { "id": "154", "source": "Feuilly", "target": "Enjolras", "data": { "value": 6 } }, { "id": "155", "source": "Feuilly", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "156", "source": "Feuilly", "target": "Combeferre", "data": { "value": 5 } }, { "id": "157", "source": "Feuilly", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "158", "source": "Feuilly", "target": "Marius", "data": { "value": 1 } }, { "id": "159", "source": "Courfeyrac", "target": "Marius", "data": { "value": 9 } }, { "id": "160", "source": "Courfeyrac", "target": "Enjolras", "data": { "value": 17 } }, { "id": "161", "source": "Courfeyrac", "target": "Combeferre", "data": { "value": 13 } }, { "id": "162", "source": "Courfeyrac", "target": "Gavroche", "data": { "value": 7 } }, { "id": "163", "source": "Courfeyrac", "target": "Mabeuf", "data": { "value": 2 } }, { "id": "164", "source": "Courfeyrac", "target": "Eponine", "data": { "value": 1 } }, { "id": "165", "source": "Courfeyrac", "target": "Feuilly", "data": { "value": 6 } }, { "id": "166", "source": "Courfeyrac", "target": "Prouvaire", "data": { "value": 3 } }, { "id": "167", "source": "Bahorel", "target": "Combeferre", "data": { "value": 5 } }, { "id": "168", "source": "Bahorel", "target": "Gavroche", "data": { "value": 5 } }, { "id": "169", "source": "Bahorel", "target": "Courfeyrac", "data": { "value": 6 } }, { "id": "170", "source": "Bahorel", "target": "Mabeuf", "data": { "value": 2 } }, { "id": "171", "source": "Bahorel", "target": "Enjolras", "data": { "value": 4 } }, { "id": "172", "source": "Bahorel", "target": "Feuilly", "data": { "value": 3 } }, { "id": "173", "source": "Bahorel", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "174", "source": "Bahorel", "target": "Marius", "data": { "value": 1 } }, { "id": "175", "source": "Bossuet", "target": "Marius", "data": { "value": 5 } }, { "id": "176", "source": "Bossuet", "target": "Courfeyrac", "data": { "value": 12 } }, { "id": "177", "source": "Bossuet", "target": "Gavroche", "data": { "value": 5 } }, { "id": "178", "source": "Bossuet", "target": "Bahorel", "data": { "value": 4 } }, { "id": "179", "source": "Bossuet", "target": "Enjolras", "data": { "value": 10 } }, { "id": "180", "source": "Bossuet", "target": "Feuilly", "data": { "value": 6 } }, { "id": "181", "source": "Bossuet", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "182", "source": "Bossuet", "target": "Combeferre", "data": { "value": 9 } }, { "id": "183", "source": "Bossuet", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "184", "source": "Bossuet", "target": "Valjean", "data": { "value": 1 } }, { "id": "185", "source": "Joly", "target": "Bahorel", "data": { "value": 5 } }, { "id": "186", "source": "Joly", "target": "Bossuet", "data": { "value": 7 } }, { "id": "187", "source": "Joly", "target": "Gavroche", "data": { "value": 3 } }, { "id": "188", "source": "Joly", "target": "Courfeyrac", "data": { "value": 5 } }, { "id": "189", "source": "Joly", "target": "Enjolras", "data": { "value": 5 } }, { "id": "190", "source": "Joly", "target": "Feuilly", "data": { "value": 5 } }, { "id": "191", "source": "Joly", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "192", "source": "Joly", "target": "Combeferre", "data": { "value": 5 } }, { "id": "193", "source": "Joly", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "194", "source": "Joly", "target": "Marius", "data": { "value": 2 } }, { "id": "195", "source": "Grantaire", "target": "Bossuet", "data": { "value": 3 } }, { "id": "196", "source": "Grantaire", "target": "Enjolras", "data": { "value": 3 } }, { "id": "197", "source": "Grantaire", "target": "Combeferre", "data": { "value": 1 } }, { "id": "198", "source": "Grantaire", "target": "Courfeyrac", "data": { "value": 2 } }, { "id": "199", "source": "Grantaire", "target": "Joly", "data": { "value": 2 } }, { "id": "200", "source": "Grantaire", "target": "Gavroche", "data": { "value": 1 } }, { "id": "201", "source": "Grantaire", "target": "Bahorel", "data": { "value": 1 } }, { "id": "202", "source": "Grantaire", "target": "Feuilly", "data": { "value": 1 } }, { "id": "203", "source": "Grantaire", "target": "Prouvaire", "data": { "value": 1 } }, { "id": "204", "source": "MotherPlutarch", "target": "Mabeuf", "data": { "value": 3 } }, { "id": "205", "source": "Gueulemer", "target": "Thenardier", "data": { "value": 5 } }, { "id": "206", "source": "Gueulemer", "target": "Valjean", "data": { "value": 1 } }, { "id": "207", "source": "Gueulemer", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "208", "source": "Gueulemer", "target": "Javert", "data": { "value": 1 } }, { "id": "209", "source": "Gueulemer", "target": "Gavroche", "data": { "value": 1 } }, { "id": "210", "source": "Gueulemer", "target": "Eponine", "data": { "value": 1 } }, { "id": "211", "source": "Babet", "target": "Thenardier", "data": { "value": 6 } }, { "id": "212", "source": "Babet", "target": "Gueulemer", "data": { "value": 6 } }, { "id": "213", "source": "Babet", "target": "Valjean", "data": { "value": 1 } }, { "id": "214", "source": "Babet", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "215", "source": "Babet", "target": "Javert", "data": { "value": 2 } }, { "id": "216", "source": "Babet", "target": "Gavroche", "data": { "value": 1 } }, { "id": "217", "source": "Babet", "target": "Eponine", "data": { "value": 1 } }, { "id": "218", "source": "Claquesous", "target": "Thenardier", "data": { "value": 4 } }, { "id": "219", "source": "Claquesous", "target": "Babet", "data": { "value": 4 } }, { "id": "220", "source": "Claquesous", "target": "Gueulemer", "data": { "value": 4 } }, { "id": "221", "source": "Claquesous", "target": "Valjean", "data": { "value": 1 } }, { "id": "222", "source": "Claquesous", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "223", "source": "Claquesous", "target": "Javert", "data": { "value": 1 } }, { "id": "224", "source": "Claquesous", "target": "Eponine", "data": { "value": 1 } }, { "id": "225", "source": "Claquesous", "target": "Enjolras", "data": { "value": 1 } }, { "id": "226", "source": "Montparnasse", "target": "Javert", "data": { "value": 1 } }, { "id": "227", "source": "Montparnasse", "target": "Babet", "data": { "value": 2 } }, { "id": "228", "source": "Montparnasse", "target": "Gueulemer", "data": { "value": 2 } }, { "id": "229", "source": "Montparnasse", "target": "Claquesous", "data": { "value": 2 } }, { "id": "230", "source": "Montparnasse", "target": "Valjean", "data": { "value": 1 } }, { "id": "231", "source": "Montparnasse", "target": "Gavroche", "data": { "value": 1 } }, { "id": "232", "source": "Montparnasse", "target": "Eponine", "data": { "value": 1 } }, { "id": "233", "source": "Montparnasse", "target": "Thenardier", "data": { "value": 1 } }, { "id": "234", "source": "Toussaint", "target": "Cosette", "data": { "value": 2 } }, { "id": "235", "source": "Toussaint", "target": "Javert", "data": { "value": 1 } }, { "id": "236", "source": "Toussaint", "target": "Valjean", "data": { "value": 1 } }, { "id": "237", "source": "Child1", "target": "Gavroche", "data": { "value": 2 } }, { "id": "238", "source": "Child2", "target": "Gavroche", "data": { "value": 2 } }, { "id": "239", "source": "Child2", "target": "Child1", "data": { "value": 3 } }, { "id": "240", "source": "Brujon", "target": "Babet", "data": { "value": 3 } }, { "id": "241", "source": "Brujon", "target": "Gueulemer", "data": { "value": 3 } }, { "id": "242", "source": "Brujon", "target": "Thenardier", "data": { "value": 3 } }, { "id": "243", "source": "Brujon", "target": "Gavroche", "data": { "value": 1 } }, { "id": "244", "source": "Brujon", "target": "Eponine", "data": { "value": 1 } }, { "id": "245", "source": "Brujon", "target": "Claquesous", "data": { "value": 1 } }, { "id": "246", "source": "Brujon", "target": "Montparnasse", "data": { "value": 1 } }, { "id": "247", "source": "Mme.Hucheloup", "target": "Bossuet", "data": { "value": 1 } }, { "id": "248", "source": "Mme.Hucheloup", "target": "Joly", "data": { "value": 1 } }, { "id": "249", "source": "Mme.Hucheloup", "target": "Grantaire", "data": { "value": 1 } }, { "id": "250", "source": "Mme.Hucheloup", "target": "Bahorel", "data": { "value": 1 } }, { "id": "251", "source": "Mme.Hucheloup", "target": "Courfeyrac", "data": { "value": 1 } }, { "id": "252", "source": "Mme.Hucheloup", "target": "Gavroche", "data": { "value": 1 } }, { "id": "253", "source": "Mme.Hucheloup", "target": "Enjolras", "data": { "value": 1 } } ] } ================================================ FILE: packages/g6/__tests__/dataset/soccer.json ================================================ { "nodes": [ { "id": "Argentina", "data": { "name": "Argentina" } }, { "id": "Australia", "data": { "name": "Australia" } }, { "id": "Belgium", "data": { "name": "Belgium" } }, { "id": "Brazil", "data": { "name": "Brazil" } }, { "id": "Colombia", "data": { "name": "Colombia" } }, { "id": "Costa Rica", "data": { "name": "Costa Rica" } }, { "id": "Croatia", "data": { "name": "Croatia" } }, { "id": "Denmark", "data": { "name": "Denmark" } }, { "id": "Egypt", "data": { "name": "Egypt" } }, { "id": "England", "data": { "name": "England" } }, { "id": "France", "data": { "name": "France" } }, { "id": "Germany", "data": { "name": "Germany" } }, { "id": "Iceland", "data": { "name": "Iceland" } }, { "id": "IR Iran", "data": { "name": "IR Iran" } }, { "id": "Japan", "data": { "name": "Japan" } }, { "id": "Korea Republic", "data": { "name": "Korea Republic" } }, { "id": "Mexico", "data": { "name": "Mexico" } }, { "id": "Morocco", "data": { "name": "Morocco" } }, { "id": "Nigeria", "data": { "name": "Nigeria" } }, { "id": "Panama", "data": { "name": "Panama" } }, { "id": "Peru", "data": { "name": "Peru" } }, { "id": "Poland", "data": { "name": "Poland" } }, { "id": "Portugal", "data": { "name": "Portugal" } }, { "id": "Russia", "data": { "name": "Russia" } }, { "id": "Saudi Arabia", "data": { "name": "Saudi Arabia" } }, { "id": "Senegal", "data": { "name": "Senegal" } }, { "id": "Serbia", "data": { "name": "Serbia" } }, { "id": "Spain", "data": { "name": "Spain" } }, { "id": "Sweden", "data": { "name": "Sweden" } }, { "id": "Switzerland", "data": { "name": "Switzerland" } }, { "id": "Tunisia", "data": { "name": "Tunisia" } }, { "id": "Uruguay", "data": { "name": "Uruguay" } } ], "edges": [ { "id": "0", "target": "Russia", "source": "Saudi Arabia", "data": { "target_score": 5, "source_score": 0, "directed": true } }, { "id": "1", "target": "Uruguay", "source": "Egypt", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "2", "target": "Russia", "source": "Egypt", "data": { "target_score": 3, "source_score": 1, "directed": true } }, { "id": "3", "target": "Uruguay", "source": "Saudi Arabia", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "4", "target": "Uruguay", "source": "Russia", "data": { "target_score": 3, "source_score": 0, "directed": true } }, { "id": "5", "target": "Saudi Arabia", "source": "Egypt", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "6", "target": "IR Iran", "source": "Morocco", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "7", "target": "Portugal", "source": "Spain", "data": { "target_score": 3, "source_score": 3, "directed": false } }, { "id": "8", "target": "Portugal", "source": "Morocco", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "9", "target": "Spain", "source": "IR Iran", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "10", "target": "IR Iran", "source": "Portugal", "data": { "target_score": 1, "source_score": 1, "directed": false } }, { "id": "11", "target": "Spain", "source": "Morocco", "data": { "target_score": 2, "source_score": 2, "directed": false } }, { "id": "12", "target": "France", "source": "Australia", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "13", "target": "Denmark", "source": "Peru", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "14", "target": "Denmark", "source": "Australia", "data": { "target_score": 1, "source_score": 1, "directed": false } }, { "id": "15", "target": "France", "source": "Peru", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "16", "target": "Denmark", "source": "France", "data": { "target_score": 0, "source_score": 0, "directed": false } }, { "id": "17", "target": "Peru", "source": "Australia", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "18", "target": "Argentina", "source": "Iceland", "data": { "target_score": 1, "source_score": 1 } }, { "id": "19", "target": "Croatia", "source": "Nigeria", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "20", "target": "Croatia", "source": "Argentina", "data": { "target_score": 3, "source_score": 0, "directed": true } }, { "id": "21", "target": "Nigeria", "source": "Iceland", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "22", "target": "Argentina", "source": "Nigeria", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "23", "target": "Croatia", "source": "Iceland", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "24", "target": "Serbia", "source": "Costa Rica", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "25", "target": "Brazil", "source": "Switzerland", "data": { "target_score": 1, "source_score": 1, "directed": false } }, { "id": "26", "target": "Brazil", "source": "Costa Rica", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "27", "target": "Switzerland", "source": "Serbia", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "28", "target": "Brazil", "source": "Serbia", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "29", "target": "Switzerland", "source": "Costa Rica", "data": { "target_score": 2, "source_score": 2, "directed": false } }, { "id": "30", "target": "Mexico", "source": "Germany", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "31", "target": "Sweden", "source": "Korea Republic", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "32", "target": "Mexico", "source": "Korea Republic", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "33", "target": "Germany", "source": "Sweden", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "34", "target": "Korea Republic", "source": "Germany", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "35", "target": "Sweden", "source": "Mexico", "data": { "target_score": 3, "source_score": 0, "directed": true } }, { "id": "36", "target": "Belgium", "source": "Panama", "data": { "target_score": 3, "source_score": 0, "directed": true } }, { "id": "37", "target": "England", "source": "Tunisia", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "38", "target": "Belgium", "source": "Tunisia", "data": { "target_score": 5, "source_score": 2, "directed": true } }, { "id": "39", "target": "England", "source": "Panama", "data": { "target_score": 6, "source_score": 1, "directed": true } }, { "id": "40", "target": "Belgium", "source": "England", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "41", "target": "Tunisia", "source": "Panama", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "42", "target": "Japan", "source": "Colombia", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "43", "target": "Senegal", "source": "Poland", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "44", "target": "Japan", "source": "Senegal", "data": { "target_score": 2, "source_score": 2, "directed": false } }, { "id": "45", "target": "Colombia", "source": "Poland", "data": { "target_score": 3, "source_score": 0, "directed": true } }, { "id": "46", "target": "Poland", "source": "Japan", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "47", "target": "Colombia", "source": "Senegal", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "48", "target": "Uruguay", "source": "Portugal", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "49", "target": "France", "source": "Argentina", "data": { "target_score": 4, "source_score": 3, "directed": true } }, { "id": "50", "target": "Russia", "source": "Spain", "data": { "target_score": 5, "source_score": 4, "directed": true } }, { "id": "51", "target": "Croatia", "source": "Denmark", "data": { "target_score": 4, "source_score": 3, "directed": true } }, { "id": "52", "target": "Brazil", "source": "Mexico", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "53", "target": "Belgium", "source": "Japan", "data": { "target_score": 3, "source_score": 2, "directed": true } }, { "id": "54", "target": "Sweden", "source": "Switzerland", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "55", "target": "England", "source": "Colombia", "data": { "target_score": 4, "source_score": 3, "directed": true } }, { "id": "56", "target": "France", "source": "Uruguay", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "57", "target": "Belgium", "source": "Brazil", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "58", "target": "Croatia", "source": "Russia", "data": { "target_score": 6, "source_score": 5, "directed": true } }, { "id": "59", "target": "England", "source": "Sweden", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "60", "target": "France", "source": "Belgium", "data": { "target_score": 1, "source_score": 0, "directed": true } }, { "id": "61", "target": "Croatia", "source": "England", "data": { "target_score": 2, "source_score": 1, "directed": true } }, { "id": "62", "target": "Belgium", "source": "England", "data": { "target_score": 2, "source_score": 0, "directed": true } }, { "id": "63", "target": "France", "source": "Croatia", "data": { "target_score": 4, "source_score": 2, "directed": true } } ] } ================================================ FILE: packages/g6/__tests__/demos/animation-element-edge-cubic.ts ================================================ import { Graph } from '@antv/g6'; export const animationElementEdgeCubic: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 100, y: 100 } }, { id: 'node-2', style: { x: 350, y: 150 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', style: { curveOffset: 30, }, }, ], }, edge: { type: 'cubic', style: { lineWidth: 2, stroke: '#1890FF', labelText: 'cubic-edge', labelFontSize: 12, endArrow: true, }, }, }); await graph.draw(); animationElementEdgeCubic.form = (panel) => [ panel.add( { Play: () => { graph.updateNodeData([{ id: 'node-2', style: { y: 300 } }]); graph.updateEdgeData([{ id: 'edge-1', style: { curveOffset: 60 } }]); graph.draw(); }, }, 'Play', ), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/animation-element-edge-line.ts ================================================ import { Graph } from '@antv/g6'; export const animationEdgeLine: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 100, y: 150 } }, { id: 'node-2', style: { x: 300, y: 200 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, cursor: 'grab', edge: { style: { lineWidth: 2, lineDash: [10, 10], stroke: '#1890FF', halo: true, haloOpacity: 0.25, haloLineWidth: 12, label: true, labelText: 'line-edge', labelFontSize: 12, labelFill: '#000', labelPadding: 0, startArrow: true, startArrowType: 'circle', endArrow: true, endArrowFill: 'red', }, }, }); await graph.render(); animationEdgeLine.form = (panel) => [ panel.add( { Play: () => { graph.updateNodeData([{ id: 'node-2', style: { x: 450, y: 350 } }]); graph.draw(); }, }, 'Play', ), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/animation-element-edge-quadratic.ts ================================================ import { Graph } from '@antv/g6'; export const animationElementEdgeQuadratic: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 100, y: 150 } }, { id: 'node-2', style: { x: 300, y: 200 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', }, ], }, edge: { type: 'quadratic', style: { lineWidth: 2, stroke: '#1890FF', labelText: 'quadratic-edge', labelFontSize: 12, endArrow: true, }, }, }); await graph.draw(); animationElementEdgeQuadratic.form = (panel) => [ panel.add( { Play: () => { graph.updateNodeData([{ id: 'node-2', style: { x: 450, y: 350 } }]); graph.draw(); }, }, 'Play', ), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/animation-element-position.ts ================================================ import type { GraphOptions } from '@antv/g6'; import { Graph } from '@antv/g6'; export const animationElementPosition: TestCase = async (context) => { const options: GraphOptions = { ...context, data: { nodes: [ { id: 'node-1', style: { x: 250, y: 200 } }, { id: 'node-2', style: { x: 250, y: 200 } }, { id: 'node-3', style: { x: 250, y: 200 } }, { id: 'node-4', style: { x: 250, y: 200 } }, { id: 'node-5', style: { x: 250, y: 200 } }, { id: 'node-6', style: { x: 250, y: 200 } }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-3' }, { source: 'node-3', target: 'node-1' }, { source: 'node-3', target: 'node-5' }, { source: 'node-2', target: 'node-4' }, { source: 'node-2', target: 'node-5' }, { source: 'node-3', target: 'node-6' }, { source: 'node-4', target: 'node-5' }, { source: 'node-5', target: 'node-6' }, ], }, node: { style: { size: 20, }, }, }; const graph = new Graph(options); await graph.render(); const play = () => { graph.translateElementTo( { 'node-1': [250, 100], 'node-2': [175, 200], 'node-3': [325, 200], 'node-4': [100, 300], 'node-5': [250, 300], 'node-6': [400, 300], }, true, ); }; animationElementPosition.form = (panel) => [panel.add({ play }, 'play').name('Play')]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/animation-element-state-switch.ts ================================================ import type { GraphOptions } from '@antv/g6'; import { Graph } from '@antv/g6'; export const animationElementStateSwitch: TestCase = async (context) => { const options: GraphOptions = { ...context, data: { nodes: [ { id: 'node-1', states: ['active', 'selected'], style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', states: ['active'], style: { x: 125, y: 150 } }, ], edges: [ { source: 'node-1', target: 'node-2', states: ['active'] }, { source: 'node-2', target: 'node-3' }, { source: 'node-3', target: 'node-1' }, ], }, theme: 'light', node: { style: { lineWidth: 1, size: 20, }, state: { active: { lineWidth: 2, }, selected: { fill: 'pink', }, }, animation: { update: [{ fields: ['lineWidth', 'fill'] }], }, }, edge: { style: { lineWidth: 1, }, state: { active: { lineWidth: 2, stroke: 'pink', }, }, animation: { update: [ { fields: ['lineWidth', 'stroke'], }, ], }, }, }; const graph = new Graph(options); await graph.render(); const play = () => { graph.updateData({ nodes: [ { id: 'node-1', states: [] }, { id: 'node-2', states: ['active'] }, { id: 'node-3', states: ['selected'] }, ], edges: [ { source: 'node-1', target: 'node-2', states: [] }, { source: 'node-2', target: 'node-3', states: ['active'] }, ], }); graph.draw(); }; animationElementStateSwitch.form = (panel) => [panel.add({ play }, 'play').name('Play')]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/animation-element-state.ts ================================================ import { Circle, ExtensionCategory, Graph, Line, register } from '@antv/g6'; export const animationElementState: TestCase = async (context) => { class BreathingCircle extends Circle { onCreate() { this.shapeMap.halo.animate([{ lineWidth: 5 }, { lineWidth: 10 }], { duration: 1000, iterations: Infinity, direction: 'alternate', }); } } class FlyLine extends Line { onCreate() { this.shapeMap.key.animate([{ lineDashOffset: -20 }, { lineDashOffset: 0 }], { duration: 500, iterations: Infinity, }); } } register(ExtensionCategory.NODE, 'breathing-circle', BreathingCircle); register(ExtensionCategory.EDGE, 'fly-line', FlyLine); const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', style: { x: 125, y: 150 } }, ], edges: [ { source: 'node-1', target: 'node-2', style: {} }, { source: 'node-2', target: 'node-3', style: {} }, { source: 'node-3', target: 'node-1', style: {} }, ], }, node: { type: 'breathing-circle', style: { halo: true, haloLineWidth: 5, }, }, edge: { type: 'fly-line', style: { lineDash: [10, 10], }, }, behaviors: ['drag-element'], }); await graph.draw(); animationElementState.form = (panel) => [ panel.add( { Animate: () => { graph.translateElementBy('node-2', [0, 50]); }, }, 'Animate', ), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/animation-element-style-position.ts ================================================ import { Graph, type GraphOptions } from '@antv/g6'; export const animationElementStylePosition: TestCase = async (context) => { const options: GraphOptions = { ...context, data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', style: { x: 125, y: 150 } }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-3' }, { source: 'node-3', target: 'node-1' }, ], }, theme: 'light', node: { style: { size: 20, }, }, edge: { style: {}, }, }; const graph = new Graph(options); await graph.render(); const play = () => { graph.addNodeData([ { id: 'node-4', style: { x: 50, y: 200, fill: 'orange' } }, { id: 'node-5', style: { x: 75, y: 150, fill: 'purple' } }, { id: 'node-6', style: { x: 200, y: 100, fill: 'cyan' } }, ]); graph.removeNodeData(['node-1']); graph.updateNodeData([{ id: 'node-2', style: { x: 200, y: 200, stroke: 'green' } }]); graph.draw(); }; animationElementStylePosition.form = (panel) => [panel.add({ play }, 'play').name('Play')]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-auto-adapt-label.ts ================================================ import data from '@@/dataset/language-tree.json'; import { Graph, IPointerEvent, type Element } from '@antv/g6'; export const behaviorAutoAdaptLabel: TestCase = async (context) => { const graph = new Graph({ ...context, padding: 20, theme: 'light', data, node: { style: { labelText: (d) => d.id, labelBackground: true, labelFontFamily: 'Gill Sans', labelFill: '#333', }, state: { active: { label: true, }, }, palette: { type: 'group', color: 'tableau', field: 'group', }, }, edge: { style: { stroke: '#E2E3E1', endArrow: true, }, }, behaviors: [ 'drag-canvas', 'zoom-canvas', function () { return { type: 'hover-activate', degree: 0, onHover: (e: IPointerEvent) => { this.frontElement(e.target.id); }, }; }, { key: 'auto-adapt-label', type: 'auto-adapt-label', }, ], layout: { type: 'd3-force', manyBody: { strength: -200 }, x: {}, y: {}, }, transforms: [ { key: 'map-node-size', type: 'map-node-size', maxSize: 60, minSize: 12, scale: 'linear', mapLabelSize: true, }, ], plugins: [{ type: 'background', background: '#fff' }], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-brush-select.ts ================================================ import { Graph } from '@antv/g6'; export const behaviorBrushSelect: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 150, y: 250, lineWidth: 0 } }, { id: 'node2', combo: 'combo1', style: { x: 250, y: 200, lineWidth: 0 } }, { id: 'node3', combo: 'combo1', style: { x: 350, y: 250, lineWidth: 0 } }, { id: 'node4', combo: 'combo1', style: { x: 250, y: 300, lineWidth: 0 } }, ], edges: [ { id: 'edge1', source: 'node1', target: 'node2', }, { id: 'edge2', source: 'node2', target: 'node3', }, { id: 'edge3', source: 'node3', target: 'node4', }, { id: 'edge4', source: 'node1', target: 'node4', }, ], combos: [{ id: 'combo1' }], }, node: { style: { labelText: (d) => d.id, }, }, animation: false, behaviors: [ { type: 'brush-select', key: 'brush-select', trigger: 'drag', }, ], }); await graph.render(); behaviorBrushSelect.form = (panel) => { const config = { mode: 'default', }; const handleChange = () => { graph.updateBehavior({ key: 'brush-select', ...config }); }; return [panel.add(config, 'mode', ['union', 'default', 'intersect', 'diff']).onChange(handleChange)]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-click-select.ts ================================================ import type { ClickSelectOptions } from '@/src/behaviors'; import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const behaviorClickSelect: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'd3-force' }, behaviors: [{ type: 'click-select', key: 'click-select' }, 'drag-element'], }); await graph.render(); const config = { multiple: false, trigger: ['shift'], degree: 0, state: 'selected', unselectedState: undefined, }; const updateClickSelectOption = (options: Partial) => { graph.updateBehavior({ key: 'click-select', ...options }); }; behaviorClickSelect.form = (panel) => [ panel .add(config, 'multiple') .name('Multiple') .onChange((multiple: boolean) => updateClickSelectOption({ multiple })), panel .add(config, 'trigger', ['Shift', 'Control', 'Alt', 'Meta']) .name('Trigger') .onChange((trigger: string) => updateClickSelectOption({ trigger: [trigger] })), panel .add(config, 'degree', [0, 1, 2, 3]) .name('Degree') .onChange((degree: number) => updateClickSelectOption({ degree })), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-create-edge.ts ================================================ import { Graph } from '@antv/g6'; export const behaviorCreateEdge: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 250, y: 150 } }, { id: 'node2', combo: 'combo1', style: { x: 350, y: 150 } }, { id: 'node3', combo: 'combo2', style: { x: 250, y: 300 } }, ], edges: [], combos: [ { id: 'combo1', }, { id: 'combo2', style: { // 指向中心 ports: [{ key: 'port-1', placement: [0.5, 0.5] }], }, }, ], }, node: { style: { size: 20 } }, edge: { style: { endArrow: true }, }, behaviors: [{ type: 'create-edge' }], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-drag-canvas.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const behaviorDragCanvas: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'd3-force', }, node: { style: { size: 20, }, }, behaviors: [ 'drag-canvas', { type: 'drag-canvas', key: 'drag-canvas', trigger: { up: ['ArrowUp'], down: ['ArrowDown'], right: ['ArrowRight'], left: ['ArrowLeft'], }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-drag-element.ts ================================================ import { Graph } from '@antv/g6'; export const behaviorDragNode: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 100, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 200, y: 100 } }, { id: 'node-3', style: { x: 100, y: 200 } }, { id: 'node-4', combo: 'combo-1', style: { x: 200, y: 200 } }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-4' }, { source: 'node-1', target: 'node-3' }, { source: 'node-3', target: 'node-4' }, ], combos: [{ id: 'combo-1' }], }, node: { style: { size: 20 } }, edge: { style: { endArrow: true }, }, behaviors: [{ type: 'drag-element' }], }); await graph.render(); behaviorDragNode.form = (panel) => { const config = { enable: true, hideEdge: 'none', shadow: false, }; const handleChange = () => { graph.setBehaviors([{ type: 'drag-element', ...config }]); }; return [ panel.add(config, 'enable').onChange(handleChange), panel.add(config, 'hideEdge', ['none', 'in', 'out', 'both']).onChange(handleChange), panel.add(config, 'shadow').onChange(handleChange), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-expand-collapse-combo.ts ================================================ import { Graph } from '@antv/g6'; export const behaviorExpandCollapseCombo: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', combo: 'combo-2', style: { x: 120, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 300, y: 200 } }, { id: 'node-3', combo: 'combo-1', style: { x: 200, y: 300 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, ], combos: [ { id: 'combo-1', type: 'rect', combo: 'combo-2', style: { collapsed: true, }, }, { id: 'combo-2' }, ], }, node: { style: { labelText: (d) => d.id, }, }, combo: { style: { labelText: (d) => d.id, lineDash: 0, }, }, behaviors: [{ type: 'drag-element' }, 'collapse-expand', 'click-select'], }); await graph.render(); behaviorExpandCollapseCombo.form = (panel) => { const config = { element: 'combo-1', dropEffect: 'move', collapse: () => graph.collapseElement(config.element), expand: () => graph.expandElement(config.element), }; return [ panel .add(config, 'element', { 'combo-1': 'combo-1', 'combo-2': 'combo-2', 'combo-3': 'combo-3', 'combo-4': 'combo-4', }) .name('Combo'), panel.add(config, 'collapse').name('Collapse'), panel.add(config, 'expand').name('Expand'), panel.add(config, 'dropEffect', ['link', 'move', 'none']).onChange((value: string) => { graph.setBehaviors((behaviors) => { return behaviors.map((behavior) => { if (typeof behavior === 'object' && behavior.type === 'drag-element') { return { ...behavior, dropEffect: value, }; } return behavior; }); }); }), ]; }; Object.assign(window, { graph }); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-expand-collapse-node.ts ================================================ import { Graph, treeToGraphData } from '@antv/g6'; export const behaviorExpandCollapseNode: TestCase = async (context) => { const graph = new Graph({ ...context, x: 200, y: 200, data: treeToGraphData({ id: 'A', children: [ { id: 'B', children: [{ id: 'D' }, { id: 'E' }] }, { id: 'C', style: { collapsed: true }, children: [{ id: 'F' }, { id: 'G' }] }, ], }), node: { style: { size: 32, labelText: (d) => d.id, labelPlacement: 'right', ports: [{ position: 'center' }], }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'dendrogram', nodeSep: 40, rankSep: 100, preLayout: false, }, behaviors: [{ type: 'collapse-expand', trigger: 'click', align: false }, 'drag-element'], }); await graph.render(); behaviorExpandCollapseNode.form = (panel) => { const config = { element: 'A', collapse: () => graph.collapseElement(config.element), expand: () => graph.expandElement(config.element), }; return [ panel.add(config, 'element', ['A', 'B', 'C', 'D', 'E', 'F', 'G']).name('Node'), panel.add(config, 'collapse').name('Collapse'), panel.add(config, 'expand').name('Expand'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-fix-element-size.ts ================================================ import { Graph, GraphData } from '@antv/g6'; const data: GraphData = { nodes: [ { id: 'node0', size: 50, label: '0', style: { x: 326, y: 268 }, states: ['selected'] }, { id: 'node1', size: 30, label: '1', style: { x: 280, y: 384 }, states: ['selected'] }, { id: 'node2', size: 30, label: '2', style: { x: 234, y: 167 } }, { id: 'node3', size: 30, label: '3', style: { x: 391, y: 368 } }, { id: 'node4', size: 30, label: '4', style: { x: 444, y: 209 } }, { id: 'node5', size: 30, label: '5', style: { x: 378, y: 157 } }, { id: 'node6', size: 15, label: '6', style: { x: 229, y: 400 } }, { id: 'node7', size: 15, label: '7', style: { x: 281, y: 440 } }, { id: 'node8', size: 15, label: '8', style: { x: 188, y: 119 } }, { id: 'node9', size: 15, label: '9', style: { x: 287, y: 157 } }, { id: 'node10', size: 15, label: '10', style: { x: 185, y: 200 } }, { id: 'node11', size: 15, label: '11', style: { x: 238, y: 110 } }, { id: 'node12', size: 15, label: '12', style: { x: 239, y: 221 } }, { id: 'node13', size: 15, label: '13', style: { x: 176, y: 160 } }, { id: 'node14', size: 15, label: '14', style: { x: 389, y: 423 } }, { id: 'node15', size: 15, label: '15', style: { x: 441, y: 341 } }, { id: 'node16', size: 15, label: '16', style: { x: 442, y: 398 } }, ], edges: [ { source: 'node0', target: 'node1', label: '0-1', states: ['selected'] }, { source: 'node0', target: 'node2', label: '0-2' }, { source: 'node0', target: 'node3', label: '0-3' }, { source: 'node0', target: 'node4', label: '0-4' }, { source: 'node0', target: 'node5', label: '0-5' }, { source: 'node1', target: 'node6', label: '1-6' }, { source: 'node1', target: 'node7', label: '1-7' }, { source: 'node2', target: 'node8', label: '2-8' }, { source: 'node2', target: 'node9', label: '2-9' }, { source: 'node2', target: 'node10', label: '2-10' }, { source: 'node2', target: 'node11', label: '2-11' }, { source: 'node2', target: 'node12', label: '2-12' }, { source: 'node2', target: 'node13', label: '2-13' }, { source: 'node3', target: 'node14', label: '3-14' }, { source: 'node3', target: 'node15', label: '3-15' }, { source: 'node3', target: 'node16', label: '3-16' }, ], }; export const behaviorFixElementSize: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.label, size: (d) => d.size, lineWidth: 1, }, }, edge: { style: { labelText: (d) => d.label } }, behaviors: [ 'zoom-canvas', 'drag-canvas', { type: 'fix-element-size', key: 'fix-element-size', state: 'selected', reset: true, }, { type: 'click-select', key: 'click-select', multiple: true }, ], plugins: [{ key: 'history', type: 'history' }], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-focus-element.ts ================================================ import { Graph } from '@antv/g6'; export const behaviorFocusElement: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 100, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 200, y: 100 } }, { id: 'node-3', style: { x: 100, y: 200 } }, { id: 'node-4', combo: 'combo-1', style: { x: 200, y: 200 } }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-4' }, { source: 'node-1', target: 'node-3' }, { source: 'node-3', target: 'node-4' }, ], combos: [{ id: 'combo-1' }], }, node: { style: { size: 20 } }, edge: { style: { endArrow: true }, }, behaviors: ['focus-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-hover-activate.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const behaviorHoverActivate: TestCase = async (context) => { const graph = new Graph({ ...context, data: data, layout: { type: 'd3-force' }, node: { style: { size: 20, }, }, zoomRange: [0.5, 5], behaviors: [{ type: 'hover-activate' }], }); await graph.render(); const config = { degree: 0, }; behaviorHoverActivate.form = (panel) => [ panel .add(config, 'degree', 0, 3, 1) .name('Degree') .onChange((degree: number) => graph.setBehaviors([{ type: 'hover-activate', degree }])), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-lasso-select.ts ================================================ import { Graph } from '@antv/g6'; export const behaviorLassoSelect: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 150, y: 250, lineWidth: 0 } }, { id: 'node2', combo: 'combo1', style: { x: 250, y: 200, lineWidth: 0 } }, { id: 'node3', combo: 'combo1', style: { x: 350, y: 250, lineWidth: 0 } }, { id: 'node4', combo: 'combo1', style: { x: 250, y: 300, lineWidth: 0 } }, ], edges: [ { id: 'edge1', source: 'node1', target: 'node2', }, { id: 'edge2', source: 'node2', target: 'node3', }, { id: 'edge3', source: 'node3', target: 'node4', }, { id: 'edge4', source: 'node1', target: 'node4', }, ], combos: [{ id: 'combo1' }], }, node: { style: { labelText: (d) => d.id, }, }, animation: false, behaviors: [{ type: 'lasso-select', key: 'lasso-select', trigger: 'drag' }], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-optimize-viewport-transform.ts ================================================ import data from '@@/dataset/cluster.json'; import type { DisplayObject } from '@antv/g'; import type { ElementType } from '@antv/g6'; import { Graph } from '@antv/g6'; export const behaviorOptimizeViewportTransform: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'd3-force', }, node: { style: { iconSrc: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg', labelFontSize: 8, labelText: (datum) => datum.id, size: 20, }, }, edge: { style: { labelFontSize: 8, labelText: (datum) => datum.id, startArrow: true, }, }, behaviors: [ 'drag-canvas', 'zoom-canvas', 'scroll-canvas', { key: 'optimize-viewport-transform', type: 'optimize-viewport-transform', shapes: (type: ElementType, shape: DisplayObject) => type === 'node' && shape.className === 'key', }, ], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-scroll-canvas.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const behaviorScrollCanvas: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'd3-force', }, node: { style: { size: 20, }, }, behaviors: [ { key: 'scroll-canvas', type: 'scroll-canvas', }, ], }); behaviorScrollCanvas.form = (panel) => { const config = { direction: '', sensitivity: 1, }; panel.onChange(() => { graph.updateBehavior({ key: 'scroll-canvas', ...config, }); }); return [panel.add(config, 'direction', ['xy', 'x', 'y']), panel.add(config, 'sensitivity', 1, 10, 1)]; }; await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/behavior-zoom-canvas.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const behaviorZoomCanvas: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'd3-force', }, node: { style: { size: 20, }, }, zoomRange: [0.5, 5], behaviors: [{ type: 'zoom-canvas' }], }); await graph.render(); behaviorZoomCanvas.form = (panel) => { const config = { DisableZoom: false, addShortcutZoom: () => { graph.setBehaviors((currBehaviors) => [ ...currBehaviors, { key: 'shortcut-zoom-canvas', type: 'zoom-canvas', trigger: { zoomIn: ['Control', '='], zoomOut: ['Control', '-'], reset: ['Control', '0'], }, }, ]); alert('Zoom behavior added'); }, removeShortcutZoom: () => { graph.setBehaviors((currBehaviors) => { return currBehaviors.slice(0, 1); }); alert('Zoom behavior removed'); }, }; return [ panel.add(config, 'DisableZoom').onChange((disable: boolean) => { graph.setBehaviors((currBehaviors) => { return currBehaviors.map((behavior, index) => { if (index === 0 && typeof behavior === 'object') { return { ...behavior, enable: !disable }; } return behavior; }); }); }), panel.add(config, 'addShortcutZoom'), panel.add(config, 'removeShortcutZoom'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/bug-drag-rotated-canvas.ts ================================================ import { Graph } from '@antv/g6'; export const bugDragRotatedCanvas: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', combo: 'comboA' }, { id: 'node2', combo: 'comboA' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }, ], combos: [{ id: 'comboA' }], edges: [ { source: 'node1', target: 'node2' }, { source: 'node1', target: 'node3' }, { source: 'node1', target: 'node4' }, { source: 'node2', target: 'node3' }, { source: 'node3', target: 'node4' }, { source: 'node4', target: 'node5' }, ], }, layout: { type: 'grid', }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); graph.rotateTo(2160 + 60); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/bug-drag-rotated-element-force.ts ================================================ // ref: https://observablehq.com/@d3/force-directed-lattice import { Graph } from '@antv/g6'; export const bugDragRotatedElementForce: TestCase = async (context) => { const graph = new Graph({ ...context, data: getData(), layout: { type: 'd3-force', manyBody: { strength: -30, }, link: { strength: 1, distance: 20, iterations: 10, }, }, node: { style: { size: 10, fill: '#000', }, }, edge: { style: { stroke: '#000', }, }, behaviors: [{ type: 'drag-element-force' }, 'zoom-canvas'], }); await graph.render(); graph.rotateTo(2160 + 60); return graph; }; function getData(size = 10) { const nodes = Array.from({ length: size * size }, (_, i) => ({ id: `${i}` })); const edges = []; for (let y = 0; y < size; ++y) { for (let x = 0; x < size; ++x) { if (y > 0) edges.push({ source: `${(y - 1) * size + x}`, target: `${y * size + x}` }); if (x > 0) edges.push({ source: `${y * size + (x - 1)}`, target: `${y * size + x}` }); } } return { nodes, edges }; } ================================================ FILE: packages/g6/__tests__/demos/bug-process-parallel-edges-combo-fixed.ts ================================================ import type { IElementDragEvent } from '@antv/g6'; import { Graph } from '@antv/g6'; /** * 测试 process-parallel-edges 与 collapse-expand 配合的修复效果 * 确保修复后向后兼容,不影响原有功能 * @param context */ export const bugProcessParallelEdgesComboFixed: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 300, y: 100 } }, { id: 'node2', combo: 'combo1', style: { x: 300, y: 150 } }, { id: 'node3', combo: 'combo2', style: { x: 100, y: 100 } }, { id: 'node4', combo: 'combo2', style: { x: 50, y: 150 } }, { id: 'node5', combo: 'combo2', style: { x: 150, y: 150 } }, ], edges: [ { source: 'node1', target: 'node2' }, { source: 'node3', target: 'node4' }, { source: 'node3', target: 'node5' }, ], combos: [ { id: 'combo1', style: { labelText: '双击折叠', collapsed: true } }, { id: 'combo2', style: { labelText: '单击折叠 (无 process-parallel-edges)', collapsed: false } }, ], }, node: { style: { labelText: (d) => d.id, size: 20, }, }, edge: { type: 'quadratic', state: { highlighted: { stroke: '#F5AD21', labelFontWeight: 600, labelFontSize: 18, }, }, style: { loop: false, lineWidth: 2, haloOpacity: 0.2, endArrow: true, endArrowType: 'vee', stroke: '#C4CDE3', labelText: 'fixed version', labelFontSize: 14, labelFontWeight: 500, labelBackground: true, labelBackgroundFill: '#FFFFFF', labelBackgroundRadius: 4, labelPadding: [4, 8], }, }, combo: { style: { lineWidth: 2, stroke: '#99ADD1', fill: '#F3F6FF', radius: 8, padding: [10, 20, 30, 20], labelFontSize: 12, labelFill: '#666', }, }, // 注意:这里添加了 process-parallel-edges 变换来测试修复效果 transforms: [ { type: 'process-parallel-edges', distance: 60, }, ], behaviors: [ { type: 'drag-element', }, { type: 'collapse-expand', trigger: 'dblclick', enable: (event: IElementDragEvent) => event.targetType === 'combo' && event.target.id === 'combo1', }, { type: 'collapse-expand', trigger: 'click', enable: (event: IElementDragEvent) => event.targetType === 'combo' && event.target.id === 'combo2', }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/bug-tooltip-resize.ts ================================================ import { Graph } from '@antv/g6'; export const bugTooltipResize: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], edges: [ { source: 'node1', target: 'node2' }, { source: 'node1', target: 'node3' }, { source: 'node1', target: 'node4' }, { source: 'node2', target: 'node3' }, { source: 'node3', target: 'node4' }, { source: 'node4', target: 'node5' }, ], }, layout: { type: 'grid', }, plugins: [ { type: 'tooltip', style: { ['.tooltip']: { transition: 'none', }, }, }, ], }); await graph.render(); bugTooltipResize.form = (panel) => { let width = 500; return [ panel.add( { resize: () => { const newWidth = width === 500 ? 300 : 500; width = newWidth; document.querySelector('#container')!.style.width = `${newWidth}px`; graph.resize(); graph.fitView(); }, }, 'resize', ), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/canvas-cursor.ts ================================================ import { Graph } from '@antv/g6'; export const canvasCursor: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }], }, cursor: 'progress', }); await graph.render(); canvasCursor.form = (panel) => { return [ panel .add({ cursor: 'progress' }, 'cursor', [ 'auto', 'default', 'none', 'context-menu', 'help', 'pointer', 'progress', 'wait', 'cell', 'crosshair', 'text', 'vertical-text', 'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab', 'grabbing', 'all-scroll', 'col-resize', 'row-resize', 'n-resize', 'e-resize', 's-resize', 'w-resize', 'ne-resize', 'nw-resize', 'se-resize', 'sw-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'zoom-in', 'zoom-out', ]) .onChange((cursor: any) => { graph.setOptions({ cursor }); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-fishbone.ts ================================================ import type { TextStyleProps } from '@antv/g'; import { Text } from '@antv/g'; import { BaseTransform, BaseTransformOptions, CategoricalPalette, DrawData, ExtensionCategory, Graph, register, RuntimeContext, treeToGraphData, } from '@antv/g6'; const data = { id: '克服拖延', children: [ { id: '完美主义情结', children: [{ id: '正确评估事情难度' }, { id: '先完成,再完善' }, { id: 'Just do it' }] }, { id: '提高专注度', children: [{ id: '番茄工作法' }, { id: '限时、限量,一次只做一件事' }, { id: '提高抗干扰能力,减少打断' }], }, { id: '设定清晰的任务管理流程', children: [ { id: '设立完成事项的优先级' }, { id: '拆解具体可执行的目标' }, { id: '收集-整理-排序-执行反馈-总结' }, ], }, { id: '建立积极反馈', children: [{ id: '做喜欢的事情' }, { id: '精神激励' }, { id: '物质激励' }], }, { id: '放松、享受', children: [{ id: '注重过程而非结果' }, { id: '靠需求驱动而非焦虑' }, { id: '接受、理解' }], }, ], }; interface AssignColorByBranchOptions extends BaseTransformOptions { colors?: CategoricalPalette; } export const caseFishbone: TestCase = async (context) => { let textShape: Text | null; const measureText = (style: TextStyleProps) => { if (!textShape) textShape = new Text({ style }); textShape.attr(style); return textShape.getBBox().width; }; class AssignColorByBranch extends BaseTransform { static defaultOptions: Partial = { colors: [ '#1783FF', '#F08F56', '#D580FF', '#00C9C9', '#7863FF', '#DB9D0D', '#60C42D', '#FF80CA', '#2491B3', '#17C76F', ], }; constructor(context: RuntimeContext, options: AssignColorByBranchOptions) { super(context, Object.assign({}, AssignColorByBranch.defaultOptions, options)); } beforeDraw(input: DrawData): DrawData { const nodes = this.context.model.getNodeData(); if (nodes.length === 0) return input; let colorIndex = 0; const dfs = (nodeId: string, color?: string) => { const node = nodes.find((datum) => datum.id == nodeId); if (!node) return; node.style ||= {}; node.style.color = color || this.options.colors[colorIndex++ % this.options.colors.length]; node.children?.forEach((childId) => dfs(childId, node.style?.color as string)); }; nodes.filter((node) => node.depth === 1).forEach((rootNode) => dfs(rootNode.id)); return input; } } class ArrangeEdgeZIndex extends BaseTransform { public beforeDraw(input: DrawData): DrawData { const { model } = this.context; const { nodes, edges } = model.getData(); const oneLevelNodes = nodes.filter((node) => node.depth === 1); const oneLevelNodeIds = oneLevelNodes.map((node) => node.id); edges.forEach((edge) => { if (oneLevelNodeIds.includes(edge.target)) { edge.style ||= {}; edge.style.zIndex = oneLevelNodes.length - oneLevelNodes.findIndex((node) => node.id === edge.target); } }); return input; } } register(ExtensionCategory.TRANSFORM, 'assign-color-by-branch', AssignColorByBranch); register(ExtensionCategory.TRANSFORM, 'arrange-edge-z-index', ArrangeEdgeZIndex); const getNodeSize = (id: string, depth: number) => { const FONT_FAMILY = 'system-ui, sans-serif'; return depth === 0 ? [measureText({ text: id, fontSize: 24, fontWeight: 'bold', fontFamily: FONT_FAMILY }) + 80, 58] : depth === 1 ? [measureText({ text: id, fontSize: 18, fontFamily: FONT_FAMILY }) + 50, 42] : [0, 30]; }; const graph = new Graph({ ...context, autoFit: 'view', padding: 10, data: treeToGraphData(data), node: { type: 'rect', style: (d) => { const style = { radius: 8, size: getNodeSize(d.id, d.depth!), labelText: d.id, labelPlacement: 'right', }; if (d.depth === 0) { Object.assign(style, { fill: '#EFF0F0', labelFill: '#262626', labelFontWeight: 'bold', labelFontSize: 24, labelOffsetY: 8, labelPlacement: 'center', }); } else if (d.depth === 1) { Object.assign(style, { labelFontSize: 18, labelFill: '#fff', labelFillOpacity: 0.9, labelOffsetY: 3, labelPlacement: 'center', fill: d.style?.color, }); } else { Object.assign(style, { fill: 'transparent', labelFontSize: 16, labeFill: '#262626', }); } return style; }, }, edge: { type: 'polyline', style: { lineWidth: 3, stroke: function (data) { return (this.getNodeData(data.target).style!.color as string) || '#99ADD1'; }, }, }, layout: { type: 'fishbone', direction: 'LR', hGap: 40, vGap: 60, }, behaviors: ['zoom-canvas', 'drag-canvas'], transforms: ['assign-color-by-branch', 'arrange-edge-z-index'], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-fund-flow.ts ================================================ import data from '@@/dataset/decision-tree.json'; import type { RectStyleProps as GRectStyleProps, TextStyleProps as GTextStyleProps } from '@antv/g'; import { Rect as GRect, Group, Text as GText } from '@antv/g'; import type { BadgeStyleProps, LabelStyleProps, NodeData, RectStyleProps, TreeData } from '@antv/g6'; import { Badge, CommonEvent, ExtensionCategory, Graph, Label, Rect, register, treeToGraphData } from '@antv/g6'; export const caseFundFlow: TestCase = async (context) => { const COLORS: Record = { B: '#1783FF', R: '#F46649', Y: '#DB9D0D', G: '#60C42D', DI: '#A7A7A7', }; const GREY_COLOR = '#CED4D9'; class TreeNode extends Rect { get data() { return this.context.model.getNodeLikeDatum(this.id) as Record; } get childrenData() { return this.context.model.getChildrenData(this.id); } protected getLabelStyle(attributes: Required): LabelStyleProps { const [width, height] = this.getSize(attributes); return { x: -width / 2 + 8, y: -height / 2 + 16, text: this.data.name, fontSize: 12, opacity: 0.85, fill: '#000', cursor: 'pointer', }; } protected getPriceStyle(attributes: Required): GTextStyleProps { const [width, height] = this.getSize(attributes); return { x: -width / 2 + 8, y: height / 2 - 8, text: this.data.label, fontSize: 16, fill: '#000', opacity: 0.85, }; } protected drawPriceShape(attributes: Required, container: Group) { const priceStyle = this.getPriceStyle(attributes); this.upsert('price', GText, priceStyle, container); } protected getCurrencyStyle(attributes: Required): GTextStyleProps { const [, height] = this.getSize(attributes); return { x: this.shapeMap['price'].getLocalBounds().max[0] + 4, y: height / 2 - 8, text: this.data.currency, fontSize: 12, fill: '#000', opacity: 0.75, }; } protected drawCurrencyShape(attributes: Required, container: Group) { const currencyStyle = this.getCurrencyStyle(attributes); this.upsert('currency', GText, currencyStyle, container); } protected getPercentStyle(attributes: Required): GTextStyleProps { const [width, height] = this.getSize(attributes); return { x: width / 2 - 4, y: height / 2 - 8, text: `${((Number(this.data.variableValue) || 0) * 100).toFixed(2)}%`, fontSize: 12, textAlign: 'right', fill: COLORS[this.data.status], }; } protected drawPercentShape(attributes: Required, container: Group) { const percentStyle = this.getPercentStyle(attributes); this.upsert('percent', GText, percentStyle, container); } protected getTriangleStyle(attributes: Required): LabelStyleProps { const percentMinX = this.shapeMap['percent'].getLocalBounds().min[0]; const [, height] = this.getSize(attributes); return { fill: COLORS[this.data.status], x: this.data.variableUp ? percentMinX - 18 : percentMinX, y: height / 2 - 16, fontFamily: 'iconfont', fontSize: 16, text: '\ue62d', transform: this.data.variableUp ? [] : [['rotate', 180]], }; } protected drawTriangleShape(attributes: Required, container: Group) { const triangleStyle = this.getTriangleStyle(attributes); this.upsert('triangle', Label, triangleStyle, container); } protected getVariableStyle(attributes: Required): GTextStyleProps { const [, height] = this.getSize(attributes); return { fill: '#000', fontSize: 12, opacity: 0.45, text: this.data.variableName, textAlign: 'right', x: this.shapeMap['triangle'].getLocalBounds().min[0] - 4, y: height / 2 - 8, }; } protected drawVariableShape(attributes: Required, container: Group) { const variableStyle = this.getVariableStyle(attributes); this.upsert('variable', GText, variableStyle, container); } protected getCollapseStyle(attributes: Required): BadgeStyleProps | false { if (this.childrenData.length === 0) return false; const { collapsed } = attributes; const [width, height] = this.getSize(attributes); return { backgroundFill: '#fff', backgroundHeight: 16, backgroundLineWidth: 1, backgroundRadius: 0, backgroundStroke: GREY_COLOR, backgroundWidth: 16, cursor: 'pointer', fill: GREY_COLOR, fontSize: 16, text: collapsed ? '+' : '-', textAlign: 'center', textBaseline: 'middle', x: width / 2, y: 0, }; } protected drawCollapseShape(attributes: Required, container: Group) { const collapseStyle = this.getCollapseStyle(attributes); const btn = this.upsert('collapse', Badge, collapseStyle, container); if (btn && !Reflect.has(btn, '__bind__')) { Reflect.set(btn, '__bind__', true); btn.addEventListener(CommonEvent.CLICK, () => { const { collapsed } = this.attributes; const graph = this.context.graph; if (collapsed) graph.expandElement(this.id); else graph.collapseElement(this.id); }); } } protected getProcessBarStyle(attributes: Required): GRectStyleProps { const { rate, status } = this.data; // @ts-ignore const { radius } = attributes; const color = COLORS[status]; const percent = `${Number(rate) * 100}%`; const [width, height] = this.getSize(attributes); return { x: -width / 2, y: height / 2 - 4, width: width, height: 4, radius: [0, 0, radius, radius], fill: `linear-gradient(to right, ${color} ${percent}, ${GREY_COLOR} ${percent})`, }; } protected drawProcessBarShape(attributes: Required, container: Group) { const processBarStyle = this.getProcessBarStyle(attributes); this.upsert('process-bar', GRect, processBarStyle, container); } protected getKeyStyle(attributes: Required): GRectStyleProps { const keyStyle = super.getKeyStyle(attributes); return { ...keyStyle, fill: '#fff', lineWidth: 1, stroke: GREY_COLOR, }; } public render(attributes: Required = this.parsedAttributes, container: Group) { super.render(attributes, container); this.drawPriceShape(attributes, container); this.drawCurrencyShape(attributes, container); this.drawPercentShape(attributes, container); this.drawTriangleShape(attributes, container); this.drawVariableShape(attributes, container); this.drawProcessBarShape(attributes, container); this.drawCollapseShape(attributes, container); } } register(ExtensionCategory.NODE, 'tree-node', TreeNode); const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data, { getNodeData: (datum: TreeData, depth: number) => { if (!datum.style) datum.style = {}; datum.style.collapsed = depth >= 2; if (!datum.children) return datum as NodeData; const { children, ...restDatum } = datum; return { ...restDatum, children: children.map((child) => child.id) } as NodeData; }, }), node: { type: 'tree-node', style: { size: [202, 60], ports: [{ placement: 'left' }, { placement: 'right' }], radius: 4, }, }, edge: { type: 'cubic-horizontal', style: { stroke: GREY_COLOR, }, }, layout: { type: 'indented', direction: 'LR', dropCap: false, indent: 300, getHeight: () => 60, }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-indented-tree.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import type { BaseStyleProps, DisplayObject, DisplayObjectConfig, Group, RectStyleProps, TextStyleProps, } from '@antv/g'; import { Text as GText, Rect } from '@antv/g'; import type { BadgeStyleProps, BaseBehaviorOptions, BaseNodeStyleProps, Element, ID, IElementDragEvent, IPointerEvent, LabelStyleProps, Node, NodeData, Point, PolylineStyleProps, Prefix, RuntimeContext, Vector2, } from '@antv/g6'; import { Badge, BaseBehavior, BaseNode, CommonEvent, ExtensionCategory, Graph, NodeEvent, Polyline, idOf, register, subStyleProps, treeToGraphData, } from '@antv/g6'; export const caseIndentedTree: TestCase = async (context) => { const rootId = data.id; const COLORS = [ '#5B8FF9', '#F6BD16', '#5AD8A6', '#945FB9', '#E86452', '#6DC8EC', '#FF99C3', '#1E9493', '#FF9845', '#5D7092', ]; let textShape: GText | null; const measureText = (text: TextStyleProps) => { if (!textShape) textShape = new GText({ style: text }); textShape.attr(text); return textShape.getBBox().width; }; interface IndentedNodeStyleProps extends BaseNodeStyleProps { showIcon: boolean; color: string; } const TreeEvent = { COLLAPSE_EXPAND: 'collapse-expand', ADD_CHILD: 'add-child', }; class IndentedNode extends BaseNode { static defaultStyleProps: Partial = { ports: [ { key: 'in', placement: 'right-bottom', }, { key: 'out', placement: 'left-bottom', }, ], }; constructor(options: DisplayObjectConfig) { Object.assign(options.style!, IndentedNode.defaultStyleProps); super(options); } protected get childrenData() { return this.context!.model.getChildrenData(this.id); } protected getKeyStyle(attributes: Required): RectStyleProps { const [width, height] = this.getSize(attributes); const keyStyle = super.getKeyStyle(attributes); return { width, height, ...keyStyle, fill: 'transparent', }; } protected drawKeyShape(attributes: Required, container: Group): Rect | undefined { const keyStyle = this.getKeyStyle(attributes); return this.upsert('key', Rect, keyStyle, container); } protected getLabelStyle(attributes: Required): false | LabelStyleProps { if (attributes.label === false || !attributes.labelText) return false; return subStyleProps(this.getGraphicStyle(attributes), 'label'); } private drawIconArea(attributes: Required, container: Group) { const [, h] = this.getSize(attributes); const iconAreaStyle = { fill: 'transparent', height: 30, width: 12, x: -6, y: h, zIndex: -1, }; this.upsert('icon-area', Rect, iconAreaStyle, container); } private forwardEvent(target: DisplayObject | undefined, type: string, listener: (event: any) => void) { if (target && !Reflect.has(target, '__bind__')) { Reflect.set(target, '__bind__', true); target.addEventListener(type, listener); } } private getCountStyle(attributes: Required): false | BadgeStyleProps { const { collapsed, color } = attributes; if (collapsed) { const [, height] = this.getSize(attributes); return { backgroundFill: color, cursor: 'pointer', fill: '#fff', fontSize: 8, padding: [0, 10], text: `${this.childrenData.length}`, textAlign: 'center', y: height + 8, }; } return false; } private drawCountShape(attributes: Required, container: Group) { const countStyle = this.getCountStyle(attributes); const btn = this.upsert('count', Badge, countStyle, container); this.forwardEvent(btn, CommonEvent.CLICK, (event: IPointerEvent) => { event.stopPropagation(); this.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { id: this.id, collapsed: false, }); }); } private isShowCollapse(attributes: Required) { return !attributes.collapsed && this.childrenData.length > 0; } private getCollapseStyle(attributes: Required): false | BadgeStyleProps { const { showIcon, color } = attributes; if (!this.isShowCollapse(attributes)) return false; const [, height] = this.getSize(attributes); return { visibility: showIcon ? 'visible' : 'hidden', backgroundFill: color, backgroundHeight: 12, backgroundWidth: 12, cursor: 'pointer', fill: '#fff', fontFamily: 'iconfont', fontSize: 8, text: '\ue6e4', textAlign: 'center', x: -1, // half of edge line width y: height + 8, }; } private drawCollapseShape(attributes: Required, container: Group) { const iconStyle = this.getCollapseStyle(attributes); const btn = this.upsert('collapse-expand', Badge, iconStyle, container); this.forwardEvent(btn, CommonEvent.CLICK, (event: IPointerEvent) => { event.stopPropagation(); this.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { id: this.id, collapsed: !attributes.collapsed, }); }); } private getAddStyle(attributes: Required): false | BadgeStyleProps { const { collapsed, showIcon } = attributes; if (collapsed) return false; const [, height] = this.getSize(attributes); const color = '#ddd'; const lineWidth = 1; return { visibility: showIcon ? 'visible' : 'hidden', backgroundFill: '#fff', backgroundHeight: 12, backgroundLineWidth: lineWidth, backgroundStroke: color, backgroundWidth: 12, cursor: 'pointer', fill: color, fontFamily: 'iconfont', text: '\ue664', textAlign: 'center', x: -1, y: height + (this.isShowCollapse(attributes) ? 22 : 8), }; } private drawAddShape(attributes: Required, container: Group) { const addStyle = this.getAddStyle(attributes); const btn = this.upsert('add', Badge, addStyle, container); this.forwardEvent(btn, CommonEvent.CLICK, (event: IPointerEvent) => { event.stopPropagation(); this.context.graph.emit(TreeEvent.ADD_CHILD, { id: this.id }); }); } public render(attributes: Required = this.parsedAttributes, container: Group = this): void { super.render(attributes, container); this.drawCountShape(attributes, container); this.drawIconArea(attributes, container); this.drawCollapseShape(attributes, container); this.drawAddShape(attributes, container); } } class IndentedEdge extends Polyline { protected getControlPoints(attributes: Required) { const [sourcePoint, targetPoint] = this.getEndpoints(attributes, false); const [sx] = sourcePoint; const [, ty] = targetPoint; return [[sx, ty]] as Point[]; } } interface CollapseExpandTreeOptions extends BaseBehaviorOptions { onCreateChild?: (parent: ID) => NodeData; } class CollapseExpandTree extends BaseBehavior { constructor(context: RuntimeContext, options: CollapseExpandTreeOptions) { super(context, options); this.bindEvents(); } public update(options: Partial) { this.unbindEvents(); super.update(options); this.bindEvents(); } private bindEvents() { const { graph } = this.context; graph.on(NodeEvent.POINTER_ENTER, this.showIcon); graph.on(NodeEvent.POINTER_LEAVE, this.hideIcon); graph.on(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); graph.on(TreeEvent.ADD_CHILD, this.addChild); } private unbindEvents() { const { graph } = this.context; graph.off(NodeEvent.POINTER_ENTER, this.showIcon); graph.off(NodeEvent.POINTER_LEAVE, this.hideIcon); graph.off(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); graph.off(TreeEvent.ADD_CHILD, this.addChild); } private status = 'idle'; private showIcon = (event: IPointerEvent) => { this.setIcon(event, true); }; private hideIcon = (event: IPointerEvent) => { this.setIcon(event, false); }; private setIcon = (event: IPointerEvent, show: boolean) => { if (this.status !== 'idle') return; const { target } = event; const id = target.id; const { graph, element } = this.context; graph.updateNodeData([{ id, style: { showIcon: show } }]); element!.draw({ animation: false, silence: true }); }; private onCollapseExpand = async (event: any) => { this.status = 'busy'; const { id, collapsed } = event; const { graph } = this.context; await graph.frontElement(id); if (collapsed) await graph.collapseElement(id); else await graph.expandElement(id); this.status = 'idle'; }; private addChild = async (event: any) => { this.status = 'busy'; const { onCreateChild = (id) => { const parent = this.context.graph.getNodeData(id); const { x = 0, y = 0 } = parent.style || {}; return { id: `${Date.now()}`, style: { x, y, labelText: 'new node' } }; }, } = this.options; const { graph } = this.context; const datum = onCreateChild(event.id); const parent = graph.getNodeData(event.id); graph.addNodeData([datum]); graph.addEdgeData([{ source: event.id, target: datum.id }]); graph.updateNodeData([ { id: event.id, children: [...(parent.children || []), datum.id], style: { collapsed: false } }, ]); await graph.render(); this.status = 'idle'; }; } interface DragBranchOptions extends BaseBehaviorOptions, Prefix<'shadow', BaseStyleProps> { enable?: boolean | ((event: IElementDragEvent) => boolean); } /** * 支持拖拽节点到其他节点下作为子节点 * * Support dragging nodes to other nodes as child nodes */ class DragBranch extends BaseBehavior { constructor(context: RuntimeContext, options: DragBranchOptions) { super(context, options); this.bindEvents(); } public update(options: Partial) { this.unbindEvents(); super.update(options); this.bindEvents(); } private bindEvents() { const { graph } = this.context; graph.on(NodeEvent.DRAG_START, this.onDragStart); graph.on(NodeEvent.DRAG, this.onDrag); graph.on(NodeEvent.DRAG_END, this.onDragEnd); graph.on(NodeEvent.DRAG_ENTER, this.onDragEnter); graph.on(NodeEvent.DRAG_LEAVE, this.onDragLeave); } private unbindEvents() { const { graph } = this.context; graph.off(NodeEvent.DRAG_START, this.onDragStart); graph.off(NodeEvent.DRAG, this.onDrag); graph.off(NodeEvent.DRAG_END, this.onDragEnd); graph.off(NodeEvent.DRAG_ENTER, this.onDragEnter); graph.off(NodeEvent.DRAG_LEAVE, this.onDragLeave); } private enable = true; private validate(event: IElementDragEvent) { if (this.destroyed) return false; const { enable = (evt) => evt.target.id !== rootId } = this.options; if (typeof enable === 'function') return enable(event); return !!enable; } private shadow?: Rect; private createShadow(target: Element) { const shadowStyle = subStyleProps(this.options, 'shadow'); const positionStyle = target.getShape('label').getBBox(); this.shadow = new Rect({ style: { pointerEvents: 'none', fill: '#F3F9FF', fillOpacity: 0.5, stroke: '#1890FF', strokeOpacity: 0.9, lineDash: [5, 5], ...shadowStyle, ...positionStyle, }, }); this.context.canvas.appendChild(this.shadow); } private moveShadow(offset: Vector2) { if (!this.shadow) return; const [dx, dy] = offset; this.shadow.translate(dx, dy); } private destroyShadow() { this.shadow?.remove(); this.shadow = undefined; } private child?: Node; private parent?: Node; private onDragStart = (event: IElementDragEvent) => { this.enable = this.validate(event); if (!this.enable) return; const { target } = event; this.child = target as Node; this.createShadow(target); }; private getDelta(event: IElementDragEvent): Vector2 { const zoom = this.context.graph.getZoom(); return [event.dx / zoom, event.dy / zoom]; } private onDrag = (event: IElementDragEvent) => { if (!this.enable) return; const delta = this.getDelta(event); this.moveShadow(delta); }; private onDragEnd = () => { this.destroyShadow(); if (this.child === undefined || this.parent === undefined) return; const { graph } = this.context; const childId = this.child.id; const parentId = this.parent.id; const originalParent = graph.getParentData(childId, 'tree') as NodeData; // 前后父节点不应该相同 // The previous and current parent nodes should not be the same if (idOf(originalParent) === parentId) return; // 新的父节点不应该是当前节点的子节点 // The new parent node should not be a child node of the current node const ancestors = graph.getAncestorsData(parentId, 'tree'); if (ancestors.some((ancestor) => ancestor.id === childId)) return; const edges = graph .getEdgeData() .filter((edge) => edge.target === childId) .map(idOf); graph.removeEdgeData(edges); graph.updateNodeData([ { id: idOf(originalParent), children: originalParent?.children?.filter((child) => child !== childId) }, ]); const modifiedParent = graph.getNodeData(parentId); graph.updateNodeData([{ id: parentId, children: [...(modifiedParent.children || []), childId] }]); graph.addEdgeData([{ source: parentId, target: childId }]); graph.render(); }; private onDragEnter = (event: IElementDragEvent) => { const { graph, element } = this.context; const targetId = event.target.id; if (targetId === this.child?.id || targetId === rootId) { if (targetId === rootId) this.parent = event.target as Node; return; } this.parent = event.target as Node; graph.updateNodeData([{ id: targetId, states: ['selected'] }]); element!.draw({ animation: false, silence: true }); }; private onDragLeave = (event: IElementDragEvent) => { const { graph, element } = this.context; const targetId = event.target.id; this.parent = undefined; graph.updateNodeData([{ id: targetId, states: [] }]); element!.draw({ animation: false, silence: true }); }; } register(ExtensionCategory.NODE, 'indented', IndentedNode); register(ExtensionCategory.EDGE, 'indented', IndentedEdge); register(ExtensionCategory.BEHAVIOR, 'collapse-expand-tree', CollapseExpandTree); register(ExtensionCategory.BEHAVIOR, 'drag-branch', DragBranch); const graph: Graph = new Graph({ ...context, data: treeToGraphData(data), x: 100, y: 100, node: { type: 'indented', style: { size: (d) => [measureText({ text: d.id, fontSize: 12 }) + 6, 20], labelBackground: true, labelBackgroundRadius: 0, labelBackgroundFill: (d) => (d.id === rootId ? '#576286' : '#fff'), labelFill: (d) => (d.id === rootId ? '#fff' : '#666'), labelText: (d) => d.style?.labelText || d.id, labelTextAlign: (d) => (d.id === rootId ? 'center' : 'left'), labelTextBaseline: 'top', color: (datum: NodeData) => { const depth = graph.getAncestorsData(datum.id, 'tree').length - 1; return COLORS[depth % COLORS.length] || '#576286'; }, }, state: { selected: { lineWidth: 0, labelFill: '#40A8FF', labelBackground: true, labelFontWeight: 'normal', labelBackgroundFill: '#e8f7ff', labelBackgroundRadius: 10, }, }, }, edge: { type: 'indented', style: { radius: 16, lineWidth: 2, sourcePort: 'out', targetPort: 'in', stroke: (datum) => { const depth = graph.getAncestorsData(datum.source, 'tree').length; return COLORS[depth % COLORS.length]; }, }, }, layout: { type: 'indented', direction: 'LR', isHorizontal: true, indent: 40, getHeight: () => 20, getVGap: () => 10, }, behaviors: [ 'scroll-canvas', 'drag-branch', 'collapse-expand-tree', { type: 'click-select', enable: (event: IPointerEvent) => event.targetType === 'node' && event.target.id !== rootId, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-language-tree.ts ================================================ import { labelPropagation } from '@antv/algorithm'; import type { Element, IPointerEvent, NodeData } from '@antv/g6'; import { Graph } from '@antv/g6'; import data from '../dataset/language-tree.json'; export const caseLanguageTree: TestCase = async (context) => { const size = (node: NodeData) => Math.max(...(node.style?.size as [number, number, number])); const graph = new Graph({ ...context, data: { ...data, nodes: labelPropagation(data).clusters.flatMap((cluster) => cluster.nodes), }, node: { style: { label: true, labelBackground: true, labelPadding: [0, 4], labelText: (d) => d.id, icon: true, iconSrc: 'https://gw.alipayobjects.com/zos/basement_prod/012bcf4f-423b-4922-8c24-32a89f8c41ce.svg', }, state: { inactive: { fill: '#E0E0E0', fillOpacity: 1, icon: false, label: false, labelBackground: false, }, }, palette: { field: (d) => d.clusterId, }, }, edge: { style: { stroke: '#E0E0E0', endArrow: true, }, }, layout: { type: 'd3-force', link: { distance: (edge: any) => size(edge.source) + size(edge.target), }, collide: { radius: (node: NodeData) => size(node) + 1, }, manyBody: { strength: (node: NodeData) => -4 * size(node), }, animation: false, }, transforms: [ { key: 'map-node-size', type: 'map-node-size', scale: 'log', }, ], behaviors: [ 'drag-canvas', 'zoom-canvas', function () { return { key: 'hover-activate', type: 'hover-activate', enable: true, degree: 1, inactiveState: 'inactive', onHover: (e: IPointerEvent) => { this.frontElement(e.target.id); e.view.setCursor('pointer'); }, onHoverEnd: (e: IPointerEvent) => { e.view.setCursor('default'); }, }; }, { key: 'fix-element-size', type: 'fix-element-size', enable: true, node: [{ shape: 'label' }], }, { key: 'auto-adapt-label', type: 'auto-adapt-label', }, ], plugins: [{ type: 'background', background: '#fff' }], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-mindmap.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import type { DisplayObject, DisplayObjectConfig, Group, RectStyleProps, TextStyleProps } from '@antv/g'; import { Rect, Text } from '@antv/g'; import type { BadgeStyleProps, BaseBehaviorOptions, BaseNodeStyleProps, CubicStyleProps, DrawData, ID, IPointerEvent, Node, NodeData, PathArray, RuntimeContext, } from '@antv/g6'; import { Badge, BaseBehavior, BaseNode, BaseTransform, CommonEvent, CubicHorizontal, ExtensionCategory, Graph, idOf, NodeEvent, positionOf, register, treeToGraphData, } from '@antv/g6'; export const caseMindmap: TestCase = async (context) => { const COLORS = [ '#1783FF', '#00C9C9', '#F08F56', '#D580FF', '#7863FF', '#DB9D0D', '#60C42D', '#FF80CA', '#2491B3', '#17C76F', ]; const RootNodeStyle = { fill: '#EFF0F0', labelFill: '#262626', labelFontSize: 16, labelFontWeight: 600, labelPlacement: 'center', ports: [{ placement: 'right' }, { placement: 'left' }], radius: 4, }; const NodeStyle = { fill: 'transparent', labelPlacement: 'center', labelFontSize: 12, ports: [{ placement: 'right-bottom' }, { placement: 'left-bottom' }], }; const TreeEvent = { COLLAPSE_EXPAND: 'collapse-expand', ADD_CHILD: 'add-child', }; let textShape: Text | null; const measureText = (text: TextStyleProps) => { if (!textShape) textShape = new Text({ style: text }); textShape.attr(text); return textShape.getBBox().width; }; const getNodeWidth = (nodeId: ID, isRoot: boolean) => { return isRoot ? measureText({ text: nodeId, fontSize: RootNodeStyle.labelFontSize }) + 20 : measureText({ text: nodeId, fontSize: NodeStyle.labelFontSize }) + 30; }; interface MindmapNodeStyleProps extends BaseNodeStyleProps { color: string; showIcon: boolean; direction: 'left' | 'right'; } class MindmapNode extends BaseNode { static defaultStyleProps: Partial = { showIcon: false, }; constructor(options: DisplayObjectConfig) { Object.assign(options.style!, MindmapNode.defaultStyleProps); super(options); } get childrenData() { return this.context.model.getChildrenData(this.id); } get rootId() { return idOf(this.context.model.getRootsData()[0]); } private isShowCollapse(attributes: Required) { const { collapsed, showIcon } = attributes; return !collapsed && showIcon && this.childrenData.length > 0; } protected getCollapseStyle(attributes: Required): BadgeStyleProps | false { const { showIcon, color, direction } = attributes; if (!this.isShowCollapse(attributes)) return false; const [width, height] = this.getSize(attributes); return { backgroundFill: color, backgroundHeight: 12, backgroundWidth: 12, cursor: 'pointer', fill: '#fff', fontFamily: 'iconfont', fontSize: 8, text: '\ue6e4', textAlign: 'center', transform: direction === 'left' ? [['rotate', 90]] : [['rotate', -90]], visibility: showIcon ? 'visible' : 'hidden', x: direction === 'left' ? -6 : width + 6, y: height, }; } protected drawCollapseShape(attributes: Required, container: Group) { const iconStyle = this.getCollapseStyle(attributes); const btn = this.upsert('collapse-expand', Badge, iconStyle, container); this.forwardEvent(btn, CommonEvent.CLICK, (event: IPointerEvent) => { event.stopPropagation(); this.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { id: this.id, collapsed: !attributes.collapsed, }); }); } protected getCountStyle(attributes: Required): BadgeStyleProps | false { const { collapsed, color, direction } = attributes; const count = this.context.model.getDescendantsData(this.id).length; if (!collapsed || count === 0) return false; const [width, height] = this.getSize(attributes); return { backgroundFill: color, backgroundHeight: 12, backgroundWidth: 12, cursor: 'pointer', fill: '#fff', fontSize: 8, text: count.toString(), textAlign: 'center', x: direction === 'left' ? -6 : width + 6, y: height, }; } protected drawCountShape(attributes: Required, container: Group) { const countStyle = this.getCountStyle(attributes); const btn = this.upsert('count', Badge, countStyle, container); this.forwardEvent(btn, CommonEvent.CLICK, (event: IPointerEvent) => { event.stopPropagation(); this.context.graph.emit(TreeEvent.COLLAPSE_EXPAND, { id: this.id, collapsed: false, }); }); } protected getAddStyle(attributes: Required): BadgeStyleProps | false { const { collapsed, showIcon, direction } = attributes; if (collapsed || !showIcon) return false; const [width, height] = this.getSize(attributes); const color = '#ddd'; const offsetX = this.isShowCollapse(attributes) ? 24 : 12; const isRoot = this.id === this.rootId; return { backgroundFill: '#fff', backgroundHeight: 12, backgroundLineWidth: 1, backgroundStroke: color, backgroundWidth: 12, cursor: 'pointer', fill: color, fontFamily: 'iconfont', fontSize: 8, text: '\ue664', textAlign: 'center', x: isRoot ? width + 12 : direction === 'left' ? -offsetX : width + offsetX, y: isRoot ? height / 2 : height, }; } protected getAddBarStyle(attributes: Required): RectStyleProps | false { const { collapsed, showIcon, direction, color = COLORS[0] } = attributes; if (collapsed || !showIcon) return false; const [width, height] = this.getSize(attributes); const offsetX = this.isShowCollapse(attributes) ? 12 : 0; const isRoot = this.id === this.rootId; const HEIGHT = 2; const WIDTH = 6; return { cursor: 'pointer', fill: direction === 'left' ? `linear-gradient(180deg, #fff 20%, ${color})` : `linear-gradient(0deg, #fff 20%, ${color})`, height: HEIGHT, width: WIDTH, x: isRoot ? width : direction === 'left' ? -offsetX - WIDTH : width + offsetX, y: isRoot ? height / 2 - HEIGHT / 2 : height - HEIGHT / 2, zIndex: -1, }; } protected drawAddShape(attributes: Required, container: Group) { const addStyle = this.getAddStyle(attributes); const addBarStyle = this.getAddBarStyle(attributes); this.upsert('add-bar', Rect, addBarStyle, container); const btn = this.upsert('add', Badge, addStyle, container); this.forwardEvent(btn, CommonEvent.CLICK, (event: IPointerEvent) => { event.stopPropagation(); this.context.graph.emit(TreeEvent.ADD_CHILD, { id: this.id, direction: attributes.direction }); }); } private forwardEvent(target: DisplayObject | undefined, type: string, listener: (event: any) => void) { if (target && !Reflect.has(target, '__bind__')) { Reflect.set(target, '__bind__', true); target.addEventListener(type, listener); } } protected getKeyStyle(attributes: Required): RectStyleProps { const [width, height] = this.getSize(attributes); const keyShape = super.getKeyStyle(attributes); return { width, height, ...keyShape }; } protected drawKeyShape(attributes: Required, container: Group) { const keyStyle = this.getKeyStyle(attributes); return this.upsert('key', Rect, keyStyle, container); } public render(attributes: Required = this.parsedAttributes, container: Group = this) { super.render(attributes, container); this.drawCollapseShape(attributes, container); this.drawAddShape(attributes, container); this.drawCountShape(attributes, container); } } class MindmapEdge extends CubicHorizontal { get rootId() { return idOf(this.context.model.getRootsData()[0]); } protected getKeyPath(attributes: Required): PathArray { const path = super.getKeyPath(attributes); const isRoot = this.targetNode.id === this.rootId; const labelWidth = getNodeWidth(this.targetNode.id, isRoot); const [, tp] = this.getEndpoints(attributes); const sign = this.sourceNode.getCenter()[0] < this.targetNode.getCenter()[0] ? 1 : -1; return [...path, ['L', tp[0] + labelWidth * sign, tp[1]]] as PathArray; } } interface CollapseExpandTreeOptions extends BaseBehaviorOptions { onCreateChild?: (parent: ID) => NodeData; } class CollapseExpandTree extends BaseBehavior { constructor(context: RuntimeContext, options: Partial) { super(context, options); this.bindEvents(); } update(options: Partial) { this.unbindEvents(); super.update(options); this.bindEvents(); } bindEvents() { const { graph } = this.context; graph.on(NodeEvent.POINTER_ENTER, this.showIcon); graph.on(NodeEvent.POINTER_LEAVE, this.hideIcon); graph.on(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); graph.on(TreeEvent.ADD_CHILD, this.addChild); } unbindEvents() { const { graph } = this.context; graph.off(NodeEvent.POINTER_ENTER, this.showIcon); graph.off(NodeEvent.POINTER_LEAVE, this.hideIcon); graph.off(TreeEvent.COLLAPSE_EXPAND, this.onCollapseExpand); graph.off(TreeEvent.ADD_CHILD, this.addChild); } status = 'idle'; showIcon = (event: IPointerEvent) => { this.setIcon(event, true); }; hideIcon = (event: IPointerEvent) => { this.setIcon(event, false); }; setIcon = (event: IPointerEvent, show: boolean) => { if (this.status !== 'idle') return; const { target } = event; const id = target.id; const { graph, element } = this.context; graph.updateNodeData([{ id, style: { showIcon: show } }]); element!.draw({ animation: false, silence: true }); }; onCollapseExpand = async (event: any) => { this.status = 'busy'; const { id, collapsed } = event; const { graph } = this.context; await graph.frontElement(id); if (collapsed) await graph.collapseElement(id); else await graph.expandElement(id); this.status = 'idle'; }; addChild = async (event: any) => { this.status = 'busy'; const { onCreateChild = () => { const currentTime = new Date(Date.now()).toLocaleString(); return { id: `New Node in ${currentTime}` }; }, } = this.options; const { graph } = this.context; const datum = onCreateChild(event.id); const parent = graph.getNodeData(event.id); graph.addNodeData([datum]); graph.addEdgeData([{ source: event.id, target: datum.id }]); graph.updateNodeData([ { id: event.id, children: [...(parent.children || []), datum.id], style: { collapsed: false, showIcon: false }, }, ]); await graph.render(); await graph.focusElement(datum.id); this.status = 'idle'; }; } class AssignElementColor extends BaseTransform { beforeDraw(data: DrawData): DrawData { const { nodes = [], edges = [] } = this.context.graph.getData(); const nodeColorMap = new Map(); let colorIndex = 0; const dfs = (nodeId: string, color?: string) => { const node = nodes.find((datum) => datum.id == nodeId)!; if (!node) return; if (node.depth !== 0) { const nodeColor = color || COLORS[colorIndex++ % COLORS.length]; node.style ||= {}; node.style.color = nodeColor; nodeColorMap.set(nodeId, nodeColor); } node.children?.forEach((childId) => dfs(childId, node.style!.color as string)); }; nodes.filter((node) => node.depth === 0).forEach((rootNode) => dfs(rootNode.id)); edges.forEach((edge) => { edge.style ||= {}; edge.style.stroke = nodeColorMap.get(edge.target); }); return data; } } register(ExtensionCategory.NODE, 'mindmap', MindmapNode); register(ExtensionCategory.EDGE, 'mindmap', MindmapEdge); register(ExtensionCategory.BEHAVIOR, 'collapse-expand-tree', CollapseExpandTree); register(ExtensionCategory.TRANSFORM, 'assign-element-color', AssignElementColor); const rootId = data.id; const getNodeSide = (nodeData: NodeData, parentData?: NodeData): 'center' | 'left' | 'right' => { if (!parentData) return 'center'; const nodePositionX = positionOf(nodeData)[0]; const parentPositionX = positionOf(parentData)[0]; return parentPositionX > nodePositionX ? 'left' : 'right'; }; const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { type: 'mindmap', style: function (d: NodeData) { const direction = getNodeSide(d, this.getParentData(idOf(d), 'tree')); const isRoot = idOf(d) === rootId; return { direction, labelText: idOf(d), size: [getNodeWidth(idOf(d), isRoot), 30], // 通过设置节点标签背景来扩大节点的交互区域 // Enlarge the interactive area of the node by setting label background labelBackground: true, labelBackgroundFill: 'transparent', labelPadding: direction === 'left' ? [2, 0, 10, 40] : [2, 40, 10, 0], ...(isRoot ? RootNodeStyle : NodeStyle), }; }, }, edge: { type: 'mindmap', style: { lineWidth: 2 }, }, layout: { type: 'mindmap', direction: 'H', getHeight: () => 30, getWidth: (node: NodeData) => getNodeWidth(node.id, node.id === rootId), getVGap: () => 6, getHGap: () => 60, animation: false, }, behaviors: ['drag-canvas', 'zoom-canvas', 'collapse-expand-tree'], transforms: ['assign-element-color'], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-org-chart.ts ================================================ import data from '@@/dataset/organization-chart.json'; import type { RectStyleProps as GRectStyleProps, Group } from '@antv/g'; import type { BaseBehaviorOptions, IViewportEvent, LabelStyleProps, RectStyleProps, RuntimeContext } from '@antv/g6'; import { Badge, BaseBehavior, ExtensionCategory, Graph, GraphEvent, Label, Rect, register } from '@antv/g6'; enum ZoomLevel { OVERVIEW = 'overview', DETAILED = 'detailed', } const statusColors: Record = { online: '#17BEBB', busy: '#E36397', offline: '#B7AD99', }; const DEFAULT_LEVEL = ZoomLevel.DETAILED; export const caseOrgChart: TestCase = async (context) => { /** * Draw a chart node with different ui based on the zoom level. */ class ChartNode extends Rect { protected get data() { return this.context.model.getElementDataById(this.id).data as Record; } protected get level() { return this.data.level || DEFAULT_LEVEL; } protected getLabelStyle(): false | LabelStyleProps { const text = this.data.name as string; const labelStyle = this.level === ZoomLevel.OVERVIEW ? { fill: '#fff', fontSize: 20, fontWeight: 600, textAlign: 'center', transform: [['translate', 0, 0]], } : { fill: '#2078B4', fontSize: 14, fontWeight: 400, textAlign: 'left', transform: [['translate', -65, -15]], }; return { text, ...labelStyle } as LabelStyleProps; } protected getKeyStyle(attributes: Required): GRectStyleProps { return { ...super.getKeyStyle(attributes), fill: this.level === ZoomLevel.OVERVIEW ? statusColors[this.data.status] : '#fff', }; } protected getPositionStyle(attributes: Required): false | LabelStyleProps { if (this.level === ZoomLevel.OVERVIEW) return false; return { text: this.data.position, fontSize: 8, fontWeight: 400, textTransform: 'uppercase', fill: '#343f4a', textAlign: 'left', transform: [['translate', -65, 0]], }; } protected drawPositionShape(attributes: Required, container: Group) { const positionStyle = this.getPositionStyle(attributes); this.upsert('position', Label, positionStyle, container); } protected getStatusStyle(attributes: Required): false | LabelStyleProps { if (this.level === ZoomLevel.OVERVIEW) return false; return { text: this.data.status, fontSize: 8, textAlign: 'left', transform: [['translate', 40, -16]], padding: [0, 4], fill: '#fff', backgroundFill: statusColors[this.data.status as string], }; } protected drawStatusShape(attributes: Required, container: Group) { const statusStyle = this.getStatusStyle(attributes); this.upsert('status', Badge, statusStyle, container); } protected getPhoneStyle(attributes: Required): false | LabelStyleProps { if (this.level === ZoomLevel.OVERVIEW) return false; return { text: this.data.phone, fontSize: 8, fontWeight: 300, textAlign: 'left', transform: [['translate', -65, 20]], }; } protected drawPhoneShape(attributes: Required, container: Group) { const style = this.getPhoneStyle(attributes); this.upsert('phone', Label, style, container); } public render(attributes: Required = this.parsedAttributes, container: Group = this): void { super.render(attributes, container); this.drawPositionShape(attributes, container); this.drawStatusShape(attributes, container); this.drawPhoneShape(attributes, container); } } /** * Implement a level of detail rendering, which will show different details based on the zoom level. */ class LevelOfDetail extends BaseBehavior { private prevLevel: ZoomLevel = DEFAULT_LEVEL; private levels: Record = { [ZoomLevel.OVERVIEW]: [0, 0.6], [ZoomLevel.DETAILED]: [0.6, Infinity], }; constructor(context: RuntimeContext, options: BaseBehaviorOptions) { super(context, options); this.bindEvents(); } private updateZoomLevel = async (e: IViewportEvent) => { if ('scale' in e.data) { const scale = e.data.scale!; const level = Object.entries(this.levels).find( ([key, [min, max]]) => scale > min && scale <= max, )?.[0] as ZoomLevel; if (level && this.prevLevel !== level) { const { graph } = this.context; graph.updateNodeData((prev) => prev.map((node) => ({ ...node, data: { ...node.data, level } }))); await graph.draw(); this.prevLevel = level; } } }; private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.AFTER_TRANSFORM, this.updateZoomLevel); } private unbindEvents() { const { graph } = this.context; graph.off(GraphEvent.AFTER_TRANSFORM, this.updateZoomLevel); } public destroy() { this.unbindEvents(); super.destroy(); } } register(ExtensionCategory.NODE, 'chart-node', ChartNode); register(ExtensionCategory.BEHAVIOR, 'level-of-detail', LevelOfDetail); const graph = new Graph({ ...context, data, node: { type: 'chart-node', style: { labelPlacement: 'center', lineWidth: 1, ports: [{ placement: 'top' }, { placement: 'bottom' }], radius: 2, shadowBlur: 10, shadowColor: '#e0e0e0', shadowOffsetX: 3, size: [150, 60], stroke: '#C0C0C0', }, }, edge: { type: 'polyline', style: { router: { type: 'orth', }, stroke: '#C0C0C0', }, }, layout: { type: 'dagre', }, autoFit: 'view', behaviors: ['level-of-detail', 'zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-radial-dendrogram.ts ================================================ import data from '@@/dataset/flare.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const caseRadialDendrogram: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { style: { size: 14, labelText: (d) => d.id, labelBackground: true, }, state: { active: { fill: '#00C9C9', }, }, }, edge: { type: 'cubic-radial', style: { lineWidth: 2, }, state: { active: { stroke: '#009999', }, }, }, layout: { type: 'dendrogram', radial: true, nodeSep: 30, rankSep: 200, preLayout: false, }, behaviors: [ 'drag-canvas', 'zoom-canvas', 'drag-element', { key: 'hover-activate', type: 'hover-activate', degree: 5, direction: 'in', inactiveState: 'inactive', }, ], transforms: ['place-radial-labels'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-unicorns-investors.ts ================================================ import type { Element, ElementDatum, IElementEvent, IPointerEvent, NodeData } from '@/src'; import { Graph } from '@/src'; /** * Inspired by https://graphcommons.com/graphs/be8bc972-5b26-4f5c-837d-a34704f33a9e * Network Map of 🦄 Unicorns and Their 💰Investors * 1086 nodes, 1247 edges * * 10 VC firms in Silicon Valley funded 82% of all unicorns, 98% of all exited unicorns. Data from CB Insights, updated March 2020. * @param context - context * @returns - graph */ export const caseUnicornsInvestors: TestCase = async (context) => { const data = await fetch('https://assets.antv.antgroup.com/g6/unicorns-investors.json').then((res) => res.json()); const size = (node: NodeData) => Math.max(...(node.style?.size as [number, number, number])); const graph = new Graph({ ...context, data, autoFit: 'view', node: { style: { label: true, labelText: (d) => d.data?.name, labelBackground: true, icon: true, iconText: (d) => (d.data?.type === 'Investor' ? '💰' : '🦄️'), fill: (d) => (d.data?.type === 'Investor' ? '#6495ED' : '#FFA07A'), }, state: { inactive: { fillOpacity: 0.3, icon: false, label: false, }, }, }, edge: { style: { label: false, labelText: (d) => d.data?.type, labelBackground: true, }, state: { active: { label: true, }, inactive: { strokeOpacity: 0, }, }, }, layout: { type: 'd3-force', link: { distance: (edge: any) => { size(edge.source) + size(edge.target); }, }, collide: { radius: (node: NodeData) => size(node) }, manyBody: { strength: (node: NodeData) => -4 * size(node) }, animation: false, iterations: 20, }, transforms: [ { type: 'map-node-size', scale: 'linear', maxSize: 60, minSize: 20, mapLabelSize: [12, 16], }, ], behaviors: [ 'drag-canvas', 'zoom-canvas', function () { return { key: 'hover-activate', type: 'hover-activate', enable: (e: IPointerEvent) => e.targetType === 'node', degree: 1, inactiveState: 'inactive', onHover: (e: IPointerEvent) => { this.frontElement(e.target.id); e.view.setCursor('pointer'); }, onHoverEnd: (e: IPointerEvent) => { e.view.setCursor('default'); }, }; }, { type: 'fix-element-size', enable: true }, 'auto-adapt-label', ], plugins: [ { type: 'tooltip', position: 'right', enable: (e: IElementEvent) => e.targetType === 'node', getContent: (e: IElementEvent, items: ElementDatum[]) => { const { type, name } = items[0].data as { type: string; name: string }; const color = type === 'Investor' ? '#6495ED' : '#FFA07A'; return `
${type}
${name}
`; }, style: { '.tooltip': { padding: '2px 8px', 'border-radius': '8px', }, }, }, ], animation: false, }); performance.mark('render-start'); await graph.render(); performance.mark('render-end'); performance.measure('render', 'render-start', 'render-end'); console.log(performance.getEntriesByType('measure')[0].duration); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/case-why-do-cats.ts ================================================ import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Plugin as PluginRoughCanvasRenderer } from '@antv/g-plugin-rough-canvas-renderer'; import type { ComboData, GraphData, NodeData } from '@antv/g6'; import { BaseLayout, ExtensionCategory, Graph, register } from '@antv/g6'; import { hierarchy, pack } from '@antv/vendor/d3-hierarchy'; export const caseWhyDoCats: TestCase = async (context) => { const style = document.createElement('style'); style.innerHTML = ` @font-face { font-family: 'handwriting'; src: url('https://mass-office.alipay.com/huamei_koqzbu/afts/file/sgUeRbI3d-IAAAAAAAAAABAADnV5AQBr/font.woff2') format('woff2'); }`; document.head.appendChild(style); function getColor(id: string) { const colors = [ '#8dd3c7', '#bebada', '#fb8072', '#80b1d3', '#fdb462', '#b3de69', '#fccde5', '#d9d9d9', '#bc80bd', '#ccebc5', '#ffed6f', ]; const index = parseInt(id); return colors[index % colors.length]; } type RowDatum = { animal: string; id: string; id_num: string; index_value: string; leaf: string; parentId: string; remainder: string; start_sentence: string; sum_index_value: string; text: string; }; const rawData: RowDatum[] = await fetch('https://assets.antv.antgroup.com/g6/cat-hierarchy.json').then((res) => res.json(), ); const topics = [ 'cat.like', 'cat.hate', 'cat.love', 'cat.not.like', 'cat.afraid_of', 'cat.want.to', 'cat.scared.of', 'cat.not.want_to', ]; const graphData = rawData.reduce( (acc, row) => { const { id } = row; topics.forEach((topic) => { if (id.startsWith(topic)) { if (id === topic) { acc.nodes.push({ ...row, depth: 1 }); } else { acc.nodes.push({ ...row, depth: 2, actualParentId: topic }); } } }); return acc; }, { nodes: [], edges: [], combos: [] } as Required, ); class BubbleLayout extends BaseLayout { id = 'bubble-layout'; public async execute(model: GraphData, options?: any): Promise { const { nodes = [] } = model; const { width = 0, height = 0 } = { ...this.options, ...options }; const root = hierarchy({ id: 'root' }, (datum) => { const { id } = datum; if (id === 'root') return nodes.filter((node) => node.depth === 1); else if (datum.depth === 2) return []; else return nodes.filter((node) => node.actualParentId === id); }); root.sum((d: any) => (+d.index_value || 0.01) ** 0.5 * 100); pack() .size([width, height]) .padding((node) => { return node.depth === 0 ? 20 : 2; })(root); const result: Required = { nodes: [], edges: [], combos: [] }; root.descendants().forEach((node) => { const { data: { id }, x, y, // @ts-expect-error r is exist r, } = node; if (node.depth >= 1) result.nodes.push({ id, style: { x, y, size: r * 2 } }); }); return result; } } register(ExtensionCategory.LAYOUT, 'bubble-layout', BubbleLayout); const graph = new Graph({ ...context, animation: false, data: graphData, renderer: (layer) => { const renderer = new CanvasRenderer(); if (layer === 'main') { renderer.registerPlugin(new PluginRoughCanvasRenderer()); } return renderer; }, node: { style: (d) => { const id_num = d.id_num as string; const color = getColor(id_num); if (d.depth === 1) { return { fill: 'none', stroke: color, labelFontFamily: 'handwriting', labelFontSize: 20, labelText: d.id.replace('cat.', '').replace(/\.|_/g, ' '), labelTextTransform: 'capitalize', lineWidth: 1, zIndex: -1, }; } const text = d.text as string; const diameter = d.style!.size as number; return { fill: color, fillOpacity: 0.7, stroke: color, fillStyle: 'cross-hatch', hachureGap: 1.5, iconFontFamily: 'handwriting', iconFontSize: (diameter / text.length) * 2, iconText: diameter > 20 ? d.text : '', iconFontWeight: 'bold', iconStroke: color, iconLineWidth: 2, lineWidth: (diameter || 20) ** 0.5 / 5, }; }, }, layout: { type: 'bubble-layout', }, plugins: [ { type: 'tooltip', getContent: (event: any, items: NodeData[]) => { return `${items[0].id.replace(/\.|_/g, ' ')}`; }, }, ], behaviors: [{ type: 'drag-canvas', enable: true }, 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/common-graph.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const commonGraph: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { fill: (d) => (d.id === '33' ? '#d4414c' : '#2f363d'), }, }, layout: { type: 'd3-force' }, behaviors: ['zoom-canvas', 'drag-canvas'], plugins: [], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/controller-viewport.ts ================================================ import { Graph } from '@antv/g6'; export const controllerViewport: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 200, y: 200 } }, { id: 'node-2', style: { x: 300, y: 300 } }, ], edges: [{ source: 'node-1', target: 'node-2' }], }, }); await graph.render(); controllerViewport.form = (panel) => { const animation = { duration: 500 }; const config = { translateBy: () => graph.translateBy([10, 10], animation), translateTo: () => graph.translateTo([0, 0], animation), rotateBy: () => graph.rotateBy(45, animation), rotateTo: () => graph.rotateTo(0, animation), zoomBy: () => graph.zoomBy(1.1, animation), zoomTo: () => graph.zoomTo(1, animation), }; return [ panel.add(config, 'translateBy'), panel.add(config, 'translateTo'), panel.add(config, 'rotateBy'), panel.add(config, 'rotateTo'), panel.add(config, 'zoomBy'), panel.add(config, 'zoomTo'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/demo-autosize-element-label.ts ================================================ import type { FixShapeConfig } from '@antv/g6'; import { Graph } from '@antv/g6'; const mockText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit'; const fixLabelConfig: FixShapeConfig = { shape: (shapes) => shapes.find((shape) => shape.parentElement?.className === 'label' && shape.className === 'text')!, fields: ['fontSize', 'lineHeight'], }; export const demoAutosizeElementLabel: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', combo: 'combo1', style: { x: 100, y: 100 } }, { id: 'node2', style: { x: 300, y: 300 } }, ], edges: [{ id: 'edge1', source: 'node1', target: 'node2' }], combos: [{ id: 'combo1', label: mockText }], }, node: { style: { labelMaxLines: 3, labelMaxWidth: '200%', labelText: mockText, labelWordWrap: true, }, }, edge: { style: { labelMaxLines: 2, labelMaxWidth: '60%', labelText: mockText, labelWordWrap: true, }, }, combo: { style: { labelMaxLines: 1, labelMaxWidth: '200%', labelText: mockText, labelWordWrap: true, }, }, behaviors: [ { type: 'fix-element-size', key: 'fix-element-size', enable: true, state: undefined, node: [fixLabelConfig], edge: [fixLabelConfig], combo: [fixLabelConfig], }, 'zoom-canvas', 'drag-canvas', 'drag-element', ], autoFit: 'center', }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/demo-found-flow.ts ================================================ import { Rect as GRect, Text as GText } from '@antv/g'; import { Badge, CommonEvent, ExtensionCategory, Graph, Label, Rect, register, treeToGraphData } from '@antv/g6'; import data from '../dataset/decision-tree.json'; const COLORS = { B: '#1783FF', R: '#F46649', Y: '#DB9D0D', G: '#60C42D', DI: '#A7A7A7', }; const GREY_COLOR = '#CED4D9'; class TreeNode extends Rect { get data() { return this.context.model.getNodeLikeDatum(this.id); } get childrenData() { return this.context.model.getChildrenData(this.id); } getLabelStyle(attributes: any) { const [width, height] = this.getSize(attributes); return { x: -width / 2 + 8, y: -height / 2 + 16, text: (this.data.name || '') as string, fontSize: 12, opacity: 0.85, fill: '#000', cursor: 'pointer' as const, }; } getPriceStyle(attributes: any) { const [width, height] = this.getSize(attributes); return { x: -width / 2 + 8, y: height / 2 - 8, text: (this.data.label || '') as string, fontSize: 16, fill: '#000', opacity: 0.85, }; } drawPriceShape(attributes: any, container: any) { const priceStyle = this.getPriceStyle(attributes); this.upsert('price', GText, priceStyle, container); } getCurrencyStyle(attributes: any) { const [, height] = this.getSize(attributes); return { x: this.shapeMap['price'].getLocalBounds().max[0] + 4, y: height / 2 - 8, text: (this.data.currency || '') as string, fontSize: 12, fill: '#000', opacity: 0.75, }; } drawCurrencyShape(attributes: any, container: any) { const currencyStyle = this.getCurrencyStyle(attributes); this.upsert('currency', GText, currencyStyle, container); } getPercentStyle(attributes: any) { const [width, height] = this.getSize(attributes); return { x: width / 2 - 4, y: height / 2 - 8, text: `${((Number(this.data.variableValue) || 0) * 100).toFixed(2)}%`, fontSize: 12, textAlign: 'right' as const, fill: COLORS[this.data.status as keyof typeof COLORS], }; } drawPercentShape(attributes: any, container: any) { const percentStyle = this.getPercentStyle(attributes); this.upsert('percent', GText, percentStyle, container); } getTriangleStyle(attributes: any) { const percentMinX = this.shapeMap['percent'].getLocalBounds().min[0]; const [, height] = this.getSize(attributes); return { fill: COLORS[this.data.status as keyof typeof COLORS], x: this.data.variableUp ? percentMinX - 18 : percentMinX, y: height / 2 - 16, fontFamily: 'iconfont', fontSize: 16, text: '\ue62d', transform: this.data.variableUp ? ('' as any) : ('rotate(180deg)' as any), }; } drawTriangleShape(attributes: any, container: any) { const triangleStyle = this.getTriangleStyle(attributes); this.upsert('triangle', Label, triangleStyle, container); } getVariableStyle(attributes: any) { const [, height] = this.getSize(attributes); return { fill: '#000', fontSize: 12, opacity: 0.45, text: (this.data.variableName || '') as string, textAlign: 'right' as const, x: this.shapeMap['triangle'].getLocalBounds().min[0] - 4, y: height / 2 - 8, }; } drawVariableShape(attributes: any, container: any) { const variableStyle = this.getVariableStyle(attributes); this.upsert('variable', GText, variableStyle, container); } getCollapseStyle(attributes: any) { if (this.childrenData.length === 0) return false; const { collapsed } = attributes; const [width] = this.getSize(attributes); return { backgroundFill: '#fff', backgroundHeight: 16, backgroundLineWidth: 1, backgroundRadius: 0, backgroundStroke: GREY_COLOR, backgroundWidth: 16, cursor: 'pointer' as const, fill: GREY_COLOR, fontSize: 16, text: collapsed ? '+' : '-', textAlign: 'center' as const, textBaseline: 'middle' as const, x: width / 2, y: 0, }; } drawCollapseShape(attributes: any, container: any) { const collapseStyle = this.getCollapseStyle(attributes); const btn = this.upsert('collapse', Badge, collapseStyle, container); if (btn && !Reflect.has(btn, '__bind__')) { Reflect.set(btn, '__bind__', true); btn.addEventListener(CommonEvent.CLICK, () => { const { collapsed } = this.attributes; const graph = this.context.graph; if (collapsed) graph.expandElement(this.id); else graph.collapseElement(this.id); }); } } getProcessBarStyle(attributes: any) { const { rate, status } = this.data; const { radius } = attributes; const color = COLORS[status as keyof typeof COLORS]; const percent = `${Number(rate) * 100}%`; const [width, height] = this.getSize(attributes); return { x: -width / 2, y: height / 2 - 4, width: width, height: 4, radius: [0, 0, radius, radius], fill: `linear-gradient(to right, ${color} ${percent}, ${GREY_COLOR} ${percent})`, }; } drawProcessBarShape(attributes: any, container: any) { const processBarStyle = this.getProcessBarStyle(attributes); this.upsert('process-bar', GRect, processBarStyle, container); } getKeyStyle(attributes: any) { const keyStyle = super.getKeyStyle(attributes); return { ...keyStyle, fill: '#fff', lineWidth: 1, stroke: GREY_COLOR, }; } render(attributes = this.parsedAttributes, container?: any) { super.render(attributes, container); this.drawPriceShape(attributes, container); this.drawCurrencyShape(attributes, container); this.drawPercentShape(attributes, container); this.drawTriangleShape(attributes, container); this.drawVariableShape(attributes, container); this.drawProcessBarShape(attributes, container); this.drawCollapseShape(attributes, container); } } register(ExtensionCategory.NODE, 'tree-node', TreeNode); export const demoFoundFlow: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data, { getNodeData: (datum: any, depth: number) => { if (!datum.style) datum.style = {}; datum.style.collapsed = depth >= 2; if (!datum.children) return datum; const { children, ...restDatum } = datum; return { ...restDatum, children: children.map((child: any) => child.id) }; }, }), node: { type: 'tree-node', style: { size: [202, 60], ports: [{ placement: 'left' }, { placement: 'right' }], radius: 4, }, }, edge: { type: 'cubic-horizontal', style: { stroke: GREY_COLOR, }, }, layout: { type: 'indented', direction: 'LR', dropCap: false, indent: 300, getHeight: () => 60, preLayout: false, }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/demo-supply-chains.ts ================================================ import type { EdgeData } from '@antv/g6'; import { Graph } from '@antv/g6'; const urls: Record = { container: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*ldqFQLTPIwwAAAAAAAAAAAAADmJ7AQ/original', warehouse: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*ldqFQLTPIwwAAAAAAAAAAAAADmJ7AQ/original', factory: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*k2sASKsW0EQAAAAAAAAAAAAADmJ7AQ/original', store: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*Z3HuQbWRrncAAAAAAAAAAAAADmJ7AQ/original', }; const data = { nodes: [ { id: 'assembly-line-1', data: { type: 'factory', name: 'Assembly Line 1', }, combo: 'factory', style: { x: 50, y: 150 }, }, { id: 'assembly-line-2', data: { type: 'factory', name: 'Assembly Line 2', }, combo: 'factory', style: { x: 50, y: 300 }, }, { id: 'warehouse-1-container-1', combo: 'warehouse-1', data: { type: 'container', name: 'Container 1', }, style: { x: 275, y: 175 }, }, { id: 'warehouse-1-container-2', combo: 'warehouse-1', data: { type: 'container', name: 'Container 2', }, style: { x: 275, y: 115 }, }, { id: 'warehouse-1-container-3', combo: 'warehouse-1', data: { type: 'container', name: 'Container 3', }, style: { x: 275, y: 55 }, }, { id: 'warehouse-2-container-1', combo: 'warehouse-2', data: { type: 'container', name: 'Container 1', }, style: { x: 275, y: 255 }, }, { id: 'warehouse-2-container-2', combo: 'warehouse-2', data: { type: 'container', name: 'Container 2', }, style: { x: 275, y: 315 }, }, { id: 'warehouse-2-container-3', combo: 'warehouse-2', data: { type: 'container', name: 'Container 3', }, style: { x: 275, y: 370 }, }, { id: 'warehouse-2-container-4', combo: 'warehouse-2', data: { type: 'container', name: 'Container 4', }, style: { x: 275, y: 430 }, }, { id: 'store', data: { type: 'store', name: 'Store', }, style: { x: 500, y: 225 }, }, ], edges: [ { id: 'g15', source: 'assembly-line-1', target: 'warehouse-1-container-1', data: { transportation: 'airplane' }, }, { id: 'g16', source: 'assembly-line-1', target: 'warehouse-1-container-2', data: { transportation: 'truck' }, }, { id: 'g17', source: 'assembly-line-1', target: 'warehouse-1-container-3', data: { transportation: 'truck' }, }, { id: 'g18', source: 'assembly-line-2', target: 'warehouse-2-container-1', data: { transportation: 'train' }, }, { id: 'g19', source: 'assembly-line-2', target: 'warehouse-2-container-2', data: { transportation: 'train' }, }, { id: 'g20', source: 'assembly-line-2', target: 'warehouse-2-container-3', data: { transportation: 'truck' }, }, { id: 'g21', source: 'assembly-line-2', target: 'warehouse-2-container-4', data: { transportation: 'truck' }, }, { id: 'g22', source: 'warehouse-1-container-1', target: 'store', data: { transportation: 'truck' }, }, { id: 'g23', source: 'warehouse-1-container-2', target: 'store', data: { transportation: 'train' }, }, { id: 'g24', source: 'warehouse-1-container-3', target: 'store', data: { transportation: 'train' }, }, { id: 'g25', source: 'warehouse-2-container-1', target: 'store', data: { transportation: 'train' }, }, { id: 'g26', source: 'warehouse-2-container-2', target: 'store', data: { transportation: 'train' }, }, { id: 'g27', source: 'warehouse-2-container-3', target: 'store', data: { transportation: 'train' }, }, { id: 'g28', source: 'warehouse-2-container-4', target: 'store', data: { transportation: 'train' }, }, ], combos: [ { id: 'factory', data: { type: 'factory', name: 'Factory', }, }, { id: 'warehouse-1', combo: 'warehouse', data: { type: 'warehouse', name: 'Warehouse 1', }, }, { id: 'warehouse-2', combo: 'warehouse', data: { type: 'warehouse', name: 'Warehouse 2', }, }, { id: 'warehouse', data: { type: 'warehouse', name: 'Warehouses', }, }, ], }; export const demoSupplyChains: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: (datum) => ({ labelText: datum.data!.name, labelFill: '#fff', labelBackground: true, labelBackgroundFill: '#00C9C9', labelBackgroundOpacity: 1, labelBackgroundRadius: 10, labelPadding: [-3, 5], labelFontSize: 10, iconSrc: urls[datum.data?.type as string], iconWidth: 24, iconHeight: 24, size: 30, fill: 'transparent', }), }, edge: { style: (datum) => { const index = ['airplane', 'truck', 'train'].indexOf(datum.data?.transportation as string); const color = ['#5B8FF9', '#61DDAA', '#F6903D'][index]; return { stroke: color, labelText: datum.data!.transportation, labelFill: '#fff', labelBackground: true, labelBackgroundFill: color, labelBackgroundOpacity: 1, labelBackgroundRadius: 10, labelPadding: [-3, 5], labelFontSize: 8, lineDash: 0, ...datum.style, }; }, }, combo: { type: 'rect', style: (datum) => ({ collapsedMarker: true, collapsedMarkerHeight: 32, collapsedMarkerSrc: urls[datum.data?.type as string], collapsedMarkerWidth: 32, fill: datum.style?.collapsed ? ' transparent' : '#99add1', fillOpacity: 0.05, labelBackground: true, labelBackgroundFill: '#00C9C9', labelBackgroundOpacity: 1, labelBackgroundRadius: 10, labelFill: '#fff', labelFontSize: 10, labelPadding: [-3, 5], labelText: datum.data!.name, stroke: datum.style?.collapsed ? ' transparent' : '#99add1', badge: !!datum.style?.collapsed, badges: [ { placement: 'top-right', backgroundFill: '#FF7474', padding: [0, 4], text: '可展开', fontSize: 6, fill: '#fff', offsetX: -10, }, ], }), state: { active: { halo: false, stroke: '#5AD8A6', lineWidth: 2, }, }, }, behaviors: ['collapse-expand', 'drag-element'], transforms: [ { key: 'process-parallel-edges', type: 'process-parallel-edges', distance: 20, style: (edges: EdgeData[]) => ({ stroke: '#99add1', lineWidth: 3, lineDash: [2, 2], labelText: edges.length.toString(), labelBackgroundFill: '#99add1', }), }, ], }); graph.render(); demoSupplyChains.form = (panel) => { const config = { mode: 'bundle', }; return [ panel .add(config, 'Parallel Edge Mode', ['bundle', 'merge']) .name('node-1 type') .onChange((value: string) => { graph.updateTransform({ key: 'process-parallel-edges', mode: value, }); graph.render(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-change-type.ts ================================================ import { Graph } from '@antv/g6'; export const elementChangeType: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', type: 'rect', style: { x: 100, y: 100, fill: 'transparent', stroke: '#1783ff' } }, { id: 'node-2', style: { x: 200, y: 100 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); await graph.render(); elementChangeType.form = (panel) => { const config = { node1: 'rect', node2: 'circle', }; const options = { Circle: 'circle', Rect: 'rect', Diamond: 'diamond', Star: 'star' }; const changeType = (id: string, type: string) => { graph.updateNodeData([{ id, type }]); graph.draw(); }; return [ panel .add(config, 'node1', options) .name('node-1 type') .onChange((value: string) => changeType('node-1', value)), panel .add(config, 'node2', options) .name('node-2 type') .onChange((value: string) => changeType('node-2', value)), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-combo.ts ================================================ import { Graph } from '@antv/g6'; export const elementCombo: TestCase = async (context) => { const data = { nodes: [ { id: 'node-1', combo: 'combo-2', style: { x: 120, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 300, y: 200 } }, { id: 'node-3', combo: 'combo-1', style: { x: 200, y: 300 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, ], combos: [ { id: 'combo-1', type: 'rect', combo: 'combo-2', }, { id: 'combo-2', }, ], }; const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, }, }, combo: { style: { labelText: (d) => d.id, lineDash: 0, collapsedLineDash: [5, 5], }, }, behaviors: ['drag-element'], }); await graph.render(); const COMBO_TYPE = ['circle', 'rect']; const COLLAPSED_MARKER_TYPE = ['child-count', 'descendant-count', 'node-count', 'custom']; elementCombo.form = (panel) => { const config: Record = { combo2Type: 'circle', collapsedMarker: true, collapsedMarkerType: 'child-count', collapseCombo1: () => { graph.collapseElement('combo-1'); }, expandCombo1: () => { graph.expandElement('combo-1'); }, collapseCombo2: () => { graph.collapseElement('combo-2'); }, expandCombo2: () => { graph.expandElement('combo-2'); }, addRemoveNode: async () => { const node4 = graph.getNodeData('node-4'); if (node4) { graph.removeNodeData(['node-4']); } else { graph.addNodeData([ { id: 'node-4', combo: 'combo-2', style: { x: 100, y: 200, fill: 'pink' }, }, ]); } panels.at(-1)?.disable(); await graph.render(); panels.at(-1)?.enable(); }, }; const panels = [ panel.add(config, 'combo2Type', COMBO_TYPE).onChange((type: string) => { config.combo2Type = type; const combo2Data = graph.getComboData('combo-2'); graph.updateComboData([{ ...combo2Data, style: { ...combo2Data.style, type: config.combo2Type } }]); graph.render(); }), panel.add(config, 'collapsedMarker'), panel.add(config, 'collapsedMarkerType', COLLAPSED_MARKER_TYPE), panel.add(config, 'collapseCombo1'), panel.add(config, 'expandCombo1'), panel.add(config, 'collapseCombo2'), panel.add(config, 'expandCombo2'), panel.add(config, 'addRemoveNode'), ]; return panels; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-arrow.ts ================================================ import { idOf } from '@/src/utils/id'; import { Graph } from '@antv/g6'; export const elementEdgeArrow: TestCase = async (context) => { const edgeIds = [ 'default-arrow', 'triangle-arrow', 'simple-arrow', 'vee-arrow', 'circle-arrow', 'rect-arrow', 'diamond-arrow', 'triangleRect-arrow', ]; const data = { nodes: new Array(16).fill(0).map((_, i) => ({ id: `node${i + 1}` })), edges: edgeIds.map((id, i) => ({ id, source: `node${i * 2 + 1}`, target: `node${i * 2 + 2}`, })), }; const graph = new Graph({ ...context, data, edge: { type: 'line', // 👈🏻 Edge shape type. style: { labelText: (d) => d.id!, labelBackground: true, endArrow: true, endArrowType: (d: any) => idOf(d).toString().split('-')[0] as any, }, }, behaviors: ['drag-element'], layout: { type: 'grid', cols: 2, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-cubic-horizontal.ts ================================================ import data from '@@/dataset/element-edges.json'; import { Graph } from '@antv/g6'; export const elementEdgeCubicHorizontal: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { port: true, ports: [{ placement: 'left' }, { placement: 'right' }], }, }, edge: { type: 'cubic-horizontal', // 👈🏻 Edge shape type. style: { labelText: (d) => d.id!, labelBackground: true, endArrow: true, }, }, layout: { type: 'antv-dagre', begin: [50, 50], rankdir: 'LR', nodesep: 30, ranksep: 150, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-cubic-radial.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const elementEdgeCubicRadial: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), edge: { type: 'cubic-radial', }, layout: { type: 'dendrogram', radial: true, nodeSep: 30, rankSep: 200, preLayout: true, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-cubic-vertical.ts ================================================ import data from '@@/dataset/element-edges.json'; import { Graph } from '@antv/g6'; export const elementEdgeCubicVertical: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { port: true, ports: [{ placement: 'top' }, { placement: 'bottom' }], }, }, edge: { type: 'cubic-vertical', // 👈🏻 Edge shape type. style: { labelText: (d) => d.id!, labelBackground: true, endArrow: true, }, }, layout: { type: 'antv-dagre', begin: [50, 50], rankdir: 'TB', nodesep: 25, ranksep: 150, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-cubic.ts ================================================ import data from '@@/dataset/element-edges.json'; import { Graph } from '@antv/g6'; export const elementEdgeCubic: TestCase = async (context) => { const graph = new Graph({ ...context, data, edge: { type: 'cubic', // 👈🏻 Edge shape type. style: { labelText: (d) => d.id!, labelBackground: true, endArrow: true, }, }, layout: { type: 'radial', unitRadius: 220, linkDistance: 220, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-custom-arrow.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgeCustomArrow: TestCase = async (context) => { const data = { nodes: new Array(6).fill(0).map((_, i) => ({ id: `node${i + 1}` })), edges: [ { id: 'custom-arrow-1', source: 'node1', target: 'node2', style: { endArrowD: 'M-14,0 L-4,-4 L0,-14 L4,-4 L14,0 L4,4 L0,14 L-4,4 Z', endArrowOffset: 14, }, }, { id: 'custom-arrow-2', source: 'node3', target: 'node4', style: { endArrowD: 'M -6,-5 L -6,5 L 6,10 L 6,-10 Z', endArrowOffset: 20, }, }, { id: 'image-arrow', source: 'node5', target: 'node6', style: { endArrowSrc: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ', endArrowSize: 28, endArrowTransform: [['rotate', 90]], endArrowX: -14, endArrowY: -14, }, }, ], }; const graph = new Graph({ ...context, data, edge: { type: 'line', // 👈🏻 Edge shape type. style: { stroke: '#F6BD16', labelText: (d) => d.id!, labelBackground: true, endArrow: true, }, }, layout: { type: 'grid', cols: 2, }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-line.ts ================================================ import data from '@@/dataset/element-edges.json'; import { Graph } from '@antv/g6'; export const elementEdgeLine: TestCase = async (context) => { const graph = new Graph({ ...context, data, edge: { type: 'line', // 👈🏻 Edge shape type. style: { labelText: (d) => d.id!, labelBackground: true, endArrow: true, badge: true, badgeText: '\ue603', badgeFontFamily: 'iconfont', badgeBackgroundWidth: 12, badgeBackgroundHeight: 12, }, }, layout: { type: 'radial', unitRadius: 220, linkDistance: 220, preLayout: false, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-loop-curve.ts ================================================ import { idOf } from '@/src/utils/id'; import { Graph } from '@antv/g6'; export const elementEdgeLoopCurve: TestCase = async (context) => { const data = { nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3-ports' }, { id: 'node4-ports' }], edges: [ { id: 'loop-1', source: 'node1', target: 'node1', style: { placement: 'top' }, }, { id: 'loop-2', source: 'node1', target: 'node1', style: { placement: 'right' }, }, { id: 'loop-3', source: 'node1', target: 'node1', style: { placement: 'bottom' }, }, { id: 'loop-4', source: 'node1', target: 'node1', style: { placement: 'left' }, }, { id: 'loop-5', source: 'node2', target: 'node2', style: { placement: 'top-right' }, }, { id: 'loop-6', source: 'node2', target: 'node2', style: { placement: 'bottom-right' }, }, { id: 'loop-7', source: 'node2', target: 'node2', style: { placement: 'bottom-left' }, }, { id: 'loop-8', source: 'node2', target: 'node2', style: { placement: 'top-left' }, }, { id: 'loop-9', source: 'node3-ports', target: 'node3-ports', style: { sourcePort: 'port-top', targetPort: 'port-right', }, }, { id: 'loop-10', source: 'node4-ports', target: 'node4-ports', style: { sourcePort: 'port-right', targetPort: 'port-right', }, }, ], }; const graph = new Graph({ ...context, data, node: { type: 'rect', style: { size: [80, 30], labelBackground: true, port: (d) => idOf(d).toString().includes('ports'), portR: 3, ports: [ { key: 'port-top', placement: [0.7, 0], }, { key: 'port-right', placement: 'right', }, ], }, }, edge: { type: 'line', style: { endArrow: true, loopPlacement: (d) => d.style!.placement, }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-loop-polyline.ts ================================================ import { idOf } from '@/src/utils/id'; import { Graph } from '@antv/g6'; export const elementEdgeLoopPolyline: TestCase = async (context) => { const data = { nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3-ports' }, { id: 'node4-ports' }], edges: [ { id: 'loop-1', source: 'node1', target: 'node1', style: { placement: 'top' }, }, { id: 'loop-2', source: 'node1', target: 'node1', style: { placement: 'right' }, }, { id: 'loop-3', source: 'node1', target: 'node1', style: { placement: 'bottom' }, }, { id: 'loop-4', source: 'node1', target: 'node1', style: { placement: 'left' }, }, { id: 'loop-5', source: 'node2', target: 'node2', style: { placement: 'top-right' }, }, { id: 'loop-6', source: 'node2', target: 'node2', style: { placement: 'bottom-right' }, }, { id: 'loop-7', source: 'node2', target: 'node2', style: { placement: 'bottom-left' }, }, { id: 'loop-8', source: 'node2', target: 'node2', style: { placement: 'top-left' }, }, { id: 'loop-9', source: 'node3-ports', target: 'node3-ports', style: { sourcePort: 'port-top', targetPort: 'port-right', }, }, { id: 'loop-10', source: 'node4-ports', target: 'node4-ports', style: { sourcePort: 'port-right', targetPort: 'port-right', }, }, ], }; const graph = new Graph({ ...context, data, node: { type: 'rect', style: { size: [80, 30], port: (d) => idOf(d).toString().includes('ports'), portR: 3, ports: [ { key: 'port-top', placement: [0.7, 0], }, { key: 'port-right', placement: 'right', }, ], }, }, edge: { type: 'polyline', style: { endArrow: true, loopPlacement: (d) => d.style!.placement, }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-polyline-animation.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgePolylineAnimation: TestCase = async (context) => { const data = { nodes: [ { id: 'node-1', style: { x: 200, y: 200 } }, { id: 'node-2', style: { x: 350, y: 120 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }; const graph = new Graph({ ...context, data, edge: { type: 'polyline', }, behaviors: [{ type: 'drag-element' }], }); await graph.render(); elementEdgePolylineAnimation.form = (panel) => { const config = { radius: 0, enableRouter: false, controlPoints: false, }; const updateEdgeStyle = (id: string, attr: string, value: any) => { graph.updateEdgeData((prev) => { const edgeData = prev.find((edge: any) => edge.id === id)!; return [ ...prev.filter((edge: any) => edge.id !== id), { ...edgeData, style: { ...edgeData?.style, [attr]: value, }, }, ]; }); graph.render(); }; return [ panel.add(config, 'radius', 0, 100, 1).onChange((val: number) => { updateEdgeStyle('edge-1', 'radius', val); }), panel.add(config, 'enableRouter').onChange((val: boolean) => { updateEdgeStyle('edge-1', 'router', val); }), panel.add(config, 'controlPoints').onChange((val: boolean) => { updateEdgeStyle('edge-1', 'controlPoints', val ? [[300, 190]] : []); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-polyline-astar.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgePolylineAstar: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 100, y: 100 } }, { id: 'node-2', style: { x: 100, y: 200 } }, { id: 'node-3', style: { x: 100, y: 150 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', style: { router: { type: 'shortest-path', offset: 0, enableObstacleAvoidance: true, }, }, }, ], }, node: { type: 'rect', style: { size: 25, fill: '#f8f8f8', stroke: '#8b9baf', lineWidth: 1, }, }, edge: { type: 'polyline', style: { lineWidth: 1, }, }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-polyline-orth.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgePolylineOrth: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }, { id: '7' }, { id: '8' }, { id: '9' }, ], edges: [ { source: '0', target: '1' }, { source: '0', target: '2' }, { source: '1', target: '4' }, { source: '0', target: '3' }, { source: '3', target: '4' }, { source: '4', target: '5' }, { source: '4', target: '6' }, { source: '5', target: '7' }, { source: '5', target: '8' }, { source: '8', target: '9' }, { source: '2', target: '9' }, { source: '3', target: '9' }, ], }, layout: { type: 'antv-dagre', nodesep: 50, ranksep: 20, controlPoints: true, }, autoFit: 'view', node: { type: 'rect', style: { size: [60, 30], radius: 8, ports: [{ placement: 'top' }, { placement: 'bottom' }], }, }, edge: { type: 'polyline', style: { endArrow: true, endArrowSize: 8, lineWidth: 2, radius: 10, router: { type: 'orth', }, }, }, animation: false, behaviors: ['drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-polyline.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgePolyline: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-0', style: { x: 50, y: 40, labelText: 'Loop' } }, { id: 'node-0-1', style: { x: 150, y: 40, labelText: 'Loop', port: true, ports: [ { key: 'top', placement: [0, 0.5], r: 2, fill: '#31d0c6' }, { key: 'left', placement: [0.5, 0], r: 2, fill: '#31d0c6', }, ], }, }, { id: 'node-0-2', style: { x: 250, y: 40, labelText: 'Loop', port: true, ports: [{ key: 'top', placement: [0.5, 0], r: 2, fill: '#31d0c6' }], }, }, { id: 'node-1', style: { x: 50, y: 120, labelText: '1' }, }, { id: 'node-2', style: { x: 150, y: 75, labelText: '2' }, }, { id: 'node-3', style: { x: 50, y: 220, labelText: '3' }, }, { id: 'node-4', style: { x: 150, y: 175, labelText: '4' }, }, { id: 'control-point-1', type: 'circle', style: { x: 100, y: 175, size: 4 }, }, { id: 'node-5', style: { x: 50, y: 320, labelText: '5' }, }, { id: 'node-6', style: { x: 150, y: 275, labelText: '6' }, }, { id: 'control-point-2', type: 'circle', style: { x: 100, y: 300, size: 4 }, }, { id: 'node-7', style: { x: 50, y: 420, labelText: '7', ports: [{ key: 'top', placement: [0.3, 0], r: 2, fill: '#31d0c6' }] }, }, { id: 'node-8', style: { x: 150, y: 375, labelText: '8' }, }, { id: 'node-9', style: { x: 250, y: 420, labelText: '9' }, }, { id: 'node-10', style: { x: 350, y: 375, labelText: '10', size: 50 }, }, { id: 'control-point-3', type: 'circle', style: { x: 340, y: 390, size: 4 }, }, ], edges: [ { id: 'edge-1', source: 'node-0', target: 'node-0', style: { loopPlacement: 'top' }, }, { id: 'edge-2', source: 'node-0', target: 'node-0', style: { loopPlacement: 'bottom-right' }, }, { source: 'node-0-1', target: 'node-0-1', style: { sourcePort: 'top', targetPort: 'left', loopPlacement: 'bottom-left' }, }, { source: 'node-0-2', target: 'node-0-2', style: { sourcePort: 'top' }, }, { source: 'node-1', target: 'node-2', style: { router: { type: 'orth' } }, }, { source: 'node-3', target: 'node-4', style: { router: false, controlPoints: [[100, 175]] }, }, { source: 'node-5', target: 'node-6', style: { router: { type: 'orth' }, controlPoints: [[100, 300]] }, }, { source: 'node-7', target: 'node-8', style: { router: { type: 'orth' } }, }, { source: 'node-9', target: 'node-10', style: { router: { type: 'orth' }, controlPoints: [[340, 390]] }, }, ], }, node: { type: (d) => d.type || 'rect', style: { size: (d) => d.style?.size || [50, 20], fill: '#f8f8f8', stroke: '#8b9baf', lineWidth: 1, labelPlacement: 'center', labelFill: '#8b9baf', portLineWidth: 0, }, }, edge: { type: 'polyline', style: { stroke: '#1890FF', }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-port.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgePort: TestCase = async (context) => { const nodes: Record = { 'node-2': { ports: [ { key: 'left', placement: [0, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, fill: '#31d0c6' }, { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-3': { ports: [ { key: 'left', placement: [0, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'bottom', placement: [0.5, 1], r: 4, fill: '#31d0c6' }, ], }, 'node-4': { ports: [ { key: 'left', placement: [0, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-5': { ports: [ { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-6': { ports: [ { key: 'left', placement: [0, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-7': { ports: [ { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'bottom', placement: [0.5, 1], r: 4, fill: '#31d0c6' }, ], }, 'node-8': { ports: [ { key: 'left', placement: [0, 0.5], r: 4, fill: '#31d0c6' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-9': { ports: [ { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-11': { ports: [ { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-13': { ports: [ { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, fill: '#31d0c6' }, ], }, 'node-14': { ports: [ { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-16': { ports: [ { key: 'bottom', placement: [0.5, 1], r: 4, fill: '#31d0c6' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, 'node-18': { ports: [ { key: 'bottom', placement: [0.5, 1], r: 4, fill: '#31d0c6' }, { key: 'right', placement: [1, 0.5], r: 4, stroke: '#31d0c6', fill: '#fff' }, ], }, }; const edges: Record = { 'edge-0': { labelText: 'sourcePort❓ targetPort❓', }, 'edge-1': { sourcePort: 'right', targetPort: 'bottom', labelText: 'sourcePort✅ targetPort✅', }, 'edge-2': { labelText: 'sourcePort✖️ targetPort✖️', }, 'edge-3': { targetPort: 'bottom', labelText: 'sourcePort✖️ targetPort✅', }, 'edge-4': { sourcePort: 'left', labelText: 'sourcePort✅ targetPort✖️', }, 'edge-5': { labelText: 'sourcePort❓ targetPort✖️', }, 'edge-6': { targetPort: 'right', labelText: 'sourcePort❓ targetPort✅', }, 'edge-7': { labelText: 'sourcePort✖️ targetPort❓', }, 'edge-8': { sourcePort: 'bottom', labelText: 'sourcePort✅ targetPort❓', }, 'edge-9': { sourcePort: 'bottom', labelText: 'sourcePort✅ targetPort❓', }, }; const graph = new Graph({ ...context, data: { nodes: Array.from({ length: 20 }).map((_, i) => ({ id: `node-${i}`, data: { index: i }, type: i === 19 ? 'star' : 'circle', style: nodes[`node-${i}`] || {}, })), edges: Array.from({ length: 10 }).map((_, i) => ({ id: `edge-${i}`, source: `node-${i * 2}`, target: `node-${i * 2 + 1}`, type: i === 9 ? 'cubic' : 'line', style: edges[`edge-${i}`] || {}, })), }, node: { style: { x: (d) => [50, 200, 300, 450][(d.data!.index as number) % 4], y: (d) => 50 + Math.floor((d.data!.index as number) / 4) * 100, size: 50, fill: '#f8f8f8', stroke: '#8b9baf', lineWidth: 1, }, }, edge: { style: { stroke: '#1890FF', lineWidth: 2, labelFontSize: 12, labelMaxLines: 2, labelWordWrap: true, labelWordWrapWidth: 100, endArrow: true, }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-quadratic.ts ================================================ import data from '@@/dataset/element-edges.json'; import { Graph } from '@antv/g6'; export const elementEdgeQuadratic: TestCase = async (context) => { const graph = new Graph({ ...context, data, edge: { type: 'quadratic', // 👈🏻 Edge shape type. style: { labelText: (d) => d.id!, labelBackground: true, endArrow: true, }, }, layout: { type: 'radial', unitRadius: 220, linkDistance: 220, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-edge-size.ts ================================================ import { Graph } from '@antv/g6'; export const elementEdgeSize: TestCase = async (context) => { const data = { nodes: new Array(14).fill(0).map((_, i) => ({ id: `node${i + 1}` })), edges: [1, 2, 4, 6, 8, 10, 12].map((lineWidth, i) => ({ id: `edge-${i}`, source: `node${i * 2 + 1}`, target: `node${i * 2 + 2}`, style: { lineWidth }, })), }; const graph = new Graph({ ...context, data, edge: { type: 'line', // 👈🏻 Edge shape type. style: { endArrow: true }, }, layout: { type: 'grid', cols: 2, }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-html-sub-graph.ts ================================================ import type { DisplayObject, Group } from '@antv/g'; import type { BaseComboStyleProps, GraphData, HTMLStyleProps, IElementEvent, NodeData } from '@antv/g6'; import { BaseCombo, ExtensionCategory, Graph, HTML, isCollapsed, register } from '@antv/g6'; import { isEqual } from '@antv/util'; export const elementHTMLSubGraph: TestCase = async (context) => { interface CardNodeData { type: 'card'; status: 'expanded' | 'collapsed'; data: { name: string; value: number }[]; children: CardNodeData[] | [GraphNodeData]; } interface GraphNodeData { type: 'graph'; data: GraphData; } type Data = CardNodeData | GraphNodeData; const getSize = (d: NodeData) => { const data = d.data as unknown as Data; if (data.type === 'card') return data.status === 'expanded' ? [200, 100 * data.children.length] : [200, 100]; else return [200, 200]; }; class SubGraph extends HTML { public connectedCallback(): void { super.connectedCallback(); this.drawSubGraph(); } public render(attributes?: Required, container?: Group): void { super.render(attributes, container); this.drawSubGraph(); } private get data() { return this.context.graph.getElementData(this.id).data; } private graph?: Graph; private previousData?: Record; private drawSubGraph() { if (!this.isConnected) return; const data = this.data; if (isEqual(this.previousData, data)) return; this.previousData = data; this.drawGraphNode(data!.data as GraphData); } private drawGraphNode(data: GraphData) { const [width, height] = this.getSize(); const container = this.getDomElement(); container.innerHTML = ''; const subGraph = new Graph({ container, width, height, animation: false, data: data, node: { style: { labelText: (d) => d.id, iconFontFamily: 'iconfont', iconText: '\ue6e5', }, }, layout: { type: 'force', linkDistance: 50, }, behaviors: ['zoom-canvas', { type: 'drag-canvas', enable: (event: MouseEvent) => event.shiftKey === true }], autoFit: 'view', }); subGraph.render(); this.graph = subGraph; } public destroy(): void { this.graph?.destroy(); super.destroy(); } } class CardCombo extends BaseCombo { protected getKeyStyle(attributes: Required) { const keyStyle = super.getKeyStyle(attributes); const [width, height] = this.getKeySize(attributes); return { ...keyStyle, width, height, x: -width / 2, y: -height / 2, }; } protected drawKeyShape(attributes: Required, container: Group): DisplayObject | undefined { const { collapsed } = attributes; const outer = this.upsert('key', 'rect', this.getKeyStyle(attributes), container); if (!outer || !collapsed) { this.removeCardShape(); return outer; } this.drawCardShape(attributes, container); return outer; } protected drawCardShape(attributes: Required, container: Group) { const [width, height] = this.getCollapsedKeySize(attributes); const data = this.context.graph.getComboData(this.id).data as unknown as CardNodeData; const baseX = -width / 2; const baseY = -height / 2; this.upsert( 'card-title', 'text', { x: baseX, y: baseY, text: '点分组: ' + this.id, textAlign: 'left', textBaseline: 'top', fontSize: 16, fontWeight: 'bold', fill: '#4083f7', }, container, ); const gap = 10; const sep = (width + gap) / data.data.length; data.data.forEach(({ name, value }, index) => { this.upsert( `card-item-name-${index}`, 'text', { x: baseX + index * sep, y: baseY + 40, text: name, textAlign: 'left', textBaseline: 'top', fontSize: 12, fill: 'gray', }, container, ); this.upsert( `card-item-value-${index}`, 'text', { x: baseX + index * sep, y: baseY + 60, text: value + '%', textAlign: 'left', textBaseline: 'top', fontSize: 24, }, container, ); }); } protected removeCardShape() { Object.entries(this.shapeMap).forEach(([key, shape]) => { if (key.startsWith('card-')) { delete this.shapeMap[key]; shape.destroy(); } }); } } register(ExtensionCategory.NODE, 'sub-graph', SubGraph); register(ExtensionCategory.COMBO, 'card', CardCombo); const graph = new Graph({ ...context, animation: false, zoom: 0.8, data: { nodes: [ { id: 'node-1', combo: 'combo-1-1', style: { x: 120, y: 70 }, data: { data: { nodes: [ { id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }, { id: 'node-5' }, { id: 'node-6' }, { id: 'node-7' }, { id: 'node-8' }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-1', target: 'node-3' }, { source: 'node-1', target: 'node-4' }, { source: 'node-1', target: 'node-5' }, { source: 'node-1', target: 'node-6' }, { source: 'node-1', target: 'node-7' }, { source: 'node-1', target: 'node-8' }, ], }, }, }, { id: 'node-2', combo: 'combo-1-2', style: { x: 370, y: 70 }, data: { data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-1', target: 'node-3' }, { source: 'node-1', target: 'node-4' }, ], }, }, }, { id: 'node-3', combo: 'combo-1-3-1', style: { x: 120, y: 220 }, data: { data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-1', target: 'node-3' }, { source: 'node-1', target: 'node-4' }, ], }, }, }, { id: 'node-4', combo: 'combo-1-3-2', style: { x: 120, y: 370 }, data: { data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-1', target: 'node-3' }, { source: 'node-1', target: 'node-4' }, ], }, }, }, ], edges: [], combos: [ { id: 'combo-1', data: { data: [ { name: '指标名1', value: 33 }, { name: '指标名2', value: 44 }, { name: '指标名3', value: 55 }, ], }, }, { id: 'combo-1-1', combo: 'combo-1', style: { collapsed: true }, data: { data: [ { name: '指标名1', value: 33 }, { name: '指标名2', value: 44 }, { name: '指标名3', value: 55 }, ], }, }, { id: 'combo-1-2', combo: 'combo-1', style: { collapsed: true }, data: { data: [ { name: '指标名1', value: 33 }, { name: '指标名2', value: 44 }, { name: '指标名3', value: 55 }, ], }, }, { id: 'combo-1-3', combo: 'combo-1', style: { collapsed: true }, data: { data: [ { name: '指标名1', value: 33 }, { name: '指标名2', value: 44 }, { name: '指标名3', value: 55 }, ], }, }, { id: 'combo-1-3-1', combo: 'combo-1-3', style: { collapsed: true }, data: { data: [ { name: '指标名1', value: 33 }, { name: '指标名2', value: 44 }, { name: '指标名3', value: 55 }, ], }, }, { id: 'combo-1-3-2', combo: 'combo-1-3', style: { collapsed: true }, data: { data: [ { name: '指标名1', value: 33 }, { name: '指标名2', value: 44 }, { name: '指标名3', value: 55 }, ], }, }, ], }, node: { type: 'sub-graph', style: { dx: -100, dy: -50, size: getSize, }, }, combo: { type: 'card', style: { collapsedSize: [200, 100], collapsedMarker: false, radius: 10, }, }, behaviors: [ { type: 'drag-element', enable: (event: MouseEvent) => event.shiftKey !== true }, 'collapse-expand', 'zoom-canvas', 'drag-canvas', ], plugins: [ { type: 'contextmenu', getItems: (event: IElementEvent) => { const { targetType, target } = event; if (!['node', 'combo'].includes(targetType)) return []; const id = target.id; if (targetType === 'combo') { const data = graph.getComboData(id); if (isCollapsed(data)) { return [{ name: '展开', value: 'expanded' }]; } else return [{ name: '收起', value: 'collapsed' }]; } return [{ name: '收起', value: 'collapsed' }]; }, onClick: (value: CardNodeData['status'], target: HTMLElement, current: SubGraph) => { const id = current.id; const elementType = graph.getElementType(id); if (elementType === 'node') { const parent = graph.getParentData(id, 'combo'); if (parent) return graph.collapseElement(parent.id, false); } if (value === 'expanded') graph.expandElement(id, false); else graph.collapseElement(id, false); }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-label-background.ts ================================================ import { Graph } from '@antv/g6'; export const elementLabelBackground: TestCase = async (context) => { const data = { nodes: [ { id: 'node1', style: { x: 250, y: 100 }, }, { id: 'node2', style: { x: 150, y: 300 }, }, { id: 'node3', style: { x: 400, y: 300 }, }, ], edges: [ { id: 'edge1', source: 'node1', target: 'node2', }, { id: 'edge2', source: 'node1', target: 'node3', }, { id: 'edge3', source: 'node2', target: 'node3', }, ], }; const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'bottom', labelFill: '#e66465', labelFontSize: 12, labelFontStyle: 'italic', labelBackground: true, labelBackgroundFill: '#eee', labelBackgroundStroke: '#9ec9ff', labelBackgroundRadius: 2, }, }, edge: { style: { labelText: (d) => d.id!, labelPlacement: 'center', labelTextBaseline: 'top', labelDy: 5, labelFontSize: 12, labelFontWeight: 'bold', labelFill: '#1890ff', labelBackgroundFill: '#eee', labelBackgroundStroke: '#9ec9ff', labelBackgroundRadius: 2, }, }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-label-oversized.ts ================================================ import { Graph } from '@antv/g6'; export const elementLabelOversized: TestCase = async (context) => { const data = { nodes: [ { id: 'node1', data: { x: 100, y: 150, label: `This label with padding is too long to be displayed`, size: 100, }, }, { id: 'node2', data: { x: 400, y: 150, label: 'This label with padding is too long to be displayed', size: 150, }, }, ], edges: [ { id: 'edge1', source: 'node1', target: 'node2', data: { label: 'This label is too long to be displayed', }, }, ], }; const graph = new Graph({ ...context, data, node: { type: 'rect', style: { x: (d) => d.data!.x as number, y: (d) => d.data!.y as number, size: (d) => d.data!.size as number, labelPlacement: 'bottom', labelText: (d) => d.data!.label as string, labelMaxWidth: '90%', labelBackground: true, labelBackgroundFill: '#eee', labelBackgroundFillOpacity: 0.5, labelBackgroundRadius: 4, labelPadding: [0, 10, 0, 10], labelWordWrap: true, labelMaxLines: 4, }, }, edge: { style: { labelOffsetY: -4, labelTextBaseline: 'bottom', labelText: (d) => d.data!.label as string, labelMaxWidth: '80%', labelBackground: true, labelBackgroundFill: 'red', labelBackgroundFillOpacity: 0.5, labelBackgroundRadius: 4, labelWordWrap: true, labelMaxLines: 4, }, }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-avatar.ts ================================================ import { Graph } from '@antv/g6'; const avatar = 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*_Do9Tq7MxFQAAAAAAAAAAAAADmJ7AQ/original'; const logo = 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*AzSISZeq81IAAAAAAAAAAAAADmJ7AQ/original'; export const elementNodeAvatar: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', type: 'circle', style: { iconSrc: avatar, iconWidth: 30, iconHeight: 30, iconRadius: 15, size: 40 }, }, { id: 'node-2', type: 'image', style: { src: avatar, size: 40, radius: 20, iconSrc: logo, iconWidth: 40, iconHeight: 40, iconRadius: 20 }, }, ], }, layout: { type: 'grid', }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-badges.ts ================================================ import { Graph } from '@antv/g6'; export const elementNodeBadges: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 150, y: 150, size: 100, badges: [ { text: 'left', placement: 'left' }, { text: 'right', placement: 'right' }, { text: 'top', placement: 'top' }, { text: 'bottom', placement: 'bottom' }, // { text: 'top-left', placement: 'top-left' }, // { text: 'top-right', placement: 'top-right' }, // { text: 'bottom-left', placement: 'bottom-left' }, // { text: 'bottom-right', placement: 'bottom-right' }, ], badgeFontSize: 8, badgePadding: [10, 10], }, }, ], }, }); await graph.render(); elementNodeBadges.form = (panel) => { const config = { add: () => { graph.updateNodeData([ { id: 'node-1', style: { badges: [ { text: 'left', placement: 'left' }, { text: 'right', placement: 'right' }, { text: 'top', placement: 'top' }, { text: 'bottom', placement: 'bottom' }, { text: 'top-left', placement: 'top-left' }, { text: 'top-right', placement: 'top-right' }, { text: 'bottom-left', placement: 'bottom-left' }, { text: 'bottom-right', placement: 'bottom-right' }, ], }, }, ]); graph.draw(); }, update: () => { graph.updateNodeData([ { id: 'node-1', style: { badges: [ { text: 'left', placement: 'left', backgroundFill: 'red' }, { text: 'right', placement: 'right' }, { text: 'top', placement: 'top' }, { text: 'bottom', placement: 'bottom' }, { text: 'top-left', placement: 'top-left' }, { text: 'top-right', placement: 'top-right' }, { text: 'bottom-left', placement: 'bottom-left' }, { text: 'bottom-right', placement: 'bottom-right' }, ], }, }, ]); graph.draw(); }, remove: () => { graph.updateNodeData([ { id: 'node-1', style: { badges: [ { text: 'left', placement: 'left' }, { text: 'right', placement: 'right' }, { text: 'top', placement: 'top' }, { text: 'bottom', placement: 'bottom' }, ], }, }, ]); graph.draw(); }, }; return [ panel.add(config, 'add').name('Add Badge'), panel.add(config, 'update').name('Update Badge'), panel.add(config, 'remove').name('Remove Badge'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-circle.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeCircle: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'circle', // 👈🏻 Node shape type. style: { iconFontFamily: 'iconfont', iconText: '\ue602', labelText: (d) => d.id!, size: 40, }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-diamond.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeDiamond: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'diamond', // 👈🏻 Node shape type. style: { size: 40, labelText: (d) => d.id!, iconFontFamily: 'iconfont', iconText: '\ue602', }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-donut.ts ================================================ import { idOf } from '@/src/utils/id'; import { Graph } from '@antv/g6'; export const elementNodeDonut: TestCase = async (context) => { const data = { nodes: [ { id: 'default', style: { innerR: '60%', donuts: [ { color: 'orange', lineWidth: 2, }, ], fill: 'purple', }, }, { id: 'halo', style: { donuts: [{ color: 'red' }, { color: 'green' }], }, }, { id: 'badges', style: { donuts: [1, 2, 3], }, }, { id: 'ports', style: { donuts: [1, 1, 1], }, }, { id: 'active', states: ['active'], style: { donuts: [ { value: 20, }, { value: 1000, }, ], }, }, { id: 'selected', states: ['selected'], style: { donuts: [{ value: 1000 }, { value: 20 }], }, }, { id: 'highlight', states: ['highlight'], style: { donutLineWidth: 1, donutStroke: '#fff', donuts: [1, 2, 3], }, }, { id: 'inactive', states: ['inactive'], style: { innerR: 0, donuts: [{ fill: 'red' }, { fill: 'green' }], }, }, { id: 'disabled', states: ['disabled'], style: { innerR: '50%', donuts: [{ color: 'green' }, { color: 'red' }], }, }, ], }; const graph = new Graph({ ...context, data, node: { type: 'donut', // 👈🏻 Node shape type. style: { size: 40, innerR: (d: any) => d.style.innerR ?? '50%', labelText: (d) => d.id, iconHeight: 20, iconWidth: 20, iconFontFamily: 'iconfont', iconText: '\ue602', halo: (d) => idOf(d).toString().includes('halo'), portR: 3, ports: (d) => idOf(d).toString().includes('ports') ? [{ placement: 'left' }, { placement: 'right' }, { placement: 'top' }, { placement: 'bottom' }] : [], badges: (d) => idOf(d).toString().includes('badges') ? [ { text: 'A', placement: 'right-top' }, { text: 'Important', placement: 'right' }, { text: 'Notice', placement: 'right-bottom' }, ] : [], badgeFontSize: 8, badgePadding: [1, 4], }, }, // TODO fixme when animation is enabled animation: false, layout: { type: 'grid', }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-ellipse.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeEllipse: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'ellipse', // 👈🏻 Node shape type. style: { size: [45, 35], labelText: (d) => d.id!, iconHeight: 20, iconWidth: 20, iconFontFamily: 'iconfont', iconText: '\ue602', }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-hexagon.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeHexagon: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'hexagon', // 👈🏻 Node shape type. style: { size: 40, labelText: (d) => d.id!, iconFontFamily: 'iconfont', iconText: '\ue602', }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-html-2.ts ================================================ import { Graph } from '@antv/g6'; export const elementNodeHTML2: TestCase = async (context) => { const ICON_MAP = { error: '❌', overload: '⚡', running: '✅', }; const COLOR_MAP = { error: '#f5222d', overload: '#faad14', running: '#52c41a', } as const; const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', data: { location: 'East', status: 'error', ip: '192.168.1.2' } }, { id: 'node-2', data: { location: 'West', status: 'overload', ip: '192.168.1.3' } }, { id: 'node-3', data: { location: 'South', status: 'running', ip: '192.168.1.4' }, states: ['active'] }, ], }, node: { type: 'html', style: { size: [160, 60], dx: -80, dy: -30, innerHTML: (d: any) => { const { data: { location, status, ip }, } = d; const color = COLOR_MAP[status as keyof typeof COLOR_MAP]; return `
${location} Node
status: ${status} ${ICON_MAP[status as keyof typeof ICON_MAP]}
${ip}
`; }, }, }, layout: { type: 'grid', }, behaviors: ['drag-element', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-html.ts ================================================ import type { NodeData } from '@antv/g6'; import { Graph } from '@antv/g6'; export const elementNodeHTML: TestCase = async (context) => { const data = { nodes: [ { id: 'html-1', style: { x: 100, y: 100, fill: 'orange' }, data: { content: 'HTML NODE 1' } }, { id: 'html-2', style: { x: 100, y: 200, fill: 'pink' }, data: { content: 'HTML NODE 2' } }, ], edges: [{ source: 'html-1', target: 'html-2' }], }; const graph = new Graph({ ...context, data, node: { type: 'html', // 👈🏻 Node shape type. style: { size: [240, 80], innerHTML: (d: NodeData) => `
${d.data!.content}
`, }, }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-image.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeImage: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'image', // 👈🏻 Node shape type. style: { size: 40, labelText: (d) => d.id!, src: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ', iconSrc: '', haloStroke: '#227eff', }, state: { inactive: { fillOpacity: 0.5, }, disabled: { fillOpacity: 0.2, }, }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-rect.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeRect: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'rect', // 👈🏻 Node shape type. style: { radius: 4, // 👈🏻 Set the radius. size: 40, labelText: (d) => d.id!, iconFontFamily: 'iconfont', iconText: '\ue602', }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-star.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeStar: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'star', // 👈🏻 Node shape type. style: { size: 40, labelText: (d) => d.id!, iconFontFamily: 'iconfont', iconText: '\ue602', }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-svg-icon.ts ================================================ import comment from '@@/assets/comment.svg'; import user from '@@/assets/user.svg'; import { Graph } from '@antv/g6'; export const elementNodeSVGIcon: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'user', data: { type: 'user' }, style: { x: 50, y: 50 } }, { id: 'comment', data: { type: 'comment' }, style: { x: 100, y: 50 } }, ], }, node: { style: { size: 40, fill: 'transparent', iconSrc: (d) => (d?.data?.type === 'user' ? user : comment), iconWidth: 40, iconHeight: 40, }, }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-node-triangle.ts ================================================ import data from '@@/dataset/element-nodes.json'; import { Graph } from '@antv/g6'; export const elementNodeTriangle: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { type: 'triangle', // 👈🏻 Node shape type. style: { size: 40, direction: (d: any) => (d.id === 'ports' ? 'left' : 'up'), labelText: (d) => d.id!, iconFontFamily: 'iconfont', iconText: '\ue602', ports: (d) => (d.id === 'ports' ? [{ placement: 'left' }, { placement: 'top' }, { placement: 'bottom' }] : []), }, }, layout: { type: 'grid', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-port.ts ================================================ import { idOf } from '@/src/utils/id'; import { Graph } from '@antv/g6'; export const elementPort: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 80, y: 200 } }, { id: 'node-2', style: { x: 250, y: 200 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', }, { id: 'edge-2', source: 'node-1', target: 'node-2', }, { id: 'edge-3', source: 'node-1', target: 'node-2' }, ], }, node: { type: (d) => (d.id === 'node-1' ? 'circle' : 'rect'), style: { size: (d) => (d.id === 'node-1' ? 30 : [50, 150]), port: true, ports: (d) => d.id === 'node-2' ? [ { key: 'port-1', placement: [0, 0.15] }, { key: 'port-2', placement: 'left' }, { key: 'port-3', placement: [0, 0.85] }, ] : [], }, }, edge: { style: { endArrow: true, targetPort: (d) => `port-${idOf(d).toString().split('-')[1]}`, }, }, }); await graph.render(); const config = { showPort: false, linkToCenter: false, }; elementPort.form = (panel) => { const updatePort = (attr: string, value: any) => { graph.updateNodeData((prev) => { const node2Data = prev.find((node: any) => node.id === 'node-2')!; return [ ...prev.filter((node: any) => node.id !== 'node-2'), { ...node2Data, style: { ...node2Data!.style, [attr]: value, }, }, ]; }); }; return [ panel.add(config, 'showPort').onChange((showPort: boolean) => { updatePort('portR', showPort ? 3 : 0); graph.draw(); }), panel.add(config, 'linkToCenter').onChange((linkToCenter: boolean) => { updatePort('portLinkToCenter', linkToCenter); graph.draw(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-position-combo.ts ================================================ import { Graph } from '@antv/g6'; export const elementPositionCombo: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', combo: 'combo-1', style: { x: 100, y: 150 } }, { id: 'node-2', combo: 'combo-1', style: { x: 200, y: 150 } }, { id: 'node-3', combo: 'combo-2', style: { x: 400, y: 200 } }, { id: 'node-4', combo: 'combo-3', style: { x: 150, y: 300 } }, ], combos: [{ id: 'combo-1', combo: 'combo-2' }, { id: 'combo-2' }, { id: 'combo-3', combo: 'combo-1' }], }, node: { style: { size: 20, labelWordWrapWidth: 200, labelText: (d) => d.id, }, }, combo: { style: { labelText: (d) => d.id, }, }, padding: 20, autoFit: 'view', behaviors: ['hover-activate'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-position.ts ================================================ import { Graph } from '@antv/g6'; export const elementPosition: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', style: { x: 125, y: 150 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, { id: 'edge-3', source: 'node-3', target: 'node-1' }, ], }, node: { style: { size: 20, }, }, }); await graph.render(); elementPosition.form = (panel) => { const config = { element: 'node-1', x: 50, y: 50, }; const translate = () => { graph.translateElementTo( { [config.element]: [config.x, config.y], }, false, ); }; const element = panel.add(config, 'element', ['node-1', 'node-2', 'node-3']).onChange((id: string) => { const position = graph.getElementPosition(id); x.setValue(position[0]); y.setValue(position[1]); }); const x = panel.add(config, 'x', 0, 300, 1).onChange(translate); const y = panel.add(config, 'y', 0, 300, 1).onChange(translate); return [element, x, y]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-state.ts ================================================ import { Graph } from '@antv/g6'; export const elementState: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', states: ['active', 'selected'], style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', states: ['active'], style: { x: 125, y: 150 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', states: ['active'] }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, { id: 'edge-3', source: 'node-3', target: 'node-1' }, ], }, theme: 'light', node: { style: { size: 20, }, state: { selected: { fill: 'pink', }, }, animation: { update: [{ fields: ['lineWidth', 'fill'] }], }, }, edge: { style: { lineWidth: 1, }, state: { active: { lineWidth: 2, stroke: 'pink', }, }, animation: { update: [ { fields: ['lineWidth', 'stroke'], }, ], }, }, }); await graph.render(); elementState.form = (panel) => { const config = { element: 'node-1', active: true, selected: true, }; const setState = () => { const state: string[] = []; if (config.active) state.push('active'); if (config.selected) state.push('selected'); graph.setElementState({ [config.element]: state }); }; const element = panel .add(config, 'element', ['node-1', 'node-2', 'node-3', 'edge-1', 'edge-2', 'edge-3']) .onChange((id: string) => { const states = graph.getElementState(id); selected.setValue(states.includes('selected')); active.setValue(states.includes('active')); }); const active = panel.add(config, 'active').onChange(setState); const selected = panel.add(config, 'selected').onChange(setState); return [element, active, selected]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-visibility-part.ts ================================================ import { Group, Rect } from '@antv/g'; import { BaseNodeStyleProps, Circle, ExtensionCategory, Graph, register, setVisibility } from '@antv/g6'; interface CustomCircleStyleProps extends BaseNodeStyleProps { show: boolean; } class CustomCircle extends Circle { public renderPart(attributes: Required, container: Group) { const part = this.upsert('part', Rect, { width: 10, height: 10, stroke: 'red', lineWidth: 2 }, container)!; this.upsert('part-rect', Rect, { x: 1, y: 1, width: 8, height: 8, fill: 'pink' }, part); setVisibility(part, attributes.show ? 'visible' : 'hidden'); } public render(attributes: Required, container: Group): void { super.render(); this.renderPart(attributes, container); } } export const elementVisibilityPart: TestCase = async (context) => { register(ExtensionCategory.NODE, 'custom-circle', CustomCircle); const graph = new Graph({ ...context, data: { nodes: [{ id: 'node-1', style: { x: 100, y: 100, show: true } }], }, node: { type: 'custom-circle', style: { size: 20, }, }, }); await graph.draw(); elementVisibilityPart.form = (panel) => { const config = { node: true, part: true, }; return [ panel.add(config, 'node').onChange((show: boolean) => { graph.updateNodeData([{ id: 'node-1', style: { visibility: show ? 'visible' : 'hidden' } }]); graph.draw(); }), panel.add(config, 'part').onChange((show: boolean) => { graph.updateNodeData([{ id: 'node-1', style: { show } }]); graph.draw(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-visibility.ts ================================================ import { Graph } from '@antv/g6'; export const elementVisibility: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', style: { x: 125, y: 150 } }, { id: 'node-4', style: { x: 125, y: 200, visibility: 'hidden' } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, { id: 'edge-3', source: 'node-3', target: 'node-1' }, ], }, theme: 'light', node: { style: { size: 20, labelText: (d) => d.id!.toString().at(-1)! } }, edge: { style: { endArrow: true, labelText: (d) => d.id! } }, }); await graph.render(); elementVisibility.form = (panel) => { const config = { element: 'node-1', visible: true, }; const element = panel .add(config, 'element', ['node-1', 'node-2', 'node-3', 'node-4', 'edge-1', 'edge-2', 'edge-3']) .onChange((id: string) => { visible.setValue(graph.getElementVisibility(id) !== 'hidden'); }); const visible = panel.add(config, 'visible').onChange((value: boolean) => { value ? graph.showElement(config.element) : graph.hideElement(config.element); }); return [element, visible]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/element-z-index.ts ================================================ import { Graph } from '@antv/g6'; export const elementZIndex: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', style: { x: 125, y: 150 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, { id: 'edge-3', source: 'node-3', target: 'node-1' }, ], combos: [ { id: 'combo-1', style: { x: 50, y: 250 } }, { id: 'combo-2', combo: 'combo-1', style: { x: 50, y: 250 } }, { id: 'combo-3', combo: 'combo-2', style: { x: 150, y: 250 } }, { id: 'combo-4', style: { x: 350, y: 250 } }, ], }, node: { style: { size: 40, labelText: (d) => d.id, labelWordWrapWidth: 200, }, palette: 'tableau', }, combo: { style: { labelText: (d) => d.id, fillOpacity: 1, }, palette: 'tableau', }, behaviors: ['drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/graph-to-data-url.ts ================================================ import icon from '@@/assets/user.svg'; import { Graph } from '@antv/g6'; export const graphToDataURL: TestCase = async (context) => { const graph = new Graph({ ...context, background: '#f4df4d', data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50, fill: 'purple', halo: true, labelText: 'node-1' } }, { id: 'node-2', style: { x: 100, y: 50, fill: 'pink', halo: true, labelText: 'node-2' } }, { id: 'node-3', style: { x: 150, y: 50, iconSrc: icon, iconWidth: 30, iconHeight: 30, labelText: 'node-2' } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', style: { stroke: 'orange', lineWidth: 2 } }], }, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'], }); graphToDataURL.form = (panel) => { const config = { toDataURL: () => { graph.toDataURL({ mode: config.mode } as any).then((url) => { navigator.clipboard.writeText(url); alert('The data URL has been copied to the clipboard'); }); }, mode: 'viewport', download: async () => { const dataURL = await graph.toDataURL({ mode: config.mode } as any); const [head, content] = dataURL.split(','); const contentType = head.match(/:(.*?);/)![1]; const bstr = atob(content); let length = bstr.length; const u8arr = new Uint8Array(length); while (length--) { u8arr[length] = bstr.charCodeAt(length); } const blob = new Blob([u8arr], { type: contentType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'graph.png'; a.click(); }, }; return [ panel.add(config, 'toDataURL'), panel.add(config, 'mode', ['viewport', 'overall']), panel.add(config, 'download').name('Download'), ]; }; await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/image-node-halo-test.ts ================================================ import data from '@@/dataset/dagre-combo.json'; import { Graph } from '@antv/g6'; export const imageNodeHaloTest: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'center', data, node: { type: (d) => (Number(d.id) % 2 === 0 ? 'image' : 'rect'), style: { src: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*N4ZMS7gHsUIAAAAAAAAAAABkARQnAQ', size: [60, 30], radius: 8, labelText: (d) => d.id, labelBackground: true, ports: [{ placement: 'top' }, { placement: 'bottom' }], }, state: { selected: { haloStroke: '#111', haloLineWidth: 80, }, }, palette: { field: (d) => d.combo, }, }, edge: { type: 'cubic-vertical', style: { endArrow: true, }, }, combo: { type: 'rect', style: { radius: 8, labelText: (d) => d.id, }, }, layout: { type: 'antv-dagre', ranksep: 50, nodesep: 5, sortByCombo: true, }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas', 'click-select'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/index.ts ================================================ export { animationElementEdgeCubic } from './animation-element-edge-cubic'; export { animationEdgeLine } from './animation-element-edge-line'; export { animationElementEdgeQuadratic } from './animation-element-edge-quadratic'; export { animationElementPosition } from './animation-element-position'; export { animationElementState } from './animation-element-state'; export { animationElementStateSwitch } from './animation-element-state-switch'; export { animationElementStylePosition } from './animation-element-style-position'; export { behaviorAutoAdaptLabel } from './behavior-auto-adapt-label'; export { behaviorBrushSelect } from './behavior-brush-select'; export { behaviorClickSelect } from './behavior-click-select'; export { behaviorCreateEdge } from './behavior-create-edge'; export { behaviorDragCanvas } from './behavior-drag-canvas'; export { behaviorDragNode } from './behavior-drag-element'; export { behaviorExpandCollapseCombo } from './behavior-expand-collapse-combo'; export { behaviorExpandCollapseNode } from './behavior-expand-collapse-node'; export { behaviorFixElementSize } from './behavior-fix-element-size'; export { behaviorFocusElement } from './behavior-focus-element'; export { behaviorHoverActivate } from './behavior-hover-activate'; export { behaviorLassoSelect } from './behavior-lasso-select'; export { behaviorOptimizeViewportTransform } from './behavior-optimize-viewport-transform'; export { behaviorScrollCanvas } from './behavior-scroll-canvas'; export { behaviorZoomCanvas } from './behavior-zoom-canvas'; export { bugDragRotatedCanvas } from './bug-drag-rotated-canvas'; export { bugDragRotatedElementForce } from './bug-drag-rotated-element-force'; export { bugProcessParallelEdgesComboFixed } from './bug-process-parallel-edges-combo-fixed'; export { bugTooltipResize } from './bug-tooltip-resize'; export { canvasCursor } from './canvas-cursor'; export { caseFishbone } from './case-fishbone'; export { caseFundFlow } from './case-fund-flow'; export { caseIndentedTree } from './case-indented-tree'; export { caseLanguageTree } from './case-language-tree'; export { caseMindmap } from './case-mindmap'; export { caseOrgChart } from './case-org-chart'; export { caseRadialDendrogram } from './case-radial-dendrogram'; export { caseUnicornsInvestors } from './case-unicorns-investors'; export { caseWhyDoCats } from './case-why-do-cats'; export { commonGraph } from './common-graph'; export { controllerViewport } from './controller-viewport'; export { demoAutosizeElementLabel } from './demo-autosize-element-label'; export { demoFoundFlow } from './demo-found-flow'; export { elementChangeType } from './element-change-type'; export { elementCombo } from './element-combo'; export { elementEdgeArrow } from './element-edge-arrow'; export { elementEdgeCubic } from './element-edge-cubic'; export { elementEdgeCubicHorizontal } from './element-edge-cubic-horizontal'; export { elementEdgeCubicRadial } from './element-edge-cubic-radial'; export { elementEdgeCubicVertical } from './element-edge-cubic-vertical'; export { elementEdgeCustomArrow } from './element-edge-custom-arrow'; export { elementEdgeLine } from './element-edge-line'; export { elementEdgeLoopCurve } from './element-edge-loop-curve'; export { elementEdgeLoopPolyline } from './element-edge-loop-polyline'; export { elementEdgePolyline } from './element-edge-polyline'; export { elementEdgePolylineAnimation } from './element-edge-polyline-animation'; export { elementEdgePolylineAstar } from './element-edge-polyline-astar'; export { elementEdgePolylineOrth } from './element-edge-polyline-orth'; export { elementEdgePort } from './element-edge-port'; export { elementEdgeQuadratic } from './element-edge-quadratic'; export { elementEdgeSize } from './element-edge-size'; export { elementHTMLSubGraph } from './element-html-sub-graph'; export { elementLabelBackground } from './element-label-background'; export { elementLabelOversized } from './element-label-oversized'; export { elementNodeAvatar } from './element-node-avatar'; export { elementNodeBadges } from './element-node-badges'; export { elementNodeCircle } from './element-node-circle'; export { elementNodeDiamond } from './element-node-diamond'; export { elementNodeDonut } from './element-node-donut'; export { elementNodeEllipse } from './element-node-ellipse'; export { elementNodeHexagon } from './element-node-hexagon'; export { elementNodeHTML } from './element-node-html'; export { elementNodeHTML2 } from './element-node-html-2'; export { elementNodeImage } from './element-node-image'; export { elementNodeRect } from './element-node-rect'; export { elementNodeStar } from './element-node-star'; export { elementNodeSVGIcon } from './element-node-svg-icon'; export { elementNodeTriangle } from './element-node-triangle'; export { elementPort } from './element-port'; export { elementPosition } from './element-position'; export { elementPositionCombo } from './element-position-combo'; export { elementState } from './element-state'; export { elementVisibility } from './element-visibility'; export { elementVisibilityPart } from './element-visibility-part'; export { elementZIndex } from './element-z-index'; export { graphToDataURL } from './graph-to-data-url'; export { imageNodeHaloTest } from './image-node-halo-test'; export { layoutAntVDagreFlow } from './layout-antv-dagre-flow'; export { layoutAntVDagreFlowCombo } from './layout-antv-dagre-flow-combo'; export { layoutCircularBasic } from './layout-circular-basic'; export { layoutCircularConfigurationTranslate } from './layout-circular-configuration-translate'; export { layoutCircularDegree } from './layout-circular-degree'; export { layoutCircularDivision } from './layout-circular-division'; export { layoutCircularSpiral } from './layout-circular-spiral'; export { layoutComboCombined } from './layout-combo-combined'; export { layoutCompactBoxBasic } from './layout-compact-box-basic'; export { layoutCompactBoxTopToBottom } from './layout-compact-box-left-align'; export { layoutCompactBoxLeftAlign } from './layout-compact-box-top-to-bottom'; export { layoutConcentric } from './layout-concentric'; export { layoutCustomDagre } from './layout-custom-dagre'; export { layoutCustomHorizontal } from './layout-custom-horizontal'; export { layoutCustomIterative } from './layout-custom-iterative'; export { layoutD3Force } from './layout-d3-force'; export { layoutDagre } from './layout-dagre'; export { layoutDendrogramBasic } from './layout-dendrogram-basic'; export { layoutDendrogramRadial } from './layout-dendrogram-radial'; export { layoutDendrogramTb } from './layout-dendrogram-tb'; export { layoutFishbone } from './layout-fishbone'; export { layoutForce } from './layout-force'; export { layoutForceCollision } from './layout-force-collision'; export { layoutForceLattice } from './layout-force-lattice'; export { layoutForceatlas2WASM } from './layout-forceatlas2-wasm'; export { layoutFruchtermanBasic } from './layout-fruchterman-basic'; export { layoutFruchtermanCluster } from './layout-fruchterman-cluster'; export { layoutFruchtermanFix } from './layout-fruchterman-fix'; export { layoutFruchtermanGPU } from './layout-fruchterman-gpu'; export { layoutFruchtermanWASM } from './layout-fruchterman-wasm'; export { layoutGrid } from './layout-grid'; export { layoutIndented } from './layout-indented'; export { layoutMDS } from './layout-mds'; export { layoutMindmapH } from './layout-mindmap-h'; export { layoutMindmapHCustomSide } from './layout-mindmap-h-custom-side'; export { layoutMindmapHLeft } from './layout-mindmap-h-left'; export { layoutMindmapHRight } from './layout-mindmap-h-right'; export { layoutPipelineMdsForce } from './layout-pipeline-mds-force'; export { layoutRadialBasic } from './layout-radial-basic'; export { layoutRadialConfigurationTranslate } from './layout-radial-configuration-translate'; export { layoutRadialPreventOverlap } from './layout-radial-prevent-overlap'; export { layoutRadialPreventOverlapUnstrict } from './layout-radial-prevent-overlap-unstrict'; export { layoutRadialSort } from './layout-radial-sort'; export { layoutSnake } from './layout-snake'; export { perf20000Elements } from './perf-20000-elements'; export { perfFCP } from './perf-fcp'; export { pluginBackground } from './plugin-background'; export { pluginBubbleSets } from './plugin-bubble-sets'; export { pluginCameraSetting } from './plugin-camera-setting'; export { pluginContextmenu } from './plugin-contextmenu'; export { pluginEdgeBundling } from './plugin-edge-bundling'; export { pluginEdgeFilterLens } from './plugin-edge-filter-lens'; export { pluginFisheye } from './plugin-fisheye'; export { pluginFullscreen } from './plugin-fullscreen'; 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 { pluginMiniMapEdgeArrow } from './plugin-minimap-edge-arrow'; export { pluginSnapline } from './plugin-snapline'; export { pluginTimebar } from './plugin-timebar'; export { pluginTitle } from './plugin-title'; export { pluginToolbarBuildIn } from './plugin-toolbar-build-in'; export { pluginToolbarIconfont } from './plugin-toolbar-iconfont'; export { pluginTooltip } from './plugin-tooltip'; export { pluginTooltipAsync } from './plugin-tooltip-async'; export { pluginTooltipDual } from './plugin-tooltip-dual'; export { pluginTooltipEnable } from './plugin-tooltip-enable'; export { pluginTooltipWithCustomNode } from './plugin-tooltip-with-custom-node'; export { pluginWatermark } from './plugin-watermark'; export { pluginWatermarkImage } from './plugin-watermark-image'; export { theme } from './theme'; export { transformMapNodeSize } from './transform-map-node-size'; export { transformPlaceRadialLabels } from './transform-place-radial-labels'; export { transformProcessParallelEdges } from './transform-process-parallel-edges'; export { viewportFit } from './viewport-fit'; ================================================ FILE: packages/g6/__tests__/demos/layout-antv-dagre-flow-combo.ts ================================================ import data from '@@/dataset/dagre-combo.json'; import { Graph } from '@antv/g6'; export const layoutAntVDagreFlowCombo: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { type: 'rect', style: { size: [60, 30], radius: 8, labelText: (d) => d.id, labelPlacement: 'center', ports: [{ placement: 'top' }, { placement: 'bottom' }], }, palette: { field: (d) => d.combo, }, }, edge: { type: 'cubic-vertical', style: { endArrow: true, }, }, combo: { type: 'rect', style: { radius: 8, labelText: (d) => d.id, lineDash: 0, collapsedLineDash: [5, 5], }, }, layout: { type: 'antv-dagre', ranksep: 50, nodesep: 5, sortByCombo: true, }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas', 'collapse-expand'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-antv-dagre-flow.ts ================================================ import data from '@@/dataset/dagre.json'; import { Graph } from '@antv/g6'; export const layoutAntVDagreFlow: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { type: 'rect', style: { size: [60, 30], radius: 8, labelFill: '#fff', labelPlacement: 'center', labelText: (d) => d.id, }, }, edge: { type: 'polyline', style: { radius: 20, endArrow: true, lineWidth: 2, stroke: '#C2C8D5', }, }, layout: { type: 'antv-dagre', controlPoints: true, }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-circular-basic.ts ================================================ import data from '@@/dataset/circular.json'; import { Graph } from '@antv/g6'; export const layoutCircularBasic: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, layout: { type: 'circular', }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-circular-configuration-translate.ts ================================================ import data from '@@/dataset/circular.json'; import { Graph } from '@antv/g6'; export const layoutCircularConfigurationTranslate: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'circular', }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-circular-degree.ts ================================================ import data from '@@/dataset/circular.json'; import { Graph } from '@antv/g6'; export const layoutCircularDegree: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { style: { labelText: (d) => d.id, }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'circular', ordering: 'degree', }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-circular-division.ts ================================================ import data from '@@/dataset/circular.json'; import { Graph } from '@antv/g6'; export const layoutCircularDivision: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { style: { labelText: (d) => d.id, }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'circular', divisions: 5, radius: 200, startAngle: Math.PI / 4, endAngle: Math.PI, }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-circular-spiral.ts ================================================ import data from '@@/dataset/circular.json'; import { Graph } from '@antv/g6'; export const layoutCircularSpiral: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { style: { labelText: (d) => d.id, }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'circular', startRadius: 10, endRadius: 300, }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-combo-combined.ts ================================================ import data from '@@/dataset/combo.json'; import { Graph } from '@antv/g6'; export const layoutComboCombined: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'combo-combined', comboPadding: 10, nodeSpacing: 20, comboSpacing: 80, }, node: { style: { size: 20, labelText: (d) => d.id, }, }, edge: { style: (model) => { const { size, color } = model.data as { size: number; color: string }; return { stroke: color || '#99ADD1', lineWidth: size || 1 }; }, }, combo: { style: { labelText: (d) => d.id, padding: 10, }, }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas'], autoFit: 'view', }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-compact-box-basic.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutCompactBoxBasic: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element', 'collapse-expand'], node: { style: { labelText: (data) => data.id, labelPlacement: 'right', labelMaxWidth: 200, ports: [{ placement: 'right' }, { placement: 'left' }], }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'compact-box', direction: 'LR', getHeight: function getHeight() { return 32; }, getWidth: function getWidth() { return 32; }, getVGap: function getVGap() { return 10; }, getHGap: function getHGap() { return 100; }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-compact-box-left-align.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import type { NodeData } from '@antv/g6'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutCompactBoxTopToBottom: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'], node: { style: { labelText: (data) => data.id, labelPlacement: 'right', labelMaxWidth: 200, size: 12, lineWidth: 1, fill: '#fff', ports: [{ placement: 'right' }, { placement: 'left' }], }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'compact-box', direction: 'LR', getId: function getId(d: NodeData) { return d.id; }, getHeight: function getHeight() { return 16; }, getVGap: function getVGap() { return 10; }, getHGap: function getHGap() { return 100; }, getWidth: function getWidth(d: NodeData) { return d.id.toString().length + 20; }, }, animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-compact-box-top-to-bottom.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import type { NodeData } from '@antv/g6'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutCompactBoxLeftAlign: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { style: { labelText: (data) => data.id, labelPlacement: 'right', labelMaxWidth: 200, transform: [['rotate', 90]], size: 26, fill: '#EFF4FF', lineWidth: 1, stroke: '#5F95FF', ports: [{ placement: 'bottom' }, { placement: 'top' }], }, }, edge: { type: 'cubic-vertical', }, layout: { type: 'compact-box', direction: 'TB', getId: function getId(d: NodeData) { return d.id; }, getHeight: function getHeight() { return 16; }, getWidth: function getWidth() { return 16; }, getVGap: function getVGap() { return 80; }, getHGap: function getHGap() { return 20; }, }, behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-concentric.ts ================================================ import data from '@@/dataset/gene.json'; import { Graph } from '@antv/g6'; export const layoutConcentric: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, layout: { type: 'concentric', maxLevelDiff: 0.5, preventOverlap: true, }, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-custom-dagre.ts ================================================ import { layoutAdapter } from '@/src/utils/layout'; import type { BaseLayoutOptions, GraphData, NodeData } from '@antv/g6'; import { BaseLayout, DagreLayout, ExtensionCategory, Graph, register } from '@antv/g6'; interface CustomLayoutOptions extends BaseLayoutOptions { gap?: number; nodeSize: (node: NodeData) => number; center: [number, number]; } class CustomLayout extends BaseLayout { id = 'custom-layout'; async execute(data: GraphData): Promise { const AdaptiveDagreLayout = layoutAdapter(DagreLayout, this.context); const layout = new AdaptiveDagreLayout(this.context, this.options); const model = await layout.execute(data); return model; } } export const layoutCustomDagre: TestCase = async (context) => { register(ExtensionCategory.LAYOUT, 'custom-layout', CustomLayout); const data = { nodes: [ { id: 'kspacey', data: { label: 'Kevin Spacey', width: 144, height: 100 } }, { id: 'swilliams', data: { label: 'Saul Williams', width: 160, height: 100 } }, { id: 'bpitt', data: { label: 'Brad Pitt', width: 108, height: 100 } }, { id: 'hford', data: { label: 'Harrison Ford', width: 168, height: 100 } }, { id: 'lwilson', data: { label: 'Luke Wilson', width: 144, height: 100 } }, { id: 'kbacon', data: { label: 'Kevin Bacon', width: 121, height: 100 } }, ], edges: [ { id: 'kspacey->swilliams', source: 'kspacey', target: 'swilliams' }, { id: 'swilliams->kbacon', source: 'swilliams', target: 'kbacon' }, { id: 'bpitt->kbacon', source: 'bpitt', target: 'kbacon' }, { id: 'hford->lwilson', source: 'hford', target: 'lwilson' }, { id: 'lwilson->kbacon', source: 'lwilson', target: 'kbacon' }, ], }; const graph = new Graph({ ...context, autoFit: 'center', data, layout: { type: 'custom-layout', }, zoom: 0.8, node: { type: 'rect', style: { labelText: (d) => d.data!.label as string, size: (d) => [d.data!.width, d.data!.height] as [number, number], }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-custom-horizontal.ts ================================================ import { BaseLayout, ExtensionCategory, Graph, idOf, register } from '@antv/g6'; import type { BaseLayoutOptions, GraphData, NodeData } from '@antv/g6'; interface CustomLayoutOptions extends BaseLayoutOptions { gap?: number; nodeSize: (node: NodeData) => number; center: [number, number]; } class CustomLayout extends BaseLayout { id = 'custom-layout'; async execute(data: GraphData): Promise { const { nodes = [] } = data; const { nodeSize, gap = 10 } = this.options; return { nodes: nodes.map((node, index) => { const size = nodeSize(node); return { id: idOf(node), style: { x: index * (size + gap) + size / 2, y: 100, }, }; }), }; } } export const layoutCustomHorizontal: TestCase = async (context) => { register(ExtensionCategory.LAYOUT, 'custom-layout', CustomLayout); const graph = new Graph({ ...context, autoFit: 'center', data: { nodes: Array.from({ length: 10 }).map((_, i) => ({ id: i })), }, layout: { type: 'custom-layout', gap: 10, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-custom-iterative.ts ================================================ import { BaseLayout, ExtensionCategory, Graph, register } from '@antv/g6'; import type { BaseLayoutOptions, GraphData } from '@antv/g6'; interface CustomLayoutOptions extends BaseLayoutOptions { onTick: (data: GraphData) => void; } class CustomIterativeLayout extends BaseLayout { public id = 'custom-layout'; private tickCount = 0; private data?: GraphData; private timer?: number; private resolve?: () => void; private promise?: Promise; async execute(data: GraphData, options: CustomLayoutOptions): Promise { const { onTick } = { ...this.options, ...options }; this.tickCount = 0; this.data = data; this.promise = new Promise((resolve) => { this.resolve = resolve; }); this.timer = window.setInterval(() => { onTick(this.simulateTick()); if (this.tickCount === 10) this.stop(); }, 200); await this.promise; return this.simulateTick(); } simulateTick = () => { const x = this.tickCount++ % 2 === 0 ? 50 : 150; return { nodes: (this?.data?.nodes || []).map((node, index) => ({ id: node.id, style: { x, y: 100 + index * 30 }, })), }; }; tick = () => { return this.simulateTick(); }; stop = () => { clearInterval(this.timer); this.resolve?.(); }; } export const layoutCustomIterative: TestCase = async (context) => { register(ExtensionCategory.LAYOUT, 'custom-iterative', CustomIterativeLayout); const graph = new Graph({ ...context, data: { nodes: Array.from({ length: 10 }).map((_, i) => ({ id: i })), }, layout: { type: 'custom-iterative', gap: 10, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-d3-force.ts ================================================ import data from '@@/dataset/force.json'; import { Graph } from '@antv/g6'; export const layoutD3Force: TestCase = async (context) => { const graph = new Graph({ ...context, data, padding: 20, autoFit: 'view', behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element', 'click-select'], layout: { type: 'd3-force', nodeSize: (d: { style: { size: number } }) => (d.style.size as number) * 2, collide: { strength: 0.5, }, }, node: { style: { labelText: (d) => d.id, labelMaxWidth: '300%', }, palette: { type: 'group', field: 'cluster', color: [ '#1783FF', '#00C9C9', '#F08F56', '#D580FF', '#7863FF', '#DB9D0D', '#60C42D', '#FF80CA', '#2491B3', '#17C76F', ], }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-dagre.ts ================================================ import { Graph } from '@antv/g6'; export const layoutDagre: TestCase = async (context) => { const data = { nodes: [ { id: 'kspacey', data: { label: 'Kevin Spacey', width: 144, height: 100 } }, { id: 'swilliams', data: { label: 'Saul Williams', width: 160, height: 100 } }, { id: 'bpitt', data: { label: 'Brad Pitt', width: 108, height: 100 } }, { id: 'hford', data: { label: 'Harrison Ford', width: 168, height: 100 } }, { id: 'lwilson', data: { label: 'Luke Wilson', width: 144, height: 100 } }, { id: 'kbacon', data: { label: 'Kevin Bacon', width: 121, height: 100 } }, ], edges: [ { id: 'kspacey->swilliams', source: 'kspacey', target: 'swilliams' }, { id: 'swilliams->kbacon', source: 'swilliams', target: 'kbacon' }, { id: 'bpitt->kbacon', source: 'bpitt', target: 'kbacon' }, { id: 'hford->lwilson', source: 'hford', target: 'lwilson' }, { id: 'lwilson->kbacon', source: 'lwilson', target: 'kbacon' }, ], }; const graph = new Graph({ ...context, data, layout: { type: 'dagre', }, zoom: 0.8, node: { type: 'rect', style: { labelText: (d) => d.data!.label as string, size: (d) => [d.data!.width, d.data!.height] as [number, number], }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-dendrogram-basic.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutDendrogramBasic: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { style: { labelText: (d) => d.id, labelPlacement: (model) => (model.children?.length ? 'left' : 'right'), ports: [{ placement: 'right' }, { placement: 'left' }], }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'dendrogram', direction: 'LR', nodeSep: 36, rankSep: 250, preLayout: true, }, behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element', 'collapse-expand'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-dendrogram-radial.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutDendrogramRadial: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { style: { labelText: (d) => d.id, }, }, layout: { type: 'dendrogram', radial: true, nodeSep: 30, rankSep: 200, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-dendrogram-tb.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutDendrogramTb: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { style: (d) => { const isLeafNode = !d.children?.length; const style = { labelText: d.id, labelPlacement: 'right', labelOffsetX: 2, labelBackground: true, ports: [{ placement: 'top' }, { placement: 'bottom' }], }; if (isLeafNode) { Object.assign(style, { labelTransform: [ ['rotate', 90], ['translate', 18], ], labelBaseline: 'center', labelTextAlign: 'left', }); } return style; }, animation: { enter: false, }, }, edge: { type: 'cubic-vertical', }, layout: { type: 'dendrogram', direction: 'TB', nodeSep: 40, rankSep: 100, preLayout: true, }, behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element', 'collapse-expand'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-fishbone.ts ================================================ import { Graph, treeToGraphData } from '@antv/g6'; const data = { id: 'Quality', children: [ { id: 'Machine', children: [{ id: 'Mill' }, { id: 'Mixer' }, { id: 'Metal Lathe', children: [{ id: 'Milling' }] }], }, { id: 'Method' }, { id: 'Material', children: [ { id: 'Masonite', children: [ { id: 'spearMint' }, { id: 'pepperMint', children: [{ id: 'test3' }] }, { id: 'test1', children: [{ id: 'test4' }] }, ], }, { id: 'Marscapone', children: [{ id: 'Malty' }, { id: 'Minty' }], }, { id: 'Meat', children: [{ id: 'Mutton' }] }, ], }, { id: 'Man Power', children: [ { id: 'Manager' }, { id: "Master's Student" }, { id: 'Magician' }, { id: 'Miner' }, { id: 'Magister', children: [{ id: 'Malpractice' }] }, { id: 'Massage Artist', children: [{ id: 'Masseur' }, { id: 'Masseuse' }], }, ], }, { id: 'Measurement', children: [{ id: 'Malleability' }], }, { id: 'Milieu', children: [{ id: 'Marine' }], }, ], }; export const layoutFishbone: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { type: 'rect', style: { size: [32, 32], // fill: () => randomColor(), label: false, labelFill: '#262626', labelFontFamily: 'Gill Sans', labelMaxLines: 2, labelMaxWidth: '100%', labelPlacement: 'center', labelText: (d) => d.id, labelWordWrap: true, }, }, edge: { type: 'polyline', style: { lineWidth: 3, }, }, layout: { type: 'fishbone', vGap: 48, hGap: 48, direction: 'RL', }, behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'], animation: false, }); await graph.render(); layoutFishbone.form = (panel) => { const config = { type: 'fishbone', direction: 'RL', }; return [ panel .add(config, 'direction', ['LR', 'RL']) .name('Direction') .onChange((value: 'LR' | 'RL') => { graph.setLayout((prev) => ({ ...prev, direction: value })); graph.render(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-force-collision.ts ================================================ // ref: https://observablehq.com/@d3/collision-detection import { invokeLayoutMethod } from '@/src/utils/layout'; import type { IPointerEvent, RuntimeContext } from '@antv/g6'; import { BaseBehavior, ExtensionCategory, Graph, register } from '@antv/g6'; export const layoutForceCollision: TestCase = async (context) => { const width = 500; class CollisionElement extends BaseBehavior { constructor(context: RuntimeContext) { super(context, {}); this.onPointerMove = this.onPointerMove.bind(this); this.bindEvents(); } bindEvents() { this.context.graph.on('pointermove', this.onPointerMove); } onPointerMove(event: IPointerEvent) { const pos = this.context.graph.getCanvasByClient([event.client.x, event.client.y]); const layoutInstance = this.context.layout ?.getLayoutInstance() .find((layout) => ['d3-force', 'd3-force-3d'].includes(layout?.id)); if (layoutInstance) { invokeLayoutMethod(layoutInstance, 'setFixedPosition', '0', [...pos]); } } } register(ExtensionCategory.BEHAVIOR, 'collision-element', CollisionElement); const graph = new Graph({ ...context, data: getData(500), layout: { type: 'd3-force', alphaTarget: 0.3, velocityDecay: 0.1, x: { strength: 0.01, x: width / 2, }, y: { strength: 0.01, y: width / 2, }, nodeSize: (d: { data: { r: number } }) => (d.data.r as number) * 2, collide: { iterations: 3, }, manyBody: { strength: (d: any, i: number) => (i ? 0 : (-width * 2) / 3), }, link: false, }, node: { style: { size: (d) => (d.id === '0' ? 0 : (d.data!.r as number) * 2), }, palette: { color: 'tableau', type: 'group', field: (d) => d.data!.group as string, }, }, behaviors: ['collision-element'], }); await graph.render(); return graph; }; function getData(width: number, size = 200) { const k = width / 200; const r = randomUniform(k * 2, k * 8); const n = 4; return { nodes: Array.from({ length: size }, (_, i) => ({ id: `${i}`, data: { r: r(), group: i && (i % n) + 1 } })), edges: [], }; } // d3-random function randomUniform(min: number, max: number) { min = min == null ? 0 : +min; max = max == null ? 1 : +max; if (arguments.length === 1) { max = min; min = 0; } else max -= min; return function () { return Math.random() * max + min; }; } ================================================ FILE: packages/g6/__tests__/demos/layout-force-lattice.ts ================================================ // ref: https://observablehq.com/@d3/force-directed-lattice import { Graph } from '@antv/g6'; export const layoutForceLattice: TestCase = async (context) => { const graph = new Graph({ ...context, data: getData(), layout: { type: 'd3-force', manyBody: { strength: -30, }, link: { strength: 1, distance: 20, iterations: 10, }, }, node: { style: { size: 10, fill: '#000', }, }, edge: { style: { stroke: '#000', }, }, behaviors: [{ type: 'drag-element-force' }, 'zoom-canvas'], }); await graph.render(); return graph; }; function getData(size = 10) { const nodes = Array.from({ length: size * size }, (_, i) => ({ id: `${i}` })); const edges = []; for (let y = 0; y < size; ++y) { for (let x = 0; x < size; ++x) { if (y > 0) edges.push({ source: `${(y - 1) * size + x}`, target: `${y * size + x}` }); if (x > 0) edges.push({ source: `${y * size + (x - 1)}`, target: `${y * size + x}` }); } } return { nodes, edges }; } ================================================ FILE: packages/g6/__tests__/demos/layout-force.ts ================================================ import data from '@@/dataset/force.json'; import { Graph } from '@antv/g6'; export const layoutForce: TestCase = async (context) => { const graph = new Graph({ ...context, data, padding: 20, autoFit: 'view', behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element', 'click-select'], layout: { type: 'force', }, node: { style: { labelText: (d) => d.id, labelMaxWidth: '300%', }, palette: { type: 'group', field: 'cluster', color: [ '#1783FF', '#00C9C9', '#F08F56', '#D580FF', '#7863FF', '#DB9D0D', '#60C42D', '#FF80CA', '#2491B3', '#17C76F', ], }, }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-forceatlas2-wasm.ts ================================================ import data from '@@/dataset/soccer.json'; import type { GraphOptions } from '@antv/g6'; import { Graph, register } from '@antv/g6'; export const layoutForceatlas2WASM: TestCase = async (context) => { const { ForceAtlas2Layout, initThreads, supportsThreads } = await import('@antv/layout-wasm'); register('layout', 'forceatlas2-wasm', ForceAtlas2Layout as any); const supported = await supportsThreads(); const threads = await initThreads(supported); const options: GraphOptions = { ...context, data, theme: 'light', layout: { type: 'forceatlas2-wasm', threads, dimensions: 2, maxIteration: 100, minMovement: 0.4, distanceThresholdMode: 'mean', kg: 5, kr: 10, ks: 0.1, }, node: { style: { size: 20 } }, }; const graph = new Graph(options); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-fruchterman-basic.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const layoutFruchtermanBasic: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelPlacement: 'center', labelText: (d) => d.id, }, }, layout: { type: 'fruchterman', gravity: 5, speed: 5, nodeClusterBy: 'data.cluster', }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-fruchterman-cluster.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const layoutFruchtermanCluster: TestCase = async (context) => { const graph = new Graph({ ...context, data: { ...data, nodes: data.nodes.map((n) => ({ ...n, cluster: n.data.cluster })) }, node: { style: { labelPlacement: 'center', labelText: (d) => d.id, }, palette: { field: 'cluster', }, }, layout: { type: 'fruchterman', gravity: 5, speed: 5, clustering: true, nodeClusterBy: 'cluster', }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-fruchterman-fix.ts ================================================ import data from '@@/dataset/relations.json'; import { Graph } from '@antv/g6'; export const layoutFruchtermanFix: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'fruchterman', speed: 10, maxIteration: 500, }, behaviors: ['drag-canvas', 'drag-element'], }); graph.on('node:dragstart', function () { graph.stopLayout(); }); graph.on('node:dragend', function () { // FIXME: 不应该完全重新布局,而是以当前画布数据进行布局 graph.layout(); }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-fruchterman-gpu.ts ================================================ import data from '@@/dataset/soccer.json'; import type { GraphOptions } from '@antv/g6'; import { Graph, register } from '@antv/g6'; export const layoutFruchtermanGPU: TestCase = async (context) => { register('layout', 'fruchterman-gpu', (await import('@antv/layout-gpu')).FruchtermanLayout as any); const options: GraphOptions = { ...context, data, theme: 'light', layout: { type: 'fruchterman-gpu', maxIteration: 1000, minMovement: 0.4, distanceThresholdMode: 'mean', gravity: 1, speed: 5, }, node: { style: { size: 20 } }, }; const graph = new Graph(options); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-fruchterman-wasm.ts ================================================ import data from '@@/dataset/soccer.json'; import type { GraphOptions } from '@antv/g6'; import { Graph, register } from '@antv/g6'; export const layoutFruchtermanWASM: TestCase = async (context) => { const { FruchtermanLayout, initThreads, supportsThreads } = await import('@antv/layout-wasm'); register('layout', 'fruchterman-wasm', FruchtermanLayout as any); const supported = await supportsThreads(); const threads = await initThreads(supported); const options: GraphOptions = { ...context, data, theme: 'light', layout: { type: 'fruchterman-wasm', threads, dimensions: 2, maxIteration: 1000, minMovement: 0.4, distanceThresholdMode: 'mean', gravity: 1, speed: 5, }, node: { style: { size: 20 } }, }; const graph = new Graph(options); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-grid.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const layoutGrid: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, }, }, layout: { type: 'grid', sortBy: (d) => d.data.cluster, }, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element', 'click-select'], }); await graph.render(); layoutGrid.form = (panel) => { const config = { sortBy: 'degree', }; return [ panel .add(config, 'sortBy', { ID: 'id', Degree: 'degree', Cluster: (n1: any, n2: any) => Number(n2.data.cluster) - Number(n1.data.cluster), }) .name('sortBy') .onChange((value: string) => { graph.setLayout({ type: 'grid', sortBy: value }); graph.layout(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-indented.ts ================================================ import tree from '@@/dataset/file-system.json'; import type { GraphOptions } from '@antv/g6'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutIndented: TestCase = async (context) => { const options: GraphOptions = { ...context, y: -200, zoom: 0.5, data: treeToGraphData(tree), theme: 'light', layout: { type: 'indented', isHorizontal: true, direction: 'LR', indent: 30, getHeight: function getHeight() { return 16; }, getWidth: function getWidth() { return 16; }, }, node: { style: { size: 20 } }, edge: { type: 'polyline', }, }; const graph = new Graph(options); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-mds.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const layoutMDS: TestCase = async (context) => { const graph = new Graph({ ...context, padding: 20, autoFit: 'view', data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', }, }, layout: { type: 'mds', linkDistance: 100, }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas', 'click-select'], }); await graph.render(); layoutMDS.form = (panel) => { const config = { linkDistance: 100, }; return [ panel.add(config, 'linkDistance', 50, 120, 10).onChange((v: number) => { graph.setLayout({ type: 'mds', linkDistance: v }); graph.layout(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-mindmap-h-custom-side.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import type { NodeData } from '@antv/g6'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutMindmapHCustomSide: TestCase = async (context) => { const graph = new Graph({ ...context, data: treeToGraphData(data), autoFit: 'view', node: { style: function (this: Graph, model) { const root = this.getNodeData().find((node) => node.depth === 0); const rootX = Number(root?.style?.x ?? 0); const x = Number(model.style?.x ?? 0) - rootX; return { labelText: model.id, size: 26, labelPlacement: x >= 0 ? 'right' : 'left', labelMaxWidth: 200, labelTextAlign: x >= 0 ? 'start' : 'end', lineWidth: 1, stroke: '#5F95FF', fill: '#EFF4FF', ports: [{ placement: 'right' }, { placement: 'left' }], }; }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'mindmap', direction: 'H', preLayout: false, getHeight: () => { return 16; }, getWidth: () => { return 16; }, getVGap: () => { return 10; }, getHGap: () => { return 50; }, getSide: (d: NodeData) => { if (d.id === 'Classification') { return 'left'; } return 'right'; }, }, behaviors: ['collapse-expand', 'drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-mindmap-h-left.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutMindmapHLeft: TestCase = async (context) => { const graph = new Graph({ ...context, data: treeToGraphData(data), autoFit: 'view', node: { style: (model) => { return { labelText: model.id, size: 26, labelPlacement: 'left', labelMaxWidth: 200, labelTextAlign: 'end', lineWidth: 1, stroke: '#5F95FF', fill: '#EFF4FF', ports: [{ placement: 'right' }, { placement: 'left' }], }; }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'mindmap', direction: 'H', getHeight: () => { return 16; }, getWidth: () => { return 16; }, getVGap: () => { return 10; }, getHGap: () => { return 100; }, getSide: () => { return 'left'; }, }, behaviors: ['collapse-expand', 'drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-mindmap-h-right.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutMindmapHRight: TestCase = async (context) => { const graph = new Graph({ ...context, data: treeToGraphData(data), autoFit: 'view', node: { style: (model) => { return { labelText: model.id, size: 26, labelPlacement: 'right', labelMaxWidth: 200, lineWidth: 1, stroke: '#5F95FF', fill: '#EFF4FF', ports: [{ placement: 'right' }, { placement: 'left' }], }; }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'mindmap', direction: 'H', getHeight: () => { return 16; }, getWidth: () => { return 16; }, getVGap: () => { return 10; }, getHGap: () => { return 100; }, getSide: () => { return 'right'; }, }, behaviors: ['collapse-expand', 'drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-mindmap-h.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const layoutMindmapH: TestCase = async (context) => { const graph = new Graph({ ...context, data: treeToGraphData(data), autoFit: 'view', node: { style: function (this: Graph, model) { const root = this.getNodeData().find((node) => node.depth === 0); const rootX = Number(root?.style?.x ?? 0); const x = Number(model.style?.x ?? 0) - rootX; return { labelText: model.id, size: 26, labelPlacement: x >= 0 ? 'right' : 'left', labelMaxWidth: 200, labelTextAlign: x >= 0 ? 'start' : 'end', lineWidth: 1, stroke: '#5F95FF', fill: '#EFF4FF', ports: [{ placement: 'right' }, { placement: 'left' }], }; }, }, edge: { type: 'cubic-horizontal', }, layout: { type: 'mindmap', direction: 'H', preLayout: false, getHeight: () => { return 16; }, getWidth: () => { return 16; }, getVGap: () => { return 10; }, getHGap: () => { return 50; }, }, behaviors: ['collapse-expand', 'drag-canvas', 'zoom-canvas'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-pipeline-mds-force.ts ================================================ import { Graph } from '@antv/g6'; export const layoutPipelineMdsForce: TestCase = async (context) => { const data = { nodes: [ { id: '2023022111330994' }, { id: '2023022131662846' }, { id: '2023022134006229' }, { id: '2023022134355387' }, { id: '2023022134649283' }, { id: '2023022135378377' }, { id: '2023022159807939' }, { id: '2023022171679817' }, { id: '2023022192941079' }, { id: '2023032121629632' }, { id: '2023032149712027' }, { id: '2023032171523093' }, { id: '2023032439702273' }, { id: '2023053113971286' }, { id: '2023062814858004' }, { id: '2023083116793312' }, { id: '2023083116798008' }, { id: '2023083116802328' }, { id: '2023083116802329' }, { id: '2023092717337264' }, { id: '2022042607099685' }, { id: '2023022115050705' }, { id: '2023022124015954' }, { id: '2023022160748942' }, { id: '2023022176798458' }, { id: '2023022183981042' }, { id: '2023032138615654' }, { id: '2023033152992057' }, { id: '2023062614749332' }, { id: '2023083016776599' }, { id: '2023083116802327' }, { id: '2023090716992598' }, { id: '2023112718364754' }, { id: '2023112918428536' }, { id: '10' }, { id: '11' }, { id: '13' }, ], edges: [ { source: '2023032149712027', target: '2022042607099685', id: '2024030419919960' }, { source: '2023033152992057', target: '2023022124015954', id: '2024030419923397' }, { source: '2023092717337264', target: '2022042607099685', id: '2024022919878863' }, { source: '2023092717337264', target: '2023033152992057', id: '2024022919878864' }, { source: '2023092717337264', target: '2023022135378377', id: '2024022919878865' }, { source: '2023022159807939', target: '2023022183981042', id: '2023022171425708' }, { source: '2023022183981042', target: '2023022159807939', id: '2024022919872476' }, { source: '2023022171679817', target: '2023083016776599', id: '2024013019576232' }, { source: '2023083116802327', target: '2023032149712027', id: '2023102317587829' }, { source: '2023083116802327', target: '2023092717337264', id: '2023092717340047' }, { source: '2023083116802328', target: '2023092717337264', id: '2023092717340048' }, { source: '2023083116802329', target: '2023092717337264', id: '2023092717340049' }, { source: '2023092717337264', target: '2022042607099685', id: '2023092717337342' }, { source: '2023053113971286', target: '2023053113971286', id: '2023071415272433' }, { source: '2023062814858004', target: '2023062814858004', id: '2023062814858057' }, { source: '2023062614749332', target: '2023062614749332', id: '2023062614749380' }, { source: '2023022159807939', target: '2023053113971286', id: '2023053113971328' }, { source: '2023053113971286', target: '2023053113971286', id: '2023053113971329' }, { source: '2023032171523093', target: '2023032171523093', id: '2023032188641552' }, { source: '2023032138615654', target: '2023032138615654', id: '2023032163869608' }, { source: '2023032439702273', target: '2023032439702273', id: '2023032461304126' }, { source: '2023033152992057', target: '2023032149712027', id: '2023033129305007' }, { source: '2023033152992057', target: '2023022134006229', id: '2023040438213028' }, { source: '2023032121629632', target: '2023032121629632', id: '2023032184876493' }, { source: '2023022124015954', target: '2023032149712027', id: '2023041947056477' }, { source: '2023022134006229', target: '2023022192941079', id: '2023022160953640' }, { source: '2023033152992057', target: '2023022124015954', id: '2023041466404158' }, { source: '2023022192941079', target: '2023032149712027', id: '2023032412723482' }, { source: '2023022124015954', target: '2023022131662846', id: '2023041985653086' }, { source: '2023032149712027', target: '2023032149712027', id: '2023032177181441' }, { source: '2023022134006229', target: '2023022135378377', id: '2023022191267699' }, { source: '2023022134006229', target: '2023022115050705', id: '2023022113765807' }, { source: '2022042607099685', target: '2022042607099685', id: '2022042607099906' }, { source: '2023022192941079', target: '13', id: '1000' }, { source: '2023083116802329', target: '10', id: '1001' }, { source: '2023022124015954', target: '11', id: '1002' }, { source: '2023033152992057', target: '10', id: '1003' }, ], }; const graph = new Graph({ ...context, data, layout: [ { type: 'mds', animation: false }, { type: 'force', animation: false, preventOverlap: true, nodeSize: 32, maxSpeed: 500, leafCluster: true, clustering: false, clusterNodeStrength: 35, minMovement: 1.5, }, ], autoFit: 'view', }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-radial-basic.ts ================================================ import data from '@@/dataset/radial.json'; import { Graph } from '@antv/g6'; export const layoutRadialBasic: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', }, }, layout: { type: 'radial', unitRadius: 50, }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-radial-configuration-translate.ts ================================================ import data from '@@/dataset/radial.json'; import { Graph } from '@antv/g6'; export const layoutRadialConfigurationTranslate: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'radial', unitRadius: 50, }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-radial-prevent-overlap-unstrict.ts ================================================ import data from '@@/dataset/radial.json'; import { Graph } from '@antv/g6'; export const layoutRadialPreventOverlapUnstrict: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'radial', unitRadius: 70, preventOverlap: true, strictRadial: false, }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-radial-prevent-overlap.ts ================================================ import data from '@@/dataset/radial.json'; import { Graph } from '@antv/g6'; export const layoutRadialPreventOverlap: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'radial', unitRadius: 50, preventOverlap: true, maxPreventOverlapIteration: 100, }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-radial-sort.ts ================================================ import data from '@@/dataset/radial.json'; import { Graph } from '@antv/g6'; export const layoutRadialSort: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', }, }, edge: { style: { endArrow: true, endArrowType: 'vee', }, }, layout: { type: 'radial', unitRadius: 70, maxIteration: 1000, linkDistance: 10, preventOverlap: true, nodeSize: 30, sortBy: (d) => d!.data.sortAttr2, sortStrength: 50, }, behaviors: ['drag-canvas', 'drag-element'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/layout-snake.ts ================================================ import { Graph } from '@antv/g6'; const data = { nodes: [ { id: '0', data: { label: '开始流程', time: '17:00:00' } }, { id: '1', data: { label: '流程1', time: '17:00:05' } }, { id: '2', data: { label: '流程2', time: '17:00:12' } }, { id: '3', data: { label: '流程3', time: '17:00:30' } }, { id: '4', data: { label: '流程4', time: '17:02:00' } }, { id: '5', data: { label: '流程5', time: '17:02:40' } }, { id: '6', data: { label: '流程6', time: '17:05:50' } }, { id: '7', data: { label: '流程7', time: '17:10:00' } }, { id: '8', data: { label: '流程8', time: '17:11:20' } }, { id: '9', data: { label: '流程9', time: '17:15:00' } }, { id: '10', data: { label: '流程10', time: '17:30:00' } }, { id: '11', data: { label: '流程11' } }, { id: '12', data: { label: '流程12' } }, { id: '13', data: { label: '流程13' } }, { id: '14', data: { label: '流程14' } }, { id: '15', data: { label: '流程结束' } }, ], edges: [ { source: '0', target: '1', data: { done: true } }, { source: '1', target: '2', data: { done: true } }, { source: '2', target: '3', data: { done: true } }, { source: '3', target: '4', data: { done: true } }, { source: '4', target: '5', data: { done: true } }, { source: '5', target: '6', data: { done: true } }, { source: '6', target: '7', data: { done: true } }, { source: '7', target: '8', data: { done: true } }, { source: '8', target: '9', data: { done: true } }, { source: '9', target: '10', data: { done: true } }, { source: '10', target: '11', data: { done: false } }, { source: '11', target: '12', data: { done: false } }, { source: '12', target: '13', data: { done: false } }, { source: '13', target: '14', data: { done: false } }, { source: '14', target: '15', data: { done: false } }, ], }; export const layoutSnake: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id, labelPlacement: 'center', labelFill: '#fff', }, }, edge: { style: { endArrow: true, }, }, layout: { key: 'snake', type: 'snake', }, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/perf-20000-elements.ts ================================================ import type { GraphOptions } from '@antv/g6'; import { Graph } from '@antv/g6'; export const perf20000Elements: TestCase = async (context) => { const data = await fetch('https://assets.antv.antgroup.com/g6/20000.json').then((res) => res.json()); const graph = new Graph({ ...context, animation: false, data, node: { style: { size: 8, fill: 'gray', }, }, theme: false, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'], autoFit: 'view', plugins: [{ type: 'background', background: '#fff' }], }); console.time('time'); await graph.render(); console.timeEnd('time'); perf20000Elements.form = (gui) => { const themes: Record = { '🌞 Light': { theme: 'light', node: { palette: { type: 'group', field: 'cluster', }, }, plugins: [{ type: 'background', background: '#fff' }], }, '🌚 Dark': { theme: 'dark', node: { palette: { type: 'group', field: 'cluster', }, }, plugins: [{ type: 'background', background: '#000' }], }, '🌎 Blue': { theme: 'light', node: { palette: { type: 'group', field: 'cluster', color: 'blues', invert: true, }, }, plugins: [{ type: 'background', background: '#f3faff' }], }, '🌕 Yellow': { background: '#fcf9f1', theme: 'light', node: { palette: { type: 'group', field: 'cluster', color: ['#ffe7ba', '#ffd591', '#ffc069', '#ffa940', '#fa8c16', '#d46b08', '#ad4e00', '#873800', '#612500'], }, }, plugins: [{ type: 'background', background: '#fcf9f1' }], }, }; return [ gui.add({ theme: '🌞 Light' }, 'theme', Object.keys(themes)).onChange((theme: string) => { graph.setOptions(themes[theme]); graph.draw(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/perf-fcp.ts ================================================ import { Graph } from '@antv/g6'; export const perfFCP: TestCase = async (context) => { const data = { nodes: new Array(1000).fill(undefined).map((_, i) => ({ id: `${i}` })), }; const graph = new Graph({ ...context, animation: false, data, node: { type: 'circle', // 👈🏻 Node shape type. style: { size: 40, labelText: (d) => d.id!, iconHeight: 20, iconWidth: 20, iconFontFamily: 'iconfont', iconText: '\ue602', }, }, layout: { type: 'grid', }, }); const timeStart = performance.now(); await graph.render(); const timeElapsed = performance.now() - timeStart; console.log('timeElapsed', timeElapsed); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-background.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const pluginBackground: TestCase = async (context) => { const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, behaviors: ['drag-canvas', 'drag-element'], plugins: [ { type: 'background', key: 'background', backgroundImage: 'url(https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*0Qq0ToQm1rEAAAAAAAAAAAAADmJ7AQ/original)', }, ], }); await graph.render(); pluginBackground.form = (panel) => { const config = { backgroundSize: 'cover', }; return [ panel .add(config, 'backgroundSize', { Cover: 'cover', Contain: 'contain', }) .name('backgroundSize') .onChange((backgroundSize: string) => { graph.updatePlugin({ key: 'background', backgroundSize, }); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-bubble-sets.ts ================================================ import type { BubbleSetsOptions } from '@/src/plugins'; import { idOf } from '@/src/utils/id'; import { BubbleSets, Graph } from '@antv/g6'; const data = { nodes: [ { id: 'node0', style: { size: 50, x: 220, y: 326 }, }, { id: 'node1', style: { size: 30, x: 426, y: 421 }, }, { id: 'node2', style: { size: 30, x: 329, y: 88 }, }, { id: 'node3', style: { size: 30, x: -16, y: 255 }, }, { id: 'node4', style: { size: 30, x: 79, y: 493 }, }, { id: 'node5', style: { size: 30, x: 235, y: 540 }, }, { id: 'node6', style: { size: 15, x: 428, y: 547 }, }, { id: 'node7', style: { size: 15, x: 546, y: 371 }, }, { id: 'node8', style: { size: 15, x: 333, y: -57 }, }, { id: 'node9', style: { size: 15, x: 202, y: -8 }, }, { id: 'node10', style: { size: 15, x: 473, y: 145 }, }, { id: 'node11', style: { size: 15, x: 458, y: 12 }, }, { id: 'node12', style: { size: 15, x: 353, y: 221 }, }, { id: 'node13', style: { size: 15, x: 201, y: 133 }, }, { id: 'node14', style: { size: 15, x: 94, y: 241 }, }, { id: 'node15', style: { size: 15, x: -67, y: 127 }, }, { id: 'node16', style: { size: 15, x: -91, y: 359 }, }, ], edges: [ { id: 'edge1', source: 'node0', target: 'node1', }, { id: 'edge2', source: 'node0', target: 'node2', }, { id: 'edge3', source: 'node0', target: 'node3', }, { id: 'edge4', source: 'node0', target: 'node4', }, { id: 'edge5', source: 'node0', target: 'node5', }, { id: 'edge6', source: 'node1', target: 'node6', }, { id: 'edge7', source: 'node1', target: 'node7', }, { id: 'edge8', source: 'node2', target: 'node8', }, { id: 'edge9', source: 'node2', target: 'node9', }, { id: 'edge10', source: 'node2', target: 'node10', }, { id: 'edge11', source: 'node2', target: 'node11', }, { id: 'edge12', source: 'node2', target: 'node12', }, { id: 'edge13', source: 'node2', target: 'node13', }, { id: 'edge14', source: 'node3', target: 'node14', }, { id: 'edge15', source: 'node3', target: 'node15', }, { id: 'edge16', source: 'node3', target: 'node16', }, ], combos: [], }; export const pluginBubbleSets: TestCase = async (context) => { const graph = new Graph({ ...context, data, behaviors: ['drag-canvas', 'drag-element'], plugins: [ { key: 'bubble-sets', type: 'bubble-sets', members: ['node0', 'node1'], labelText: 'Bubble', }, ], node: { style: { labelText: (d) => d.id } }, autoFit: 'view', }); await graph.render(); pluginBubbleSets.form = (panel) => { const bubblesets = graph.getPluginInstance('bubble-sets'); const config = { member: 'node0', // default options in bubblesets-js // More info see: https://github.com/upsetjs/bubblesets-js/blob/main/src/BubbleSets.ts maxRoutingIterations: 100, maxMarchingIterations: 20, pixelGroup: 4, edgeR0: 10, edgeR1: 20, nodeR0: 15, nodeR1: 50, morphBuffer: 10, threshold: 1, memberInfluenceFactor: 1, edgeInfluenceFactor: 1, AvoidMemberInfluenceFactor: -0.8, virtualEdges: true, }; const members = [ ...graph.getNodeData().map(idOf), ...graph.getEdgeData().map(idOf), ...graph.getComboData().map(idOf), ]; const panels = [ panel.add(config, 'member', members).name('Element'), panel .add( { AddMember: () => { bubblesets.addMember(config.member); }, }, 'AddMember', ) .name('Add Element as Member'), panel .add( { RemoveMember: () => { bubblesets.removeMember(config.member); }, }, 'RemoveMember', ) .name('Remove Element as Member'), panel .add( { AddAvoidMember: () => { bubblesets.addAvoidMember(config.member); }, }, 'AddAvoidMember', ) .name('Add Element as Non-Member'), panel .add( { RemoveMember: () => { bubblesets.removeAvoidMember(config.member); }, }, 'RemoveMember', ) .name('Remove Element as Non-Member'), ]; const updateOptions = (options: BubbleSetsOptions) => { graph.updatePlugin({ key: 'bubble-sets', ...options, }); graph.render(); }; Object.keys(config) .slice(1, -1) .forEach((key) => { panels.push( panel.add(config, key, 0, 100, 1).onChange((value: number) => { updateOptions({ [key]: value } as BubbleSetsOptions); }), ); }); panels.push( panel.add(config, 'virtualEdges').onChange((value: boolean) => { updateOptions({ virtualEdges: value } as BubbleSetsOptions); }), ); return panels; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-camera-setting.ts ================================================ import data from '@@/dataset/cluster.json'; import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; export const pluginCameraSetting: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); const graph = new Graph({ ...context, data, layout: { type: 'd3-force' }, plugins: [{ key: 'camera-setting', type: 'camera-setting' }], }); pluginCameraSetting.form = (panel) => { const config = { cameraType: 'orbiting', near: 0.1, far: 1000, fov: 45, // aspect: 'auto', projectionMode: 'orthographic', distance: 500, roll: 0, elevation: 0, azimuth: 0, }; const handleChange = () => { graph.updatePlugin({ key: 'camera-setting', type: 'camera-setting', ...config, }); }; panel.onChange(handleChange); return [ panel.add(config, 'cameraType', ['orbiting', 'exploring', 'tracking']).name('Camera Type'), panel.add(config, 'near', 0.1, 10, 0.1).name('Near'), panel.add(config, 'far', 100, 1000, 10).name('Far'), panel.add(config, 'fov', 0, 180, 1).name('Fov'), // panel.add(config, 'aspect', ['auto', 'custom']).name('Aspect'), panel.add(config, 'projectionMode', ['orthographic', 'perspective']).name('Projection Mode'), panel.add(config, 'distance', 100, 1000, 10).name('Distance'), panel.add(config, 'roll', -180, 180, 1).name('Roll'), panel.add(config, 'elevation', -90, 90, 1).name('Elevation'), panel.add(config, 'azimuth', -180, 180, 1).name('Azimuth'), ]; }; await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-contextmenu.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const pluginContextmenu: TestCase = async (context) => { const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, behaviors: ['drag-canvas'], plugins: [ { key: 'contextmenu', type: 'contextmenu', trigger: 'contextmenu', className: 'custom-class-name', getItems: () => { return [ { name: '展开一度关系', value: 'spread' }, { name: '查看详情', value: 'detail' }, ]; }, enable: (e: any) => e.targetType === 'node', }, ], }); await graph.render(); pluginContextmenu.form = (panel) => { const config = { trigger: 'contextmenu', }; return [ panel .add(config, 'trigger', { Click: 'click', Contextmenu: 'contextmenu', }) .name('Trigger') .onChange((trigger: string) => { graph.setPlugins([ { type: 'contextmenu', trigger, getContextmenuItems: () => { return [ { name: '展开一度关系', value: 'spread' }, { name: '查看详情', value: 'detail' }, ]; }, enable: (e: any) => e.targetType === 'node', }, ]); graph.render(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-edge-bundling.ts ================================================ import { Graph } from '@antv/g6'; const data = { nodes: [ { id: '0', label: '0', }, { id: '1', label: '1', }, { id: '2', label: '2', }, { id: '3', label: '3', }, { id: '4', label: '4', }, { id: '5', label: '5', }, { id: '6', label: '6', }, { id: '7', label: '7', }, { id: '8', label: '8', }, { id: '9', label: '9', }, { id: '10', label: '10', }, { id: '11', label: '11', }, { id: '12', label: '12', }, { id: '13', label: '13', }, { id: '14', label: '14', }, { id: '15', label: '15', }, { id: '16', label: '16', }, { id: '17', label: '17', }, { id: '18', label: '18', }, { id: '19', label: '19', }, { id: '20', label: '20', }, { id: '21', label: '21', }, { id: '22', label: '22', }, { id: '23', label: '23', }, { id: '24', label: '24', }, { id: '25', label: '25', }, { id: '26', label: '26', }, { id: '27', label: '27', }, { id: '28', label: '28', }, { id: '29', label: '29', }, { id: '30', label: '30', }, { id: '31', label: '31', }, { id: '32', label: '32', }, { id: '33', label: '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', }, ], }; export const pluginEdgeBundling: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { size: 12, labelText: (d) => d.id, labelFontSize: 8, labelFill: '#252525', labelFontFamily: 'Futura', }, }, layout: { type: 'circular', radius: 200, }, plugins: ['edge-bundling'], animation: false, }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-edge-filter-lens.ts ================================================ import data from '@@/dataset/relations.json'; import { Graph } from '@antv/g6'; export const pluginEdgeFilterLens: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { size: 16 }, palette: { field: (datum) => Math.floor(Number(datum.style?.y) / 60), }, }, edge: { style: { label: false, labelText: (d) => d.data!.value?.toString(), stroke: '#ccc', }, }, plugins: [ { key: 'edge-filter-lens', type: 'edge-filter-lens', }, ], autoFit: 'view', }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-fisheye.ts ================================================ import data from '@@/dataset/relations.json'; import type { NodeData } from '@antv/g6'; import { Graph } from '@antv/g6'; export const pluginFisheye: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { style: { size: (datum: NodeData) => datum.id.length * 2 + 10, label: false, labelText: (datum: NodeData) => datum.id, labelBackground: true, icon: false, iconFontFamily: 'iconfont', iconText: '\ue6f6', iconFill: '#fff', }, palette: { type: 'group', field: (datum: NodeData) => datum.id, color: ['#1783FF', '#00C9C9', '#F08F56', '#D580FF'], }, }, edge: { style: { stroke: '#e2e2e2', }, }, plugins: [{ key: 'fisheye', type: 'fisheye', nodeStyle: { label: true, icon: true } }], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-fullscreen.ts ================================================ import data from '@@/dataset/cluster.json'; import { Fullscreen, Graph } from '@antv/g6'; export const pluginFullscreen: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'd3-force' }, plugins: [ { type: 'fullscreen', key: 'fullscreen', }, ], }); graph.setPlugins((prev) => [ ...prev, { type: 'toolbar', key: 'toolbar', position: 'top-left', onClick: (item: string) => { const fullscreenPlugin = graph.getPluginInstance('fullscreen'); if (item === 'request-fullscreen') { fullscreenPlugin.request(); } if (item === 'exit-fullscreen') { fullscreenPlugin.exit(); } }, getItems: () => { return [ { id: 'request-fullscreen', value: 'request-fullscreen' }, { id: 'exit-fullscreen', value: 'exit-fullscreen' }, ]; }, }, ]); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-grid-line.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const pluginGridLine: TestCase = async (context) => { const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, behaviors: ['drag-canvas', 'zoom-canvas'], plugins: [{ type: 'grid-line', follow: false }], }); await graph.render(); pluginGridLine.form = (panel) => { const config = { resize: () => { const $container = document.getElementById('container')!; Object.assign($container.style, { width: '600px', height: '600px' }); window.dispatchEvent(new Event('resize')); }, follow: false, size: 20, }; return [ panel.add(config, 'resize').name('Emit Resize'), panel .add(config, 'follow') .name('Follow The Graph') .onChange((follow: boolean) => { graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object' && plugin.type === 'grid-line') return { ...plugin, follow }; return plugin; }), ); }), panel .add(config, 'size', 10, 50, 5) .name('Grid Size') .onChange((size: number) => { graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object' && plugin.type === 'grid-line') return { ...plugin, size }; return plugin; }), ); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-history.ts ================================================ import type { History } from '@antv/g6'; import { Graph } from '@antv/g6'; export const pluginHistory: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node-1', combo: 'combo-2', style: { x: 120, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 300, y: 200 } }, { id: 'node-3', combo: 'combo-1', style: { x: 200, y: 300 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, ], combos: [ { id: 'combo-1', type: 'rect', combo: 'combo-2', style: { collapsed: true }, }, { id: 'combo-2' }, ], }, node: { style: { labelText: (d) => d.id, }, }, combo: { style: { labelText: (d) => d.id, lineDash: 0, collapsedLineDash: [5, 5], }, }, behaviors: ['drag-element', 'collapse-expand'], plugins: [{ key: 'history', type: 'history' }], }); await graph.render(); const history = graph.getPluginInstance('history'); pluginHistory.form = (panel) => { const config = { element: 'node-1', visible: true, add: () => { graph.addData({ nodes: [{ id: 'node-5', style: { x: 200, y: 100, fill: 'pink' } }], edges: [{ source: 'node-1', target: 'node-5', style: { stroke: 'brown' } }], }); graph.draw(); }, update: () => { graph.updateData({ nodes: [{ id: 'node-1', style: { x: 150, y: 100, fill: 'red' } }], edges: [{ id: 'edge-1', style: { stroke: 'green' } }], }); graph.draw(); }, remove: () => { graph.removeData({ nodes: ['node-1'], edges: ['edge-1'], }); graph.draw(); }, collapse: () => graph.collapseElement('combo-2'), expand: () => graph.expandElement('combo-1'), state: () => graph.setElementState('node-1', 'selected', true), zIndex: () => graph.setElementZIndex('combo-2', 100), undo: () => history.undo(), redo: () => history.redo(), clear: () => history.clear(), }; const visible = panel .add(config, 'visible') .name('node-1 visibility') .onChange((value: boolean) => { value ? graph.showElement(config.element) : graph.hideElement(config.element); }); return [ visible, panel.add(config, 'add').name('add'), panel.add(config, 'update').name('update'), panel.add(config, 'remove').name('remove'), panel.add(config, 'collapse').name('collapse combo2'), panel.add(config, 'expand').name('expand combo1'), panel.add(config, 'state').name('set node1 selected'), panel.add(config, 'zIndex').name('front combo2'), panel.add(config, 'undo'), panel.add(config, 'redo'), panel.add(config, 'clear'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-hull.ts ================================================ import { HullOptions } from '@/src/plugins'; import type { CardinalPlacement } from '@/src/types'; import { Graph, Hull } from '@antv/g6'; const data = { nodes: [ { id: 'node0', style: { size: 50, x: 220, y: 326 }, }, { id: 'node1', style: { size: 30, x: 426, y: 421 }, }, { id: 'node2', style: { size: 30, x: 329, y: 88 }, }, { id: 'node3', style: { size: 30, x: -16, y: 255 }, }, { id: 'node4', style: { size: 30, x: 79, y: 493 }, }, { id: 'node5', style: { size: 30, x: 235, y: 540 }, }, { id: 'node6', style: { size: 15, x: 428, y: 547 }, }, { id: 'node7', style: { size: 15, x: 546, y: 371 }, }, { id: 'node8', style: { size: 15, x: 333, y: -57 }, }, { id: 'node9', style: { size: 15, x: 202, y: -8 }, }, { id: 'node10', style: { size: 15, x: 473, y: 145 }, }, { id: 'node11', style: { size: 15, x: 458, y: 12 }, }, { id: 'node12', style: { size: 15, x: 353, y: 221 }, }, { id: 'node13', style: { size: 15, x: 201, y: 133 }, }, { id: 'node14', style: { size: 15, x: 94, y: 241 }, }, { id: 'node15', style: { size: 15, x: -67, y: 127 }, }, { id: 'node16', style: { size: 15, x: -91, y: 359 }, }, ], edges: [ { id: 'edge1', source: 'node0', target: 'node1', }, { id: 'edge2', source: 'node0', target: 'node2', }, { id: 'edge3', source: 'node0', target: 'node3', }, { id: 'edge4', source: 'node0', target: 'node4', }, { id: 'edge5', source: 'node0', target: 'node5', }, { id: 'edge6', source: 'node1', target: 'node6', }, { id: 'edge7', source: 'node1', target: 'node7', }, { id: 'edge8', source: 'node2', target: 'node8', }, { id: 'edge9', source: 'node2', target: 'node9', }, { id: 'edge10', source: 'node2', target: 'node10', }, { id: 'edge11', source: 'node2', target: 'node11', }, { id: 'edge12', source: 'node2', target: 'node12', }, { id: 'edge13', source: 'node2', target: 'node13', }, { id: 'edge14', source: 'node3', target: 'node14', }, { id: 'edge15', source: 'node3', target: 'node15', }, { id: 'edge16', source: 'node3', target: 'node16', }, ], combos: [], }; export const pluginHull: TestCase = async (context) => { const graph = new Graph({ ...context, data, behaviors: ['drag-canvas', 'drag-element'], plugins: [ { key: 'hull', type: 'hull', members: ['node0', 'node1', 'node2'], labelText: 'convex hull', labelFontWeight: '700', labelBackground: true, labelBackgroundFill: 'pink', lineWidth: 5, }, ], node: { style: { labelText: (d) => d.id }, }, edge: { style: {}, }, autoFit: 'view', }); await graph.render(); const hull = graph.getPluginInstance('hull'); const updateHullOptions = (optionsToUpdate: Partial) => { graph.updatePlugin({ key: 'hull', ...optionsToUpdate }); graph.render(); }; pluginHull.form = (panel) => { const nodeIds = graph.getNodeData().map((node) => node.id); const config = { concavity: 100, padding: 10, corner: 'rounded', labelPlacement: 'bottom', labelCloseToPath: true, labelAutoRotate: true, node: 'node0', }; return [ panel.add(config, 'concavity', 0, 100, 1).onChange((concavity: number) => { updateHullOptions({ concavity }); }), panel.add(config, 'padding', 0, 100, 1).onChange((padding: number) => { updateHullOptions({ padding }); }), panel .add(config, 'corner', ['rounded', 'smooth', 'sharp']) .name('Corner Type') .onChange((corner: 'rounded' | 'smooth' | 'sharp') => { updateHullOptions({ corner }); }), panel .add(config, 'labelPlacement', ['top', 'bottom', 'left', 'right']) .name('Label Placement') .onChange((labelPlacement: CardinalPlacement) => { updateHullOptions({ labelPlacement }); }), panel .add(config, 'labelCloseToPath') .name('Label Close To Path') .onChange((labelCloseToPath: boolean) => { updateHullOptions({ labelCloseToPath }); }), panel .add(config, 'labelAutoRotate') .name('Label Auto Rotate') .onChange((labelAutoRotate: boolean) => { updateHullOptions({ labelAutoRotate }); }), panel.add(config, 'node', nodeIds).name('Node'), panel .add( { AddMember: () => { hull.addMember(config.node); }, }, 'AddMember', ) .name('Add Member'), panel .add( { RemoveMember: () => { hull.removeMember(config.node); }, }, 'RemoveMember', ) .name('Remove Member'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-legend.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const pluginLegend: TestCase = async (context) => { const { nodes, edges } = data; const findCluster = (id: string) => { return nodes.find(({ id: node }) => node === id)?.data.cluster; }; const graph = new Graph({ ...context, data: { nodes, edges: edges.map(({ source, target }) => { return { source, target, id: `${source}-${target}`, data: { cluster: `${findCluster(source)}-${findCluster(target)}`, }, }; }), }, layout: { type: 'd3-force' }, behaviors: ['drag-canvas', 'drag-element', 'zoom-canvas'], node: { type: (item: any) => { if (item.data.cluster === 'a') return 'diamond'; if (item.data.cluster === 'b') return 'rect'; if (item.data.cluster === 'c') return 'triangle'; return 'circle'; }, style: { labelText: (d) => d.id, lineWidth: 0, fill: (item: any) => { if (item.data.cluster === 'a') return 'red'; if (item.data.cluster === 'b') return 'blue'; if (item.data.cluster === 'c') return 'green'; return '#99add1'; }, }, }, plugins: [ { key: 'legend', type: 'legend', titleText: 'Cluster Legend', nodeField: 'cluster', edgeField: 'cluster', trigger: 'click', }, ], }); await graph.render(); pluginLegend.form = (panel) => { const config = { trigger: 'hover', }; return [ panel .add(config, 'trigger', ['hover', 'click']) .name('Change Trigger Method') .onChange((trigger: string) => { graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object' && plugin.type === 'legend') return { ...plugin, trigger }; return plugin; }), ); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-minimap-edge-arrow.ts ================================================ import { Graph } from '@antv/g6'; export const pluginMiniMapEdgeArrow: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: '0', name: 'a', }, { id: '1', name: 'b', }, ], edges: [ { id: '0-1', source: '0', target: '1', }, ], }, node: { style: { label: true, labelText: (node) => { return node.id; }, }, }, edge: { style: { endArrow: true, label: true, labelText: (edge) => { return `${edge.source} < ${edge.target}`; }, }, }, layout: { type: 'force', linkDistance: 50, clustering: true, nodeClusterBy: 'cluster', clusterNodeStrength: 70, }, plugins: [ { key: 'minimap', type: 'minimap', size: [240, 160], }, ], behaviors: ['drag-element'], }); await graph.render(); pluginMiniMapEdgeArrow.form = (gui) => { const config = { hide: () => { const edge = graph.getEdgeData('0-1'); graph.hideElement(edge.id!); // graph.render(); }, show: () => { const edge = graph.getEdgeData('0-1'); graph.showElement(edge.id!); // graph.render(); }, }; return [gui.add(config, 'hide'), gui.add(config, 'show')]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-minimap.ts ================================================ import { Renderer } from '@antv/g-svg'; import { Graph } from '@antv/g6'; 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', 'hover-activate'], plugins: [ { key: 'minimap', type: 'minimap', size: [240, 160], renderer: new Renderer(), }, ], node: { palette: 'spectral', }, layout: { type: 'circular' }, autoFit: 'view', }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-snapline.ts ================================================ import { Graph, Node } from '@antv/g6'; export const pluginSnapline: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', style: { x: 100, y: 100 } }, { id: 'node2', style: { x: 300, y: 300 } }, { id: 'node3', style: { x: 120, y: 200 } }, ], }, node: { type: (datum) => (datum.id === 'node3' ? 'circle' : 'rect'), style: { size: (datum) => (datum.id === 'node3' ? 40 : [60, 30]), fill: 'transparent', lineWidth: 2, labelText: (datum) => datum.id, }, }, behaviors: ['drag-element', 'drag-canvas', 'zoom-canvas'], plugins: [ { type: 'snapline', key: 'snapline', verticalLineStyle: { stroke: '#F08F56', lineWidth: 2 }, horizontalLineStyle: { stroke: '#17C76F', lineWidth: 2 }, autoSnap: false, }, ], }); await graph.render(); const config = { filter: false, offset: 20, autoSnap: false, }; pluginSnapline.form = (panel) => { return [ panel .add(config, 'filter') .name('Add Filter(exclude circle)') .onChange((filter: boolean) => { graph.updatePlugin({ key: 'snapline', filter: (node: Node) => (filter ? node.id !== 'node3' : true), }); }), panel .add(config, 'offset', [0, 20, Infinity]) .name('Offset') .onChange((offset: string) => { graph.updatePlugin({ key: 'snapline', offset, }); }), panel .add(config, 'autoSnap') .name('Auto Snap') .onChange((autoSnap: boolean) => { graph.updatePlugin({ key: 'snapline', autoSnap, }); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-timebar.ts ================================================ import type { GraphData, Timebar } from '@antv/g6'; import { Graph } from '@antv/g6'; const formatId = (index: number) => `0${index}`.slice(-2); const formatTime = (time: number) => { const year = new Date(time).getFullYear(); const month = new Date(time).getMonth() + 1; const date = new Date(time).getDate(); return `${year}-${month}-${date}`; }; export const pluginTimebar: TestCase = async (context) => { const startTime = new Date('2023-08-01 00:00:00').getTime(); const diff = 3600 * 24 * 1000; const timebarData = [10, 2, 3, 4, 15, 10, 6].map((value, index) => ({ time: new Date(startTime + index * diff), value, })); const [rows, cols] = [7, 7]; const data: GraphData = { nodes: new Array(rows * cols).fill(0).map((_, index) => ({ id: `${formatId(index)}`, data: { timestamp: startTime + (index % cols) * diff, value: index % 20, label: formatTime(startTime + (index % cols) * diff), }, })), edges: [], }; for (let i = 0; i < rows * cols; i++) { if (i % cols < cols - 1) { data.edges!.push({ source: `${formatId(i)}`, target: `${formatId(i + 1)}`, }); } if (i / rows < rows - 1) { data.edges!.push({ source: `${formatId(i)}`, target: `${formatId(i + rows)}`, }); } } const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => `${d.data!.label}`, }, }, layout: { type: 'grid', sortBy: 'id', cols, rows, }, autoFit: 'view', padding: [10, 0, 90, 0], behaviors: ['drag-element'], plugins: [ { type: 'timebar', key: 'timebar', data: timebarData, mode: 'modify', padding: 40, }, ], }); pluginTimebar.form = (panel) => { const config = { position: 'bottom', mode: 'modify', timebarType: 'time' }; const timebar = graph.getPluginInstance('timebar'); const operation = { play: () => timebar.play(), pause: () => timebar.pause(), reset: () => timebar.reset(), backward: () => timebar.backward(), forward: () => timebar.forward(), }; const handleChange = () => { graph.updatePlugin({ key: 'timebar', ...config, }); }; return [ panel.add(config, 'position', ['top', 'bottom']).onChange((position: 'top' | 'bottom') => { graph.setOptions({ padding: position === 'top' ? [100, 0, 10, 0] : [10, 0, 65, 0], }); graph.updatePlugin({ key: 'timebar', position, }); graph.fitView(); }), panel.add(config, 'mode', ['modify', 'visibility']).onChange(handleChange), panel.add(config, 'timebarType', ['time', 'chart']).onChange(() => { graph.setOptions({ padding: config.position === 'top' ? [100, 0, 10, 0] : [10, 0, 100, 0], }); graph.updatePlugin({ key: 'timebar', ...config, height: 100, }); graph.fitView(); }), ...Object.keys(operation).map((key) => panel.add(operation, key)), ]; }; await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-title.ts ================================================ import { Graph } from '@antv/g6'; export const pluginTitle: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: Array.from({ length: 12 }).map((_, i) => ({ id: `node${i}` })) }, behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'], plugins: [ { key: 'title', type: 'title', align: 'center', spacing: 4, size: 60, title: '这是一个标题这是一个标题', titleFontSize: 28, titleFontFamily: 'sans-serif', titleFontWeight: 600, titleFill: '#fff', titleFillOpacity: 1, titleStroke: '#000', titleLineWidth: 2, titleStrokeOpacity: 1, subtitle: '这是一个副标', subtitleFontSize: 16, subtitleFontFamily: 'Arial', subtitleFontWeight: 300, subtitleFill: '#2989FF', subtitleFillOpacity: 1, subtitleStroke: '#000', subtitleLineWidth: 1, subtitleStrokeOpacity: 0.5, }, ], node: { palette: 'spectral', style: { labelText: '你好' }, }, layout: { type: 'circular', }, autoFit: 'view', }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-toolbar-build-in.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const pluginToolbarBuildIn: TestCase = async (context) => { const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, plugins: [ { type: 'toolbar', position: 'top-left', onClick: (item: string, e: MouseEvent) => { console.log('item clicked:', item, e); }, getItems: () => { return [ { id: 'zoom-in', value: 'zoom-in', title: 'Zoom in' }, { id: 'zoom-out', value: 'zoom-out', title: 'Zoom out' }, { id: 'redo', value: 'redo', title: 'Redo' }, { id: 'undo', value: 'undo', title: 'Undo' }, { id: 'edit', value: 'edit', title: 'Edit' }, { id: 'delete', value: 'delete', title: 'Delete' }, { id: 'auto-fit', value: 'auto-fit', title: 'Auto fit' }, { id: 'export', value: 'export', title: 'Export' }, { id: 'reset', value: 'reset', title: 'Reset' }, ]; }, }, ], }); await graph.render(); pluginToolbarBuildIn.form = (panel) => { const config = { position: 'top-left', }; return [ panel .add(config, 'position', { 'top-right': 'top-right', 'top-left': 'top-left', 'bottom-right': 'bottom-right', 'bottom-left': 'bottom-left', 'left-top': 'left-top', 'left-bottom': 'left-bottom', 'right-top': 'right-top', 'right-bottom': 'right-bottom', }) .name('Position') .onChange((position: string) => { graph.setPlugins([ { type: 'toolbar', position, getItems: () => { return [ { id: 'zoom-in', value: 'zoom-in' }, { id: 'zoom-out', value: 'zoom-out' }, ]; }, enable: true, }, ]); graph.render(); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-toolbar-iconfont.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph, iconfont } from '@antv/g6'; export const pluginToolbarIconfont: TestCase = async (context) => { // Use iconfont for toolbar items. const iconFont = document.createElement('script'); iconFont.src = iconfont.js; document.head.appendChild(iconFont); const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, plugins: [ { type: 'toolbar', position: 'right-top', onClick: (item: string, e: MouseEvent) => { console.log('item clicked:', item, e); }, getItems: () => { return [ { id: 'icon-xinjian', value: 'new' }, { id: 'icon-fenxiang', value: 'share' }, { id: 'icon-chexiao', value: 'undo' }, ]; }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-tooltip-async.ts ================================================ import type { ElementDatum, IElementEvent } from '@antv/g6'; import { Graph } from '@antv/g6'; export const pluginTooltipAsync: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [{ id: 'node1', style: { x: 150, y: 100 }, data: { desc: 'get content async test' } }], }, node: { style: { labelText: (d) => d.id, }, }, plugins: [ { key: 'tooltip', type: 'tooltip', trigger: 'click', getContent: (evt: IElementEvent, items: ElementDatum[]) => { return new Promise((resolve) => { resolve(items[0].data?.desc || ''); }); }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-tooltip-dual.ts ================================================ import type { IElementEvent, Tooltip } from '@antv/g6'; import { Graph } from '@antv/g6'; export const pluginTooltipDual: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', style: { x: 100, y: 100 } }, { id: 'node2', style: { x: 200, y: 200 } }, ], edges: [{ id: 'edge', source: 'node1', target: 'node2' }], }, node: { style: { labelText: (d) => d.id, }, }, plugins: [ function () { return { key: 'tooltip-click', type: 'tooltip', trigger: 'click', getContent: (evt: any, items: any[]) => { return `
click ${items[0].id}
`; }, onOpenChange: (open: boolean) => { const tooltip = this.getPluginInstance('tooltip-hover') as Tooltip; if (tooltip && open) tooltip.hide(); }, }; }, function () { return { key: 'tooltip-hover', type: 'tooltip', trigger: 'hover', enable: (e: IElementEvent) => { const tooltip = this.getPluginInstance('tooltip-click') as Tooltip; // @ts-expect-error access private property return e.target.id !== tooltip.currentTarget; }, getContent: (evt: any, items: any[]) => { return `
hover ${items[0].id}
`; }, onOpenChange: (open: boolean) => { const tooltip = this.getPluginInstance('tooltip-click') as Tooltip; if (tooltip && open) { tooltip.hide(); } }, }; }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-tooltip-enable.ts ================================================ import type { ElementDatum, IElementEvent } from '@antv/g6'; import { Graph } from '@antv/g6'; export const pluginTooltipEnable: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: 'node1', style: { x: 150, y: 100 }, data: { type: 'test1', desc: 'This is a tooltip' } }, { id: 'node2', style: { x: 150, y: 200 }, data: { type: 'test1', desc: '' } }, { id: 'node3', style: { x: 150, y: 300 }, data: { type: 'test2', desc: 'This is a tooltip' } }, ], }, node: { style: { labelText: (d) => d.id, }, }, plugins: [ { key: 'tooltip', type: 'tooltip', trigger: 'click', enable: (e: IElementEvent, items: ElementDatum[]) => { return items[0].data?.type === 'test1'; }, getContent: (evt: IElementEvent, items: ElementDatum[]) => { return items[0].data?.desc || ''; }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-tooltip-with-custom-node.ts ================================================ import { Group } from '@antv/g'; import type { CircleStyleProps, ElementDatum, IElementEvent } from '@antv/g6'; import { Circle, ExtensionCategory, Graph, register } from '@antv/g6'; class CustomNode extends Circle { drawOperatorBtns(attributes: Required, container: Group) { this.upsert( 'custom-shape', 'text', { x: 0, y: 0, fontSize: 30, text: '+', fill: '#000', cursor: 'pointer', }, container, ); } render(attributes = this.parsedAttributes, container: Group) { super.render(attributes, container); this.drawOperatorBtns(attributes, container); } } register(ExtensionCategory.NODE, 'custom-node', CustomNode); export const pluginTooltipWithCustomNode: TestCase = async (context) => { const graph = new Graph({ container: 'container', data: { nodes: [ { id: 'Jack', data: {}, style: { x: 200, y: 200, }, }, ], }, node: { type: 'custom-node', style: { size: 250, }, }, plugins: [ { type: 'tooltip', trigger: 'hover', enable: (e: IElementEvent, items: ElementDatum[]) => { return e.originalTarget.className === 'custom-shape'; }, }, ], behaviors: ['drag-element'], }); graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-tooltip.ts ================================================ import data from '@@/dataset/combo.json'; import type { IElementEvent } from '@antv/g6'; import { Graph } from '@antv/g6'; export const pluginTooltip: TestCase = async (context) => { const graph = new Graph({ ...context, data, layout: { type: 'combo-combined', comboPadding: 2, }, behaviors: ['drag-canvas', 'drag-element'], node: { style: { labelText: (d) => d.id, }, }, plugins: [ { key: 'tooltip', type: 'tooltip', trigger: 'click', getContent: (evt: any, items: any[]) => { return `
${items[0].id || items[0].source + ' --> ' + items[0].target}
`; }, }, ], autoFit: 'view', }); await graph.render(); pluginTooltip.form = (panel) => { const config = { trigger: 'click', enable: 'all', }; return [ panel .add(config, 'trigger', ['hover', 'click']) .name('Change Trigger Method') .onChange((trigger: string) => { graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object' && plugin.type === 'tooltip') return { ...plugin, trigger }; return plugin; }), ); }), panel .add(config, 'enable', ['all', 'node', 'edge', 'combo']) .name('Change Enable Target') .onChange((enable: string) => { graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object' && plugin.type === 'tooltip') { if (enable === 'all') return { ...plugin, enable: true }; else return { ...plugin, enable: (event: IElementEvent) => event.targetType === enable }; } return plugin; }), ); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-watermark-image.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const pluginWatermarkImage: TestCase = async (context) => { const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, plugins: [ { type: 'watermark', width: 100, height: 100, imageURL: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*7svFR6wkPMoAAAAAAAAAAAAADmJ7AQ/original', }, ], }); await graph.render(); pluginWatermarkImage.form = (panel) => { const config = { width: 200, height: 100, fontSize: 20, textFill: 'red', }; return [ panel .add(config, 'width', 150, 400, 10) .name('Width') .onChange(() => {}), panel .add(config, 'height', 100, 200, 10) .name('Width') .onChange(() => {}), panel .add(config, 'fontSize', 10, 32, 1) .name('FontSize') .onChange(() => {}), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/plugin-watermark.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph, PluginOptions } from '@antv/g6'; export const pluginWatermark: TestCase = async (context) => { const graph = new Graph({ ...context, autoResize: true, data, layout: { type: 'd3-force' }, plugins: [ { type: 'watermark', text: 'hello, \na watermark.', textFontSize: 12, }, ], }); await graph.render(); function updatePlugin(type: string, config: object) { return (plugins: PluginOptions) => { return plugins.map((plugin) => { if (typeof plugin === 'object' && plugin.type === type) return { ...plugin, ...config }; return plugin; }); }; } pluginWatermark.form = (panel) => { const config = { width: 200, height: 100, textFontSize: 12, }; return [ panel .add(config, 'width', 150, 400, 10) .name('Width') .onChange((width: number) => { graph.setPlugins(updatePlugin('watermark', { width })); }), panel .add(config, 'height', 100, 200, 10) .name('Height') .onChange((height: number) => { graph.setPlugins(updatePlugin('watermark', { height })); }), panel .add(config, 'textFontSize', 10, 32, 1) .name('TextFontSize') .onChange((textFontSize: number) => { graph.setPlugins(updatePlugin('watermark', { textFontSize })); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/theme.ts ================================================ import data from '@@/dataset/cluster.json'; import { Graph } from '@antv/g6'; export const theme: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data, node: { palette: { field: 'cluster', }, }, layout: { type: 'radial', unitRadius: 80, }, }); await graph.render(); theme.form = (panel) => { const config = { theme: 'light', }; const options = { Light: 'light', Dark: 'dark', Blue: 'blue' }; const themeOptions: { [key: string]: any } = { light: { background: '#fff', theme: 'light', node: { palette: { field: 'cluster', }, }, }, dark: { background: '#000', theme: 'dark', node: { palette: { field: 'cluster', }, }, }, blue: { background: '#f3faff', theme: 'light', node: { palette: { type: 'group', field: 'cluster', color: 'blues', invert: true, }, }, }, yellow: { background: '#fcf9f1', theme: 'light', node: { palette: { type: 'group', field: 'cluster', color: ['#ffe7ba', '#ffd591', '#ffc069', '#ffa940', '#fa8c16', '#d46b08', '#ad4e00', '#873800', '#612500'], }, }, }, }; const changeTheme = (theme: string) => { graph.setOptions(themeOptions[theme]); graph.render(); }; return [ panel.add(config, 'theme', options).onChange((value: string) => { changeTheme(value); }), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/transform-map-node-size.ts ================================================ import { Graph } from '@antv/g6'; export const transformMapNodeSize: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }, { id: 'node-4' }, { id: 'node-5' }], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-1', target: 'node-3' }, { source: 'node-1', target: 'node-4' }, { source: 'node-4', target: 'node-5' }, ], }, node: { style: { labelText: (d) => d.id, }, }, layout: { type: 'grid', }, transforms: [ { key: 'map-node-size', type: 'map-node-size', scale: 'log', }, ], animation: false, }); await graph.render(); const config = { 'centrality.type': 'degree', mapLabelSize: false }; transformMapNodeSize.form = (panel) => [ panel .add(config, 'centrality.type', ['degree', 'betweenness', 'closeness', 'eigenvector', 'pagerank']) .name('Centrality Type') .onChange((type: string) => { graph.updateTransform({ key: 'map-node-size', centrality: { type } }); graph.draw(); }), panel .add(config, 'mapLabelSize') .name('Sync To Label Size') .onChange((mapLabelSize: boolean) => { graph.updateTransform({ key: 'map-node-size', mapLabelSize }); graph.draw(); }), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/transform-place-radial-labels.ts ================================================ import data from '@@/dataset/algorithm-category.json'; import { Graph, treeToGraphData } from '@antv/g6'; export const transformPlaceRadialLabels: TestCase = async (context) => { const graph = new Graph({ ...context, autoFit: 'view', data: treeToGraphData(data), node: { style: { labelText: (d) => d.id, }, }, layout: { type: 'dendrogram', radial: true, nodeSep: 30, rankSep: 200, preLayout: false, }, transforms: ['place-radial-labels'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6/__tests__/demos/transform-process-parallel-edges.ts ================================================ import data from '@@/dataset/parallel-edges.json'; import { Graph } from '@antv/g6'; export const transformProcessParallelEdges: TestCase = async (context) => { const graph = new Graph({ ...context, data, node: { style: { labelText: (d) => d.id } }, behaviors: [ 'drag-element', { type: 'hover-activate', key: 'hover-activate', }, ], transforms: [ { type: 'process-parallel-edges', key: 'process-parallel-edges', mode: 'merge', style: { lineDash: [2, 2], lineWidth: 3, stroke: '#99add1', }, }, ], }); await graph.render(); let targetIndex = 3; const config = { mode: 'merge' }; transformProcessParallelEdges.form = (panel) => [ panel .add(config, 'mode', ['bundle', 'merge']) .name('Mode') .onChange((mode: string) => { graph.updateTransform({ key: 'process-parallel-edges', mode }); graph.draw(); }), panel .add( { Add: () => { graph.addEdgeData([ { id: 'new-edge', source: 'node1', target: 'node4', style: { stroke: '#FF9800', lineWidth: 2 }, }, { id: 'new-loop', source: 'node5', target: 'node5', style: { stroke: '#FF9800', lineWidth: 2 }, }, ]); graph.draw(); }, }, 'Add', ) .name('Add Orange Edge'), panel .add( { Remove: () => { graph.removeEdgeData(['edge1', 'loop1']); graph.draw(); }, }, 'Remove', ) .name('Remove Purple Edge'), panel .add( { Update: () => { const target = ['node2', 'node3', 'node4', 'node5', 'node6'][targetIndex % 5]; targetIndex++; graph.updateEdgeData([{ id: 'new-edge', source: 'node1', target }]); graph.draw(); }, }, 'Update', ) .name('Update Orange Edge'), ]; return graph; }; ================================================ FILE: packages/g6/__tests__/demos/viewport-fit.ts ================================================ import { Graph } from '@antv/g6'; export const viewportFit: TestCase = async (context) => { const graph = new Graph({ ...context, data: { nodes: [ { id: '1', style: { x: 200, y: 250 } }, { id: '2', style: { x: 300, y: 250 } }, { id: '3', style: { x: 250, y: 200 } }, { id: '4', style: { x: 250, y: 300 } }, ], }, node: { style: { size: 50, fill: (d) => (d.id === '1' ? '#d4414c' : '#2f363d'), }, }, behaviors: ['zoom-canvas', 'drag-canvas'], plugins: [], }); await graph.render(); viewportFit.form = (panel) => { const config = { x: 0, y: 0, zoom: 1, fitView: () => graph.fitView(), fitCenter: () => graph.fitCenter(), focusElement: () => graph.focusElement('1'), }; return [ panel.add(config, 'x', -100, 100, 1).onChange((x: number) => graph.translateTo([x, config.y], false)), panel.add(config, 'y', -100, 100, 1).onChange((y: number) => graph.translateTo([config.x, y], false)), panel.add(config, 'zoom', 0.01, 10, 0.1).onChange((zoom: number) => graph.zoomTo(zoom, false)), panel.add(config, 'fitView'), panel.add(config, 'fitCenter'), panel.add(config, 'focusElement'), ]; }; return graph; }; ================================================ FILE: packages/g6/__tests__/index.html ================================================ G6: Preview
================================================ FILE: packages/g6/__tests__/main.ts ================================================ import { CanvasEvent } from '@antv/g'; import { Canvas, ComboEvent, CommonEvent, EdgeEvent, NodeEvent } from '@antv/g6'; import type { Controller } from 'lil-gui'; import GUI from 'lil-gui'; import Stats from 'stats.js'; import '../src/preset'; import * as demos from './demos'; import { createGraphCanvas } from './utils'; // inject Object.assign(window, { NodeEvent, EdgeEvent, ComboEvent, CommonEvent }); type Options = { Search: string; Demo: string; Renderer: string; Theme: string; Animation: boolean; MultiLayers: boolean; [keys: string]: any; }; const options: Options = { Search: '', Demo: Object.keys(demos)[0], Renderer: 'canvas', GridLine: true, Theme: 'light', Animation: true, MultiLayers: true, interval: 0, Reload: () => {}, forms: [], }; const params = ['Type', 'Demo', 'Renderer', 'GridLine', 'Theme', 'Animation'] as const; syncParamsFromSearch(); const panels = initPanel(); const stats = initStats(); window.onload = render; function initPanel() { const panel = new GUI({ container: document.getElementById('panel')!, autoPlace: true }); const Demo = panel.add(options, 'Demo', Object.keys(demos)).onChange(render); const Search = panel.add(options, 'Search').onChange((keyword: string) => { const keys = Object.keys(demos); const filtered = keys.filter((key) => key.toLowerCase().includes(keyword.toLowerCase())); Demo.options(filtered); }); const Renderer = panel.add(options, 'Renderer', { Canvas: 'canvas', SVG: 'svg', WebGL: 'webgl' }).onChange(render); const Theme = panel.add(options, 'Theme', { Light: 'light', Dark: 'dark' }).onChange(render); const GridLine = panel.add(options, 'GridLine').onChange(() => { syncParamsToSearch(); 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); const goTo = (diff: number) => { // @ts-expect-error private property const keys = Demo._values; const currentIndex = keys.indexOf(options.Demo); const nextIndex = (currentIndex + diff + keys.length) % keys.length; options.Demo = keys[nextIndex]; Demo.updateDisplay(); render(); }; globalThis.addEventListener('keydown', (e) => { if (['ArrowRight', 'ArrowDown'].includes(e.key)) { goTo(1); } else if (['ArrowLeft', 'ArrowUp'].includes(e.key)) { goTo(-1); } }); return { panel, Demo, Search, Renderer, GridLine, Theme, Animation, MultiLayers, reload }; } function initStats() { const container = document.getElementById('panel')!; const stats = new Stats(); stats.showPanel(0); const dom = stats.dom; Object.assign(dom.style, { position: 'relative', top: 'unset', right: 'unset' }); container.appendChild(dom); return stats; } let canvas: Canvas | undefined; const statsListener = () => stats.update(); async function render() { syncParamsToSearch(); applyTheme(); destroyForm(); if (canvas) { canvas.getLayer().removeEventListener(CanvasEvent.AFTER_RENDER, statsListener); } const $container = initContainer(); applyGridLine(); // render const { Renderer, Demo, Animation, Theme, MultiLayers } = options; const canvasOptions = { enableMultiLayer: MultiLayers }; canvas = createGraphCanvas($container, 500, 500, Renderer, canvasOptions); canvas.getLayer().addEventListener(CanvasEvent.AFTER_RENDER, statsListener); await canvas.ready; const testCase = demos[Demo as keyof typeof demos]; if (!testCase) return; performance.clearMarks(); performance.clearMeasures(); performance.mark('demo-start'); const graph = await testCase({ container: canvas, animation: Animation, theme: Theme, canvas: canvasOptions, }); performance.mark('demo-end'); performance.measure('demo', 'demo-start', 'demo-end'); console.log('Time:', performance.getEntriesByName('demo')[0].duration, 'ms'); Object.assign(window, { graph, __g_instances__: Object.values(graph.getCanvas().getLayers()) }); renderForm(panels.panel, testCase.form); } function renderForm(panel: GUI, form: TestCase['form']) { if (form) options.forms.push(...form(panel)); } function destroyForm() { const { forms } = options; forms.forEach((controller: Controller) => controller.destroy()); forms.length = 0; } function syncParamsFromSearch() { const searchParams = new URLSearchParams(window.location.search); params.forEach((key) => { const value = searchParams.get(key); if (!value) return; if (key === 'Animation' || key === 'GridLine') options[key] = value === 'true'; else options[key] = value; }); } function syncParamsToSearch() { const searchParams = new URLSearchParams(window.location.search); Object.entries(options).forEach(([key, value]) => { if (params.includes(key as (typeof params)[number])) searchParams.set(key, value.toString()); }); window.history.replaceState(null, '', `?${searchParams.toString()}`); } function initContainer() { document.getElementById('container')?.remove(); const $container = document.createElement('div'); $container.id = 'container'; document.getElementById('app')?.appendChild($container); return $container; } function applyTheme() { document.documentElement.setAttribute('data-theme', options.Theme); } function applyGridLine() { const show = options.GridLine; const element = document.getElementById('container'); if (!element) return; syncParamsToSearch(); if (show) { document.body.style.backgroundSize = '25px 25px'; element.style.border = '1px solid #e8e8e8'; } else { document.body.style.backgroundSize = '0'; element.style.border = 'none'; } } ================================================ FILE: packages/g6/__tests__/perf/data.perf.ts ================================================ import { Graph } from '@antv/g6'; import type { Test } from 'iperf'; const DataTestWrapper = (count: number): Test => { return async ({ container, perf }) => { const graph = new Graph({ container, data: { nodes: Array(count) .fill(0) .map((_, i) => ({ id: `${i}`, style: { x: 50, y: 50 } })), }, }); await perf.evaluate('data diff', async () => { // @ts-expect-error private method invoke await graph.prepare(); // @ts-expect-error context is private property const context = graph.context; // @ts-expect-error private method invoke context.element.computeChangesAndDrawData({}); }); }; }; export const dataDiff1000: Test = DataTestWrapper(1000); export const dataDiff5000: Test = DataTestWrapper(5000); export const dataDiff10000: Test = DataTestWrapper(10000); export const dataDiff50000: Test = DataTestWrapper(50000); export const dataDiff100000: Test = DataTestWrapper(100000); ================================================ FILE: packages/g6/__tests__/perf/draw.perf.ts ================================================ import { Graph } from '@antv/g6'; import type { Test } from 'iperf'; const ElementTestWrapper = (count: number): Test => { return async ({ container, perf }) => { const graph = new Graph({ container, animation: false, // be sure to close the animation data: { nodes: Array(count) .fill(0) .map((_, i) => ({ id: `${i}`, style: { x: 50, y: 50 } })), }, layout: { type: 'grid', }, }); await perf.evaluate('element drawing', async () => { await graph.draw(); }); await perf.evaluate('grid layout', async () => { await graph.layout(); }); }; }; export const elementDrawing100: Test = ElementTestWrapper(100); export const elementDrawing500: Test = ElementTestWrapper(500); export const elementDrawing1000: Test = ElementTestWrapper(1000); export const elementDrawing5000: Test = ElementTestWrapper(5000); export const elementDrawing10000: Test = ElementTestWrapper(10000); ================================================ FILE: packages/g6/__tests__/perf/massive-element.perf.ts ================================================ import { Graph } from '@antv/g6'; import type { Test } from 'iperf'; export const massiveElement60000: Test = async ({ container, perf }) => { const data = await fetch('https://assets.antv.antgroup.com/g6/60000.json').then((res) => res.json()); const graph = new Graph({ container, animation: false, autoFit: 'view', data, node: { style: { size: 4, batchKey: 'node', }, }, behaviors: ['zoom-canvas', 'drag-canvas'], }); await perf.evaluate('massive element drawing', async () => { await graph.draw(); }); }; massiveElement60000.iteration = 3; ================================================ FILE: packages/g6/__tests__/perf/update-state.perf.ts ================================================ import { Graph } from '@antv/g6'; import type { Test } from 'iperf'; export const UpdateElementState: Test = async ({ container, perf }) => { const nodes = Array(1000) .fill(0) .map((_, i) => ({ id: `${i}` })); const edges = Array(999) .fill(0) .map((_, i) => ({ id: `edge-${i}`, source: `${i}`, target: `${i + 1}` })); const graph = new Graph({ container, animation: false, data: { nodes, edges }, layout: { type: 'grid' }, }); await graph.render(); const selected = [...nodes, ...edges].map((element) => [element.id, 'selected']); await perf.evaluate('update element state to selected', async () => { await graph.setElementState(Object.fromEntries(selected)); }); const none = [...nodes, ...edges].map((element) => [element.id, []]); await perf.evaluate('update element state to default', async () => { await graph.setElementState(Object.fromEntries(none)); }); const position = nodes.map((node) => [node.id, [10, 10]]); await perf.evaluate('update element position', async () => { await graph.translateElementBy(Object.fromEntries(position), false); }); }; ================================================ FILE: packages/g6/__tests__/perf-report/9821ed36_2024-08-22_20-39-12.json ================================================ { "version": "1.0", "device": { "os": { "arch": "arm64", "distro": "macOS", "serial": "9821ed36011eee5abf6c71d6fc2c03fb4bf4655e674c56b7f50e2560cb6e924a" }, "cpu": { "manufacturer": "Apple", "brand": "M1 Pro", "speed": 2.4, "cores": 10 }, "memory": { "total": 16384, "free": 296.6875 }, "gpu": { "vendor": "Apple", "model": "Apple M1 Pro", "cores": "16" } }, "repo": "489f27b5c3ef49746516811e1012c88aadd61c4f", "client": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/128.0.6613.18 Safari/537.36", "reports": { "dataDiff1000": { "time": [ { "min": 1.9000000059604645, "max": 16.19999998807907, "median": 2.399999976158142, "avg": 3, "variance": 3.3875000156462196, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff10000": { "time": [ { "min": 1.9000000059604645, "max": 9.799999982118607, "median": 4.399999976158142, "avg": 4.237500000745058, "variance": 4.7523437567986555, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff100000": { "time": [ { "min": 11.300000011920929, "max": 38, "median": 12.400000005960464, "avg": 15.299999997019768, "variance": 31.937499957531692, "reliable": true, "key": "data diff" } ], "status": "passed" }, "dataDiff5000": { "time": [ { "min": 1.5999999940395355, "max": 9.699999988079071, "median": 3.2999999821186066, "avg": 3.649999998509884, "variance": 2.9674999792873864, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff50000": { "time": [ { "min": 5.700000017881393, "max": 16.099999994039536, "median": 7.5999999940395355, "avg": 8.825000006705523, "variance": 11.751875018589198, "reliable": true, "key": "data diff" } ], "status": "passed" }, "elementDrawing100": { "time": [ { "min": 9.400000005960464, "max": 20, "median": 10.300000011920929, "avg": 10.68750000372529, "variance": 1.1235937587358058, "reliable": true, "key": "element drawing" }, { "min": 6.699999988079071, "max": 9.899999976158142, "median": 8.5, "avg": 8.024999998509884, "variance": 0.67437500230968, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing1000": { "time": [ { "min": 41.69999998807907, "max": 64.7000000178814, "median": 51.5, "avg": 49.899999998509884, "variance": 43.767499907016756, "reliable": true, "key": "element drawing" }, { "min": 37, "max": 59.400000005960464, "median": 42, "avg": 42.11250000447035, "variance": 6.611093760319055, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing10000": { "time": [ { "min": 410.30000001192093, "max": 529.0999999940395, "median": 424.40000000596046, "avg": 433.4375000037253, "variance": 516.2098438784666, "reliable": true, "key": "element drawing" }, { "min": 377.19999998807907, "max": 420.09999999403954, "median": 390.2000000178814, "avg": 391.2000000067055, "variance": 151.5974999138713, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing500": { "time": [ { "min": 22.599999994039536, "max": 42.599999994039536, "median": 30.69999998807907, "avg": 30.649999998509884, "variance": 41.58999999597668, "reliable": true, "key": "element drawing" }, { "min": 21.399999976158142, "max": 29, "median": 23.30000001192093, "avg": 23.962499998509884, "variance": 4.732343725003302, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing5000": { "time": [ { "min": 201.69999998807907, "max": 253.39999997615814, "median": 208.09999999403954, "avg": 216.7749999947846, "variance": 218.39187494117766, "reliable": true, "key": "element drawing" }, { "min": 188.10000002384186, "max": 227.90000000596046, "median": 197.09999999403954, "avg": 196.08749999850988, "variance": 24.878593807034196, "reliable": true, "key": "grid layout" } ], "status": "passed" } } } ================================================ FILE: packages/g6/__tests__/perf-report/9821ed36_2024-08-29_11-11-17.json ================================================ { "version": "1.0", "device": { "os": { "arch": "arm64", "distro": "macOS", "serial": "9821ed36011eee5abf6c71d6fc2c03fb4bf4655e674c56b7f50e2560cb6e924a" }, "cpu": { "manufacturer": "Apple", "brand": "M1 Pro", "speed": 2.4, "cores": 10 }, "memory": { "total": 16384, "free": 540.28125 }, "gpu": { "vendor": "Apple", "model": "Apple M1 Pro", "cores": "16" } }, "repo": "aa87ec67c38f03808c72d82caaa3d064b4ee9c01", "client": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/128.0.6613.18 Safari/537.36", "reports": { "UpdateElementState": { "time": [ { "min": 167.09999999403954, "max": 202.29999999701977, "median": 171.79999999701977, "avg": 172.86250000447035, "variance": 18.562343744896353, "reliable": true, "key": "update element state to selected" }, { "min": 102.20000000298023, "max": 116.09999999403954, "median": 104.30000001192093, "avg": 105.06250000186265, "variance": 3.0598437559511513, "reliable": true, "key": "update element state to default" }, { "min": 86, "max": 105.8999999910593, "median": 88.5, "avg": 88.51249999925494, "variance": 2.073593743089587, "reliable": true, "key": "update element position" } ], "status": "passed" }, "dataDiff1000": { "time": [ { "min": 2.7000000029802322, "max": 11.300000011920929, "median": 5.5, "avg": 5.512499997392297, "variance": 2.638593754386529, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff10000": { "time": [ { "min": 2.8999999910593033, "max": 12.099999994039536, "median": 6, "avg": 6.325000004842877, "variance": 4.859374997671694, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff100000": { "time": [ { "min": 11, "max": 39.29999999701977, "median": 12.700000002980232, "avg": 16.387499999254942, "variance": 60.1160937285237, "reliable": true, "key": "data diff" } ], "status": "passed" }, "dataDiff5000": { "time": [ { "min": 1.8999999910593033, "max": 9.5, "median": 8.099999994039536, "avg": 6.374999998137355, "variance": 6.324374991413206, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff50000": { "time": [ { "min": 5.700000002980232, "max": 17.899999991059303, "median": 8.299999997019768, "avg": 9.5, "variance": 16.31750000014901, "reliable": true, "key": "data diff" } ], "status": "passed" }, "elementDrawing100": { "time": [ { "min": 12.700000002980232, "max": 24.600000008940697, "median": 18.899999991059303, "avg": 19.199999999254942, "variance": 8.127499995231629, "reliable": true, "key": "element drawing" }, { "min": 7.9000000059604645, "max": 14.100000008940697, "median": 11.900000005960464, "avg": 11.237500000745058, "variance": 3.8348437521792946, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing1000": { "time": [ { "min": 48.099999994039536, "max": 73.20000000298023, "median": 61.70000000298023, "avg": 61.374999998137355, "variance": 13.14437501249835, "reliable": true, "key": "element drawing" }, { "min": 39.20000000298023, "max": 59.1000000089407, "median": 43.29999999701977, "avg": 44.212499998509884, "variance": 14.278593749292193, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing10000": { "time": [ { "min": 409.3999999910593, "max": 536.2000000029802, "median": 434.3999999910593, "avg": 442.1124999951571, "variance": 829.2260936078895, "reliable": true, "key": "element drawing" }, { "min": 372.90000000596046, "max": 414.20000000298023, "median": 393.29999999701977, "avg": 389.8499999977648, "variance": 108.12999993681908, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing500": { "time": [ { "min": 28.400000005960464, "max": 51.099999994039536, "median": 41.79999999701977, "avg": 41.07499999925494, "variance": 15.709374984912573, "reliable": true, "key": "element drawing" }, { "min": 22.400000005960464, "max": 29, "median": 25.599999994039536, "avg": 25.399999998509884, "variance": 3.570000005811453, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing5000": { "time": [ { "min": 213.79999999701977, "max": 266.5, "median": 223.09999999403954, "avg": 230.39999999850988, "variance": 165.17250003792344, "reliable": true, "key": "element drawing" }, { "min": 185.79999999701977, "max": 224.1000000089407, "median": 195.70000000298023, "avg": 193.41250000149012, "variance": 35.76859376054257, "reliable": true, "key": "grid layout" } ], "status": "passed" } } } ================================================ FILE: packages/g6/__tests__/perf-report/9821ed36_2024-08-29_13-24-51.json ================================================ { "version": "1.0", "device": { "os": { "arch": "arm64", "distro": "macOS", "serial": "9821ed36011eee5abf6c71d6fc2c03fb4bf4655e674c56b7f50e2560cb6e924a" }, "cpu": { "manufacturer": "Apple", "brand": "M1 Pro", "speed": 2.4, "cores": 10 }, "memory": { "total": 16384, "free": 224.8125 }, "gpu": { "vendor": "Apple", "model": "Apple M1 Pro", "cores": "16" } }, "repo": "7fbbb77580d806932e7b777b34856243600dbf35", "client": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/128.0.6613.18 Safari/537.36", "reports": { "UpdateElementState": { "time": [ { "min": 115.70000000298023, "max": 146.29999999701977, "median": 118.5, "avg": 119.1750000026077, "variance": 11.989375011604281, "reliable": true, "key": "update element state to selected" }, { "min": 72.90000000596046, "max": 81, "median": 75.5, "avg": 75.12500000186265, "variance": 1.299374995883554, "reliable": true, "key": "update element state to default" }, { "min": 61.29999999701977, "max": 73.59999999403954, "median": 63.29999999701977, "avg": 63.28749999962747, "variance": 0.2935937537904829, "reliable": true, "key": "update element position" } ], "status": "passed" }, "dataDiff1000": { "time": [ { "min": 4.399999991059303, "max": 13.900000005960464, "median": 6.4000000059604645, "avg": 6.262500002980232, "variance": 1.0873437525331973, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff10000": { "time": [ { "min": 2.8999999910593033, "max": 13.700000002980232, "median": 6.0999999940395355, "avg": 6.199999999254942, "variance": 3.767500012814999, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff100000": { "time": [ { "min": 10.899999991059303, "max": 41.70000000298023, "median": 12.299999997019768, "avg": 17.51249999552965, "variance": 96.928593772389, "reliable": true, "key": "data diff" } ], "status": "passed" }, "dataDiff5000": { "time": [ { "min": 3.0999999940395355, "max": 13, "median": 3.6000000089406967, "avg": 5.262499999254942, "variance": 10.062343739960342, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff50000": { "time": [ { "min": 5.4000000059604645, "max": 15.400000005960464, "median": 8.399999991059303, "avg": 8.549999998882413, "variance": 8.840000012554228, "reliable": true, "key": "data diff" } ], "status": "passed" }, "elementDrawing100": { "time": [ { "min": 10.200000002980232, "max": 30.099999994039536, "median": 18.600000008940697, "avg": 19.499999998137355, "variance": 17.06999999091029, "reliable": true, "key": "element drawing" }, { "min": 6.5999999940395355, "max": 11.200000002980232, "median": 8.400000005960464, "avg": 8.550000002607703, "variance": 0.5175000003352761, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing1000": { "time": [ { "min": 55.3999999910593, "max": 75.5, "median": 65.29999999701977, "avg": 65.09999999962747, "variance": 7.902500012740493, "reliable": true, "key": "element drawing" }, { "min": 31.700000002980232, "max": 47, "median": 36.20000000298023, "avg": 36.34999999962747, "variance": 5.345000000037253, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing10000": { "time": [ { "min": 404.40000000596046, "max": 515.7999999970198, "median": 420.6000000089407, "avg": 427.75000000186265, "variance": 282.06500002410263, "reliable": true, "key": "element drawing" }, { "min": 306.70000000298023, "max": 341, "median": 321.6000000089407, "avg": 319.4125000014901, "variance": 58.756093712486326, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing500": { "time": [ { "min": 25, "max": 59.5, "median": 40.70000000298023, "avg": 42.03749999962747, "variance": 15.694843770219013, "reliable": true, "key": "element drawing" }, { "min": 16.69999998807907, "max": 24.299999997019768, "median": 20.799999997019768, "avg": 20.25, "variance": 5.507500002086163, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing5000": { "time": [ { "min": 203.70000000298023, "max": 264.30000001192093, "median": 224.79999999701977, "avg": 225.87499999813735, "variance": 117.29687504237518, "reliable": true, "key": "element drawing" }, { "min": 154.29999999701977, "max": 174.6000000089407, "median": 164.19999998807907, "avg": 161, "variance": 32.70499999940395, "reliable": true, "key": "grid layout" } ], "status": "passed" } } } ================================================ FILE: packages/g6/__tests__/perf-report/9821ed36_2024-09-03_10-33-27.json ================================================ { "version": "1.0", "device": { "os": { "arch": "arm64", "distro": "macOS", "serial": "9821ed36011eee5abf6c71d6fc2c03fb4bf4655e674c56b7f50e2560cb6e924a" }, "cpu": { "manufacturer": "Apple", "brand": "M1 Pro", "speed": 2.4, "cores": 10 }, "memory": { "total": 16384, "free": 49.90625 }, "gpu": { "vendor": "Apple", "model": "Apple M1 Pro", "cores": "16" } }, "repo": "b7bf98794ad57ba0b9facab1f71118d1a20f04ae", "client": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/128.0.6613.18 Safari/537.36", "reports": { "UpdateElementState": { "time": [ { "min": 115.19999998807907, "max": 149.60000002384186, "median": 119.80000001192093, "avg": 119.90000000596046, "variance": 12.112500025331974, "reliable": true, "key": "update element state to selected" }, { "min": 71.5, "max": 83, "median": 77.09999996423721, "avg": 75.46249997615814, "variance": 5.592343688607215, "reliable": true, "key": "update element state to default" }, { "min": 61.39999997615814, "max": 72.89999997615814, "median": 63.5, "avg": 63.474999994039536, "variance": 1.0193749868869784, "reliable": true, "key": "update element position" } ], "status": "passed" }, "dataDiff1000": { "time": [ { "min": 1.199999988079071, "max": 8, "median": 2.299999952316284, "avg": 2.7124999910593033, "variance": 2.1160937546938663, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff10000": { "time": [ { "min": 1.899999976158142, "max": 10.200000047683716, "median": 4.5, "avg": 4.524999998509884, "variance": 5.461875010505318, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff100000": { "time": [ { "min": 11.199999988079071, "max": 43.19999998807907, "median": 12.599999964237213, "avg": 17.174999997019768, "variance": 83.42187500372529, "reliable": true, "key": "data diff" } ], "status": "passed" }, "dataDiff5000": { "time": [ { "min": 1.699999988079071, "max": 9.5, "median": 3.399999976158142, "avg": 3.8124999925494194, "variance": 3.2510937972739344, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff50000": { "time": [ { "min": 5.5, "max": 15.899999976158142, "median": 8.200000047683716, "avg": 9.025000005960464, "variance": 11.214375038146972, "reliable": true, "key": "data diff" } ], "status": "passed" }, "elementDrawing100": { "time": [ { "min": 9, "max": 19.600000023841858, "median": 9.400000035762787, "avg": 10.299999997019768, "variance": 1.777499954998494, "reliable": true, "key": "element drawing" }, { "min": 5, "max": 8, "median": 5.700000047683716, "avg": 5.899999991059303, "variance": 0.6200000035762796, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing1000": { "time": [ { "min": 40.09999996423721, "max": 63, "median": 50.80000001192093, "avg": 50.024999998509884, "variance": 39.936875096932056, "reliable": true, "key": "element drawing" }, { "min": 30, "max": 47.60000002384186, "median": 36.10000002384186, "avg": 35.80000000447035, "variance": 5.50500000834465, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing10000": { "time": [ { "min": 403.5, "max": 511.10000002384186, "median": 426.9000000357628, "avg": 432.1625000014901, "variance": 785.8023441968486, "reliable": true, "key": "element drawing" }, { "min": 309.19999998807907, "max": 339.5999999642372, "median": 327.5999999642372, "avg": 322.67500000447035, "variance": 78.7543750076741, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing500": { "time": [ { "min": 21.80000001192093, "max": 39.5, "median": 28.400000035762787, "avg": 29.575000025331974, "variance": 49.00437493242323, "reliable": true, "key": "element drawing" }, { "min": 16.399999976158142, "max": 25.80000001192093, "median": 20.599999964237213, "avg": 19.849999971687794, "variance": 7.554999983757734, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing5000": { "time": [ { "min": 198.69999998807907, "max": 254, "median": 207, "avg": 214.125, "variance": 191.3443749541044, "reliable": true, "key": "element drawing" }, { "min": 154.19999998807907, "max": 176.20000004768372, "median": 162.80000001192093, "avg": 161.2749999910593, "variance": 21.26437514021993, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "massiveElement60000": { "time": [ { "min": 9387.900000035763, "max": 10796.300000011921, "median": 9416.300000011921, "avg": 9416.300000011921, "variance": 0, "reliable": true, "key": "massive element drawing" } ], "status": "passed" } } } ================================================ FILE: packages/g6/__tests__/perf-report/9821ed36_2024-09-03_11-28-42.json ================================================ { "version": "1.0", "device": { "os": { "arch": "arm64", "distro": "macOS", "serial": "9821ed36011eee5abf6c71d6fc2c03fb4bf4655e674c56b7f50e2560cb6e924a" }, "cpu": { "manufacturer": "Apple", "brand": "M1 Pro", "speed": 2.4, "cores": 10 }, "memory": { "total": 16384, "free": 48.078125 }, "gpu": { "vendor": "Apple", "model": "Apple M1 Pro", "cores": "16" } }, "repo": "41a9f98828cf1025a113360f5d3acbd7a918dd63", "client": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/128.0.6613.18 Safari/537.36", "reports": { "UpdateElementState": { "time": [ { "min": 116.09999996423721, "max": 148.30000001192093, "median": 119.70000004768372, "avg": 120.67500002682209, "variance": 13.551875019520523, "reliable": true, "key": "update element state to selected" }, { "min": 74.30000001192093, "max": 82.10000002384186, "median": 75.90000003576279, "avg": 75.63750000298023, "variance": 0.9998437715321785, "reliable": true, "key": "update element state to default" }, { "min": 61.89999997615814, "max": 73.79999995231628, "median": 63.80000001192093, "avg": 63.712500013411045, "variance": 0.5585937445983297, "reliable": true, "key": "update element position" } ], "status": "passed" }, "dataDiff1000": { "time": [ { "min": 2.099999964237213, "max": 8.600000023841858, "median": 4.699999988079071, "avg": 3.9375000074505806, "variance": 0.999843751229346, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff10000": { "time": [ { "min": 3.100000023841858, "max": 12.399999976158142, "median": 8, "avg": 7.325000002980232, "variance": 3.504375010281801, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff100000": { "time": [ { "min": 11.099999964237213, "max": 39.30000001192093, "median": 13.600000023841858, "avg": 17.825000002980232, "variance": 63.99937488421798, "reliable": true, "key": "data diff" } ], "status": "passed" }, "dataDiff5000": { "time": [ { "min": 2, "max": 15.599999964237213, "median": 4.199999988079071, "avg": 5.637500002980232, "variance": 9.34484379000962, "reliable": false, "key": "data diff" } ], "status": "passed" }, "dataDiff50000": { "time": [ { "min": 5.5, "max": 18.5, "median": 7.5, "avg": 8.487500004470348, "variance": 9.353593760840596, "reliable": true, "key": "data diff" } ], "status": "passed" }, "elementDrawing100": { "time": [ { "min": 12.900000035762787, "max": 21.80000001192093, "median": 16.19999998807907, "avg": 16.1875, "variance": 2.983593802154065, "reliable": true, "key": "element drawing" }, { "min": 5.900000035762787, "max": 8.699999988079071, "median": 7.899999976158142, "avg": 7.824999995529652, "variance": 0.49687498323619417, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing1000": { "time": [ { "min": 53.60000002384186, "max": 71.30000001192093, "median": 66.89999997615814, "avg": 66.61249999701977, "variance": 7.411093775406481, "reliable": true, "key": "element drawing" }, { "min": 33.10000002384186, "max": 48.799999952316284, "median": 36.90000003576279, "avg": 35.974999994039536, "variance": 2.8368749639391897, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing10000": { "time": [ { "min": 414.19999998807907, "max": 536.3000000119209, "median": 424.5, "avg": 431.0874999910593, "variance": 312.4835943404585, "reliable": true, "key": "element drawing" }, { "min": 306.19999998807907, "max": 339.19999998807907, "median": 321.5, "avg": 319.17499999701977, "variance": 83.65437486723066, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing500": { "time": [ { "min": 35.80000001192093, "max": 56.5, "median": 42.69999998807907, "avg": 43.962499998509884, "variance": 21.732343734689056, "reliable": true, "key": "element drawing" }, { "min": 17.100000023841858, "max": 23.80000001192093, "median": 20.899999976158142, "avg": 20.850000008940697, "variance": 0.7200000214576724, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "elementDrawing5000": { "time": [ { "min": 204.19999998807907, "max": 252.79999995231628, "median": 225.5, "avg": 225.11250000447035, "variance": 51.89859370965511, "reliable": true, "key": "element drawing" }, { "min": 153.79999995231628, "max": 171.89999997615814, "median": 161.30000001192093, "avg": 159.70000000298023, "variance": 21.232500118315222, "reliable": true, "key": "grid layout" } ], "status": "passed" }, "massiveElement60000": { "time": [ { "min": 3130.5, "max": 3468.2999999523163, "median": 3167.199999988079, "avg": 3167.199999988079, "variance": 0, "reliable": true, "key": "massive element drawing" } ], "status": "passed" } } } ================================================ FILE: packages/g6/__tests__/setup.ts ================================================ import 'jest-canvas-mock'; import './utils/to-be-close-to'; import './utils/use-snapshot-matchers'; ================================================ FILE: packages/g6/__tests__/types.d.ts ================================================ import type { G6Spec, Graph } from '@/src'; import type { Controller, GUI } from 'lil-gui'; declare global { export interface TestCase { (context: G6Spec): Promise; form?: (gui: GUI) => Controller[]; } export type TestContext = G6Spec; export module '*.svg' { const content: string; export default content; } export module '*.png' { const content: string; export default content; } export module '*.jpg' { const content: string; export default content; } export module '*.jpeg' { const content: string; export default content; } } ================================================ FILE: packages/g6/__tests__/unit/animations/element-position.spec.ts ================================================ import { animationElementPosition } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('animation element position', () => { it('animation element position', async () => { const graph = await createDemoGraph(animationElementPosition, { animation: true }); await expect(graph).toMatchAnimation(__filename, [0, 200, 1000], () => { graph.translateElementTo( { 'node-1': [250, 100], 'node-2': [175, 200], 'node-3': [325, 200], 'node-4': [100, 300], 'node-5': [250, 300], 'node-6': [400, 300], }, true, ); }); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/animations/element-state-switch.spec.ts ================================================ import { animationElementStateSwitch } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('animation element state switch', () => { it('animation element state switch', async () => { const graph = await createDemoGraph(animationElementStateSwitch, { animation: true }); await expect(graph).toMatchAnimation(__filename, [0, 200, 1000], async () => { graph.updateData({ nodes: [ { id: 'node-1', states: [] }, { id: 'node-2', states: ['active'] }, { id: 'node-3', states: ['selected'] }, ], edges: [ { source: 'node-1', target: 'node-2', states: [] }, { source: 'node-2', target: 'node-3', states: ['active'] }, ], }); await graph.draw(); }); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/animations/element-style-position.spec.ts ================================================ import { animationElementStylePosition } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('animation element style and position', () => { it('animation element style and position', async () => { const graph = await createDemoGraph(animationElementStylePosition, { animation: true }); await expect(graph).toMatchAnimation(__filename, [0, 200, 1000], () => { graph.addNodeData([ { id: 'node-4', style: { x: 50, y: 200, fill: 'orange' } }, { id: 'node-5', style: { x: 75, y: 150, fill: 'purple' } }, { id: 'node-6', style: { x: 200, y: 100, fill: 'cyan' } }, ]); graph.removeNodeData(['node-1']); graph.updateNodeData([{ id: 'node-2', style: { x: 200, y: 200, stroke: 'green' } }]); graph.draw(); }); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/auto-adapt-label.spec.ts ================================================ import { Point, type Graph } from '@/src'; import { behaviorAutoAdaptLabel } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior auto adapt label', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorAutoAdaptLabel, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('disable', async () => { graph.updateBehavior({ key: 'auto-adapt-label', enable: false }); await expect(graph).toMatchSnapshot(__filename, 'disable'); }); it('update options', async () => { graph.updateBehavior({ key: 'auto-adapt-label', enable: true, padding: 60 }); await expect(graph).toMatchSnapshot(__filename, 'padding-60'); }); it('update sorter', async () => { graph.updateBehavior({ key: 'auto-adapt-label', padding: 0 }); const origin: Point = [200, 200, 0]; graph.zoomTo(3, false, origin); await expect(graph).toMatchSnapshot(__filename, 'zoom-3'); graph.zoomTo(1, false, origin); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/behavior-create-edge-click.spec.ts ================================================ import type { EdgeData, Graph } from '@/src'; import { ComboEvent, CommonEvent, NodeEvent } from '@/src'; import { behaviorCreateEdge } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior create edge click', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorCreateEdge, { animation: false }); }); it('click create edge', async () => { await expect(graph).toMatchSnapshot(__filename); graph.setBehaviors([{ type: 'create-edge', trigger: 'click' }]); graph.emit(NodeEvent.CLICK, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(CommonEvent.POINTER_MOVE, { client: { x: 100, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'click-edge1-move'); graph.emit(NodeEvent.CLICK, { target: { id: 'node2' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'click-edge1'); graph.emit(NodeEvent.CLICK, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'click-edge2'); graph.setBehaviors([{ type: 'create-edge', trigger: 'click', style: { stroke: 'red', lineWidth: 2 } }]); graph.emit(NodeEvent.CLICK, { target: { id: 'node2' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'click-edge3'); graph.emit(ComboEvent.CLICK, { target: { id: 'combo1' }, targetType: 'combo' }); graph.emit(ComboEvent.CLICK, { target: { id: 'combo2' }, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'click-edge4-combo'); graph.setBehaviors([ { type: 'create-edge', trigger: 'click', style: { stroke: 'red', lineWidth: 2 }, onCreate: (edge: EdgeData) => { const { source, target, ...rest } = edge; return { target: source, source: target, ...rest, }; }, }, ]); graph.emit(NodeEvent.CLICK, { target: { id: 'node2' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'click-custom-edge4'); }); afterAll(() => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/behavior-create-edge-drag.spec.ts ================================================ import type { EdgeData, Graph } from '@/src'; import { ComboEvent, CommonEvent, NodeEvent } from '@/src'; import { behaviorCreateEdge } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior create edge drag', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorCreateEdge, { animation: false }); }); it('drag create edge', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(CommonEvent.POINTER_MOVE, { client: { x: 100, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'drag-edge1-move'); graph.emit(CommonEvent.POINTER_UP, { target: { id: 'node2' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'drag-edge1'); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(CommonEvent.POINTER_UP, { target: { id: 'node3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'drag-edge2'); graph.setBehaviors([{ type: 'create-edge', trigger: 'drag', style: { stroke: 'red', lineWidth: 2 } }]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node2' }, targetType: 'node' }); graph.emit(CommonEvent.POINTER_UP, { target: { id: 'node3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'drag-edge3'); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo1' }, targetType: 'combo' }); graph.emit(CommonEvent.POINTER_UP, { target: { id: 'combo2' }, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'drag-edge4-combo'); graph.setBehaviors([ { type: 'create-edge', trigger: 'drag', style: { stroke: 'red', lineWidth: 2 }, onCreate: (edge: EdgeData) => { const { source, target, ...rest } = edge; return { target: source, source: target, ...rest, }; }, }, ]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node2' }, targetType: 'node' }); graph.emit(CommonEvent.POINTER_UP, { target: { id: 'node3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'drag-custom-edge4'); }); afterAll(() => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/brush-select.spec.ts ================================================ import type { Graph } from '@/src'; import { CanvasEvent, CommonEvent } from '@/src'; import { behaviorBrushSelect } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior brush select', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorBrushSelect, { animation: false }); }); it('brush select', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 100, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-select-clear'); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-1'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-1'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-1'); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 300, y: 300 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-2'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 300, y: 300 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-2'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-2'); graph.setBehaviors([ { type: 'brush-select', trigger: 'drag', style: { fill: 'green', lineWidth: 2, stroke: 'blue' } }, ]); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-3'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-3'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-3'); graph.setBehaviors([{ type: 'brush-select', trigger: 'shift' }]); graph.emit(CommonEvent.KEY_DOWN, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-4'); graph.emit(CommonEvent.KEY_UP, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-4'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-4'); graph.setBehaviors([{ type: 'brush-select', state: 'active', trigger: 'shift', immediately: true }]); graph.emit(CommonEvent.KEY_DOWN, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-5'); graph.emit(CommonEvent.KEY_UP, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-5'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-5'); graph.setBehaviors([{ type: 'brush-select', mode: 'union', trigger: 'drag' }]); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-mode-union'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-mode-union'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-mode-union'); graph.setBehaviors([{ type: 'brush-select', mode: 'diff' }]); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-mode-diff'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-mode-diff'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-mode-diff'); graph.setBehaviors([{ type: 'brush-select', mode: 'intersect' }]); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-mode-intersect'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selected-mode-intersect'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'brush-clear-mode-intersect'); // zoom to test line width graph.zoomTo(5); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 250, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'brush-selecting-zoom'); graph.emit(CommonEvent.POINTER_UP, { canvas: { x: 250, y: 400 } }); }); afterAll(() => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/click-select-catch.spec.ts ================================================ import type { Graph } from '@/src'; import { CanvasEvent, NodeEvent } from '@/src'; import { behaviorClickSelect } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior click-select element', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorClickSelect, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('delete selected element', async () => { graph.setBehaviors([ { type: 'click-select', state: 'active', unselectedState: 'inactive', multiple: true, trigger: [] }, ]); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: '1' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'selected'); graph.removeNodeData(['1']); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'deleted'); graph.emit(CanvasEvent.CLICK, { target: {} }); await expect(graph).toMatchSnapshot(__filename, 'clear'); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/click-select.spec.ts ================================================ import type { Graph } from '@/src'; import { CanvasEvent, CommonEvent, EdgeEvent, NodeEvent } from '@/src'; import { behaviorClickSelect } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior click-select element', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorClickSelect, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'after-select'); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'after-deselect'); }); it('state and unselectedState', async () => { graph.setBehaviors([{ type: 'click-select', state: 'active', unselectedState: 'inactive' }]); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'custom-state'); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); }); it('state and neighborState', async () => { graph.setBehaviors([ { type: 'click-select', state: 'selected', neighborState: 'active', unselectedState: 'inactive', degree: 1, }, ]); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'custom-neighborState'); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); }); it('1 degree', async () => { graph.setBehaviors([ { type: 'click-select', degree: 1, state: 'selected', neighborState: 'selected', unselectedState: undefined, }, ]); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'node-1-degree'); graph.emit(CanvasEvent.CLICK, { target: {}, targetType: 'canvas' }); graph.emit(EdgeEvent.CLICK, { target: { id: '0-1' }, targetType: 'edge' }); await expect(graph).toMatchSnapshot(__filename, 'edge-1-degree'); graph.emit(CanvasEvent.CLICK, { target: {}, targetType: 'canvas' }); }); it('multiple', async () => { graph.setBehaviors([{ type: 'click-select', multiple: true, degree: 0 }]); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: '1' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); await expect(graph).toMatchSnapshot(__filename, 'multiple-shift'); graph.setBehaviors([{ type: 'click-select', multiple: true, trigger: ['meta'] }]); graph.emit(CommonEvent.KEY_DOWN, { key: 'meta' }); graph.emit(NodeEvent.CLICK, { target: { id: '0' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: '1' }, targetType: 'node' }); graph.emit(CommonEvent.KEY_UP, { key: 'meta' }); await expect(graph).toMatchSnapshot(__filename, 'multiple-meta'); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/collapse-expand-combo.spec.ts ================================================ import type { Graph } from '@/src'; import { ComboEvent } from '@/src'; import { behaviorExpandCollapseCombo } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior combo expand collapse', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorExpandCollapseCombo, { animation: true }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('expand combo-1', async () => { // @ts-expect-error access private property const combo1 = graph.context.element?.getElement('combo-1'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(ComboEvent.DBLCLICK, { target: combo1, targetType: 'combo' }); }, 'expand-combo-1', ); }); it('collapse combo-2', async () => { // @ts-expect-error access private property const combo2 = graph.context.element?.getElement('combo-2'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(ComboEvent.DBLCLICK, { target: combo2, targetType: 'combo' }); }, 'collapse-combo-2', ); }); it('collapse combo-1, expand combo-2', async () => { await graph.collapseElement('combo-1'); // @ts-expect-error access private property const combo2 = graph.context.element?.getElement('combo-2'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(ComboEvent.DBLCLICK, { target: combo2, targetType: 'combo' }); }, 'collapse-combo-1-expand-combo-2', ); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/collapse-expand-node.spec.ts ================================================ import type { Graph } from '@/src'; import { NodeEvent } from '@/src'; import { behaviorExpandCollapseNode } from '@@/demos/behavior-expand-collapse-node'; import { createDemoGraph } from '@@/utils'; describe('behavior node expand collapse', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorExpandCollapseNode, { animation: true }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('collapse B', async () => { // @ts-expect-error access private property const B = graph.context.element!.getElement('B'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(NodeEvent.CLICK, { target: B, targetType: 'node' }); }, 'collapse-B', ); }); it('expand C', async () => { // @ts-expect-error access private property const C = graph.context.element!.getElement('C'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(NodeEvent.CLICK, { target: C, targetType: 'node' }); }, 'expand-C', ); }); it('expand B', async () => { // @ts-expect-error access private property const B = graph.context.element!.getElement('B'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(NodeEvent.CLICK, { target: B, targetType: 'node' }); }, 'expand-B-again', ); }); it('collapse A', async () => { // @ts-expect-error access private property const A = graph.context.element!.getElement('A'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(NodeEvent.CLICK, { target: A, targetType: 'node' }); }, 'collapse-A', ); }); it('expand A', async () => { // @ts-expect-error access private property const A = graph.context.element!.getElement('A'); await expect(graph).toMatchAnimation( __filename, [0, 500, 1000], () => { graph.emit(NodeEvent.CLICK, { target: A, targetType: 'node' }); }, 'expand-A', ); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/collapse-expand.spec.ts ================================================ import type { Graph } from '@/src'; import { ComboEvent } from '@/src'; import { behaviorExpandCollapseCombo } from '@@/demos'; import { createDemoGraph, sleep } from '@@/utils'; describe('behavior combo expand collapse', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorExpandCollapseCombo, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('collapse', async () => { // collapse combo-2 // @ts-expect-error private method const combo2 = graph.context.element.getElement('combo-2'); graph.emit(ComboEvent.DBLCLICK, { target: combo2, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'collapse-combo-2'); }); it('expand', async () => { // expand combo-2 // @ts-expect-error private method const combo2 = graph.context.element.getElement('combo-2'); graph.emit(ComboEvent.DBLCLICK, { target: combo2, targetType: 'combo' }); // await async invoke await sleep(100); // expand combo-1 // @ts-expect-error private method const combo1 = graph.context.element.getElement('combo-1'); graph.emit(ComboEvent.DBLCLICK, { target: combo1, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'expand-combo-1'); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/drag-canvas.spec.ts ================================================ import type { Graph } from '@/src'; import { CommonEvent } from '@/src'; import { behaviorDragCanvas } from '@@/demos'; import { createDemoGraph, createGraph, dispatchCanvasEvent, sleep } from '@@/utils'; describe('behavior drag canvas', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorDragCanvas, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', () => { expect(graph.getBehaviors()).toEqual([ 'drag-canvas', { type: 'drag-canvas', key: 'drag-canvas', trigger: { up: ['ArrowUp'], down: ['ArrowDown'], right: ['ArrowRight'], left: ['ArrowLeft'], }, }, ]); }); it('arrow up', () => { const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowUp' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowUp' }); expect(graph.getPosition()).toBeCloseTo([x, y - 10]); }); it('arrow down', () => { const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowDown' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowDown' }); expect(graph.getPosition()).toBeCloseTo([x, y + 10]); }); it('arrow left', () => { const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowLeft' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowLeft' }); expect(graph.getPosition()).toBeCloseTo([x - 10, y]); }); it('arrow right', () => { const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); expect(graph.getPosition()).toBeCloseTo([x + 10, y]); }); it('drag', () => { const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getPosition()).toBeCloseTo([x + 10, y + 10]); }); it('drag for mobile', () => { const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { dx: 10, dy: 10, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getPosition()).toBeCloseTo([x + 10, y + 10]); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { dx: -10, dy: -10, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getPosition()).toBeCloseTo([x, y]); }); it('sensitivity', async () => { graph.updateBehavior({ key: 'drag-canvas', sensitivity: 20 }); const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); expect(graph.getPosition()).toBeCloseTo([x + 20, y]); await expect(graph).toMatchSnapshot(__filename); }); it('use shortcut to drag in the x-axis direction', () => { graph.updateBehavior({ key: 'drag-canvas', direction: 'x' }); const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowDown' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowDown' }); expect(graph.getPosition()).toBeCloseTo([x + 20, y]); }); it('use shortcut to drag in the y-axis direction', () => { graph.updateBehavior({ key: 'drag-canvas', direction: 'y' }); const [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowDown' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowDown' }); expect(graph.getPosition()).toBeCloseTo([x, y + 20]); graph.updateBehavior({ key: 'drag-canvas', direction: 'both' }); }); it('onFinish with key', async () => { const onFinish = jest.fn(); graph.updateBehavior({ key: 'drag-canvas', onFinish }); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); await sleep(500); expect(onFinish).toHaveBeenCalledTimes(1); onFinish.mockReset(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); await sleep(500); expect(onFinish).toHaveBeenCalledTimes(1); }); it('onFinish with drag', async () => { const onFinish = jest.fn(); graph.updateBehavior({ key: 'drag-canvas', trigger: 'drag', onFinish }); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(onFinish).toHaveBeenCalledTimes(1); }); it('drag in the x-axis direction', () => { graph.setBehaviors([{ type: 'drag-canvas', key: 'drag-canvas', trigger: 'drag', direction: 'x' }]); const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getPosition()).toBeCloseTo([x + 10, y]); }); it('drag in the y-axis direction', () => { graph.updateBehavior({ key: 'drag-canvas', trigger: 'drag', direction: 'y' }); const [x, y] = graph.getPosition(); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: 10, y: 10 }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); expect(graph.getPosition()).toBeCloseTo([x, y + 10]); }); it('trigger on element', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }], }, node: { style: { size: 20, }, }, behaviors: [{ type: 'drag-canvas', enable: true }], }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'drag-on-element-default'); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'node' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: -50, y: -50 }, targetType: 'node' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-on-element'); }); it('range', () => { graph.updateBehavior({ key: 'drag-canvas', trigger: 'drag', direction: 'both', range: 0.5 }); const emitDragEvent = (dx: number, dy: number, count: number) => { for (let i = 0; i < count; i++) { dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { movement: { x: dx, y: dy }, targetType: 'canvas' }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); } }; const [canvasWidth, canvasHeight] = graph.getCanvas().getSize(); emitDragEvent(10, 0, 60); expect(graph.getPosition()[0]).toBeCloseTo(canvasWidth / 2); emitDragEvent(-10, 0, 60); expect(graph.getPosition()[0]).toBeCloseTo(-canvasWidth / 2); emitDragEvent(0, -10, 60); expect(graph.getPosition()[0]).toBeCloseTo(-canvasHeight / 2); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/drag-element-bug.spec.ts ================================================ import { Graph, NodeEvent } from '@/src'; import { positionOf } from '@/src/utils/position'; import { createGraphCanvas } from '@@/utils'; describe('behavior drag element bug', () => { it('drag on non-default zoom', async () => { const graph = new Graph({ animation: false, container: createGraphCanvas(document.getElementById('container')), data: { nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }], }, behaviors: ['drag-element'], }); await graph.draw(); expect(graph.getZoom()).toBe(1); expect(positionOf(graph.getNodeData('node-1'))).toEqual([100, 100, 0]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); graph.emit(NodeEvent.DRAG_END); expect(positionOf(graph.getNodeData('node-1'))).toEqual([120, 120, 0]); graph.zoomTo(2); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); graph.emit(NodeEvent.DRAG_END); expect(positionOf(graph.getNodeData('node-1'))).toEqual([130, 130, 0]); graph.zoomTo(0.5); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); graph.emit(NodeEvent.DRAG_END); expect(positionOf(graph.getNodeData('node-1'))).toEqual([170, 170, 0]); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/drag-element-combo.spec.ts ================================================ import type { Graph } from '@/src'; import { CanvasEvent, ComboEvent, NodeEvent } from '@/src'; import { behaviorExpandCollapseCombo } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior drag combo', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorExpandCollapseCombo, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { graph.setBehaviors([{ type: 'drag-element', dropEffect: 'link' }]); graph.expandElement('combo-1'); await expect(graph).toMatchSnapshot(__filename); }); it('drag node out', async () => { // drag node-2 to combo-2 graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 80, dy: 60 }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-2-before-drop-out'); graph.emit(ComboEvent.DROP, { target: { id: 'combo-2' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-2-after-drop-out'); // drag node-1 to canvas graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: -70, dy: -70 }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-before-drop-out'); graph.emit(CanvasEvent.DROP, { target: {} }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-after-drop-out'); }); it('drag node into', async () => { graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 250, dy: 250 }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-before-drop-into'); graph.emit(ComboEvent.DROP, { target: { id: 'combo-2' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-after-drop-into'); }); it('drag node move', async () => { graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 100, dy: 100 }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-before-drop-move'); graph.emit(ComboEvent.DROP, { target: { id: 'combo-2' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-node-1-after-drop-move'); }); it('drag combo move', async () => { graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-1' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 100, dy: 100 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-1-before-drop-move'); graph.emit(ComboEvent.DROP, { target: { id: 'combo-2' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-1-after-drop-move'); }); it('drag combo out', async () => { graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-1' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: -250, dy: -250 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-1-before-drop-out'); graph.emit(CanvasEvent.DROP, { target: {} }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-1-after-drop-out'); }); it('drag combo into', async () => { graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-2' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: -200, dy: -200 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-2-before-drop-into'); graph.emit(ComboEvent.DROP, { target: { id: 'combo-1' } }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-2-after-drop-into'); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/drag-element.spec.ts ================================================ import type { Graph } from '@/src'; import { ComboEvent, CommonEvent, NodeEvent } from '@/src'; import { behaviorDragNode } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior drag element', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorDragNode, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('pointer cursor', () => { graph.emit(NodeEvent.POINTER_ENTER, { target: { id: 'node-4' }, targetType: 'node', type: CommonEvent.POINTER_ENTER, }); expect(graph.getCanvas().getConfig().cursor).toBe('grab'); graph.emit(NodeEvent.POINTER_LEAVE, { target: { id: 'node-4' }, targetType: 'node', type: CommonEvent.POINTER_LEAVE, }); expect(graph.getCanvas().getConfig().cursor).toBe('default'); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-4' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); expect(graph.getCanvas().getConfig().cursor).toBe('grabbing'); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'after-drag'); }); it('hide edges', async () => { graph.setBehaviors([{ type: 'drag-element', hideEdge: 'both' }]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-4' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); await expect(graph).toMatchSnapshot(__filename, 'hideEdge-both'); graph.emit(NodeEvent.DRAG_END); graph.setBehaviors([{ type: 'drag-element', hideEdge: 'in' }]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-3' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 0, dy: 20 }); await expect(graph).toMatchSnapshot(__filename, 'hideEdge-in'); graph.emit(NodeEvent.DRAG_END); graph.setBehaviors([{ type: 'drag-element', hideEdge: 'out' }]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-3' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 0, dy: 20 }); await expect(graph).toMatchSnapshot(__filename, 'hideEdge-out'); graph.emit(NodeEvent.DRAG_END); }); it('drag node shadow', async () => { graph.setBehaviors([{ type: 'drag-element', shadow: true, shadowStroke: 'red', shadowStrokeOpacity: 1 }]); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-4' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 20, dy: 20 }); await expect(graph).toMatchSnapshot(__filename, 'shadow'); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'shadow-after-drag'); }); it('drag combo', async () => { graph.setBehaviors(['drag-element']); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-1' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 20, dy: 20 }); graph.emit(ComboEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-combo'); }); it('drag combo shadow', async () => { graph.setBehaviors([{ type: 'drag-element', shadow: true, shadowStroke: 'red', shadowStrokeOpacity: 1 }]); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-1' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 20, dy: 20 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-shadow'); graph.emit(ComboEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-shadow-after-drag'); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/fix-element-size.spec.ts ================================================ import { CanvasEvent, CommonEvent, EdgeEvent, NodeEvent, type Graph } from '@/src'; import { behaviorFixElementSize } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior fix element size', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorFixElementSize, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('fix entire element size', async () => { await expect(graph).toMatchSnapshot(__filename); await expect(graph).toMatchSnapshot(__filename, 'entire-size-1'); graph.zoomTo(0.6); await expect(graph).toMatchSnapshot(__filename, 'entire-size-0.6'); graph.zoomTo(1); graph.emit(CanvasEvent.CLICK, { target: {} }); }); it('fix lineWidth of key', async () => { graph.updateBehavior({ key: 'fix-element-size', node: [{ shape: 'key', fields: ['lineWidth'] }], edge: [{ shape: 'key', fields: ['lineWidth'] }], }); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node0' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(EdgeEvent.CLICK, { target: { id: 'node0-node1' }, targetType: 'edge' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); await expect(graph).toMatchSnapshot(__filename, 'lineWidth-1'); await graph.zoomTo(0.6); await expect(graph).toMatchSnapshot(__filename, 'lineWidth-0.6'); await graph.zoomTo(1); graph.emit(CanvasEvent.CLICK, { target: {} }); }); it('fix fontSize of label', async () => { graph.updateBehavior({ key: 'fix-element-size', node: [{ shape: 'label' }], edge: [{ shape: 'label' }], }); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node0' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(EdgeEvent.CLICK, { target: { id: 'node0-node1' }, targetType: 'edge' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); await expect(graph).toMatchSnapshot(__filename, 'fontSize-1'); await graph.zoomTo(0.6); await expect(graph).toMatchSnapshot(__filename, 'fontSize-0.6'); await graph.zoomTo(1); graph.emit(CanvasEvent.CLICK, { target: {} }); }); it('fix both lineWidth and fontSize', async () => { graph.updateBehavior({ key: 'fix-element-size', node: [{ shape: 'key', fields: ['lineWidth'] }, { shape: 'label' }], edge: [{ shape: 'key', fields: ['lineWidth'] }, { shape: 'label' }], }); graph.emit(CommonEvent.KEY_DOWN, { key: 'shift' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node0' }, targetType: 'node' }); graph.emit(NodeEvent.CLICK, { target: { id: 'node1' }, targetType: 'node' }); graph.emit(EdgeEvent.CLICK, { target: { id: 'node0-node1' }, targetType: 'edge' }); graph.emit(CommonEvent.KEY_UP, { key: 'shift' }); await expect(graph).toMatchSnapshot(__filename, 'lineWidth-fontSize-1'); await graph.zoomTo(0.6); await expect(graph).toMatchSnapshot(__filename, 'lineWidth-fontSize-0.6'); await graph.zoomTo(1); graph.emit(CanvasEvent.CLICK, { target: {} }); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/focus-element.spec.ts ================================================ import type { Graph } from '@/src'; import { ComboEvent, NodeEvent } from '@/src'; import { behaviorFocusElement } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior focus element', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorFocusElement, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('focus node', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'focus-node-1'); graph.emit(NodeEvent.CLICK, { target: { id: 'node-2' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'focus-node-2'); graph.emit(NodeEvent.CLICK, { target: { id: 'node-3' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'focus-node-3'); graph.emit(NodeEvent.CLICK, { target: { id: 'node-4' }, targetType: 'node' }); await expect(graph).toMatchSnapshot(__filename, 'focus-node-4'); }); it('focus combo', async () => { graph.emit(ComboEvent.CLICK, { target: { id: 'combo-1' }, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'focus-combo'); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/hover-activate.spec.ts ================================================ import type { Graph } from '@/src'; import { CommonEvent, EdgeEvent, NodeEvent } from '@/src'; import { behaviorHoverActivate } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior hover-activate element', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorHoverActivate, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(NodeEvent.POINTER_ENTER, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_ENTER }); await expect(graph).toMatchSnapshot(__filename, 'after-hover'); graph.emit(NodeEvent.POINTER_LEAVE, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_LEAVE }); await expect(graph).toMatchSnapshot(__filename, 'after-hover-out'); }); it('state and inactiveState', async () => { graph.setBehaviors([{ type: 'hover-activate', state: 'active', inactiveState: 'inactive' }]); graph.emit(NodeEvent.POINTER_ENTER, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_ENTER }); await expect(graph).toMatchSnapshot(__filename, 'state'); graph.emit(NodeEvent.POINTER_LEAVE, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_LEAVE }); }); it('1 degree', async () => { graph.setBehaviors([{ type: 'hover-activate', state: 'active', inactiveState: 'inactive', degree: 1 }]); graph.emit(NodeEvent.POINTER_ENTER, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_ENTER }); await expect(graph).toMatchSnapshot(__filename, '1-degree-node'); graph.emit(NodeEvent.POINTER_LEAVE, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_LEAVE }); graph.emit(EdgeEvent.POINTER_ENTER, { target: { id: '0-1' }, targetType: 'edge', type: CommonEvent.POINTER_ENTER }); await expect(graph).toMatchSnapshot(__filename, '1-degree-edge'); graph.emit(EdgeEvent.POINTER_LEAVE, { target: { id: '0-1' }, targetType: 'edge', type: CommonEvent.POINTER_LEAVE }); }); it('2 degree', async () => { graph.setBehaviors([{ type: 'hover-activate', state: 'active', inactiveState: 'inactive', degree: 2 }]); graph.emit(NodeEvent.POINTER_ENTER, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_ENTER }); await expect(graph).toMatchSnapshot(__filename, '2-degree-node'); graph.emit(NodeEvent.POINTER_LEAVE, { target: { id: '0' }, targetType: 'node', type: CommonEvent.POINTER_LEAVE }); graph.emit(EdgeEvent.POINTER_ENTER, { target: { id: '0-1' }, targetType: 'edge', type: CommonEvent.POINTER_ENTER }); await expect(graph).toMatchSnapshot(__filename, '2-degree-edge'); graph.emit(EdgeEvent.POINTER_LEAVE, { target: { id: '0-1' }, targetType: 'edge', type: CommonEvent.POINTER_LEAVE }); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/lasso-select.spec.ts ================================================ import type { Graph } from '@/src'; import { CanvasEvent, CommonEvent } from '@/src'; import { behaviorLassoSelect } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior lasso select', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorLassoSelect, { animation: false }); }); it('lasso select', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-select-clear'); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 400 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-1'); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-1'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-1'); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 400 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 400, y: 400 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-2'); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-2'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-2'); graph.updateBehavior({ key: 'lasso-select', style: { fill: 'green', lineWidth: 2, stroke: 'blue' }, }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 300 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 300, y: 300 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 300, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-3'); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-3'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-3'); graph.updateBehavior({ key: 'lasso-select', trigger: 'shift' }); graph.emit(CommonEvent.KEY_DOWN, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 200 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 200, y: 200 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 200, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-4'); graph.emit(CommonEvent.KEY_UP, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-4'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-4'); graph.updateBehavior({ key: 'lasso-select', state: 'active', trigger: 'shift', immediately: true }); graph.emit(CommonEvent.KEY_DOWN, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-5'); graph.emit(CommonEvent.KEY_UP, { key: 'Shift' }); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-5'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-5'); graph.updateBehavior({ key: 'lasso-select', mode: 'union', trigger: 'drag' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-mode-union'); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-mode-union'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-mode-union'); graph.updateBehavior({ key: 'lasso-select', mode: 'diff' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-mode-diff'); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-mode-diff'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-mode-diff'); graph.updateBehavior({ key: 'lasso-select', mode: 'intersect' }); graph.emit(CommonEvent.POINTER_DOWN, { canvas: { x: 100, y: 100 }, targetType: 'canvas' }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 100, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 500 } }); graph.emit(CommonEvent.POINTER_MOVE, { canvas: { x: 500, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'lasso-selecting-mode-intersect'); graph.emit(CommonEvent.POINTER_UP); await expect(graph).toMatchSnapshot(__filename, 'lasso-selected-mode-intersect'); graph.emit(CanvasEvent.CLICK); await expect(graph).toMatchSnapshot(__filename, 'lasso-clear-mode-intersect'); }); afterAll(() => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/optimize-viewport-transform.spec.ts ================================================ import type { ElementType, Graph } from '@/src'; import { GraphEvent } from '@/src'; import { behaviorOptimizeViewportTransform } from '@@/demos'; import { createDemoGraph } from '@@/utils'; import type { DisplayObject } from '@antv/g'; describe('behavior optimize canvas', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorOptimizeViewportTransform, { animation: false }); }); it('viewport', async () => { await expect(graph).toMatchSnapshot(__filename); graph.emit(GraphEvent.BEFORE_TRANSFORM, { type: GraphEvent.BEFORE_TRANSFORM, data: { mode: 'relative', translate: [0, -3], }, }); await expect(graph).toMatchSnapshot(__filename, 'viewport-change-key'); graph.emit(GraphEvent.AFTER_TRANSFORM, { type: GraphEvent.AFTER_TRANSFORM, data: { mode: 'relative', translate: [0, 3], }, }); await expect(graph).toMatchSnapshot(__filename, 'after-viewport-change'); }); it("show node's key and icon shapes", async () => { graph.updateBehavior({ key: 'optimize-viewport-transform', shapes: (type: ElementType, shape: DisplayObject) => type === 'node' && ['key', 'text'].includes(shape.className), }); graph.emit(GraphEvent.BEFORE_TRANSFORM, { type: GraphEvent.BEFORE_TRANSFORM, data: { mode: 'relative', translate: [0, -3], }, }); await expect(graph).toMatchSnapshot(__filename, 'viewport-change-key-text'); graph.emit(GraphEvent.AFTER_TRANSFORM, { type: GraphEvent.AFTER_TRANSFORM, data: { mode: 'relative', translate: [0, 3], }, }); await expect(graph).toMatchSnapshot(__filename, 'after-viewport-change'); }); it("show node's key and edge's key", async () => { graph.updateBehavior({ key: 'optimize-viewport-transform', shapes: { node: ['key'], edge: ['key'], }, }); graph.emit(GraphEvent.BEFORE_TRANSFORM, { type: GraphEvent.BEFORE_TRANSFORM, data: { mode: 'relative', translate: [0, -3], }, }); await expect(graph).toMatchSnapshot(__filename, 'viewport-change-keys'); graph.emit(GraphEvent.AFTER_TRANSFORM, { type: GraphEvent.AFTER_TRANSFORM, data: { mode: 'relative', translate: [0, 3], }, }); await expect(graph).toMatchSnapshot(__filename, 'after-viewport-change'); }); it('destroy', () => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/scroll-canvas.spec.ts ================================================ import type { Graph } from '@/src'; import { CommonEvent } from '@/src'; import { ScrollCanvasOptions } from '@/src/behaviors'; import { behaviorScrollCanvas } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior scroll canvas', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorScrollCanvas, { animation: false }); }); function setBehavior(options?: Partial) { graph.setBehaviors((behaviors) => behaviors.map((behavior) => { if (typeof behavior === 'object' && behavior.type === 'scroll-canvas') { return { ...behavior, ...options }; } return behavior; }), ); } it('default status', () => { expect(graph.getBehaviors()).toEqual([ { key: 'scroll-canvas', type: 'scroll-canvas', }, ]); }); function emitWheelEvent(options?: { deltaX: number; deltaY: number }) { const dom = graph.getCanvas().getContextService().getDomElement(); dom?.dispatchEvent(new WheelEvent(CommonEvent.WHEEL, options)); } it('scroll', async () => { const [x, y] = graph.getPosition(); emitWheelEvent({ deltaX: -10, deltaY: -10 }); expect(graph.getPosition()).toBeCloseTo([x + 10, y + 10]); await expect(graph).toMatchSnapshot(__filename); }); it('direction', async () => { setBehavior({ direction: 'x' }); let [x, y] = graph.getPosition(); emitWheelEvent({ deltaX: -10, deltaY: -10 }); expect(graph.getPosition()).toBeCloseTo([x + 10, y]); setBehavior({ direction: 'y' }); [x, y] = graph.getPosition(); emitWheelEvent({ deltaX: -10, deltaY: -10 }); expect(graph.getPosition()).toBeCloseTo([x, y + 10]); setBehavior({ direction: undefined }); }); it('sensitivity', () => { const sensitivity = 5; setBehavior({ sensitivity }); const [x, y] = graph.getPosition(); const deltaX = -10, deltaY = -10; emitWheelEvent({ deltaX, deltaY }); expect(graph.getPosition()).toBeCloseTo([x + Math.abs(deltaX * sensitivity), y + Math.abs(deltaY * sensitivity)]); }); const shortcutScrollCanvasOptions: ScrollCanvasOptions = { key: 'shortcut-scroll-canvas', type: 'scroll-canvas', trigger: { up: ['ArrowUp'], down: ['ArrowDown'], right: ['ArrowRight'], left: ['ArrowLeft'], }, }; it('custom trigger', () => { graph.setBehaviors((behavior) => [...behavior, shortcutScrollCanvasOptions]); let [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowUp' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowUp' }); expect(graph.getPosition()).toBeCloseTo([x, y - 10]); [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowDown' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowDown' }); expect(graph.getPosition()).toBeCloseTo([x, y + 10]); [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowLeft' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowLeft' }); expect(graph.getPosition()).toBeCloseTo([x - 10, y]); [x, y] = graph.getPosition(); graph.emit(CommonEvent.KEY_DOWN, { key: 'ArrowRight' }); graph.emit(CommonEvent.KEY_UP, { key: 'ArrowRight' }); expect(graph.getPosition()).toBeCloseTo([x + 10, y]); }); it('range', () => { graph.setBehaviors((behavior) => [...behavior, { ...shortcutScrollCanvasOptions, range: 0.5 }]); const emitArrow = (key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown', count: number) => { for (let i = 0; i < count; i++) { graph.emit(CommonEvent.KEY_DOWN, { key }); graph.emit(CommonEvent.KEY_UP, { key }); } }; const [canvasWidth, canvasHeight] = graph.getCanvas().getSize(); emitArrow('ArrowRight', 50); expect(graph.getPosition()[0]).toBeCloseTo(canvasWidth / 2); emitArrow('ArrowLeft', 50); expect(graph.getPosition()[0]).toBeCloseTo(-canvasWidth / 2); emitArrow('ArrowUp', 50); expect(graph.getPosition()[1]).toBeCloseTo(-canvasHeight / 2); }); it('destroy', () => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/behaviors/zoom-canvas.spec.ts ================================================ import type { Graph } from '@/src'; import { CommonEvent, ContainerEvent } from '@/src'; import type { ZoomCanvasOptions } from '@/src/behaviors/zoom-canvas'; import { behaviorZoomCanvas } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('behavior zoom canvas', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(behaviorZoomCanvas, { animation: false }); }); afterAll(() => { graph.destroy(); }); 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(1.1); await expect(graph).toMatchSnapshot(__filename); }); it('zoom out', () => { const currentZoom = graph.getZoom(); graph.emit(CommonEvent.WHEEL, { deltaY: 5 }); expect(graph.getZoom()).toBe(currentZoom * 0.95); graph.emit(CommonEvent.WHEEL, { deltaY: 5 }); expect(graph.getZoom()).toBeCloseTo(currentZoom * 0.95 ** 2); }); it('mobile zoom', async () => { const initZoom = graph.getZoom(); const canvas = graph.getCanvas(); const container = canvas.getContainer(); if (!container) return; const initialBehaviors = graph.getBehaviors(); graph.setBehaviors([{ type: 'zoom-canvas' }, { type: 'zoom-canvas', trigger: ['pinch'] }]); const pointerdownListener = jest.fn(); const pointermoveListener = jest.fn(); const pointerByTouch = [ { client: { x: 100, y: 100, }, pointerId: 1, pointerType: 'touch', }, { client: { x: 200, y: 200, }, pointerId: 2, pointerType: 'touch', }, ]; const dxForInitial = pointerByTouch[0].client.x - pointerByTouch[1].client.x; const dyForInitial = pointerByTouch[0].client.y - pointerByTouch[1].client.y; const initialDistance = Math.sqrt(dxForInitial * dxForInitial + dyForInitial * dyForInitial); await expect(graph).toMatchSnapshot(__filename, 'mobile-initial'); graph.once('canvas:pointerdown', pointerdownListener); canvas.document.emit(CommonEvent.POINTER_DOWN, { client: { x: 100, y: 100 } }); expect(pointerdownListener).toHaveBeenCalledTimes(1); graph.once('canvas:pointermove', pointermoveListener); canvas.document.emit(CommonEvent.POINTER_MOVE, { client: { x: 200, y: 200 } }); expect(pointermoveListener).toHaveBeenCalledTimes(1); pointerByTouch[1] = { client: { x: 250, y: 250, }, pointerId: 2, pointerType: 'touch', }; const dxForMove = pointerByTouch[0].client.x - pointerByTouch[1].client.x; const dyForMove = pointerByTouch[0].client.y - pointerByTouch[1].client.y; const currentDistance = Math.sqrt(dxForMove * dxForMove + dyForMove * dyForMove); const ratio = currentDistance / initialDistance; const value = (ratio - 1) * 5; await graph.zoomTo(initZoom * value, false, undefined); expect(graph.getZoom()).not.toBe(initZoom); await expect(graph).toMatchSnapshot(__filename, 'mobile-final'); await graph.zoomTo(initZoom, false, undefined); expect(graph.getZoom()).toBe(initZoom); graph.setBehaviors(initialBehaviors); expect(graph.getBehaviors()).toEqual([{ type: 'zoom-canvas' }]); }); 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 * 1.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(1); 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(0.9); 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: any) => 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 * 1.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.1); }); it('zoom to canvas center', async () => { const center = graph.getCanvasCenter(); graph.setBehaviors([{ type: 'zoom-canvas', origin: center }]); const currentZoom = graph.getZoom(); const targetZoom = currentZoom * 0.5; graph.emit(CommonEvent.WHEEL, { deltaY: 50 }); expect(graph.getZoom()).toBe(targetZoom); await expect(graph).toMatchSnapshot(__filename, 'zoom-to-canvas-center'); }); 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(CommonEvent.POINTER_MOVE, {}); expect(pointermoveListener).toHaveBeenCalledTimes(1); // common event graph.once('canvas:click', clickListener); graph.once('canvas:wheel', wheelListener); canvas.document.emit(CommonEvent.CLICK, {}); canvas.document.emit(CommonEvent.WHEEL, {}); expect(clickListener).toHaveBeenCalledTimes(1); expect(wheelListener).toHaveBeenCalledTimes(1); // double click graph.once('canvas:dblclick', dblclickListener); canvas.document.emit(CommonEvent.CLICK, { detail: 2 }); expect(dblclickListener).toHaveBeenCalledTimes(1); // contextmenu graph.once('canvas:contextmenu', contextmenuListener); canvas.document.emit(CommonEvent.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); }); }); ================================================ FILE: packages/g6/__tests__/unit/default.spec.ts ================================================ describe('default', () => { it('expect', () => { expect(1).toBe(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/change-type.spec.ts ================================================ import type { Graph } from '@/src'; import { elementChangeType } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element change type', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementChangeType, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('change type', async () => { graph.updateNodeData([ { id: 'node-1', type: 'circle' }, { id: 'node-2', type: 'diamond' }, ]); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'change-type'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/combo.spec.ts ================================================ import type { Graph } from '@/src'; import { elementCombo } from '@@/demos'; import { createDemoGraph, createGraph } from '@@/utils'; describe('combo', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementCombo, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('collapse circle combo', async () => { const expandCombo = async () => { await graph.expandElement('combo-2'); }; const collapseCombo = async () => { graph.updateComboData([ { id: 'combo-2', style: { collapsedMarker: false, }, }, ]); await graph.collapseElement('combo-2'); }; await collapseCombo(); await expect(graph).toMatchSnapshot(__filename, 'circle-collapse-center'); await expandCombo(); }); it('collapse rect combo', async () => { const expandCombo = async () => { await graph.expandElement('combo-1'); }; const collapseCombo = async () => { graph.updateComboData([ { id: 'combo-1', type: 'rect', style: { collapsedMarker: false, }, }, ]); await graph.collapseElement('combo-1'); }; await collapseCombo(); await expect(graph).toMatchSnapshot(__filename, 'rect-collapse-center'); await expandCombo(); }); it('collapse combo with collapsed marker', async () => { const expandCombo = async () => { graph.updateComboData([ { id: 'combo-2', style: { collapsed: false, }, }, ]); await graph.render(); }; const collapseCombo = async (type: any | ((children: any) => string)) => { graph.updateComboData([ { id: 'combo-2', style: { collapsed: true, collapsedMarker: true, collapsedMarkerType: type, }, }, ]); graph.render(); }; await collapseCombo('child-count'); await expect(graph).toMatchSnapshot(__filename, 'circle-marker-childCount'); await expandCombo(); await collapseCombo('descendant-count'); await expect(graph).toMatchSnapshot(__filename, 'circle-marker-descendantCount'); await expandCombo(); await collapseCombo('node-count'); await expect(graph).toMatchSnapshot(__filename, 'circle-marker-nodeCount'); await expandCombo(); await collapseCombo((children: any) => children.length.toString() + 'nodes'); await expect(graph).toMatchSnapshot(__filename, 'circle-marker-custom'); }); }); describe('combo drag zIndex', () => { it('drag combo will bring related edges forward', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', combo: 'combo-2', style: { x: 120, y: 100 } }, { id: 'node-2', combo: 'combo-1', style: { x: 300, y: 200 } }, { id: 'node-3', combo: 'combo-1', style: { x: 200, y: 300 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3' }, ], combos: [ { id: 'combo-1', type: 'rect', combo: 'combo-2', }, { id: 'combo-2', }, ], }, edge: { style: { stroke: 'red', }, }, combo: { style: { lineWidth: 1, fillOpacity: 1, stroke: 'black', }, }, }); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'combo-zIndex'); await graph.frontElement('combo-1'); await expect(graph).toMatchSnapshot(__filename, 'combo-zIndex'); await graph.frontElement('combo-2'); await expect(graph).toMatchSnapshot(__filename, 'combo-zIndex'); }); }); describe('combo with position', () => { it('combo with position', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-1', combo: 'combo-1', style: { x: 50, y: 100 } }, { id: 'node-2', combo: 'combo-2' }, { id: 'node-3', combo: 'combo-3' }, ], combos: [ { id: 'combo-1', style: { x: 0, y: 0, collapsed: true } }, { id: 'combo-2', style: { x: 100, y: 100, collapsed: true } }, { id: 'combo-3', style: { collapsed: true } }, ], }, node: { style: { labelText: (d) => d.id, }, }, combo: { style: { labelText: (d) => d.id, }, }, }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'combo-with-position'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/arrow.spec.ts ================================================ import { elementEdgeArrow } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge arrow', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeArrow); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/cubic-horizontal.spec.ts ================================================ import { elementEdgeCubicHorizontal } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge cubic horizontal', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeCubicHorizontal); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/cubic-radial.spec.ts ================================================ import { elementEdgeCubicRadial } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge cubic radial', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeCubicRadial); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/cubic-vertical.spec.ts ================================================ import { elementEdgeCubicVertical } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge cubic vertical', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeCubicVertical); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/cubic.spec.ts ================================================ import { elementEdgeCubic } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge cubic', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeCubic); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/custom-arrow.spec.ts ================================================ import { elementEdgeCustomArrow } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge custom arrow', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeCustomArrow); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/line.spec.ts ================================================ import { elementEdgeLine } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge line', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeLine); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/loop-curve.spec.ts ================================================ import { elementEdgeLoopCurve } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge loop curve', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeLoopCurve); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/loop-polyline.spec.ts ================================================ import { elementEdgeLoopPolyline } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge loop polyline', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeLoopPolyline); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/polyline-animation.spec.ts ================================================ import { type Graph } from '@/src'; import { elementEdgePolylineAnimation } from '@@/demos'; import { createDemoGraph } from '@@/utils'; const updateEdgeStyle = (graph: Graph, id: string, attr: string, value: any) => { graph.updateEdgeData((prev) => { const edgeData = prev.find((edge: any) => edge.id === id)!; return [ ...prev.filter((edge: any) => edge.id !== id), { ...edgeData, style: { ...edgeData?.style, [attr]: value, }, }, ]; }); graph.render(); }; describe('element edge polyline animation', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementEdgePolylineAnimation, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('Control Points', async () => { updateEdgeStyle(graph, 'edge-1', 'controlPoints', [[300, 190]]); await expect(graph).toMatchSnapshot(__filename, 'controlPoints'); }); it('Radius', async () => { updateEdgeStyle(graph, 'edge-1', 'radius', 20); await expect(graph).toMatchSnapshot(__filename, 'radius'); updateEdgeStyle(graph, 'edge-1', 'radius', 0); }); it('Router', async () => { updateEdgeStyle(graph, 'edge-1', 'router', { type: 'orth' }); await expect(graph).toMatchSnapshot(__filename, 'edge-polyline-router-has-controlPoints'); updateEdgeStyle(graph, 'edge-1', 'controlPoints', []); await expect(graph).toMatchSnapshot(__filename, 'edge-polyline-router-no-controlPoints'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/polyline-astar.spec.ts ================================================ import { elementEdgePolylineAstar } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge polyline astar', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgePolylineAstar); graph.updateEdgeData([ { id: 'edge-1', style: { router: { type: 'shortest-path', enableObstacleAvoidance: false, startDirections: ['top', 'right', 'bottom', 'left'], endDirections: ['top', 'right', 'bottom', 'left'], }, }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename); graph.updateEdgeData([ { id: 'edge-1', style: { router: { type: 'shortest-path', enableObstacleAvoidance: false, startDirections: ['left'], endDirections: ['left'], }, }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'left-left'); graph.updateEdgeData([ { id: 'edge-1', style: { router: { type: 'shortest-path', offset: 0, gridSize: 5, enableObstacleAvoidance: true, startDirections: ['top', 'right', 'bottom', 'left'], endDirections: ['top', 'right', 'bottom', 'left'], }, }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'obstacle-move-node-1'); graph.updateNodeData([ { id: 'node-2', style: { x: 120, y: 200 }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'obstacle-move-node-2'); graph.updateNodeData([ { id: 'node-2', style: { x: 150, y: 200 }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'obstacle-move-node-3'); graph.updateNodeData([ { id: 'node-2', style: { x: 2000, y: 200 }, }, ]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'obstacle-move-node-4'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/polyline-orth.spec.ts ================================================ import { elementEdgePolylineOrth } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge polyline orth', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgePolylineOrth); await expect(graph).toMatchSnapshot(__filename); graph.setNode({ type: 'rect', style: { size: [60, 30], radius: 8, ports: [{ placement: 'left' }, { placement: 'right' }], }, }); graph.setLayout((prev) => ({ ...prev, rankdir: 'RL' })); graph.render(); await expect(graph).toMatchSnapshot(__filename, 'dagre-RL'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/polyline.spec.ts ================================================ import { elementEdgePolyline } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge polyline', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgePolyline); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/port.spec.ts ================================================ import { elementEdgePort } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge port', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgePort); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/quadratic.spec.ts ================================================ import { elementEdgeQuadratic } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge quadratic', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeQuadratic); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/edges/size.spec.ts ================================================ import { elementEdgeSize } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element edge line size', () => { it('render', async () => { const graph = await createDemoGraph(elementEdgeSize); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/label-background.spec.ts ================================================ import { elementLabelBackground } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label background', () => { it('render', async () => { const graph = await createDemoGraph(elementLabelBackground); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/label-oversized.spec.ts ================================================ import { elementLabelOversized } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementLabelOversized); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/avatar.spec.ts ================================================ import { elementNodeAvatar } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeAvatar); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/circle.spec.ts ================================================ import { elementNodeCircle } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeCircle); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/diamond.spec.ts ================================================ import { elementNodeDiamond } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeDiamond); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/donut.spec.ts ================================================ import { elementNodeDonut } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeDonut); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/ellipse.spec.ts ================================================ import { elementNodeEllipse } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeEllipse); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/hexagon.spec.ts ================================================ import { elementNodeHexagon } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeHexagon); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/image.spec.ts ================================================ import { elementNodeImage } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeImage); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/rect.spec.ts ================================================ import { elementNodeRect } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeRect); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/star.spec.ts ================================================ import { elementNodeStar } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeStar); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/nodes/triangle.spec.ts ================================================ import { elementNodeTriangle } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element label oversized', () => { it('render', async () => { const graph = await createDemoGraph(elementNodeTriangle); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/override-methods.spec.ts ================================================ import { Circle, register } from '@/src'; import { createGraph } from '@@/utils'; describe('element override methods', () => { it('override onCreate, onUpdate, onDestroy', async () => { const create = jest.fn(); const update = jest.fn(); const destroy = jest.fn(); register( 'node', 'custom-circle', class CustomCircle extends Circle { onCreate() { create(); } onUpdate() { update(); } onDestroy() { destroy(); } }, ); const graph = createGraph({ data: { nodes: [{ id: 'node-1', type: 'custom-circle' }], }, }); await graph.draw(); expect(create).toHaveBeenCalledTimes(1); graph.translateElementBy('node-1', [10, 10]); expect(update).toHaveBeenCalledTimes(1); graph.removeNodeData(['node-1']); await graph.draw(); expect(destroy).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/port.spec.ts ================================================ import type { Graph } from '@/src'; import { elementPort } from '@@/demos'; import { createDemoGraph } from '@@/utils'; const updatePort = (graph: Graph, attr: string, value: string | boolean | number) => { graph.updateNodeData((prev) => { const node2Data = prev.find((node: any) => node.id === 'node-2')!; return [ ...prev.filter((node: any) => node.id !== 'node-2'), { ...node2Data, style: { ...node2Data!.style, [attr]: value, }, }, ]; }); }; describe('element port', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementPort, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename, 'port_hidden'); }); it('hide port', async () => { updatePort(graph, 'portR', 3); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'port_show'); }); it('endArrow link to port center', async () => { updatePort(graph, 'portR', 3); updatePort(graph, 'portLinkToCenter', true); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'port_linkToCenter'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/position-combo.spec.ts ================================================ import type { Graph } from '@/src'; import { elementPositionCombo } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element position combo', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementPositionCombo, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/position.spec.ts ================================================ import type { Graph } from '@/src'; import { elementPosition } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element position', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementPosition, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('translateElementTo', async () => { await graph.translateElementTo({ 'node-1': [125, 100], 'node-2': [125, 100], 'node-3': [125, 100], }); await expect(graph).toMatchSnapshot(__filename, 'translateElementTo'); }); it('translateElementBy', async () => { await graph.translateElementBy({ 'node-1': [-50, -50], 'node-2': [+50, -50], 'node-3': [0, +50], }); await expect(graph).toMatchSnapshot(__filename, 'translateElementBy'); }); it('translateElementTo single api', async () => { graph.translateElementTo('node-1', [50, 50]); await expect(graph).toMatchSnapshot(__filename, 'translateElementTo-single'); }); it('translateElementBy single api', async () => { graph.translateElementBy('node-1', [50, 50]); await expect(graph).toMatchSnapshot(__filename, 'translateElementBy-single'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/shape.spec.ts ================================================ import type { BaseShapeStyleProps } from '@/src'; import { BaseShape } from '@/src'; import { Circle } from '@antv/g'; describe('element shape', () => { it('upsert hooks', () => { interface ShapeStyleProps extends BaseShapeStyleProps { shape: any; } const beforeCreate = jest.fn(); const afterCreate = jest.fn(); const beforeUpdate = jest.fn(); const afterUpdate = jest.fn(); const beforeDestroy = jest.fn(); const afterDestroy = jest.fn(); class Shape extends BaseShape { render() { this.upsert('circle', Circle, this.attributes.shape, this, { beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy, }); } } const shape = new Shape({ style: { shape: { r: 10 }, }, }); expect(beforeCreate).toHaveBeenCalledTimes(1); expect(afterCreate).toHaveBeenCalledTimes(1); expect(beforeUpdate).toHaveBeenCalledTimes(0); expect(afterUpdate).toHaveBeenCalledTimes(0); expect(beforeDestroy).toHaveBeenCalledTimes(0); expect(afterDestroy).toHaveBeenCalledTimes(0); shape.update({ shape: { r: 20 } }); expect(beforeCreate).toHaveBeenCalledTimes(1); expect(afterCreate).toHaveBeenCalledTimes(1); expect(beforeUpdate).toHaveBeenCalledTimes(1); expect(afterUpdate).toHaveBeenCalledTimes(1); expect(beforeDestroy).toHaveBeenCalledTimes(0); expect(afterDestroy).toHaveBeenCalledTimes(0); shape.update({ shape: false }); expect(beforeCreate).toHaveBeenCalledTimes(1); expect(afterCreate).toHaveBeenCalledTimes(1); expect(beforeUpdate).toHaveBeenCalledTimes(1); expect(afterUpdate).toHaveBeenCalledTimes(1); expect(beforeDestroy).toHaveBeenCalledTimes(1); expect(afterDestroy).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/state.spec.ts ================================================ import type { Graph } from '@/src'; import { elementState } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element state', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementState, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('set state', async () => { graph.setElementState({ 'node-1': ['active'], 'node-2': ['selected'], 'edge-1': [], 'edge-2': ['active'], }); await expect(graph).toMatchSnapshot(__filename, 'setState'); }); it('set state single api', async () => { graph.setElementState('node-1', ['selected']); await expect(graph).toMatchSnapshot(__filename, 'setState-single'); graph.setElementState('node-1', []); await expect(graph).toMatchSnapshot(__filename, 'setState-single-default'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/visibility.spec.ts ================================================ import type { Graph } from '@/src'; import { elementVisibility } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element visibility', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementVisibility, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('hide', async () => { await graph.hideElement(['node-1', 'node-2', 'node-3', 'edge-1', 'edge-2', 'edge-3']); await expect(graph).toMatchSnapshot(__filename, 'hide'); }); it('show', async () => { await graph.showElement(['node-1', 'node-2', 'edge-1']); await expect(graph).toMatchSnapshot(__filename, 'show'); }); it('show and hide', async () => { await graph.setElementVisibility({ 'node-1': 'hidden', 'node-3': 'visible', 'edge-1': 'hidden', 'edge-2': 'visible', }); await expect(graph).toMatchSnapshot(__filename, 'show-and-hide'); }); it('show and hide single api', async () => { graph.setElementVisibility('node-1', 'visible'); await expect(graph).toMatchSnapshot(__filename, 'show-single'); graph.setElementVisibility('node-1', 'hidden'); await expect(graph).toMatchSnapshot(__filename, 'hide-single'); }); }); ================================================ FILE: packages/g6/__tests__/unit/elements/z-index.spec.ts ================================================ import type { Graph } from '@/src'; import { ComboEvent, NodeEvent } from '@/src'; import { elementZIndex } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('element zIndex', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(elementZIndex, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default status', async () => { await expect(graph).toMatchSnapshot(__filename); }); it('drag overlap', async () => { graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 140, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-node-1'); graph.emit(NodeEvent.DRAG_END); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-2' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: -65, dy: 100 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-node-2'); graph.emit(NodeEvent.DRAG_END); graph.emit(NodeEvent.DRAG_START, { target: { id: 'node-3' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 75, dy: -100 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-node-3'); graph.emit(NodeEvent.DRAG_END); }); it('combo overlap', async () => { graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-1' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 20, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-1'); graph.emit(ComboEvent.DRAG_END); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-2' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 20, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-2'); graph.emit(ComboEvent.DRAG_END); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-3' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 20, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-combo-3'); graph.emit(ComboEvent.DRAG_END); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-3' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: 20, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-combo-3'); graph.emit(ComboEvent.DRAG_END); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-4' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: -20, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-combo-4(1)'); graph.emit(ComboEvent.DRAG_END); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-4' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: -40, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-combo-4(2)'); graph.emit(ComboEvent.DRAG_END); graph.emit(ComboEvent.DRAG_START, { target: { id: 'combo-4' }, targetType: 'combo' }); graph.emit(ComboEvent.DRAG, { dx: -40, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'drag-overlap-combo-4(3)'); graph.emit(ComboEvent.DRAG_END); }); }); ================================================ FILE: packages/g6/__tests__/unit/import.spec.ts ================================================ import * as G6 from '@/src'; describe('import', () => { it('default', () => { expect(G6).not.toBe(undefined); const entries = Object.entries(G6); expect(entries.length).toBeGreaterThan(0); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/circular.spec.ts ================================================ import { layoutCircularBasic, layoutCircularConfigurationTranslate, layoutCircularDegree, layoutCircularDivision, layoutCircularSpiral, } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('layout circular', () => { it('layoutCircularBasic', async () => { const graph = await createDemoGraph(layoutCircularBasic); await expect(graph).toMatchSnapshot(__filename, 'basic'); graph.destroy(); }); it('layoutCircularConfigurationTranslate', async () => { const graph = await createDemoGraph(layoutCircularConfigurationTranslate); await expect(graph).toMatchSnapshot(__filename, 'configuration-translate'); graph.setLayout({ type: 'circular', radius: 200, startAngle: Math.PI / 4, endAngle: Math.PI, divisions: 5, ordering: 'degree', }); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'configuration-translate-division'); graph.destroy(); }); it('layoutCircularDegree', async () => { const graph = await createDemoGraph(layoutCircularDegree); await expect(graph).toMatchSnapshot(__filename, 'degree'); graph.destroy(); }); it('layoutCircularDivision', async () => { const graph = await createDemoGraph(layoutCircularDivision); await expect(graph).toMatchSnapshot(__filename, 'division'); graph.destroy(); }); it('layoutCircularSpiral', async () => { const graph = await createDemoGraph(layoutCircularSpiral); await expect(graph).toMatchSnapshot(__filename, 'spiral'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/combo-layout.spec.ts ================================================ import { layoutComboCombined } from '@@/demos'; import { createDemoGraph } from '@@/utils'; import { clear as clearMockRandom, mock as mockRandom } from 'jest-random-mock'; describe('combo layout', () => { beforeEach(() => { mockRandom(); }); afterEach(() => { clearMockRandom(); }); it('combined', async () => { const graph = await createDemoGraph(layoutComboCombined); await expect(graph).toMatchSnapshot(__filename, 'combined'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/compact-box.spec.ts ================================================ import { layoutCompactBoxBasic, layoutCompactBoxLeftAlign, layoutCompactBoxTopToBottom } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('compact box', () => { it('basic', async () => { const graph = await createDemoGraph(layoutCompactBoxBasic); await expect(graph).toMatchSnapshot(__filename, 'basic'); graph.destroy(); }); it('left align', async () => { const graph = await createDemoGraph(layoutCompactBoxLeftAlign); await expect(graph).toMatchSnapshot(__filename, 'left-align'); graph.destroy(); }); it('top to bottom', async () => { const graph = await createDemoGraph(layoutCompactBoxTopToBottom); await expect(graph).toMatchSnapshot(__filename, 'top-to-bottom'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/concentric.spec.ts ================================================ import { layoutConcentric } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('layout concentric', () => { it('render', async () => { const graph = await createDemoGraph(layoutConcentric); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/custom-dagre.spec.ts ================================================ import { layoutCustomDagre } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('custom dagre', () => { it('render', async () => { const graph = await createDemoGraph(layoutCustomDagre); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/custom-layout-horizontal.spec.ts ================================================ import { layoutCustomHorizontal } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('custom layout horizontal', () => { it('render', async () => { const graph = await createDemoGraph(layoutCustomHorizontal); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/d3-force-collision.spec.ts ================================================ import { layoutForceCollision } from '@@/demos'; import { createDemoGraph } from '@@/utils'; import { clear as clearMockRandom, mock as mockRandom } from 'jest-random-mock'; describe('layout d3 force collision', () => { beforeAll(() => { mockRandom(); }); afterAll(() => { clearMockRandom(); }); it('render', async () => { const graph = await createDemoGraph(layoutForceCollision); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/d3-force-lattice.spec.ts ================================================ import { layoutForceLattice } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('layout d3 force lattice', () => { it('render', async () => { const graph = await createDemoGraph(layoutForceLattice); await expect(graph).toMatchSnapshot(__filename); // drag graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/d3-force.spec.ts ================================================ import { layoutD3Force } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('layout d3 force', () => { it('render', async () => { const graph = await createDemoGraph(layoutD3Force); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/dagre.spec.ts ================================================ import { layoutAntVDagreFlow, layoutAntVDagreFlowCombo, layoutDagre } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('antv dagre flow', () => { it('flow', async () => { const graph = await createDemoGraph(layoutAntVDagreFlow); await expect(graph).toMatchSnapshot(__filename, 'antv-flow'); graph.destroy(); }); it('antv dagre flow combo', async () => { const graph = await createDemoGraph(layoutAntVDagreFlowCombo); await expect(graph).toMatchSnapshot(__filename, 'antv-flow-combo'); graph.destroy(); }); it('dagre.js', async () => { const graph = await createDemoGraph(layoutDagre); await expect(graph).toMatchSnapshot(__filename, 'dagre'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/dendrogram.spec.ts ================================================ import { layoutDendrogramBasic, layoutDendrogramTb } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('dendrogram', () => { it('basic', async () => { const graph = await createDemoGraph(layoutDendrogramBasic); await expect(graph).toMatchSnapshot(__filename, 'basic'); graph.destroy(); }); it('tb', async () => { const graph = await createDemoGraph(layoutDendrogramTb); await expect(graph).toMatchSnapshot(__filename, 'tb'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/fishbone.spec.ts ================================================ import type { Graph } from '@/src'; import { layoutFishbone } from '@@/demos/layout-fishbone'; import { createDemoGraph } from '@@/utils'; describe('fishbone', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(layoutFishbone); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename, 'direction-RL'); }); it('direction RL', async () => { graph.setLayout((prev) => ({ ...prev, type: 'fishbone', direction: 'LR' })); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'direction-LR'); }); it('vGap and hGap', async () => { graph.setLayout((prev) => ({ ...prev, type: 'fishbone', vGap: 32, hGap: 32 })); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'vGap-32-hGap-32'); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/fruchterman.spec.ts ================================================ import { layoutFruchtermanBasic, layoutFruchtermanCluster } from '@@/demos'; import { createDemoGraph } from '@@/utils'; import { clear as clearMockRandom, mock as mockRandom } from 'jest-random-mock'; describe('layout fruchterman', () => { beforeEach(() => { mockRandom(); }); afterEach(() => { clearMockRandom(); }); it('basic', async () => { const graph = await createDemoGraph(layoutFruchtermanBasic); await expect(graph).toMatchSnapshot(__filename, 'basic'); graph.destroy(); }); it('cluster', async () => { const graph = await createDemoGraph(layoutFruchtermanCluster); await expect(graph).toMatchSnapshot(__filename, 'cluster'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/grid.spec.ts ================================================ import type { Graph } from '@/src'; import { layoutGrid } from '@@/demos/layout-grid'; import { createDemoGraph } from '@@/utils'; describe('grid', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(layoutGrid); }); afterAll(() => { graph.destroy(); }); it('sortBy default', async () => { await expect(graph).toMatchSnapshot(__filename, 'sortby-default'); }); it('sortBy id', async () => { graph.setLayout({ type: 'grid', sortBy: 'id' }); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'sortby-id'); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/indented.spec.ts ================================================ import { layoutIndented } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('layout d3 force', () => { it('render', async () => { const graph = await createDemoGraph(layoutIndented); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/mds.spec.ts ================================================ import type { Graph } from '@/src'; import { layoutMDS } from '@@/demos/layout-mds'; import { createDemoGraph } from '@@/utils'; describe('mds', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(layoutMDS); }); afterAll(() => { graph.destroy(); }); it('mds linkDistance = 100', async () => { await expect(graph).toMatchSnapshot(__filename, 'ld100'); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/mindmap.spec.ts ================================================ import { layoutMindmapH, layoutMindmapHCustomSide, layoutMindmapHLeft, layoutMindmapHRight } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('mindmap', () => { it('h custom side', async () => { const graph = await createDemoGraph(layoutMindmapHCustomSide); await expect(graph).toMatchSnapshot(__filename, 'h-custom-side'); graph.destroy(); }); it('h left', async () => { const graph = await createDemoGraph(layoutMindmapHLeft); await expect(graph).toMatchSnapshot(__filename, 'h-left'); graph.destroy(); }); it('h', async () => { const graph = await createDemoGraph(layoutMindmapH); await expect(graph).toMatchSnapshot(__filename, 'h'); graph.destroy(); }); it('h right', async () => { const graph = await createDemoGraph(layoutMindmapHRight); await expect(graph).toMatchSnapshot(__filename, 'h-right'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/pipeline.spec.ts ================================================ import { GraphEvent } from '@/src'; import { layoutPipelineMdsForce } from '@@/demos'; import { createDemoGraph, createGraph } from '@@/utils'; describe('pipeline', () => { it('event', async () => { const graph = createGraph({ data: { nodes: new Array(10).fill(null).map((_, i) => ({ id: `${i}` })), }, layout: [ { type: 'force', }, { type: 'd3-force', }, { type: 'grid', }, ], }); const before = jest.fn(); const after = jest.fn(); graph.on(GraphEvent.BEFORE_STAGE_LAYOUT, (e) => { before(e); }); graph.on(GraphEvent.AFTER_STAGE_LAYOUT, (e) => { after(e); }); await graph.render(); expect(before).toHaveBeenCalledTimes(3); expect(after).toHaveBeenCalledTimes(3); expect(before.mock.calls[0][0].data.options.type).toBe('force'); expect(before.mock.calls[1][0].data.options.type).toBe('d3-force'); expect(before.mock.calls[2][0].data.options.type).toBe('grid'); expect(after.mock.calls[0][0].data.options.type).toBe('force'); expect(after.mock.calls[1][0].data.options.type).toBe('d3-force'); expect(after.mock.calls[2][0].data.options.type).toBe('grid'); }); it('layout-pipeline-mds-force', async () => { const graph = await createDemoGraph(layoutPipelineMdsForce); await expect(graph).toMatchSnapshot(__filename, 'layout-pipeline-mds-force'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/radial-layout.spec.ts ================================================ import { layoutRadialBasic, layoutRadialConfigurationTranslate, layoutRadialPreventOverlap, layoutRadialPreventOverlapUnstrict, layoutRadialSort, } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('radial layout', () => { it('basic', async () => { const graph = await createDemoGraph(layoutRadialBasic); await expect(graph).toMatchSnapshot(__filename, 'basic'); graph.destroy(); }); it('configuration translate', async () => { const graph = await createDemoGraph(layoutRadialConfigurationTranslate); await expect(graph).toMatchSnapshot(__filename, 'configuration-translate'); graph.destroy(); }); it('prevent overlap', async () => { const graph = await createDemoGraph(layoutRadialPreventOverlap); await expect(graph).toMatchSnapshot(__filename, 'prevent-overlap'); graph.destroy(); }); it('prevent overlap unstrict', async () => { const graph = await createDemoGraph(layoutRadialPreventOverlapUnstrict); await expect(graph).toMatchSnapshot(__filename, 'prevent-overlap-unstrict'); graph.destroy(); }); it('sort', async () => { const graph = await createDemoGraph(layoutRadialSort); await expect(graph).toMatchSnapshot(__filename, 'sort'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/layouts/snake.spec.ts ================================================ import type { Graph } from '@/src'; import { layoutSnake } from '@@/demos/layout-snake'; import { createDemoGraph } from '@@/utils'; describe('snake', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(layoutSnake); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename, 'default'); }); it('padding', async () => { graph.setLayout((prev) => ({ ...prev, padding: 20 })); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'padding-20'); }); it('set cols as 1', async () => { graph.setLayout((prev) => ({ ...prev, padding: 0, cols: 1 })); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'cols-1'); }); it('set cols as 20', async () => { graph.setLayout((prev) => ({ ...prev, padding: 0, cols: 20 })); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'cols-20'); }); it('colSep and rowSep', async () => { graph.setLayout((prev) => ({ ...prev, cols: 6, colGap: 50, rowGap: 50 })); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'gap-50'); }); it('anti-clockwise', async () => { graph.setLayout((prev) => ({ ...prev, clockwise: false })); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'anti-clockwise'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/background.spec.ts ================================================ import { pluginBackground } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('plugin background', () => { it('background', async () => { const graph = await createDemoGraph(pluginBackground); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.g6-background') as HTMLDivElement; expect(graph.getPlugins()).toEqual([ { type: 'background', key: 'background', backgroundImage: 'url(https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*0Qq0ToQm1rEAAAAAAAAAAAAADmJ7AQ/original)', }, ]); expect(el.style.backgroundImage).toContain( 'url(https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*0Qq0ToQm1rEAAAAAAAAAAAAADmJ7AQ/original)', ); await graph.destroy(); expect(container.querySelector('.g6-background')).toBeFalsy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/bubble-sets.spec.ts ================================================ import type { BubbleSets, Graph, ID } from '@/src'; import { NodeEvent } from '@/src'; import { pluginBubbleSets } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('plugin bubble-sets', () => { let graph: Graph; let bubbleSets: BubbleSets; beforeAll(async () => { graph = await createDemoGraph(pluginBubbleSets, { animation: false }); bubbleSets = graph.getPluginInstance('bubble-sets'); }); afterAll(() => { graph.destroy(); }); const reset = () => { const members: ID[] = ['node0', 'node1', 'node8', 'node9', 'node11']; const avoidMembers: ID[] = []; bubbleSets.updateMember(members); bubbleSets.updateAvoidMember(avoidMembers); }; it('default', async () => { reset(); await expect(graph).toMatchSnapshot(__filename, 'default'); }); it('add/remove/update member', async () => { bubbleSets.addMember(['node12', 'edge1']); await expect(graph).toMatchSnapshot(__filename, 'member-add'); bubbleSets.removeMember(['node12', 'edge1']); await expect(graph).toMatchSnapshot(__filename, 'member-remove'); bubbleSets.updateMember(['node3', 'node4', 'node5', 'node6', 'node7']); await expect(graph).toMatchSnapshot(__filename, 'member-update'); expect(bubbleSets.getMember()).toEqual(['node3', 'node4', 'node5', 'node6', 'node7']); reset(); }); it('add/remove/update non-member', async () => { bubbleSets.addAvoidMember('node13'); await expect(graph).toMatchSnapshot(__filename, 'non-member-add'); bubbleSets.removeAvoidMember('node13'); await expect(graph).toMatchSnapshot(__filename, 'non-member-remove'); bubbleSets.updateAvoidMember(['node13', 'node2']); await expect(graph).toMatchSnapshot(__filename, 'non-member-update'); expect(bubbleSets.getAvoidMember()).toEqual(['node13', 'node2']); reset(); }); it('update options', async () => { graph.updatePlugin({ key: 'bubble-sets', fill: 'pink' }); graph.render(); await expect(graph).toMatchSnapshot(__filename, 'options-update'); reset(); }); it('update element', async () => { graph.emit(NodeEvent.DRAG_START, { target: { id: 'node11' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 50, dy: 50 }); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'element-position-update'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/camera-setting.spec.ts ================================================ import { pluginCameraSetting } from '@@/demos'; import { createDemoGraph } from '@@/utils'; import { CameraProjectionMode } from '@antv/g'; describe('plugin camera-setting', () => { it('camera-setting orthographic', async () => { const graph = await createDemoGraph(pluginCameraSetting); const camera = graph.getCanvas().getCamera(); expect(camera.getProjectionMode()).toBe(CameraProjectionMode.ORTHOGRAPHIC); expect([...camera.getPosition()]).toBeCloseTo([250, 250, 500]); // 视点位置 / Focal point expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]); expect(camera.getDistance()).toBe(500); expect(camera.getNear()).toBe(0.1); expect(camera.getFar()).toBe(1000); expect(camera.getZoom()).toBe(1); graph.destroy(); }); it('camera-setting perspective', async () => { const graph = await createDemoGraph(pluginCameraSetting); graph.updatePlugin({ key: 'camera-setting', type: 'camera-setting', projectionMode: 'perspective', near: 0.01, far: 50000, fov: 75, aspect: 'auto', distance: 1000, }); const camera = graph.getCanvas().getCamera(); expect(camera.getProjectionMode()).toBe(CameraProjectionMode.PERSPECTIVE); expect([...camera.getPosition()]).toBeCloseTo([250, 250, 1000]); // 视点位置 / Focal point expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]); expect(camera.getDistance()).toBe(1000); expect(camera.getNear()).toBe(0.01); expect(camera.getFar()).toBe(50000); expect(camera.getZoom()).toBe(1); graph.destroy(); }); it.only('camera-setting perspective orbiting azimuth', async () => { const graph = await createDemoGraph(pluginCameraSetting); graph.updatePlugin({ key: 'camera-setting', type: 'camera-setting', cameraType: 'orbiting', projectionMode: 'perspective', roll: 0, elevation: 45, azimuth: 0, }); const camera = graph.getCanvas().getCamera(); expect(camera.getProjectionMode()).toBe(CameraProjectionMode.PERSPECTIVE); // 相机以视点为中心,沿 y 轴顺时针旋转 45 度 // The camera rotates 45 degrees clockwise along the positive y-axis with the focal point as the center const delta = 500 / Math.sqrt(2); expect([...camera.getPosition()]).toBeCloseTo([250, 250 + delta, delta]); // 视点位置 / Focal point expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]); graph.updatePlugin({ key: 'camera-setting', type: 'camera-setting', roll: 0, elevation: 0, azimuth: 45, }); // 相机以视点为中心,沿 z 轴逆时针旋转 45 度 // The camera rotates 45 degrees counterclockwise along the positive z-axis with the focal point as the center expect([...camera.getPosition()]).toBeCloseTo([250 - delta, 250, delta]); graph.updatePlugin({ key: 'camera-setting', type: 'camera-setting', roll: 180, elevation: 45, azimuth: 0, }); expect([...camera.getPosition()]).toBeCloseTo([250, 250 + delta, delta]); expect(camera.getDistance()).toBeCloseTo(500); expect(camera.getNear()).toBe(0.1); expect(camera.getFar()).toBe(1000); expect(camera.getZoom()).toBe(1); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/contextmenu.spec.ts ================================================ import type { Contextmenu } from '@/src'; import { NodeEvent } from '@/src'; import { pluginContextmenu } from '@@/demos'; import { createDemoGraph, sleep } from '@@/utils'; describe('plugin contextmenu', () => { it('contextmenu', async () => { const graph = await createDemoGraph(pluginContextmenu); const onClick = jest.fn(); graph.updatePlugin({ key: 'contextmenu', onClick }); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.g6-contextmenu') as HTMLDivElement; const $dom = container.querySelector('.g6-contextmenu'); expect($dom).toBeTruthy(); expect($dom?.classList.contains('custom-class-name')).toBeTruthy(); expect(el.querySelector('.g6-contextmenu-li')).toBeFalsy(); const emit = () => { graph.emit(NodeEvent.CONTEXT_MENU, { target: { id: '1' }, targetType: 'node', client: { x: 100, y: 100, }, }); }; emit(); await sleep(100); expect(el.querySelector('.g6-contextmenu-ul')).toBeTruthy(); expect(el.querySelectorAll('.g6-contextmenu-li').length).toBe(2); const instance = graph.getPluginInstance('contextmenu'); // @ts-expect-error private method instance.onMenuItemClick({ target: el.querySelector('.g6-contextmenu-li') }); expect(onClick).toHaveBeenCalledTimes(1); expect(container.querySelector('.g6-contextmenu')!.style.display).toBe('none'); emit(); await sleep(100); expect(container.querySelector('.g6-contextmenu')!.style.display).toBe('block'); document.body.click(); expect(container.querySelector('.g6-contextmenu')!.style.display).toBe('none'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/edge-bundling.spec.ts ================================================ import { createDemoGraph } from '@@/utils'; import type { Graph } from '@antv/g6'; import { pluginEdgeBundling } from '../../demos'; describe('plugin edge bundling', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(pluginEdgeBundling, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('default edge bundling', async () => { await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/edge-filter-lens.spec.ts ================================================ import { pluginEdgeFilterLens } from '@@/demos'; import { CommonEvent, Graph } from '@antv/g6'; import { createDemoGraph, dispatchCanvasEvent } from '../../utils'; describe('edge-filter-lens', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(pluginEdgeFilterLens, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('move lens by pointermove', async () => { await expect(graph).toMatchSnapshot(__filename); dispatchCanvasEvent(graph, CommonEvent.POINTER_MOVE, { canvas: { x: 200, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'move-lens-pointermove'); }); it('move lens by click', async () => { graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 180, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'move-lens-click-1'); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'move-lens-click-2'); }); it('move lens by drag', async () => { graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'drag' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 180, y: 100 } }); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { canvas: { x: 200, y: 100 } }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { canvas: { x: 220, y: 100 } }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'move-lens-drag'); }); it('scale lens by wheel', async () => { function emitWheelEvent(options?: { deltaX: number; deltaY: number; clientX: number; clientY: number }) { const dom = graph.getCanvas().getContextService().getDomElement(); dom?.dispatchEvent(new WheelEvent(CommonEvent.WHEEL, options)); } emitWheelEvent({ deltaX: 1, deltaY: 2, clientX: 200, clientY: 100 }); emitWheelEvent({ deltaX: 1, deltaY: 2, clientX: 200, clientY: 100 }); await expect(graph).toMatchSnapshot(__filename, 'scale-larger'); emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 }); emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 }); emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 }); emitWheelEvent({ deltaX: -1, deltaY: -2, clientX: 200, clientY: 100 }); await expect(graph).toMatchSnapshot(__filename, 'scale-smaller'); }); it('show edge when only its source/target node in lens', async () => { graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click', nodeType: 'source' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } }); await expect(graph).toMatchSnapshot(__filename, 'node-type-source'); graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click', nodeType: 'target' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } }); await expect(graph).toMatchSnapshot(__filename, 'node-type-target'); graph.updatePlugin({ key: 'edge-filter-lens', trigger: 'click', nodeType: 'either' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } }); await expect(graph).toMatchSnapshot(__filename, 'node-type-either'); }); it('lens style', async () => { graph.updatePlugin({ key: 'edge-filter-lens', edgeStyle: () => ({ stroke: '#f00' }) }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 200 } }); await expect(graph).toMatchSnapshot(__filename, 'lens-style'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/fisheye.spec.ts ================================================ import { pluginFisheye } from '@@/demos'; import { CommonEvent, Graph } from '@antv/g6'; import { createDemoGraph, dispatchCanvasEvent } from '../../utils'; import { emitWheelEvent } from '../../utils/dom'; describe('plugin-fisheye', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(pluginFisheye, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('move lens by pointermove', async () => { await expect(graph).toMatchSnapshot(__filename); dispatchCanvasEvent(graph, CommonEvent.POINTER_MOVE, { canvas: { x: 420, y: 150 } }); await expect(graph).toMatchSnapshot(__filename, 'move-lens-pointermove'); }); it('move lens by drag', async () => { graph.updatePlugin({ key: 'fisheye', trigger: 'drag' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 420, y: 150 } }); dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { canvas: { x: 420, y: 150 } }); dispatchCanvasEvent(graph, CommonEvent.DRAG, { canvas: { x: 400, y: 180 } }); dispatchCanvasEvent(graph, CommonEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'move-lens-drag'); }); it('move lens by click', async () => { graph.updatePlugin({ key: 'fisheye', trigger: 'click' }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 180, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'move-lens-click-1'); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 200, y: 100 } }); await expect(graph).toMatchSnapshot(__filename, 'move-lens-click-2'); }); it('scale lens R/D by wheel', async () => { graph.updatePlugin({ key: 'fisheye', scaleRBy: 'wheel', scaleDBy: 'unset' }); const emitWheelUpEvent = (count: number) => { for (let i = 0; i < count; i++) { emitWheelEvent(graph, { deltaX: 1, deltaY: 2, clientX: 420, clientY: 150 }); } }; const emitWheelDownEvent = (count: number) => { for (let i = 0; i < count; i++) { emitWheelEvent(graph, { deltaX: -1, deltaY: -2, clientX: 420, clientY: 150 }); } }; dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 420, y: 150 } }); emitWheelUpEvent(5); await expect(graph).toMatchSnapshot(__filename, 'scale-R-wheel-larger'); emitWheelDownEvent(10); await expect(graph).toMatchSnapshot(__filename, 'scale-R-wheel-smaller'); emitWheelUpEvent(5); graph.updatePlugin({ key: 'fisheye', scaleRBy: 'unset', scaleDBy: 'wheel' }); emitWheelUpEvent(5); await expect(graph).toMatchSnapshot(__filename, 'scale-D-wheel-larger'); emitWheelDownEvent(10); await expect(graph).toMatchSnapshot(__filename, 'scale-D-wheel-smaller'); emitWheelUpEvent(5); }); it('scale lens R/D by drag', async () => { graph.updatePlugin({ key: 'fisheye', scaleRBy: 'drag', scaleDBy: 'unset' }); const emitPositionDragEvent = (count: number) => { dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { canvas: { x: 420, y: 150 } }); for (let i = 0; i < count; i++) { dispatchCanvasEvent(graph, CommonEvent.DRAG, { dx: 1, dy: -2 }); } dispatchCanvasEvent(graph, CommonEvent.DRAG_END); }; const emitNegativeDragEvent = (count: number) => { dispatchCanvasEvent(graph, CommonEvent.DRAG_START, { canvas: { x: 420, y: 150 } }); for (let i = 0; i < count; i++) { dispatchCanvasEvent(graph, CommonEvent.DRAG, { dx: -1, dy: 2 }); } dispatchCanvasEvent(graph, CommonEvent.DRAG_END); }; emitPositionDragEvent(5); await expect(graph).toMatchSnapshot(__filename, 'scale-R-drag-larger'); emitNegativeDragEvent(10); await expect(graph).toMatchSnapshot(__filename, 'scale-R-drag-smaller'); emitPositionDragEvent(5); graph.updatePlugin({ key: 'fisheye', scaleRBy: 'unset', scaleDBy: 'drag' }); emitPositionDragEvent(5); await expect(graph).toMatchSnapshot(__filename, 'scale-D-drag-larger'); emitNegativeDragEvent(10); await expect(graph).toMatchSnapshot(__filename, 'scale-D-drag-smaller'); emitPositionDragEvent(5); }); it('show D percent', async () => { graph.updatePlugin({ key: 'fisheye', showDPercent: false }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 420, y: 150 } }); await expect(graph).toMatchSnapshot(__filename, 'hide-D-percent'); }); it('lens style', async () => { graph.updatePlugin({ key: 'fisheye', showDPercent: true, style: { fill: '#f00', lineDash: [5, 5], stroke: '#666' }, }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 420, y: 150 } }); await expect(graph).toMatchSnapshot(__filename, 'lens-style'); }); it('node style in lens', async () => { graph.updatePlugin({ key: 'fisheye', style: { lineDash: 0 }, nodeStyle: { halo: true } }); dispatchCanvasEvent(graph, CommonEvent.CLICK, { canvas: { x: 420, y: 150 } }); await expect(graph).toMatchSnapshot(__filename, 'node-style'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/grid-line.spec.ts ================================================ import type { Graph } from '@/src'; import { pluginGridLine } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('plugin grid line', () => { let graph: Graph; let gridLineElement: HTMLCollectionOf; beforeAll(async () => { graph = await createDemoGraph(pluginGridLine, { animation: false }); gridLineElement = graph .getCanvas() .getContainer()! .getElementsByClassName('g6-grid-line')! as HTMLCollectionOf; }); afterAll(() => { graph.destroy(); }); it('default status', () => { expect(graph.getPlugins()).toEqual([{ type: 'grid-line', follow: false }]); expect(gridLineElement.length).toBe(1); expect(gridLineElement[0].style.backgroundSize).toBe('20px 20px'); }); it('update grid line', () => { graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object') { return { ...plugin, follow: true, size: 30, }; } return plugin; }), ); expect(graph.getPlugins()).toEqual([{ type: 'grid-line', follow: true, size: 30 }]); expect(gridLineElement[0].style.backgroundSize).toBe('30px 30px'); }); it('drag', () => { graph.emit('aftertransform', { data: { translate: [10, 10] } }); expect(gridLineElement[0].style.backgroundPosition).toBe('10px 10px'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/history/plugin-history.spec.ts ================================================ import type { History } from '@/src'; import { ComboEvent, Graph, NodeEvent } from '@/src'; import { pluginHistory } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('history plugin', () => { let graph: Graph; let history: History; beforeAll(async () => { graph = await createDemoGraph(pluginHistory, { animation: false }); history = graph.getPluginInstance('history'); }); afterAll(() => { graph.destroy(); }); it('addData', async () => { graph.addData({ nodes: [{ id: 'node-5', style: { x: 200, y: 100, fill: 'pink' } }], edges: [{ source: 'node-1', target: 'node-5', style: { stroke: 'brown' } }], }); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'addData'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'addData-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'addData-redo'); history.undo(); }); it('updateData', async () => { graph.updateData({ nodes: [{ id: 'node-1', style: { x: 150, y: 100, fill: 'red' } }], edges: [{ id: 'edge-1', style: { stroke: 'green' } }], }); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'updateData'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'updateData-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'updateData-redo'); history.undo(); }); it('removeData', async () => { graph.removeData({ nodes: ['node-1'], edges: ['edge-1'], }); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'deleteData'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'deleteData-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'deleteData-redo'); history.undo(); }); it('collapse/expand', async () => { graph.collapseElement('combo-2'); await expect(graph).toMatchSnapshot(__filename, 'collapse'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'collapse-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'collapse-redo'); history.undo(); graph.expandElement('combo-1'); await expect(graph).toMatchSnapshot(__filename, 'expand'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'expand-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'expand-redo'); history.undo(); }); it('setElementState', async () => { graph.setElementState('node-1', 'selected', true); await expect(graph).toMatchSnapshot(__filename, 'setElementsState'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'setElementsState-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'setElementsState-redo'); history.undo(); }); it('setElementVisibility', async () => { graph.setElementVisibility('node-1', 'hidden'); await expect(graph).toMatchSnapshot(__filename, 'hideElement'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'hideElement-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'hideElement-redo'); history.undo(); }); it('setElementZIndex', async () => { graph.setElementZIndex('combo-2', 100); graph.setElementZIndex('node-1', 101); await expect(graph).toMatchSnapshot(__filename, 'setElementZIndex'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'setElementZIndex-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'setElementZIndex-redo'); history.undo(); }); it('create-edge', async () => { graph.setBehaviors((prev) => [ ...prev, { type: 'create-edge', trigger: 'click', style: { stroke: 'red', lineWidth: 2 } }, ]); graph.emit(NodeEvent.CLICK, { target: { id: 'node-1' }, targetType: 'node' }); graph.emit(ComboEvent.CLICK, { target: { id: 'combo-2' }, targetType: 'combo' }); await expect(graph).toMatchSnapshot(__filename, 'create-edge'); history.undo(); await expect(graph).toMatchSnapshot(__filename, 'create-edge-undo'); history.redo(); await expect(graph).toMatchSnapshot(__filename, 'create-edge-redo'); history.undo(); }); it('beforeAddCommand', async () => { const undoStackLen = history.undoStack.length; graph.updatePlugin({ key: 'history', beforeAddCommand: () => false }); graph.setElementVisibility('node-1', 'hidden'); await graph.draw(); expect(history.undoStack.length).toEqual(undoStackLen); graph.updatePlugin({ key: 'history', beforeAddCommand: () => true }); graph.setElementVisibility('node-1', 'visible'); await graph.draw(); expect(history.undoStack.length).toEqual(undoStackLen + 1); }); it('canUndo/canRedo/clear', async () => { expect(history.canUndo()).toBeTruthy(); expect(history.canRedo()).toBeTruthy(); history.clear(); expect(history.undoStack.length).toEqual(0); expect(history.canUndo()).toBeFalsy(); expect(history.canRedo()).toBeFalsy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/history/utils.spec.ts ================================================ import { alignFields, parseCommand } from '@/src/plugins/history/util'; import type { DataChange } from '@/src/types'; describe('history utils', () => { it('alignFields', () => { const data = { id: 'combo-2', data: {}, style: { zIndex: 0, collapsed: true, visibility: 'hidden', states: ['active'], }, }; const data2 = { id: 'combo-2', data: {}, style: { zIndex: 0, }, }; alignFields(data, data2); expect(data2).toEqual({ id: 'combo-2', data: {}, style: { zIndex: 0, collapsed: false, visibility: 'visible', states: [], }, }); }); it('parseCommand', () => { const command = [ { value: { id: 'combo-2', data: {}, style: { x: 188.11, y: 193.5, zIndex: 0, collapsed: true, }, }, original: { id: 'combo-2', data: {}, style: { x: 188.11, y: 193.5, zIndex: 0, collapsed: false, }, }, type: 'ComboUpdated', }, ] as DataChange[]; expect(parseCommand(command).current.update.combos![0]).toEqual({ data: {}, id: 'combo-2', style: { collapsed: true, x: 188.11, y: 193.5, zIndex: 0 }, }); expect(parseCommand(command).original.update.combos![0]).toEqual({ data: {}, id: 'combo-2', style: { collapsed: false, x: 188.11, y: 193.5, zIndex: 0 }, }); const command2 = [ { type: 'NodeAdded', value: { id: 'node-1', data: {}, style: { x: 100, y: 100, }, }, }, ] as DataChange[]; expect(parseCommand(command2).current.add.nodes![0]).toEqual({ data: {}, id: 'node-1', style: { x: 100, y: 100 }, }); expect(parseCommand(command2).original.remove.nodes![0]).toEqual({ data: {}, id: 'node-1', style: { x: 100, y: 100 }, }); const command3 = [ { type: 'EdgeRemoved', value: { id: 'edge-1', data: {}, source: 'node-1', target: 'node-2', }, }, ] as DataChange[]; expect(parseCommand(command3).current.remove.edges![0]).toEqual({ data: {}, id: 'edge-1', source: 'node-1', target: 'node-2', }); expect(parseCommand(command3).original.add.edges![0]).toEqual({ data: {}, id: 'edge-1', source: 'node-1', target: 'node-2', }); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/hull/plugin-hull.spec.ts ================================================ import type { Graph, Hull } from '@/src'; import { NodeEvent } from '@/src'; import type { HullOptions } from '@/src/plugins'; import { pluginHull } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('plugin hull', () => { let graph: Graph; let hull: Hull; beforeAll(async () => { graph = await createDemoGraph(pluginHull, { animation: false }); hull = graph.getPluginInstance('hull'); }); afterAll(() => { graph.destroy(); }); it('init', async () => { await expect(graph).toMatchSnapshot(__filename, 'default'); }); const updateHullOptions = (optionsToUpdate: Partial) => { graph.updatePlugin({ key: 'hull', ...optionsToUpdate }); graph.render(); }; it('update corner', async () => { updateHullOptions({ corner: 'sharp' }); await expect(graph).toMatchSnapshot(__filename, 'corner__sharp'); updateHullOptions({ corner: 'smooth' }); await expect(graph).toMatchSnapshot(__filename, 'corner__smooth'); updateHullOptions({ corner: 'rounded' }); await expect(graph).toMatchSnapshot(__filename, 'corner__rounded'); }); it('update padding', async () => { updateHullOptions({ padding: 20 }); await expect(graph).toMatchSnapshot(__filename, 'padding__20'); updateHullOptions({ padding: 0 }); await expect(graph).toMatchSnapshot(__filename, 'padding__0'); }); it('update labelPlacement', async () => { updateHullOptions({ labelPlacement: 'top' }); await expect(graph).toMatchSnapshot(__filename, 'labelPlacement__top'); updateHullOptions({ labelPlacement: 'left' }); await expect(graph).toMatchSnapshot(__filename, 'labelPlacement__left'); updateHullOptions({ labelPlacement: 'right' }); await expect(graph).toMatchSnapshot(__filename, 'labelPlacement__right'); updateHullOptions({ labelPlacement: 'bottom' }); await expect(graph).toMatchSnapshot(__filename, 'labelPlacement__bottom'); }); it('update labelCloseToPath', async () => { updateHullOptions({ labelCloseToPath: false }); await expect(graph).toMatchSnapshot(__filename, 'labelCloseToHull__false'); updateHullOptions({ labelCloseToPath: true }); await expect(graph).toMatchSnapshot(__filename, 'labelCloseToHull__true'); }); it('update labelAutoRotate', async () => { updateHullOptions({ labelAutoRotate: false }); await expect(graph).toMatchSnapshot(__filename, 'labelAutoRotate__false'); updateHullOptions({ labelAutoRotate: true }); await expect(graph).toMatchSnapshot(__filename, 'labelAutoRotate__true'); }); it('addMember', async () => { hull.addMember('node3'); await expect(graph).toMatchSnapshot(__filename, 'addMember__node3'); }); it('removeMember', async () => { hull.removeMember('node1'); await expect(graph).toMatchSnapshot(__filename, 'removeMember__node1'); }); it('updateMember', async () => { hull.updateMember(['node5', 'node6']); await expect(graph).toMatchSnapshot(__filename, 'updateMember'); expect(hull.getMember()).toEqual(['node5', 'node6']); }); it('update element position', async () => { graph.emit(NodeEvent.DRAG_START, { target: { id: 'node5' }, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { dx: 50, dy: -50 }); graph.emit(NodeEvent.DRAG_END); await expect(graph).toMatchSnapshot(__filename, 'updateMember__position'); }); it('empty members', async () => { hull.updateMember([]); await expect(graph).toMatchSnapshot(__filename, 'emptyMembers'); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/hull/util.spec.ts ================================================ import { computeHullPath } from '@/src/plugins/hull/util'; describe('util', () => { it('computeHullPath', () => { expect(computeHullPath([[10, 10]], 0, 'rounded')).toEqual([ ['M', 10, 10], ['A', 0, 0, 0, 0, 0, 10, 10], ['A', 0, 0, 0, 0, 0, 10, 10], ]); expect(computeHullPath([[10, 10]], 0, 'sharp')).toEqual([ ['M', 10, 10], ['L', 10, 10], ['L', 10, 10], ['L', 10, 10], ['Z'], ]); expect(computeHullPath([[10, 10]], 0, 'smooth')).toEqual([ ['M', 10, 10], ['A', 0, 0, 0, 0, 0, 10, 10], ['A', 0, 0, 0, 0, 0, 10, 10], ]); expect( computeHullPath( [ [10, 10], [20, 20], ], 0, 'rounded', ), ).toEqual([ ['M', 10, 10], ['L', 20, 20], ['A', 0, 0, 0, 0, 0, 20, 20], ['L', 10, 10], ['A', 0, 0, 0, 0, 0, 10, 10], ]); }); expect( computeHullPath( [ [10, 10], [20, 20], ], 0, 'sharp', ), ).toEqual([['M', 10, 10], ['L', 20, 20], ['L', 20, 20], ['L', 10, 10], ['Z']]); expect( computeHullPath( [ [10, 10], [20, 20], ], 0, 'smooth', ), ).toEqual([ ['M', 10, 10], ['L', 20, 20], ['A', 0, 0, 0, 0, 0, 20, 20], ['L', 10, 10], ['A', 0, 0, 0, 0, 0, 10, 10], ]); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/legend.spec.ts ================================================ import { Legend } from '@/src/plugins/legend'; import { pluginLegend } from '@@/demos'; import { createDemoGraph } from '@@/utils'; const mockEvent: any = { __data__: { id: 'node__0', index: 0, style: { layout: 'flex', labelText: 'a', markerLineWidth: 3, marker: 'diamond', markerStroke: '#000000', markerFill: 'red', spacing: 4, markerSize: 16, labelFontSize: 16, markerOpacity: 1, labelOpacity: 1, }, }, }; describe('plugin legend', () => { it('normal', async () => { const graph = await createDemoGraph(pluginLegend); await expect(graph).toMatchSnapshot(__filename, 'normal'); graph.destroy(); }); it('click', async () => { const graph = await createDemoGraph(pluginLegend); const legend = graph.getPluginInstance('legend'); legend.click(mockEvent); await expect(graph).toMatchSnapshot(__filename, 'click'); legend.click(mockEvent); await expect(graph).toMatchSnapshot(__filename, 'click-again'); graph.destroy(); }); it('update trigger to hover', async () => { const graph = await createDemoGraph(pluginLegend); graph.setPlugins((plugins) => plugins.map((plugin) => { if (typeof plugin === 'object') { return { ...plugin, trigger: 'hover', position: 'top', }; } return plugin; }), ); const legend = graph.getPluginInstance('legend'); legend.mouseenter(mockEvent); await expect(graph).toMatchSnapshot(__filename, 'mouseenter'); legend.mouseleave(mockEvent); await expect(graph).toMatchSnapshot(__filename, 'mouseleave'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/snapline.spec.ts ================================================ import type { Graph, Node } from '@/src'; import { NodeEvent } from '@/src'; import { pluginSnapline } from '@@/demos'; import { createDemoGraph } from '../../utils'; describe('plugin snapline', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(pluginSnapline); }); it('snapline', async () => { await expect(graph).toMatchSnapshot(__filename); // @ts-expect-error access private property const node = graph.context.element?.getElement('node3'); let i = 0; const moveNodeAndCreateSnapshot = async (x: number, y: number, prefix: string, reset = false) => { graph.updateNodeData([{ id: 'node3', style: { x, y } }]); graph.render(); graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 }); if (reset) i = 0; await expect(graph).toMatchSnapshot(__filename, `drag-node3-${prefix}-${i}`); graph.emit(NodeEvent.DRAG_END, { target: node }); i++; }; await moveNodeAndCreateSnapshot(50, 300, 'vertical'); await moveNodeAndCreateSnapshot(90, 300, 'vertical'); await moveNodeAndCreateSnapshot(100, 300, 'vertical'); await moveNodeAndCreateSnapshot(110, 300, 'vertical'); await moveNodeAndCreateSnapshot(150, 300, 'vertical'); await moveNodeAndCreateSnapshot(200, 300, 'vertical'); await moveNodeAndCreateSnapshot(250, 300, 'vertical'); await moveNodeAndCreateSnapshot(290, 300, 'vertical'); await moveNodeAndCreateSnapshot(300, 300, 'vertical'); await moveNodeAndCreateSnapshot(310, 300, 'vertical'); await moveNodeAndCreateSnapshot(350, 300, 'vertical'); await moveNodeAndCreateSnapshot(400, 300, 'vertical'); await moveNodeAndCreateSnapshot(200, 65, 'horizontal', true); await moveNodeAndCreateSnapshot(200, 95, 'horizontal'); await moveNodeAndCreateSnapshot(200, 100, 'horizontal'); await moveNodeAndCreateSnapshot(200, 105, 'horizontal'); await moveNodeAndCreateSnapshot(200, 135, 'horizontal'); await moveNodeAndCreateSnapshot(200, 150, 'horizontal'); await moveNodeAndCreateSnapshot(200, 265, 'horizontal'); await moveNodeAndCreateSnapshot(200, 295, 'horizontal'); await moveNodeAndCreateSnapshot(200, 300, 'horizontal'); await moveNodeAndCreateSnapshot(200, 305, 'horizontal'); await moveNodeAndCreateSnapshot(200, 335, 'horizontal'); graph.updatePlugin({ key: 'snapline', offset: Infinity }); graph.updateNodeData([{ id: 'node3', style: { x: 100, y: 300 } }]); graph.render(); graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, `offset-infinity`); graph.emit(NodeEvent.DRAG_END, { target: node }); graph.updatePlugin({ key: 'snapline', filter: (node: Node) => node.id !== 'node2' }); graph.render(); graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, `filter-node2`); graph.emit(NodeEvent.DRAG_END, { target: node }); graph.updatePlugin({ key: 'snapline', filter: () => true, autoSnap: true }); graph.updateNodeData([{ id: 'node3', style: { x: 96, y: 304 } }]); graph.render(); graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, `auto-snap`); graph.emit(NodeEvent.DRAG_END, { target: node }); // zoom to test lineWidth graph.zoomTo(5, false, [300, 300]); graph.updatePlugin({ key: 'snapline', autoSnap: false }); graph.updateNodeData([{ id: 'node3', style: { x: 260, y: 300 } }]); graph.render(); graph.emit(NodeEvent.DRAG_START, { target: node, targetType: 'node' }); graph.emit(NodeEvent.DRAG, { target: node, dx: 0, dy: 0 }); await expect(graph).toMatchSnapshot(__filename, 'zoom-5'); graph.emit(NodeEvent.DRAG_END, { target: node }); }); afterAll(() => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/timebar.spec.ts ================================================ import type { Timebar } from '@/src'; import { pluginTimebar } from '@@/demos'; import { createDemoGraph, sleep } from '@@/utils'; describe('plugin timebar', () => { it('time type, modify', async () => { const graph = await createDemoGraph(pluginTimebar); await expect(graph).toMatchSnapshot(__filename); const timebar = graph.getPluginInstance('timebar'); timebar.play(); await sleep(1000); await expect(graph).toMatchSnapshot(__filename, 'play-1-time-modify'); await sleep(1000); await expect(graph).toMatchSnapshot(__filename, 'play-2-time-modify'); timebar.pause(); timebar.backward(); await expect(graph).toMatchSnapshot(__filename, 'backward-1-time-modify'); timebar.forward(); await expect(graph).toMatchSnapshot(__filename, 'forward-1-time-modify'); timebar.forward(); await expect(graph).toMatchSnapshot(__filename, 'forward-2-time-modify'); timebar.reset(); await expect(graph).toMatchSnapshot(__filename, 'reset-modify'); graph.destroy(); }); it('time type, visibility', async () => { const graph = await createDemoGraph(pluginTimebar); const timebar = graph.getPluginInstance('timebar'); timebar.update({ mode: 'visibility', }); timebar.forward(); await expect(graph).toMatchSnapshot(__filename, 'forward-1-time-visibility'); timebar.forward(); timebar.forward(); timebar.backward(); await expect(graph).toMatchSnapshot(__filename, 'backward-1-time-visibility'); timebar.reset(); await expect(graph).toMatchSnapshot(__filename, 'reset-visibility'); graph.destroy(); }); it('chart type', async () => { // In current, cannot capture the timebar snapshot }); it('event callback', async () => { const onChange = jest.fn(); const onRest = jest.fn(); const onPlay = jest.fn(); const onPause = jest.fn(); const onBackward = jest.fn(); const onForward = jest.fn(); const graph = await createDemoGraph(pluginTimebar); const timebar = graph.getPluginInstance('timebar'); timebar.update({ onChange, onRest, onPlay, onPause, onBackward, onForward, }); // api calls do not trigger event callbacks // timebar.play(); // expect(onPlay).toHaveBeenCalledTimes(1); // timebar.pause(); // expect(onPause).toHaveBeenCalledTimes(1); // timebar.forward(); // expect(onForward).toHaveBeenCalledTimes(1); // timebar.backward(); // expect(onBackward).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/title.spec.ts ================================================ import type { Graph, Label, Title } from '@/src'; import { pluginTitle } from '@@/demos'; import { createDemoGraph } from '../../utils'; describe('plugin title', () => { let graph: Graph; let titlePlugin: Title; const executeTest = () => { expect(typeof titlePlugin).toBe('object'); const [width] = graph.getSize(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const [titleLabel, subtitleLabel] = titlePlugin.canvas.getRoot().childNodes; const titleAttr = (titleLabel as Label).attributes; const titleX = titleAttr.x; expect(titleX).toBe(width / 2); const subtitleAttr = (subtitleLabel as Label).attributes; const subtitleX = subtitleAttr.x; expect(subtitleX).toBe(width / 2); expect(titleAttr.text).toBe('这是一个标题这是一个标题'); expect(subtitleAttr.text).toBe('这是一个副标'); expect(titleAttr.fontSize).toBe(28); expect(subtitleAttr.fill).toBe('#2989FF'); }; beforeAll(async () => { graph = await createDemoGraph(pluginTitle); titlePlugin = graph.getPluginInstance('title'); }); it('title', executeTest); afterAll(() => { graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/toolbar/plugin-toolbar.spec.ts ================================================ import { pluginToolbarBuildIn } from '@@/demos'; import { createDemoGraph } from '@@/utils'; import { get } from '@antv/util'; describe('plugin toolbar', () => { it('toolbar', async () => { const graph = await createDemoGraph(pluginToolbarBuildIn); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.g6-toolbar') as HTMLDivElement; expect(graph.getPlugins().length).toBe(1); expect(get(graph.getPlugins(), [0, 'position'])).toBe('top-left'); expect(el.querySelectorAll('.g6-toolbar-item').length).toBe(9); graph.destroy(); expect(container.querySelector('.g6-toolbar-item')).toBeFalsy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/toolbar/util.spec.ts ================================================ import { parsePositionToStyle } from '@/src/plugins/toolbar/util'; describe('util', () => { it('parsePositionToStyle', () => { expect(parsePositionToStyle('top-left')).toEqual({ top: '8px', right: 'unset', bottom: 'unset', left: '8px', flexDirection: 'row', }); expect(parsePositionToStyle('top-right')).toEqual({ top: '8px', right: '8px', bottom: 'unset', left: 'unset', flexDirection: 'row', }); expect(parsePositionToStyle('bottom-left')).toEqual({ top: 'unset', right: 'unset', bottom: '8px', left: '8px', flexDirection: 'row', }); expect(parsePositionToStyle('bottom-right')).toEqual({ top: 'unset', right: '8px', bottom: '8px', left: 'unset', flexDirection: 'row', }); expect(parsePositionToStyle('left-top')).toEqual({ top: '8px', right: 'unset', bottom: 'unset', left: '8px', flexDirection: 'column', }); expect(parsePositionToStyle('right-top')).toEqual({ top: '8px', right: '8px', bottom: 'unset', left: 'unset', flexDirection: 'column', }); expect(parsePositionToStyle('left-bottom')).toEqual({ top: 'unset', right: 'unset', bottom: '8px', left: '8px', flexDirection: 'column', }); expect(parsePositionToStyle('right-bottom')).toEqual({ top: 'unset', right: '8px', bottom: '8px', left: 'unset', flexDirection: 'column', }); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/tooltip.spec.ts ================================================ import type { Tooltip } from '@/src'; import { pluginTooltipAsync, pluginTooltipEnable } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('plugin tooltip', () => { it('enable', async () => { const graph = await createDemoGraph(pluginTooltipEnable); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.tooltip') as HTMLDivElement; const plugin = graph.getPluginInstance('tooltip'); await plugin.showById('node3'); expect(el.style.visibility).toBe('hidden'); await plugin.showById('node1'); expect(el.style.visibility).toBe('visible'); graph.destroy(); }); it('get content null', async () => { const graph = await createDemoGraph(pluginTooltipEnable); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.tooltip') as HTMLDivElement; const plugin = graph.getPluginInstance('tooltip'); await plugin.showById('node2'); expect(el.style.visibility).toBe('hidden'); graph.destroy(); }); it('get content async', async () => { const graph = await createDemoGraph(pluginTooltipAsync); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.tooltip') as HTMLDivElement; const plugin = graph.getPluginInstance('tooltip'); await plugin.showById('node1'); expect(el.style.visibility).toBe('visible'); expect(el.innerHTML).toBe('get content async test'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/utils/dom.spec.ts ================================================ import { createPluginContainer, insertDOM } from '@/src/plugins/utils/dom'; describe('plugin dom utils', () => { it('createPluginContainer', () => { const el = createPluginContainer('test'); expect(el.getAttribute('class')).toBe('g6-test'); expect(el.style.position).toBe('unset'); expect(el.style.display).toBe('block'); expect(el.style.inset).toBe('0px'); expect(el.style.height).toBe('100%'); expect(el.style.width).toBe('100%'); expect(el.style.overflow).toBe('hidden'); expect(el.style.pointerEvents).toBe('none'); }); it('createPluginContainer cover=false', () => { const el = createPluginContainer('test', false); expect(el.getAttribute('class')).toBe('g6-test'); expect(el.style.position).toBe('absolute'); expect(el.style.display).toBe('block'); expect(el.style.height).not.toBe('100%'); expect(el.style.width).not.toBe('100%'); expect(el.style.overflow).not.toBe('hidden'); expect(el.style.pointerEvents).not.toBe('none'); }); it('createPluginContainer with style', () => { const el = createPluginContainer('test', false, { color: 'red' }); expect(el.getAttribute('class')).toBe('g6-test'); expect(el.style.color).toBe('red'); }); it('insertDOM', () => { insertDOM('g6-test', 'div', { color: 'red' }, 'test', document.body); let el = document.getElementById('g6-test')!; expect(el).toBeTruthy(); expect(el.style.color).toBe('red'); expect(el.innerHTML).toBe('test'); insertDOM('g6-test', 'div', { color: 'red' }, 'new html', document.body); el = document.getElementById('g6-test')!; expect(el.innerHTML).toBe('new html'); el = insertDOM('g6-test'); expect(el.tagName.toLowerCase()).toBe('div'); expect(el.innerHTML).toBe(''); expect(el.parentNode).toBe(document.body); }); }); ================================================ FILE: packages/g6/__tests__/unit/plugins/watermark.spec.ts ================================================ import { pluginWatermark, pluginWatermarkImage } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('plugin watermark', () => { it('watermark text', async () => { const graph = await createDemoGraph(pluginWatermark); const container = graph.getCanvas().getContainer()!; const el = container.querySelector('.g6-watermark') as HTMLDivElement; expect(graph.getPlugins()).toEqual([{ type: 'watermark', text: 'hello, \na watermark.', textFontSize: 12 }]); expect(el.style.backgroundImage).toContain('data:image/png;base64'); await graph.destroy(); expect(container.querySelector('.g6-watermark')).toBeFalsy(); }); it('watermark image', async () => { const graph = await createDemoGraph(pluginWatermarkImage); const container = graph.getCanvas().getContainer()!; expect(graph.getPlugins()).toEqual([ { type: 'watermark', width: 100, height: 100, imageURL: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*7svFR6wkPMoAAAAAAAAAAAAADmJ7AQ/original', }, ]); await graph.destroy(); expect(container.querySelector('.g6-watermark')).toBeFalsy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/registry.spec.ts ================================================ import { Circle, CircleCombo, Cubic, CubicHorizontal, CubicRadial, CubicVertical, Diamond, Donut, Ellipse, ExtensionCategory, HTML, Hexagon, Image, Line, Polyline, Quadratic, Rect, RectCombo, Star, Triangle, getExtension, getExtensions, register, } from '@/src'; import { dark, light } from '@/src/themes'; import { Circle as GCircle } from '@antv/g'; import { pick } from '@antv/util'; describe('registry', () => { it('registerBuiltInPlugins', () => { expect(getExtensions(ExtensionCategory.NODE)).toEqual({ circle: Circle, ellipse: Ellipse, image: Image, rect: Rect, star: Star, triangle: Triangle, diamond: Diamond, donut: Donut, hexagon: Hexagon, html: HTML, }); expect(getExtensions(ExtensionCategory.EDGE)).toEqual({ cubic: Cubic, line: Line, polyline: Polyline, quadratic: Quadratic, 'cubic-horizontal': CubicHorizontal, 'cubic-vertical': CubicVertical, 'cubic-radial': CubicRadial, }); expect(getExtensions(ExtensionCategory.COMBO)).toEqual({ circle: CircleCombo, rect: RectCombo, }); expect(getExtensions(ExtensionCategory.THEME)).toEqual({ dark, light, }); }); it('register, getPlugin, getPlugins', () => { class CircleNode {} class RectNode {} class Edge {} register(ExtensionCategory.NODE, 'circle-node', CircleNode as any); register(ExtensionCategory.NODE, 'rect-node', RectNode as any); register(ExtensionCategory.EDGE, 'line-edge', Edge as any); expect(getExtension(ExtensionCategory.NODE, 'circle-node')).toEqual(CircleNode); expect(getExtension(ExtensionCategory.NODE, 'rect-node')).toEqual(RectNode); expect(getExtension(ExtensionCategory.NODE, 'diamond-node')).toEqual(undefined); expect(getExtension(ExtensionCategory.EDGE, 'line-edge')).toEqual(Edge); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); register(ExtensionCategory.NODE, 'circle-node', CircleNode as any); expect(consoleErrorSpy).toHaveBeenCalledTimes(0); consoleErrorSpy.mockRestore(); expect(pick(getExtensions(ExtensionCategory.NODE), ['circle-node', 'rect-node'])).toEqual({ 'circle-node': CircleNode, 'rect-node': RectNode, }); }); it('override', () => { class CircleNode {} class RectNode {} register(ExtensionCategory.NODE, 'circle-node', CircleNode as any); register(ExtensionCategory.NODE, 'circle-node', RectNode as any); expect(getExtension(ExtensionCategory.NODE, 'circle-node')).toEqual(RectNode); }); it('register shape', () => { const shapes = getExtensions(ExtensionCategory.SHAPE); expect(Object.keys(shapes)).toEqual([ 'circle', 'ellipse', 'group', 'html', 'image', 'line', 'path', 'polygon', 'polyline', 'rect', 'text', 'label', 'badge', ]); register(ExtensionCategory.SHAPE, 'circle-shape', GCircle); expect(getExtension(ExtensionCategory.SHAPE, 'circle-shape')).toEqual(GCircle); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/canvas.spec.ts ================================================ import { createGraph, createGraphCanvas } from '@@/utils'; describe('Canvas', () => { const svg = createGraphCanvas(null, 500, 500, 'svg'); beforeAll(async () => { await svg.ready; }); it('context', () => { expect(svg.context).toBeDefined(); }); it('getDevice', async () => { // getDevice only exists on webgl // webgl canvas cannot init in test env // expect(webgl.getDevice()).toBeDefined(); }); it('coordinate transform', () => { // TODO g canvas client 坐标转换疑似异常 expect(svg.getClientByCanvas([0, 0])).toBeCloseTo([0, 0, 0]); expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([0, 0, 0]); expect(svg.getViewportByClient([0, 0])).toBeCloseTo([0, 0, 0]); expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([0, 0, 0]); const camera = svg.getCamera(); camera.pan(100, 100); expect([...camera.getPosition()]).toBeCloseTo([350, 350, 500]); expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]); expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([100, 100, 0]); expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([-100, -100, 0]); // camera pan 采用相对移动 camera.pan(-200, -200); // focal point wont change // expect([...camera.getFocalPoint()]).toBeCloseTo([250, 250, 0]); expect([...camera.getPosition()]).toBeCloseTo([150, 150, 500]); expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]); expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([100, 100, 0]); // move to origin camera.pan(100, 100); camera.pan(-100, -100); expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]); camera.pan(100, 100); }); it('coordinate transform with landmark', async () => { const camera = svg.getCamera(); const [px, py, pz] = camera.getPosition(); const [fx, fy, fz] = camera.getFocalPoint(); // expect([fx, fy, fz]).toEqual([250, 250, 0]); expect([px, py, pz]).toEqual([250, 250, 500]); const landmark1 = camera.createLandmark('landmark1', { // 视点坐标 / viewport coordinates focalPoint: [fx + 100, fy + 100, fz], // 相机坐标 / camera coordinates position: [px + 100, py + 100, pz], }); await new Promise((resolve) => { camera.gotoLandmark(landmark1, { onfinish: resolve }); }); expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([100, 100, 0]); expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([-100, -100, 0]); const landmark2 = camera.createLandmark('landmark2', { // 视点坐标 / viewport coordinates focalPoint: [fx - 100, fy - 100, fz], // 相机坐标 / camera coordinates position: [px - 100, py - 100, pz], }); await new Promise((resolve) => { camera.gotoLandmark(landmark2, { onfinish: resolve }); }); expect(svg.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]); expect(svg.getViewportByCanvas([0, 0])).toBeCloseTo([100, 100, 0]); expect([...camera.getFocalPoint()]).toBeCloseTo([150, 150, 0]); expect([...camera.getPosition()]).toBeCloseTo([150, 150, 500]); }); it('cursor', async () => { const graph = createGraph({ cursor: 'progress', }); await graph.draw(); 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']); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/data.spec.ts ================================================ import { treeToGraphData } from '@/src'; import { DataController } from '@/src/runtime/data'; import { reduceDataChanges } from '@/src/utils/change'; import { idOf } from '@/src/utils/id'; import tree from '@@/dataset/algorithm-category.json'; import { clone } from '@antv/util'; const data = { nodes: [ { id: 'node-1', data: { value: 1 }, style: { fill: 'red' } }, { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green' } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue' } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: {} }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: {} }, ], combos: [{ id: 'combo-1', data: {}, style: {} }], }; describe('DataController', () => { it('init', () => { const controller = new DataController(); expect(controller).toBeDefined(); expect(controller.model).toBeDefined(); }); it('empty data', () => { const controller = new DataController(); expect(controller.getData()).toEqual({ nodes: [], edges: [], combos: [] }); controller.addComboData([{ id: 'combo-1' }]); expect(controller.getComboData(['combo-1'])).toEqual([{ id: 'combo-1', data: {}, style: { zIndex: 0 } }]); }); it('setData', () => { const controller = new DataController(); controller.addData(clone(data)); controller.setData({ nodes: [{ id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } }], }); expect(controller.getData()).toEqual({ nodes: [{ id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } }], edges: [], combos: [], }); }); it('addData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: { value: 1 }, style: { fill: 'red', zIndex: 0 } }, { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue', zIndex: 1 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }, ], combos: [{ id: 'combo-1', data: {}, style: { zIndex: 0 } }], }); controller.addData({ nodes: [{ id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } }], }); expect(controller.hasNode('node-4')).toBe(true); controller.addComboData([{ id: 'combo-2' }]); expect(controller.hasEdge('combo-2')).toBe(false); expect(controller.hasEdge('combo-2')).toBe(false); expect(controller.hasCombo('combo-2')).toBe(true); controller.addNodeData([]); controller.addNodeData([{ id: 'node-5', data: { value: 5 }, combo: 'combo-2', style: { fill: 'black' } }]); controller.addEdgeData([{ id: 'edge-3', source: 'node-1', target: 'node-5', data: { weight: 3 } }]); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: { value: 1 }, style: { fill: 'red', zIndex: 0 } }, { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue', zIndex: 1 } }, { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } }, { id: 'node-5', data: { value: 5 }, combo: 'combo-2', style: { fill: 'black', zIndex: 1 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }, { id: 'edge-3', source: 'node-1', target: 'node-5', data: { weight: 3 }, style: { zIndex: 0 } }, ], combos: [ { id: 'combo-1', data: {}, style: { zIndex: 0 } }, { id: 'combo-2', data: {}, style: { zIndex: 0 } }, ], }); }); it('getData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getNodeData(['node-2']).map(idOf)).toEqual(data.nodes.slice(1, 2).map(idOf)); expect(controller.getEdgeData(['edge-1']).map(idOf)).toEqual(data.edges.slice(0, 1).map(idOf)); expect(controller.getComboData(['combo-1']).map(idOf)).toEqual(data.combos.slice(0, 1).map(idOf)); expect(controller.getChildrenData('combo-1').map(idOf)).toEqual(['node-2', 'node-3']); expect(controller.getAncestorsData('node-2', 'combo').map(idOf)).toEqual(['combo-1']); }); it('updateData', () => { const controller = new DataController(); controller.addData(clone(data)); controller.updateData({ nodes: [{ id: 'node-1', data: { value: 2 }, style: { fill: 'pink', lineWidth: 2 } }], edges: [{ id: 'edge-1', data: { weight: 678 } }], }); controller.addComboData([{ id: 'combo-2' }]); controller.updateNodeData([{ id: 'node-1', data: { value: 666 }, combo: 'combo-2' }]); controller.updateNodeData([{ id: 'node-2', combo: 'combo-2' }]); controller.updateEdgeData([{ id: 'edge-2', data: { weight: 666 } }]); controller.updateComboData([{ id: 'combo-1', data: { value: 100 }, style: { stroke: 'blue' } }]); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: { value: 666 }, combo: 'combo-2', style: { fill: 'pink', lineWidth: 2, zIndex: 1 } }, { id: 'node-2', data: { value: 2 }, combo: 'combo-2', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue', zIndex: 1 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 678 }, style: { zIndex: 0 } }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 666 }, style: { zIndex: 0 } }, ], combos: [ { id: 'combo-1', data: { value: 100 }, style: { stroke: 'blue', zIndex: 0 } }, { id: 'combo-2', data: {}, style: { zIndex: 0 } }, ], }); }); it('updateNodeData', () => { const controller = new DataController(); controller.addNodeData([{ id: 'node-1', data: { value: 1 }, style: { fill: 'red' } }]); // no changes controller.updateNodeData([{ id: 'node-1', data: { value: 1 }, style: { fill: 'red' } }]); expect(controller.getNodeData()).toEqual([{ id: 'node-1', data: { value: 1 }, style: { fill: 'red', zIndex: 0 } }]); }); it('updateEdgeData', () => { const controller = new DataController(); controller.addData({ nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 } }], }); // no changes controller.updateEdgeData([{ id: 'edge-1', data: { weight: 1 } }]); expect(controller.getEdgeData()).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: -1 } }, ]); // update source controller.updateEdgeData([{ id: 'edge-1', source: 'node-2' }]); expect(controller.getEdgeData()).toEqual([ { id: 'edge-1', source: 'node-2', target: 'node-2', data: { weight: 1 }, style: { zIndex: -1 } }, ]); // update target controller.updateEdgeData([{ id: 'edge-1', target: 'node-1' }]); expect(controller.getEdgeData()).toEqual([ { id: 'edge-1', source: 'node-2', target: 'node-1', data: { weight: 1 }, style: { zIndex: -1 } }, ]); }); it('updateComboData', () => { const data = { nodes: [ { id: 'node-1', combo: 'combo-1', style: { x: 50, y: 50 } }, { id: 'node-2', combo: 'combo-1', style: { x: 100, y: 100 } }, ], combos: [ { id: 'combo-1', style: { x: 0, y: 0 }, }, { id: 'combo-2' }, ], }; const controller = new DataController(); controller.addData(data); controller.updateComboData([{ id: 'combo-1', style: { x: 100, y: 100, z: 0 } }]); // no changes controller.updateComboData([{ id: 'combo-1', style: { x: 100, y: 100, z: 0 } }]); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: {}, combo: 'combo-1', style: { x: 50, y: 50, zIndex: 1 } }, { id: 'node-2', data: {}, combo: 'combo-1', style: { x: 100, y: 100, zIndex: 1 } }, ], edges: [], combos: [ { id: 'combo-1', data: {}, style: { x: 100, y: 100, z: 0, zIndex: 0 } }, { id: 'combo-2', data: {}, style: { zIndex: 0 } }, ], }); controller.updateComboData([{ id: 'combo-1', style: { fill: 'pink' } }]); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: {}, combo: 'combo-1', style: { x: 50, y: 50, zIndex: 1 } }, { id: 'node-2', data: {}, combo: 'combo-1', style: { x: 100, y: 100, zIndex: 1 } }, ], edges: [], combos: [ { id: 'combo-1', data: {}, style: { x: 100, y: 100, z: 0, fill: 'pink', zIndex: 0 } }, { id: 'combo-2', data: {}, style: { zIndex: 0 } }, ], }); controller.updateComboData([{ id: 'combo-2' }]); }); it('translateComboBy', () => { const controller = new DataController(); controller.addData({ nodes: [{ id: 'node-1', combo: 'combo-1' }], combos: [{ id: 'combo-1' }], }); controller.translateComboBy('combo-1', [100, 100]); expect(controller.getData()).toEqual({ nodes: [{ id: 'node-1', data: {}, combo: 'combo-1', style: { x: 100, y: 100, z: 0, zIndex: 1 } }], combos: [{ id: 'combo-1', data: {}, style: { x: 100, y: 100, z: 0, zIndex: 0 } }], edges: [], }); }); it('translateComboBy with children', () => { const controller = new DataController(); controller.addData({ nodes: [ { id: 'node-1' }, { id: 'node-2', combo: 'combo-1', style: { y: 50, fill: 'green' } }, { id: 'node-3', combo: 'combo-1', style: { x: 100, y: 100, fill: 'blue' } }, ], combos: [{ id: 'combo-1' }], }); controller.translateComboBy('combo-1', [66, 67]); expect(controller.getNodeData()).toEqual([ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', combo: 'combo-1', data: {}, style: { x: 66, y: 117, z: 0, fill: 'green', zIndex: 1 } }, { id: 'node-3', combo: 'combo-1', data: {}, style: { x: 166, y: 167, z: 0, fill: 'blue', zIndex: 1 } }, ]); }); it('translateComboBy without children', () => { const controller = new DataController(); controller.addData({ combos: [{ id: 'combo-1' }], }); controller.translateComboBy('combo-1', [100, 100]); expect(controller.getData()).toEqual({ nodes: [], edges: [], combos: [{ id: 'combo-1', data: {}, style: { x: 100, y: 100, z: 0, zIndex: 0 } }], }); }); it('translateComboTo', () => { const controller = new DataController(); controller.addData({ nodes: [ { id: 'node-1', combo: 'combo-1' }, { id: 'node-2', combo: 'combo-1', style: { x: 10, y: 10 } }, ], combos: [{ id: 'combo-1' }], }); controller.translateComboTo('combo-1', [100, 100]); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: {}, combo: 'combo-1', style: { x: 100, y: 100, z: 0, zIndex: 1 } }, { id: 'node-2', data: {}, combo: 'combo-1', style: { x: 110, y: 110, z: 0, zIndex: 1 } }, ], combos: [{ id: 'combo-1', data: {}, style: { x: 100, y: 100, z: 0, zIndex: 0 } }], edges: [], }); }); it('removeData', () => { const controller = new DataController(); controller.addData(clone(data)); controller.removeData({ nodes: ['node-1'], edges: ['edge-1'], // combos: ['combo-1'], }); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue', zIndex: 1 } }, ], edges: [{ id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }], combos: [{ id: 'combo-1', data: {}, style: { zIndex: 0 } }], }); controller.removeComboData(['combo-1']); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-2', data: { value: 2 }, combo: undefined, style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: undefined, style: { fill: 'blue', zIndex: 1 } }, ], edges: [{ id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }], combos: [], }); controller.removeEdgeData([]); controller.removeEdgeData(['edge-2']); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-2', data: { value: 2 }, combo: undefined, style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: undefined, style: { fill: 'blue', zIndex: 1 } }, ], edges: [], combos: [], }); controller.removeNodeData(); controller.removeNodeData(['node-2']); expect(controller.getData()).toEqual({ nodes: [{ id: 'node-3', data: { value: 3 }, combo: undefined, style: { fill: 'blue', zIndex: 1 } }], edges: [], combos: [], }); }); it('removeComboData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getParentData('node-2', 'combo')?.id).toEqual(data.combos[0].id); controller.removeComboData(['combo-1']); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: { value: 1 }, style: { fill: 'red', zIndex: 0 } }, { id: 'node-2', data: { value: 2 }, combo: undefined, style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: undefined, style: { fill: 'blue', zIndex: 1 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }, ], combos: [], }); expect(controller.getParentData('node-2', 'combo')).toEqual(undefined); }); it('removeComboData with children', () => { const data = { nodes: [ { id: 'node-1', data: {}, combo: 'combo-1' }, { id: 'node-2', data: {}, combo: 'combo-1' }, { id: 'node-3', data: {}, combo: 'combo-1' }, ], combos: [ { id: 'combo-1', data: {}, combo: 'combo-2' }, { id: 'combo-2', data: {}, style: {} }, ], }; const controller = new DataController(); controller.addData(data); expect(controller.getComboData(['combo-1'])[0].style?.zIndex).toEqual(1); expect(controller.getComboData(['combo-2'])[0].style?.zIndex).toEqual(0); expect(controller.getParentData('node-1', 'combo')?.id).toEqual('combo-1'); expect(controller.getParentData('combo-1', 'combo')?.id).toEqual('combo-2'); // combo-1 删除后,node-1、node-2、node-3 会被移动到 combo-2 // after combo-1 is removed, node-1, node-2, node-3 will be moved to combo-2 // 迁移后 combo-2 的 zIndex 不会被更新 // after migration, the zIndex of combo-2 will not be updated controller.removeComboData(['combo-1']); expect(controller.getComboData(['combo-2'])[0].style?.zIndex).toEqual(0); expect(controller.getParentData('combo-2', 'combo')).toEqual(undefined); expect(controller.getParentData('node-1', 'combo')?.id).toEqual('combo-2'); expect(controller.getData()).toEqual({ nodes: [ { id: 'node-1', data: {}, style: { zIndex: 2 }, combo: 'combo-2' }, { id: 'node-2', data: {}, style: { zIndex: 2 }, combo: 'combo-2' }, { id: 'node-3', data: {}, style: { zIndex: 2 }, combo: 'combo-2' }, ], edges: [], combos: [{ id: 'combo-2', data: {}, style: { zIndex: 0 } }], }); }); it('changes', () => { const controller = new DataController(); controller.addData(clone(data)); const changes1 = controller.getChanges(); expect(changes1).toEqual([ { value: { id: 'combo-1', data: {}, style: {} }, type: 'ComboAdded' }, { value: { id: 'node-1', data: { value: 1 }, style: { fill: 'red' } }, type: 'NodeAdded' }, { value: { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green' } }, type: 'NodeAdded' }, { value: { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue' } }, type: 'NodeAdded' }, // 新增子元素后更新 combo / update combo after add child { value: { id: 'combo-1', data: {}, style: {} }, original: { id: 'combo-1', data: {}, style: {} }, type: 'ComboUpdated', }, { value: { id: 'combo-1', data: {}, style: {} }, original: { id: 'combo-1', data: {}, style: {} }, type: 'ComboUpdated', }, { value: { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: {} }, type: 'EdgeAdded', }, { value: { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: {} }, type: 'EdgeAdded', }, // 更新 zIndex / update zIndex { value: { id: 'combo-1', data: {}, style: { zIndex: 0 } }, original: { id: 'combo-1', data: {}, style: {} }, type: 'ComboUpdated', }, { value: { id: 'node-1', data: { value: 1 }, style: { fill: 'red', zIndex: 0 } }, original: { id: 'node-1', data: { value: 1 }, style: { fill: 'red' } }, type: 'NodeUpdated', }, { value: { id: 'node-2', combo: 'combo-1', data: { value: 2 }, style: { fill: 'green', zIndex: 1 } }, original: { id: 'node-2', combo: 'combo-1', data: { value: 2 }, style: { fill: 'green' } }, type: 'NodeUpdated', }, { value: { id: 'node-3', combo: 'combo-1', data: { value: 3 }, style: { fill: 'blue', zIndex: 1 } }, original: { id: 'node-3', combo: 'combo-1', data: { value: 3 }, style: { fill: 'blue' } }, type: 'NodeUpdated', }, { value: { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, original: { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: {} }, type: 'EdgeUpdated', }, { value: { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }, original: { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: {} }, type: 'EdgeUpdated', }, ]); controller.clearChanges(); controller.setData({ nodes: [ { id: 'node-3', data: { value: 3 }, combo: 'combo-2', style: { fill: 'pink' } }, { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow' } }, ], combos: [{ id: 'combo-2' }], }); const changes2 = controller.getChanges(); expect(changes2).toEqual([ { value: { id: 'combo-2' }, type: 'ComboAdded' }, { value: { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow' } }, type: 'NodeAdded' }, { value: { id: 'combo-2', data: {}, style: { zIndex: 0 } }, original: { id: 'combo-2', data: {}, style: {} }, type: 'ComboUpdated', }, { value: { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } }, original: { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow' } }, type: 'NodeUpdated', }, { value: { id: 'node-3', combo: 'combo-2', data: { value: 3 }, style: { fill: 'pink', zIndex: 1 } }, original: { id: 'node-3', combo: 'combo-1', data: { value: 3 }, style: { fill: 'blue', zIndex: 1 } }, type: 'NodeUpdated', }, // 移动节点后更新 combo / update combo after move node { value: { id: 'combo-2', data: {}, style: { zIndex: 0 } }, original: { id: 'combo-2', data: {}, style: { zIndex: 0 } }, type: 'ComboUpdated', }, { value: { id: 'node-3', combo: 'combo-2', data: { value: 3 }, style: { fill: 'pink', zIndex: 1 } }, original: { id: 'node-3', combo: 'combo-2', data: { value: 3 }, style: { fill: 'pink', zIndex: 1 } }, type: 'NodeUpdated', }, { value: { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, type: 'EdgeRemoved', }, { value: { id: 'edge-2', source: 'node-2', target: 'node-3', data: { weight: 2 }, style: { zIndex: 0 } }, type: 'EdgeRemoved', }, { value: { id: 'node-1', data: { value: 1 }, style: { fill: 'red', zIndex: 0 } }, type: 'NodeRemoved' }, { value: { id: 'node-2', combo: 'combo-1', data: { value: 2 }, style: { fill: 'green', zIndex: 1 } }, type: 'NodeRemoved', }, // 移除节点后更新 combo / update combo after remove node { value: { id: 'combo-1', data: {}, style: { zIndex: 0 } }, original: { id: 'combo-1', data: {}, style: { zIndex: 0 } }, type: 'ComboUpdated', }, { value: { id: 'combo-1', data: {}, style: { zIndex: 0 } }, type: 'ComboRemoved' }, ]); expect(reduceDataChanges([...changes1, ...changes2])).toEqual([ { type: 'NodeAdded', value: { id: 'node-3', combo: 'combo-2', data: { value: 3 }, style: { fill: 'pink', zIndex: 1 } }, }, { type: 'ComboAdded', value: { id: 'combo-2', data: {}, style: { zIndex: 0 } } }, { type: 'NodeAdded', value: { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } } }, ]); }); it('changes add', () => { const controller = new DataController(); controller.addData({ nodes: [ { id: 'node-3', data: { value: 3 }, combo: 'combo-2', style: { fill: 'pink' } }, { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow' } }, ], combos: [{ id: 'combo-2' }], }); const changes = controller.getChanges(); expect(reduceDataChanges(changes)).toEqual([ { value: { id: 'combo-2', data: {}, style: { zIndex: 0 } }, type: 'ComboAdded' }, { type: 'NodeAdded', value: { id: 'node-3', data: { value: 3 }, combo: 'combo-2', style: { fill: 'pink', zIndex: 1 } }, }, { value: { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow', zIndex: 0 } }, type: 'NodeAdded' }, ]); }); it('silence', () => { const controller = new DataController(); controller.silence(() => { controller.addData(clone(data)); controller.setData({ nodes: [ { id: 'node-3', data: { value: 3 }, combo: 'combo-2', style: { fill: 'pink' } }, { id: 'node-4', data: { value: 4 }, style: { fill: 'yellow' } }, ], combos: [{ id: 'combo-2' }], }); }); const changes = controller.getChanges(); expect(changes.length).toBe(0); }); it('getElementData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getElementDataById('node-1').id).toEqual(data.nodes[0].id); expect(controller.getElementDataById('edge-1').id).toEqual(data.edges[0].id); expect(controller.getElementDataById('combo-1').id).toEqual(data.combos[0].id); expect(() => { controller.getElementDataById('undefined'); }).toThrow(); }); it('getNodeLikeData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getNodeLikeData(['node-1'])[0].id).toEqual(data.nodes[0].id); expect(controller.getNodeLikeData(['edge-1'])[0]).toEqual(undefined); expect(controller.getNodeLikeData(['combo-1'])[0].id).toEqual(data.combos[0].id); expect(controller.getNodeLikeData().map(idOf)).toEqual([...data.combos, ...data.nodes].map(idOf)); }); it('getRootsData', () => { const controller = new DataController(); controller.addData(treeToGraphData(tree)); expect(controller.getRootsData('tree').map(idOf)).toEqual(['Modeling Methods']); }); it('getAncestorsData getParentData getChildrenData', () => { const controller = new DataController(); controller.addData(treeToGraphData(tree)); expect(controller.getAncestorsData('Logistic regression', 'tree').map(idOf)).toEqual([ 'Classification', 'Modeling Methods', ]); expect(controller.getParentData('Classification', 'tree')?.id).toBe(tree.id); expect(controller.getChildrenData('Modeling Methods').map((child) => child.id)).toEqual( tree.children.map((child) => child.id), ); }); it('hasNode', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.hasNode('node-1')).toBe(true); expect(controller.hasNode('node-4')).toBe(false); }); it('hasEdge', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.hasEdge('edge-1')).toBe(true); expect(controller.hasEdge('edge-4')).toBe(false); }); it('hasCombo', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.hasCombo('combo-1')).toBe(true); expect(controller.hasCombo('combo-4')).toBe(false); }); it('getComboChildrenData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getChildrenData('combo-1').map(idOf)).toEqual(data.nodes.slice(1).map(idOf)); controller.updateNodeData([{ id: 'node-1', combo: 'combo-1' }]); expect(controller.getChildrenData('combo-1')?.sort((a, b) => (a.id < b.id ? -1 : 1))).toEqual([ { id: 'node-1', data: { value: 1 }, combo: 'combo-1', style: { fill: 'red', zIndex: 1 } }, { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue', zIndex: 1 } }, ]); controller.addComboData([{ id: 'combo-2' }]); controller.updateNodeData([ { id: 'node-2', combo: 'combo-2' }, { id: 'node-3', combo: 'combo-2' }, ]); expect(controller.getChildrenData('combo-1')).toEqual([ { id: 'node-1', data: { value: 1 }, combo: 'combo-1', style: { fill: 'red', zIndex: 1 } }, ]); expect(controller.getChildrenData('combo-2')).toEqual([ { id: 'node-2', data: { value: 2 }, combo: 'combo-2', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-2', style: { fill: 'blue', zIndex: 1 } }, ]); }); it('getParentComboData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getParentData('node-1', 'combo')).toEqual(undefined); controller.updateNodeData([{ id: 'node-1', combo: 'combo-1' }]); expect(controller.getParentData('node-1', 'combo')?.id).toEqual(data.combos[0].id); expect(controller.getParentData('combo-3', 'combo')).toEqual(undefined); }); it('getRelatedEdgesData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getRelatedEdgesData('node-1')).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, ]); controller.addEdgeData([{ id: 'edge-3', source: 'node-1', target: 'node-3', data: { weight: 3 } }]); expect(controller.getRelatedEdgesData('node-1')).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, { id: 'edge-3', source: 'node-1', target: 'node-3', data: { weight: 3 }, style: { zIndex: 0 } }, ]); expect(controller.getRelatedEdgesData('node-1', 'in')).toEqual([]); expect(controller.getRelatedEdgesData('node-1', 'out')).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 1 }, style: { zIndex: 0 } }, { id: 'edge-3', source: 'node-1', target: 'node-3', data: { weight: 3 }, style: { zIndex: 0 } }, ]); }); it('getNeighborNodesData', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getNeighborNodesData('node-1')).toEqual([ { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green', zIndex: 1 } }, ]); controller.addEdgeData([{ id: 'edge-3', source: 'node-1', target: 'node-3', data: { weight: 3 } }]); expect(controller.getNeighborNodesData('node-1')).toEqual([ { id: 'node-2', data: { value: 2 }, combo: 'combo-1', style: { fill: 'green', zIndex: 1 } }, { id: 'node-3', data: { value: 3 }, combo: 'combo-1', style: { fill: 'blue', zIndex: 1 } }, ]); }); it('getElementType', () => { const controller = new DataController(); controller.addData(clone(data)); expect(controller.getElementType('node-1')).toEqual('node'); expect(controller.getElementType('edge-1')).toEqual('edge'); expect(controller.getElementType('combo-1')).toEqual('combo'); expect(() => { controller.getElementType('undefined'); }).toThrow(); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/element/event.spec.ts ================================================ import { IPointerEvent, NodeEvent } from '@/src'; import { createGraph } from '@@/utils'; import type { DisplayObject } from '@antv/g'; import { CustomEvent } from '@antv/g'; describe('element event', () => { it('element event target', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); const click = jest.fn(); graph.on(NodeEvent.CLICK, click); await graph.draw(); // @ts-expect-error context is private const node1 = graph.context.element!.getElement('node-1')!; (node1.children[0] as DisplayObject).dispatchEvent(new CustomEvent('click', {})); expect(click).toHaveBeenCalledTimes(1); expect(click.mock.calls[0][0].target).toBe(node1); expect(click.mock.calls[0][0].targetType).toBe('node'); expect(click.mock.calls[0][0].originalTarget).toBe(node1.children[0]); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/element/state.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('element states', () => { it('element state', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); await graph.draw(); expect(graph.getElementState('node-1')).toEqual([]); graph.setElementState('node-1', 'selected'); expect(graph.getElementState('node-1')).toEqual(['selected']); graph.setElementState('node-1', ['selected', 'hovered']); expect(graph.getElementState('node-1')).toEqual(['selected', 'hovered']); graph.setElementState('node-1', ''); expect(graph.getElementState('node-1')).toEqual([]); graph.setElementState('node-1', 'selected'); graph.setElementState('node-1', []); expect(graph.getElementState('node-1')).toEqual([]); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/element/visibility.spec.ts ================================================ import type { Graph } from '@/src'; import { createGraph } from '@@/utils'; describe('element visibility', () => { let graph: Graph; beforeAll(async () => { graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 50, y: 50 } }, { id: 'node-2', style: { x: 200, y: 50 } }, { id: 'node-3', style: { x: 125, y: 150, opacity: 0.5 } }, ], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-3', style: { opacity: 0.5 } }, { source: 'node-3', target: 'node-1' }, ], }, theme: 'light', node: { style: { size: 20, }, }, }); await graph.render(); }); afterAll(() => { graph.destroy(); }); it('hide', async () => { graph.hideElement(['node-3', 'node-2-node-3', 'node-3-node-1']); expect(graph.getElementVisibility('node-3')).toBe('hidden'); expect(graph.getElementVisibility('node-2-node-3')).toBe('hidden'); expect(graph.getElementVisibility('node-3-node-1')).toBe('hidden'); await expect(graph).toMatchSnapshot(__filename, 'hidden'); }); it('show', async () => { graph.showElement(['node-3', 'node-2-node-3', 'node-3-node-1']); expect(graph.getElementVisibility('node-3')).toBe('visible'); expect(graph.getElementVisibility('node-2-node-3')).toBe('visible'); expect(graph.getElementVisibility('node-3-node-1')).toBe('visible'); await expect(graph).toMatchSnapshot(__filename, 'visible'); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/element/z-index.spec.ts ================================================ import type { Graph } from '@/src'; import { createGraph } from '@@/utils'; describe('element z-index', () => { let graph: Graph; beforeAll(async () => { graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 150, y: 150, fill: 'red' } }, { id: 'node-2', style: { x: 175, y: 175, fill: 'green' } }, { id: 'node-3', style: { x: 200, y: 200, fill: 'blue' } }, ], }, theme: 'light', node: { style: { size: 50, }, }, }); await graph.render(); }); afterAll(() => { graph.destroy(); }); it('front', async () => { graph.frontElement('node-2'); await expect(graph).toMatchSnapshot(__filename, 'front'); }); it('to', async () => { graph.setElementZIndex({ 'node-2': -1 }); await expect(graph).toMatchSnapshot(__filename); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/element.spec.ts ================================================ import { Graph } from '@/src'; import * as BUILT_IN_PALETTES from '@/src/palettes'; import { light as LIGHT_THEME } from '@/src/themes'; import { idOf } from '@/src/utils/id'; import { createGraph } from '@@/utils'; import { omit } from '@antv/util'; describe('ElementController', () => { let graph: Graph; beforeAll(async () => { graph = createGraph({ data: { nodes: [ { id: 'node-1', style: { x: 100, y: 100, fill: 'red', stroke: 'pink', lineWidth: 1 }, data: { value: 100 } }, { id: 'node-2', style: { x: 150, y: 100 }, data: { value: 150 } }, { id: 'node-3', combo: 'combo-1', states: ['selected'], style: { x: 125, y: 150 }, data: { value: 150 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { weight: 250 } }, { id: 'edge-2', source: 'node-2', target: 'node-3', states: ['active', 'selected'], style: { lineWidth: 5 }, data: { weight: 300 }, }, ], combos: [{ id: 'combo-1' }], }, node: { style: { fill: (datum) => ((datum?.data?.value as number) > 100 ? 'red' : 'blue'), }, state: { selected: { fill: (datum) => ((datum?.data?.value as number) > 100 ? 'purple' : 'cyan'), }, }, palette: 'spectral', }, edge: { style: {}, state: { selected: { stroke: 'red', }, active: { stroke: 'pink', lineWidth: 4, }, }, palette: { type: 'group', color: 'oranges', field: (d) => idOf(d).toString(), invert: true }, }, combo: { style: {}, state: {}, palette: 'blues', }, }); await graph.render(); }); afterAll(() => { graph.destroy(); }); it('static', async () => { await expect(graph).toMatchSnapshot(__filename); // @ts-expect-error context is private. const elementController = graph.context.element!; const options = graph.getOptions(); const node1 = options.data!.nodes![0]; const node2 = options.data!.nodes![1]; const node3 = options.data!.nodes![2]; const edge1 = options.data!.edges![0]; const edge2 = options.data!.edges![1]; const combo1 = options.data!.combos![0]; const edge1Id = idOf(edge1); const edge2Id = idOf(edge2); // ref light theme expect(elementController.getThemeStyle('node')).toEqual(LIGHT_THEME.node!.style); expect(elementController.getThemeStateStyle('node', [])).toEqual({}); expect(elementController.getThemeStateStyle('node', ['selected'])).toEqual({ ...LIGHT_THEME.node!.state!.selected, }); expect(elementController.getThemeStateStyle('node', ['selected', 'active'])).toEqual({ ...LIGHT_THEME.node!.state!.selected, ...LIGHT_THEME.node!.state!.active, }); expect(elementController.getDefaultStyle('node-1')).toEqual({ fill: 'blue' }); expect(elementController.getDefaultStyle('node-2')).toEqual({ fill: 'red' }); expect(elementController.getDefaultStyle('node-3')).toEqual({ fill: 'red' }); expect(elementController.getDefaultStyle(edge1Id)).toEqual({}); expect(elementController.getDefaultStyle('combo-1')).toEqual({}); expect(elementController.getStateStyle('node-1')).toEqual({}); expect(elementController.getStateStyle('node-2')).toEqual({}); expect(elementController.getStateStyle('node-3')).toEqual({ fill: 'purple' }); expect(elementController.getStateStyle(idOf(options.data!.edges![1]))).toEqual({ stroke: 'red', lineWidth: 4, }); expect(elementController.getStateStyle('combo-1')).toEqual({}); expect(elementController.getElementComputedStyle('node', node1)).toEqual({ ...LIGHT_THEME.node?.style, fill: 'blue', stroke: 'pink', lineWidth: 1, // from palette x: 100, y: 100, }); expect(elementController.getElementComputedStyle('node', node2)).toEqual({ ...LIGHT_THEME.node?.style, fill: 'red', // from palette x: 150, y: 100, }); expect(elementController.getElementComputedStyle('node', node3)).toEqual({ ...LIGHT_THEME.node?.style, ...LIGHT_THEME.node?.state?.selected, // from state fill: 'purple', // from palette x: 125, y: 150, }); expect(omit(elementController.getElementComputedStyle('edge', edge1), ['sourceNode', 'targetNode'])).toEqual({ ...LIGHT_THEME.edge?.style, stroke: BUILT_IN_PALETTES.oranges.at(-1), }); expect(omit(elementController.getElementComputedStyle('edge', edge2), ['sourceNode', 'targetNode'])).toEqual({ ...LIGHT_THEME.edge?.style, ...LIGHT_THEME.edge?.state?.active, ...LIGHT_THEME.edge?.state?.selected, lineWidth: 4, stroke: 'red', }); const comboStyle = elementController.getElementComputedStyle('combo', combo1); expect(comboStyle.childrenNode[0]).toEqual('node-3'); expect(omit(comboStyle, ['childrenNode', 'childrenData'])).toEqual({ ...LIGHT_THEME.combo?.style, fill: BUILT_IN_PALETTES.blues[0], }); }); it('runtime', async () => { graph.setData({ nodes: [{ id: 'node-1' }, { id: 'node-2', combo: 'combo-1' }, { id: 'node-3', combo: 'combo-1' }], edges: [ { source: 'node-1', target: 'node-2' }, { source: 'node-2', target: 'node-3' }, ], combos: [{ id: 'combo-1' }], }); await graph.render(); // @ts-expect-error context is private. const elementController = graph.context.element!; expect(elementController.getNodes().length).toBe(3); expect(elementController.getEdges().length).toBe(2); expect(elementController.getCombos().length).toBe(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/graph/add-children-data.spec.ts ================================================ import { layoutCompactBoxBasic } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('add children data', () => { it('default', async () => { const graph = await createDemoGraph(layoutCompactBoxBasic, { animation: false }); await expect(graph).toMatchSnapshot(__filename); graph.addChildrenData('Rules', [{ id: 'node-1' }, { id: 'node-2' }]); await graph.render(); await expect(graph).toMatchSnapshot(__filename, 'add-children-data'); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/graph/auto-resize.spec.ts ================================================ import { Graph } from '@/src'; import { createGraphCanvas, sleep } from '@@/utils'; describe('Graph autoResize', () => { it('autoResize trigger by window.resize', async () => { const $container = document.createElement('div'); document.body.appendChild($container); const canvas = createGraphCanvas($container, 500, 500); const graph = new Graph({ container: canvas, width: 500, height: 500, autoResize: true, data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], }, theme: 'light', node: {}, layout: { type: 'grid', }, }); await graph.render(); expect(graph.getSize()).toEqual([500, 500]); $container.style.display = 'block'; $container.style.width = '400px'; $container.style.height = '400px'; window.dispatchEvent(new Event('resize')); // auto resize debounce is 300ms. await sleep(500); expect(graph.getSize()).toEqual([400, 400]); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/graph/event.spec.ts ================================================ import { GraphEvent } from '@/src'; import { createGraph } from '@@/utils'; import { Renderer as CanvasRenderer } from '@antv/g-canvas'; describe('event', () => { it('canvas ready', async () => { const graph = createGraph({ container: document.createElement('div'), }); const ready = jest.fn(); graph.on(GraphEvent.BEFORE_CANVAS_INIT, ready); graph.on(GraphEvent.AFTER_CANVAS_INIT, ready); await graph.draw(); expect(ready).toHaveBeenCalledTimes(2); graph.destroy(); }); it('graph lifecycle event', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, layout: { type: 'grid', }, }); const sequence: string[] = []; const addSequence = (type: string) => () => { sequence.push(type); }; const beforeDraw = jest.fn(addSequence('beforeDraw')); const afterDraw = jest.fn(addSequence('afterDraw')); const beforeRender = jest.fn(addSequence('beforeRender')); const afterRender = jest.fn(addSequence('afterRender')); const beforeLayout = jest.fn(addSequence('beforeLayout')); const afterLayout = jest.fn(addSequence('afterLayout')); graph.on(GraphEvent.BEFORE_DRAW, beforeDraw); graph.on(GraphEvent.AFTER_DRAW, afterDraw); graph.on(GraphEvent.BEFORE_RENDER, beforeRender); graph.on(GraphEvent.AFTER_RENDER, afterRender); graph.on(GraphEvent.BEFORE_LAYOUT, beforeLayout); graph.on(GraphEvent.AFTER_LAYOUT, afterLayout); await graph.render(); expect(beforeDraw).toHaveBeenCalledTimes(1); expect(afterDraw).toHaveBeenCalledTimes(1); expect(beforeRender).toHaveBeenCalledTimes(1); expect(afterRender).toHaveBeenCalledTimes(1); expect(beforeLayout).toHaveBeenCalledTimes(1); expect(afterLayout).toHaveBeenCalledTimes(1); expect(sequence).toEqual(['beforeRender', 'beforeLayout', 'afterLayout', 'beforeDraw', 'afterDraw', 'afterRender']); graph.destroy(); }); it('element lifecycle event', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); const create = jest.fn(); const update = jest.fn(); const destroy = jest.fn(); graph.on(GraphEvent.AFTER_ELEMENT_CREATE, create); graph.on(GraphEvent.AFTER_ELEMENT_UPDATE, update); graph.on(GraphEvent.AFTER_ELEMENT_DESTROY, destroy); await graph.draw(); expect(create).toHaveBeenCalledTimes(3); expect(update).toHaveBeenCalledTimes(0); expect(destroy).toHaveBeenCalledTimes(0); expect(create.mock.calls[0][0].elementType).toEqual('node'); expect(create.mock.calls[0][0].data.id).toEqual('node-1'); expect(create.mock.calls[1][0].elementType).toEqual('node'); expect(create.mock.calls[1][0].data.id).toEqual('node-2'); expect(create.mock.calls[2][0].elementType).toEqual('edge'); expect(create.mock.calls[2][0].data.id).toEqual('edge-1'); create.mockClear(); graph.addNodeData([{ id: 'node-3' }]); graph.updateData({ nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-3' }], }); graph.removeNodeData(['node-2']); await graph.draw(); expect(create).toHaveBeenCalledTimes(1); expect(update).toHaveBeenCalledTimes(2); expect(destroy).toHaveBeenCalledTimes(1); expect(create.mock.calls[0][0].data.id).toEqual('node-3'); expect(update.mock.calls[0][0].data.id).toEqual('node-1'); expect(update.mock.calls[1][0].data.id).toEqual('edge-1'); expect(destroy.mock.calls[0][0].data.id).toEqual('node-2'); graph.destroy(); }); it('element state', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); await graph.draw(); const create = jest.fn(); const update = jest.fn(); const destroy = jest.fn(); graph.on(GraphEvent.AFTER_ELEMENT_CREATE, create); graph.on(GraphEvent.AFTER_ELEMENT_UPDATE, update); graph.on(GraphEvent.AFTER_ELEMENT_DESTROY, destroy); // state change graph.updateNodeData([{ id: 'node-1', states: ['active'] }]); await graph.draw(); expect(update).toHaveBeenCalledTimes(2); expect(update.mock.calls[0][0].data.id).toEqual('node-1'); expect(update.mock.calls[0][0].data.states).toEqual(['active']); // 同时会更新相邻的边 / It will also update the adjacent edge expect(update.mock.calls[1][0].data.id).toEqual('edge-1'); update.mockClear(); graph.setElementState('node-1', []); expect(update).toHaveBeenCalledTimes(2); expect(update.mock.calls[0][0].data.id).toEqual('node-1'); expect(update.mock.calls[0][0].data.states).toEqual([]); expect(update.mock.calls[1][0].data.id).toEqual('edge-1'); graph.destroy(); }); it('renderer change event', async () => { const graph = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); const beforeRendererChange = jest.fn(); const afterRendererChange = jest.fn(); graph.on(GraphEvent.BEFORE_RENDERER_CHANGE, beforeRendererChange); graph.on(GraphEvent.AFTER_RENDERER_CHANGE, afterRendererChange); await graph.render(); expect(beforeRendererChange).toHaveBeenCalledTimes(0); expect(afterRendererChange).toHaveBeenCalledTimes(0); const renderer = () => new CanvasRenderer(); graph.setOptions({ renderer, }); 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); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/graph/get-plugin-instantce.spec.ts ================================================ import { BasePlugin, ExtensionCategory, register } from '@/src'; import { createGraph } from '@@/utils'; describe('getPluginInstance', () => { it('getPluginInstance', async () => { const fn = jest.fn(); class CustomPlugin extends BasePlugin { // plugin api api() { fn(); } } register(ExtensionCategory.PLUGIN, 'custom', CustomPlugin); const graph = createGraph({ plugins: [ { key: 'custom-plugin', type: 'custom', }, ], }); await graph.draw(); const plugin = graph.getPluginInstance('custom-plugin'); expect(plugin instanceof CustomPlugin).toBe(true); plugin.api(); expect(fn).toHaveBeenCalled(); const undefinedPlugin = graph.getPluginInstance('undefined-plugin'); expect(undefinedPlugin).toBe(undefined); }); it('getPluginInstance by type', async () => { const fn = jest.fn(); class CustomPlugin extends BasePlugin { api() { fn(); } } register(ExtensionCategory.PLUGIN, 'custom-2', CustomPlugin); const graph = createGraph({ plugins: ['custom-2', 'custom-2'], }); await graph.draw(); const plugin = graph.getPluginInstance('custom-2'); expect(plugin instanceof CustomPlugin).toBe(true); plugin.api(); expect(fn).toHaveBeenCalled(); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/graph/graph.spec.ts ================================================ import { Graph, idOf } from '@/src'; import data from '@@/dataset/cluster.json'; import { commonGraph } from '@@/demos/common-graph'; import { createDemoGraph, createGraph } from '@@/utils'; describe('Graph', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(commonGraph, { animation: false }); }); it('getOptions/setOptions', () => { graph.setOptions({ zoomRange: [-10, 10] }); expect(graph.getOptions().zoomRange).toEqual([-10, 10]); }); it('setZoomRange/getZoomRange', () => { graph.setZoomRange([-5, 5]); expect(graph.getZoomRange()).toEqual([-5, 5]); }); it('setNode/getNode', () => { const options = graph.getOptions(); graph.setNode(Object.assign({}, options.node, { state: { selected: { fill: 'pink' } } })); expect(graph.getOptions().node!.state!.selected).toEqual({ fill: 'pink', }); }); it('setEdge/getEdge', () => { const options = graph.getOptions(); graph.setEdge(Object.assign({}, options.edge, { state: { selected: { stroke: 'pink' } } })); expect(graph.getOptions().edge!.state!.selected).toEqual({ stroke: 'pink', }); }); it('setCombo/getCombo', () => { const options = graph.getOptions(); graph.setCombo(Object.assign({}, options.combo, { state: { selected: { fill: 'pink' } } })); expect(graph.getOptions().combo!.state!.selected).toEqual({ fill: 'pink', }); }); it('hasNode', () => { expect(graph.hasNode('0')).toBe(true); expect(graph.hasNode('1')).toBe(true); expect(graph.hasNode('non-existent-node')).toBe(false); expect(graph.hasNode('node-999')).toBe(false); expect(graph.hasNode('')).toBe(false); expect(graph.hasNode(null as any)).toBe(false); expect(graph.hasNode(undefined as any)).toBe(false); }); it('hasEdge', () => { expect(graph.hasEdge('0-1')).toBe(true); expect(graph.hasEdge('non-existent-edge')).toBe(false); expect(graph.hasEdge('edge-999')).toBe(false); expect(graph.hasEdge('')).toBe(false); expect(graph.hasEdge(null as any)).toBe(false); expect(graph.hasEdge(undefined as any)).toBe(false); }); it('hasCombo', () => { graph.addComboData([ { id: 'combo-test-1', style: {} }, { id: 'combo-test-2', style: {} }, ]); expect(graph.hasCombo('combo-test-1')).toBe(true); expect(graph.hasCombo('combo-test-2')).toBe(true); expect(graph.hasCombo('non-existent-combo')).toBe(false); expect(graph.hasCombo('combo-999')).toBe(false); expect(graph.hasCombo('')).toBe(false); expect(graph.hasCombo(null as any)).toBe(false); expect(graph.hasCombo(undefined as any)).toBe(false); graph.removeComboData(['combo-test-1', 'combo-test-2']); }); it('setSize/getSize', () => { expect(graph.getSize()).toEqual([500, 500]); expect(createGraph({}).getSize()).toEqual([0, 0]); const g = createGraph({ width: 100, height: 50 }); expect(g.getSize()).toEqual([100, 50]); g.setSize(400, 100); expect(g.getSize()).toEqual([400, 100]); }); it('setTheme', () => { graph.setTheme('light'); expect(graph.getTheme()).toEqual('light'); }); it('getTheme', () => { expect(graph.getTheme()).toEqual('light'); }); it('getLayout', () => { expect(graph.getLayout()).toEqual({ type: 'd3-force' }); }); it('getBehaviors/setBehaviors/updateBehavior', () => { expect(graph.getBehaviors()).toEqual(['zoom-canvas', 'drag-canvas']); graph.setBehaviors(['drag-canvas']); expect(graph.getBehaviors()).toEqual(['drag-canvas']); graph.setBehaviors([{ key: 'behavior-1', type: 'zoom-canvas', enable: false }]); expect(graph.getBehaviors()).toEqual([{ key: 'behavior-1', type: 'zoom-canvas', enable: false }]); graph.updateBehavior({ key: 'behavior-1', enable: true }); expect(graph.getBehaviors()).toEqual([{ key: 'behavior-1', type: 'zoom-canvas', enable: true }]); expect(createGraph({}).getBehaviors()).toEqual([]); }); it('getPlugins/setPlugins/updatePlugin', () => { expect(graph.getPlugins()).toEqual([]); graph.setPlugins([{ type: 'test' }]); expect(graph.getPlugins()).toEqual([{ type: 'test' }]); graph.setPlugins([{ key: 'plugin-1', type: 'test' }]); expect(graph.getPlugins()).toEqual([{ key: 'plugin-1', type: 'test' }]); graph.updatePlugin({ key: 'plugin-1', enable: false }); expect(graph.getPlugins()).toEqual([{ key: 'plugin-1', type: 'test', enable: false }]); graph.setPlugins([]); const g = createGraph({}); expect(g.getPlugins()).toEqual([]); g.setPlugins([ { type: 'test', key: 'test' }, { type: 'test2', key: 'test2' }, ]); g.updatePlugin({ key: 'test', enable: false }); expect(g.getPlugins()).toEqual([ { type: 'test', key: 'test', enable: false }, { type: 'test2', key: 'test2' }, ]); }); it('getTransforms/setTransforms/updateTransform', () => { expect(graph.getTransforms()).toEqual([]); graph.setTransforms([{ type: 'flow', key: 'flow-1' }]); expect(graph.getTransforms()).toEqual([{ type: 'flow', key: 'flow-1' }]); graph.updateTransform({ key: 'flow-1', props1: 'a' }); expect(graph.getTransforms()).toEqual([{ type: 'flow', key: 'flow-1', props1: 'a' }]); graph.setTransforms([ { type: 'flow', key: 'flow-1' }, { type: 'flow', key: 'flow-2' }, ]); graph.updateTransform({ key: 'flow-2', props1: 'b' }); expect(graph.getTransforms()).toEqual([ { type: 'flow', key: 'flow-1' }, { type: 'flow', key: 'flow-2', props1: 'b' }, ]); graph.setTransforms([]); }); it('updateData/getData/setData', () => { // 调整之后,getData 获取的为当前 graph 最新的数据,而不是初始化时的数据 // After adjustment, the data obtained by getData is the latest data of the graph, not the data when it is initialized const currData = graph.getData(); expect(currData.nodes?.map(idOf)).toEqual(data.nodes.map(idOf)); expect(currData.edges?.map(idOf)).toEqual(data.edges.map(idOf)); graph.setData({ nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }); expect(graph.getData()).toEqual({ nodes: [ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { zIndex: 0 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: { zIndex: -1 } }], combos: [], }); graph.updateData({ edges: [{ id: 'edge-1', style: { lineWidth: 5 } }] }); expect(graph.getData()).toEqual({ nodes: [ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { zIndex: 0 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: { lineWidth: 5, zIndex: -1 } }], combos: [], }); }); it('addData/updateData/setData callback', () => { const g = createGraph({ data: { nodes: [{ id: 'node-1' }, { id: 'node-2' }], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }], }, }); g.updateData((data) => { expect(data).toEqual({ nodes: [ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { zIndex: 0 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: { zIndex: -1 } }], combos: [], }); return { nodes: [{ id: 'node-1', data: { value: 1 } }] }; }); expect(g.getNodeData('node-1')).toEqual({ id: 'node-1', data: { value: 1 }, style: { zIndex: 0 } }); g.setData((data) => { expect(data).toEqual({ nodes: [ { id: 'node-1', data: { value: 1 }, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { zIndex: 0 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: { zIndex: -1 } }], combos: [], }); return { nodes: [], edges: [] }; }); g.addData((data) => { expect(data).toEqual({ nodes: [], edges: [], combos: [], }); return { nodes: [{ id: 'node-1' }] }; }); expect(g.getNodeData('node-1')).toEqual({ id: 'node-1', data: {}, style: { zIndex: 0 } }); }); it('getElementData', () => { expect(graph.getElementData('node-1').id).toEqual('node-1'); expect(graph.getElementData(['node-1']).map(idOf)).toEqual(['node-1']); }); it('getXxxData/addXxxData/updateXxxData/removeXxxData', () => { expect(graph.getNodeData('node-1').id).toEqual('node-1'); expect(graph.getNodeData(['node-1']).map(idOf)).toEqual(['node-1']); expect(graph.getNodeData().map(idOf)).toEqual(['node-1', 'node-2']); expect(graph.getEdgeData('edge-1').id).toEqual('edge-1'); expect(graph.getEdgeData(['edge-1']).map(idOf)).toEqual(['edge-1']); expect(graph.getEdgeData().map(idOf)).toEqual(['edge-1']); expect(graph.getComboData()).toEqual([]); graph.addComboData([{ id: 'combo-2', style: {} }]); graph.addComboData([{ id: 'combo-1', combo: 'combo-2', style: {} }]); graph.addNodeData([ { id: 'node-3', combo: 'combo-1' }, { id: 'node-4', combo: 'combo-1' }, ]); graph.addEdgeData([{ id: 'edge-2', source: 'node-3', target: 'node-4' }]); expect(graph.getNodeData().map(idOf)).toEqual(['node-1', 'node-2', 'node-3', 'node-4']); expect(graph.getEdgeData().map(idOf)).toEqual(['edge-1', 'edge-2']); expect(graph.getComboData('combo-1').id).toEqual('combo-1'); expect(graph.getComboData(['combo-1']).map(idOf)).toEqual(['combo-1']); expect(graph.getComboData()).toEqual([ { id: 'combo-2', data: {}, style: { zIndex: 0 } }, { id: 'combo-1', combo: 'combo-2', data: {}, style: { zIndex: 1 } }, ]); expect(graph.getChildrenData('combo-1').map(idOf)).toEqual(['node-3', 'node-4']); expect(graph.getDescendantsData('combo-2').map(idOf)).toEqual(['combo-1', 'node-3', 'node-4']); graph.removeComboData(['combo-2']); graph.updateNodeData([{ id: 'node-3', style: { x: 100, y: 100 } }]); graph.updateEdgeData([{ id: 'edge-2', style: { lineWidth: 10 } }]); graph.updateComboData([{ id: 'combo-1', style: { stroke: 'red' } }]); expect(graph.getNodeData()).toEqual([ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { zIndex: 0 } }, { id: 'node-3', data: {}, combo: 'combo-1', style: { x: 100, y: 100, zIndex: 2 } }, { id: 'node-4', data: {}, combo: 'combo-1', style: { zIndex: 2 } }, ]); expect(graph.getEdgeData().map(idOf)).toEqual(['edge-1', 'edge-2']); expect(graph.getComboData()).toEqual([{ id: 'combo-1', data: {}, style: { stroke: 'red', zIndex: 1 } }]); graph.removeComboData(['combo-1']); graph.removeNodeData(['node-3', 'node-4']); expect(graph.getNodeData().map(idOf)).toEqual(['node-1', 'node-2']); expect(graph.getEdgeData().map(idOf)).toEqual(['edge-1']); expect(graph.getComboData()).toEqual([]); }); it('getXxxData/addXxxData/updateXxxData/removeXxxData callback', () => { const g = createGraph({ data: { nodes: [{ id: 'node-1' }], }, }); g.addNodeData((data) => { expect(data).toEqual([{ id: 'node-1', data: {}, style: { zIndex: 0 } }]); return [{ id: 'node-2' }]; }); expect(g.getNodeData().map(idOf)).toEqual(['node-1', 'node-2']); g.updateNodeData((data) => { expect(data).toEqual([ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { zIndex: 0 } }, ]); return [{ id: 'node-2', style: { x: 100 } }]; }); expect(g.getNodeData()).toEqual([ { id: 'node-1', data: {}, style: { zIndex: 0 } }, { id: 'node-2', data: {}, style: { x: 100, zIndex: 0 } }, ]); g.addEdgeData((data) => { expect(data).toEqual([]); return [{ id: 'edge-1', source: 'node-1', target: 'node-2' }]; }); expect(g.getEdgeData().map(idOf)).toEqual(['edge-1']); g.updateEdgeData((data) => { expect(data).toEqual([{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: { zIndex: -1 } }]); return [{ id: 'edge-1', style: { lineWidth: 5, zIndex: 1 } }]; }); expect(g.getEdgeData()).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: { lineWidth: 5, zIndex: 1 } }, ]); g.addComboData((data) => { expect(data).toEqual([]); return [{ id: 'combo-1' }]; }); expect(g.getComboData().map(idOf)).toEqual(['combo-1']); g.updateComboData((data) => { expect(data).toEqual([{ id: 'combo-1', data: {}, style: { zIndex: 0 } }]); return [{ id: 'combo-1', style: { stroke: 'red' } }]; }); expect(g.getComboData()).toEqual([{ id: 'combo-1', data: {}, style: { stroke: 'red', zIndex: 0 } }]); g.removeEdgeData((data) => { expect(data.length).toBe(1); return ['edge-1']; }); expect(g.getEdgeData()).toEqual([]); g.removeNodeData((data) => { expect(data.length).toBe(2); return ['node-1']; }); expect(g.getNodeData().map(idOf)).toEqual(['node-2']); g.removeComboData((data) => { expect(data.length).toBe(1); return ['combo-1']; }); expect(g.getComboData()).toEqual([]); }); it('draw', async () => { await expect(graph).toMatchSnapshot(__filename, 'before-draw'); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'after-draw'); expect(graph.getElementRenderStyle('node-1')).toBeDefined(); }); it('getElementType', () => { expect(graph.getElementType('node-1')).toEqual('node'); expect(graph.getElementType('edge-1')).toEqual('edge'); }); it('getRelatedEdgesData', () => { expect(graph.getRelatedEdgesData('node-1').map(idOf)).toEqual(['edge-1']); expect(graph.getRelatedEdgesData('node-1', 'in')).toEqual([]); expect(graph.getRelatedEdgesData('node-1', 'out').map(idOf)).toEqual(['edge-1']); }); it('getNeighborNodesData', () => { expect(graph.getNeighborNodesData('node-1')).toEqual([{ id: 'node-2', data: {}, style: { zIndex: 0 } }]); }); it('getParentData', () => { expect(graph.getParentData('node-1', 'combo')).toBeUndefined(); }); it('getAncestors', () => { const tree = createGraph({ data: { nodes: [{ id: 'node-1', children: ['node-2'] }, { id: 'node-2', children: ['node-3'] }, { id: 'node-3' }], }, }); expect(tree.getAncestorsData('node-3', 'tree').map(idOf)).toEqual(['node-2', 'node-1']); expect(tree.getAncestorsData('node-2', 'tree').map(idOf)).toEqual(['node-1']); expect(tree.getAncestorsData('node-1', 'tree')).toEqual([]); const combo = createGraph({ data: { nodes: [ { id: 'node-1', combo: 'combo-1' }, { id: 'node-2', combo: 'combo-1' }, ], combos: [{ id: 'combo-1' }, { id: 'combo-2', combo: 'combo-1' }], }, }); expect(combo.getAncestorsData('node-1', 'combo').map(idOf)).toEqual(['combo-1']); expect(combo.getAncestorsData('node-2', 'combo').map(idOf)).toEqual(['combo-1']); expect(combo.getAncestorsData('combo-1', 'combo')).toEqual([]); expect(combo.getAncestorsData('combo-2', 'combo').map(idOf)).toEqual(['combo-1']); }); it('getElementRenderBounds', () => { const renderBounds = graph.getElementRenderBounds('node-1'); // the default size of the node is 32 expect(renderBounds.min).toEqual([-16, -16, 0]); expect(renderBounds.max).toEqual([16, 16, 0]); }); it('setElementState/getElementState/getElementDataByState', async () => { await graph.setElementState('node-2', 'selected'); expect(graph.getElementState('node-2')).toEqual(['selected']); await graph.setElementState('node-2', []); expect(graph.getElementState('node-2')).toEqual([]); await graph.setElementState({ 'node-1': 'selected' }); expect(graph.getElementState('node-1')).toEqual(['selected']); expect(graph.getElementState('node-2')).toEqual([]); expect(graph.getElementDataByState('node', 'selected')).toEqual([ { id: 'node-1', data: {}, style: { zIndex: 0 }, states: ['selected'] }, ]); }); it('setElementZIndex/getElementZIndex', async () => { await graph.setElementZIndex('node-1', 2); expect(graph.getElementZIndex('node-1')).toBe(2); await graph.setElementZIndex({ 'node-1': 0 }); expect(graph.getElementZIndex('node-1')).toBe(0); const baseNodeZIndex = 0; await graph.frontElement('node-1'); expect(graph.getElementZIndex('node-1')).toBe(baseNodeZIndex + 1); expect(graph.getElementZIndex('node-2')).toBe(baseNodeZIndex); }); it('setElementVisibility/getElementVisibility', async () => { await graph.hideElement('node-1'); expect(graph.getElementVisibility('node-1')).toBe('hidden'); await graph.showElement('node-1'); expect(graph.getElementVisibility('node-1')).toBe('visible'); await graph.setElementVisibility({ 'node-1': 'hidden' }); expect(graph.getElementVisibility('node-1')).toBe('hidden'); expect(graph.getElementVisibility('node-2')).toBe('visible'); await graph.setElementVisibility({ 'node-1': 'visible' }); expect(graph.getElementVisibility('node-1')).toBe('visible'); }); it('layout', async () => { await expect(graph).toMatchSnapshot(__filename, 'before-layout'); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'after-layout'); expect(graph.getElementPosition('node-1')).toBeDefined(); }); it('translateBy/translateTo', async () => { const [px, py] = graph.getPosition(); await graph.translateBy([100, 100]); expect(graph.getPosition()).toBeCloseTo([px + 100, py + 100]); await expect(graph).toMatchSnapshot(__filename, 'after-translate'); await graph.translateTo([0, 0]); expect(graph.getPosition()).toBeCloseTo([px, py]); }); it('zoomTo/zoomBy', async () => { const zoom = graph.getZoom(); expect(zoom).toBeCloseTo(1); await graph.zoomTo(2); expect(graph.getZoom()).toBeCloseTo(2); await expect(graph).toMatchSnapshot(__filename, 'after-zoom-2'); graph.zoomBy(0.5); expect(graph.getZoom()).toBeCloseTo(1); }); it('rotateTo/rotateBy', async () => { const rotate = graph.getRotation(); expect(rotate).toBeCloseTo(0); graph.rotateTo(90); expect(graph.getRotation()).toBeCloseTo(90); await expect(graph).toMatchSnapshot(__filename, 'after-rotate-90'); graph.rotateBy(-90); expect(graph.getRotation()).toBeCloseTo(0); }); it('translateElementTo/translateElementBy', async () => { const [px, py] = graph.getElementPosition('node-1'); graph.translateElementBy({ 'node-1': [100, 100] }, false); await expect(graph).toMatchSnapshot(__filename, 'after-translate-node-1'); expect(graph.getElementPosition('node-1')).toBeCloseTo([px + 100, py + 100, 0]); graph.translateElementTo({ 'node-1': [px, py] }, false); expect(graph.getElementPosition('node-1')).toBeCloseTo([px, py, 0]); }); it('getCanvasByViewport', () => { expect(graph.getCanvasByViewport([250, 250])).toBeCloseTo([250, 250, 0]); }); it('getViewportByCanvas', () => { expect(graph.getViewportByCanvas([250, 250])).toBeCloseTo([250, 250, 0]); }); it('getClientByCanvas', () => { expect(graph.getClientByCanvas([250, 250])).toBeCloseTo([250, 250, 0]); }); it('getCanvasByClient', () => { expect(graph.getCanvasByClient([250, 250])).toBeCloseTo([250, 250, 0]); }); it('getViewportCenter', () => { expect(graph.getViewportCenter()).toBeCloseTo([250, 250, 0]); }); it('toDataURL', async () => { expect(await graph.toDataURL()).toBeDefined(); expect(await graph.toDataURL({ mode: 'overall' })).toBeDefined(); expect((await graph.toDataURL({ type: 'image/jpeg' })).startsWith('data:image/jpeg')).toBe(true); expect((await graph.toDataURL({ type: 'image/png' })).startsWith('data:image/png')).toBe(true); }); it('resize', () => { graph.resize(600, 600); expect(graph.getSize()).toEqual([600, 600]); }); it('clear/removeData', async () => { graph.removeEdgeData(['edge-1']); expect(graph.getEdgeData()).toEqual([]); graph.removeData({ nodes: ['node-1'] }); expect(graph.getNodeData().map(idOf)).toEqual(['node-2']); await graph.clear(); expect(graph.getData()).toEqual({ nodes: [], edges: [], combos: [] }); }); it('destroy', () => { graph.destroy(); // @ts-expect-error context is private. expect(graph.context).toEqual({}); expect(graph.destroyed).toBe(true); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/graph/this.spec.ts ================================================ import { createGraph } from '@@/utils'; describe('this pointer', () => { it('element mapper', async () => { const graph = createGraph({ data: { nodes: [ { id: 'node-0', combo: 'combo-0', style: { x: 100, y: 100 }, states: ['selected'] }, { id: 'node-1', combo: 'combo-0', style: { x: 150, y: 100 } }, { id: 'node-2', style: { x: 250, y: 100 } }, ], edges: [{ source: 'node-1', target: 'node-2', states: ['activate'] }], combos: [{ id: 'combo-0', states: ['disabled'] }], }, }); await graph.render(); const test = jest.fn((instance) => { expect(instance).toBe(graph); }); const node = { type: function () { test(this); // 3 times return 'circle'; }, style: { fill: function () { test(this); // 3 times return 'red'; }, }, state: { selected: { fill: function () { test(this); // 1 time return 'yellow'; }, }, }, }; const edge = { type: function () { test(this); // 1 time return 'line'; }, style: { stroke: function () { test(this); // 1 time return 'blue'; }, }, state: { activate: { stroke: function () { test(this); // 1 time return 'orange'; }, }, }, }; const combo = { type: function () { test(this); // 1 time return 'circle'; }, style: { fill: function () { test(this); // 1 time return 'green'; }, }, state: { disabled: { fill: function () { test(this); // 1 time return 'purple'; }, }, }, }; graph.setNode(node); graph.setEdge(edge); graph.setCombo(combo); await graph.render(); graph.setNode({ style: function () { test(this); // 3 times return {}; }, state: { selected: function () { test(this); // 1 time return {}; }, }, }); graph.setEdge({ state: { activate: { fill: function () { test(this); // 1 time return 'pink'; }, }, }, }); graph.setCombo({}); await graph.render(); expect(test).toHaveBeenCalledTimes(18); }); it('behavior, plugin, transform', async () => { const test = jest.fn((instance) => { expect(instance).toBe(graph); }); const graph = createGraph({ behaviors: [ 'click-select', function () { test(this); return { type: 'drag-element', }; }, ], plugins: [ 'history', function () { test(this); return { type: 'tooltip', }; }, ], transforms: [ function () { test(this); return { type: 'parallel-edges', }; }, ], }); await graph.render(); expect(test).toHaveBeenCalledTimes(3); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/layout.spec.ts ================================================ import { layoutCircularBasic } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('layout options', () => { it('layoutEmptyOptions', async () => { const graph = await createDemoGraph(layoutCircularBasic); await expect(graph).toMatchSnapshot(__filename, 'empty'); graph.destroy(); }); it('layoutExtraOptions', async () => { const graph = await createDemoGraph(layoutCircularBasic); await graph.layout({ type: 'circular', radius: 1000, }); await expect(graph).toMatchSnapshot(__filename, 'extra'); graph.destroy(); }); it('layoutOtherTypeOptionsAndRecover', async () => { const graph = await createDemoGraph(layoutCircularBasic); await graph.layout({ type: 'concentric', }); await expect(graph).toMatchSnapshot(__filename, 'other-type'); await graph.layout(); await expect(graph).toMatchSnapshot(__filename, 'recover'); graph.destroy(); }); it('layoutArrayOptions', async () => { const graph = await createDemoGraph(layoutCircularBasic); await graph.layout( Array.from({ length: 4 }, () => ({ type: 'circular', })), ); await expect(graph).toMatchSnapshot(__filename, 'layout-array'); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/runtime/viewport.spec.ts ================================================ import { Graph } from '@/src'; import { controllerViewport } from '@@/demos/controller-viewport'; import { viewportFit } from '@@/demos/viewport-fit'; import { createDemoGraph } from '@@/utils'; import { AABB } from '@antv/g'; describe('ViewportController', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(controllerViewport); }); afterAll(() => { graph.destroy(); }); it('viewport center', () => { expect(graph.getViewportCenter()).toBeCloseTo([250, 250, 0]); }); it('canvas size', () => { // @ts-expect-error context is private. expect(graph.context.viewport.getCanvasSize()).toEqual([500, 500]); }); it('viewport zoom', async () => { expect(graph.getZoom()).toBe(1); await graph.zoomBy(0.5); expect(graph.getZoom()).toBe(0.5); await expect(graph).toMatchSnapshot(__filename, 'zoom-0.5'); await graph.zoomBy(4, { duration: 100 }); expect(graph.getZoom()).toBe(2); await expect(graph).toMatchSnapshot(__filename, 'zoom-2'); await graph.zoomTo(1); expect(graph.getZoom()).toBe(1); graph.setZoomRange([0.1, 10]); expect(graph.getZoomRange()).toEqual([0.1, 10]); }); it('viewport translate', async () => { await graph.translateBy([100, 100]); expect(graph.getPosition()).toBeCloseTo([100, 100]); await graph.translateTo([200, 200]); expect(graph.getPosition()).toBeCloseTo([200, 200]); await expect(graph).toMatchSnapshot(__filename, 'translate'); await graph.translateTo([0, 0], { duration: 100 }); }); it('viewport rotate', async () => { await graph.rotateBy(45); expect(graph.getRotation()).toBe(45); await graph.rotateBy(90); expect(graph.getRotation()).toBe(45 + 90); await expect(graph).toMatchSnapshot(__filename, 'rotate-135'); await graph.rotateTo(90, { duration: 100 }); expect(graph.getRotation()).toBe(90); await expect(graph).toMatchSnapshot(__filename, 'rotate-90'); await graph.rotateTo(0); }); it('coordinate transform', async () => { expect(graph.getPosition()).toBeCloseTo([0, 0]); expect(graph.getClientByCanvas([0, 0])).toBeCloseTo([0, 0, 0]); expect(graph.getCanvasCenter()).toBeCloseTo([250, 250, 0]); expect(graph.getViewportCenter()).toBeCloseTo([250, 250, 0]); expect(graph.getCanvasByViewport([0, 0])).toBeCloseTo([0, 0, 0]); expect(graph.getViewportByCanvas([0, 0])).toBeCloseTo([0, 0, 0]); // without animation await graph.translateTo([100, 100]); expect(graph.getPosition()).toBeCloseTo([100, 100]); expect(graph.getCanvasCenter()).toBeCloseTo([250, 250, 0]); expect(graph.getViewportCenter()).toBeCloseTo([250 - 100, 250 - 100, 0]); expect(graph.getCanvasByViewport([0, 0])).toBeCloseTo([-100, -100, 0]); expect(graph.getViewportByCanvas([0, 0])).toBeCloseTo([100, 100, 0]); }); it('getViewportSize', async () => { await graph.zoomTo(0.5); const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [100, 100, 0]); // @ts-expect-error expect(graph.context.viewport.getBBoxInViewport(bbox).halfExtents).toBeCloseTo([25, 25, 0]); await graph.zoomTo(1); // @ts-expect-error expect(graph.context.viewport.getBBoxInViewport(bbox).halfExtents).toBeCloseTo([50, 50, 0]); await graph.zoomTo(2); // @ts-expect-error expect(graph.context.viewport.getBBoxInViewport(bbox).halfExtents).toBeCloseTo([100, 100, 0]); }); it('isInViewport', async () => { await graph.translateTo([100, 100]); // @ts-expect-error expect(graph.context.viewport?.isInViewport([0, 0])).toBe(false); // @ts-expect-error expect(graph.context.viewport?.isInViewport([100, 100])).toBe(true); const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [100, 100, 0]); // @ts-expect-error expect(graph.context.viewport?.isInViewport(bbox)).toBe(true); }); }); describe('Viewport Fit without Animation', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(viewportFit); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename, 'before-fit'); }); it('focusElement', async () => { await graph.focusElement('1'); await expect(graph).toMatchSnapshot(__filename, 'focusElement'); }); it('fitCenter', async () => { await graph.fitCenter(); await expect(graph).toMatchSnapshot(__filename, 'fitCenter'); }); it('fitView', async () => { await graph.fitView(); await expect(graph).toMatchSnapshot(__filename, 'fitView'); }); it('re focusElement', async () => { await graph.focusElement('1'); await expect(graph).toMatchSnapshot(__filename, 're-focusElement'); }); it('re fitCenter', async () => { await graph.fitCenter(); await expect(graph).toMatchSnapshot(__filename, 're-fitCenter'); }); }); describe('Viewport Fit with Animation', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(viewportFit, { animation: true }); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename, 'before-fit-animation'); }); it('focusElement', async () => { await graph.focusElement('1'); await expect(graph).toMatchSnapshot(__filename, 'focusElement-animation'); }); it('fitCenter', async () => { await graph.fitCenter(); await expect(graph).toMatchSnapshot(__filename, 'fitCenter-animation'); }); it('fitView', async () => { await graph.fitView(); await expect(graph).toMatchSnapshot(__filename, 'fitView-animation'); }); it('re focusElement', async () => { await graph.focusElement('1'); await expect(graph).toMatchSnapshot(__filename, 're-focusElement-animation'); }); it('re fitCenter', async () => { await graph.fitCenter(); await expect(graph).toMatchSnapshot(__filename, 're-fitCenter-animation'); }); }); describe('Viewport Fit with AutoFit and Padding without Animation', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(viewportFit, { padding: [100, 0, 0, 100], autoFit: 'view', }); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename, 'auto-fit-with-padding'); }); }); describe('Viewport Fit with AutoFit and Padding with Animation', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(viewportFit, { padding: [100, 0, 0, 100], autoFit: 'view', animation: true, }); }); afterAll(() => { graph.destroy(); }); it('default', async () => { await expect(graph).toMatchSnapshot(__filename, 'auto-fit-with-padding-animation'); }); }); describe('Viewport Fit with lineWidth', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(viewportFit, { padding: 10 }); }); afterAll(() => { graph.destroy(); }); it('default', async () => { graph.setNode({ ...graph.getOptions().node, style: { size: 50, lineWidth: 5, stroke: 'pink', fill: (d: any) => (d.id === '1' ? '#d4414c' : '#2f363d'), }, }); await graph.draw(); await graph.fitView(); await expect(graph).toMatchSnapshot(__filename, 'with-lineWidth'); }); }); describe('Fit View with no data in graph', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(async (context) => { const graph = new Graph(context); await graph.render(); return graph; }); }); afterAll(() => { graph.destroy(); }); it('fitView with no contents in the graph is ignored', () => { // @ts-expect-error context is private. const zoomBeforeFitView = graph.context.viewport?.getZoom(); graph.fitView(); // @ts-expect-error context is private. expect(graph.context.viewport?.getZoom()).toBe(zoomBeforeFitView); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/behavior.spec.ts ================================================ import type { BehaviorOptions } from '@/src'; describe('spec behavior', () => { it('behavior', () => { const behavior: BehaviorOptions = ['drag-canvas', 'zoom-canvas', { type: 'unset' }, { type: 'any', anyProps: 1 }]; expect(behavior).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/canvas.spec.ts ================================================ import type { CanvasOptions } from '@/src'; import { Renderer } from '@antv/g-canvas'; describe('spec canvas', () => { it('canvas', () => { const canvas: CanvasOptions = { width: 100, height: 100, renderer: () => new Renderer(), autoResize: true, }; expect(canvas).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/data.spec.ts ================================================ import type { GraphData } from '@/src'; describe('spec data', () => { it('empty data', () => { const data: GraphData = { nodes: [], edges: [], }; expect(data).toBeTruthy(); }); it('data', () => { const data: GraphData = { nodes: [ { id: 'node1', data: { value: 1, }, combo: 'combo-1', style: { collapsed: true, fill: 'red', }, }, ], }; expect(data).toBeTruthy(); }); it('data with combo', () => { const data: GraphData = { nodes: [ { id: 'node1', data: { value: 1, }, combo: 'combo-1', collapsed: true, style: { fill: 'red', }, }, ], combos: [ { id: 'combo-1', data: { value: 1, }, collapsed: true, style: { fill: 'red', }, }, ], }; expect(data).toBeTruthy(); }); it('normal data', () => { const data: GraphData = { nodes: [ { id: 'node-1' }, { id: 'node-2', data: { value: 1, field: 'A' } }, { id: 'node-3', data: { value: 2 }, style: { x: 1, fill: 'red', y: 1, opacity: 0.1 } }, ], edges: [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { value: 1, field: 'A' }, style: { stroke: 'red' }, }, { id: 'edge-2', source: 'node-1', target: 'node-3' }, ], combos: [ { id: 'combo-1' }, { id: 'combo-2', data: { value: 1, field: 'A' } }, { id: 'combo-3', data: { value: 2 }, style: { x: 1, fill: 'red' } }, ], }; expect(data).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/element/combo.spec.ts ================================================ import type { ComboOptions } from '@/src'; describe('spec element combo', () => { it('combo 1', () => { const combo: ComboOptions = { style: { comboStyle: (model: any) => model.style?.comboStyle || 'white', }, state: { state1: { comboStyle: 'red', }, }, animation: { enter: 'fade', show: 'fade', }, }; expect(combo).toBeTruthy(); }); it('combo 2', () => { const combo: ComboOptions = { style: { opacity: (data) => data.style?.opacity || 1, }, state: { activate: { opacity: 1, }, }, animation: { enter: [ { fields: ['lineWidth'], shape: 'keyShape', duration: 1000, }, ], }, }; expect(combo).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/element/edge.spec.ts ================================================ import type { EdgeOptions } from '@/src'; describe('spec element edge', () => { it('edge 1', () => { const edge: EdgeOptions = { style: { edgeStyle: (model: any) => model.style?.edgeStyle || 'white', }, state: { state1: { edgeStyle: 'red', }, }, animation: { enter: 'fade', show: 'fade', }, palette: { type: 'group', color: 'my-palette', invert: true, }, }; expect(edge).toBeTruthy(); }); it('edge 2', () => { const edge: EdgeOptions = { style: { opacity: (data) => data.style?.opacity || 1, }, state: { activate: { opacity: 1, }, }, animation: { enter: [ { fields: ['lineWidth'], shape: 'keyShape', duration: 1000, }, ], }, palette: { type: 'group', color: 'my-palette', invert: true, }, }; expect(edge).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/element/node.spec.ts ================================================ import type { NodeOptions } from '@/src'; describe('spec element node', () => { it('node 1', () => { const node: NodeOptions = { animation: { enter: [ { shape: 'keyShape', fields: ['opacity'], duration: 1000, }, ], }, style: { x: 1, y: 1, }, palette: 'spectral', state: { selected: { any: 1, x: 1, }, }, }; expect(node).toBeTruthy(); }); it('node 2', () => { const node: NodeOptions = { style: { nodeStyle: (model: any) => model.style?.nodeStyle || 'white', }, state: { state1: { nodeStyle: (data: any) => data.style?.nodeStyle || 'white', }, }, animation: { enter: 'fade', show: 'fade', }, palette: 'spectral', }; expect(node).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/index.spec.ts ================================================ import type { GraphOptions } from '@/src'; import { Renderer } from '@antv/g-canvas'; describe('spec', () => { it('spec', () => { const options: GraphOptions = { width: 800, height: 600, renderer: () => new Renderer(), devicePixelRatio: 2, autoResize: true, autoFit: 'view', padding: [10, 10], zoom: 1.2, zoomRange: [0.5, 2], data: { nodes: [ { id: 'node-1', data: { value: 1, }, style: { nodeStyle: 'red', }, }, ], edges: [], combos: [], }, node: { style: { nodeStyle: 'blue', }, state: { state1: { nodeStyle: 'green', }, }, animation: { enter: 'fade', }, palette: { type: 'group', field: 'field', color: 'brBG', }, }, edge: { animation: { enter: [{ fields: ['opacity'], duration: 500, shape: 'keyShape' }], }, }, theme: 'light', behaviors: ['drag-canvas', 'my-behavior', { type: 'drag-element' }], plugins: ['my-plugin', { type: 'another-plugin', text: 'text', value: 1 }], }; expect(options).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/layout.spec.ts ================================================ import type { LayoutOptions } from '@/src'; describe('spec layout', () => { it('layout 1', () => { const layout: LayoutOptions = { type: 'force', linkDistance: 50, maxSpeed: 100, animated: true, clustering: true, nodeClusterBy: 'cluster', clusterNodeStrength: 70, }; expect(layout).toBeTruthy(); }); it('layout 2', () => { const layout: LayoutOptions = { type: 'antv-dagre', nodesep: 100, ranksep: 70, controlPoints: true, }; expect(layout).toBeTruthy(); }); it('custom layout', () => { const layout: LayoutOptions = { type: 'any', value: 1, }; expect(layout).toBeTruthy(); }); it('register', () => { const builtInLayout: LayoutOptions = { type: 'concentric', clockwise: true, height: 100, }; expect(builtInLayout).toBeTruthy(); type RegisterLayout = LayoutOptions; const registerLayout1: RegisterLayout = { type: 'layout1', param: 1, }; expect(registerLayout1).toBeTruthy(); const registerLayout2: RegisterLayout = { type: 'layout2', args: true, }; expect(registerLayout2).toBeTruthy(); const pipeLayout: LayoutOptions = [ { type: 'force', nodeFilter: (node) => (node.data as { value: number }).value > 1, }, ]; expect(pipeLayout).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/optimize.spec.ts ================================================ describe('spec optimize', () => { it('optimize 1', () => { expect(1).toBe(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/plugin.spec.ts ================================================ import type { PluginOptions } from '@/src'; describe('spec plugin', () => { it('plugin', () => { const plugin: PluginOptions = ['minimap', { type: 'unset', key: '1' }]; expect(plugin).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/theme.spec.ts ================================================ import type { Graph, ThemeOptions } from '@/src'; import { theme } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('spec theme', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(theme, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('theme', async () => { const theme: ThemeOptions = 'light'; expect(theme).toBeTruthy(); }); it('palette', async () => { graph.setOptions({ node: { palette: { type: 'group', field: 'cluster', color: 'spectral', }, }, }); graph.render(); await expect(graph).toMatchSnapshot(__filename, 'theme_node_palette_spectral'); }); }); ================================================ FILE: packages/g6/__tests__/unit/spec/viewport.spec.ts ================================================ import type { ViewportOptions } from '@/src'; describe('spec viewport', () => { it('viewport 1', () => { const viewport: ViewportOptions = { autoFit: 'view', padding: 0, zoom: 1, zoomRange: [0.5, 2], }; expect(viewport).toBeTruthy(); }); it('viewport 2', () => { const viewport: ViewportOptions = { autoFit: { type: 'center', animation: { duration: 1000, }, }, padding: [10, 10], zoom: 0.5, }; expect(viewport).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/themes/base.spec.ts ================================================ import { create } from '@/src/themes/base'; describe('base', () => { it('create', () => { expect( create({ bgColor: '#ffffff', comboColor: '#99ADD1', comboColorDisabled: '#f0f0f0', comboStroke: '#99add1', comboStrokeDisabled: '#d9d9d9', edgeColor: '#99add1', edgeColorDisabled: '#d9d9d9', edgeColorInactive: '#1B324F', nodeColor: '#1783ff', nodeColorDisabled: '#1B324F', nodeStroke: '#000000', textColor: '#000000', }), ).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/transforms/base-transform.spec.ts ================================================ import { BaseTransform } from '@/src/transforms/base-transform'; describe('BaseTransform', () => { it('beforeDraw', () => { class Transform extends BaseTransform {} const baseTransform = new Transform({} as any, {}); expect(baseTransform.beforeDraw({} as any, {})).toEqual({}); }); }); ================================================ FILE: packages/g6/__tests__/unit/transforms/transform-map-node-size.spec.ts ================================================ import type { Graph } from '@/src'; import { transformMapNodeSize } from '@@/demos'; import { createDemoGraph } from '@@/utils'; const nodeSizeMap = (graph: Graph) => Object.fromEntries(graph.getNodeData().map((node) => [node.id, node.style?.size])); describe('transform map node size', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(transformMapNodeSize, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('centrality', async () => { await expect(graph).toMatchSnapshot(__filename); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'degree' }, minSize: 10, maxSize: 40, scale: 'linear', }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [25, 25, 25], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'betweenness' }, }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [28, 28, 28], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'pagerank' }, }); await graph.draw(); expect(nodeSizeMap(graph)['node-1']).toEqual([10, 10, 10]); expect(nodeSizeMap(graph)['node-5']).toEqual([40, 40, 40]); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'eigenvector' }, }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [25, 25, 25], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'eigenvector', directed: true }, }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [20, 20, 20], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'closeness' }, minSize: 10, maxSize: 50, }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [50, 50, 50], 'node-2': [16.25, 16.25, 16.25], 'node-3': [16.25, 16.25, 16.25], 'node-4': [35, 35, 35], 'node-5': [10, 10, 10], }); }); it('multiple scale types', async () => { graph.updateTransform({ key: 'map-node-size', centrality: { type: 'degree' }, minSize: 10, maxSize: 40, scale: 'pow', }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [17.5, 17.5, 17.5], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'degree' }, minSize: 10, maxSize: 40, scale: 'sqrt', }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [10 + 30 * Math.sqrt(0.5), 10 + 30 * Math.sqrt(0.5), 10 + 30 * Math.sqrt(0.5)], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'degree' }, minSize: 10, maxSize: 40, scale: 'none', }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [10, 10, 10], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [10, 10, 10], 'node-5': [10, 10, 10], }); graph.updateTransform({ key: 'map-node-size', centrality: { type: 'degree' }, minSize: 10, maxSize: 40, scale: (value: number, domain: [number, number], range: [number, number]) => { const [d0, d1] = domain; const [r0, r1] = range; return r0 + ((value - d0) / (d1 - d0)) * (r1 - r0); }, }); await graph.draw(); expect(nodeSizeMap(graph)).toEqual({ 'node-1': [40, 40, 40], 'node-2': [10, 10, 10], 'node-3': [10, 10, 10], 'node-4': [25, 25, 25], 'node-5': [10, 10, 10], }); }); it('sync to label size', async () => { graph.updateTransform({ key: 'map-node-size', centrality: { type: 'degree' }, maxSize: 80, minSize: 20, scale: 'log', mapLabelSize: true, }); await graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'label-size'); }); }); ================================================ FILE: packages/g6/__tests__/unit/transforms/transform-position-radial-labels.spec.ts ================================================ import { transformPlaceRadialLabels } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('transform position radial labels', () => { it('render', async () => { const graph = await createDemoGraph(transformPlaceRadialLabels); await expect(graph).toMatchSnapshot(__filename); graph.destroy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/transforms/transform-process-parallel-edges.spec.ts ================================================ import type { Graph } from '@/src'; import { getParallelEdges, groupByEndpoints, isParallelEdges } from '@/src/transforms/process-parallel-edges'; import { transformProcessParallelEdges } from '@@/demos'; import { createDemoGraph } from '@@/utils'; describe('transform-process-parallel-edges', () => { let graph: Graph; beforeAll(async () => { graph = await createDemoGraph(transformProcessParallelEdges, { animation: false }); }); afterAll(() => { graph.destroy(); }); it('mode', async () => { await expect(graph).toMatchSnapshot(__filename, 'merge-mode'); graph.updateTransform({ key: 'process-parallel-edges', mode: 'bundle' }); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'bundle-mode'); await expect(graph).toMatchSnapshot(__filename, 'bundle-add-orange-edge__before'); graph.addEdgeData([ { id: 'new-edge', source: 'node1', target: 'node4', style: { stroke: '#FF9800', lineWidth: 2 }, }, { id: 'new-loop', source: 'node5', target: 'node5', style: { stroke: '#FF9800', lineWidth: 2 }, }, ]); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'bundle-add-orange-edge__after'); await expect(graph).toMatchSnapshot(__filename, 'bundle-update-orange-edge__before'); graph.updateEdgeData([{ id: 'new-edge', source: 'node1', target: 'node6' }]); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'bundle-update-orange-edge__after'); await expect(graph).toMatchSnapshot(__filename, 'bundle-remove-purple-edge__before'); graph.removeEdgeData(['edge1', 'loop1']); graph.draw(); await expect(graph).toMatchSnapshot(__filename, 'bundle-remove-purple-edge__after'); }); it('isParallelEdges', () => { expect( isParallelEdges( { source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, { source: 'node2', target: 'node1', style: { sourceNode: 'node2', targetNode: 'node1' } }, ), ).toBe(true); expect( isParallelEdges( { source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, { source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, ), ).toBe(true); expect( isParallelEdges( { source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, { source: 'node2', target: 'node3', style: { sourceNode: 'node2', targetNode: 'node3' } }, ), ).toBe(false); }); it('getParallelEdges', () => { expect( getParallelEdges({ source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, [ { source: 'node2', target: 'node1', style: { sourceNode: 'node2', targetNode: 'node1' } }, ]), ).toEqual([{ source: 'node2', target: 'node1', style: { sourceNode: 'node2', targetNode: 'node1' } }]); expect( getParallelEdges({ source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, [ { source: 'node1', target: 'node2', style: { sourceNode: 'node1', targetNode: 'node2' } }, { source: 'node2', target: 'node3', style: { sourceNode: 'node2', targetNode: 'node3' } }, ]), ).toEqual([]); }); it('groupByEndpoints', () => { expect(groupByEndpoints(new Map())).toEqual({ edgeMap: new Map(), reverses: {} }); expect(groupByEndpoints(new Map([['edge1', { source: 'node1', target: 'node2' }]])).edgeMap).toEqual( new Map([['node1-node2', [{ source: 'node1', target: 'node2' }]]]), ); const res = groupByEndpoints( new Map([ ['edge1', { source: 'node1', target: 'node2' }], ['edge2', { source: 'node2', target: 'node1' }], ]), ); expect(res.edgeMap).toEqual( new Map([ [ 'node1-node2', [ { source: 'node1', target: 'node2' }, { source: 'node2', target: 'node1' }, ], ], ]), ); expect(res.reverses).toEqual({ 'node2|node1|1': true }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/anchor.spec.ts ================================================ import { parseAnchor } from '@/src/utils/anchor'; import { getXYByAnchor } from '@/src/utils/position'; import { AABB } from '@antv/g'; describe('anchor', () => { it('parseAnchor', () => { expect(parseAnchor([0.5, 0.5])).toEqual([0.5, 0.5]); expect(parseAnchor('0.5 0.5')).toEqual([0.5, 0.5]); expect(parseAnchor('1.8 1.8')).toEqual([0.5, 0.5]); }); it('getXYByAnchor', () => { const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [100, 100, 0]); expect(getXYByAnchor(bbox, [0.5, 0.5])).toEqual([50, 50]); expect(getXYByAnchor(bbox, '0.5 0.5')).toEqual([50, 50]); expect(getXYByAnchor(bbox, [0.25, 0.25])).toEqual([25, 25]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/animation.spec.ts ================================================ import type { GraphOptions } from '@/src'; import { AnimationEffectTiming } from '@/src'; import { STDAnimation } from '@/src/animations/types'; import { DEFAULT_ANIMATION_OPTIONS, DEFAULT_ELEMENTS_ANIMATION_OPTIONS } from '@/src/constants'; import { register } from '@/src/registry/register'; import { createAnimationsProxy, getAnimationOptions, getElementAnimationOptions, inferDefaultValue, preprocessKeyframes, } from '@/src/utils/animation'; import type { IAnimation } from '@antv/g'; describe('animation', () => { it('createAnimationsProxy', () => { const sourcePause = jest.fn(); const targetPause = jest.fn(); const source = { currentTime: 0, pause: () => sourcePause(), } as IAnimation; const targets = [ { currentTime: 0, pause: () => targetPause() }, { currentTime: 0, pause: () => targetPause() }, ] as IAnimation[]; const proxy = createAnimationsProxy(source, targets); expect(proxy.currentTime).toBe(0); proxy.currentTime = 100; expect(source.currentTime).toBe(100); expect(targets[0].currentTime).toBe(100); expect(targets[1].currentTime).toBe(100); proxy.pause(); expect(sourcePause).toHaveBeenCalledTimes(1); expect(targetPause).toHaveBeenCalledTimes(2); expect(createAnimationsProxy([])).toBe(null); const proxy2 = createAnimationsProxy([source, ...targets])!; proxy2.currentTime = 200; expect(proxy2.currentTime).toBe(200); expect(source.currentTime).toBe(200); expect(targets[0].currentTime).toBe(200); expect(targets[1].currentTime).toBe(200); proxy2.pause(); expect(sourcePause).toHaveBeenCalledTimes(2); expect(targetPause).toHaveBeenCalledTimes(4); }); it('preprocessKeyframes', () => { expect( preprocessKeyframes([ { fill: 'red', opacity: 0, stroke: 1, lineWidth: 0, lineDash: undefined, startPoint: [0, 0, 0] }, { fill: 'blue', opacity: 1, lineWidth: 0, lineDash: undefined, startPoint: [0, 0, 0] }, ]), ).toEqual([ { fill: 'red', opacity: 0 }, { fill: 'blue', opacity: 1 }, ]); }); it('inferDefaultValue', () => { expect(inferDefaultValue('x')).toBe(0); expect(inferDefaultValue('y')).toBe(0); expect(inferDefaultValue('z')).toBe(0); expect(inferDefaultValue('opacity')).toBe(1); expect(inferDefaultValue('stroke')).toBe(undefined); expect(inferDefaultValue('visibility')).toBe('visible'); expect(inferDefaultValue('collapsed')).toBe(false); expect(inferDefaultValue('states')).toEqual([]); }); it('getAnimation', () => { expect(getAnimationOptions({}, false)).toBe(false); expect(getAnimationOptions({ animation: false }, true)).toBe(false); expect(getAnimationOptions({}, true)).toEqual(DEFAULT_ANIMATION_OPTIONS); expect(getAnimationOptions({ animation: { duration: 1000 } }, true)).toEqual({ ...DEFAULT_ANIMATION_OPTIONS, duration: 1000, }); expect(getAnimationOptions({ animation: { duration: 1000 } }, { duration: 500, easing: 'linear' })).toEqual({ ...DEFAULT_ANIMATION_OPTIONS, duration: 500, easing: 'linear', }); }); it('getElementAnimationOptions', () => { // global, element, local => result // total 2 * 3 * 3 = 18 cases const animations: [ GraphOptions['animation'], undefined | false | AnimationEffectTiming, undefined | false | AnimationEffectTiming, false | STDAnimation, ][] = [ [false, false, false, []], [false, false, undefined, []], [false, undefined, false, []], [false, undefined, undefined, []], [undefined, false, false, []], [undefined, false, undefined, []], [undefined, undefined, false, []], [undefined, undefined, undefined, []], [{ duration: 1000 }, undefined, undefined, []], [false, undefined, undefined, []], [undefined, { duration: 1000 }, undefined, [{ ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, fields: [] }]], [ { duration: 1000 }, { duration: 500 }, undefined, [{ ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, duration: 500, fields: [] }], ], [ { duration: 1000 }, { duration: 500 }, { duration: 200 }, [{ ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, duration: 200, fields: [] }], ], [{ duration: 1000 }, { duration: 500 }, false, []], [{ duration: 1000 }, false, { duration: 200 }, []], [false, { duration: 500 }, { duration: 200 }, []], [false, { duration: 500 }, false, []], [{ duration: 1000 }, false, false, []], [true, undefined, undefined, []], [true, { duration: 500 }, undefined, [{ ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, duration: 500, fields: [] }]], [ true, { duration: 500 }, { duration: 200 }, [{ ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, duration: 200, fields: [] }], ], [true, false, { duration: 200 }, []], ]; const stage = 'update' as const; const elementType = 'node' as const; for (const [global, element, local, result] of animations) { expect( getElementAnimationOptions( { animation: global, [elementType]: { animation: { ...(element === false ? { [stage]: false } : element === undefined ? {} : { [stage]: [{ ...element, fields: [] }] }), }, }, }, elementType, stage, local, ), ).toEqual(result); } }); it('getElementAnimationOptions in theme', () => { const stage = 'update' as const; register('theme', 'test-element-animation', { node: { animation: { [stage]: false } }, edge: { animation: false }, combo: { animation: { [stage]: [{ fields: ['d', 'stroke'], shape: 'key', duration: 2000 }] }, }, }); expect(getElementAnimationOptions({ theme: 'test-element-animation' }, 'node', stage)).toEqual([]); expect(getElementAnimationOptions({ theme: 'test-element-animation' }, 'edge', stage)).toEqual([]); expect(getElementAnimationOptions({ theme: 'test-element-animation' }, 'combo', stage)).toEqual([ { ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, fields: ['d', 'stroke'], shape: 'key', duration: 2000 }, ]); }); it('getElementAnimationOptions mixin', () => { const stage = 'update' as const; register('theme', 'test-element-animation-mixin', { node: { animation: { [stage]: false } }, edge: { animation: false }, combo: { animation: { [stage]: [{ fields: ['d', 'stroke'], shape: 'key', duration: 2000 }] }, }, }); const options = { theme: 'test-element-animation-mixin', node: { animation: { enter: [{ fields: ['x', 'y'], duration: 1000 }], }, }, }; expect(getElementAnimationOptions(options, 'node', stage)).toEqual([]); expect(getElementAnimationOptions(options, 'node', 'enter')).toEqual([ { ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, fields: ['x', 'y'], duration: 1000 }, ]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/array.spec.ts ================================================ import { deduplicate } from '@/src/utils/array'; describe('array', () => { it('deduplicate', () => { expect(deduplicate([1, 2, 3, 4, 5, 1, 2, 3, 4, 5])).toEqual([1, 2, 3, 4, 5]); expect( deduplicate( [{ id: 'node-1', data: { value: 1 } }, { id: 'node-2' }, { id: 'node-1', data: { value: 2 } }], (item) => item.id, ), ).toEqual(expect.arrayContaining([{ id: 'node-1', data: { value: 1 } }, { id: 'node-2' }])); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/bbox.spec.ts ================================================ import { getBBoxHeight, getBBoxSize, getBBoxWidth, getCombinedBBox, getExpandedBBox, getIncircleRadius, getNearestBoundaryPoint, getNearestBoundarySide, getNodeBBox, getPointBBox, getTriangleCenter, isBBoxInside, isPointInBBox, isPointOnBBoxBoundary, isPointOutsideBBox, } from '@/src/utils/bbox'; import { AABB } from '@antv/g'; describe('bbox', () => { const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [1, 1, 1]); it('getBBoxWidth', () => { expect(getBBoxWidth(bbox)).toBe(1); }); it('getBBoxHeight', () => { expect(getBBoxHeight(bbox)).toBe(1); }); it('getBBoxSize', () => { expect(getBBoxSize(bbox)).toEqual([1, 1]); }); it('getNodeBBox', () => { const bbox = new AABB(); bbox.setMinMax([10, 10, 0], [10, 10, 0]); expect(getNodeBBox([10, 10, 0])).toEqual(bbox); }); it('getPointBBox', () => { const pointBBox = new AABB(); pointBBox.setMinMax([10, 10, 0], [10, 10, 0]); expect(getPointBBox([10, 10, 0])).toEqual(pointBBox); }); it('getExpandedBBox', () => { const expandedBBox = new AABB(); expandedBBox.setMinMax([-10, -10, 0], [11, 11, 1]); expect(getExpandedBBox(bbox, 10)).toEqual(expandedBBox); expect(getExpandedBBox(bbox, [10, 10, 10, 10])).toEqual(expandedBBox); }); it('getCombinedBBox', () => { const bbox1 = new AABB(); bbox1.setMinMax([0, 0, 0], [1, 1, 1]); const bbox2 = new AABB(); bbox2.setMinMax([2, 2, 2], [3, 3, 3]); const bbox3 = new AABB(); bbox3.setMinMax([0, 0, 0], [3, 3, 3]); expect(getCombinedBBox([bbox1, bbox2])).toEqual(bbox3); expect(getCombinedBBox([])).toEqual(new AABB()); }); it('isBBoxInside', () => { const bbox1 = new AABB(); bbox1.setMinMax([0, 0, 0], [1, 1, 1]); const bbox2 = new AABB(); bbox2.setMinMax([0.5, 0.5, 0], [1.5, 1.5, 1]); const bbox3 = new AABB(); bbox3.setMinMax([0, 0, 0], [2, 2, 2]); expect(isBBoxInside(bbox1, bbox2)).toBe(false); expect(isBBoxInside(bbox1, bbox3)).toBe(true); }); it('isPointInBBox', () => { expect(isPointInBBox([0.5, 0.5, 0], bbox)).toBe(true); expect(isPointInBBox([0, 0, 0], bbox)).toBe(true); expect(isPointInBBox([1, 1, 1], bbox)).toBe(true); expect(isPointInBBox([2, 2, 2], bbox)).toBe(false); }); it('isPointOutsideBBox', () => { expect(isPointOutsideBBox([2, 2, 2], bbox)).toBe(true); expect(isPointOutsideBBox([0.5, 0.5, 0], bbox)).toBe(false); }); it('isPointOnBBoxBoundary', () => { expect(isPointOnBBoxBoundary([0, 0.5, 0], bbox)).toEqual(true); expect(isPointOnBBoxBoundary([0, 2, 0], bbox)).toEqual(false); expect(isPointOnBBoxBoundary([0, 2, 0], bbox, true)).toEqual(true); }); it('getNearestBoundarySide', () => { expect(getNearestBoundarySide([0.2, 0.5, 0], bbox)).toBe('left'); expect(getNearestBoundarySide([0.5, 0.2, 0], bbox)).toBe('top'); expect(getNearestBoundarySide([0.8, 0.5, 0], bbox)).toBe('right'); expect(getNearestBoundarySide([0.5, 0.8, 0], bbox)).toBe('bottom'); }); it('getNearestBoundaryPoint', () => { expect(getNearestBoundaryPoint([0.2, 0.5, 0], bbox)).toEqual([0, 0.5, 0]); expect(getNearestBoundaryPoint([0.5, 0.2, 0], bbox)).toEqual([0.5, 0, 0]); expect(getNearestBoundaryPoint([0.8, 0.5, 0], bbox)).toEqual([1, 0.5, 0]); expect(getNearestBoundaryPoint([0.5, 0.8, 0], bbox)).toEqual([0.5, 1, 0]); expect(getNearestBoundaryPoint([1.8, 0.5, 0], bbox)).toEqual([1, 0.5, 0]); expect(getNearestBoundaryPoint([0.5, 1.8, 0], bbox)).toEqual([0.5, 1, 0]); }); it('getTriangleCenter', () => { expect(getTriangleCenter(bbox, 'left')).toEqual([2 / 3, 0.5]); expect(getTriangleCenter(bbox, 'right')).toEqual([0.33333333333333337, 0.5]); expect(getTriangleCenter(bbox, 'up')).toEqual([0.5, 2 / 3]); expect(getTriangleCenter(bbox, 'down')).toEqual([0.5, 0.33333333333333337]); }); it('getIncircleRadius', () => { expect(getIncircleRadius(bbox, 'left')).toEqual(0.3090169943749474); expect(getIncircleRadius(bbox, 'right')).toEqual(0.3090169943749474); expect(getIncircleRadius(bbox, 'up')).toEqual(0.3090169943749474); expect(getIncircleRadius(bbox, 'down')).toEqual(0.3090169943749474); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/cache.spec.ts ================================================ import { cacheStyle, getCachedStyle, hasCachedStyle, setCacheStyle } from '@/src/utils/cache'; import { Circle } from '@antv/g'; describe('cache', () => { it('cacheStyle and getCachedStyle and setCacheStyle', () => { const circle = new Circle({ style: { r: 10, fill: 'red', stroke: 'blue', }, }); expect(hasCachedStyle(circle, 'fill')).toBe(false); cacheStyle(circle, ['fill', 'stroke']); circle.style.fill = 'green'; expect(hasCachedStyle(circle, 'fill')).toBe(true); expect(getCachedStyle(circle, 'fill')).toBe('red'); setCacheStyle(circle, 'fill', 'yellow'); expect(getCachedStyle(circle, 'fill')).toBe('yellow'); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/change.spec.ts ================================================ import { reduceDataChanges } from '@/src/utils/change'; describe('change', () => { it('reduceDataChanges', () => { expect( reduceDataChanges([ { type: 'NodeAdded', value: { id: 'node-0' } }, { type: 'NodeAdded', value: { id: 'node-1' } }, { type: 'NodeAdded', value: { id: 'node-2' } }, { type: 'EdgeAdded', value: { source: 'node-1', target: 'edge-2' } }, { type: 'NodeUpdated', value: { id: 'node-3', style: { fill: 'pink' } }, original: { id: 'node-3', style: { fill: 'red' } }, }, { type: 'NodeUpdated', value: { id: 'node-3', style: { fill: 'purple', lineWidth: 2 } }, original: { id: 'node-3', style: { fill: 'pink' } }, }, { type: 'NodeUpdated', value: { id: 'node-0', data: { value: 10 } }, original: { id: 'node-0' } }, { type: 'NodeRemoved', value: { id: 'node-0' } }, ]), ).toEqual([ { type: 'NodeAdded', value: { id: 'node-1' } }, { type: 'NodeAdded', value: { id: 'node-2' } }, { type: 'EdgeAdded', value: { source: 'node-1', target: 'edge-2' } }, { type: 'NodeUpdated', value: { id: 'node-3', style: { fill: 'purple', lineWidth: 2 } }, original: { id: 'node-3', style: { fill: 'red' } }, }, ]); }); it('reduceDataChanges with Updated and Removed', () => { expect( reduceDataChanges([ { type: 'NodeUpdated', value: { id: 'node-3', style: { fill: 'pink' } }, original: { id: 'node-3', style: { fill: 'red' } }, }, { type: 'NodeRemoved', value: { id: 'node-3' } }, ]), ).toEqual([{ type: 'NodeRemoved', value: { id: 'node-3' } }]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/collapsibility.spec.ts ================================================ import { isCollapsed } from '@/src/utils/collapsibility'; describe('collapsibility', () => { it('isCollapsed', () => { expect(isCollapsed({ id: 'xxx' })).toBe(false); expect(isCollapsed({ id: 'xxx', style: { collapsed: true } })).toBe(true); expect(isCollapsed({ id: 'xxx', style: { collapsed: false } })).toBe(false); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/contextmenu.spec.ts ================================================ import { getContentFromItems } from '@/src/plugins/contextmenu/util'; describe('contextmenu', () => { it('getContentFromItems', () => { expect( getContentFromItems([ { name: 'expand', value: 'expand' }, { name: 'collapse', value: 'collapse' }, ]), ).toEqual(`
  • expand
  • collapse
`); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/data.spec.ts ================================================ import type { EdgeData, NodeData } from '@/src'; import { cloneElementData, isElementDataEqual, isEmptyData, mergeElementsData } from '@/src/utils/data'; describe('data', () => { it('mergeElementsData', () => { const originalData: NodeData = { id: 'node-1', data: { value1: 100, value2: { value3: 300, value4: 400 }, }, style: { fill: 'red', badges: [ { text: 'badge1', stroke: 'red' }, { text: 'badge2', stroke: 'blue' }, ], }, }; const modifiedData: NodeData = { id: 'node-1', data: { value1: 200, value2: { value3: 300 }, }, style: { badges: [ { text: 'badge2', stroke: 'blue' }, { text: 'badge3', stroke: 'green' }, ], stroke: 'pink', }, }; expect(mergeElementsData(originalData, modifiedData)).toEqual({ id: 'node-1', data: { value1: 200, value2: { value3: 300 }, }, style: { fill: 'red', badges: [ { text: 'badge2', stroke: 'blue' }, { text: 'badge3', stroke: 'green' }, ], stroke: 'pink', }, }); }); it('mergeElementsData edge', () => { const originalData: EdgeData = { id: 'edge-1', source: 'node-1', target: 'node-2', }; const modifiedData: EdgeData = { id: 'edge-1', source: 'node-2', target: 'node-1', data: { weight: 10, }, }; expect(mergeElementsData(originalData, modifiedData)).toEqual({ id: 'edge-1', source: 'node-2', target: 'node-1', data: { weight: 10, }, }); }); it('cloneElementData', () => { const data = { id: 'node-1', data: { value1: { a: 1 }, value2: 2 }, style: { fill: 'pink', startPoint: [0, 100] } }; const clonedData = cloneElementData(data); expect(clonedData).toEqual(data); data.data.value1.a = 2; data.style.startPoint[0] = 100; expect(clonedData).toEqual(data); data.data.value2 = 3; data.style.startPoint = [100, 100]; expect(clonedData).not.toEqual(data); }); it('isEmptyData', () => { expect(isEmptyData({})).toBe(true); expect(isEmptyData({ nodes: [] })).toBe(true); expect(isEmptyData({ nodes: [], edges: [] })).toBe(true); expect(isEmptyData({ nodes: [], edges: [], combos: [] })).toBe(true); expect(isEmptyData({ nodes: [{ id: 'node-1' }] })).toBe(false); expect(isEmptyData({ edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }] })).toBe(false); expect(isEmptyData({ combos: [{ id: 'combo-1' }] })).toBe(false); }); it('isElementDataEqual', () => { expect(isElementDataEqual({ id: 'node-1' }, { id: 'node-1' })).toBe(true); expect(isElementDataEqual({ id: 'node-1' }, { id: 'node-2' })).toBe(false); // children expect(isElementDataEqual({ id: 'node-1', children: ['a', 'b'] }, { id: 'node-1', children: ['a', 'b'] })).toBe( true, ); expect(isElementDataEqual({ id: 'node-1', children: ['a', 'b'] }, { id: 'node-1', children: ['a', 'c'] })).toBe( false, ); expect( isElementDataEqual({ id: 'node-1', children: ['a', 'b'] }, { id: 'node-1', children: ['a', 'b', 'c'] }), ).toBe(false); expect(isElementDataEqual({ id: 'node-1' }, { id: 'node-1', data: {} })).toBe(true); expect(isElementDataEqual({ id: 'node-1', data: { value: 1 } }, { id: 'node-1', data: { value: 1 } })).toBe(true); // states expect(isElementDataEqual({ id: 'node-1' }, { id: 'node-1', states: [] })).toBe(true); expect(isElementDataEqual({ id: 'node-1', states: [] }, { id: 'node-1', states: [] })).toBe(true); expect(isElementDataEqual({ id: 'node-1', states: ['selected'] }, { id: 'node-1', states: ['selected'] })).toBe( true, ); expect( isElementDataEqual({ id: 'node-1', states: ['selected'] }, { id: 'node-1', states: ['selected', 'hover'] }), ).toBe(false); // too deep const obj = { a: 1 }; expect( isElementDataEqual({ id: 'node-1', data: { value: { ...obj } } }, { id: 'node-1', data: { value: { ...obj } } }), ).toBe(false); expect(isElementDataEqual({ id: 'node-1', data: { value: obj } }, { id: 'node-1', data: { value: obj } })).toBe( true, ); // style expect(isElementDataEqual({ id: 'node-1' }, { id: 'node-1', style: {} })).toBe(true); expect(isElementDataEqual({ id: 'node-1', style: { fill: 'red' } }, { id: 'node-1', style: { fill: 'red' } })).toBe( true, ); expect( isElementDataEqual({ id: 'node-1', style: { fill: 'red' } }, { id: 'node-1', style: { fill: 'blue' } }), ).toBe(false); expect( isElementDataEqual( { id: 'node-1', style: { fill: 'red' } }, { id: 'node-1', style: { fill: 'red', stroke: 'red' } }, ), ).toBe(false); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/diff.spec.ts ================================================ import { arrayDiff } from '@/src/utils/diff'; describe('diff', () => { const key = (d: { id: string }) => d.id; it('array diff simple', () => { const original = [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-3' }]; const modified = [{ id: 'node-1' }, { id: 'node-2' }, { id: 'node-4' }]; const { enter, update, exit, keep } = arrayDiff(original, modified, key); expect(enter).toEqual([{ id: 'node-4' }]); expect(update).toEqual([]); expect(exit).toEqual([{ id: 'node-3' }]); expect(keep).toEqual([{ id: 'node-1' }, { id: 'node-2' }]); }); it('array diff', () => { const original = [ { id: 'node-1' }, { id: 'node-2', data: { value: 1 } }, { id: 'node-3', data: { value: 2 }, style: { fill: 'red' } }, ]; const modified = [ { id: 'node-1' }, { id: 'node-2', data: { value: 2 } }, { id: 'node-4', data: { value: 3 }, style: { fill: 'red' } }, ]; const { enter, update, exit, keep } = arrayDiff(original, modified, key); expect(enter).toEqual([{ id: 'node-4', data: { value: 3 }, style: { fill: 'red' } }]); expect(update).toEqual([{ id: 'node-2', data: { value: 2 } }]); expect(exit).toEqual([{ id: 'node-3', data: { value: 2 }, style: { fill: 'red' } }]); expect(keep).toEqual([{ id: 'node-1' }]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/dom.spec.ts ================================================ import { sizeOf } from '@/src/utils/dom'; describe('dom', () => { it('should return the size of the graph container', () => { // Create a mock container element const container = document.createElement('div'); container.style.width = '500px'; container.style.height = '300px'; // Call the sizeOf function const result = sizeOf(container); // Assert the result expect(result).toEqual([500, 300]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/edge.spec.ts ================================================ import type { ID } from '@/src'; import { Rect } from '@/src/elements'; import { Badge, Label } from '@/src/elements/shapes'; import { findActualConnectNodeData, getBadgePositionStyle, getCubicPath, getCurveControlPoint, getLabelPositionStyle, getPolylineLoopControlPoints, getPolylinePath, getQuadraticPath, getRadians, getSubgraphRelatedEdges, parseCurveOffset, parseCurvePosition, } from '@/src/utils/edge'; import { AABB, Line } from '@antv/g'; describe('edge', () => { describe('getLabelPositionStyle', () => { it('should return correctly label position style', () => { // horizontal line const line = new Line({ style: { x1: 0, y1: 100, x2: 100, y2: 100, }, }); // with rotation angle below Math.PI const line1 = new Line({ style: { x1: 0, y1: 100, x2: 100, y2: 200, }, }); // with rotation angle over Math.PI const line2 = new Line({ style: { x1: 0, y1: 200, x2: 100, y2: 100, }, }); const labelPlacement = getLabelPositionStyle(line, 'center', false, 0, 0); expect(labelPlacement.textAlign).toEqual('center'); expect(labelPlacement.transform).toEqual([['translate', 50, 100]]); const labelPosition2 = getLabelPositionStyle(line, 'center', true, 5, 5); expect(labelPosition2.textAlign).toEqual('center'); expect(labelPosition2.transform).toEqual([['translate', 55, 105]]); const labelPosition3 = getLabelPositionStyle(line, 'start', true, 5, 5); expect(labelPosition3.textAlign).toEqual('left'); expect(labelPosition3.transform).toEqual([['translate', 5, 105]]); const labelPosition4 = getLabelPositionStyle(line, 'end', true, 5, 5); expect(labelPosition4.textAlign).toEqual('right'); expect(labelPosition4.transform).toEqual([['translate', 104, 105]]); const labelPosition5 = getLabelPositionStyle(line, 0.5, true, 5, 5); expect(labelPosition5.textAlign).toEqual('center'); expect(labelPosition5.transform).toEqual([['translate', 55, 105]]); // with rotation angle below Math.PI const labelPosition6 = getLabelPositionStyle(line1, 'center', true, 5, 5); expect(labelPosition6.textAlign).toEqual('center'); expect(labelPosition6.transform).toEqual([ [ 'translate', 50 + 5 * Math.cos(Math.PI / 4) - 5 * Math.sin(Math.PI / 4), 150 + 5 * Math.sin(Math.PI / 4) + 5 * Math.cos(Math.PI / 4), ], ['rotate', 45], ]); const labelPosition7 = getLabelPositionStyle(line1, 'start', true, 5, 5); expect(labelPosition7.textAlign).toEqual('left'); const labelPosition8 = getLabelPositionStyle(line1, 'end', true, 5, 5); expect(labelPosition8.textAlign).toEqual('right'); // with rotation angle over Math.PI const labelPosition9 = getLabelPositionStyle(line2, 'center', true, 5, 5); expect(labelPosition9.textAlign).toEqual('center'); expect(labelPosition9.transform).toEqual([ [ 'translate', 50 + 5 * Math.cos(-Math.PI / 4) - 5 * Math.sin(-Math.PI / 4), 150 + 5 * Math.sin(-Math.PI / 4) + 5 * Math.cos(-Math.PI / 4), ], ['rotate', -45], ]); }); }); it('getBadgePositionStyle', () => { const shapeMap = { key: new Line({ style: { x1: 0, y1: 0, x2: 100, y2: 0 } }), label: new Label({ style: { text: 'label', background: true } }), badge: new Badge({ style: { text: 'badge', background: true } }), }; expect(getBadgePositionStyle(shapeMap, 'prefix', 'center', 10, 0)).toEqual({ textAlign: 'center', transform: [['translate', 50, 0]], }); }); it('getCurveControlPoint', () => { expect(getCurveControlPoint([0, 0], [100, 0], 0.5, 20)).toEqual([50, -20]); expect(getCurveControlPoint([0, 0], [100, 0], 0.5, -20)).toEqual([50, 20]); }); it('parseCurveOffset', () => { expect(parseCurveOffset(20)).toEqual([20, -20]); expect(parseCurveOffset([20, 30])).toEqual([20, 30]); }); it('parseCurvePosition', () => { expect(parseCurvePosition(0.2)).toEqual([0.2, 0.8]); expect(parseCurvePosition([0.2, 0.8])).toEqual([0.2, 0.8]); }); it('getQuadraticPath', () => { expect(getQuadraticPath([0, 10], [10, 10], [100, 100])).toEqual([ ['M', 0, 10], ['Q', 100, 100, 10, 10], ]); }); it('getCubicPath', () => { expect( getCubicPath( [0, 10], [100, 100], [ [20, 20], [50, 50], ], ), ).toEqual([ ['M', 0, 10], ['C', 20, 20, 50, 50, 100, 100], ]); }); it('getPolylinePath', () => { expect( getPolylinePath( [ [0, 10], [20, 20], [50, 50], [100, 100], ], 0, ), ).toEqual([ ['M', 0, 10], ['L', 20, 20], ['L', 50, 50], ['L', 100, 100], ]); expect( getPolylinePath( [ [0, 10], [20, 20], [50, 50], [100, 100], ], 0, true, ), ).toEqual([['M', 0, 10], ['L', 20, 20], ['L', 50, 50], ['L', 100, 100], ['Z']]); expect( getPolylinePath( [ [0, 10], [20, 20], [50, 50], [100, 100], ], 10, )[1][1], ).toBeCloseTo(13.33); }); it('getRadians', () => { const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [100, 100, 0]); const EIGHTH_PI = Math.PI / 8; expect(getRadians(bbox).bottom[0]).toBeCloseTo(EIGHTH_PI * 3); expect(getRadians(bbox).top[0]).toBeCloseTo(-EIGHTH_PI * 5); }); it('getPolylineLoopControlPoints', () => { const node = new Rect({ style: { x: 100, y: 100, size: 100 } }); expect(getPolylineLoopControlPoints(node, [150, 100], [150, 100], 10)).toEqual([ [160, 100], [160, 110], [150, 110], ]); expect(getPolylineLoopControlPoints(node, [100, 150], [100, 150], 10)).toEqual([ [100, 160], [110, 160], [110, 150], ]); expect(getPolylineLoopControlPoints(node, [50, 100], [50, 100], 10)).toEqual([ [40, 100], [40, 110], [50, 110], ]); expect(getPolylineLoopControlPoints(node, [100, 50], [100, 50], 10)).toEqual([ [100, 40], [110, 40], [110, 50], ]); expect(getPolylineLoopControlPoints(node, [150, 150], [100, 150], 10)).toEqual([ [160, 150], [160, 160], [100, 160], ]); expect(getPolylineLoopControlPoints(node, [150, 150], [150, 100], 10)).toEqual([ [160, 150], [160, 100], ]); expect(getPolylineLoopControlPoints(node, [120, 50], [140, 50], 10)).toEqual([ [120, 40], [140, 40], ]); expect(getPolylineLoopControlPoints(node, [150, 120], [150, 140], 10)).toEqual([ [160, 120], [160, 140], ]); expect(getPolylineLoopControlPoints(node, [50, 120], [50, 140], 10)).toEqual([ [40, 120], [40, 140], ]); }); it('getSubgraphRelatedEdges', () => { /** * 1 - 2 * / \ * 3 - - - 4 * \ | / * 5 */ const data = { nodes: [ { id: 'node-1', combo: 'combo-1' }, { id: 'node-2', combo: 'combo-1' }, { id: 'node-3' }, { id: 'node-4' }, { id: 'node-5' }, ], edges: [ { id: 'node-1-node-2', source: 'node-1', target: 'node-2' }, { id: 'node-1-node-3', source: 'node-1', target: 'node-3' }, { id: 'node-2-node-4', source: 'node-2', target: 'node-4' }, { id: 'node-3-node-5', source: 'node-3', target: 'node-5' }, { id: 'node-4-node-5', source: 'node-4', target: 'node-5' }, { id: 'combo-1-node-5', source: 'combo-1', target: 'node-5' }, ], combos: [{ id: 'combo-1' }], }; const getRelatedEdges = (id: ID) => data.edges.filter((edge) => edge.id.includes(id as string)); expect(getSubgraphRelatedEdges(['node-1', 'node-2', 'combo-1'], getRelatedEdges)).toEqual({ edges: [ { id: 'node-1-node-2', source: 'node-1', target: 'node-2' }, { id: 'node-1-node-3', source: 'node-1', target: 'node-3' }, { id: 'node-2-node-4', source: 'node-2', target: 'node-4' }, { id: 'combo-1-node-5', source: 'combo-1', target: 'node-5' }, ], external: [ { id: 'node-1-node-3', source: 'node-1', target: 'node-3' }, { id: 'node-2-node-4', source: 'node-2', target: 'node-4' }, { id: 'combo-1-node-5', source: 'combo-1', target: 'node-5' }, ], internal: [{ id: 'node-1-node-2', source: 'node-1', target: 'node-2' }], }); expect(getSubgraphRelatedEdges(['node-3', 'node-5'], getRelatedEdges)).toEqual({ edges: [ { id: 'node-1-node-3', source: 'node-1', target: 'node-3' }, { id: 'node-3-node-5', source: 'node-3', target: 'node-5' }, { id: 'node-4-node-5', source: 'node-4', target: 'node-5' }, { id: 'combo-1-node-5', source: 'combo-1', target: 'node-5' }, ], external: [ { id: 'node-1-node-3', source: 'node-1', target: 'node-3' }, { id: 'node-4-node-5', source: 'node-4', target: 'node-5' }, { id: 'combo-1-node-5', source: 'combo-1', target: 'node-5' }, ], internal: [{ id: 'node-3-node-5', source: 'node-3', target: 'node-5' }], }); }); it('findActualConnectNodeData', () => { expect(findActualConnectNodeData({ id: 'node-1' }, () => undefined).id).toBe('node-1'); expect( findActualConnectNodeData({ id: 'node-1' }, (id) => { if (id === 'node-1') return { id: 'node-2' }; return undefined; }).id, ).toBe('node-1'); expect( findActualConnectNodeData({ id: 'node-1' }, (id) => { if (id === 'node-1') return { id: 'node-2', style: { collapsed: true } }; if (id === 'node-2') return { id: 'node-3' }; return undefined; }).id, ).toBe('node-2'); expect( findActualConnectNodeData({ id: 'node-1' }, (id) => { if (id === 'node-1') return { id: 'node-2' }; if (id === 'node-2') return { id: 'node-3', style: { collapsed: true } }; return undefined; }).id, ).toBe('node-3'); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/element.spec.ts ================================================ import { Polyline } from '@/src/elements/edges'; import { Circle } from '@/src/elements/nodes'; import { ID, PortStyleProps } from '@/src/types'; import { findPorts, getAllPorts, getBoundingPoints, getHexagonPoints, getPortConnectionPoint, getPortXYByPlacement, getStarPoints, getStarPorts, getTextStyleByPlacement, getTrianglePoints, getTrianglePorts, isEdge, isElement, isNode, isSameNode, isSimplePort, isVisible, updateStyle, } from '@/src/utils/element'; import { getXYByPlacement } from '@/src/utils/position'; import { AABB, DisplayObject, Line, Rect } from '@antv/g'; describe('element', () => { const bbox = new AABB(); bbox.setMinMax([100, 100, 0], [200, 200, 0]); const node1 = new Circle({ id: 'node-1', }); const node2 = new Circle({ id: 'node-2', }); const context: any = { element: { getElement(id: ID) { if (id === 'node-1') return node1; else return node2; }, }, }; const edge = new Polyline({ style: { sourceNode: 'node-1', targetNode: 'node-2' }, context }); it('isNode', () => { const rect = new Rect({ style: { width: 10, height: 10 } }); expect(isNode(rect)).toBe(false); expect(isElement(rect)).toBe(false); const node = new Circle({}); expect(isNode(node)).toBe(true); expect(isElement(node)).toBe(true); }); it('isEdge', () => { const line = new Line({ style: { x1: 0, y1: 0, x2: 10, y2: 10 } }); expect(isEdge(line)).toBe(false); expect(isElement(line)).toBe(false); expect(isEdge(edge)).toBe(true); expect(isElement(edge)).toBe(true); }); it('isSameNode', () => { expect(isSameNode(node1, undefined!)).toBeFalsy(); expect(isSameNode(node1, node2)).toBeFalsy(); expect(isSameNode(node1, node1)).toBeTruthy(); }); it('getXYByPlacement', () => { expect(getXYByPlacement(bbox, 'left')).toEqual([100, 150]); expect(getXYByPlacement(bbox, 'right')).toEqual([200, 150]); expect(getXYByPlacement(bbox, 'top')).toEqual([150, 100]); expect(getXYByPlacement(bbox, 'bottom')).toEqual([150, 200]); expect(getXYByPlacement(bbox, 'left-top')).toEqual([100, 100]); expect(getXYByPlacement(bbox, 'right-bottom')).toEqual([200, 200]); expect(getXYByPlacement(bbox, 'center')).toEqual([150, 150]); expect(getXYByPlacement(bbox)).toEqual([150, 150]); }); it('getPortXYByPlacement', () => { expect(getPortXYByPlacement(bbox, 'left')).toEqual([100, 150]); expect(getPortXYByPlacement(bbox, 'right')).toEqual([200, 150]); expect(getPortXYByPlacement(bbox, 'top')).toEqual([150, 100]); expect(getPortXYByPlacement(bbox, 'bottom')).toEqual([150, 200]); expect(getPortXYByPlacement(bbox)).toEqual([150, 150]); expect(getPortXYByPlacement(bbox, [0.5, 1])).toEqual([150, 200]); expect(getPortXYByPlacement(bbox, [0, 0.5])).toEqual([100, 150]); }); it('getAllPorts', () => { const node = new Circle({ style: { x: 0, y: 0, size: 100, port: true, ports: [ { key: 'left', placement: [0, 0.5], r: 4 }, { key: 'right', placement: [1, 0.5] }, ], }, }); expect(Object.values(getAllPorts(node)).length).toBe(2); expect(getAllPorts(node)['right']).toEqual([50, 0]); }); it('isSimplePort', () => { expect( isSimplePort({ placement: 'left', }), ).toBeTruthy(); expect( isSimplePort({ placement: 'left', r: 0, }), ).toBeTruthy(); expect( isSimplePort({ placement: 'left', r: 4, }), ).toBeFalsy(); }); it('findPorts', () => { const sourceNode = new Circle({ id: 'source', style: { port: true, ports: [{ key: 'left', placement: [0, 0.5], r: 4 }], }, }); const targetNode = new Circle({ id: 'target', style: { port: true, ports: [{ key: 'top', placement: [0.5, 0], r: 4 }], }, }); const sourcePortKey = 'left'; const targetPortKey = 'top'; const [sourcePort, targetPort] = findPorts(sourceNode, targetNode, sourcePortKey, targetPortKey); expect((sourcePort as DisplayObject).className).toEqual('port-left'); expect((targetPort as DisplayObject).className).toEqual('port-top'); }); it('getPortConnectionPoint', () => { const node = new Circle({ id: 'source', style: { x: 100, y: 100, port: true, ports: [{ key: 'left', placement: [0, 0.5], r: 4 }], portLinkToCenter: true, }, }); expect(getPortConnectionPoint(node.getPorts()['left'], [0, 0])).toEqual([84, 100, 0]); }); it('getTextStyleByPlacement', () => { expect(getTextStyleByPlacement(bbox, 'left')).toEqual({ transform: [['translate', 100, 150]], textAlign: 'right', textBaseline: 'middle', }); expect(getTextStyleByPlacement(bbox, 'right')).toEqual({ transform: [['translate', 200, 150]], textAlign: 'left', textBaseline: 'middle', }); expect(getTextStyleByPlacement(bbox, 'top')).toEqual({ transform: [['translate', 150, 100]], textAlign: 'center', textBaseline: 'bottom', }); expect(getTextStyleByPlacement(bbox, 'bottom')).toEqual({ transform: [['translate', 150, 200]], textAlign: 'center', textBaseline: 'top', }); expect(getTextStyleByPlacement(bbox, 'left-top')).toEqual({ transform: [['translate', 100, 100]], textAlign: 'right', textBaseline: 'bottom', }); expect(getTextStyleByPlacement(bbox, 'right-bottom')).toEqual({ transform: [['translate', 200, 200]], textAlign: 'left', textBaseline: 'top', }); expect(getTextStyleByPlacement(bbox, 'center')).toEqual({ transform: [['translate', 150, 150]], textAlign: 'center', textBaseline: 'middle', }); expect(getTextStyleByPlacement(bbox)).toEqual({ transform: [['translate', 150, 200]], textAlign: 'center', textBaseline: 'top', }); }); it('getStarPoints', () => { expect(getStarPoints(32, 16).length).toBe(10); }); it('getStarPorts', () => { expect(getStarPorts(32, 16).top).toEqual([0, -32]); }); it('getTrianglePoints', () => { expect(getTrianglePoints(40, 40, 'up').length).toBe(3); expect(getTrianglePoints(40, 40, 'up')).toEqual([ [-20, 20], [20, 20], [0, -20], ]); }); it('getTrianglePorts', () => { const ports = getTrianglePorts(32, 16, 'up'); expect(ports.default).toEqual([0, -8]); expect(ports.left).toEqual([-16, 8]); expect(ports.right).toEqual([16, 8]); expect(ports.top).toEqual([0, -8]); expect(ports.bottom).toBeFalsy(); expect(getTrianglePorts(32, 16, 'down')).toEqual({ default: [0, 8], left: [-16, -8], right: [16, -8], bottom: [0, 8], }); expect(getTrianglePorts(32, 16, 'left')).toEqual({ default: [-16, 0], top: [16, -8], bottom: [16, 8], left: [-16, 0], }); expect(getTrianglePorts(32, 16, 'right')).toEqual({ default: [16, 0], top: [-16, -8], bottom: [-16, 8], right: [16, 0], }); }); it('getBoundingPoints', () => { expect(getBoundingPoints(100, 100).length).toBe(4); expect(getBoundingPoints(100, 100)).toEqual([ [50, -50], [50, 50], [-50, 50], [-50, -50], ]); }); it('isVisible', () => { expect(isVisible(new Rect({ style: { width: 50, height: 50 } }))).toBe(true); expect(isVisible(new Rect({ style: { width: 50, height: 50, visibility: 'hidden' } }))).toBe(false); expect(isVisible(new Rect({ style: { width: 50, height: 50, visibility: 'unset' } }))).toBe(true); expect(isVisible(new Rect({ style: { width: 50, height: 50, visibility: 'visible' } }))).toBe(true); expect(isVisible(new Rect({ style: { width: 50, height: 50, visibility: 'inherit' } }))).toBe(true); expect(isVisible(new Rect({ style: { width: 50, height: 50, visibility: 'initial' } }))).toBe(true); }); it('update', () => { const rect = new Rect({ style: { width: 50, height: 50 } }); updateStyle(rect, { width: 100, height: 100 }); expect(rect.style.width).toBe(100); expect(rect.style.height).toBe(100); const circle = new Circle({ style: { size: 50 } }); updateStyle(circle, { size: 100 }); expect(circle.style.size).toBe(100); }); it('getHexagonPoints', () => { expect(getHexagonPoints(32).length).toBe(6); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/event.spec.ts ================================================ import type { ID } from '@/src'; 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 context: any = { element: { getElement(id: ID) { if (id === 'node-1') return node1; else return node2; }, }, }; const edge = new Polyline({ style: { sourceNode: 'node-1', targetNode: 'node-2' }, context }); 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'); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/extension.spec.ts ================================================ import { BehaviorOptions, PluginOptions } from '@/src'; import { parseExtensions } from '@/src/utils/extension'; describe('extension', () => { it('parseBehaviors', () => { expect(parseExtensions({} as any, 'behavior', [])).toEqual([]); const options: BehaviorOptions = [ 'drag-element', { type: 'drag-canvas' }, { type: 'shortcut', key: 'shortcut-zoom-in' }, { type: 'shortcut', key: 'shortcut-zoom-out' }, 'scroll-canvas', 'scroll-canvas', ]; expect(parseExtensions({} as any, 'behavior', options)).toEqual([ { type: 'drag-element', key: 'behavior-drag-element-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' }, ]); }); it('parsePlugins', () => { expect(parseExtensions({} as any, 'plugin', [])).toEqual([]); const options: PluginOptions = [ 'minimap', { key: 'my-tooltip', type: 'tooltip' }, { type: 'tooltip' }, { type: 'contextmenu', key: 'my-contextmenu', trigger: 'contextmenu', }, 'minimap', ]; expect(parseExtensions({} as any, 'plugin', options)).toEqual([ { type: 'minimap', key: 'plugin-minimap-0' }, { type: 'tooltip', key: 'my-tooltip' }, { type: 'tooltip', key: 'plugin-tooltip-0' }, { type: 'contextmenu', key: 'my-contextmenu', trigger: 'contextmenu', }, { type: 'minimap', key: 'plugin-minimap-1' }, ]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/graphlib.spec.ts ================================================ import type { NodeData } from '@/src'; import { toG6Data, toGraphlibData } from '@/src/utils/graphlib'; describe('graphlib', () => { it('toGraphlibData', () => { expect( [ { id: 'node-1' }, { id: 'node-2', data: { value: 1 } }, { id: 'node-3', data: { value: 2 }, style: { opacity: 0.5 } }, ].map(toGraphlibData), ).toEqual([ { id: 'node-1', data: { id: 'node-1', data: {}, style: {} } }, { id: 'node-2', data: { id: 'node-2', data: { value: 1 }, style: {} } }, { id: 'node-3', data: { id: 'node-3', data: { value: 2 }, style: { opacity: 0.5 } } }, ]); expect( [ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { value: 2 }, style: { fill: 'blue' } }, ].map(toGraphlibData), ).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { id: 'edge-1', source: 'node-1', target: 'node-2', data: {}, style: {} }, }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { id: 'edge-2', source: 'node-2', target: 'node-3', data: { value: 2 }, style: { fill: 'blue' } }, }, ]); }); it('data isolation', () => { const raw: NodeData = { id: 'node-3', data: { basic: 2, array: [1, 2, 3], object: { a: 1 } }, style: { x: 100, y: 100, opacity: 0.5, size: [100, 100] }, }; const graphlibData = toGraphlibData(raw); expect(graphlibData.data).toEqual(raw); Object.assign(graphlibData.data.data!, { basic: 3, array: [4, 5, 6], object: { b: 2 } }); expect(raw.data).toEqual({ basic: 2, array: [1, 2, 3], object: { a: 1 } }); expect(graphlibData.data.data).toEqual({ basic: 3, array: [4, 5, 6], object: { b: 2 } }); graphlibData.data.style!.x = 200; graphlibData.data.style!.size = [200, 200]; expect(raw.style).toEqual({ x: 100, y: 100, opacity: 0.5, size: [100, 100] }); }); it('toG6Data', () => { expect( [ { id: 'node-1', data: { id: 'node-1' } }, { id: 'node-2', data: { id: 'node-2', data: { value: 1 } } }, { id: 'node-3', data: { id: 'node-3', data: { value: 2 }, style: { opacity: 0.5 } } }, ].map(toG6Data), ).toEqual([ { id: 'node-1' }, { id: 'node-2', data: { value: 1 } }, { id: 'node-3', data: { value: 2 }, style: { opacity: 0.5 } }, ]); expect( [ { id: 'edge-1', source: 'node-1', target: 'node-2', data: { id: 'edge-1', source: 'node-1', target: 'node-2' }, }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { id: 'edge-2', source: 'node-2', target: 'node-3', data: { value: 2 }, style: { fill: 'blue' } }, }, ].map(toG6Data), ).toEqual([ { id: 'edge-1', source: 'node-1', target: 'node-2' }, { id: 'edge-2', source: 'node-2', target: 'node-3', data: { value: 2 }, style: { fill: 'blue' } }, ]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/id.spec.ts ================================================ import { idOf, idsOf, parentIdOf } from '@/src/utils/id'; describe('id', () => { it('idOf', () => { expect(idOf({ id: '1' })).toBe('1'); expect(idOf({ source: 'node-1', target: 'edge-1' })).toBe(`node-1-edge-1`); expect(() => idOf({})).toThrow(); }); it('parentIdOf', () => { expect(parentIdOf({ combo: '1' })).toBe('1'); expect(parentIdOf({})).toBeUndefined(); }); it('idsOf', () => { expect(idsOf({ nodes: [{ id: '1' }, { id: '2' }], edges: [{ source: '1', target: '2' }] }, true)).toEqual([ '1', '2', '1-2', ]); expect(idsOf({}, true)).toEqual([]); expect(idsOf({ nodes: [{ id: '1' }, { id: '2' }], edges: [{ source: '1', target: '2' }] }, false)).toEqual({ nodes: ['1', '2'], edges: ['1-2'], combos: [], }); expect(idsOf({}, false)).toEqual({ nodes: [], edges: [], combos: [] }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/is.spec.ts ================================================ import { isEdgeData, isPoint, isVector2, isVector3 } from '@/src/utils/is'; describe('is', () => { it('isEdgeData', () => { expect(isEdgeData({})).toBe(false); expect(isEdgeData({ source: 'node-1', target: 'edge-1' })).toBe(true); }); it('isVector2', () => { expect(isVector2([1, 2])).toBe(true); expect(isVector2([1, 2, 3])).toBe(false); }); it('isVector3', () => { expect(isVector3([1, 2, 3])).toBe(true); expect(isVector3([1, 2])).toBe(false); }); it('isPoint', () => { expect(isPoint([1, 2])).toBe(true); expect(isPoint([1, 2, 3])).toBe(true); expect(isPoint(new Float32Array([1, 2, 3]))).toBe(true); expect(isPoint([1, '2'])).toBe(false); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/layout.spec.ts ================================================ import type { GraphData } from '@/src'; import { AntVGraphData } from '@/src/layouts/types'; import { getLayoutProperty, invokeLayoutMethod, isComboLayout, isPositionSpecified, isTreeLayout, layoutAdapter, layoutMapping2GraphData, } from '@/src/utils/layout'; import dagreData from '@@/dataset/dagre.json'; class MockLayout { public id = 'mock'; private nodes: any[] = []; private edges: any[] = []; public async execute(model: any, options: any): Promise { this.nodes = (model.nodes ?? []).map((datum: any, index: number) => { const node = typeof options?.node === 'function' ? options.node(datum) : datum; return { ...node, x: index * 10, y: index * 20, z: node.z ?? 0 }; }); this.edges = (model.edges ?? []).map((datum: any, index: number) => { const edge = typeof options?.edge === 'function' ? options.edge(datum) : datum; return { ...edge, points: [ { x: index, y: index }, { x: index + 1, y: index + 1 }, ], }; }); if (typeof options?.onTick === 'function') { for (let i = 0; i < 3; i++) options.onTick(this); } } public forEachNode(callback: (node: any) => void) { this.nodes.forEach(callback); } public forEachEdge(callback: (edge: any) => void) { this.edges.forEach(callback); } } describe('layout', () => { it('isComboLayout', () => { expect(isComboLayout({ type: 'force' })).toBe(false); expect(isComboLayout({ type: 'comboCombined' })).toBe(true); expect(isComboLayout({ type: 'antv-dagre', sortByCombo: true })).toBe(true); expect(isComboLayout({ type: 'antv-dagre' })).toBe(false); }); it('isTreeLayout', () => { expect(isTreeLayout({ type: 'force' })).toBe(false); expect(isTreeLayout({ type: 'compact-box' })).toBe(true); expect(isTreeLayout({ type: 'mindmap' })).toBe(true); }); it('isPositionSpecified', () => { expect(isPositionSpecified({})).toBe(false); expect(isPositionSpecified({ x: 100 })).toBe(false); expect(isPositionSpecified({ y: 100 })).toBe(false); expect(isPositionSpecified({ x: 100, y: 100 })).toBe(true); expect(isPositionSpecified({ x: 100, y: 100, z: 100 })).toBe(true); expect(isPositionSpecified({ x: 0, y: 0, z: 0 })).toBe(true); }); it('layoutMapping2GraphData', () => { const layoutMapping: AntVGraphData = { nodes: [ { id: 'node-1', data: { x: 0, y: 0 } }, { id: 'node-2', data: { x: 100, y: 100 } }, { id: 'combo-1', data: { x: 50, y: 50, _isCombo: true } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: {} }], }; const graphData: GraphData = layoutMapping2GraphData(layoutMapping); expect(graphData).toEqual({ nodes: [ { id: 'node-1', style: { x: 0, y: 0, z: 0 } }, { id: 'node-2', style: { x: 100, y: 100, z: 0 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', style: {} }], combos: [{ id: 'combo-1', style: { x: 50, y: 50, z: 0 } }], }); }); it('layoutMapping2GraphData with controlPoints', () => { const layoutMapping: AntVGraphData = { nodes: [ { id: 'node-1', data: { x: 0, y: 0 } }, { id: 'node-2', data: { x: 100, y: 100 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', data: { controlPoints: [{ x: 50, y: 50 }] } }], }; const graphData: GraphData = layoutMapping2GraphData(layoutMapping); expect(graphData).toEqual({ nodes: [ { id: 'node-1', style: { x: 0, y: 0, z: 0 } }, { id: 'node-2', style: { x: 100, y: 100, z: 0 } }, ], edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2', style: { controlPoints: [[50, 50, 0]] } }], combos: [], }); }); it('layoutAdapter', async () => { const data: GraphData = { nodes: [{ id: 'node-1', style: { x: 1, y: 2, z: 3 } }], edges: [{ id: 'edge-1', source: 'node-1', target: 'combo-1' }], combos: [{ id: 'combo-1' }], }; const context = { model: { isCombo: (id: string) => id === 'combo-1', model: { hasTreeStructure: () => true, getParent: () => null, }, }, } as any; const AdaptiveMockLayout = layoutAdapter(MockLayout as any, context); const layout = new AdaptiveMockLayout(context); const result = await layout.execute(data); expect(result).toEqual({ nodes: [{ id: 'node-1', style: { x: 0, y: 0, z: 3 } }], edges: [ { id: 'edge-1', source: 'node-1', target: 'combo-1', style: { controlPoints: [ { x: 0, y: 0 }, { x: 1, y: 1 }, ], }, }, ], combos: [{ id: 'combo-1', style: { x: 10, y: 20, z: 0 } }], }); }); it('layoutAdapter with onTick', async () => { const data: GraphData = { nodes: [{ id: 'node-1' }], edges: [], combos: [], }; const context = { model: { isCombo: () => false, model: { hasTreeStructure: () => true, getParent: () => null, }, }, } as any; const AdaptiveMockLayout = layoutAdapter(MockLayout as any, context); const onTick = jest.fn(); const layout = new AdaptiveMockLayout(context, { onTick, }); await layout.execute(data); expect(onTick).toHaveBeenCalled(); expect(onTick).toHaveBeenCalledTimes(3); }); it('invoke and get', async () => { const context = { model: { isCombo: () => false, model: { hasTreeStructure: () => true, getParent: () => null, }, }, } as any; const AdaptiveMockLayout = layoutAdapter(MockLayout as any, context); const layout = new AdaptiveMockLayout(context); expect(invokeLayoutMethod(layout, 'execute', dagreData)).toBeInstanceOf(Promise); expect(invokeLayoutMethod(layout, 'null')).toBe(null); expect(typeof getLayoutProperty(layout, 'options')).toBe('object'); expect(getLayoutProperty(layout, 'id')).toBe('mock'); expect(getLayoutProperty(layout, 'null')).toBe(null); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/line.spec.ts ================================================ import { getLinesIntersection, isLinesParallel } from '@/src/utils/line'; describe('line', () => { it('isLinesParallel', () => { expect( isLinesParallel( [ [100, 100], [100, 50], ], [ [100, 150], [100, 200], ], ), ).toEqual(true); expect( isLinesParallel( [ [100, 100], [100, 50], ], [ [100, 150], [150, 200], ], ), ).toEqual(false); }); it('getLinesIntersection', () => { expect( getLinesIntersection( [ [100, 0], [100, 200], ], [ [0, 100], [200, 100], ], ), ).toEqual([100, 100]); expect( getLinesIntersection( [ [100, 0], [100, 200], ], [ [0, 100], [50, 300], ], ), ).toEqual(undefined); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/math.spec.ts ================================================ import { isBetween } from '@/src/utils/math'; describe('math', () => { it('isBetween', () => { expect(isBetween(1, 0, 2)).toBe(true); expect(isBetween(1, 2, 3)).toBe(false); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/padding.spec.ts ================================================ import { getHorizontalPadding, getVerticalPadding, parsePadding } from '@/src/utils/padding'; describe('padding', () => { it('parsePadding', () => { expect(parsePadding()).toEqual([0, 0, 0, 0]); expect(parsePadding([])).toEqual([0, 0, 0, 0]); expect(parsePadding(10)).toEqual([10, 10, 10, 10]); expect(parsePadding([10, 20])).toEqual([10, 20, 10, 20]); expect(parsePadding([10, 20, 30])).toEqual([10, 20, 30, 20]); expect(parsePadding([10, 20, 30, 40])).toEqual([10, 20, 30, 40]); }); it('getVerticalPadding', () => { expect(getVerticalPadding()).toEqual(0); expect(getVerticalPadding([])).toEqual(0); expect(getVerticalPadding(10)).toEqual(20); expect(getVerticalPadding([10, 20])).toEqual(20); expect(getVerticalPadding([10, 20, 30])).toEqual(40); expect(getVerticalPadding([10, 20, 30, 40])).toEqual(40); }); it('getHorizontalPadding', () => { expect(getHorizontalPadding()).toEqual(0); expect(getHorizontalPadding([])).toEqual(0); expect(getHorizontalPadding(10)).toEqual(20); expect(getHorizontalPadding([10, 20])).toEqual(40); expect(getHorizontalPadding([10, 20, 30])).toEqual(40); expect(getHorizontalPadding([10, 20, 30, 40])).toEqual(60); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/palette.spec.ts ================================================ import { register } from '@/src'; import { idOf } from '@/src/utils/id'; import { assignColorByPalette, getPaletteColors, parsePalette } from '@/src/utils/palette'; describe('palette', () => { it('parsePalette', () => { expect(parsePalette('category3')?.color).toEqual('category3'); expect(parsePalette(['red', 'green', 'blue'])?.color).toEqual(['red', 'green', 'blue']); expect(parsePalette({ type: 'value', color: 'custom-blues', field: 'value' })).toEqual({ type: 'value', color: 'custom-blues', field: 'value', }); }); it('assignColorByPalette unset', () => { const data = [ { id: 'node-1', data: { value: 100, category: 'A' } }, { id: 'node-2', data: { value: 200, category: 'B' } }, { id: 'node-3', data: { value: 300, category: 'C' } }, ]; expect(assignColorByPalette(data)).toEqual({}); }); it('assignColorByPalette discrete', () => { register('palette', 'category3', ['#1f77b4', '#ff7f0e', '#2ca02c']); const data3 = [ { id: 'node-1', data: { value: 100, category: 'A' } }, { id: 'node-2', data: { value: 200, category: 'B' } }, { id: 'node-3', data: { value: 300, category: 'C' } }, ]; const data4 = [...data3, { id: 'node-4', data: { value: 400, category: 'D' } }]; const data5 = [...data4, { id: 'node-5', data: { value: 500, category: 'A' } }]; expect( assignColorByPalette(data3, { type: 'group', color: 'category3', field: 'category', }), ).toEqual({ 'node-1': '#1f77b4', 'node-2': '#ff7f0e', 'node-3': '#2ca02c', }); // invert expect( assignColorByPalette(data3, { type: 'group', color: 'category3', field: 'category', invert: true, }), ).toEqual({ 'node-1': '#2ca02c', 'node-2': '#ff7f0e', 'node-3': '#1f77b4', }); expect( assignColorByPalette(data4, { type: 'group', color: 'category3', field: 'category', }), ).toEqual({ 'node-1': '#1f77b4', 'node-2': '#ff7f0e', 'node-3': '#2ca02c', 'node-4': '#1f77b4', }); expect( assignColorByPalette(data5, { type: 'group', color: 'category3', field: 'category', }), ).toEqual({ 'node-1': '#1f77b4', 'node-2': '#ff7f0e', 'node-3': '#2ca02c', 'node-4': '#1f77b4', 'node-5': '#1f77b4', }); expect( assignColorByPalette(data5, { type: 'group', field: (d) => idOf(d).toString(), color: 'category3', }), ).toEqual({ 'node-1': '#1f77b4', 'node-2': '#ff7f0e', 'node-3': '#2ca02c', 'node-4': '#1f77b4', 'node-5': '#ff7f0e', }); expect( assignColorByPalette(data5, { type: 'group', field: (d) => idOf(d).toString(), color: 'spectral', }), ).toEqual({ 'node-1': 'rgb(158, 1, 66)', 'node-2': 'rgb(213, 62, 79)', 'node-3': 'rgb(244, 109, 67)', 'node-4': 'rgb(253, 174, 97)', 'node-5': 'rgb(254, 224, 139)', }); }); it('assignColorByPalette continuous', () => { register('palette', 'custom-blues', (value) => `rgb(0, 0, ${(value * 255).toFixed(0)})`); const createData = (length: number) => { return Array.from({ length }, (_, index) => ({ id: `node-${index + 1}`, data: { value: index * 100 + 100 } })); }; const data3 = createData(3); expect( assignColorByPalette(data3, { type: 'value', color: 'custom-blues', field: 'value', }), ).toEqual({ 'node-1': 'rgb(0, 0, 0)', 'node-2': 'rgb(0, 0, 128)', 'node-3': 'rgb(0, 0, 255)', }); // invert expect( assignColorByPalette(data3, { type: 'value', color: 'custom-blues', field: 'value', invert: true, }), ).toEqual({ 'node-1': 'rgb(0, 0, 255)', 'node-2': 'rgb(0, 0, 128)', 'node-3': 'rgb(0, 0, 0)', }); const data11 = createData(11); expect( assignColorByPalette(data11, { type: 'value', color: 'custom-blues', field: 'value', }), ).toEqual({ 'node-1': 'rgb(0, 0, 0)', 'node-2': 'rgb(0, 0, 26)', 'node-3': 'rgb(0, 0, 51)', 'node-4': 'rgb(0, 0, 77)', 'node-5': 'rgb(0, 0, 102)', 'node-6': 'rgb(0, 0, 128)', 'node-7': 'rgb(0, 0, 153)', 'node-8': 'rgb(0, 0, 179)', 'node-9': 'rgb(0, 0, 204)', 'node-10': 'rgb(0, 0, 230)', 'node-11': 'rgb(0, 0, 255)', }); }); it('getPaletteColors', () => { expect(getPaletteColors('spectral')![0]).toBe('rgb(158, 1, 66)'); expect(getPaletteColors(['#f00', '#0f0', '#00f'])).toEqual(['#f00', '#0f0', '#00f']); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/path.spec.ts ================================================ import { getClosedSpline, parsePath, pathToPoints } from '@/src/utils/path'; import type { PathArray } from '@antv/util'; describe('path', () => { const EMPTY_PATH: PathArray = [ ['M', 0, 0], ['L', 0, 0], ]; it('parsePath', () => { expect(parsePath('M 0 0 L 0 0')).toEqual([ ['M', 0, 0], ['L', 0, 0], ]); expect(parsePath('M 0 0 L 0 0 Z')).toEqual([['M', 0, 0], ['L', 0, 0], ['Z']]); // Arc expect(parsePath('M 0 0 A 1 1 0 0 0 0 0')).toEqual([ ['M', 0, 0], ['A', 1, 1, 0, 0, 0, 0, 0], ]); // Q expect(parsePath('M 0 0 Q 1 1 0 0')).toEqual([ ['M', 0, 0], ['Q', 1, 1, 0, 0], ]); }); it('pathToPoints', () => { expect(pathToPoints('M 0 0 L 0 0')).toEqual([ [0, 0, 0], [0, 0, 0], ]); expect(pathToPoints('M 0 0 L 0 0 Z')).toEqual([ [0, 0, 0], [0, 0, 0], [0, 0, 0], ]); expect(pathToPoints('M 0 0 A 1 1 0 0 0 0 0')).toEqual([ [0, 0, 0], [0, 0, 0], ]); expect(pathToPoints('M 0 0 A 1 1 0 0 0 1 1')).toEqual([ [0, 0, 0], [1, 1, 0], ]); expect(pathToPoints('M 0 0 Q 1 1 0 0')).toEqual([ [0, 0, 0], [1, 1, 0], [0, 0, 0], ]); }); it('getClosedSpline', () => { expect(getClosedSpline([])).toEqual(EMPTY_PATH); expect( getClosedSpline([ [0, 0], [6, 6], ]), ).toEqual([ ['M', 6, 6], ['C', 6, 6, 0, 0, 0, 0], ['C', 0, 0, 6, 6, 6, 6], ['C', 6, 6, 0, 0, 0, 0], ]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/placement.spec.ts ================================================ import { parsePlacement } from '@/src/utils/placement'; describe('placement', () => { it('parsePlacement', () => { expect(parsePlacement('top')).toEqual([0.5, 0]); expect(parsePlacement('bottom')).toEqual([0.5, 1]); expect(parsePlacement('left')).toEqual([0, 0.5]); expect(parsePlacement('right')).toEqual([1, 0.5]); expect(parsePlacement('left-top')).toEqual([0, 0]); expect(parsePlacement('left-bottom')).toEqual([0, 1]); expect(parsePlacement('right-top')).toEqual([1, 0]); expect(parsePlacement('right-bottom')).toEqual([1, 1]); expect(parsePlacement('top-left')).toEqual([0, 0]); expect(parsePlacement('top-right')).toEqual([1, 0]); expect(parsePlacement('bottom-left')).toEqual([0, 1]); expect(parsePlacement('bottom-right')).toEqual([1, 1]); expect(parsePlacement('center')).toEqual([0.5, 0.5]); expect(parsePlacement([0.5, 0.5])).toEqual([0.5, 0.5]); expect(parsePlacement([0.5, 0])).toEqual([0.5, 0]); expect(parsePlacement([0.5, 0])).toEqual([0.5, 0]); expect(parsePlacement([0.5, 1.2])).toEqual([0.5, 0.5]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/point.spec.ts ================================================ import { getDiamondPoints } from '@/src/utils/element'; import { centerOf, deduplicate, findNearestLine, findNearestPointOnLine, findNearestPoints, getDistanceToLine, getEllipseIntersectPoint, getPolygonIntersectPoint, getRectIntersectPoint, getSymmetricPoint, isCollinear, isHorizontal, isOrthogonal, isPointInPolygon, isVertical, moveTo, parsePoint, round, sortByClockwise, sortByX, toPointObject, } from '@/src/utils/point'; import { Circle, Rect } from '@antv/g'; describe('Point Functions', () => { it('parsePoint', () => { expect(parsePoint({ x: 100, y: 100 })).toEqual([100, 100, 0]); }); it('toPointObject', () => { expect(toPointObject([100, 100])).toEqual({ x: 100, y: 100, z: 0 }); }); it('sortByX', () => { expect( sortByX([ [150, 150], [50, 50], [100, 100], ]), ).toEqual([ [50, 50], [100, 100], [150, 150], ]); }); it('deduplicate', () => { expect( deduplicate([ [100, 100], [100, 100], [100, 100], ]), ).toEqual([[100, 100]]); }); it('sortByX', () => { expect( sortByX([ [100, 100], [50, 50], [150, 150], ]), ).toEqual([ [50, 50], [100, 100], [150, 150], ]); }); it('round', () => { expect(round([100.123, 100.123], 2)).toEqual([100.12, 100.12]); }); it('moveTo', () => { expect(moveTo([100, 100], [50, 100], 10)).toEqual([90, 100]); expect(moveTo([50, 100], [100, 100], 10)).toEqual([60, 100]); }); it('isHorizontal', () => { expect(isHorizontal([100, 100], [50, 100])).toEqual(true); expect(isHorizontal([100, 100], [50, 150])).toEqual(false); }); it('isVertical', () => { expect(isVertical([100, 100], [100, 50])).toEqual(true); expect(isVertical([100, 100], [50, 150])).toEqual(false); }); it('isOrthogonal', () => { expect(isOrthogonal([100, 100], [100, 50])).toEqual(true); expect(isOrthogonal([100, 100], [50, 100])).toEqual(true); }); it('isCollinear', () => { expect(isCollinear([100, 100], [100, 50], [100, 150])).toEqual(true); expect(isCollinear([100, 100], [50, 100], [150, 100])).toEqual(true); expect(isCollinear([100, 100], [50, 50], [150, 100])).toEqual(false); }); it('getSymmetricPoint', () => { expect(getSymmetricPoint([50, 50], [100, 100])).toEqual([150, 150]); expect(getSymmetricPoint([-50, -50], [0, 0])).toEqual([50, 50]); }); it('getRectIntersectPoint', () => { const rect = new Rect({ style: { x: 100, y: 100, width: 2, height: 2, }, }); expect(getRectIntersectPoint([110, 110], rect.getBounds())).toEqual([102, 102]); expect(getRectIntersectPoint([110, 110], rect.getBounds(), true)).toEqual([100, 100]); }); it('getEllipseIntersectPoint', () => { const circle = new Circle({ style: { cx: 100, cy: 100, r: 1, }, }); expect(getEllipseIntersectPoint([110, 100], circle.getBounds())).toEqual([101, 100]); expect(getEllipseIntersectPoint([110, 100], circle.getBounds(), true)).toEqual([99, 100]); const circle2 = new Circle({ style: { r: 20, }, }); expect(getEllipseIntersectPoint([0, 0], circle2.getBounds())).toEqual([20, 0]); const circle3 = new Circle({ style: { cx: 100, cy: 100, r: 20, }, }); expect(getEllipseIntersectPoint([100, 100], circle3.getBounds())).toEqual([120, 100]); }); it('getDiamondIntersectPoint', () => { expect(getPolygonIntersectPoint([100, 100], [0, 0], getDiamondPoints(100, 100)).point).toEqual([25, 25]); expect(getDiamondPoints(0, 0)).toEqual([ [0, -0], [0, 0], [0, 0], [-0, 0], ]); const height = 10; const width = 10; expect(getDiamondPoints(width, height)).toEqual([ [0, -height / 2], [width / 2, 0], [0, height / 2], [-width / 2, 0], ]); }); it('findNearestPoints', () => { expect( findNearestPoints( [ [0, 0], [100, 110], ], [ [1, 1], [100, 100], ], ), ).toEqual([ [0, 0], [1, 1], ]); }); it('isPointInPolygon', () => { expect( isPointInPolygon( [10, 10], [ [0, 0], [100, 0], [100, 100], [0, 100], ], ), ).toEqual(true); expect( isPointInPolygon( [20, 20], [ [0, 0], [10, 0], [10, 10], [0, 10], ], ), ).toEqual(false); expect( isPointInPolygon( [20, 30], [ [0, 0], [20, 0], [20, 20], [0, 20], ], ), ).toEqual(false); }); it('findNearestLine', () => { expect( findNearestLine( [10, 10], [ [ [20, 0], [20, 20], ], [ [30, 0], [30, 30], ], ], ), ).toEqual([ [20, 0], [20, 20], ]); }); it('getDistanceToLine', () => { expect( getDistanceToLine( [10, 10], [ [20, 0], [20, 20], ], ), ).toEqual(10); }); it('findNearestPointOnLine', () => { expect( findNearestPointOnLine( [10, 10], [ [20, 0], [20, 20], ], ), ).toEqual([20, 10]); expect( findNearestPointOnLine( [10, 10], [ [20, 20], [20, 20], ], ), ).toEqual([20, 20]); expect( findNearestPointOnLine( [10, 10], [ [20, 0], [20, 5], ], ), ).toEqual([20, 5]); }); it('centerOf', () => { expect( centerOf([ [0, 0], [100, 100], ]), ).toEqual([50, 50]); }); it('sortByClockwise', () => { expect( sortByClockwise([ [100, 100], [50, 50], [150, 150], ]), ).toEqual([ [150, 150], [100, 100], [50, 50], ]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/polygon.spec.ts ================================================ import { getPolygonTextStyleByPlacement } from '@/src/utils/polygon'; import type { TransformArray } from '@antv/g'; import { AABB, Path } from '@antv/g'; import type { PathArray } from '@antv/util'; describe('polygon', () => { const EMPTY_PATH: PathArray = [ ['M', 0, 0], ['L', 0, 0], ]; it('getPolygonTextStyleByPlacement', () => { const bounds = new AABB(); bounds.setMinMax([0, 0, 0], [100, 100, 0]); // different placement expect(getPolygonTextStyleByPlacement(bounds, 'top', 0, 0, false, EMPTY_PATH, false)).toEqual({ textAlign: 'center', textBaseline: 'bottom', transform: [['translate', 50, 0]], }); expect(getPolygonTextStyleByPlacement(bounds, 'left', 0, 0, false, EMPTY_PATH, false)).toEqual({ textAlign: 'right', textBaseline: 'middle', transform: [['translate', 0, 50]], }); expect(getPolygonTextStyleByPlacement(bounds, 'right', 0, 0, false, EMPTY_PATH, false)).toEqual({ textAlign: 'left', textBaseline: 'middle', transform: [['translate', 100, 50]], }); expect(getPolygonTextStyleByPlacement(bounds, 'bottom', 0, 0, false, EMPTY_PATH, false)).toEqual({ textAlign: 'center', textBaseline: 'top', transform: [['translate', 50, 100]], }); // with offset expect(getPolygonTextStyleByPlacement(bounds, 'top', 10, 10, false, EMPTY_PATH, false)).toEqual({ textAlign: 'center', textBaseline: 'bottom', transform: [['translate', 60, 10]], }); // closeToHull and autoRotate const circle: PathArray = [ ['M', 0, 0], ['A', 100, 100, 0, 0, 0, 100, 0], ]; expect(getPolygonTextStyleByPlacement(bounds, 'top', 0, 0, true, circle, true)).toEqual({ textAlign: 'center', textBaseline: 'bottom', transform: [['translate', 50, 0]], }); const d: PathArray = [ ['M', 0, 0], ['L', 100, 20], ['L', 80, 100], ['L', 0, 60], ['L', 0, 0], ]; const shape = new Path({ style: { d } }); const labelStyle1 = getPolygonTextStyleByPlacement(shape.getRenderBounds(), 'top', 0, 0, true, d, true); expect(labelStyle1.textAlign).toBe('center'); expect(labelStyle1.textBaseline).toBe('bottom'); expect((labelStyle1.transform as TransformArray).some((t) => t[0] === 'rotate')).toBe(true); const labelStyle2 = getPolygonTextStyleByPlacement(shape.getRenderBounds(), 'right', 0, 0, true, d, true); expect(labelStyle2.textAlign).toBe('center'); expect(labelStyle2.textBaseline).toBe('top'); expect((labelStyle2.transform as TransformArray).some((t) => t[0] === 'rotate')).toBe(true); const labelStyle3 = getPolygonTextStyleByPlacement(shape.getRenderBounds(), 'bottom', 0, 0, true, d, true); expect(labelStyle3.textAlign).toBe('center'); expect(labelStyle3.textBaseline).toBe('top'); expect((labelStyle3.transform as TransformArray).some((t) => t[0] === 'rotate')).toBe(true); const labelStyle4 = getPolygonTextStyleByPlacement(shape.getRenderBounds(), 'left', 0, 0, true, d, true); expect(labelStyle4.textAlign).toBe('center'); expect(labelStyle4.textBaseline).toBe('top'); expect((labelStyle4.transform as TransformArray).some((t) => t[0] === 'rotate')).toBe(true); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/position.spec.ts ================================================ import { getXYByAnchor, getXYByPlacement, getXYByRelativePlacement, hasPosition, positionOf, } from '@/src/utils/position'; import { AABB } from '@antv/g'; describe('position', () => { const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [100, 100, 0]); it('getXYByRelativePlacement', () => { expect(getXYByRelativePlacement(bbox, [0.5, 0.5])).toEqual([50, 50]); expect(getXYByRelativePlacement(bbox, [0, 0])).toEqual([0, 0]); expect(getXYByRelativePlacement(bbox, [1, 1])).toEqual([100, 100]); expect(getXYByRelativePlacement(bbox, [0.2, 0.2])).toEqual([20, 20]); }); it('getXYByPlacement', () => { expect(getXYByPlacement(bbox, 'center')).toEqual([50, 50]); expect(getXYByPlacement(bbox, 'left')).toEqual([0, 50]); expect(getXYByPlacement(bbox, 'right')).toEqual([100, 50]); expect(getXYByPlacement(bbox, 'top')).toEqual([50, 0]); expect(getXYByPlacement(bbox, 'bottom')).toEqual([50, 100]); }); it('getXYByAnchor', () => { expect(getXYByAnchor(bbox, '0.5 0.5')).toEqual([50, 50]); expect(getXYByAnchor(bbox, '0 0')).toEqual([0, 0]); expect(getXYByAnchor(bbox, '1 1')).toEqual([100, 100]); expect(getXYByAnchor(bbox, '0.2 0.2')).toEqual([20, 20]); expect(getXYByAnchor(bbox, [0.5, 0.5])).toEqual([50, 50]); expect(getXYByAnchor(bbox, [0, 0])).toEqual([0, 0]); expect(getXYByAnchor(bbox, [1, 1])).toEqual([100, 100]); expect(getXYByAnchor(bbox, [0.2, 0.2])).toEqual([20, 20]); }); it('positionOf', () => { expect(positionOf({ id: 'node' })).toEqual([0, 0, 0]); expect(positionOf({ id: 'node', style: { x: 10 } })).toEqual([10, 0, 0]); expect(positionOf({ id: 'node', style: { x: 10, y: 20 } })).toEqual([10, 20, 0]); expect(positionOf({ id: 'node', style: { x: 10, y: 20, z: 30 } })).toEqual([10, 20, 30]); }); it('hasPosition', () => { expect(hasPosition({ id: 'node' })).toBeFalsy(); expect(hasPosition({ id: 'node', style: { x: 10 } })).toBeTruthy(); expect(hasPosition({ id: 'node', style: { y: 20 } })).toBeTruthy(); expect(hasPosition({ id: 'node', style: { z: 30 } })).toBeTruthy(); expect(hasPosition({ id: 'node', style: { x: 10, y: 20, z: 30 } })).toBeTruthy(); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/prefix.spec.ts ================================================ import { addPrefix, omitStyleProps, removePrefix, replacePrefix, startsWith, subObject, subStyleProps, } from '@/src/utils/prefix'; describe('prefix', () => { it('addPrefix', () => { expect(addPrefix('abc', 'prefix')).toBe('prefixAbc'); expect(addPrefix('Abc', 'prefix')).toBe('prefixAbc'); expect(addPrefix('', 'prefix')).toBe('prefix'); }); it('removePrefix', () => { expect(removePrefix('prefixAbc', 'prefix')).toBe('abc'); expect(removePrefix('prefixAbc', 'prefix', false)).toBe('Abc'); expect(removePrefix('Abc', 'prefix')).toBe('Abc'); expect(removePrefix('', 'prefix')).toBe(''); expect(removePrefix('prefix', '')).toBe('prefix'); expect(removePrefix('prefixAbc', '')).toBe('prefixAbc'); }); it('startsWith', () => { expect(startsWith('prefixAbc', 'prefix')).toBe(true); expect(startsWith('prefixAbc', 'prefix')).toBe(true); expect(startsWith('Abc', 'prefix')).toBe(false); expect(startsWith('', 'prefix')).toBe(false); expect(startsWith('prefix', 'prefix')).toBe(false); }); it('subStyleProps', () => { expect(subStyleProps({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix')).toEqual({ abc: 1, def: 2 }); expect(subStyleProps({ Abc: 1, Def: 2 }, 'prefix')).toEqual({}); expect(subStyleProps({}, 'prefix')).toEqual({}); expect(subStyleProps({ prefixAbc: 1, className: 3, class: 2 }, 'prefix')).toEqual({ abc: 1 }); }); it('subObject', () => { expect(subObject({ ab: 1, ac: 2, bc: 3 }, 'a')).toEqual({ b: 1, c: 2 }); expect(subObject({ ab: 1, ac: 2, bc: 3 }, 'b')).toEqual({ c: 3 }); }); it('omitStyleProps', () => { expect(omitStyleProps({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix')).toEqual({ Abc: 3 }); expect(omitStyleProps({ Abc: 1, Def: 2 }, 'prefix')).toEqual({ Abc: 1, Def: 2 }); expect(omitStyleProps({}, 'prefix')).toEqual({}); expect(omitStyleProps({ prefixAbc: 1, prefixDef: 2, labelAbc: 3 }, ['prefix', 'label'])).toEqual({}); }); it('replacePrefix', () => { expect(replacePrefix({ prefixAbc: 1, prefixDef: 2, Abc: 3 }, 'prefix', 'newPrefix')).toEqual({ newPrefixAbc: 1, newPrefixDef: 2, Abc: 3, }); expect(replacePrefix({ Abc: 1, Def: 2 }, 'prefix', 'newPrefix')).toEqual({ Abc: 1, Def: 2 }); expect(replacePrefix({}, 'prefix', 'newPrefix')).toEqual({}); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/print.spec.ts ================================================ import { version } from '@/src'; import { print } from '@/src/utils/print'; describe('print', () => { it('print', () => { const spy = jest.spyOn(console, 'debug').mockImplementation(); print.debug('debug message'); expect(spy).toHaveBeenCalledWith(`[G6 v${version}] debug message`); spy.mockRestore(); const spy2 = jest.spyOn(console, 'info').mockImplementation(); print.info('info message'); expect(spy2).toHaveBeenCalledWith(`[G6 v${version}] info message`); spy2.mockRestore(); const spy3 = jest.spyOn(console, 'warn').mockImplementation(); print.warn('warn message'); expect(spy3).toHaveBeenCalledWith(`[G6 v${version}] warn message`); spy3.mockRestore(); const spy4 = jest.spyOn(console, 'error').mockImplementation(); print.error('error message'); expect(spy4).toHaveBeenCalledWith(`[G6 v${version}] error message`); spy4.mockRestore(); }); it('print mute', () => { print.mute = true; const spy = jest.spyOn(console, 'debug').mockImplementation(); print.debug('debug message'); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); const spy2 = jest.spyOn(console, 'info').mockImplementation(); print.info('info message'); expect(spy2).not.toHaveBeenCalled(); spy2.mockRestore(); const spy3 = jest.spyOn(console, 'warn').mockImplementation(); print.warn('warn message'); expect(spy3).not.toHaveBeenCalled(); spy3.mockRestore(); const spy4 = jest.spyOn(console, 'error').mockImplementation(); print.error('error message'); expect(spy4).not.toHaveBeenCalled(); spy4.mockRestore(); print.mute = false; }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/random.spec.ts ================================================ import { createDeterministicRandom } from '@@/utils'; describe('createDeterministicRandom', () => { it('should generate a random number between 0 and 1', () => { const r1 = createDeterministicRandom(); const r2 = createDeterministicRandom(); expect(new Array(100).fill(0).map(r1)).toEqual(new Array(100).fill(0).map(r2)); expect(r1()).toBeGreaterThanOrEqual(0); expect(r1()).toBeLessThan(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/relation.spec.ts ================================================ import { Graph } from '@/src'; import { getElementNthDegreeIds, getNodeNthDegreeIds } from '@/src/utils/relation'; describe('relation', () => { let graph: Graph; beforeAll(() => { graph = new Graph({ data: { nodes: [{ id: '1', combo: 'combo1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }], edges: [ { source: '1', target: '2' }, { source: '1', target: '3' }, { source: '2', target: '4' }, { source: '3', target: '5' }, { source: '5', target: '6' }, { source: 'combo1', target: '6' }, ], combos: [{ id: 'combo1' }], }, }); }); afterAll(() => { graph.destroy(); }); it('getElementNthDegreeIds', () => { expect(getElementNthDegreeIds(graph, 'node', '1', 0)).toEqual(['1']); expect(getElementNthDegreeIds(graph, 'node', '1', 1)).toEqual(['1', '1-2', '1-3', '2', '3']); expect(getElementNthDegreeIds(graph, 'edge', '1-2', 0)).toEqual(['1-2']); expect(getElementNthDegreeIds(graph, 'edge', '1-2', 1)).toEqual(['1', '2', '1-2']); expect(getElementNthDegreeIds(graph, 'edge', '1-2', 2)).toEqual(['1', '1-2', '1-3', '2', '3', '2-4', '4']); expect(getElementNthDegreeIds(graph, 'combo', 'combo1', 1)).toEqual(['combo1', 'combo1-6', '6']); expect(getElementNthDegreeIds(graph, 'node', '1', 1, 'in')).toEqual(['1']); expect(getElementNthDegreeIds(graph, 'node', '1', 1, 'out')).toEqual(['1', '1-2', '1-3', '2', '3']); }); it('getNodeNthDegreeIds', () => { expect(getNodeNthDegreeIds(graph, '1', 0)).toEqual(['1']); expect(getNodeNthDegreeIds(graph, '1', 1)).toEqual(['1', '1-2', '1-3', '2', '3']); expect(getNodeNthDegreeIds(graph, '1', 2)).toEqual(['1', '1-2', '1-3', '2', '2-4', '3', '3-5', '4', '5']); expect(getNodeNthDegreeIds(graph, '1', 1, 'in')).toEqual(['1']); expect(getNodeNthDegreeIds(graph, '1', 1, 'out')).toEqual(['1', '1-2', '1-3', '2', '3']); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/router.spec.ts ================================================ import { Rect } from '@/src'; import type { Point } from '@/src/types'; import { freeJoin, getBBoxSize, getDirection, insideNode, nodeToNode, nodeToPoint, orth, pointToNode, pointToPoint, } from '@/src/utils/router/orth'; import { estimateCost, getNearestPoint } from '@/src/utils/router/shortest-path'; import { manhattanDistance } from '@/src/utils/vector'; import { AABB } from '@antv/g'; describe('router', () => { describe('orth', () => { const bbox = new AABB(); bbox.setMinMax([0, 0, 0], [1, 2, 0]); it('orth', () => { const sourceNode = new Rect({ style: { size: 15, x: 5, y: 5 } }); const sourcePoint: Point = [5, 5]; const targetNode = new Rect({ style: { size: 15, x: 25, y: 25 } }); const targetPoint: Point = [25, 25]; const controlPoints: Point[] = [[5, 10]]; expect(orth(sourcePoint, targetPoint, sourceNode, targetNode, controlPoints, { padding: 10 })).toEqual([ [-12.51, 5], [-12.51, 22.51], [5, 22.51], [5, 10], [5, 7.5], [25, 7.5], ]); const sourceNode2 = new Rect({ style: { size: 25, x: 100, y: 100 } }); const targetNode2 = new Rect({ style: { size: 25, x: 150, y: 150 } }); const controlPoints2: Point[] = [[160, 100]]; expect(orth([100, 100], [150, 150], sourceNode2, targetNode2, controlPoints2, { padding: 10 })).toEqual([ [160, 100], [172.5, 100], [172.5, 150], ]); expect(orth([100, 100], [150, 150], sourceNode2, targetNode2, [], { padding: 10 })).toEqual([[100, 150]]); const sourceNode3 = new Rect({ style: { size: 10, x: 5, y: 0 } }); const targetNode3 = new Rect({ style: { size: 10, x: 20, y: 25 } }); expect(orth([5, 5], [20, 20], sourceNode3, targetNode3, [], { padding: 0 })).toEqual([ [5, 5], [5, 20], [20, 20], ]); }); it('getDirection', () => { expect(getDirection([0, 0], [0, 1])).toEqual('S'); expect(getDirection([0, 0], [1, 0])).toEqual('E'); expect(getDirection([0, 0], [0, -1])).toEqual('N'); expect(getDirection([0, 0], [-1, 0])).toEqual('W'); expect(getDirection([0, 0], [1, 1])).toEqual(null); }); it('getBBoxSize', () => { expect(getBBoxSize(bbox, 'N')).toEqual(2); expect(getBBoxSize(bbox, 'S')).toEqual(2); expect(getBBoxSize(bbox, 'E')).toEqual(1); expect(getBBoxSize(bbox, 'W')).toEqual(1); }); it('pointToPoint', () => { expect(pointToPoint([0, 0], [1, 1], 'S').points).toEqual([[0, 1]]); expect(pointToPoint([0, 0], [1, 1], 'S').direction).toEqual('E'); expect(pointToPoint([0, 0], [1, 1], 'E').points).toEqual([[1, 0]]); expect(pointToPoint([0, 0], [1, 1], 'E').direction).toEqual('S'); }); it('nodeToPoint', () => { const sourceBBox = new AABB(); sourceBBox.setMinMax([0, 0, 0], [10, 10, 0]); expect(nodeToPoint([5, 5], [20, 20], sourceBBox).points).toEqual([[5, 20]]); expect(nodeToPoint([5, 5], [20, 20], sourceBBox).direction).toEqual('E'); expect(nodeToPoint([10, 5], [20, 20], sourceBBox).points).toEqual([[20, 5]]); expect(nodeToPoint([10, 5], [20, 20], sourceBBox).direction).toEqual('S'); }); it('pointToNode', () => { const targetBBox = new AABB(); targetBBox.setMinMax([10, 10, 0], [25, 25, 0]); expect(pointToNode([10, 5], [20, 25], targetBBox, 'N').points).toEqual([[20, 5]]); }); it('nodeToNode', () => { const sourcePoint: Point = [5, 5, 0]; const targetPoint: Point = [7, 7, 0]; const sourceBBox = new AABB(); sourceBBox.setMinMax([0, 0, 0], [10, 10, 0]); const targetBBox = new AABB(); targetBBox.setMinMax([2, 2, 0], [12, 12, 0]); expect(nodeToNode(sourcePoint, targetPoint, sourceBBox, targetBBox).points).toEqual([ [6, 5], [6, 2], ]); }); it('insideNode', () => { const sourcePoint: Point = [5, 5]; const targetPoint: Point = [7, 7]; const sourceBBox = new AABB(); sourceBBox.setMinMax([0, 0, 0], [10, 10, 0]); const targetBBox = new AABB(); targetBBox.setMinMax([0, 0, 0], [12, 12, 0]); expect(insideNode(sourcePoint, targetPoint, sourceBBox, targetBBox).points).toEqual([ [-0.01, 5], [-0.01, 7], ]); expect(insideNode(sourcePoint, targetPoint, sourceBBox, targetBBox, 'S').points).toEqual([ [5, 12.01], [7, 12.01], ]); }); it('freeJoin', () => { const sourceBBox = new AABB(); sourceBBox.setMinMax([0, 0, 0], [10, 10, 0]); const sourcePoint: Point = [5, 10]; const targetPoint: Point = [20, 5]; expect(freeJoin(sourcePoint, targetPoint, sourceBBox)).toEqual([20, 10]); }); }); describe('shortestPath', () => { it('estimateCost', () => { expect( estimateCost( [0, 0], [ [1, 0], [0, 1], [1, 1], ], manhattanDistance, ), ).toEqual(1); }); it('getNearestPoint', () => { expect( getNearestPoint( [ [0, 0], [1, 0], [0, 1], [1, 1], ], [2, 0], manhattanDistance, ), ).toEqual([1, 0]); }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/scale.spec.ts ================================================ import { linear, log, pow, sqrt } from '@/src/utils/scale'; describe('scale', () => { it('linear', () => { expect(linear(0.2, [0, 1], [0, 0])).toEqual(0); expect(linear(0, [0, 1], [0, 100])).toEqual(0); expect(linear(0.5, [0, 1], [0, 100])).toEqual(50); expect(linear(1, [0, 1], [0, 100])).toEqual(100); }); it('log', () => { expect(log(0, [0, 1], [0, 100])).toEqual(0); expect(log(0.5, [0, 1], [0, 100])).toEqual((Math.log(1.5) / Math.log(2)) * 100); expect(log(1, [0, 1], [0, 100])).toEqual(100); }); it('pow', () => { expect(pow(0, [0, 1], [0, 100], 2)).toEqual(0); expect(pow(0.5, [0, 1], [0, 100])).toEqual(25); expect(pow(0.5, [0, 1], [0, 100], 2)).toEqual(25); expect(pow(1, [0, 1], [0, 100], 2)).toEqual(100); }); it('sqrt', () => { expect(sqrt(0, [0, 1], [0, 100])).toEqual(0); expect(sqrt(0.25, [0, 1], [0, 100])).toEqual(50); expect(sqrt(1, [0, 1], [0, 100])).toEqual(100); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/shape.spec.ts ================================================ import { getAncestorShapes, getDescendantShapes } from '@/src/utils/shape'; import { Circle, Group, Line, Rect } from '@antv/g'; describe('shape', () => { it('getDescendantShapes', () => { const group = new Group(); const rect = group.appendChild(new Rect()); const circleGroup = group.appendChild(new Group()); const circle = circleGroup.appendChild(new Circle()); const line = circleGroup.appendChild(new Line()); expect(getDescendantShapes(group)).toEqual([rect, circleGroup, circle, line]); expect(getDescendantShapes(circleGroup)).toEqual([circle, line]); expect(getDescendantShapes(circle)).toEqual([]); }); it('getDescendantShapes with marker', () => { const marker = new Rect({ style: { width: 10, height: 10, }, }); const line = new Line({ style: { x1: 0, y1: 0, x2: 100, y2: 100, markerEnd: marker, }, }); expect(getDescendantShapes(line)[0]).not.toBe(marker); expect(getDescendantShapes(line)[0]).toBe(line.parsedStyle.markerEnd); }); it('getAncestorShapes', () => { const group = new Group(); const rect = group.appendChild(new Rect()); const circleGroup = group.appendChild(new Group()); const circle = circleGroup.appendChild(new Circle()); expect(getAncestorShapes(group)).toEqual([]); expect(getAncestorShapes(rect)).toEqual([group]); expect(getAncestorShapes(circle)).toEqual([circleGroup, group]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/shortcut.spec.ts ================================================ import { CommonEvent } from '@/src'; import { Shortcut } from '@/src/utils/shortcut'; import EventEmitter from '@antv/event-emitter'; describe('shortcut', () => { const emitter = new EventEmitter(); const shortcut = new Shortcut(emitter); afterAll(() => { shortcut.destroy(); emitter.off(); }); it('bind and unbind', () => { const controlEqual = jest.fn(); const controlMinus = jest.fn(); shortcut.bind(['Control', '='], controlEqual); shortcut.bind(['Control', '-'], controlMinus); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '=' }); emitter.emit(CommonEvent.KEY_UP, { key: 'Control' }); emitter.emit(CommonEvent.KEY_UP, { key: '=' }); expect(controlEqual).toHaveBeenCalledTimes(1); expect(controlMinus).toHaveBeenCalledTimes(0); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '-' }); emitter.emit(CommonEvent.KEY_UP, { key: 'Control' }); emitter.emit(CommonEvent.KEY_UP, { key: '-' }); expect(controlEqual).toHaveBeenCalledTimes(1); expect(controlMinus).toHaveBeenCalledTimes(1); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '=' }); emitter.emit(CommonEvent.KEY_UP, { key: '=' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '-' }); emitter.emit(CommonEvent.KEY_UP, { key: '-' }); emitter.emit(CommonEvent.KEY_UP, { key: 'Control' }); expect(controlEqual).toHaveBeenCalledTimes(2); expect(controlMinus).toHaveBeenCalledTimes(2); // Test modifier key works at window level too window.dispatchEvent(new KeyboardEvent(CommonEvent.KEY_DOWN, { key: 'Control' })); emitter.emit(CommonEvent.KEY_DOWN, { key: '=' }); emitter.emit(CommonEvent.KEY_UP, { key: '=' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '-' }); emitter.emit(CommonEvent.KEY_UP, { key: '-' }); window.dispatchEvent(new KeyboardEvent(CommonEvent.KEY_UP, { key: 'Control' })); expect(controlEqual).toHaveBeenCalledTimes(3); expect(controlMinus).toHaveBeenCalledTimes(3); shortcut.unbind(['Control', '='], controlEqual); shortcut.unbind(['Control', '-']); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '=' }); emitter.emit(CommonEvent.KEY_UP, { key: '=' }); emitter.emit(CommonEvent.KEY_DOWN, { key: '-' }); emitter.emit(CommonEvent.KEY_UP, { key: '-' }); emitter.emit(CommonEvent.KEY_UP, { key: 'Control' }); expect(controlEqual).toHaveBeenCalledTimes(3); expect(controlMinus).toHaveBeenCalledTimes(3); }); it('wheel', () => { const wheel = jest.fn(); shortcut.bind(['Control', 'wheel'], wheel); emitter.emit(CommonEvent.WHEEL, { deltaX: 0, deltaY: 10 }); expect(wheel).toHaveBeenCalledTimes(0); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.WHEEL, { deltaX: 0, deltaY: 10 }); expect(wheel).toHaveBeenCalledTimes(1); expect(wheel.mock.calls[0][0].deltaY).toBe(10); emitter.emit(CommonEvent.KEY_UP, { key: 'Control' }); }); it('drag', () => { const drag = jest.fn(); shortcut.bind(['drag'], drag); emitter.emit(CommonEvent.DRAG, { deltaX: 10, deltaY: 0 }); expect(drag).toHaveBeenCalledTimes(1); expect(drag.mock.calls[0][0].deltaX).toBe(10); expect(drag.mock.calls[0][0].deltaY).toBe(0); drag.mockClear(); // shift drag emitter.emit(CommonEvent.KEY_DOWN, { key: 'Shift' }); emitter.emit(CommonEvent.DRAG, { deltaX: 10, deltaY: 0 }); emitter.emit(CommonEvent.KEY_UP, { key: 'Shift' }); expect(drag).toHaveBeenCalledTimes(0); shortcut.unbindAll(); shortcut.bind(['Shift', 'drag'], drag); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Shift' }); emitter.emit(CommonEvent.DRAG, { deltaX: 10, deltaY: 0 }); emitter.emit(CommonEvent.KEY_UP, { key: 'Shift' }); expect(drag).toHaveBeenCalledTimes(1); expect(drag.mock.calls[0][0].deltaX).toBe(10); expect(drag.mock.calls[0][0].deltaY).toBe(0); }); it('focus', () => { const wheel = jest.fn(); shortcut.bind(['Control', 'wheel'], wheel); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.WHEEL, { deltaX: 0, deltaY: 10 }); expect(wheel).toHaveBeenCalledTimes(1); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); window.dispatchEvent(new FocusEvent('focus')); // @ts-expect-error private property expect(shortcut.recordKey.size).toBe(0); emitter.emit(CommonEvent.KEY_DOWN, { key: 'Control' }); emitter.emit(CommonEvent.WHEEL, { deltaX: 0, deltaY: 10 }); expect(wheel).toHaveBeenCalledTimes(2); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/size.spec.ts ================================================ import { parseSize } from '@/src/utils/size'; describe('size', () => { it('parseSize', () => { expect(parseSize()).toEqual([0, 0, 0]); expect(parseSize(10)).toEqual([10, 10, 10]); expect(parseSize([10, 20])).toEqual([10, 20, 10]); expect(parseSize([10, 20, 30])).toEqual([10, 20, 30]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/state.spec.ts ================================================ import { statesOf } from '@/src/utils/state'; describe('state', () => { it('statesOf', () => { expect( statesOf({ id: 'node-1', }), ).toEqual([]); expect( statesOf({ id: 'node-1', states: ['selected'], }), ).toEqual(['selected']); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/style.spec.ts ================================================ import { computeElementCallbackStyle, getSubShapeStyle, mergeOptions } from '@/src/utils/style'; describe('style', () => { it('computeElementCallbackStyle', () => { const datum = { id: 'node-1', data: { value: 100, }, type: 'A', style: { fill: 'pink', lineWidth: 5, }, }; const style = { stroke: 'blue', size: (data: any) => data.data.value / 2, fill: (data: any) => (data.data.type === 'B' ? 'green' : 'red'), }; const computedStyle = computeElementCallbackStyle(style, { datum, graph: {} as any }); expect(computedStyle).toEqual({ stroke: 'blue', size: 50, fill: 'red', }); const style1 = (data: any) => { return { fill: data.data.type === 'B' ? 'green' : 'red', }; }; expect(computeElementCallbackStyle(style1, { datum, graph: {} as any })).toEqual({ fill: 'red', }); }); it('mergeOptions', () => { expect( mergeOptions( { style: { a: 1, b: [1, 2], c: { d: 1 } }, id: '1' }, { style: { a: 2, b: [2, 3], c: { f: 1 } }, id: '2' }, ), ).toEqual({ style: { a: 2, b: [2, 3], c: { f: 1 } }, id: '2' }); }); it('getSubShapeStyle', () => { const style = { x: 100, y: 100, class: 'node', transform: [['translate', 100, 100]], zIndex: 100, fill: 'pink', }; expect(getSubShapeStyle(style)).toEqual({ fill: 'pink' }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/symbol.spec.ts ================================================ import { circle, diamond, rect, simple, triangle, triangleRect, vee } from '@/src/utils/symbol'; describe('Symbol Functions', () => { describe('circle', () => { it('should return the correct path for a circle', () => { const path = circle(10, 10); expect(path).toEqual([['M', -5, 0], ['A', 5, 5, 0, 1, 0, 5, 0], ['A', 5, 5, 0, 1, 0, -5, 0], ['Z']]); }); }); describe('triangle', () => { it('should return the correct path for a triangle', () => { const path = triangle(10, 10); expect(path).toEqual([['M', -5, 0], ['L', 5, -5], ['L', 5, 5], ['Z']]); }); }); describe('diamond', () => { it('should return the correct path for a diamond', () => { const path = diamond(10, 10); expect(path).toEqual([['M', -5, 0], ['L', 0, -5], ['L', 5, 0], ['L', 0, 5], ['Z']]); }); }); describe('vee', () => { it('should return the correct path for a vee', () => { const path = vee(10, 10); expect(path).toEqual([['M', -5, 0], ['L', 5, -5], ['L', 3, 0], ['L', 5, 5], ['Z']]); }); }); describe('rect', () => { it('should return the correct path for a rectangle', () => { const path = rect(10, 10); expect(path).toEqual([['M', -5, -5], ['L', 5, -5], ['L', 5, 5], ['L', -5, 5], ['Z']]); }); }); describe('triangleRect', () => { it('should return the correct path for a triangleRect', () => { const path = triangleRect(10, 10); expect(path).toEqual([ ['M', -5, 0], ['L', 0, -5], ['L', 0, 5], ['Z'], ['M', 3.571428571428571, -5], ['L', 5, -5], ['L', 5, 5], ['L', 3.571428571428571, 5], ['Z'], ]); }); }); describe('simple', () => { it('should return the correct path for a simple shape', () => { const path = simple(10, 10); expect(path).toEqual([ ['M', 5, -5], ['L', -5, 0], ['L', 5, 0], ['L', -5, 0], ['L', 5, 5], ]); }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/text.spec.ts ================================================ import { getWordWrapWidthWithBase } from '@/src/utils/text'; describe('text', () => { it('getWordWrapWidthWithBase', () => { expect(getWordWrapWidthWithBase(10, 100)).toBe(100); expect(getWordWrapWidthWithBase(10, '100%')).toBe(10); expect(getWordWrapWidthWithBase(10, '100!')).toBe(20); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/theme.spec.ts ================================================ import { register } from '@/src/registry/register'; import { themeOf } from '@/src/utils/theme'; describe('theme', () => { it('themeOf', () => { expect(themeOf({})).toEqual({}); expect(themeOf({ theme: 'null' })).toEqual({}); const theme = { node: {} }; register('theme', 'light', theme); expect(themeOf({ theme: 'light' })).toBe(theme); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/traverse.spec.ts ================================================ import type { HierarchyStructure } from '@/src/utils/traverse'; import { bfs, dfs } from '@/src/utils/traverse'; const tree: HierarchyStructure<{ value: number }> = { value: 1, children: [ { value: 2, children: [{ value: 3, children: [{ value: 4 }] }] }, { value: 5, children: [{ value: 6 }] }, ], }; describe('traverse', () => { it('dfs TB', () => { const result: number[] = []; dfs( tree, (node) => { result.push(node.value); }, (node) => node.children, 'TB', ); expect(result).toEqual([1, 2, 3, 4, 5, 6]); }); it('dfs BT', () => { const result: number[] = []; dfs( tree, (node) => { result.push(node.value); }, (node) => node.children, 'BT', ); expect(result).toEqual([4, 3, 2, 6, 5, 1]); }); it('validate depth TB', () => { const result: Record = {}; dfs( tree, (node, depth) => { result[node.value] = depth; }, (node) => node.children, 'TB', ); expect(result).toEqual({ 1: 0, 2: 1, 3: 2, 4: 3, 5: 1, 6: 2 }); }); it('validate depth BT', () => { const result: Record = {}; dfs( tree, (node, depth) => { result[node.value] = depth; }, (node) => node.children, 'BT', ); expect(result).toEqual({ 1: 0, 2: 1, 3: 2, 4: 3, 5: 1, 6: 2 }); }); it('bfs', () => { const result: Record = {}; bfs( tree, (node, depth) => { result[node.value] = depth; }, (node) => node.children, ); expect(result).toEqual({ 1: 0, 2: 1, 5: 1, 3: 2, 6: 2, 4: 3 }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/tree.spec.ts ================================================ import { treeToGraphData } from '@/src/utils/tree'; describe('tree', () => { it('treeToGraphData', () => { expect( treeToGraphData({ id: 'root', children: [{ id: 'child' }], }), ).toEqual({ nodes: [ { id: 'root', depth: 0, children: ['child'], }, { id: 'child', depth: 1 }, ], edges: [{ source: 'root', target: 'child' }], }); expect( treeToGraphData({ id: 'root', style: { fill: 'red' }, data: { value: 10 }, children: [{ id: 'child', style: { fill: 'green' }, data: { value: 1 } }], }), ).toEqual({ nodes: [ { id: 'root', children: ['child'], depth: 0, style: { fill: 'red' }, data: { value: 10 }, }, { id: 'child', depth: 1, style: { fill: 'green' }, data: { value: 1 } }, ], edges: [{ source: 'root', target: 'child' }], }); expect( treeToGraphData( { id: 'root', style: { fill: 'red' }, data: { value: 10 }, children: [{ id: 'child', style: { fill: 'green' }, data: { value: 1 } }], }, { getEdgeData: (source, target) => ({ source: source.id, target: target.id, data: { weight: source.data.value + target.data.value }, }), }, ), ).toEqual({ nodes: [ { id: 'root', children: ['child'], depth: 0, style: { fill: 'red' }, data: { value: 10 }, }, { id: 'child', depth: 1, style: { fill: 'green' }, data: { value: 1 } }, ], edges: [{ source: 'root', target: 'child', data: { weight: 11 } }], }); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/vector.spec.ts ================================================ import { add, angle, cross, distance, divide, dot, exactEquals, manhattanDistance, mod, multiply, normalize, perpendicular, rad, rotate, scale, subtract, toVector2, toVector3, } from '@/src/utils/vector'; describe('Vector Functions', () => { it('add', () => { expect(add([0, 1], [2, 3])).toEqual([2, 4]); expect(add([0, 1, 3], [2, 3, 4])).toEqual([2, 4, 7]); expect(add([0, 1], [2, 3, 0])).toEqual([2, 4]); }); it('subtract', () => { expect(subtract([0, 1], [2, 3])).toEqual([-2, -2]); expect(subtract([0, 1, 3], [2, 3, 4])).toEqual([-2, -2, -1]); expect(subtract([0, 1], [2, 3, 0])).toEqual([-2, -2]); }); it('multiply', () => { expect(multiply([0, 1], [2, 3])).toEqual([0, 3]); expect(multiply([0, 1, 3], [2, 3, 4])).toEqual([0, 3, 12]); expect(multiply([0, 1], [2, 3, 0])).toEqual([0, 3]); expect(multiply([0, 1], 2)).toEqual([0, 2]); expect(multiply([0, 1, 3], 2)).toEqual([0, 2, 6]); }); it('divide', () => { expect(divide([0, 1], [2, 3])).toEqual([0, 1 / 3]); expect(divide([0, 1, 3], [2, 3, 4])).toEqual([0, 1 / 3, 3 / 4]); expect(divide([0, 1], 2)).toEqual([0, 0.5]); expect(divide([0, 1, 3], 2)).toEqual([0, 0.5, 1.5]); }); it('dot', () => { expect(dot([0, 1], [2, 3])).toEqual(3); expect(dot([0, 1, 0], [2, 3])).toEqual(3); expect(dot([0, 1, 3], [2, 3, 4])).toEqual(15); }); it('cross', () => { expect(cross([0, 1], [-1, 0])).toEqual([0, -0, 1]); expect(cross([0, 1], [2, 3])).toEqual([0, 0, -2]); expect(cross([0, 1, 3], [2, 3, 4])).toEqual([-5, 6, -2]); }); it('scale', () => { expect(scale([0, 1], 2)).toEqual([0, 2]); expect(scale([0, 1, 3], 2)).toEqual([0, 2, 6]); }); it('distance', () => { expect(distance([0, 0], [3, 4])).toEqual(5); expect(distance([0, 0, 0], [3, 4])).toEqual(5); expect(distance([0, 0, 0], [3, 4, 0])).toEqual(5); }); it('manhattanDistance', () => { expect(manhattanDistance([0, 0], [3, 4])).toEqual(7); expect(manhattanDistance([0, 0, 0], [3, 4])).toEqual(7); expect(manhattanDistance([0, 0, 0], [3, 4, 0])).toEqual(7); }); it('normalize', () => { expect(normalize([3, 4])).toEqual([0.6, 0.8]); expect(normalize([3, 4, 0])).toEqual([0.6, 0.8, 0]); }); it('angle', () => { expect(angle([1, 0], [0, 1])).toEqual(Math.PI / 2); expect(angle([1, 0, 0], [0, 1])).toEqual(Math.PI / 2); expect(angle([1, 0], [-1, 0], true)).toEqual(Math.PI); expect(angle([1, 0], [0, -1], true)).toEqual((Math.PI * 3) / 2); }); it('exactEquals', () => { expect(exactEquals([1, 2], [1, 2])).toEqual(true); expect(exactEquals([1, 2], [1, 3])).toEqual(false); expect(exactEquals([1, 2, 3], [1, 2, 3])).toEqual(true); expect(exactEquals([1, 2, 3], [1, 2, 4])).toEqual(false); }); it('perpendicular', () => { expect(perpendicular([1, 1])).toEqual([-1, 1]); expect(perpendicular([1, 1], false)).toEqual([1, -1]); }); it('mode', () => { expect(mod([1, 2], 2)).toEqual([1, 0]); expect(mod([1, 2, 3], 2)).toEqual([1, 0, 1]); }); it('toVector2', () => { expect(toVector2([1, 2, 3])).toEqual([1, 2]); expect(toVector2([1, 2])).toEqual([1, 2]); }); it('toVector3', () => { expect(toVector3([1, 2, 3])).toEqual([1, 2, 3]); expect(toVector3([1, 2])).toEqual([1, 2, 0]); }); it('rad', () => { expect(rad([1, 0])).toEqual(0); expect(rad([0, 1])).toEqual(Math.PI / 2); }); it('rotate', () => { expect(rotate([10, 10], 30)).toBeCloseTo([3.66, 13.66]); expect(rotate([10, 20], 90)).toBeCloseTo([-20, 10]); expect(rotate([10, 20], 180)).toBeCloseTo([-10, -20]); expect(rotate([10, 20], 270)).toBeCloseTo([20, -10]); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/visibility.spec.ts ================================================ import { BaseShape } from '@/src'; import { setVisibility } from '@/src/utils/visibility'; import { Circle } from '@antv/g'; class Shape extends BaseShape<{ visibility: 'visible' | 'hidden' }> { render() { this.upsert('visibleShape', Circle, { r: 10 }, this); this.upsert('hiddenShape', Circle, { r: 10, visibility: 'hidden' }, this); } } describe('visibility', () => { it('shape visibility', () => { const shape = new Shape({}); const vShape = shape.getShape('visibleShape'); const hShape = shape.getShape('hiddenShape'); expect(shape.style.visibility).toBe(undefined); expect(vShape.style.visibility).toBe(undefined); expect(hShape.style.visibility).toBe('hidden'); setVisibility(shape, 'hidden'); expect(shape.style.visibility).toBe('hidden'); expect(vShape.style.visibility).toBe('hidden'); expect(hShape.style.visibility).toBe('hidden'); setVisibility(shape, 'visible'); expect(shape.style.visibility).toBe('visible'); expect(vShape.style.visibility).toBe('visible'); expect(hShape.style.visibility).toBe('visible'); }); it('default is hidden', () => { const shape = new Shape({ style: { visibility: 'hidden' } }); const vShape = shape.getShape('visibleShape'); const hShape = shape.getShape('hiddenShape'); expect(shape.style.visibility).toBe('hidden'); expect(vShape.style.visibility).toBe('hidden'); expect(hShape.style.visibility).toBe('hidden'); setVisibility(shape, 'visible'); expect(shape.style.visibility).toBe('visible'); expect(vShape.style.visibility).toBe('visible'); expect(hShape.style.visibility).toBe('visible'); }); it('setVisibility', () => { /** * d: default * v: visible * h: hidden * d * / | \ * d v h * /|\ /|\ /|\ * d v h d v h d b h */ const root = new Circle({ id: 'root', style: { r: 10 } }); const l1 = root.appendChild(new Circle({ id: 'l1', style: { r: 10 } })); const l2 = root.appendChild(new Circle({ id: 'l2', style: { r: 10, visibility: 'visible' } })); const l3 = root.appendChild(new Circle({ id: 'l3', style: { r: 10, visibility: 'hidden' } })); const l1_1 = l1.appendChild(new Circle({ id: 'l1_1', style: { r: 10 } })); const l1_2 = l1.appendChild(new Circle({ id: 'l1_2', style: { r: 10, visibility: 'visible' } })); const l1_3 = l1.appendChild(new Circle({ id: 'l1_3', style: { r: 10, visibility: 'hidden' } })); const l2_1 = l2.appendChild(new Circle({ id: 'l2_1', style: { r: 10 } })); const l2_2 = l2.appendChild(new Circle({ id: 'l2_2', style: { r: 10, visibility: 'visible' } })); const l2_3 = l2.appendChild(new Circle({ id: 'l2_3', style: { r: 10, visibility: 'hidden' } })); const l3_1 = l3.appendChild(new Circle({ id: 'l3_1', style: { r: 10 } })); const l3_2 = l3.appendChild(new Circle({ id: 'l3_2', style: { r: 10, visibility: 'visible' } })); const l3_3 = l3.appendChild(new Circle({ id: 'l3_3', style: { r: 10, visibility: 'hidden' } })); const assertDefault = () => { expect(root.style.visibility).toBe(undefined); expect(l1.style.visibility).toBe(undefined); expect(l2.style.visibility).toBe('visible'); expect(l3.style.visibility).toBe('hidden'); expect(l1_1.style.visibility).toBe(undefined); expect(l1_2.style.visibility).toBe('visible'); expect(l1_3.style.visibility).toBe('hidden'); expect(l2_1.style.visibility).toBe(undefined); expect(l2_2.style.visibility).toBe('visible'); expect(l2_3.style.visibility).toBe('hidden'); expect(l3_1.style.visibility).toBe(undefined); expect(l3_2.style.visibility).toBe('visible'); expect(l3_3.style.visibility).toBe('hidden'); }; const assertHidden = () => { expect(root.style.visibility).toBe('hidden'); expect(l1.style.visibility).toBe('hidden'); expect(l2.style.visibility).toBe('hidden'); expect(l3.style.visibility).toBe('hidden'); expect(l1_1.style.visibility).toBe('hidden'); expect(l1_2.style.visibility).toBe('hidden'); expect(l1_3.style.visibility).toBe('hidden'); expect(l2_1.style.visibility).toBe('hidden'); expect(l2_2.style.visibility).toBe('hidden'); expect(l2_3.style.visibility).toBe('hidden'); expect(l3_1.style.visibility).toBe('hidden'); expect(l3_2.style.visibility).toBe('hidden'); expect(l3_3.style.visibility).toBe('hidden'); }; const assertVisible = () => { expect(root.style.visibility).toBe('visible'); expect(l1.style.visibility).toBe('visible'); expect(l2.style.visibility).toBe('visible'); expect(l3.style.visibility).toBe('visible'); expect(l1_1.style.visibility).toBe('visible'); expect(l1_2.style.visibility).toBe('visible'); expect(l1_3.style.visibility).toBe('visible'); expect(l2_1.style.visibility).toBe('visible'); expect(l2_2.style.visibility).toBe('visible'); expect(l2_3.style.visibility).toBe('visible'); expect(l3_1.style.visibility).toBe('visible'); expect(l3_2.style.visibility).toBe('visible'); expect(l3_3.style.visibility).toBe('visible'); }; assertDefault(); setVisibility(root, 'hidden'); assertHidden(); setVisibility(root, 'visible'); assertVisible(); setVisibility(root, 'hidden'); assertHidden(); }); it('setVisibility 2', () => { const root = new Circle({ id: 'root', style: { r: 10 } }); const level1 = root.appendChild(new Circle({ id: 'level1', style: { r: 10 } })); const level2 = level1.appendChild(new Circle({ id: 'level2', style: { r: 10 } })); expect(root.style.visibility).toBe(undefined); expect(level1.style.visibility).toBe(undefined); expect(level2.style.visibility).toBe(undefined); setVisibility(level1, 'hidden'); expect(root.style.visibility).toBe(undefined); expect(level1.style.visibility).toBe('hidden'); expect(level2.style.visibility).toBe('hidden'); setVisibility(level1, 'visible'); expect(root.style.visibility).toBe(undefined); expect(level1.style.visibility).toBe('visible'); expect(level2.style.visibility).toBe('visible'); setVisibility(root, 'hidden'); expect(root.style.visibility).toBe('hidden'); expect(level1.style.visibility).toBe('hidden'); expect(level2.style.visibility).toBe('hidden'); setVisibility(root, 'visible'); expect(root.style.visibility).toBe('visible'); expect(level1.style.visibility).toBe('visible'); expect(level2.style.visibility).toBe('visible'); setVisibility(level1, 'hidden'); expect(root.style.visibility).toBe('visible'); expect(level1.style.visibility).toBe('hidden'); expect(level2.style.visibility).toBe('hidden'); setVisibility(root, 'hidden'); expect(root.style.visibility).toBe('hidden'); expect(level1.style.visibility).toBe('hidden'); expect(level2.style.visibility).toBe('hidden'); setVisibility(root, 'visible'); expect(root.style.visibility).toBe('visible'); expect(level1.style.visibility).toBe('visible'); expect(level2.style.visibility).toBe('visible'); setVisibility(level1, 'visible'); expect(root.style.visibility).toBe('visible'); expect(level1.style.visibility).toBe('visible'); expect(level2.style.visibility).toBe('visible'); }); }); ================================================ FILE: packages/g6/__tests__/unit/utils/z-index.spec.ts ================================================ import { getZIndexOf } from '@/src/utils/z-index'; describe('z-index', () => { it('getZIndexOf', () => { expect(getZIndexOf({ id: 'node-1' })).toBe(0); expect(getZIndexOf({ id: 'node-1', style: {} })).toBe(0); expect(getZIndexOf({ id: 'node-1', style: { zIndex: 1 } })).toBe(1); }); }); ================================================ FILE: packages/g6/__tests__/unit/version.spec.ts ================================================ import pkg from '@/package.json'; import { version } from '@/src'; describe('version', () => { it('version', () => { expect(version).toBe(pkg.version); }); }); ================================================ FILE: packages/g6/__tests__/utils/canvas.ts ================================================ import type { Graph } from '@/src'; import { CustomEvent } from '@antv/g'; export function dispatchCanvasEvent(graph: Graph, type: string, data?: any) { // @ts-expect-error private method const canvas = graph.context.canvas.document; canvas.dispatchEvent(new CustomEvent(type, data)); } ================================================ FILE: packages/g6/__tests__/utils/create.ts ================================================ 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) { switch (renderer) { case 'svg': return new SVGRenderer(); case 'webgl': return new WebGLRenderer(); case 'canvas': return new CanvasRenderer(); default: return new SVGRenderer(); } } /** * Create graph canvas with config. * @param dom - dom * @param width - width * @param height - height * @param renderer - render * @param options - options * @returns instance */ export function createGraphCanvas( dom?: null | HTMLElement, width: number = 500, height: number = 500, renderer: string = 'svg', options?: Partial, ) { const container = dom || document.createElement('div'); resetEntityCounter(); const extraOptions: Record = { ...options, }; if (globalThis.process) { const offscreenNodeCanvas = { getContext: () => context, } as unknown as HTMLCanvasElement; const context = new OffscreenCanvasContext(offscreenNodeCanvas); // 下列参数仅在 node 环境下需要传入 / These parameters only need to be passed in the node environment Object.assign(extraOptions, { document: container.ownerDocument, offscreenCanvas: offscreenNodeCanvas, }); } const offscreenNodeCanvas = { getContext: () => context, } as unknown as HTMLCanvasElement; const context = new OffscreenCanvasContext(offscreenNodeCanvas); return new Canvas({ container, width, height, renderer: () => getRenderer(renderer), ...extraOptions, }); } /** * 一个会连接到圆心的节点 * * A node that will connect to the center */ class CenterConnectCircle extends Circle { public getIntersectPoint(): Point { const bounds = this.getShape('key').getBounds(); return bounds.center; } } export function createEdgeNode(point: Point): Node { return new CenterConnectCircle({ style: { x: point[0], y: point[1], }, }); } export async function createDemoGraph(demo: TestCase, context?: Partial): Promise { const container = createGraphCanvas(document.getElementById('container')); return demo({ animation: false, container, theme: 'light', ...context }); } export function createGraph(options: GraphOptions) { const container = createGraphCanvas(document.getElementById('container')); return new Graph({ container, animation: false, theme: 'light', ...options, }); } ================================================ FILE: packages/g6/__tests__/utils/dir.ts ================================================ import path from 'path'; /** * 获取快照目录 * * Get snapshot directory * @param dir - __filename * @param detail - 快照详情 | snapshot detail * @returns 快照目录 | snapshot directory */ export function getSnapshotDir(dir: string, detail: string = 'default'): [string, string] { const root = process.cwd(); const relativeDir = dir.replace(root, ''); const relativeDirSlices = relativeDir.split(path.sep); const testsPartIdx = relativeDirSlices.findIndex((slice) => slice === '__tests__'); if (testsPartIdx !== -1) relativeDirSlices[testsPartIdx] = ''; const unitPartIdx = relativeDirSlices.findIndex((slice) => slice === 'unit'); if (unitPartIdx !== -1) relativeDirSlices[unitPartIdx] = ''; const outputDir = path.join(root, '__tests__', 'snapshots', ...relativeDirSlices).replace('.spec.ts', ''); return [outputDir, detail]; } ================================================ FILE: packages/g6/__tests__/utils/dom.ts ================================================ import type { Graph } from '@/src'; import { CommonEvent } from '@/src'; export function emitWheelEvent( graph: Graph, options?: { deltaX: number; deltaY: number; clientX: number; clientY: number }, ) { const dom = graph.getCanvas().getContextService().getDomElement(); dom?.dispatchEvent(new WheelEvent(CommonEvent.WHEEL, options)); } ================================================ FILE: packages/g6/__tests__/utils/index.ts ================================================ export { dispatchCanvasEvent } from './canvas'; export { createDemoGraph, createEdgeNode, createGraph, createGraphCanvas } from './create'; export { createDeterministicRandom } from './random'; export { sleep } from './sleep'; ================================================ FILE: packages/g6/__tests__/utils/offscreen-canvas-context.ts ================================================ // Computed as round(measureText(text).width * 10) at 10px system-ui. For // characters that are not represented in this map, we’d ideally want to use a // weighted average of what we expect to see. But since we don’t really know // what that is, using “e” seems reasonable. const defaultWidthMap: Record = { a: 56, b: 63, c: 57, d: 63, e: 58, f: 37, g: 62, h: 60, i: 26, j: 26, k: 55, l: 26, m: 88, n: 60, o: 60, p: 62, q: 62, r: 39, s: 54, t: 38, u: 60, v: 55, w: 79, x: 54, y: 55, z: 55, A: 69, B: 67, C: 73, D: 74, E: 61, F: 58, G: 76, H: 75, I: 28, J: 55, K: 67, L: 58, M: 89, N: 75, O: 78, P: 65, Q: 78, R: 67, S: 65, T: 65, U: 75, V: 69, W: 98, X: 69, Y: 67, Z: 67, 0: 64, 1: 48, 2: 62, 3: 64, 4: 66, 5: 63, 6: 65, 7: 58, 8: 65, 9: 65, ' ': 29, '!': 32, '"': 49, "'": 31, '(': 39, ')': 39, ',': 31, '-': 48, '.': 31, '/': 32, ':': 31, ';': 31, '?': 52, '‘': 31, '’': 31, '“': 47, '”': 47, '…': 82, }; export function measureText(text: string, fontSize: number) { let sum = 0; for (let i = 0; i < text.length; i++) { sum += ((defaultWidthMap[text[i]] ?? 100) * fontSize) / 100; } return sum; } export class OffscreenCanvasContext { private fontSize!: number; constructor(public canvas: HTMLCanvasElement) {} set font(font: string) { // `${fontStyle} ${fontVariant} ${fontWeight} ${fontSizeString} const [, , , fontSizeString] = font.split(' '); const fontSize = parseFloat(fontSizeString.replace('px', '')); this.fontSize = fontSize; } fillRect() {} fillText() {} getImageData(sx: number, sy: number, sw: number, sh: number) { return { // ignore ascent and descent data: new Uint8ClampedArray(sw * sh * 4).fill(0), }; } measureText(text: string) { return { width: measureText(text, this.fontSize), actualBoundingBoxAscent: 0, actualBoundingBoxDescent: 0, actualBoundingBoxLeft: 0, actualBoundingBoxRight: 0, fontBoundingBoxAscent: 0, fontBoundingBoxDescent: 0, }; } } ================================================ FILE: packages/g6/__tests__/utils/random.ts ================================================ /** * Get random number(0 - 1) generator, with deterministic random number. * @returns Random number */ export function createDeterministicRandom() { let i = 0; return () => { i++; return (Math.E * i) % 1; }; } ================================================ FILE: packages/g6/__tests__/utils/sleep.ts ================================================ export function sleep(n: number) { return new Promise((resolve) => { setTimeout(resolve, n); }); } ================================================ FILE: packages/g6/__tests__/utils/svg-transformer.js ================================================ module.exports = { process() { return { code: `module.exports = {};`, }; }, }; ================================================ FILE: packages/g6/__tests__/utils/to-be-close-to.ts ================================================ type Digital = number | number[]; function closeTo(received: number, expected: number, numDigits: number = 2) { return Math.abs(received - expected) < 10 ** -numDigits / 2; } export function toBeCloseTo(received: Digital, expected: Digital, numDigits?: number) { const isSourceArray = Array.isArray(received); const isTargetArray = Array.isArray(expected); if (isSourceArray !== isTargetArray) return false; if (isSourceArray && isTargetArray) { return received.length === expected.length && received.every((n, i) => closeTo(n, expected[i], numDigits)); } if (!isSourceArray && !isTargetArray) { return closeTo(received, expected, numDigits); } return false; } declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface Matchers { toBeCloseTo(expected: Digital, numDigits?: number): R; } } } expect.extend({ toBeCloseTo: (received: Digital, expected: Digital, numDigits?: number) => { const pass = toBeCloseTo(received, expected, numDigits); return { message: () => `expected: \x1b[32m${received}\n\x1b[31mreceived: ${expected}\x1b[0m`, pass, }; }, }); ================================================ FILE: packages/g6/__tests__/utils/to-match-svg-snapshot.ts ================================================ import type { Graph, IAnimateEvent } from '@/src'; import type { CanvasLayer } from '@/src/types'; import type { Canvas, IAnimation } from '@antv/g'; import chalk from 'chalk'; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { join } from 'path'; import { optimize } from 'svgo'; import { serializeToString } from 'xmlserializer'; import { getSnapshotDir } from './dir'; import { sleep } from './sleep'; const format = (svg: SVGElement) => { return optimize(serializeToString(svg as any), { js2svg: { pretty: true, indent: 2, }, plugins: [ 'cleanupIds', 'cleanupAttrs', 'sortAttrs', 'sortDefsChildren', 'removeUselessDefs', { name: 'convertPathData', params: { floatPrecision: 4, forceAbsolutePath: true, applyTransforms: false, applyTransformsStroked: false, straightCurves: false, convertToQ: false, lineShorthands: false, convertToZ: false, curveSmoothShorthands: false, smartArcRounding: false, removeUseless: false, collapseRepeated: false, utilizeAbsolute: false, negativeExtraSpace: false, }, }, { name: 'convertTransform', params: { floatPrecision: 4, convertToShorts: false, matrixToTransform: false, shortTranslate: false, shortScale: false, shortRotate: false, removeUseless: false, collapseIntoOne: false, }, }, { name: 'cleanupNumericValues', params: { floatPrecision: 4, }, }, ], }).data; }; export type ToMatchSVGSnapshotOptions = { fileFormat?: string; }; // @see https://jestjs.io/docs/26.x/expect#expectextendmatchers export async function toMatchSVGSnapshot( gCanvas: Record, dir: string, name: string, options: ToMatchSVGSnapshotOptions = {}, ): Promise<{ message: () => string; pass: boolean }> { await sleep(300); const { fileFormat = 'svg' } = options; const namePath = join(dir, name); const actualPath = join(dir, `${name}-actual.${fileFormat}`); const expectedPath = join(dir, `${name}.${fileFormat}`); let actual: string = ''; // Clone const svg = (gCanvas.main.getContextService().getDomElement() as unknown as SVGElement).cloneNode(true) as SVGElement; const gRoot = svg.querySelector('#g-root'); // remove css style svg.style.gridArea = ''; Object.entries(gCanvas).forEach(([key, gCanvas]) => { if (key === 'main') return; const dom = (gCanvas.getContextService().getDomElement() as unknown as SVGElement).cloneNode(true) as SVGElement; // @ts-expect-error dom is SVGElement gRoot?.append(...(dom.querySelector('#g-root')?.childNodes || [])); }); actual += svg ? format(svg) : ''; try { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); if (!existsSync(expectedPath)) { if (process.env.CI === 'true') { throw new Error(`Please generate golden image for ${namePath}`); } console.warn(`! generate ${namePath}`); writeFileSync(expectedPath, actual); return { message: () => `generate ${namePath}`, pass: true, }; } else { const expected = readFileSync(expectedPath, { encoding: 'utf8', flag: 'r', }); if (actual === expected) { if (existsSync(actualPath)) unlinkSync(actualPath); return { message: () => `match ${namePath}`, pass: true, }; } // Perverse actual file. if (actual) writeFileSync(actualPath, actual); const formatPath = (p: string) => p.split('/g6/')[1]; return { message: () => `mismatch: \n expected: ${chalk.green(formatPath(expectedPath))}\n received: ${chalk.red(formatPath(actualPath))}`, pass: false, }; } } catch (e) { return { message: () => `${e}`, pass: false, }; } } export async function toMatchSnapshot( graph: Graph, dir: string, detail?: string, options: ToMatchSVGSnapshotOptions = {}, ) { return await toMatchSVGSnapshot(graph.getCanvas().getLayers(), ...getSnapshotDir(dir, detail), options); } export async function toMatchAnimation( graph: Graph, dir: string, frames: number[], operation: () => void | Promise, detail = 'default', options: ToMatchSVGSnapshotOptions = {}, ) { const animationPromise = new Promise((resolve) => { graph.once('beforeanimate', (e) => { resolve(e.animation!); }); }); await operation(); const animation = await animationPromise; animation.pause(); for (const frame of frames) { animation.currentTime = frame; await sleep(32); const result = await toMatchSVGSnapshot( graph.getCanvas().getLayers(), ...getSnapshotDir(dir, `${detail}-${frame}`), options, ); if (!result.pass) { return result; } } return { message: () => `match ${detail}`, pass: true, }; } ================================================ FILE: packages/g6/__tests__/utils/use-snapshot-matchers.ts ================================================ import { ToMatchSVGSnapshotOptions, toMatchAnimation, toMatchSVGSnapshot, toMatchSnapshot, } from './to-match-svg-snapshot'; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface Matchers { toMatchSVGSnapshot(dir: string, name: string, options?: ToMatchSVGSnapshotOptions): Promise; toMatchSnapshot(dir: string, detail?: string, options?: ToMatchSVGSnapshotOptions): Promise; toMatchAnimation( dir: string, frames: number[], operation: () => void | Promise, detail?: string, options?: ToMatchSVGSnapshotOptions, ): Promise; } } } expect.extend({ toMatchSVGSnapshot, toMatchSnapshot, toMatchAnimation, }); ================================================ FILE: packages/g6/jest.config.js ================================================ // Installing third-party modules by tnpm or cnpm will name modules with underscore as prefix. // In this case _{module} is also necessary. const esm = ['internmap', 'd3-*', 'lodash-es', 'chalk'].map((d) => `_${d}|${d}`).join('|'); module.exports = { testTimeout: 30 * 1000, testEnvironment: 'jsdom', setupFilesAfterEnv: ['./__tests__/setup.ts'], transform: { '^.+\\.[tj]s$': [ '@swc/jest', { jsc: { parser: { syntax: 'typescript', decorators: true, }, }, }, ], '^.+\\.svg$': ['/__tests__/utils/svg-transformer.js'], }, collectCoverageFrom: ['src/**/*.ts'], coveragePathIgnorePatterns: [ '/src/elements/nodes/html.ts', '/src/plugins/minimap', '/src/plugins/hull/(?!index\\.ts$).*', ], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], collectCoverage: true, testRegex: '(/__tests__/.*\\.(test|spec))\\.(ts|tsx|js)$', transformIgnorePatterns: [`/node_modules/.pnpm/(?!(${esm}))`], testPathIgnorePatterns: ['/(lib|esm)/__tests__/'], moduleNameMapper: { '^@@/(.*)$': '/__tests__/$1', '^@/(.*)$': '/$1', '@antv/g6': '/src', }, }; ================================================ FILE: packages/g6/package.json ================================================ { "name": "@antv/g6", "version": "5.1.0", "description": "A Graph Visualization Framework in JavaScript", "keywords": [ "antv", "g6", "graph", "graph analysis", "graph editor", "graph visualization", "relational data" ], "homepage": "https://g6.antv.antgroup.com/", "bugs": { "url": "https://github.com/antvis/g6/issues" }, "repository": { "type": "git", "url": "https://github.com/antvis/g6" }, "license": "MIT", "author": "https://github.com/orgs/antvis/people", "main": "lib/index.js", "module": "esm/index.js", "types": "lib/index.d.ts", "files": [ "src", "esm", "lib", "dist", "README" ], "scripts": { "build": "run-p build:*", "build:cjs": "rimraf ./lib && tsc --module commonjs --outDir lib -p tsconfig.build.json", "build:dev:watch": "npm run build:esm -- --watch", "build:esm": "rimraf ./esm && tsc --module ESNext --outDir esm -p tsconfig.build.json", "build:umd": "rimraf ./dist && rollup -c && npm run size", "bundle-vis": "cross-env BUNDLE_VIS=1 npm run build:umd", "ci": "run-s lint type-check 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=1024 --unhandled-rejections=strict ../../node_modules/jest/bin/jest --coverage --logHeapUsage --detectOpenHandles", "lint": "eslint ./src __tests__ --quiet && prettier ./src __tests__ --check", "prepublishOnly": "run-s ci readme", "readme": "cp ../../README.* ./", "size": "limit-size", "start": "rimraf ./lib && tsc --module commonjs --outDir lib --watch", "tag": "node ./scripts/tag.mjs", "test": "jest", "test:integration": "npm run jest __tests__/integration", "test:unit": "npm run jest __tests__/unit", "type-check": "tsc --noEmit", "version": "node ./scripts/version.mjs" }, "dependencies": { "@antv/algorithm": "^0.1.26", "@antv/component": "^2.1.7", "@antv/event-emitter": "^0.1.3", "@antv/g": "^6.1.28", "@antv/g-canvas": "^2.0.48", "@antv/g-plugin-dragndrop": "^2.0.38", "@antv/graphlib": "^2.0.4", "@antv/hierarchy": "^0.7.1", "@antv/layout": "^2.0.0", "@antv/util": "^3.3.11", "bubblesets-js": "^2.3.4" }, "devDependencies": { "@antv/g-svg": "^2.0.42", "@antv/g-webgl": "^2.0.52", "@antv/layout-gpu": "^1.1.7", "@antv/layout-wasm": "^1.4.2", "@antv/vendor": "^1.0.11", "@types/hull.js": "^1.0.4", "@types/xmlserializer": "^0.6.6", "cross-env": "^7.0.3", "jest-canvas-mock": "^2.5.2", "jest-random-mock": "^1.0.0", "xmlserializer": "^0.6.1" }, "publishConfig": { "registry": "https://registry.npmjs.org/" }, "limit-size": [ { "gzip": true, "limit": "400 Kb", "path": "dist/g6.min.js" }, { "limit": "1.5 Mb", "path": "dist/g6.min.js" } ] } ================================================ FILE: packages/g6/perf.config.js ================================================ import { defineConfig } from 'iperf'; import path from 'path'; export default defineConfig({ perf: { report: { dir: './__tests__/perf-report', }, }, resolve: { alias: { '@antv/g6': path.resolve(__dirname, './src'), }, }, }); ================================================ FILE: packages/g6/rollup.config.mjs ================================================ import commonjs from '@rollup/plugin-commonjs'; 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'; import { visualizer } from 'rollup-plugin-visualizer'; const isBundleVis = !!process.env.BUNDLE_VIS; export default [ { input: 'src/index.ts', output: { file: 'dist/g6.min.js', name: 'G6', format: 'umd', sourcemap: false, }, plugins: [ nodePolyfills(), resolve(), commonjs(), typescript({ tsconfig: 'tsconfig.build.json', }), terser(), ...(isBundleVis ? [visualizer()] : []), ], }, ]; ================================================ FILE: packages/g6/scripts/tag.mjs ================================================ import chalk from 'chalk'; import { execFileSync } from 'child_process'; import { readFileSync } from 'fs'; import open from 'open'; import readline from 'readline'; const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); const version = pkg.version; const repository = pkg.repository.url; console.log(chalk.yellow('The tag will be created with the version: '), chalk.bold(chalk.green(version))); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('Do you want to continue? (y/n): ', (answer) => { if (answer === 'y') { execFileSync('git', ['tag', version]); execFileSync('git', ['push', 'origin', version]); open(`${repository}/releases/new`); } rl.close(); }); ================================================ FILE: packages/g6/scripts/version.mjs ================================================ import { readFileSync, writeFileSync } from 'fs'; const pkg = readFileSync('./package.json', 'utf-8'); const version = JSON.parse(pkg).version; writeFileSync('./src/version.ts', `export const version = '${version}';\n`, 'utf-8'); ================================================ FILE: packages/g6/src/animations/executor.ts ================================================ import type { DisplayObject, IAnimation } from '@antv/g'; import { upperFirst } from '@antv/util'; import { createAnimationsProxy, inferDefaultValue, preprocessKeyframes } from '../utils/animation'; import { replaceTranslateInTransform } from '../utils/transform'; import type { AnimationExecutor } from './types'; /** * 动画语法执行器 * * Animation syntax executor * @param element - 要执行动画的图形 | shape to execute animation * @param keyframes - 动画关键帧 | animation keyframes * @param options - 动画语法 | animation syntax * @returns 动画实例 | animation instance */ export const executor: AnimationExecutor = (element, keyframes, options) => { if (!options.length) return null; const [originalStyle, modifiedStyle] = keyframes; /** * 获取图形关键帧样式 * * Get the keyframe style of the shape * @param shapeID - 图形 ID | shape ID * @returns 图形关键帧样式 | keyframe style of the shape */ const getKeyframeStyle = ( shapeID?: string, ): { shape: DisplayObject; fromStyle: Record; toStyle: Record } | null => { if (shapeID) { const shape = element.getShape(shapeID); if (!shape) return null; const name = `get${upperFirst(shapeID)}Style` as keyof typeof element; const styler: (attrs?: Record) => Record = element?.[name]?.bind(element) || ((attrs) => attrs); const fromStyle = styler?.(originalStyle) || {}; const toStyle = styler?.(modifiedStyle) || {}; return { shape, fromStyle, toStyle }; } else { const shape = element; return { shape, fromStyle: originalStyle, toStyle: modifiedStyle }; } }; let mainResult: IAnimation; const subResults = options .map(({ fields, shape: shapeID, states: enabledStates, ...effectTiming }) => { const keyframeStyle = getKeyframeStyle(shapeID); if (!keyframeStyle) return null; const { shape, fromStyle, toStyle } = keyframeStyle; const keyframes: Keyframe[] = [{}, {}]; fields.forEach((attr) => { Object.assign(keyframes[0], { [attr]: fromStyle[attr] ?? inferDefaultValue(attr) }); Object.assign(keyframes[1], { [attr]: toStyle[attr] ?? inferDefaultValue(attr) }); }); // x/y -> translate if (keyframes.some((keyframe) => Object.keys(keyframe).some((attr) => ['x', 'y', 'z'].includes(attr)))) { const { x = 0, y = 0, z, transform = '' } = shape.attributes || {}; keyframes.forEach((keyframe) => { // @ts-expect-error ignore type error keyframe.transform = replaceTranslateInTransform( (keyframe.x as number) ?? (x as number), (keyframe.y as number) ?? (y as number), (keyframe.z as number) ?? (z as number), transform, ); }); } const result = shape.animate(preprocessKeyframes(keyframes), effectTiming); if (shapeID === undefined) mainResult = result!; return result; }) .filter(Boolean) as IAnimation[]; const result = mainResult! || subResults?.[0]; if (!result) return null; return createAnimationsProxy( result, subResults.filter((result) => result !== result), ); }; ================================================ FILE: packages/g6/src/animations/index.ts ================================================ /** * 内置的动画元素。 * Built-in animations. */ export { executor } from './executor'; export const Fade = [{ fields: ['opacity'] }]; export const Translate = [{ fields: ['x', 'y'] }]; export const NodeCollapse = [{ fields: ['x', 'y'] }]; export const NodeExpand = NodeCollapse; export const PathIn = [{ fields: ['sourceNode', 'targetNode'] }]; export const PathOut = PathIn; export const ComboCollapse = [{ fields: ['childrenNode', 'x', 'y'] }]; export const ComboExpand = ComboCollapse; export const ComboCollapseExpand = [{ fields: ['childrenNode', 'x', 'y'] }]; ================================================ FILE: packages/g6/src/animations/types.ts ================================================ import type { IAnimation } from '@antv/g'; import type { AnimationStage } from '../spec/element/animation'; import type { Element, ElementType, State } from '../types'; export type STDAnimation = AnimationOptions[]; /** * 元素动画选项 * * Element animation options */ export interface AnimationOptions extends AnimationEffectTiming { /** * 执行动画的字段(样式)名 * * Field (style) name of the animation */ fields: string[]; /** * 执行动画的图形,默认为当前元素 * * Shape of the animation, default is the current element */ shape?: string; /** * 参与动画的状态 * * States involved in the animation */ states?: State[]; } export interface AnimationContext { /** * 执行动画的元素 * * Element to execute animation */ element: Element; /** * 元素类型 * * Element type */ elementType: ElementType; /** * 动画阶段 * * Animation stage */ stage: AnimationStage; /** * 动画的源样式 * * Source style of animation * @remarks * 用于在动画执行前将 shape 的样式设置为源样式,例如 move-to 动画,需要将 shape 的 x, y 设置为源样式 * * Used to set the style of shape to the source style before the animation is executed. For example, the move-to animation needs to set the x and y of shape to the source style */ originalStyle: Record; /** * 额外的动画终态样式 * * Additional animation final state style * @remarks * 例如元素销毁前,需要将元素的终态透明度设置为 0 * * For example, before the element is destroyed, the final state opacity of the element needs to be set to 0 */ modifiedStyle?: Record; /** * 变更样式 * * Updated style */ updatedStyle?: Record; } /** * 动画效果时序 * * Animation effect timing */ export interface AnimationEffectTiming { /** * 动画延迟时间 * * Animation delay time */ delay?: number; /** * 动画方向 * * Animation direction */ direction?: PlaybackDirection; /** * 动画持续时间 * * Animation duration */ duration?: number; /** * 动画缓动函数 * * Animation easing function */ easing?: string; /** * 动画结束后的填充模式 * * Fill mode after the animation ends */ fill?: FillMode; /** * 动画迭代次数 * * Number of iterations of the animation */ iterations?: number; } export type AnimationExecutor = ( element: Element, keyframes: [Record, Record], options: STDAnimation, ) => IAnimation | null; ================================================ FILE: packages/g6/src/behaviors/auto-adapt-label.ts ================================================ import { AABB } from '@antv/g'; import { groupBy, isFunction, throttle } from '@antv/util'; import { GraphEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { ComboData, EdgeData, NodeData } from '../spec'; import type { Element, ElementDatum, ID, IEvent, Node, NodeCentralityOptions, Padding } from '../types'; import { getExpandedBBox } from '../utils/bbox'; import { getNodeCentralities } from '../utils/centrality'; import { arrayDiff } from '../utils/diff'; import { setVisibility } from '../utils/visibility'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 标签自适应显示配置项 * * Auto Adapt Label Options */ export interface AutoAdaptLabelOptions extends BaseBehaviorOptions { /** * 是否启用 * * Whether to enable * @defaultValue `true` */ enable?: boolean | ((event: IEvent) => boolean); /** * 根据元素的重要性从高到低排序,重要性越高的元素其标签显示优先级越高。一般情况下 combo > node > edge * * Sort elements by their importance in descending order; elements with higher importance have higher label display priority; usually combo > node > edge */ sort?: (elementA: ElementDatum, elementB: ElementDatum) => -1 | 0 | 1; /** * 根据节点的重要性从高到低排序,重要性越高的节点其标签显示优先级越高。内置几种中心性算法,也可以自定义排序函数。需要注意,如果设置了 `sort`,则 `sortNode` 不会生效 * * Sort nodes by importance in descending order; nodes with higher importance have higher label display priority. Several centrality algorithms are built in, and custom sorting functions can also be defined. It should be noted that if `sort` is set, `sortNode` will not take effect * @defaultValue { type: 'degree' } */ sortNode?: NodeCentralityOptions | ((nodeA: NodeData, nodeB: NodeData) => -1 | 0 | 1); /** * 根据边的重要性从高到低排序,重要性越高的边其标签显示优先级越高。默认按照数据先后进行排序。需要注意,如果设置了 `sort`,则 `sortEdge` 不会生效 * * Sort edges by importance in descending order; edges with higher importance have higher label display priority. By default, they are sorted according to the data. It should be noted that if `sort` is set, `sortEdge` will not take effect */ sortEdge?: (edgeA: EdgeData, edgeB: EdgeData) => -1 | 0 | 1; /** * 根据群组的重要性从高到低排序,重要性越高的群组其标签显示优先级越高。默认按照数据先后进行排序。需要注意,如果设置了 `sort`,则 `sortCombo` 不会生效 * * Sort combos by importance in descending order; combos with higher importance have higher label display priority. By default, they are sorted according to the data. It should be noted that if `sort` is set, `sortCombo` will not take effect */ sortCombo?: (comboA: ComboData, comboB: ComboData) => -1 | 0 | 1; /** * 设置标签的内边距,用于判断标签是否重叠,以避免标签显示过于密集 * * Set the padding of the label to determine whether the label overlaps to avoid the label being displayed too densely * @defaultValue 0 */ padding?: Padding; /** * 节流时间 * * Throttle time * @defaultValue 32 */ throttle?: number; } /** * 标签自适应显示 * * Auto Adapt Label * @remarks * 标签自适应显示是一种动态标签管理策略,旨在根据当前可视范围的空间分配、节点重要性等因素,智能调整哪些标签应显示或隐藏。通过对可视区域的实时分析,确保用户在不同的交互场景下获得最相关最清晰的信息展示,同时避免视觉过载和信息冗余。 * * Label Adaptive Display is a dynamic label management strategy designed to intelligently adjust which labels should be shown or hidden based on factors such as the spatial allocation of the current viewport and node importance. By analyzing the visible area in real-time, it ensures that users receive the most relevant and clear information display in various interactive scenarios, while avoiding visual overload and information redundancy. */ export class AutoAdaptLabel extends BaseBehavior { static defaultOptions: Partial = { enable: true, throttle: 100, padding: 0, sortNode: { type: 'degree' }, }; constructor(context: RuntimeContext, options: AutoAdaptLabelOptions) { super(context, Object.assign({}, AutoAdaptLabel.defaultOptions, options)); this.bindEvents(); } public update(options: Partial): void { this.unbindEvents(); super.update(options); this.bindEvents(); this.onToggleVisibility({} as IEvent); } /** * 检查当前包围盒是否有足够的空间进行展示;如果与已经展示的包围盒有重叠,则不会展示 * * Check whether the current bounding box has enough space to display; if it overlaps with the displayed bounding box, it will not be displayed * @param bbox - bbox * @param bboxes - occupied bboxes which are already shown * @returns whether the bbox is overlapping with the bboxes or outside the viewpointBounds */ private isOverlapping = (bbox: AABB, bboxes: AABB[]) => { return bboxes.some((b) => bbox.intersects(b)); }; private occupiedBounds: AABB[] = []; private detectLabelCollision = (elements: Element[]): { show: Element[]; hide: Element[] } => { const viewport = this.context.viewport!; const res: { show: Element[]; hide: Element[] } = { show: [], hide: [] }; this.occupiedBounds = []; elements.forEach((element) => { const labelBounds = element.getShape('label').getRenderBounds(); if (viewport.isInViewport(labelBounds, true) && !this.isOverlapping(labelBounds, this.occupiedBounds)) { res.show.push(element); this.occupiedBounds.push(getExpandedBBox(labelBounds, this.options.padding)); } else { res.hide.push(element); } }); return res; }; private getLabelElements(): Element[] { // @ts-expect-error access private property const { elementMap } = this.context.element; const elements: Element[] = []; for (const key in elementMap) { const element = elementMap[key]; if (element.isVisible() && element.getShape('label')) { elements.push(element); } } return elements; } private getLabelElementsInView(): Element[] { const viewport = this.context.viewport!; return this.getLabelElements().filter((node) => viewport.isInViewport(node.getShape('key').getRenderBounds())); } private hideLabelIfExceedViewport = (prevElementsInView: Element[], currentElementsInView: Element[]) => { const { exit } = arrayDiff(prevElementsInView, currentElementsInView, (d) => d.id); exit?.forEach(this.hideLabel); }; private nodeCentralities: Map = new Map(); private sortNodesByCentrality = (nodes: Node[], centrality: NodeCentralityOptions) => { const { model } = this.context; const graphData = model.getData(); const getRelatedEdgesData = model.getRelatedEdgesData.bind(model); const nodesWithCentrality = nodes.map((node) => { if (!this.nodeCentralities.has(node.id)) { this.nodeCentralities = getNodeCentralities(graphData, getRelatedEdgesData, centrality); } return { node, centrality: this.nodeCentralities.get(node.id)! }; }); return nodesWithCentrality.sort((a, b) => b.centrality - a.centrality).map((item) => item.node); }; protected sortLabelElementsInView = (labelElements: Element[]): Element[] => { const { sort, sortNode, sortCombo, sortEdge } = this.options; const { model } = this.context; if (isFunction(sort)) return labelElements.sort((a, b) => sort(model.getElementDataById(a.id), model.getElementDataById(b.id))); const { node: nodes = [], edge: edges = [], combo: combos = [] } = groupBy(labelElements, (el) => (el as any).type); const sortedCombos = isFunction(sortCombo) ? combos.sort((a, b) => sortCombo(...(model.getComboData([a.id, b.id]) as [ComboData, ComboData]))) : combos; const sortedNodes = isFunction(sortNode) ? nodes.sort((a, b) => sortNode(...(model.getNodeData([a.id, b.id]) as [NodeData, NodeData]))) : this.sortNodesByCentrality(nodes as Node[], sortNode); const sortedEdges = isFunction(sortEdge) ? edges.sort((a, b) => sortEdge(...(model.getEdgeData([a.id, b.id]) as [EdgeData, EdgeData]))) : edges; return [...sortedCombos, ...sortedNodes, ...sortedEdges]; }; private labelElementsInView: Element[] = []; private isFirstRender = true; protected onToggleVisibility = (event: IEvent) => { // @ts-expect-error missing type if (event.data?.stage === 'zIndex') return; if (!this.validate(event)) { if (this.hiddenElements.size > 0) { this.hiddenElements.forEach(this.showLabel); this.hiddenElements.clear(); } return; } const labelElementsInView = this.isFirstRender ? this.getLabelElements() : this.getLabelElementsInView(); this.hideLabelIfExceedViewport(this.labelElementsInView, labelElementsInView); this.labelElementsInView = labelElementsInView; // 根据元素的重要性从高到低排序,重要性越高的元素其标签显示优先级越高;通常 combo > node > edge // Sort elements by their importance in descending order; elements with higher importance have higher label display priority; usually combo > node > edge const sortedElements = this.sortLabelElementsInView(this.labelElementsInView); const { show, hide } = this.detectLabelCollision(sortedElements); for (let i = show.length - 1; i >= 0; i--) { this.showLabel(show[i]); } hide.forEach(this.hideLabel); }; private hiddenElements: Map = new Map(); private hideLabel = (element: Element) => { const label = element.getShape('label'); if (label) setVisibility(label, 'hidden'); this.hiddenElements.set(element.id, element); }; private showLabel = (element: Element) => { const label = element.getShape('label'); if (label) setVisibility(label, 'visible'); element.toFront(); this.hiddenElements.delete(element.id); }; protected onTransform = throttle(this.onToggleVisibility, this.options.throttle, { leading: true }) as () => void; private enableToggle = true; private toggle = (event: IEvent) => { if (!this.enableToggle) return; this.onToggleVisibility(event); }; private onBeforeRender = () => { this.enableToggle = false; }; private onAfterRender = (event: IEvent) => { this.onToggleVisibility(event); this.enableToggle = true; }; private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.BEFORE_RENDER, this.onBeforeRender); graph.on(GraphEvent.AFTER_RENDER, this.onAfterRender); graph.on(GraphEvent.AFTER_DRAW, this.toggle); graph.on(GraphEvent.AFTER_LAYOUT, this.toggle); graph.on(GraphEvent.AFTER_TRANSFORM, this.onTransform); } private unbindEvents() { const { graph } = this.context; graph.off(GraphEvent.BEFORE_RENDER, this.onBeforeRender); graph.off(GraphEvent.AFTER_RENDER, this.onAfterRender); graph.off(GraphEvent.AFTER_DRAW, this.toggle); graph.off(GraphEvent.AFTER_LAYOUT, this.toggle); graph.off(GraphEvent.AFTER_TRANSFORM, this.onTransform); } private validate(event: IEvent): boolean { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } public destroy(): void { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/base-behavior.ts ================================================ import { BaseExtension } from '../registry/extension'; import type { CustomBehaviorOption } from '../spec/behavior'; /** * 交互通用配置项 * * Base options for behaviors */ export interface BaseBehaviorOptions extends CustomBehaviorOption {} /** * 交互的基类 * * Base class for behaviors */ export abstract class BaseBehavior extends BaseExtension {} ================================================ FILE: packages/g6/src/behaviors/brush-select.ts ================================================ import type { RectStyleProps } from '@antv/g'; import { Rect } from '@antv/g'; import { deepMix, isFunction } from '@antv/util'; import { CanvasEvent, CommonEvent } from '../constants'; import type { Graph } from '../runtime/graph'; import type { RuntimeContext } from '../runtime/types'; import type { ElementDatum, ElementType, ID, IPointerEvent, Point, State } from '../types'; import { idOf } from '../utils/id'; import { getBoundingPoints, isPointInPolygon } from '../utils/point'; import type { ShortcutKey } from '../utils/shortcut'; import { Shortcut } from '../utils/shortcut'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 框选配置项 * * Brush select options */ export interface BrushSelectOptions extends BaseBehaviorOptions { /** * 是否启用动画 * * Whether to enable animation. * @defaultValue false */ animation?: boolean; /** * 是否启用框选功能 * * Whether to enable Brush select element function. * @defaultValue true */ enable?: boolean | ((event: IPointerEvent) => boolean); /** * 可框选的元素类型 * * Enable Elements type. * @defaultValue ['node', 'combo', 'edge'] */ enableElements?: ElementType[]; /** * 按下该快捷键配合鼠标点击进行框选 * * Press this shortcut key to apply brush select with mouse click. * @remarks * 注意,`trigger` 设置为 `['drag']` 时会导致 `drag-canvas` 行为失效。两者不可同时配置。 * * Note that setting `trigger` to `['drag']` will cause the `drag-canvas` behavior to fail. The two cannot be configured at the same time. * @defaultValue ['shift'] */ trigger?: ShortcutKey; /** * 被选中时切换到该状态 * * The state to switch to when selected. * @defaultValue 'selected' */ state?: State; /** * 框选的选择模式 * - `'union'`:保持已选元素的当前状态,并添加指定的 state 状态。 * - `'intersect'`:如果已选元素已有指定的 state 状态,则保留;否则清除该状态。 * - `'diff'`:对已选元素的指定 state 状态进行取反操作。 * - `'default'`:清除已选元素的当前状态,并添加指定的 state 状态。 * * Brush select mode * - `'union'`: Keep the current state of the selected elements and add the specified state. * - `'intersect'`: If the selected elements already have the specified state, keep it; otherwise, clearBrush it. * - `'diff'`: Perform a negation operation on the specified state of the selected elements. * - `'default'`: Clear the current state of the selected elements and add the specified state. * @defaultValue 'default' */ mode?: 'union' | 'intersect' | 'diff' | 'default'; /** * 是否及时框选, 仅在框选模式为 `default` 时生效 * * Whether to brush select immediately, only valid when the brush select mode is `default` * @defaultValue false */ immediately?: boolean; /** * 框选 框样式 * * Timely screening. */ style?: RectStyleProps; /** * 框选元素状态回调。 * * Callback when brush select elements. * @param states - 选中的元素状态 */ onSelect?: (states: Record) => void; } /** * 框选一组元素 * * Brush select elements */ export class BrushSelect extends BaseBehavior { static defaultOptions: Partial = { animation: false, enable: true, enableElements: ['node', 'combo', 'edge'], immediately: false, mode: 'default', state: 'selected', trigger: ['shift'], style: { width: 0, height: 0, lineWidth: 1, fill: '#1677FF', stroke: '#1677FF', fillOpacity: 0.1, zIndex: 2, pointerEvents: 'none', }, }; private startPoint?: Point; private endPoint?: Point; private rectShape?: Rect; private shortcut?: Shortcut; constructor(context: RuntimeContext, options: BrushSelectOptions) { super(context, deepMix({}, BrushSelect.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.onPointerDown = this.onPointerDown.bind(this); this.onPointerMove = this.onPointerMove.bind(this); this.onPointerUp = this.onPointerUp.bind(this); this.clearStates = this.clearStates.bind(this); this.bindEvents(); } /** * Triggered when the pointer is pressed * @param event - Pointer event * @internal */ protected onPointerDown(event: IPointerEvent) { if (!this.validate(event) || !this.isKeydown() || this.startPoint) return; const { canvas, graph } = this.context; const style = { ...this.options.style }; // 根据缩放比例调整 lineWidth // Adjust lineWidth according to the zoom ratio if (this.options.style.lineWidth) { style.lineWidth = +this.options.style.lineWidth / graph.getZoom(); } this.rectShape = new Rect({ id: 'g6-brush-select', style }); canvas.appendChild(this.rectShape); this.startPoint = [event.canvas.x, event.canvas.y]; } /** * Triggered when the pointer is moved * @param event - Pointer event * @internal */ protected onPointerMove(event: IPointerEvent) { if (!this.startPoint) return; const { immediately, mode } = this.options; this.endPoint = getCursorPoint(event, this.context.graph); this.rectShape?.attr({ x: Math.min(this.endPoint[0], this.startPoint[0]), y: Math.min(this.endPoint[1], this.startPoint[1]), width: Math.abs(this.endPoint[0] - this.startPoint[0]), height: Math.abs(this.endPoint[1] - this.startPoint[1]), }); if (immediately && mode === 'default') this.updateElementsStates(getBoundingPoints(this.startPoint, this.endPoint)); } /** * Triggered when the pointer is released * @param event - Pointer event * @internal */ protected onPointerUp(event: IPointerEvent) { if (!this.startPoint) return; if (!this.endPoint) { this.clearBrush(); return; } this.endPoint = getCursorPoint(event, this.context.graph); this.updateElementsStates(getBoundingPoints(this.startPoint, this.endPoint)); this.clearBrush(); } /** * 清除状态 * * Clear state * @internal */ protected clearStates() { if (this.endPoint) return; this.clearElementsStates(); } /** * 清除画布上所有元素的状态 * * Clear the state of all elements on the canvas * @internal */ protected clearElementsStates() { const { graph } = this.context; const states = Object.values(graph.getData()).reduce((acc, data) => { return Object.assign( {}, acc, data.reduce((acc: Record, datum: ElementDatum) => { const restStates = (datum.states || [])?.filter((state) => state !== this.options.state); acc[idOf(datum)] = restStates; return acc; }, {}), ); }, {}); graph.setElementState(states, this.options.animation); } /** * 更新选中的元素状态 * * Update the state of the selected elements * @param points - 框选区域的顶点 | The vertex of the selection area * @internal */ protected updateElementsStates(points: Point[]) { const { graph } = this.context; const { enableElements, state, mode, onSelect } = this.options; const selectedIds = this.selector(graph, points, enableElements); const states: Record = {}; switch (mode) { case 'union': selectedIds.forEach((id) => { states[id] = [...graph.getElementState(id), state]; }); break; case 'diff': selectedIds.forEach((id) => { const prevStates = graph.getElementState(id); states[id] = prevStates.includes(state) ? prevStates.filter((s) => s !== state) : [...prevStates, state]; }); break; case 'intersect': selectedIds.forEach((id) => { const prevStates = graph.getElementState(id); states[id] = prevStates.includes(state) ? [state] : []; }); break; case 'default': default: selectedIds.forEach((id) => { states[id] = [state]; }); break; } if (isFunction(onSelect)) onSelect(states); graph.setElementState(states, this.options.animation); } /** * 查找画布上在指定区域内显示的元素。当节点的包围盒中心在矩形内时,节点被选中;当边的两端节点在矩形内时,边被选中;当 combo 的包围盒中心在矩形内时,combo 被选中。 * * Find the elements displayed in the specified area on the canvas. A node is selected if the center of its bbox is inside the rect; An edge is selected if both end nodes are inside the rect ;A combo is selected if the center of its bbox is inside the rect. * @param graph - 图实例 | Graph instance * @param points - 框选区域的顶点 | The vertex of the selection area * @param itemTypes - 元素类型 | Element type * @returns 选中的元素 ID 数组 | Selected element ID array * @internal */ protected selector(graph: Graph, points: Point[], itemTypes: ElementType[]): ID[] { if (!itemTypes || itemTypes.length === 0) return []; const elements: ID[] = []; const graphData = graph.getData(); itemTypes.forEach((itemType) => { graphData[`${itemType}s`].forEach((datum) => { const id = idOf(datum); if (graph.getElementVisibility(id) !== 'hidden' && isPointInPolygon(graph.getElementPosition(id), points)) { elements.push(id); } }); }); // 如果边的两端节点都在框选范围内,则边也被选中 | If source node and target node are within the selection range, that edge is also selected if (itemTypes.includes('edge')) { const edges = graphData.edges; edges?.forEach((edge) => { const { source, target } = edge; if (elements.includes(source) && elements.includes(target)) { elements.push(idOf(edge)); } }); } return elements; } private clearBrush() { this.rectShape?.remove(); this.rectShape = undefined; this.startPoint = undefined; this.endPoint = undefined; } /** * 当前按键是否和 trigger 配置一致 * * Is the current key consistent with the trigger configuration * @returns 是否一致 | Is consistent * @internal */ protected isKeydown(): boolean { const { trigger } = this.options; const keys = (Array.isArray(trigger) ? trigger : [trigger]) as string[]; return this.shortcut!.match(keys.filter((key) => key !== 'drag')); } /** * 验证是否启用框选 * * Verify whether brush select is enabled * @param event - 事件 | Event * @returns 是否启用 | Whether to enable * @internal */ protected validate(event: IPointerEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } private bindEvents() { const { graph } = this.context; graph.on(CommonEvent.POINTER_DOWN, this.onPointerDown); graph.on(CommonEvent.POINTER_MOVE, this.onPointerMove); graph.on(CommonEvent.POINTER_UP, this.onPointerUp); graph.on(CanvasEvent.CLICK, this.clearStates); } private unbindEvents() { const { graph } = this.context; graph.off(CommonEvent.POINTER_DOWN, this.onPointerDown); graph.off(CommonEvent.POINTER_MOVE, this.onPointerMove); graph.off(CommonEvent.POINTER_UP, this.onPointerUp); graph.off(CanvasEvent.CLICK, this.clearStates); } /** * 更新配置项 * * Update configuration * @param options - 配置项 | Options * @internal */ public update(options: Partial) { this.unbindEvents(); this.options = deepMix(this.options, options); this.bindEvents(); } /** * 销毁 * * Destroy * @internal */ public destroy() { this.unbindEvents(); super.destroy(); } } export const getCursorPoint = (event: IPointerEvent, graph: Graph): Point => { // Fixed #7182: 判断 html 类型节点,并把 html 节点的浏览器坐标转换为 canvas 坐标。 // 没有直接判断的方式,nativeEvent.target 非 canvas 则表示 html 节点触发的。 // Fixed #7182: Handles brush selection on HTML nodes by converting client coordinates to canvas coordinates. // An HTML node is identified if the event's targetType is 'node' but the nativeEvent.target is not the canvas element. if ( (event.targetType === 'node' || event.targetType === 'combo') && !(event.nativeEvent.target instanceof HTMLCanvasElement) ) { const [x, y] = graph.getCanvasByClient([event.client.x, event.client.y]); return [x, y]; } return [event.canvas.x, event.canvas.y]; }; ================================================ FILE: packages/g6/src/behaviors/click-select.ts ================================================ import { isFunction } from '@antv/util'; import { CanvasEvent, CommonEvent } from '../constants'; import { ELEMENT_TYPES } from '../constants/element'; import type { RuntimeContext } from '../runtime/types'; import type { Element, ElementType, ID, IPointerEvent, State } from '../types'; import { idOf } from '../utils/id'; import { getElementNthDegreeIds } from '../utils/relation'; import type { ShortcutKey } from '../utils/shortcut'; import { Shortcut } from '../utils/shortcut'; import { statesOf } from '../utils/state'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 点击元素交互配置项 * * Click element behavior options */ export interface ClickSelectOptions extends BaseBehaviorOptions { /** * 是否启用动画 * * Whether to enable animation * @defaultValue true */ animation?: boolean; /** * 是否启用点击元素的功能 * * Whether to enable the function of clicking the element * @defaultValue true * @remarks * 可以通过函数的方式动态控制是否启用,例如只有节点被选中时才启用。 * * Whether to enable can be dynamically controlled by functions, such as only when nodes are selected. * * ```json * { enable: event => event.targetType === 'node'} * ``` */ enable?: boolean | ((event: IPointerEvent) => boolean); /** * 是否允许多选 * * Whether to allow multiple selection * @defaultValue false */ multiple?: boolean; /** * 按下该快捷键配合鼠标点击进行多选 * * Press this shortcut key to apply multiple selection with mouse click * @defaultValue ['shift'] */ trigger?: ShortcutKey; /** * 当元素被选中时应用的状态 * * The state to be applied when an element is selected * @defaultValue 'selected' */ state?: State; /** * 当有元素选中时,其相邻 n 度关系的元素应用的状态。n 的值由属性 degree 控制,例如 degree 为 1 时表示直接相邻的元素 * * The state to be applied to the neighboring elements within n degrees when an element is selected. The value of n is controlled by the degree property, for instance, a degree of 1 indicates direct neighbors * @defaultValue 'selected' */ neighborState?: State; /** * 当有元素被选中时,除了选中元素及其受影响的邻居元素外,其他所有元素应用的状态。 * * The state to be applied to all unselected elements when some elements are selected, excluding the selected element and its affected neighbors */ unselectedState?: State; /** * 选中元素的度,即决定了影响范围 * * The degree to determine the scope of influence * @defaultValue 0 * @remarks * 对于节点来说,`0` 表示只选中当前节点,`1` 表示选中当前节点及其直接相邻的节点和边,以此类推。 * * 对于边来说,`0` 表示只选中当前边,`1` 表示选中当前边及其直接相邻的节点,以此类推。 * * For nodes, `0` means only the current node is selected, `1` means the current node and its directly adjacent nodes and edges are selected, etc. * * For edges, `0 `means only the current edge is selected,`1` means the current edge and its directly adjacent nodes are selected, etc. */ degree?: number | ((event: IPointerEvent) => number); /** * 点击元素时的回调 * * Callback when the element is clicked * @param event - 点击事件 | click event */ onClick?: (event: IPointerEvent) => void; } /** * 点击元素 * * Click Element * @remarks * 当鼠标点击元素时,可以激活元素的状态,例如选中节点或边。当 degree 设置为 `1` 时,点击节点会高亮当前节点及其直接相邻的节点和边。 * * When the mouse clicks on an element, you can activate the state of the element, such as selecting nodes or edges. When the degree is 1, clicking on a node will highlight the current node and its directly adjacent nodes and edges. */ export class ClickSelect extends BaseBehavior { private shortcut: Shortcut; static defaultOptions: Partial = { animation: true, enable: true, multiple: false, trigger: ['shift'], state: 'selected', neighborState: 'selected', unselectedState: undefined, degree: 0, }; constructor(context: RuntimeContext, options: ClickSelectOptions) { super(context, Object.assign({}, ClickSelect.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.bindEvents(); } private bindEvents() { const { graph } = this.context; this.unbindEvents(); ELEMENT_TYPES.forEach((type) => { graph.on(`${type}:${CommonEvent.CLICK}`, this.onClickSelect); }); graph.on(CanvasEvent.CLICK, this.onClickCanvas); } private onClickSelect = async (event: IPointerEvent) => { if (!this.validate(event)) return; await this.updateState(event); this.options.onClick?.(event); }; private onClickCanvas = async (event: IPointerEvent) => { if (!this.validate(event)) return; await this.clearState(); this.options.onClick?.(event); }; private get isMultipleSelect() { const { multiple, trigger } = this.options; return multiple && this.shortcut.match(trigger); } protected getNeighborIds(event: IPointerEvent) { const { target, targetType } = event; const { graph } = this.context; const { degree } = this.options; return getElementNthDegreeIds( graph, targetType as ElementType, target.id, typeof degree === 'function' ? degree(event) : degree, ).filter((id) => id !== target.id); } private async updateState(event: IPointerEvent) { const { state: selectState, unselectedState, neighborState, animation } = this.options; if (!selectState && !neighborState && !unselectedState) return; const { target } = event; const { graph } = this.context; const datum = graph.getElementData(target.id); const type = statesOf(datum).includes(selectState) ? 'unselect' : 'select'; const states: Record = {}; const isMultipleSelect = this.isMultipleSelect; const click = [target.id]; const neighbor = this.getNeighborIds(event); if (!isMultipleSelect) { if (type === 'select') { Object.assign(states, this.getClearStates(!!unselectedState)); const addState = (list: ID[], state: State) => { list.forEach((id) => { if (!states[id]) states[id] = graph.getElementState(id); states[id].push(state); }); }; addState(click, selectState); addState(neighbor, neighborState); if (unselectedState) { Object.keys(states).forEach((id) => { if (!click.includes(id) && !neighbor.includes(id)) states[id].push(unselectedState); }); } } else Object.assign(states, this.getClearStates()); } else { Object.assign(states, this.getDataStates()); if (type === 'select') { const addState = (list: ID[], state: State) => { list.forEach((id) => { const dataStatesSet = new Set(graph.getElementState(id)); dataStatesSet.add(state); dataStatesSet.delete(unselectedState); states[id] = Array.from(dataStatesSet); }); }; addState(click, selectState); addState(neighbor, neighborState); if (unselectedState) { Object.keys(states).forEach((id) => { const _states = states[id]; if ( !_states.includes(selectState) && !_states.includes(neighborState) && !_states.includes(unselectedState) ) { states[id].push(unselectedState); } }); } } else { const targetState = states[target.id]; states[target.id] = targetState.filter((s) => s !== selectState && s !== neighborState); if (!targetState.includes(unselectedState)) states[target.id].push(unselectedState); neighbor.forEach((id) => { states[id] = states[id].filter((s) => s !== neighborState); if (!states[id].includes(selectState)) states[id].push(unselectedState); }); } } await graph.setElementState(states, animation); } private getDataStates() { const { graph } = this.context; const { nodes, edges, combos } = graph.getData(); const states: Record = {}; [...nodes, ...edges, ...combos].forEach((data) => { states[idOf(data)] = statesOf(data); }); return states; } /** * 获取需要清除的状态 * * Get the states that need to be cleared * @param complete - 是否返回所有状态 | Whether to return all states * @returns - 需要清除的状态 | States that need to be cleared */ private getClearStates(complete = false) { const { graph } = this.context; const { state, unselectedState, neighborState } = this.options; const statesToClear = new Set([state, unselectedState, neighborState]); const { nodes, edges, combos } = graph.getData(); const states: Record = {}; [...nodes, ...edges, ...combos].forEach((data) => { const datumStates = statesOf(data); const newStates = datumStates.filter((s) => !statesToClear.has(s)); if (complete) states[idOf(data)] = newStates; else if (newStates.length !== datumStates.length) states[idOf(data)] = newStates; }); return states; } private async clearState() { const { graph } = this.context; await graph.setElementState(this.getClearStates(), this.options.animation); } private validate(event: IPointerEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } private unbindEvents() { const { graph } = this.context; ELEMENT_TYPES.forEach((type) => { graph.off(`${type}:${CommonEvent.CLICK}`, this.onClickSelect); }); graph.off(CanvasEvent.CLICK, this.onClickCanvas); } public destroy() { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/collapse-expand.ts ================================================ import { isFunction } from '@antv/util'; import { CommonEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { ID, IPointerEvent, NodeLikeData } from '../types'; import { isCollapsed } from '../utils/collapsibility'; import { isElement } from '../utils/element'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 展开/收起元素交互配置项 * * Collapse/Expand combo behavior options */ export interface CollapseExpandOptions extends BaseBehaviorOptions { /** * 是否启用动画 * * Whether to enable animation * @defaultValue true */ animation?: boolean; /** * 是否启用展开/收起功能 * * Whether to enable the expand/collapse function * @defaultValue true */ enable?: boolean | ((event: IPointerEvent) => boolean); /** * 触发方式 * * Trigger method * @defaultValue 'dblclick' */ trigger?: CommonEvent.CLICK | CommonEvent.DBLCLICK; /** * 完成收起时的回调 * * Callback when collapse is completed */ onCollapse?: (id: ID) => void; /** * 完成展开时的回调 * * Callback when expand is completed */ onExpand?: (id: ID) => void; /** * 是否对准目标元素,避免视图偏移 * * Whether to focus on the target element to avoid view offset */ align?: boolean; } /** * 展开/收起元素交互 * * Collapse/Expand Element behavior * @remarks * 通过操作展开/收起元素。 * * Expand/collapse elements by operation. */ export class CollapseExpand extends BaseBehavior { static defaultOptions: Partial = { enable: true, animation: true, trigger: CommonEvent.DBLCLICK, align: true, }; constructor(context: RuntimeContext, options: CollapseExpandOptions) { super(context, Object.assign({}, CollapseExpand.defaultOptions, options)); this.bindEvents(); } public update(options: Partial) { this.unbindEvents(); super.update(options); this.bindEvents(); } private bindEvents() { const { graph } = this.context; const { trigger } = this.options; graph.on(`node:${trigger}`, this.onCollapseExpand); graph.on(`combo:${trigger}`, this.onCollapseExpand); } private unbindEvents() { const { graph } = this.context; const { trigger } = this.options; graph.off(`node:${trigger}`, this.onCollapseExpand); graph.off(`combo:${trigger}`, this.onCollapseExpand); } private onCollapseExpand = async (event: IPointerEvent) => { if (!this.validate(event)) return; const { target } = event; if (!isElement(target)) return; const id = target.id; const { model, graph } = this.context; const data = model.getElementDataById(id) as NodeLikeData; if (!data) return false; const { onCollapse, onExpand, animation, align } = this.options; if (isCollapsed(data)) { await graph.expandElement(id, { animation, align }); onExpand?.(id); } else { await graph.collapseElement(id, { animation, align }); onCollapse?.(id); } }; private validate(event: IPointerEvent): boolean { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } public destroy(): void { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/create-edge.ts ================================================ import { isFunction, uniqueId } from '@antv/util'; import { CanvasEvent, ComboEvent, CommonEvent, EdgeEvent, NodeEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { EdgeData } from '../spec'; import type { EdgeStyle } from '../spec/element/edge'; import type { ID, IElementEvent, IPointerEvent, NodeLikeData } from '../types'; import { OVERRIDE_KEY } from '../utils/data'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; const ASSIST_EDGE_ID = 'g6-create-edge-assist-edge-id'; const ASSIST_NODE_ID = 'g6-create-edge-assist-node-id'; /** * 创建边交互配置项 * * Create edge behavior options */ export interface CreateEdgeOptions extends BaseBehaviorOptions { /** * 是否启用创建边的功能 * * Whether to enable the function of creating edges * @defaultValue true */ enable?: boolean | ((event: IPointerEvent) => boolean); /** * 新建边的样式配置 * * Style configs of the new edge */ style?: EdgeStyle; /** * 交互配置 点击 或 拖拽 * * trigger click or drag. * @defaultValue 'drag' */ trigger?: 'click' | 'drag'; /** * 成功创建边回调 * * Callback when create is completed. */ onFinish?: (edge: EdgeData) => void; /** * 创建边回调,返回边数据。如果返回 undefined,则不创建该边。 * * Callback when create edge, return EdgeData. If returns undefined, the edge will not be created. */ onCreate?: (edge: EdgeData) => EdgeData | undefined; } /** * 创建边交互 * * Create edge behavior * @remarks * 通过拖拽或点击节点创建边,支持自定义边样式。 * * Create edges by dragging or clicking nodes, and support custom edge styles. */ export class CreateEdge extends BaseBehavior { static defaultOptions: Partial = { animation: true, enable: true, style: {}, trigger: 'drag', onCreate: (data) => data, onFinish: () => {}, }; public source?: ID; constructor(context: RuntimeContext, options: CreateEdgeOptions) { super(context, Object.assign({}, CreateEdge.defaultOptions, options)); this.bindEvents(); } /** * Update options * @param options - The options to update * @internal */ public update(options: Partial): void { super.update(options); this.bindEvents(); } private bindEvents() { const { graph } = this.context; const { trigger } = this.options; this.unbindEvents(); if (trigger === 'click') { graph.on(NodeEvent.CLICK, this.handleCreateEdge); graph.on(ComboEvent.CLICK, this.handleCreateEdge); graph.on(CanvasEvent.CLICK, this.cancelEdge); graph.on(EdgeEvent.CLICK, this.cancelEdge); } else { graph.on(NodeEvent.DRAG_START, this.handleCreateEdge); graph.on(ComboEvent.DRAG_START, this.handleCreateEdge); graph.on(CommonEvent.POINTER_UP, this.drop); } graph.on(CommonEvent.POINTER_MOVE, this.updateAssistEdge); } private drop = async (event: IElementEvent) => { const { targetType } = event; if (['combo', 'node'].includes(targetType) && this.source) { await this.handleCreateEdge(event); } else { await this.cancelEdge(); } }; private handleCreateEdge = async (event: IElementEvent) => { if (!this.validate(event)) return; const { graph, canvas, batch, element } = this.context; const { style } = this.options; if (this.source) { this.createEdge(event); await this.cancelEdge(); return; } batch!.startBatch(); canvas.setCursor('crosshair'); this.source = this.getSelectedNodeIDs([event.target.id])[0]; const sourceNode = graph.getElementData(this.source) as NodeLikeData; graph.addNodeData([ { id: ASSIST_NODE_ID, type: 'circle', [OVERRIDE_KEY]: false, style: { size: 1, visibility: 'hidden', ports: [{ key: 'port-1', placement: [0.5, 0.5] }], x: sourceNode.style?.x, y: sourceNode.style?.y, }, }, ]); graph.addEdgeData([ { id: ASSIST_EDGE_ID, source: this.source, target: ASSIST_NODE_ID, style: { pointerEvents: 'none', ...style, }, }, ]); await element!.draw({ animation: false })?.finished; }; private updateAssistEdge = async (event: IPointerEvent) => { if (!this.source) return; const { model, element } = this.context; model.translateNodeTo(ASSIST_NODE_ID, [event.client.x, event.client.y]); await element!.draw({ animation: false, silence: true })?.finished; }; private createEdge = (event: IElementEvent) => { const { graph } = this.context; const { style, onFinish, onCreate } = this.options; const targetId = event.target?.id; if (targetId === undefined || this.source === undefined) return; const target = this.getSelectedNodeIDs([event.target.id])?.[0]; const id = `${this.source}-${target}-${uniqueId()}`; const edgeData = onCreate({ id, source: this.source, target, style }); if (edgeData) { graph.addEdgeData([edgeData]); onFinish(edgeData); } }; private cancelEdge = async () => { if (!this.source) return; const { graph, element, batch } = this.context; graph.removeNodeData([ASSIST_NODE_ID]); this.source = undefined; await element!.draw({ animation: false })?.finished; batch!.endBatch(); }; private getSelectedNodeIDs(currTarget: ID[]) { return Array.from( new Set( this.context.graph .getElementDataByState('node', this.options.state) .map((node) => node.id) .concat(currTarget), ), ); } private validate(event: IPointerEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } private unbindEvents() { const { graph } = this.context; graph.off(NodeEvent.CLICK, this.handleCreateEdge); graph.off(ComboEvent.CLICK, this.handleCreateEdge); graph.off(CanvasEvent.CLICK, this.cancelEdge); graph.off(EdgeEvent.CLICK, this.cancelEdge); graph.off(NodeEvent.DRAG_START, this.handleCreateEdge); graph.off(ComboEvent.DRAG_START, this.handleCreateEdge); graph.off(CommonEvent.POINTER_UP, this.drop); graph.off(CommonEvent.POINTER_MOVE, this.updateAssistEdge); } public destroy() { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/drag-canvas.ts ================================================ import type { Cursor } from '@antv/g'; import { debounce, isObject } from '@antv/util'; import { CommonEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { IDragEvent, IKeyboardEvent, IPointerEvent, Vector2, ViewportAnimationEffectTiming } from '../types'; import { getExpandedBBox, getPointBBox, isPointInBBox } from '../utils/bbox'; import { parsePadding } from '../utils/padding'; import { PinchHandler } from '../utils/pinch'; import type { ShortcutKey } from '../utils/shortcut'; import { Shortcut } from '../utils/shortcut'; import { multiply, rotate, subtract } from '../utils/vector'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 拖拽画布交互配置项 * * Drag canvas behavior options */ export interface DragCanvasOptions extends BaseBehaviorOptions { /** * 是否启用拖拽动画,仅在使用按键移动时有效 * * Whether to enable the animation of dragging, only valid when using key movement */ animation?: ViewportAnimationEffectTiming; /** * 是否启用拖拽画布的功能 * * Whether to enable the function of dragging the canvas * @defaultValue true */ enable?: boolean | ((event: IPointerEvent | IKeyboardEvent) => boolean); /** * 允许拖拽方向 * - `'x'`: 只允许水平拖拽 * - `'y'`: 只允许垂直拖拽 * - `'both'`: 不受限制,允许水平和垂直拖拽 * * Allowed drag direction * - `'x'`: Only allow horizontal drag * - `'y'`: Only allow vertical drag * - `'both'`: Allow horizontal and vertical drag * @defaultValue `'both'` */ direction?: 'x' | 'y' | 'both'; /** * 可拖拽的视口范围,默认最多可拖拽一屏。可以分别设置上、右、下、左四个方向的范围,每个方向的范围在 [0, Infinity] 之间 * * The draggable viewport range allows you to drag up to one screen by default. You can set the range for each direction (top, right, bottom, left) individually, with each direction's range between [0, Infinity] * @defaultValue Infinity */ range?: number | number[]; /** * 触发拖拽的方式,默认使用指针按下拖拽 * * The way to trigger dragging, default to dragging with the pointer pressed */ trigger?: { up: ShortcutKey; down: ShortcutKey; left: ShortcutKey; right: ShortcutKey; }; /** * 触发一次按键移动的距离 * * The distance of a single key movement * @defaultValue 10 */ sensitivity?: number; /** * 完成拖拽时的回调 * * Callback when dragging is completed */ onFinish?: () => void; } /** * 拖拽画布交互 * * Drag canvas behavior */ export class DragCanvas extends BaseBehavior { static defaultOptions: Partial = { enable: (event) => { if ('targetType' in event) return event.targetType === 'canvas'; return true; }, sensitivity: 10, direction: 'both', range: Infinity, }; private shortcut: Shortcut; private defaultCursor: Cursor; constructor(context: RuntimeContext, options: DragCanvasOptions) { super(context, Object.assign({}, DragCanvas.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.bindEvents(); this.defaultCursor = this.context.canvas.getConfig().cursor || 'default'; } /** * 更新配置 * * Update options * @param options - 配置项 | Options * @internal */ public update(options: Partial): void { this.unbindEvents(); super.update(options); this.bindEvents(); } private bindEvents() { const { trigger } = this.options; if (isObject(trigger)) { const { up = [], down = [], left = [], right = [] } = trigger; this.shortcut.bind(up, (event) => this.onTranslate([0, 1], event)); this.shortcut.bind(down, (event) => this.onTranslate([0, -1], event)); this.shortcut.bind(left, (event) => this.onTranslate([1, 0], event)); this.shortcut.bind(right, (event) => this.onTranslate([-1, 0], event)); } else { const { graph } = this.context; graph.on(CommonEvent.DRAG_START, this.onDragStart); graph.on(CommonEvent.DRAG, this.onDrag); graph.on(CommonEvent.DRAG_END, this.onDragEnd); } } private isDragging = false; private onDragStart = (event: IDragEvent) => { if (!this.validate(event)) return; this.isDragging = true; this.context.canvas.setCursor('grabbing'); }; private onDrag = (event: IDragEvent) => { if (!this.isDragging || PinchHandler.isPinching) return; const x = event.movement?.x ?? event.dx; const y = event.movement?.y ?? event.dy; if ((x | y) !== 0) { this.translate([x, y], false); } }; private onDragEnd = () => { this.isDragging = false; this.context.canvas.setCursor(this.defaultCursor); this.options.onFinish?.(); }; private invokeOnFinish = debounce(() => { this.options.onFinish?.(); }, 300); private async onTranslate(value: Vector2, event: IPointerEvent | IKeyboardEvent) { if (!this.validate(event)) return; const { sensitivity } = this.options; const delta = sensitivity * -1; await this.translate(multiply(value, delta) as Vector2, this.options.animation); this.invokeOnFinish(); } /** * 平移画布 * * Translate canvas * @param offset - 平移距离 | Translation distance * @param animation - 动画配置 | Animation configuration * @internal */ protected async translate(offset: Vector2, animation?: ViewportAnimationEffectTiming) { offset = this.clampByDirection(offset); offset = this.clampByRange(offset); offset = this.clampByRotation(offset); await this.context.graph.translateBy(offset, animation); } private clampByRotation([dx, dy]: Vector2): Vector2 { const rotation = this.context.graph.getRotation(); return rotate([dx, dy], rotation); } private clampByDirection([dx, dy]: Vector2): Vector2 { const { direction } = this.options; if (direction === 'x') { dy = 0; } else if (direction === 'y') { dx = 0; } return [dx, dy]; } private clampByRange([dx, dy]: Vector2): Vector2 { const { viewport, canvas } = this.context; const [canvasWidth, canvasHeight] = canvas.getSize(); const [top, right, bottom, left] = parsePadding(this.options.range); const range = [canvasHeight * top, canvasWidth * right, canvasHeight * bottom, canvasWidth * left]; const draggableArea = getExpandedBBox(getPointBBox(viewport!.getCanvasCenter()), range); const nextViewportCenter = subtract(viewport!.getViewportCenter(), [dx, dy, 0]); if (!isPointInBBox(nextViewportCenter, draggableArea)) { const { min: [minX, minY], max: [maxX, maxY], } = draggableArea; if ((nextViewportCenter[0] < minX && dx > 0) || (nextViewportCenter[0] > maxX && dx < 0)) { dx = 0; } if ((nextViewportCenter[1] < minY && dy > 0) || (nextViewportCenter[1] > maxY && dy < 0)) { dy = 0; } } return [dx, dy]; } private validate(event: IPointerEvent | IKeyboardEvent) { if (this.destroyed) return false; const { enable } = this.options; if (typeof enable === 'function') return enable(event); return !!enable; } private unbindEvents() { this.shortcut.unbindAll(); const { graph } = this.context; graph.off(CommonEvent.DRAG_START, this.onDragStart); graph.off(CommonEvent.DRAG, this.onDrag); graph.off(CommonEvent.DRAG_END, this.onDragEnd); } public destroy(): void { this.shortcut.destroy(); this.unbindEvents(); this.context.canvas.setCursor(this.defaultCursor); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/drag-element-force.ts ================================================ import type { ID, IElementDragEvent, Point } from '../types'; import { idOf } from '../utils/id'; import { getLayoutProperty, invokeLayoutMethod } from '../utils/layout'; import { print } from '../utils/print'; import { add } from '../utils/vector'; import type { DragElementOptions } from './drag-element'; import { DragElement } from './drag-element'; /** * 调用力导布局拖拽元素交互配置项 * * Call d3-force layout to drag element behavior options */ export interface DragElementForceOptions extends Omit { /** * 在拖拽结束后,节点是否保持固定位置 * - `true`: 在拖拽结束后,节点的位置将保持固定,不受布局算法的影响 * - `false`: 在拖拽结束后,节点的位置将继续受到布局算法的影响 * * Whether the node remains in a fixed position after dragging ends * - `true`: After dragging ends, the node's position will remain fixed and will not be affected by the layout algorithm * - `false`: After dragging ends, the node's position will continue to be affected by the layout algorithm */ fixed?: boolean; } /** * 调用力导布局拖拽元素的交互 * * Call d3-force layout to drag element behavior * @remarks * 只能在使用 d3-force 布局时使用该交互,在拖拽过程中会实时重新计算布局。 * * This behavior can only be used with d3-force layout. The layout will be recalculated in real time during dragging. */ export class DragElementForce extends DragElement { private get forceLayoutInstance() { return this.context.layout!.getLayoutInstance().find((layout) => ['d3-force', 'd3-force-3d'].includes(layout?.id)); } /** * Whether the behavior is enabled * @param event - The event object * @returns Is the behavior enabled * @internal */ protected validate(event: IElementDragEvent): boolean { if (!this.context.layout) return false; // 未使用力导布局 / The force layout is not used if (!this.forceLayoutInstance) { print.warn('DragElementForce only works with d3-force or d3-force-3d layout'); return false; } return super.validate(event); } /** * Move selected elements by offset * @param ids - The selected element IDs * @param offset - The offset to move * @internal */ protected async moveElement(ids: ID[], offset: Point) { const layout = this.forceLayoutInstance; this.context.graph.getNodeData(ids).forEach((element, index) => { const { x = 0, y = 0 } = element.style || {}; if (layout) invokeLayoutMethod(layout, 'setFixedPosition', ids[index], [...add([+x, +y], this.clampByRotation(offset))]); }); } /** * Triggered when the drag starts * @param event - The event object * @internal */ protected onDragStart(event: IElementDragEvent) { this.enable = this.validate(event); if (!this.enable) return; this.target = this.getSelectedNodeIDs([event.target.id]); this.hideEdge(); this.context.graph.frontElement(this.target); const layout = this.forceLayoutInstance; if (layout) getLayoutProperty(layout, 'simulation').alphaTarget(0.3).restart(); this.context.graph.getNodeData(this.target).forEach((element) => { const { x = 0, y = 0 } = element.style || {}; if (layout) invokeLayoutMethod(layout, 'setFixedPosition', idOf(element), [+x, +y]); }); } /** * Triggered when dragging * @param event - The event object * @internal */ protected onDrag(event: IElementDragEvent) { if (!this.enable) return; const delta = this.getDelta(event); this.moveElement(this.target, delta); } /** * Triggered when the drag ends * @internal */ protected onDragEnd() { const layout = this.forceLayoutInstance; if (layout) getLayoutProperty(layout, 'simulation').alphaTarget(0); if (this.options.fixed) return; this.context.graph.getNodeData(this.target).forEach((element) => { if (layout) invokeLayoutMethod(layout, 'setFixedPosition', idOf(element), [null, null, null]); }); } } ================================================ FILE: packages/g6/src/behaviors/drag-element.ts ================================================ import type { BaseStyleProps, Cursor } from '@antv/g'; import { Rect } from '@antv/g'; import { isFunction } from '@antv/util'; import { COMBO_KEY, CanvasEvent, ComboEvent, CommonEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { EdgeDirection, ID, IElementDragEvent, IPointerEvent, Point, Prefix, State, Vector2 } from '../types'; import { getBBoxSize, getCombinedBBox } from '../utils/bbox'; import { isToBeDestroyed } from '../utils/element'; import { idOf } from '../utils/id'; import { subStyleProps } from '../utils/prefix'; import { Shortcut, ShortcutKey } from '../utils/shortcut'; import { divide, rotate, subtract } from '../utils/vector'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 拖拽元素交互配置项 * * Drag element behavior options */ export interface DragElementOptions extends BaseBehaviorOptions, Prefix<'shadow', BaseStyleProps> { /** * 是否启用拖拽动画 * * Whether to enable drag animation * @defaultValue true */ animation?: boolean; /** * 是否启用拖拽节点的功能,默认可以拖拽 node 和 combo * * Whether to enable the function of dragging the node,default can drag node and combo * @defaultValue ['node', 'combo'].includes(event.targetType) */ enable?: boolean | ((event: IElementDragEvent) => boolean); /** * 触发拖拽的方式 * 支持按下组合键才能触发拖拽元素 * * The way to trigger drag element * Support triggering by pressing a combination of keys */ trigger?: ShortcutKey; /** * 拖拽操作效果 * - `'link'`: 将拖拽元素置入为目标元素的子元素 * - `'move'`: 移动元素并更新父元素尺寸 * - `'none'`: 仅更新拖拽目标位置,不做任何额外操作 * * Drag operation effect * - `'link'`: Place the drag element as a child element of the target element * - `'move'`: Move the element and update the parent element size * - `'none'`: Only update the drag target position, no additional operations * @remarks * combo 元素可作为元素容器置入 node 或 combo 元素 * * The combo element can be placed as an element container into the node or combo element * @defaultValue 'move' */ dropEffect?: 'link' | 'move' | 'none'; /** * 节点选中的状态,启用多选时会基于该状态查找选中的节点 * * The state name of the selected node, when multi-selection is enabled, the selected nodes will be found based on this state * @defaultValue 'selected' */ state?: State; /** * 拖拽时隐藏的边 * - `'none'`: 不隐藏 * - `'out'`: 隐藏以节点为源节点的边 * - `'in'`: 隐藏以节点为目标节点的边 * - `'both'`: 隐藏与节点相关的所有边 * - `'all'`: 隐藏图中所有边 * * Edges hidden during dragging * - `'none'`: do not hide * - `'out'`: hide the edges with the node as the source node * - `'in'`: hide the edges with the node as the target node * - `'both'`: hide all edges related to the node * - `'all'`: hide all edges in the graph * @remarks * 使用幽灵节点时不会隐藏边 * * Edges will not be hidden when using the drag shadow * @defaultValue 'none' */ hideEdge?: 'none' | 'all' | EdgeDirection; /** * 是否启用幽灵节点,即用一个图形代替节点跟随鼠标移动 * * Whether to enable the drag shadow, that is, use a shape to replace the node to follow the mouse movement */ shadow?: boolean; /** * 完成拖拽时的回调 * * Callback when dragging is completed */ onFinish?: (ids: ID[]) => void; /** * 指针样式 * * Cursor style */ cursor?: { /** * 默认指针样式 * * Default cursor style */ default?: Cursor; /** * 可抓取指针样式 * * Cursor style that can be grabbed */ grab: Cursor; /** * 抓取中指针样式 * * Cursor style when grabbing */ grabbing: Cursor; }; } /** * 拖拽元素交互 * * Drag element behavior */ export class DragElement extends BaseBehavior { static defaultOptions: Partial = { animation: true, enable: (event) => ['node', 'combo'].includes(event.targetType), trigger: [], dropEffect: 'move', state: 'selected', hideEdge: 'none', shadow: false, shadowZIndex: 100, shadowFill: '#F3F9FF', shadowFillOpacity: 0.5, shadowStroke: '#1890FF', shadowStrokeOpacity: 0.9, shadowLineDash: [5, 5], cursor: { default: 'default', grab: 'grab', grabbing: 'grabbing', }, }; protected enable: boolean = false; private enableElements = ['node', 'combo']; protected target: ID[] = []; private shadow?: Rect; private shadowOrigin: Point = [0, 0]; private hiddenEdges: ID[] = []; private isDragging: boolean = false; private shortcut: Shortcut; constructor(context: RuntimeContext, options: DragElementOptions) { super(context, Object.assign({}, DragElement.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.onDragStart = this.onDragStart.bind(this); this.onDrag = this.onDrag.bind(this); this.onDragEnd = this.onDragEnd.bind(this); this.onDrop = this.onDrop.bind(this); this.bindEvents(); } /** * 更新元素拖拽配置 * * Update the element dragging configuration * @param options - 配置项 | options * @internal */ public update(options: Partial): void { this.unbindEvents(); super.update(options); this.bindEvents(); } private bindEvents() { const { graph, canvas } = this.context; // @ts-expect-error internal property const $canvas: HTMLCanvasElement = canvas.getLayer().getContextService().$canvas; if ($canvas) { $canvas.addEventListener('blur', this.onDragEnd); $canvas.addEventListener('contextmenu', this.onDragEnd); } this.enableElements.forEach((type) => { graph.on(`${type}:${CommonEvent.DRAG_START}`, this.onDragStart); graph.on(`${type}:${CommonEvent.DRAG}`, this.onDrag); graph.on(`${type}:${CommonEvent.DRAG_END}`, this.onDragEnd); graph.on(`${type}:${CommonEvent.POINTER_ENTER}`, this.setCursor); graph.on(`${type}:${CommonEvent.POINTER_LEAVE}`, this.setCursor); }); if (['link'].includes(this.options.dropEffect)) { graph.on(ComboEvent.DROP, this.onDrop); graph.on(CanvasEvent.DROP, this.onDrop); } } /** * 获取当前选中的节点 id 集合 * * Get the id collection of the currently selected node * @param currTarget - 当前拖拽目标元素 id 集合 | The id collection of the current drag target element * @returns 当前选中的节点 id 集合 | The id collection of the currently selected node * @internal */ protected getSelectedNodeIDs(currTarget: ID[]) { return Array.from( new Set( this.context.graph .getElementDataByState('node', this.options.state) .map((node) => node.id) .concat(currTarget), ), ); } /** * Get the delta of the drag * @param event - drag event object * @returns delta * @internal */ protected getDelta(event: IElementDragEvent) { const zoom = this.context.graph.getZoom(); return divide([event.dx, event.dy], zoom); } /** * 拖拽开始时的回调 * * Callback when dragging starts * @param event - 拖拽事件对象 | drag event object * @internal */ protected onDragStart(event: IElementDragEvent) { this.enable = this.validate(event); if (!this.enable) return; const { batch, canvas, graph } = this.context; canvas.setCursor(this.options!.cursor?.grabbing || 'grabbing'); this.isDragging = true; batch!.startBatch(); // 如果当前节点是选中状态,则查询出画布中所有选中的节点,否则只拖拽当前节点 // If the current node is selected, query all selected nodes in the canvas, otherwise only drag the current node const id = event.target.id; const states = graph.getElementState(id); if (states.includes(this.options.state)) this.target = this.getSelectedNodeIDs([id]); else this.target = [id]; this.hideEdge(); this.context.graph.frontElement(this.target); if (this.options.shadow) this.createShadow(this.target); } /** * 拖拽过程中的回调 * * Callback when dragging * @param event - 拖拽事件对象 | drag event object * @internal */ protected onDrag(event: IElementDragEvent) { if (!this.enable) return; const delta = this.getDelta(event); if (this.options.shadow) this.moveShadow(delta); else this.moveElement(this.target, delta); } /** * 元素拖拽结束的回调 * * Callback when dragging ends * @internal */ protected onDragEnd() { if (!this.enable) return; // It can be called multiple times this.enable = false; if (this.options.shadow) { if (!this.shadow) return; this.shadow.style.visibility = 'hidden'; const { x = 0, y = 0 } = this.shadow.attributes; const [dx, dy] = subtract([+x, +y], this.shadowOrigin); this.moveElement(this.target, [dx, dy]); } this.showEdges(); this.options.onFinish?.(this.target); const { batch, canvas } = this.context; batch!.endBatch(); canvas.setCursor(this.options!.cursor?.grab || 'grab'); this.isDragging = false; this.target = []; } /** * 拖拽放下的回调 * * Callback when dragging is released * @param event - 拖拽事件对象 | drag event object */ private onDrop = async (event: IElementDragEvent) => { if (this.options.dropEffect !== 'link') return; const { model, element } = this.context; const modifiedParentId = event.target.id; this.target.forEach((id) => { const originalParent = model.getParentData(id, COMBO_KEY); // 如果是在原父 combo 内部拖拽,需要刷新 combo 数据 // If it is a drag and drop within the original parent combo, you need to refresh the combo data if (originalParent && idOf(originalParent) === modifiedParentId) { model.refreshComboData(modifiedParentId); } model.setParent(id, modifiedParentId, COMBO_KEY); }); await element?.draw({ animation: true })?.finished; }; private setCursor = (event: IPointerEvent) => { if (this.isDragging) return; const { type } = event; const { canvas } = this.context; const { cursor } = this.options; if (type === CommonEvent.POINTER_ENTER) canvas.setCursor(cursor?.grab || 'grab'); else canvas.setCursor(cursor?.default || 'default'); }; /** * 当前按键是否和 trigger 配置一致 * * Is the current key consistent with the trigger configuration * @returns 是否一致 | Is consistent * @internal */ protected isKeydown(): boolean { const { trigger } = this.options; if (!trigger?.length) return true; return this.shortcut.match(trigger); } /** * 验证元素是否允许拖拽 * * Verify if the element is allowed to be dragged * @param event - 拖拽事件对象 | drag event object * @returns 是否允许拖拽 | Whether to allow dragging * @internal */ protected validate(event: IElementDragEvent) { if ( this.destroyed || isToBeDestroyed(event.target) || // @ts-expect-error private property // 避免动画冲突,在combo/node折叠展开过程中不触发 this.context.graph.isCollapsingExpanding || !this.isKeydown() ) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } protected clampByRotation([dx, dy]: Point): Vector2 { const rotation = this.context.graph.getRotation(); return rotate([dx, dy], rotation); } /** * 移动元素 * * Move the element * @param ids - 元素 id 集合 | element id collection * @param offset 偏移量 | offset * @internal */ protected async moveElement(ids: ID[], offset: Point) { const { graph, model } = this.context; const { dropEffect } = this.options; if (dropEffect === 'move') ids.forEach((id) => model.refreshComboData(id)); graph.translateElementBy(Object.fromEntries(ids.map((id) => [id, this.clampByRotation(offset)])), false); } private moveShadow(offset: Point) { if (!this.shadow) return; const { x = 0, y = 0 } = this.shadow.attributes; const [dx, dy] = offset; this.shadow.attr({ x: +x + dx, y: +y + dy }); } private createShadow(target: ID[]) { const shadowStyle = subStyleProps(this.options, 'shadow'); const bbox = getCombinedBBox(target.map((id) => this.context.element!.getElement(id)!.getBounds())); const [x, y] = bbox.min; this.shadowOrigin = [x, y]; const [width, height] = getBBoxSize(bbox); const positionStyle = { width, height, x, y }; if (this.shadow) { this.shadow.attr({ ...shadowStyle, ...positionStyle, visibility: 'visible', }); } else { this.shadow = new Rect({ style: { // @ts-ignore $layer is not in the type definition $layer: 'transient', ...shadowStyle, ...positionStyle, pointerEvents: 'none', }, }); this.context.canvas.appendChild(this.shadow); } } private showEdges() { if (this.options.shadow || this.hiddenEdges.length === 0) return; this.context.graph.showElement(this.hiddenEdges); this.hiddenEdges = []; } /** * Hide the edge * @internal */ protected hideEdge() { const { hideEdge, shadow } = this.options; if (hideEdge === 'none' || shadow) return; const { graph } = this.context; if (hideEdge === 'all') this.hiddenEdges = graph.getEdgeData().map(idOf); else { this.hiddenEdges = Array.from( new Set(this.target.map((id) => graph.getRelatedEdgesData(id, hideEdge).map(idOf)).flat()), ); } graph.hideElement(this.hiddenEdges); } private unbindEvents() { const { graph, canvas } = this.context; // @ts-expect-error internal property const $canvas: HTMLCanvasElement = canvas.getLayer().getContextService().$canvas; if ($canvas) { $canvas.removeEventListener('blur', this.onDragEnd); $canvas.removeEventListener('contextmenu', this.onDragEnd); } this.enableElements.forEach((type) => { graph.off(`${type}:${CommonEvent.DRAG_START}`, this.onDragStart); graph.off(`${type}:${CommonEvent.DRAG}`, this.onDrag); graph.off(`${type}:${CommonEvent.DRAG_END}`, this.onDragEnd); graph.off(`${type}:${CommonEvent.POINTER_ENTER}`, this.setCursor); graph.off(`${type}:${CommonEvent.POINTER_LEAVE}`, this.setCursor); }); graph.off(`combo:${CommonEvent.DROP}`, this.onDrop); graph.off(`canvas:${CommonEvent.DROP}`, this.onDrop); } public destroy() { this.unbindEvents(); this.shadow?.destroy(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/fix-element-size.ts ================================================ import type { DisplayObject } from '@antv/g'; import { isEmpty, isFunction, isNumber } from '@antv/util'; import { GraphEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { ComboData, EdgeData, NodeData } from '../spec'; import type { Combo, Edge, Element, ID, IGraphLifeCycleEvent, IViewportEvent, Node, NodeLikeData, State, } from '../types'; import { idOf } from '../utils/id'; import { getDescendantShapes } from '../utils/shape'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; export type FixShapeConfig = { /** * 指定要固定大小的图形,可以是图形的类名字,或者是一个函数,该函数接收构成元素的所有图形并返回目标图形 * * Specify the shape to be fixed in size. This can be a class name string of the shape, or a function that takes all shapes composing the element and returns the target shape */ shape: string | ((shapes: DisplayObject[]) => DisplayObject); /** * 指定要固定大小的图形属性字段。如果未指定,则默认固定整个图形的大小 * * Specify the fields of the shape to be fixed in size. If not specified, the entire shape's size will be fixed by default */ fields?: string[]; }; /** * 固定元素大小交互配置项 * * Fix element size behavior options */ export interface FixElementSizeOptions extends BaseBehaviorOptions { /** * 是否启用固定元素大小交互。默认在缩小画布时启用 * * Whether to enable the fix element size behavior. Enabled by default when zooming out * @remarks * 默认在缩小画布时启用,设置 `enable: (event) => event.data.scale < 1`;如果希望在放大画布时启用,设置 `enable: (event) => event.data.scale > 1`;如果希望在放大缩小画布时都启用,设置 `enable: true` * * Enabled by default when zooming out, set `enable: (event) => event.data.scale < 1`; If you want to enable it when zooming in, set `enable: (event) => event.data.scale > 1`; If you want to enable it when zooming in and out, set `enable: true` * @defaultValue (event) => Boolean(event.data.scale < 1) */ enable?: boolean | ((event: IViewportEvent) => boolean); /** * 指定要固定大小的元素状态 * * Specify the state of elements to be fixed in size * @defaultValue `'selected'` */ state?: State; /** * 节点过滤器,用于过滤哪些节点在缩放过程中保持固定大小 * * Node filter for filtering which nodes remain fixed in size during zooming * @defaultValue () => true */ nodeFilter?: (datum: NodeData) => boolean; /** * 边过滤器,用于过滤哪些边在缩放过程中保持固定大小 * * Edge filter for filtering which edges remain fixed in size during zooming * @defaultValue () => true */ edgeFilter?: (datum: EdgeData) => boolean; /** * Combo 过滤器,用于过滤哪些 Combo 在缩放过程中保持固定大小 * * Combo filter for filtering which combos remain fixed in size during zooming * @defaultValue () => true */ comboFilter?: (datum: ComboData) => boolean; /** * 节点配置项,用于定义哪些属性在视觉上保持固定大小。若未指定(即为 undefined),则整个节点将被固定 * * Node configuration for defining which node attributes should remain fixed in size visually. If not specified (i.e., undefined), the entire node will be fixed in size. * @example * 如果在缩放过程中希望固定节点主图形的 lineWidth,可以这样配置: * * If you want to fix the lineWidth of the key shape of the node during zooming, you can configure it like this: * ```ts * { node: [{ shape: 'key', fields: ['lineWidth'] }] } *``` * 如果在缩放过程中想保持元素标签大小不变,可以这样配置: * * If you want to keep the label size of the element unchanged during zooming, you can configure it like this: * ```ts * { shape: 'label' } * ``` */ node?: FixShapeConfig | FixShapeConfig[]; /** * 边配置项,用于定义哪些属性在视觉上保持固定大小。默认固定 lineWidth、labelFontSize 属性 * * Edge configuration for defining which edge attributes should remain fixed in size visually. By default, the lineWidth and labelFontSize attributes are fixed * @defaultValue [{ shape: 'key', fields: ['lineWidth'] }, { shape: 'halo', fields: ['lineWidth'] }, { shape: 'label' }] */ edge?: FixShapeConfig | FixShapeConfig[]; /** * Combo 配置项,用于定义哪些属性在视觉上保持固定大小。默认整个 Combo 将被固定 * * Combo configuration for defining which combo attributes should remain fixed in size visually. By default, the entire combo will be fixed */ combo?: FixShapeConfig | FixShapeConfig[]; /** * 元素重绘时是否还原样式 * * Whether to reset styles when elements are redrawn * @defaultValue false */ reset?: boolean; } /** * 缩放画布过程中固定元素大小 * * Fix element size while zooming */ export class FixElementSize extends BaseBehavior { static defaultOptions: Partial = { enable: (event: IViewportEvent) => event.data.scale! < 1, nodeFilter: () => true, edgeFilter: () => true, comboFilter: () => true, edge: [{ shape: 'key', fields: ['lineWidth'] }, { shape: 'halo', fields: ['lineWidth'] }, { shape: 'label' }], reset: false, }; constructor(context: RuntimeContext, options: FixElementSizeOptions) { super(context, Object.assign({}, FixElementSize.defaultOptions, options)); this.bindEvents(); } private isZoomEvent = (event: IViewportEvent) => Boolean(event.data && 'scale' in event.data); private relatedEdgeToUpdate: Set = new Set(); private zoom = this.context.graph.getZoom(); private fixElementSize = async (event: IViewportEvent) => { if (!this.validate(event)) return; const { graph } = this.context; const { state, nodeFilter, edgeFilter, comboFilter } = this.options; const nodeData = (state ? graph.getElementDataByState('node', state) : graph.getNodeData()).filter(nodeFilter); const edgeData = (state ? graph.getElementDataByState('edge', state) : graph.getEdgeData()).filter(edgeFilter); const comboData = (state ? graph.getElementDataByState('combo', state) : graph.getComboData()).filter(comboFilter); // 设置阈值防止过大或过小时抖动 | Set the threshold to prevent jitter when too large or too small const currentScale = this.isZoomEvent(event) ? (this.zoom = Math.max(0.01, Math.min(event.data.scale!, 10))) : this.zoom; const nodeLikeData = [...nodeData, ...comboData]; if (nodeLikeData.length > 0) { nodeLikeData.forEach((datum) => this.fixNodeLike(datum, currentScale)); } this.updateRelatedEdges(); if (edgeData.length > 0) { edgeData.forEach((datum) => this.fixEdge(datum, currentScale)); } }; private cachedStyles: Map = new Map(); private getOriginalFieldValue = (id: ID, shape: DisplayObject, field: string) => { const shapesStyle = this.cachedStyles.get(id) || []; const shapeStyle = shapesStyle.find((style) => style.shape === shape)?.style || {}; if (!(field in shapeStyle)) { shapeStyle[field] = shape.attributes[field]; this.cachedStyles.set(id, [ ...shapesStyle.filter((style) => style.shape !== shape), { shape, style: shapeStyle }, ]); } return shapeStyle[field]; }; private scaleEntireElement = (id: ID, el: DisplayObject, currentScale: number) => { el.setLocalScale(1 / currentScale); const shapesStyle = this.cachedStyles.get(id) || []; shapesStyle.push({ shape: el }); this.cachedStyles.set(id, shapesStyle); }; private scaleSpecificShapes = (el: Element, currentScale: number, config: FixShapeConfig | FixShapeConfig[]) => { const descendantShapes = getDescendantShapes(el); const configs = Array.isArray(config) ? config : [config]; configs.forEach((config: FixShapeConfig) => { const { shape: shapeFilter, fields } = config; const shape = typeof shapeFilter === 'function' ? shapeFilter(descendantShapes) : el.getShape(shapeFilter); if (!shape) return; if (!fields) { this.scaleEntireElement(el.id, shape, currentScale); return; } fields.forEach((field) => { const oriFieldValue = this.getOriginalFieldValue(el.id, shape, field); if (!isNumber(oriFieldValue)) return; shape.style[field] = oriFieldValue / currentScale; }); }); }; private skipIfExceedViewport = (el: Element) => { const { viewport } = this.context; return !viewport?.isInViewport(el.getRenderBounds(), false, 30); }; private fixNodeLike = (datum: NodeLikeData, currentScale: number) => { const id = idOf(datum); const { element, model } = this.context; const el = element!.getElement(id) as Node | Combo; if (!el || this.skipIfExceedViewport(el)) return; const edges = model.getRelatedEdgesData(id); edges.forEach((edge) => this.relatedEdgeToUpdate.add(idOf(edge))); const config = this.options[(el as any).type]; if (!config) { this.scaleEntireElement(id, el, currentScale); return; } this.scaleSpecificShapes(el, currentScale, config); }; private fixEdge = (datum: EdgeData, currentScale: number) => { const id = idOf(datum); const el = this.context.element!.getElement(id) as Edge; if (!el || this.skipIfExceedViewport(el)) return; const config = this.options.edge; if (!config) { el.style.transformOrigin = 'center'; this.scaleEntireElement(id, el, currentScale); return; } this.scaleSpecificShapes(el, currentScale, config); }; private updateRelatedEdges = () => { const { element } = this.context; if (this.relatedEdgeToUpdate.size > 0) { this.relatedEdgeToUpdate.forEach((id) => { const edge = element!.getElement(id) as Edge; edge?.update({}); }); } this.relatedEdgeToUpdate.clear(); }; private restoreCachedStyles() { if (this.cachedStyles.size > 0) { this.cachedStyles.forEach((shapesStyle) => { shapesStyle.forEach(({ shape, style }) => { if (isEmpty(style)) { shape.setLocalScale(1); } else { if (this.options.state) return; Object.entries(style).forEach(([field, value]) => (shape.style[field] = value)); } }); }); const { graph, element } = this.context; const nodeIds = Object.keys(Object.fromEntries(this.cachedStyles)).filter( (id) => id && graph.getElementType(id) === 'node', ); if (nodeIds.length > 0) { const edgeIds = new Set(); nodeIds.forEach((id) => { graph.getRelatedEdgesData(id).forEach((edge) => edgeIds.add(idOf(edge))); }); edgeIds.forEach((id) => { const edge = element?.getElement(id) as Edge; edge?.update({}); }); } } // this.cachedStyles.clear(); } private resetTransform = async (event: IGraphLifeCycleEvent) => { // 首屏渲染时跳过 | Skip when rendering the first screen if (event.data?.firstRender) return; if (this.options.reset) { this.restoreCachedStyles(); } else { this.fixElementSize({ data: { scale: this.zoom } } as IViewportEvent); } }; private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.AFTER_DRAW, this.resetTransform); graph.on(GraphEvent.AFTER_TRANSFORM, this.fixElementSize); } private unbindEvents() { const { graph } = this.context; graph.off(GraphEvent.AFTER_DRAW, this.resetTransform); graph.off(GraphEvent.AFTER_TRANSFORM, this.fixElementSize); } private validate(event: IViewportEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } public destroy(): void { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/focus-element.ts ================================================ import { isFunction } from '@antv/util'; import { CommonEvent } from '../constants'; import { ELEMENT_TYPES } from '../constants/element'; import type { RuntimeContext } from '../runtime/types'; import type { IElementEvent, ViewportAnimationEffectTiming } from '../types'; import { Shortcut, ShortcutKey } from '../utils/shortcut'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 聚焦元素交互配置项 * * Focus element behavior options */ export interface FocusElementOptions extends BaseBehaviorOptions { /** * 是否启用动画以及动画配置 * * Whether to enable animation */ animation?: ViewportAnimationEffectTiming; /** * 是否启用聚焦功能 * * Whether to enable the function of focusing on the element * @defaultValue true */ enable?: boolean | ((event: IElementEvent) => boolean); /** * 触发聚焦的组合键 * 支持按下组合键的同时点击元素才能触发聚焦 * * The shortcut key to trigger focus * Focus can only be triggered when the element is clicked while the key combination is pressed. */ trigger?: ShortcutKey; } /** * 聚焦元素交互行为 * * Focus element behavior * @remarks * 点击元素时,将元素聚焦到视图中心。 * * When an element is clicked, the element is focused to the center of the view. */ export class FocusElement extends BaseBehavior { static defaultOptions: Partial = { animation: { easing: 'ease-in', duration: 500, }, enable: true, trigger: [], }; private shortcut: Shortcut; constructor(context: RuntimeContext, options: FocusElementOptions) { super(context, Object.assign({}, FocusElement.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.bindEvents(); } private bindEvents() { const { graph } = this.context; this.unbindEvents(); ELEMENT_TYPES.forEach((type) => { graph.on(`${type}:${CommonEvent.CLICK}`, this.focus); }); } private focus = async (event: IElementEvent) => { if (!this.validate(event)) return; const { graph } = this.context; await graph.focusElement(event.target.id, this.options.animation); }; private validate(event: IElementEvent) { if (this.destroyed || !this.isKeydown()) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } /** * 当前按键是否和 trigger 配置一致 * * Is the current key consistent with the trigger configuration * @returns 是否一致 | Is consistent * @internal */ private isKeydown(): boolean { const { trigger } = this.options; if (!trigger?.length) return true; return this.shortcut.match(trigger); } private unbindEvents() { const { graph } = this.context; ELEMENT_TYPES.forEach((type) => { graph.off(`${type}:${CommonEvent.CLICK}`, this.focus); }); } public destroy() { this.unbindEvents(); this.shortcut.destroy(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/hover-activate.ts ================================================ import { isFunction } from '@antv/util'; import { CommonEvent } from '../constants'; import { ELEMENT_TYPES } from '../constants/element'; import type { RuntimeContext } from '../runtime/types'; import type { EdgeDirection, Element, ElementType, ID, IDragEvent, IPointerEvent, State } from '../types'; import { isToBeDestroyed } from '../utils/element'; import { idsOf } from '../utils/id'; import { getElementNthDegreeIds } from '../utils/relation'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 悬浮元素交互配置项 * * Hover element behavior options */ export interface HoverActivateOptions extends BaseBehaviorOptions { /** * 是否启用动画 * * Whether to enable animation * @defaultValue true */ animation?: boolean; /** * 是否启用悬浮元素的功能 * * Whether to enable hover element function * @defaultValue true */ enable?: boolean | ((event: IPointerEvent) => boolean); /** * 激活元素的n度关系 * - 默认为 `0`,表示只激活当前节点 * - `1` 表示激活当前节点及其直接相邻的节点和边,以此类推 * * N-degree relationship of the hovered element * - default to `0`, which means only the current node is activated * - `1` means the current node and its directly adjacent nodes and edges are activated, etc * @defaultValue 0 */ degree?: number | ((event: IPointerEvent) => number); /** * 指定边的方向 * - `'both'`: 表示激活当前节点的所有关系 * - `'in'`: 表示激活当前节点的入边和入节点 * - `'out'`: 表示激活当前节点的出边和出节点 * * Specify the direction of the edge * - `'both'`: Activate all relationships of the current node * - `'in'`: Activate the incoming edges and nodes of the current node * - `'out'`: Activate the outgoing edges and nodes of the current node * @defaultValue 'both' */ direction?: EdgeDirection; /** * 激活元素的状态,默认为 `active` * * Active element state, default to`active` * @defaultValue 'active' */ state?: State; /** * 非激活元素的状态,默认为不改变 * * Inactive element state, default to no change */ inactiveState?: State; /** * 当元素被悬停时的回调 * * Callback when the element is hovered */ onHover?: (event: IPointerEvent) => void; /** * 当悬停结束时的回调 * * Callback when the hover ends */ onHoverEnd?: (event: IPointerEvent) => void; } /** * 悬浮元素交互 * * Hover element behavior * @remarks * 当鼠标悬停在元素上时,可以激活元素的状态,例如高亮节点或边。 * * When the mouse hovers over an element, you can activate the state of the element, such as highlighting nodes or edges. */ export class HoverActivate extends BaseBehavior { static defaultOptions: Partial = { animation: false, enable: true, degree: 0, direction: 'both', state: 'active', inactiveState: undefined, }; private isFrozen = false; constructor(context: RuntimeContext, options: HoverActivateOptions) { super(context, Object.assign({}, HoverActivate.defaultOptions, options)); this.bindEvents(); } private toggleFrozen = (e: IDragEvent) => { this.isFrozen = e.type === 'dragstart'; }; private bindEvents() { const { graph } = this.context; this.unbindEvents(); ELEMENT_TYPES.forEach((type) => { graph.on(`${type}:${CommonEvent.POINTER_ENTER}`, this.hoverElement); graph.on(`${type}:${CommonEvent.POINTER_LEAVE}`, this.hoverElement); }); const canvas = this.context.canvas.document; canvas.addEventListener(`${CommonEvent.DRAG_START}`, this.toggleFrozen); canvas.addEventListener(`${CommonEvent.DRAG_END}`, this.toggleFrozen); } private hoverElement = (event: IPointerEvent) => { if (!this.validate(event)) return; const isEnter = event.type === CommonEvent.POINTER_ENTER; this.updateElementsState(event, isEnter); const { onHover, onHoverEnd } = this.options; if (isEnter) onHover?.(event); else onHoverEnd?.(event); }; protected getActiveIds(event: IPointerEvent) { const { graph } = this.context; const { degree, direction } = this.options; const elementId = event.target.id; return degree ? getElementNthDegreeIds( graph, event.targetType as ElementType, elementId, typeof degree === 'function' ? degree(event) : degree, direction, ) : [elementId]; } private updateElementsState = (event: IPointerEvent, add: boolean) => { if (!this.options.state && !this.options.inactiveState) return; const { graph } = this.context; const { state, animation, inactiveState } = this.options; const activeIds = this.getActiveIds(event); const states: Record = {}; if (state) { Object.assign(states, this.getElementsState(activeIds, state, add)); } if (inactiveState) { const inactiveIds = idsOf(graph.getData(), true).filter((id) => !activeIds.includes(id)); Object.assign(states, this.getElementsState(inactiveIds, inactiveState, add)); } graph.setElementState(states, animation); }; private getElementsState = (ids: ID[], state: State, add: boolean) => { const { graph } = this.context; const states: Record = {}; ids.forEach((id) => { const currentState = graph.getElementState(id); if (add) { states[id] = currentState.includes(state) ? currentState : [...currentState, state]; } else { states[id] = currentState.filter((s) => s !== state); } }); return states; }; private validate(event: IPointerEvent) { if ( this.destroyed || this.isFrozen || isToBeDestroyed(event.target) || // @ts-expect-error private property // 避免动画冲突,在combo/node折叠展开过程中不触发 this.context.graph.isCollapsingExpanding ) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } private unbindEvents() { const { graph } = this.context; ELEMENT_TYPES.forEach((type) => { graph.off(`${type}:${CommonEvent.POINTER_ENTER}`, this.hoverElement); graph.off(`${type}:${CommonEvent.POINTER_LEAVE}`, this.hoverElement); }); const canvas = this.context.canvas.document; canvas.removeEventListener(`${CommonEvent.DRAG_START}`, this.toggleFrozen); canvas.removeEventListener(`${CommonEvent.DRAG_END}`, this.toggleFrozen); } public destroy() { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/index.ts ================================================ export { AutoAdaptLabel } from './auto-adapt-label'; export { BaseBehavior } from './base-behavior'; export { BrushSelect } from './brush-select'; export { ClickSelect } from './click-select'; export { CollapseExpand } from './collapse-expand'; export { CreateEdge } from './create-edge'; export { DragCanvas } from './drag-canvas'; export { DragElement } from './drag-element'; export { DragElementForce } from './drag-element-force'; export { FixElementSize } from './fix-element-size'; export { FocusElement } from './focus-element'; export { HoverActivate } from './hover-activate'; export { LassoSelect } from './lasso-select'; export { OptimizeViewportTransform } from './optimize-viewport-transform'; export { ScrollCanvas } from './scroll-canvas'; export { ZoomCanvas } from './zoom-canvas'; export type { AutoAdaptLabelOptions } from './auto-adapt-label'; export type { BaseBehaviorOptions } from './base-behavior'; export type { BrushSelectOptions } from './brush-select'; export type { ClickSelectOptions } from './click-select'; export type { CollapseExpandOptions } from './collapse-expand'; export type { CreateEdgeOptions } from './create-edge'; export type { DragCanvasOptions } from './drag-canvas'; export type { DragElementOptions } from './drag-element'; export type { DragElementForceOptions } from './drag-element-force'; export type { FixElementSizeOptions } from './fix-element-size'; export type { FocusElementOptions } from './focus-element'; export type { HoverActivateOptions } from './hover-activate'; export type { LassoSelectOptions } from './lasso-select'; export type { OptimizeViewportTransformOptions } from './optimize-viewport-transform'; export type { ScrollCanvasOptions } from './scroll-canvas'; export type { ZoomCanvasOptions } from './zoom-canvas'; ================================================ FILE: packages/g6/src/behaviors/lasso-select.ts ================================================ import { Path } from '@antv/g'; import type { IPointerEvent, Point } from '../types'; import { pointsToPath } from '../utils/path'; import type { BrushSelectOptions } from './brush-select'; import { BrushSelect, getCursorPoint } from './brush-select'; /** * 套索选择交互配置项 * * Lasso select behavior options */ export interface LassoSelectOptions extends BrushSelectOptions {} /** * 套索选择交互 * * Lasso select behavior * @remarks * 用不规则多边形框选一组元素。 * * Select a group of elements with an irregular polygon. */ export class LassoSelect extends BrushSelect { private points?: Point[]; private pathShape?: Path; /** * Triggered when the mouse is pressed * @param event - mouse event * @internal */ protected onPointerDown(event: IPointerEvent) { if (!super.validate(event) || !super.isKeydown() || this.points) return; const { canvas, graph } = this.context; this.pathShape = new Path({ id: 'g6-lasso-select', style: this.options.style, }); canvas.appendChild(this.pathShape); this.points = [getCursorPoint(event, graph)]; } /** * Triggered when the mouse is moved * @param event - mouse event * @internal */ protected onPointerMove(event: IPointerEvent) { if (!this.points) return; const { immediately, mode } = this.options; this.points.push(getCursorPoint(event, this.context.graph)); this.pathShape?.setAttribute('d', pointsToPath(this.points)); if (immediately && mode === 'default' && this.points.length > 2) super.updateElementsStates(this.points); } /** * Triggered when the mouse is released * @internal */ protected onPointerUp() { if (!this.points) return; if (this.points.length < 2) { this.clearLasso(); return; } super.updateElementsStates(this.points); this.clearLasso(); } private clearLasso() { this.pathShape?.remove(); this.pathShape = undefined; this.points = undefined; } } ================================================ FILE: packages/g6/src/behaviors/optimize-viewport-transform.ts ================================================ import type { BaseStyleProps, DisplayObject } from '@antv/g'; import { debounce, isFunction } from '@antv/util'; import { GraphEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { ElementType } from '../types'; import type { IViewportEvent } from '../types/event'; import { setVisibility } from '../utils/visibility'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 画布优化交互配置项 * * Canvas optimization behavior options */ export interface OptimizeViewportTransformOptions extends BaseBehaviorOptions { /** * 是否启用画布优化功能 * * Whether to enable canvas optimization function * @defaultValue true */ enable?: boolean | ((event: IViewportEvent) => boolean); /** * 指定始终显示的图形元素。应用此交互后,在画布操作过程中,只有通过该属性指定的图形元素会保持可见,其余图形元素将被隐藏,从而提升渲染性能。默认情况下,节点始终可见,而其他图形元素在操作画布过程中会自动隐藏 * * Specify the shapes that are always visible. After applying this interaction, only the shapes specified by this property will remain visible during the canvas operation, and the rest of the shapes will be hidden to improve rendering performance. By default, nodes are always visible, while other shapes are automatically hidden during canvas operations */ shapes?: | { node?: string[]; edge?: string[]; combo?: string[] } | ((type: ElementType, shape: DisplayObject) => boolean); /** * 设置防抖时间 * * Set debounce time * @defaultValue 200 */ debounce?: number; } /** * 操作画布过程中隐藏元素 * * Hide elements during canvas operations (dragging, zooming, scrolling) */ export class OptimizeViewportTransform extends BaseBehavior { static defaultOptions: Partial = { enable: true, debounce: 200, shapes: (type: ElementType) => type === 'node', }; private hiddenShapes: DisplayObject[] = []; private isVisible: boolean = true; constructor(context: RuntimeContext, options: OptimizeViewportTransformOptions) { super(context, Object.assign({}, OptimizeViewportTransform.defaultOptions, options)); this.bindEvents(); } private setElementsVisibility = ( elements: DisplayObject[], visibility: BaseStyleProps['visibility'], filter?: (shape: DisplayObject) => boolean, ) => { elements.filter(Boolean).forEach((element) => { if (visibility === 'hidden' && !element.isVisible()) { this.hiddenShapes.push(element); } else if (visibility === 'visible' && this.hiddenShapes.includes(element)) { this.hiddenShapes.splice(this.hiddenShapes.indexOf(element), 1); } else { setVisibility(element, visibility, filter); } }); }; private filterShapes = (type: ElementType, filter: OptimizeViewportTransformOptions['shapes']) => { if (isFunction(filter)) return (shape: DisplayObject) => !filter(type, shape); const includesClassnames = filter?.[type]; return (shape: DisplayObject) => { if (!shape.className) return true; return !includesClassnames?.includes(shape.className); }; }; private hideShapes = (event: IViewportEvent) => { if (!this.validate(event) || !this.isVisible) return; const { element } = this.context; const { shapes = {} } = this.options; this.setElementsVisibility(element!.getNodes(), 'hidden', this.filterShapes('node', shapes)); this.setElementsVisibility(element!.getEdges(), 'hidden', this.filterShapes('edge', shapes)); this.setElementsVisibility(element!.getCombos(), 'hidden', this.filterShapes('combo', shapes)); this.isVisible = false; }; private showShapes = debounce((event: IViewportEvent) => { if (!this.validate(event) || this.isVisible) return; const { element } = this.context; this.setElementsVisibility(element!.getNodes(), 'visible'); this.setElementsVisibility(element!.getEdges(), 'visible'); this.setElementsVisibility(element!.getCombos(), 'visible'); this.isVisible = true; }, this.options.debounce); private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.BEFORE_TRANSFORM, this.hideShapes); graph.on(GraphEvent.AFTER_TRANSFORM, this.showShapes); } private unbindEvents() { const { graph } = this.context; graph.off(GraphEvent.BEFORE_TRANSFORM, this.hideShapes); graph.off(GraphEvent.AFTER_TRANSFORM, this.showShapes); } private validate(event: IViewportEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } public update(options: Partial) { this.unbindEvents(); super.update(options); this.bindEvents(); } public destroy() { this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/scroll-canvas.ts ================================================ import { isFunction, isObject } from '@antv/util'; import { CommonEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { IKeyboardEvent, Point } from '../types'; import { getExpandedBBox, getPointBBox, isPointInBBox } from '../utils/bbox'; import { parsePadding } from '../utils/padding'; import { Shortcut, ShortcutKey } from '../utils/shortcut'; import { multiply, subtract } from '../utils/vector'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 滚动画布交互配置项 * * Scroll canvas behavior options */ export interface ScrollCanvasOptions extends BaseBehaviorOptions { /** * 是否启用滚动画布的功能 * * Whether to enable the function of scrolling the canvas * @defaultValue true */ enable?: boolean | ((event: WheelEvent | IKeyboardEvent) => boolean); /** * 触发滚动的方式,默认使用指针滚动 * * The way to trigger scrolling, default to scrolling with the pointer pressed */ trigger?: { up: ShortcutKey; down: ShortcutKey; left: ShortcutKey; right: ShortcutKey; }; /** * 允许的滚动方向 * - 默认情况下没有限制 * - `'x'` : 只允许水平滚动 * - `'y'` : 只允许垂直滚动 * * The allowed rolling direction * - by default, there is no restriction * - `'x'`: only allow horizontal scrolling * - `'y'`: only allow vertical scrolling */ direction?: 'x' | 'y'; /** * 可滚动的视口范围,默认最多可滚动一屏。可以分别设置上、右、下、左四个方向的范围,每个方向的范围在 [0, Infinity] 之间 * * The scrollable viewport range allows you to scroll up to one screen by default. You can set the range for each direction (top, right, bottom, left) individually, with each direction's range between [0, Infinity] * @defaultValue 1 */ range?: number | number[]; /** * 滚动灵敏度 * * Scroll sensitivity * @defaultValue 1 */ sensitivity?: number; /** * 完成滚动时的回调 * * Callback when scrolling is completed */ onFinish?: () => void; /** * 是否阻止默认事件 * * Whether to prevent the default event * @defaultValue true */ preventDefault?: boolean; } /** * 滚动画布交互 * * Scroll canvas behavior */ export class ScrollCanvas extends BaseBehavior { static defaultOptions: Partial = { enable: true, sensitivity: 1, preventDefault: true, range: Infinity, }; private shortcut: Shortcut; constructor(context: RuntimeContext, options: ScrollCanvasOptions) { super(context, Object.assign({}, ScrollCanvas.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.bindEvents(); } /** * 更新配置 * * Update options * @param options - 配置项 | Options * @internal */ public update(options: Partial): void { super.update(options); this.bindEvents(); } private bindEvents() { const { trigger } = this.options; this.shortcut.unbindAll(); if (isObject(trigger)) { this.graphDom?.removeEventListener(CommonEvent.WHEEL, this.onWheel); const { up = [], down = [], left = [], right = [] } = trigger; this.shortcut.bind(up, (event) => this.scroll([0, -10], event)); this.shortcut.bind(down, (event) => this.scroll([0, 10], event)); this.shortcut.bind(left, (event) => this.scroll([-10, 0], event)); this.shortcut.bind(right, (event) => this.scroll([10, 0], event)); } else { /** * 这里必需在原生canvas上绑定wheel事件,参考: * https://g.antv.antgroup.com/api/event/faq#%E5%9C%A8-chrome-%E4%B8%AD%E7%A6%81%E6%AD%A2%E9%A1%B5%E9%9D%A2%E9%BB%98%E8%AE%A4%E6%BB%9A%E5%8A%A8%E8%A1%8C%E4%B8%BA */ this.graphDom?.addEventListener(CommonEvent.WHEEL, this.onWheel, { passive: false }); } } get graphDom() { return this.context.graph.getCanvas().getContextService().getDomElement(); } private onWheel = async (event: WheelEvent) => { if (this.options.preventDefault) event.preventDefault(); const diffX = event.deltaX; const diffY = event.deltaY; await this.scroll([-diffX, -diffY], event); }; private formatDisplacement(d: Point) { const { sensitivity } = this.options; d = multiply(d, sensitivity); d = this.clampByDirection(d); d = this.clampByRange(d); return d; } private clampByDirection([dx, dy]: Point) { const { direction } = this.options; if (direction === 'x') { dy = 0; } else if (direction === 'y') { dx = 0; } return [dx, dy] as Point; } private clampByRange([dx, dy]: Point) { const { viewport, canvas } = this.context; const [canvasWidth, canvasHeight] = canvas.getSize(); const [top, right, bottom, left] = parsePadding(this.options.range); const range = [canvasHeight * top, canvasWidth * right, canvasHeight * bottom, canvasWidth * left]; const scrollableArea = getExpandedBBox(getPointBBox(viewport!.getCanvasCenter()), range); const nextViewportCenter = subtract(viewport!.getViewportCenter(), [dx, dy, 0]); if (!isPointInBBox(nextViewportCenter, scrollableArea)) { const { min: [minX, minY], max: [maxX, maxY], } = scrollableArea; if ((nextViewportCenter[0] < minX && dx > 0) || (nextViewportCenter[0] > maxX && dx < 0)) { dx = 0; } if ((nextViewportCenter[1] < minY && dy > 0) || (nextViewportCenter[1] > maxY && dy < 0)) { dy = 0; } } return [dx, dy] as Point; } private async scroll(value: Point, event: WheelEvent | IKeyboardEvent) { if (!this.validate(event)) return; const { onFinish } = this.options; const graph = this.context.graph; const formattedValue = this.formatDisplacement(value); await graph.translateBy(formattedValue, false); onFinish?.(); } private validate(event: WheelEvent | IKeyboardEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } /** * 销毁画布滚动 * * Destroy the canvas scrolling */ public destroy(): void { this.shortcut.destroy(); this.graphDom?.removeEventListener(CommonEvent.WHEEL, this.onWheel); super.destroy(); } } ================================================ FILE: packages/g6/src/behaviors/types.ts ================================================ import type { BaseBehavior } from './base-behavior'; export type Behavior = BaseBehavior; ================================================ FILE: packages/g6/src/behaviors/zoom-canvas.ts ================================================ import { clamp, isFunction } from '@antv/util'; import { CommonEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { IKeyboardEvent, IPointerEvent, IWheelEvent, Point, PointObject, ViewportAnimationEffectTiming, } from '../types'; import { parsePoint } from '../utils/point'; import type { ShortcutKey } from '../utils/shortcut'; import { Shortcut } from '../utils/shortcut'; import type { BaseBehaviorOptions } from './base-behavior'; import { BaseBehavior } from './base-behavior'; /** * 缩放画布交互配置项 * * Zoom canvas behavior options */ export interface ZoomCanvasOptions extends BaseBehaviorOptions { /** * 是否启用缩放动画 * * Whether to enable the animation of zooming * @defaultValue '{ duration: 200 }' */ animation?: ViewportAnimationEffectTiming; /** * 是否启用缩放画布的功能 * * Whether to enable the function of zooming the canvas * @defaultValue true */ enable?: boolean | ((event: IWheelEvent | IKeyboardEvent | IPointerEvent) => boolean); /** * 缩放中心点(视口坐标) * - 默认情况下为鼠标位置中心 * * zoom center(viewport coordinates) * - by default , the center is the mouse position center */ origin?: Point; /** * 触发缩放的方式 * - ShortcutKey:组合快捷键,**默认使用滚轮缩放**,['Control'] 表示按住 Control 键滚动鼠标滚轮时触发缩放 * - CombinationKey:缩放快捷键,例如 { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] } * * The way to trigger zoom * - ShortcutKey: Combination shortcut key, **default to zoom with the mouse wheel**, ['Control'] means zooming when holding down the Control key and scrolling the mouse wheel * - CombinationKey: Zoom shortcut key, such as { zoomIn: ['Control', '+'], zoomOut: ['Control', '-'], reset: ['Control', '0'] } */ trigger?: | ShortcutKey | { zoomIn: ShortcutKey; zoomOut: ShortcutKey; reset: ShortcutKey; }; /** * 缩放灵敏度 * * Zoom sensitivity * @defaultValue 1 */ sensitivity?: number; /** * 完成缩放时的回调 * * Callback when zooming is completed */ onFinish?: () => void; /** * 是否阻止默认事件 * * Whether to prevent the default event * @defaultValue true */ preventDefault?: boolean; } /** * 缩放画布交互 * * Zoom canvas behavior */ export class ZoomCanvas extends BaseBehavior { static defaultOptions: Partial = { animation: { duration: 200 }, enable: true, sensitivity: 1, trigger: [], preventDefault: true, }; private shortcut: Shortcut; constructor(context: RuntimeContext, options: ZoomCanvasOptions) { super(context, Object.assign({}, ZoomCanvas.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.bindEvents(); } /** * 更新配置 * * Update options * @param options - 配置项 | Options * @internal */ public update(options: Partial): void { super.update(options); this.bindEvents(); } private bindEvents() { const { trigger } = this.options; this.shortcut.unbindAll(); if (Array.isArray(trigger)) { if (trigger.includes(CommonEvent.PINCH)) { this.shortcut.bind([CommonEvent.PINCH], (event) => { this.zoom(event.scale, event, false); }); } else { const container = this.context.canvas.getContainer(); container?.addEventListener(CommonEvent.WHEEL, this.preventDefault); this.shortcut.bind([...trigger, CommonEvent.WHEEL], (event) => { const { deltaX, deltaY } = event; this.zoom(-(deltaY ?? deltaX), event, false); }); } } if (typeof trigger === 'object') { const { zoomIn = [], zoomOut = [], reset = [], } = trigger as { zoomIn: ShortcutKey; zoomOut: ShortcutKey; reset: ShortcutKey; }; this.shortcut.bind(zoomIn, (event) => this.zoom(10, event, this.options.animation)); this.shortcut.bind(zoomOut, (event) => this.zoom(-10, event, this.options.animation)); this.shortcut.bind(reset, this.onReset); } } /** * 缩放画布 * * Zoom canvas * @param value - 缩放值, > 0 放大, < 0 缩小 | Zoom value, > 0 zoom in, < 0 zoom out * @param event - 事件对象 | Event object * @param animation - 缩放动画配置 | Zoom animation configuration */ protected zoom = async ( value: number, event: IWheelEvent | IKeyboardEvent | IPointerEvent, animation: ZoomCanvasOptions['animation'], ) => { if (!this.validate(event)) return; const { graph } = this.context; let origin: Point | undefined = this.options.origin; if (!origin && 'viewport' in event) { origin = parsePoint(event.viewport as PointObject); } const { sensitivity, onFinish } = this.options; const ratio = 1 + (clamp(value, -50, 50) * sensitivity) / 100; const zoom = graph.getZoom(); await graph.zoomTo(zoom * ratio, animation, origin); onFinish?.(); }; protected onReset = async () => { await this.context.graph.zoomTo(1, this.options.animation); }; /** * 验证是否可以缩放 * * Verify whether it can be zoomed * @param event - 事件对象 | Event object * @returns 是否可以缩放 | Whether it can be zoomed * @internal */ protected validate(event: IWheelEvent | IKeyboardEvent | IPointerEvent) { if (this.destroyed) return false; const { enable } = this.options; if (isFunction(enable)) return enable(event); return !!enable; } private preventDefault = (event: Event) => { if (this.options.preventDefault) event.preventDefault(); }; /** * 销毁缩放画布 * * Destroy zoom canvas */ public destroy() { this.shortcut.destroy(); this.context.canvas.getContainer()?.removeEventListener(CommonEvent.WHEEL, this.preventDefault); super.destroy(); } } ================================================ FILE: packages/g6/src/constants/animation.ts ================================================ import type { AnimationEffectTiming } from '../animations/types'; export const DEFAULT_ANIMATION_OPTIONS: AnimationEffectTiming = { duration: 500, }; export const DEFAULT_ELEMENTS_ANIMATION_OPTIONS: AnimationEffectTiming = { duration: 1000, easing: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)', iterations: 1, fill: 'both', }; ================================================ FILE: packages/g6/src/constants/change.ts ================================================ export const ChangeEvent = { /** * 数据变更 * * Data changes */ CHANGE: 'change', }; export const enum ChangeType { /** * 节点添加 * * Node added */ 'NodeAdded' = 'NodeAdded', /** * 节点更新 * * Node updated */ 'NodeUpdated' = 'NodeUpdated', /** * 节点删除 * * Node removed */ 'NodeRemoved' = 'NodeRemoved', /** * 边添加 * * Edge added */ 'EdgeAdded' = 'EdgeAdded', /** * 边更新 * * Edge updated */ 'EdgeUpdated' = 'EdgeUpdated', /** * 边删除 * * Edge removed */ 'EdgeRemoved' = 'EdgeRemoved', /** * Combo添加 * * Combo added */ 'ComboAdded' = 'ComboAdded', /** * Combo更新 * * Combo updated */ 'ComboUpdated' = 'ComboUpdated', /** * Combo删除 * * Combo removed */ 'ComboRemoved' = 'ComboRemoved', } ================================================ FILE: packages/g6/src/constants/element.ts ================================================ /** * 根据不同的 node,自动计算 icon 的大小之后,乘以一下缩放系数,防止贴的太紧密 * * According to the different nodes, the size of the icon is automatically calculated, and then multiplied by the following scaling factor to prevent it from being too close */ export const ICON_SIZE_RATIO = 0.8; export const ELEMENT_TYPES = ['node', 'edge', 'combo'] as const; ================================================ FILE: packages/g6/src/constants/events/animation.ts ================================================ export enum AnimationType { DRAW = 'draw', COLLAPSE = 'collapse', EXPAND = 'expand', TRANSFORM = 'transform', } ================================================ FILE: packages/g6/src/constants/events/canvas.ts ================================================ /** * 画布事件 * * Canvas event */ export enum CanvasEvent { /** * 点击时触发 * * Triggered when click */ CLICK = 'canvas:click', /** * 双击时触发 * * Triggered when double click */ DBLCLICK = 'canvas:dblclick', /** * 指针移入时触发 * * Triggered when the pointer enters */ POINTER_OVER = 'canvas:pointerover', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_LEAVE = 'canvas:pointerleave', /** * 指针移入时或移入子元素时触发(不会冒泡) * * Triggered when the pointer enters or enters a child element (does not bubble) */ POINTER_ENTER = 'canvas:pointerenter', /** * 指针移动时触发 * * Triggered when the pointer moves */ POINTER_MOVE = 'canvas:pointermove', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_OUT = 'canvas:pointerout', /** * 指针按下时触发 * * Triggered when the pointer is pressed */ POINTER_DOWN = 'canvas:pointerdown', /** * 指针抬起时触发 * * Triggered when the pointer is lifted */ POINTER_UP = 'canvas:pointerup', /** * 打开上下文菜单时触发 * * Triggered when the context menu is opened */ CONTEXT_MENU = 'canvas:contextmenu', /** * 开始拖拽时触发 * * Triggered when dragging starts */ DRAG_START = 'canvas:dragstart', /** * 拖拽过程中触发 * * Triggered when dragging */ DRAG = 'canvas:drag', /** * 拖拽结束时触发 * * Triggered when dragging ends */ DRAG_END = 'canvas:dragend', /** * 拖拽进入时触发 * * Triggered when dragging enters */ DRAG_ENTER = 'canvas:dragenter', /** * 拖拽经过时触发 * * Triggered when dragging passes */ DRAG_OVER = 'canvas:dragover', /** * 拖拽离开时触发 * * Triggered when dragging leaves */ DRAG_LEAVE = 'canvas:dragleave', /** * 拖拽放下时触发 * * Triggered when dragging is dropped */ DROP = 'canvas:drop', /** * 滚动时触发 * * Triggered when scrolling */ WHEEL = 'canvas:wheel', } ================================================ FILE: packages/g6/src/constants/events/combo.ts ================================================ /** * 组合事件 * * Combo event */ export enum ComboEvent { /** * 点击时触发 * * Triggered when click */ CLICK = 'combo:click', /** * 双击时触发 * * Triggered when double click */ DBLCLICK = 'combo:dblclick', /** * 指针移入时触发 * * Triggered when the pointer enters */ POINTER_OVER = 'combo:pointerover', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_LEAVE = 'combo:pointerleave', /** * 指针移入时或移入子元素时触发(不会冒泡) * * Triggered when the pointer enters or enters a child element (does not bubble) */ POINTER_ENTER = 'combo:pointerenter', /** * 指针移动时触发 * * Triggered when the pointer moves */ POINTER_MOVE = 'combo:pointermove', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_OUT = 'combo:pointerout', /** * 指针按下时触发 * * Triggered when the pointer is pressed */ POINTER_DOWN = 'combo:pointerdown', /** * 指针抬起时触发 * * Triggered when the pointer is lifted */ POINTER_UP = 'combo:pointerup', /** * 打开上下文菜单时触发 * * Triggered when the context menu is opened */ CONTEXT_MENU = 'combo:contextmenu', /** * 开始拖拽时触发 * * Triggered when dragging starts */ DRAG_START = 'combo:dragstart', /** * 拖拽过程中触发 * * Triggered when dragging */ DRAG = 'combo:drag', /** * 拖拽结束时触发 * * Triggered when dragging ends */ DRAG_END = 'combo:dragend', /** * 拖拽进入时触发 * * Triggered when dragging enters */ DRAG_ENTER = 'combo:dragenter', /** * 拖拽经过时触发 * * Triggered when dragging passes */ DRAG_OVER = 'combo:dragover', /** * 拖拽离开时触发 * * Triggered when dragging leaves */ DRAG_LEAVE = 'combo:dragleave', /** * 拖拽放下时触发 * * Triggered when dragging is dropped */ DROP = 'combo:drop', } ================================================ FILE: packages/g6/src/constants/events/common.ts ================================================ 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', /** * 双指捏拢或张开时触发 * * Triggered when pinch in and pinch out */ PINCH = 'pinch', } ================================================ FILE: packages/g6/src/constants/events/container.ts ================================================ export enum ContainerEvent { /** * 按下键盘时触发 * * Triggered when the keyboard is pressed */ KEY_DOWN = 'keydown', /** * 抬起键盘时触发 * * Triggered when the keyboard is lifted */ KEY_UP = 'keyup', } ================================================ FILE: packages/g6/src/constants/events/edge.ts ================================================ /** * 边事件 * * Edge event */ export enum EdgeEvent { /** * 点击时触发 * * Triggered when click */ CLICK = 'edge:click', /** * 双击时触发 * * Triggered when double click */ DBLCLICK = 'edge:dblclick', /** * 指针移入时触发 * * Triggered when the pointer enters */ POINTER_OVER = 'edge:pointerover', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_LEAVE = 'edge:pointerleave', /** * 指针移入时或移入子元素时触发(不会冒泡) * * Triggered when the pointer enters or enters a child element (does not bubble) */ POINTER_ENTER = 'edge:pointerenter', /** * 指针移动时触发 * * Triggered when the pointer moves */ POINTER_MOVE = 'edge:pointermove', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_OUT = 'edge:pointerout', /** * 指针按下时触发 * * Triggered when the pointer is pressed */ POINTER_DOWN = 'edge:pointerdown', /** * 指针抬起时触发 * * Triggered when the pointer is lifted */ POINTER_UP = 'edge:pointerup', /** * 打开上下文菜单时触发 * * Triggered when the context menu is opened */ CONTEXT_MENU = 'edge:contextmenu', /** * 拖拽进入时触发 * * Triggered when dragging enters */ DRAG_ENTER = 'edge:dragenter', /** * 拖拽经过时触发 * * Triggered when dragging passes */ DRAG_OVER = 'edge:dragover', /** * 拖拽离开时触发 * * Triggered when dragging leaves */ DRAG_LEAVE = 'edge:dragleave', /** * 拖拽放下时触发 * * Triggered when dragging is dropped */ DROP = 'edge:drop', } ================================================ FILE: packages/g6/src/constants/events/graph.ts ================================================ export enum GraphEvent { /** * 画布初始化之前 * * Before the canvas is initialized */ BEFORE_CANVAS_INIT = 'beforecanvasinit', /** * 画布初始化之后 * * After the canvas is initialized */ AFTER_CANVAS_INIT = 'aftercanvasinit', /** * 视口尺寸变更之前 * * Before the viewport size changes */ BEFORE_SIZE_CHANGE = 'beforesizechange', /** * 视口尺寸变更之后 * * After the viewport size changes */ AFTER_SIZE_CHANGE = 'aftersizechange', /** * 元素创建之前 * * Before creating element */ BEFORE_ELEMENT_CREATE = 'beforeelementcreate', /** * 元素创建之后 * * After creating element */ AFTER_ELEMENT_CREATE = 'afterelementcreate', /** * 元素更新之前 * * Before updating element */ BEFORE_ELEMENT_UPDATE = 'beforeelementupdate', /** * 元素更新之后 * * After updating element */ AFTER_ELEMENT_UPDATE = 'afterelementupdate', /** * 元素销毁之前 * * Before destroying element */ BEFORE_ELEMENT_DESTROY = 'beforeelementdestroy', /** * 元素销毁之后 * * After destroying element */ AFTER_ELEMENT_DESTROY = 'afterelementdestroy', /** * 元素平移之前 * * Before element translation */ BEFORE_ELEMENT_TRANSLATE = 'beforeelementtranslate', /** * 元素平移之后 * * After element translation */ AFTER_ELEMENT_TRANSLATE = 'afterelementtranslate', /** * 绘制开始之前 * * Before drawing */ BEFORE_DRAW = 'beforedraw', /** * 绘制结束之后 * * After drawing */ AFTER_DRAW = 'afterdraw', /** * 渲染开始之前 * * Before rendering */ BEFORE_RENDER = 'beforerender', /** * 渲染完成之后 * * After rendering */ AFTER_RENDER = 'afterrender', /** * 动画开始之前 * * Before animation */ BEFORE_ANIMATE = 'beforeanimate', /** * 动画结束之后 * * After animation */ AFTER_ANIMATE = 'afteranimate', /** * 布局开始之前 * * Before layout */ BEFORE_LAYOUT = 'beforelayout', /** * 布局结束之后 * * After layout */ AFTER_LAYOUT = 'afterlayout', /** * 布局过程之前,用于流水线布局过程获取当前执行的布局 * * Before the layout process, used to get the current layout being executed in the pipeline layout process */ BEFORE_STAGE_LAYOUT = 'beforestagelayout', /** * 布局过程之后,用于流水线布局过程获取当前执行的布局 * * After the layout process, used to get the current layout being executed in the pipeline layout process */ AFTER_STAGE_LAYOUT = 'afterstagelayout', /** * 可视区域变化之前 * * Before the visible area changes */ BEFORE_TRANSFORM = 'beforetransform', /** * 可视区域变化之后 * * After the visible area changes */ AFTER_TRANSFORM = 'aftertransform', /** * 批处理开始 * * Batch processing starts */ BATCH_START = 'batchstart', /** * 批处理结束 * * Batch processing ends */ BATCH_END = 'batchend', /** * 销毁开始之前 * * Before destruction */ BEFORE_DESTROY = 'beforedestroy', /** * 销毁结束之后 * * After destruction */ AFTER_DESTROY = 'afterdestroy', /** * 渲染器变更之前 * * Before the renderer changes */ BEFORE_RENDERER_CHANGE = 'beforerendererchange', /** * 渲染器变更之后 * * After the renderer changes */ AFTER_RENDERER_CHANGE = 'afterrendererchange', } ================================================ FILE: packages/g6/src/constants/events/history.ts ================================================ export enum HistoryEvent { /** * 当命令被撤销时 * * When the command is undone */ UNDO = 'undo', /** * 当命令被重做时 * * When the command is redone */ REDO = 'redo', /** * 当命令被取消时 * * When the command is canceled */ CANCEL = 'cancel', /** * 当命令被添加到队列时 * * When the command is added */ ADD = 'add', /** * 当历史队列被清空时 * * When the command queue is cleared */ CLEAR = 'clear', /** * 当历史队列发生变化时 * * When the command queue changes */ CHANGE = 'change', } ================================================ FILE: packages/g6/src/constants/events/index.ts ================================================ 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 { HistoryEvent } from './history'; export { NodeEvent } from './node'; ================================================ FILE: packages/g6/src/constants/events/node.ts ================================================ /** * 节点事件 * * Node event */ export enum NodeEvent { /** * 点击时触发 * * Triggered when click */ CLICK = 'node:click', /** * 双击时触发 * * Triggered when double click */ DBLCLICK = 'node:dblclick', /** * 指针移入时触发 * * Triggered when the pointer enters */ POINTER_OVER = 'node:pointerover', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_LEAVE = 'node:pointerleave', /** * 指针移入时或移入子元素时触发(不会冒泡) * * Triggered when the pointer enters or enters a child element (does not bubble) */ POINTER_ENTER = 'node:pointerenter', /** * 指针移动时触发 * * Triggered when the pointer moves */ POINTER_MOVE = 'node:pointermove', /** * 指针移出时触发 * * Triggered when the pointer leaves */ POINTER_OUT = 'node:pointerout', /** * 指针按下时触发 * * Triggered when the pointer is pressed */ POINTER_DOWN = 'node:pointerdown', /** * 指针抬起时触发 * * Triggered when the pointer is lifted */ POINTER_UP = 'node:pointerup', /** * 打开上下文菜单时触发 * * Triggered when the context menu is opened */ CONTEXT_MENU = 'node:contextmenu', /** * 开始拖拽时触发 * * Triggered when dragging starts */ DRAG_START = 'node:dragstart', /** * 拖拽过程中触发 * * Triggered when dragging */ DRAG = 'node:drag', /** * 拖拽结束时触发 * * Triggered when dragging ends */ DRAG_END = 'node:dragend', /** * 拖拽进入时触发 * * Triggered when dragging enters */ DRAG_ENTER = 'node:dragenter', /** * 拖拽经过时触发 * * Triggered when dragging passes */ DRAG_OVER = 'node:dragover', /** * 拖拽离开时触发 * * Triggered when dragging leaves */ DRAG_LEAVE = 'node:dragleave', /** * 拖拽放下时触发 * * Triggered when dragging is dropped */ DROP = 'node:drop', } ================================================ FILE: packages/g6/src/constants/graphlib.ts ================================================ export const COMBO_KEY = 'combo'; export const TREE_KEY = 'tree'; ================================================ FILE: packages/g6/src/constants/index.ts ================================================ export * from './animation'; export * from './change'; export * from './events'; export * from './graphlib'; export * from './registry'; ================================================ FILE: packages/g6/src/constants/registry.ts ================================================ export enum ExtensionCategory { /** * 节点元素 * * Node element */ NODE = 'node', /** * 边元素 * * Edge element */ EDGE = 'edge', /** * 组合元素 * * Combination element */ COMBO = 'combo', /** * 主题 * * Theme */ THEME = 'theme', /** * 色板 * * Palette */ PALETTE = 'palette', /** * 布局 * * Layout */ LAYOUT = 'layout', /** * 交互 * * Behavior */ BEHAVIOR = 'behavior', /** * 插件 * * Plugin */ PLUGIN = 'plugin', /** * 动画 * * Animation */ ANIMATION = 'animation', /** * 数据转换 * * Data transform */ TRANSFORM = 'transform', /** * 图形 * * Shape */ SHAPE = 'shape', } ================================================ FILE: packages/g6/src/elements/base-element.ts ================================================ import type { IAnimation } from '@antv/g'; import type { RuntimeContext } from '../runtime/types'; import type { Keyframe } from '../types'; import type { BaseShapeStyleProps } from './shapes'; import { BaseShape } from './shapes'; export abstract class BaseElement extends BaseShape { protected get context(): RuntimeContext { // @ts-expect-error skip type-check return this.config.context; } protected get parsedAttributes() { return this.attributes as Required; } /** * 动画帧执行函数 * * Animation frame execution function */ protected onframe() {} public animate(keyframes: Keyframe[], options?: number | KeyframeAnimationOptions | undefined): IAnimation | null { const animation = super.animate(keyframes, options); if (animation) { animation.onframe = () => this.onframe(); animation.finished.then(() => this.onframe()); } return animation; } } ================================================ FILE: packages/g6/src/elements/combos/base-combo.ts ================================================ import { AABB, BaseStyleProps, DisplayObject, DisplayObjectConfig, Group } from '@antv/g'; import { isFunction } from '@antv/util'; import type { CollapsedMarkerStyleProps, Combo, ID, NodeLikeData, Padding, Point, Prefix, STDSize, Size, } from '../../types'; import { getBBoxHeight, getBBoxWidth, getCombinedBBox, getExpandedBBox } from '../../utils/bbox'; import { idOf } from '../../utils/id'; import { parsePadding } from '../../utils/padding'; import { getXYByPlacement, hasPosition, positionOf } from '../../utils/position'; import { subStyleProps } from '../../utils/prefix'; import { parseSize } from '../../utils/size'; import { mergeOptions } from '../../utils/style'; import { add, divide } from '../../utils/vector'; import type { BaseNodeStyleProps } from '../nodes'; import { BaseNode } from '../nodes'; import { Icon, IconStyleProps } from '../shapes'; import { connectImage, dispatchPositionChange } from '../shapes/image'; /** * 组合通用样式配置项 * * Common style props for combo */ export interface BaseComboStyleProps extends BaseNodeStyleProps, Prefix<'collapsed', BaseStyleProps>, Prefix<'collapsedMarker', CollapsedMarkerStyleProps> { /** * 组合展开后的默认大小 * * The default size of combo when expanded */ size?: Size; /** * 组合收起后的默认大小 * * The default size of combo when collapsed */ collapsedSize?: Size; /** * 组合的子元素,可以是节点或者组合 * * The children of combo, which can be nodes or combos */ childrenNode?: ID[]; /** * 组合的子元素数据 * * The data of the children of combo * @remarks * 如果组合是收起状态,children 可能为空,通过 childrenData 能够获取完整的子元素数据 * * If the combo is collapsed, children may be empty, and the complete child element data can be obtained through childrenData */ childrenData?: NodeLikeData[]; /** * 组合的内边距,只在展开状态下生效 * * The padding of combo, only effective when expanded */ padding?: Padding; /** * 组合收起时是否显示标记 * * Whether to show the marker when the combo is collapsed */ collapsedMarker?: boolean; } /** * 组合元素的基类 * * Base class of combo * @remarks * 自定义组合时,推荐使用这个类作为基类。这样,用户只需要专注于实现 keyShape 的绘制逻辑 * * When customizing a combo, it is recommended to use this class as the base class. In this way, users only need to focus on the logic of drawing keyShape */ export abstract class BaseCombo extends BaseNode implements Combo { public type = 'combo'; static defaultStyleProps: Partial = { childrenNode: [], droppable: true, draggable: true, collapsed: false, collapsedSize: 32, collapsedMarker: true, collapsedMarkerZIndex: 1, collapsedMarkerFontSize: 12, collapsedMarkerTextAlign: 'center', collapsedMarkerTextBaseline: 'middle', collapsedMarkerType: 'child-count', }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: BaseCombo.defaultStyleProps }, options)); this.updateComboPosition(this.parsedAttributes); } /** * Draw the key shape of combo */ protected abstract drawKeyShape(attributes: Required, container: Group): DisplayObject | undefined; protected getKeySize(attributes: Required): STDSize { const { collapsed, childrenNode = [] } = attributes; if (childrenNode.length === 0) return this.getEmptyKeySize(attributes); return collapsed ? this.getCollapsedKeySize(attributes) : this.getExpandedKeySize(attributes); } protected getEmptyKeySize(attributes: Required): STDSize { const { padding, collapsedSize } = attributes; const [top, right, bottom, left] = parsePadding(padding); return add(parseSize(collapsedSize), [left + right, top + bottom, 0]) as STDSize; } protected getCollapsedKeySize(attributes: Required): STDSize { return parseSize(attributes.collapsedSize); } protected getExpandedKeySize(attributes: Required): STDSize { const contentBBox = this.getContentBBox(attributes); return [getBBoxWidth(contentBBox), getBBoxHeight(contentBBox), 0]; } protected getContentBBox(attributes: Required): AABB { const { childrenNode = [], padding } = attributes; const children = childrenNode.map((id) => this.context!.element!.getElement(id)).filter(Boolean); if (children.length === 0) { const bbox = new AABB(); const { x = 0, y = 0, size } = attributes; const [width, height] = parseSize(size); bbox.setMinMax([x - width / 2, y - height / 2, 0], [x + width / 2, y + height / 2, 0]); return bbox; } const childrenBBox = getCombinedBBox(children.map((child) => child!.getBounds())); if (!padding) return childrenBBox; return getExpandedBBox(childrenBBox, padding); } protected drawCollapsedMarkerShape(attributes: Required, container: Group): void { const style = this.getCollapsedMarkerStyle(attributes); this.upsert('collapsed-marker', Icon, style, container); connectImage(this); } protected getCollapsedMarkerStyle(attributes: Required): IconStyleProps | false { if (!attributes.collapsed || !attributes.collapsedMarker) return false; const { type, ...collapsedMarkerStyle } = subStyleProps( this.getGraphicStyle(attributes), 'collapsedMarker', ); const keyShape = this.getShape('key'); const [x, y] = getXYByPlacement(keyShape.getLocalBounds(), 'center'); const style = { ...collapsedMarkerStyle, x, y }; if (type) { const text = this.getCollapsedMarkerText(type, attributes); Object.assign(style, { text }); } return style; } protected getCollapsedMarkerText(type: CollapsedMarkerStyleProps['type'], attributes: Required): string { const { childrenData = [] } = attributes; const { model } = this.context; if (type === 'descendant-count') return model.getDescendantsData(this.id).length.toString(); if (type === 'child-count') return childrenData.length.toString(); if (type === 'node-count') return model .getDescendantsData(this.id) .filter((datum) => model.getElementType(idOf(datum)) === 'node') .length.toString(); if (isFunction(type)) return type(childrenData); return ''; } public getComboPosition(attributes: Required): Point { const { x = 0, y = 0, collapsed, childrenData = [] } = attributes; if (childrenData.length === 0) return [+x, +y, 0]; if (collapsed) { const { model } = this.context; const descendants = model.getDescendantsData(this.id).filter((datum) => !model.isCombo(idOf(datum))); if (descendants.length > 0 && descendants.some(hasPosition)) { // combo 被收起,返回平均中心位置 / combo is collapsed, return the average center position const totalPosition = descendants.reduce((acc, datum) => add(acc, positionOf(datum)), [0, 0, 0] as Point); return divide(totalPosition, descendants.length); } // empty combo return [+x, +y, 0]; } return this.getContentBBox(attributes).center; } protected getComboStyle(attributes: Required) { const [x, y] = this.getComboPosition(attributes); // x/y will be used to calculate position later. return { x, y, transform: [['translate', x, y]] }; } protected updateComboPosition(attributes: Required) { const comboStyle = this.getComboStyle(attributes); Object.assign(this.style, comboStyle); // Sync combo position to model const { x, y } = comboStyle; this.context.model.syncNodeLikeDatum({ id: this.id, style: { x, y } }); dispatchPositionChange(this); } public render(attributes: Required, container: Group = this) { super.render(attributes, container); // collapsed marker this.drawCollapsedMarkerShape(attributes, container); } public update(attr: Partial = {}): void { super.update(attr); this.updateComboPosition(this.parsedAttributes); } protected onframe() { super.onframe(); // 收起状态下,通过动画来更新位置 // Update position through animation in collapsed state if (!this.attributes.collapsed) this.updateComboPosition(this.parsedAttributes); this.drawKeyShape(this.parsedAttributes, this); } public animate(keyframes: Keyframe[], options?: number | KeyframeAnimationOptions) { const animation = super.animate( this.attributes.collapsed ? keyframes : // 如果当前 combo 是展开状态,则动画不受 x, y, z, transform 影响,仅由子元素决定位置 // If the current combo is in the expanded state, the animation is not affected by x, y, z, transform, and the position is determined only by the child elements keyframes.map(({ x, y, z, transform, ...keyframe }: any) => keyframe), options, ); if (!animation) return animation; return new Proxy(animation, { set: (target, propKey, value) => { if (propKey === 'currentTime') Promise.resolve().then(() => this.onframe()); return Reflect.set(target, propKey, value); }, }); } } ================================================ FILE: packages/g6/src/elements/combos/circle.ts ================================================ import type { DisplayObjectConfig, CircleStyleProps as GCircleStyleProps } from '@antv/g'; import { Circle as GCircle, Group } from '@antv/g'; import type { Point, STDSize } from '../../types'; import { getBBoxSize } from '../../utils/bbox'; import { getEllipseIntersectPoint } from '../../utils/point'; import { subStyleProps } from '../../utils/prefix'; import { parseSize } from '../../utils/size'; import type { BaseComboStyleProps } from './base-combo'; import { BaseCombo } from './base-combo'; /** * 圆形组合样式配置项 * * Circle combo style props */ export interface CircleComboStyleProps extends BaseComboStyleProps {} /** * 圆形组合 * * Circle combo */ export class CircleCombo extends BaseCombo { constructor(options: DisplayObjectConfig) { super(options); } protected drawKeyShape(attributes: Required, container: Group): GCircle | undefined { return this.upsert('key', GCircle, this.getKeyStyle(attributes), container); } protected getKeyStyle(attributes: Required): GCircleStyleProps { const { collapsed } = attributes; const keyStyle = super.getKeyStyle(attributes); const [width] = this.getKeySize(attributes); return { ...keyStyle, ...(collapsed && subStyleProps(keyStyle, 'collapsed')), r: width / 2, }; } protected getCollapsedKeySize(attributes: Required): STDSize { const [collapsedWidth, collapsedHeight] = parseSize(attributes.collapsedSize); const collapsedR = Math.max(collapsedWidth, collapsedHeight) / 2; return [collapsedR * 2, collapsedR * 2, 0]; } protected getExpandedKeySize(attributes: Required): STDSize { const contentBBox = this.getContentBBox(attributes); const [width, height] = getBBoxSize(contentBBox); const expandedR = Math.sqrt(width ** 2 + height ** 2) / 2; return [expandedR * 2, expandedR * 2, 0]; } public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); return getEllipseIntersectPoint(point, keyShapeBounds, useExtendedLine); } } ================================================ FILE: packages/g6/src/elements/combos/index.ts ================================================ export { BaseCombo } from './base-combo'; export { CircleCombo } from './circle'; export { RectCombo } from './rect'; export type { BaseComboStyleProps } from './base-combo'; export type { CircleComboStyleProps } from './circle'; export type { RectComboStyleProps } from './rect'; ================================================ FILE: packages/g6/src/elements/combos/rect.ts ================================================ import type { DisplayObjectConfig, RectStyleProps as GRectStyleProps } from '@antv/g'; import { Rect as GRect, Group } from '@antv/g'; import { subStyleProps } from '../../utils/prefix'; import type { BaseComboStyleProps } from './base-combo'; import { BaseCombo } from './base-combo'; /** * 矩形组合样式配置项 * * Rect combo style props */ export interface RectComboStyleProps extends BaseComboStyleProps {} /** * 矩形组合 * * Rect combo */ export class RectCombo extends BaseCombo { constructor(options: DisplayObjectConfig) { super(options); } protected drawKeyShape(attributes: Required, container: Group): GRect | undefined { return this.upsert('key', GRect, this.getKeyStyle(attributes), container); } protected getKeyStyle(attributes: Required): GRectStyleProps { const keyStyle = super.getKeyStyle(attributes); const [width, height] = this.getKeySize(attributes); return { ...keyStyle, ...(attributes.collapsed && subStyleProps(keyStyle, 'collapsed')), width, height, x: -width / 2, y: -height / 2, }; } } ================================================ FILE: packages/g6/src/elements/edges/base-edge.ts ================================================ import type { DisplayObject, DisplayObjectConfig, Group, LineStyleProps, PathStyleProps } from '@antv/g'; import { Image, Path } from '@antv/g'; import type { PathArray } from '@antv/util'; import { isFunction, pick } from '@antv/util'; import type { Edge, EdgeArrowStyleProps, EdgeBadgeStyleProps, EdgeKey, EdgeLabelStyleProps, ID, Keyframe, LoopStyleProps, Node, Point, Prefix, } from '../../types'; import { getBBoxHeight, getBBoxWidth, getNodeBBox } from '../../utils/bbox'; import { getArrowSize, getBadgePositionStyle, getCubicLoopPath, getLabelPositionStyle } from '../../utils/edge'; import { findPorts, getConnectionPoint, getPortPosition, isSameNode } from '../../utils/element'; import { subStyleProps } from '../../utils/prefix'; import { parseSize } from '../../utils/size'; import { mergeOptions } from '../../utils/style'; import * as Symbol from '../../utils/symbol'; import { getWordWrapWidthByEnds } from '../../utils/text'; import { BaseElement } from '../base-element'; import type { BadgeStyleProps, BaseShapeStyleProps, LabelStyleProps } from '../shapes'; import { Badge, Label } from '../shapes'; /** * 边的通用样式属性 * * Base style properties of the edge */ export interface BaseEdgeStyleProps extends BaseShapeStyleProps, Prefix<'label', EdgeLabelStyleProps>, Prefix<'halo', PathStyleProps>, Prefix<'badge', EdgeBadgeStyleProps>, Prefix<'startArrow', EdgeArrowStyleProps>, Prefix<'endArrow', EdgeArrowStyleProps>, Prefix<'loop', LoopStyleProps> { /** * 是否显示边的标签 * * Whether to display the label of the edge * @defaultValue true */ label?: boolean; /** * 是否启用自环边 * * Whether to enable self-loop edge * @defaultValue true */ loop?: boolean; /** * 是否显示边的光晕 * * Whether to display the halo of the edge * @defaultValue false */ halo?: boolean; /** * 是否显示边的徽标 * * Whether to display the badge of the edge * @defaultValue true */ badge?: boolean; /** * 是否显示边的起始箭头 * * Whether to display the start arrow of the edge * @defaultValue false */ startArrow?: boolean; /** * 是否显示边的结束箭头 * * Whether to display the end arrow of the edge * @defaultValue false */ endArrow?: boolean; /** * 起始箭头的偏移量 * * Offset of the start arrow */ startArrowOffset?: number; /** * 结束箭头的偏移量 * * Offset of the end arrow */ endArrowOffset?: number; /** * 边的起点 ID * * The ID of the source node * @remarks * 该属性指向物理意义上的起点,由 G6 内部维护,用户无需过多关注。通常情况下,`sourceNode` 与上一级的 `source` 属性一致。但在某些情况下,`sourceNode` 可能会被 G6 内部转换,例如在 Combo 收起时内部节点上的边会自动连接到父 Combo,此时 `sourceNode` 会变更为父 Combo 的 ID。 * * This property concerning the physical origin, maintained internally by G6. In general, `sourceNode` corresponds to the `source` attribute of the parent level. However, in certain cases, such as when a Combo is collapsed and internal nodes are destroyed, corresponding edges will automatically connect to the parent Combo. At this point, `sourceNode` will be changed to the ID of the parent Combo */ sourceNode: ID; /** * 边的终点 shape * * The source shape. Represents the start of the edge */ targetNode: ID; /** * 边起始连接的 port * * The Port of the source node */ sourcePort?: string; /** * 边终点连接的 port * * The Port of the target node */ targetPort?: string; /** * 在 “起点” 处添加一个标记图形,其中 “起始点” 为边与起始节点的交点 * * Add a marker at the "start point", where the "start point" is the intersection of the edge and the source node */ markerStart?: DisplayObject | null; /** * 调整 “起点” 处标记图形的位置,正偏移量向内,负偏移量向外 * * Adjust the position of the marker at the "start point", positive offset inward, negative offset outward * @defaultValue 0 */ markerStartOffset?: number; /** * 在 “终点” 处添加一个标记图形,其中 “终点” 为边与终止节点的交点 * * Add a marker at the "end point", where the "end point" is the intersection of the edge and the target node */ markerEnd?: DisplayObject | null; /** * 调整 “终点” 处标记图形的位置,正偏移量向内,负偏移量向外 * * Adjust the position of the marker at the "end point", positive offset inward, negative offset outward * @defaultValue 0 */ markerEndOffset?: number; /** * 在路径除了 “起点” 和 “终点” 之外的每一个顶点上放置标记图形。在内部实现中,由于我们会把路径中部分命令转换成 C 命令,因此这些顶点实际是三阶贝塞尔曲线的控制点 * * Place a marker on each vertex of the path except for the "start point" and "end point". In the internal implementation, because we will convert some commands in the path to C commands, these controlPoints are actually the control points of the cubic Bezier curve */ markerMid?: DisplayObject | null; /** * 3D 场景中生效,始终朝向屏幕,因此线宽不受透视投影影像 * * Effective in 3D scenes, always facing the screen, so the line width is not affected by the perspective projection image * @defaultValue true */ isBillboard?: boolean; } type ParsedBaseEdgeStyleProps = Required; /** * 边元素基类 * * Base class of the edge */ export abstract class BaseEdge extends BaseElement implements Edge { public type = 'edge'; static defaultStyleProps: Partial = { badge: true, badgeOffsetX: 0, badgeOffsetY: 0, badgePlacement: 'suffix', isBillboard: true, label: true, labelAutoRotate: true, labelIsBillboard: true, labelMaxWidth: '80%', labelOffsetX: 4, labelOffsetY: 0, labelPlacement: 'center', labelTextBaseline: 'middle', labelWordWrap: false, halo: false, haloDroppable: false, haloLineDash: 0, haloLineWidth: 12, haloPointerEvents: 'none', haloStrokeOpacity: 0.25, haloZIndex: -1, loop: true, startArrow: false, startArrowLineDash: 0, startArrowLineJoin: 'round', startArrowLineWidth: 1, startArrowTransformOrigin: 'center', startArrowType: 'vee', endArrow: false, endArrowLineDash: 0, endArrowLineJoin: 'round', endArrowLineWidth: 1, endArrowTransformOrigin: 'center', endArrowType: 'vee', loopPlacement: 'top', loopClockwise: true, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: BaseEdge.defaultStyleProps }, options)); } protected get sourceNode() { const { sourceNode: source } = this.parsedAttributes; return this.context.element!.getElement(source)!; } protected get targetNode() { const { targetNode: target } = this.parsedAttributes; return this.context.element!.getElement(target)!; } protected getKeyStyle(attributes: ParsedBaseEdgeStyleProps): PathStyleProps { const { loop, ...style } = this.getGraphicStyle(attributes); const { sourceNode, targetNode } = this; const d = loop && isSameNode(sourceNode, targetNode) ? this.getLoopPath(attributes) : this.getKeyPath(attributes); const keyStyle: PathStyleProps = { d }; Path.PARSED_STYLE_LIST.forEach((key) => { // @ts-expect-error skip type error if (key in style) keyStyle[key] = style[key]; }); return keyStyle; } protected abstract getKeyPath(attributes: ParsedBaseEdgeStyleProps): PathArray; protected getLoopPath(attributes: ParsedBaseEdgeStyleProps): PathArray { const { sourcePort, targetPort } = attributes; const node = this.sourceNode; const bbox = getNodeBBox(node); const defaultDist = Math.max(getBBoxWidth(bbox), getBBoxHeight(bbox)); const { placement, clockwise, dist = defaultDist, } = subStyleProps>(this.getGraphicStyle(attributes), 'loop'); return getCubicLoopPath(node, placement, clockwise, dist, sourcePort, targetPort); } protected getEndpoints( attributes: ParsedBaseEdgeStyleProps, optimize = true, controlPoints: Point[] | (() => Point[]) = [], ): [Point, Point] { const { sourcePort: sourcePortKey, targetPort: targetPortKey } = attributes; const { sourceNode, targetNode } = this; const [sourcePort, targetPort] = findPorts(sourceNode, targetNode, sourcePortKey, targetPortKey); if (!optimize) { const sourcePoint = sourcePort ? getPortPosition(sourcePort) : sourceNode.getCenter(); const targetPoint = targetPort ? getPortPosition(targetPort) : targetNode.getCenter(); return [sourcePoint, targetPoint]; } const _controlPoints = typeof controlPoints === 'function' ? controlPoints() : controlPoints; const sourcePoint = getConnectionPoint(sourcePort || sourceNode, _controlPoints[0] || targetPort || targetNode); const targetPoint = getConnectionPoint( targetPort || targetNode, _controlPoints[_controlPoints.length - 1] || sourcePort || sourceNode, ); return [sourcePoint, targetPoint]; } protected getHaloStyle(attributes: ParsedBaseEdgeStyleProps): false | PathStyleProps { if (attributes.halo === false) return false; const keyStyle = this.getKeyStyle(attributes); const haloStyle = subStyleProps(this.getGraphicStyle(attributes), 'halo'); return { ...keyStyle, ...haloStyle }; } protected getLabelStyle(attributes: ParsedBaseEdgeStyleProps): false | LabelStyleProps { if (attributes.label === false || !attributes.labelText) return false; const labelStyle = subStyleProps>(this.getGraphicStyle(attributes), 'label'); const { placement, offsetX, offsetY, autoRotate, maxWidth, ...restStyle } = labelStyle; const labelPositionStyle = getLabelPositionStyle( this.shapeMap.key as EdgeKey, placement, autoRotate, offsetX, offsetY, ); const bbox = this.shapeMap.key.getLocalBounds(); const wordWrapWidth = getWordWrapWidthByEnds([bbox.min, bbox.max], maxWidth); return Object.assign({ wordWrapWidth }, labelPositionStyle, restStyle); } protected getBadgeStyle(attributes: ParsedBaseEdgeStyleProps): false | BadgeStyleProps { if (attributes.badge === false || !attributes.badgeText) return false; const { offsetX, offsetY, placement, ...badgeStyle } = subStyleProps>( attributes, 'badge', ); return Object.assign( badgeStyle, getBadgePositionStyle(this.shapeMap, placement, attributes.labelPlacement, offsetX, offsetY), ); } protected drawArrow(attributes: ParsedBaseEdgeStyleProps, type: 'start' | 'end') { const isStart = type === 'start'; const arrowType = type === 'start' ? 'startArrow' : 'endArrow'; const enable = attributes[arrowType]; const keyShape = this.shapeMap.key as Path; if (enable) { const arrowStyle = this.getArrowStyle(attributes, isStart); const [marker, markerOffset, arrowOffset] = isStart ? (['markerStart', 'markerStartOffset', 'startArrowOffset'] as const) : (['markerEnd', 'markerEndOffset', 'endArrowOffset'] as const); const arrow = keyShape.parsedStyle[marker]; // update if (arrow) arrow.attr(arrowStyle); // create else { const Ctor = arrowStyle.src ? Image : Path; const arrowShape = new Ctor({ style: arrowStyle }); keyShape.style[marker] = arrowShape; } keyShape.style[markerOffset] = attributes[arrowOffset] || arrowStyle.width / 2 + +arrowStyle.lineWidth; } else { // destroy const marker = isStart ? 'markerStart' : 'markerEnd'; keyShape.style[marker]?.destroy(); keyShape.style[marker] = null; } } private getArrowStyle(attributes: ParsedBaseEdgeStyleProps, isStart: boolean) { const keyStyle = this.getShape('key')!.attributes; const arrowType = isStart ? 'startArrow' : 'endArrow'; const { size, type, ...arrowStyle } = subStyleProps>( this.getGraphicStyle(attributes), arrowType, ); const [width, height] = parseSize(getArrowSize(keyStyle.lineWidth, size)); const arrowFn = isFunction(type) ? type : Symbol[type] || Symbol.triangle; const d = arrowFn(width, height); return Object.assign( pick(keyStyle, ['stroke', 'strokeOpacity', 'fillOpacity']), { width, height }, { ...(d && { d, fill: type === 'simple' ? '' : keyStyle.stroke }) }, arrowStyle, ); } protected drawLabelShape(attributes: ParsedBaseEdgeStyleProps, container: Group) { const style = this.getLabelStyle(attributes); this.upsert('label', Label, style, container); } protected drawHaloShape(attributes: ParsedBaseEdgeStyleProps, container: Group) { const style = this.getHaloStyle(attributes); this.upsert('halo', Path, style, container); } protected drawBadgeShape(attributes: ParsedBaseEdgeStyleProps, container: Group) { const style = this.getBadgeStyle(attributes); this.upsert('badge', Badge, style, container); } protected drawSourceArrow(attributes: ParsedBaseEdgeStyleProps) { this.drawArrow(attributes, 'start'); } protected drawTargetArrow(attributes: ParsedBaseEdgeStyleProps) { this.drawArrow(attributes, 'end'); } protected drawKeyShape(attributes: ParsedBaseEdgeStyleProps, container: Group): Path | undefined { const style = this.getKeyStyle(attributes); return this.upsert('key', Path, style, container); } public render(attributes = this.parsedAttributes, container: Group = this): void { // 1. key shape this.drawKeyShape(attributes, container); if (!this.getShape('key')) return; // 2. arrows this.drawSourceArrow(attributes); this.drawTargetArrow(attributes); // 3. label this.drawLabelShape(attributes, container); // 4. halo this.drawHaloShape(attributes, container); // 5. badges this.drawBadgeShape(attributes, container); } protected onframe() { this.drawKeyShape(this.parsedAttributes, this); this.drawSourceArrow(this.parsedAttributes); this.drawTargetArrow(this.parsedAttributes); this.drawHaloShape(this.parsedAttributes, this); this.drawLabelShape(this.parsedAttributes, this); this.drawBadgeShape(this.parsedAttributes, this); } public animate(keyframes: Keyframe[], options?: number | KeyframeAnimationOptions) { const animation = super.animate(keyframes, options); if (!animation) return animation; // 设置 currentTime 时触发更新 // Trigger update when setting currentTime return new Proxy(animation, { set: (target, propKey, value) => { // 需要推迟 onframe 调用时机,等待节点位置更新完成 // Need to delay the timing of the onframe call, wait for the node position update to complete if (propKey === 'currentTime') Promise.resolve().then(() => this.onframe()); return Reflect.set(target, propKey, value); }, }); } } ================================================ FILE: packages/g6/src/elements/edges/cubic-horizontal.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { Point } from '../../types'; import { mergeOptions } from '../../utils/style'; import type { BaseEdgeStyleProps } from './base-edge'; import { Cubic } from './cubic'; /** * 水平方向的三次贝塞尔曲线样式配置项 * * Cubic Bezier curve in horizontal direction style properties */ export interface CubicHorizontalStyleProps extends BaseEdgeStyleProps { /** * 控制点在两端点连线上的相对位置,范围为`0-1` * * The relative position of the control point on the line, ranging from `0-1` * @defaultValue [0.5, 0.5] */ curvePosition?: number | [number, number]; /** * 控制点距离两端点连线的距离,可理解为控制边的弯曲程度 * * The distance of the control point from the line * @defaultValue [0, 0] */ curveOffset?: number | [number, number]; } /** * 水平方向的三次贝塞尔曲线 * * Cubic Bezier curve in horizontal direction * @remarks * 特别注意,计算控制点时主要考虑 x 轴上的距离,忽略 y 轴的变化 * * Please note that when calculating the control points, the distance on the x-axis is mainly considered, and the change on the y-axis is ignored */ export class CubicHorizontal extends Cubic { static defaultStyleProps: Partial = { curvePosition: [0.5, 0.5], curveOffset: [0, 0], }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: CubicHorizontal.defaultStyleProps }, options)); } protected getControlPoints( sourcePoint: Point, targetPoint: Point, curvePosition: [number, number], curveOffset: [number, number], ): [Point, Point] { const xDist = targetPoint[0] - sourcePoint[0]; return [ [sourcePoint[0] + xDist * curvePosition[0] + curveOffset[0], sourcePoint[1]], [targetPoint[0] - xDist * curvePosition[1] + curveOffset[1], targetPoint[1]], ]; } } ================================================ FILE: packages/g6/src/elements/edges/cubic-radial.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { NodeData } from '../../spec'; import type { Point } from '../../types'; import { positionOf } from '../../utils/position'; import { mergeOptions } from '../../utils/style'; import { distance, rad, subtract } from '../../utils/vector'; import type { CubicStyleProps } from './cubic'; import { Cubic } from './cubic'; /** * 径向贝塞尔曲线样式配置项 * * Radial cubic style props */ export interface CubicRadialStyleProps extends CubicStyleProps {} /** * 径向贝塞尔曲线 * * Radial cubic edge */ export class CubicRadial extends Cubic { static defaultStyleProps: Partial = { curvePosition: 0.5, curveOffset: 20, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: CubicRadial.defaultStyleProps }, options)); } private get ref(): NodeData { return this.context.model.getRootsData()[0]; } protected getEndpoints(attributes: Required): [Point, Point] { if (this.sourceNode.id === this.ref.id) { return super.getEndpoints(attributes); } const refPoint = positionOf(this.ref); const sourcePoint = this.sourceNode.getIntersectPoint(refPoint, true); const targetPoint = this.targetNode.getIntersectPoint(refPoint); return [sourcePoint, targetPoint]; } private toRadialCoordinate(p: Point) { const refPoint = positionOf(this.ref); const r = distance(p, refPoint); const radian = rad(subtract(p, refPoint)); return [r, radian]; } protected getControlPoints( sourcePoint: Point, targetPoint: Point, curvePosition: [number, number], curveOffset: [number, number], ): [Point, Point] { const [r1, rad1] = this.toRadialCoordinate(sourcePoint); const [r2] = this.toRadialCoordinate(targetPoint); const rDist = r2 - r1; return [ [ sourcePoint[0] + (rDist * curvePosition[0] + curveOffset[0]) * Math.cos(rad1), sourcePoint[1] + (rDist * curvePosition[0] + curveOffset[0]) * Math.sin(rad1), ], [ targetPoint[0] - (rDist * curvePosition[1] - curveOffset[0]) * Math.cos(rad1), targetPoint[1] - (rDist * curvePosition[1] - curveOffset[0]) * Math.sin(rad1), ], ]; } } ================================================ FILE: packages/g6/src/elements/edges/cubic-vertical.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { Point } from '../../types'; import { mergeOptions } from '../../utils/style'; import type { BaseEdgeStyleProps } from './base-edge'; import { Cubic } from './cubic'; /** * 垂直方向的三次贝塞尔曲线样式配置项 * * Cubic Bezier curve style properties in vertical direction */ export interface CubicVerticalStyleProps extends BaseEdgeStyleProps { /** * 控制点在两端点连线上的相对位置,范围为`0-1` * * The relative position of the control point on the line, ranging from `0-1` * @defaultValue [0.5, 0.5] */ curvePosition?: number | [number, number]; /** * 控制点距离两端点连线的距离,可理解为控制边的弯曲程度 * * The distance of the control point from the line * @defaultValue [0, 0] */ curveOffset?: number | [number, number]; } /** * 垂直方向的三次贝塞尔曲线 * * Cubic Bezier curve in vertical direction * @remarks * 特别注意,计算控制点时主要考虑 y 轴上的距离,忽略 x 轴的变化 * * Please note that when calculating the control points, the distance on the y-axis is mainly considered, and the change on the x-axis is ignored */ export class CubicVertical extends Cubic { static defaultStyleProps: Partial = { curvePosition: [0.5, 0.5], curveOffset: [0, 0], }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: CubicVertical.defaultStyleProps }, options)); } protected getControlPoints( sourcePoint: Point, targetPoint: Point, curvePosition: [number, number], curveOffset: [number, number], ): [Point, Point] { const yDist = targetPoint[1] - sourcePoint[1]; return [ [sourcePoint[0], sourcePoint[1] + yDist * curvePosition[0] + curveOffset[0]], [targetPoint[0], targetPoint[1] - yDist * curvePosition[1] + curveOffset[1]], ]; } } ================================================ FILE: packages/g6/src/elements/edges/cubic.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { PathArray } from '@antv/util'; import type { Point } from '../../types'; import { getCubicPath, getCurveControlPoint, parseCurveOffset, parseCurvePosition } from '../../utils/edge'; import { mergeOptions } from '../../utils/style'; import type { BaseEdgeStyleProps } from './base-edge'; import { BaseEdge } from './base-edge'; /** * 三次贝塞尔曲线样式配置项 * * Cubic Bezier curve style properties */ export interface CubicStyleProps extends BaseEdgeStyleProps { /** * 控制点数组,用于定义曲线的形状。如果不指定,将会通过 `curveOffset` 和 `curvePosition` 来计算控制点 * * Control points. Used to define the shape of the curve. If not specified, it will be calculated using `curveOffset` and `curvePosition`. */ controlPoints?: [Point, Point]; /** * 控制点在两端点连线上的相对位置,范围为`0-1` * * The relative position of the control point on the line, ranging from `0-1` * @defaultValue 0.5 */ curvePosition?: number | [number, number]; /** * 控制点距离两端点连线的距离,可理解为控制边的弯曲程度 * * The distance of the control point from the line * @defaultValue 20 */ curveOffset?: number | [number, number]; } type ParsedCubicStyleProps = Required; /** * 三次贝塞尔曲线 * * Cubic Bezier curve */ export class Cubic extends BaseEdge { static defaultStyleProps: Partial = { curvePosition: 0.5, curveOffset: 20, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Cubic.defaultStyleProps }, options)); } /** * @inheritdoc */ protected getKeyPath(attributes: ParsedCubicStyleProps): PathArray { const [sourcePoint, targetPoint] = this.getEndpoints(attributes); const { controlPoints, curvePosition, curveOffset } = attributes; const actualControlPoints = this.getControlPoints( sourcePoint, targetPoint, parseCurvePosition(curvePosition), parseCurveOffset(curveOffset), controlPoints, ); return getCubicPath(sourcePoint, targetPoint, actualControlPoints); } protected getControlPoints( sourcePoint: Point, targetPoint: Point, curvePosition: [number, number], curveOffset: [number, number], controlPoints?: [Point, Point], ): [Point, Point] { return controlPoints?.length === 2 ? controlPoints : [ getCurveControlPoint(sourcePoint, targetPoint, curvePosition[0], curveOffset[0]), getCurveControlPoint(sourcePoint, targetPoint, curvePosition[1], curveOffset[1]), ]; } } ================================================ FILE: packages/g6/src/elements/edges/index.ts ================================================ export { BaseEdge } from './base-edge'; export { Cubic } from './cubic'; export { CubicHorizontal } from './cubic-horizontal'; export { CubicRadial } from './cubic-radial'; export { CubicVertical } from './cubic-vertical'; export { Line } from './line'; export { Polyline } from './polyline'; export { Quadratic } from './quadratic'; export type { BaseEdgeStyleProps } from './base-edge'; export type { CubicStyleProps } from './cubic'; export type { CubicHorizontalStyleProps } from './cubic-horizontal'; export type { CubicRadialStyleProps } from './cubic-radial'; export type { CubicVerticalStyleProps } from './cubic-vertical'; export type { LineStyleProps } from './line'; export type { PolylineStyleProps } from './polyline'; export type { QuadraticStyleProps } from './quadratic'; ================================================ FILE: packages/g6/src/elements/edges/line.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { PathArray } from '@antv/util'; import { mergeOptions } from '../../utils/style'; import type { BaseEdgeStyleProps } from './base-edge'; import { BaseEdge } from './base-edge'; /** * 直线样式配置项 * * Line style properties */ export interface LineStyleProps extends BaseEdgeStyleProps {} type ParsedLineStyleProps = Required; /** * 直线 * * Line */ export class Line extends BaseEdge { static defaultStyleProps: Partial = {}; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Line.defaultStyleProps }, options)); } protected getKeyPath(attributes: ParsedLineStyleProps): PathArray { const [sourcePoint, targetPoint] = this.getEndpoints(attributes); return [ ['M', sourcePoint[0], sourcePoint[1]], ['L', targetPoint[0], targetPoint[1]], ]; } } ================================================ FILE: packages/g6/src/elements/edges/polyline.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { PathArray } from '@antv/util'; import type { LoopStyleProps, Point, PolylineRouter } from '../../types'; import { getBBoxHeight, getBBoxWidth, getNodeBBox } from '../../utils/bbox'; import { getPolylineLoopPath, getPolylinePath } from '../../utils/edge'; import { subStyleProps } from '../../utils/prefix'; import { orth } from '../../utils/router/orth'; import { aStarSearch } from '../../utils/router/shortest-path'; import { mergeOptions } from '../../utils/style'; import type { BaseEdgeStyleProps } from './base-edge'; import { BaseEdge } from './base-edge'; /** * 折线样式配置项 * * Polyline style properties */ export interface PolylineStyleProps extends BaseEdgeStyleProps { /** * 圆角半径 * * The radius of the rounded corner * @defaultValue 0 */ radius?: number; /** * 控制点数组 * * Control point array */ controlPoints?: Point[]; /** * 是否启用路由,默认开启且 controlPoints 会自动计入 * * Whether to enable routing, it is enabled by default and controlPoints will be automatically included * @defaultValue false */ router?: PolylineRouter; } type ParsedPolylineStyleProps = Required; /** * 折线 * * Polyline */ export class Polyline extends BaseEdge { static defaultStyleProps: Partial = { radius: 0, controlPoints: [], router: false, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Polyline.defaultStyleProps }, options)); } protected getControlPoints(attributes: ParsedPolylineStyleProps): Point[] { const { router } = attributes; const { sourceNode, targetNode } = this; const [sourcePoint, targetPoint] = this.getEndpoints(attributes, false); let controlPoints: Point[] = []; if (!router) { controlPoints = attributes.controlPoints; } else { if (router.type === 'shortest-path') { const nodes = this.context.element!.getNodes(); controlPoints = aStarSearch(sourceNode, targetNode, nodes, router); if (!controlPoints.length) { controlPoints = orth(sourcePoint, targetPoint, sourceNode, targetNode, attributes.controlPoints, { padding: router.offset, }); } } else if (router.type === 'orth') { controlPoints = orth(sourcePoint, targetPoint, sourceNode, targetNode, attributes.controlPoints, router); } } return controlPoints; } protected getPoints(attributes: ParsedPolylineStyleProps): Point[] { const controlPoints = this.getControlPoints(attributes); const [newSourcePoint, newTargetPoint] = this.getEndpoints(attributes, true, controlPoints); return [newSourcePoint, ...controlPoints, newTargetPoint]; } protected getKeyPath(attributes: ParsedPolylineStyleProps): PathArray { const points = this.getPoints(attributes); return getPolylinePath(points, attributes.radius); } protected getLoopPath(attributes: ParsedPolylineStyleProps): PathArray { const { sourcePort: sourcePortKey, targetPort: targetPortKey, radius } = attributes; const node = this.sourceNode; const bbox = getNodeBBox(node); // 默认转折点距离为 bbox 的最大宽高的 1/4 // Default distance of the turning point is 1/4 of the maximum width and height of the bbox const defaultDist = Math.max(getBBoxWidth(bbox), getBBoxHeight(bbox)) / 4; const { placement, clockwise, dist = defaultDist, } = subStyleProps>(this.getGraphicStyle(attributes), 'loop'); return getPolylineLoopPath(node, radius, placement, clockwise, dist, sourcePortKey, targetPortKey); } } ================================================ FILE: packages/g6/src/elements/edges/quadratic.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { PathArray } from '@antv/util'; import type { Point } from '../../types'; import { getCurveControlPoint, getQuadraticPath } from '../../utils/edge'; import { mergeOptions } from '../../utils/style'; import type { BaseEdgeStyleProps } from './base-edge'; import { BaseEdge } from './base-edge'; /** * 二次贝塞尔曲线样式配置项 * * Quadratic Bezier curve style properties */ export interface QuadraticStyleProps extends BaseEdgeStyleProps { /** * 控制点,用于定义曲线的形状。如果不指定,将会通过`curveOffset`和`curvePosition`来计算控制点 * * Control point. Used to define the shape of the curve. If not specified, it will be calculated using `curveOffset` and `curvePosition`. */ controlPoint?: Point; /** * 控制点在两端点连线上的相对位置,范围为`0-1` * * The relative position of the control point on the line, ranging from `0-1` * @defaultValue 0.5 */ curvePosition?: number; /** * 控制点距离两端点连线的距离,可理解为控制边的弯曲程度 * * The distance of the control point from the line * @defaultValue 30 */ curveOffset?: number; } type ParsedQuadraticStyleProps = Required; /** * 二次贝塞尔曲线 * * Quadratic Bezier curve */ export class Quadratic extends BaseEdge { static defaultStyleProps: Partial = { curvePosition: 0.5, curveOffset: 30, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Quadratic.defaultStyleProps }, options)); } protected getKeyPath(attributes: ParsedQuadraticStyleProps): PathArray { const { curvePosition, curveOffset } = attributes; const [sourcePoint, targetPoint] = this.getEndpoints(attributes); const controlPoint = attributes.controlPoint || getCurveControlPoint(sourcePoint, targetPoint, curvePosition, curveOffset); return getQuadraticPath(sourcePoint, targetPoint, controlPoint); } } ================================================ FILE: packages/g6/src/elements/effect.ts ================================================ import type { Element } from '../types'; const EFFECT_WEAKMAP = new WeakMap>(); /** * 判定给定样式是否与上一次的样式相同 * * Determine whether the given style are the same as the previous ones * @param target - 目标元素 | Target element * @param key - 缓存 key | Cache key * @param style - 样式属性 | Style attribute * @returns 是否执行函数 | Whether to execute the function * @deprecated 该方法已废弃,并不能显著提升性能 | This method is deprecated and does not significantly improve performance */ export function effect>(target: Element, key: string, style: T): boolean { // return true; if (!EFFECT_WEAKMAP.has(target)) EFFECT_WEAKMAP.set(target, {}); const cache = EFFECT_WEAKMAP.get(target)!; if (!cache[key]) { cache[key] = style; return true; } const original = cache[key]; if (isStyleEqual(original, style)) return false; cache[key] = style; return true; } /** * 比较两个样式属性是否相等 * * Compare whether two style attributes are equal * @param a - 样式属性 a | Style attribute a * @param b - 样式属性 b | Style attribute b * @param depth - 比较深度 | Comparison depth * @returns 是否相等 | Whether they are equal * @remarks * 进行第二层浅比较用于比较 badges、ports 等复合图形属性 * * Perform a second-level shallow comparison to compare complex shape attributes such as badges and ports */ const isStyleEqual = (a: false | Record, b: false | Record, depth = 2): boolean => { if (typeof a !== 'object' || typeof b !== 'object') return a === b; const keys1 = Object.keys(a); const keys2 = Object.keys(b); if (keys1.length !== keys2.length) return false; for (const key of keys1) { const val1 = a[key]; const val2 = b[key]; if (depth > 1 && typeof val1 === 'object' && typeof val2 === 'object') { if (!isStyleEqual(val1 as Record, val2 as Record, depth - 1)) return false; } else if (val1 !== val2) return false; } return true; }; ================================================ FILE: packages/g6/src/elements/index.ts ================================================ export * from './combos'; export * from './edges'; export * from './nodes'; ================================================ FILE: packages/g6/src/elements/nodes/base-node.ts ================================================ import type { BaseStyleProps, CircleStyleProps, DisplayObject, DisplayObjectConfig, Group } from '@antv/g'; import { Circle as GCircle } from '@antv/g'; import type { CategoricalPalette } from '../../palettes/types'; import type { RuntimeContext } from '../../runtime/types'; import type { NodeData } from '../../spec'; import type { ID, Node, NodeBadgeStyleProps, NodeLabelStyleProps, NodePortStyleProps, Point, Port, PortPlacement, PortStyleProps, Prefix, Size, } from '../../types'; import { getPortXYByPlacement, getTextStyleByPlacement, isSimplePort } from '../../utils/element'; import { inferIconStyle } from '../../utils/node'; import { getPaletteColors } from '../../utils/palette'; import { getRectIntersectPoint } from '../../utils/point'; import { omitStyleProps, subObject, subStyleProps } from '../../utils/prefix'; import { parseSize } from '../../utils/size'; import { mergeOptions } from '../../utils/style'; import { getWordWrapWidthByBox } from '../../utils/text'; import { setVisibility } from '../../utils/visibility'; import { BaseElement } from '../base-element'; import type { BadgeStyleProps, BaseShapeStyleProps, IconStyleProps, LabelStyleProps } from '../shapes'; import { Badge, Icon, Label } from '../shapes'; import { connectImage, dispatchPositionChange } from '../shapes/image'; /** * 节点通用样式配置项 * * Base node style props */ export interface BaseNodeStyleProps extends BaseShapeStyleProps, Prefix<'label', NodeLabelStyleProps>, Prefix<'halo', BaseStyleProps>, Prefix<'icon', IconStyleProps>, Prefix<'badge', BadgeStyleProps>, Prefix<'port', PortStyleProps> { /** * x 坐标 * * The x-coordinate of node */ x?: number; /** * y 坐标 * * The y-coordinate of node */ y?: number; /** * z 坐标 * * The z-coordinate of node */ z?: number; /** * 节点大小,快捷设置节点宽高 * - 若值为数字,则表示节点的宽度、高度以及深度相同为指定值 * - 若值为数组,则按数组元素依次表示节点的宽度、高度以及深度 * * The size of node, which is a shortcut to set the width and height of node * - If the value is a number, it means the width, height, and depth of the node are the same as the specified value * - If the value is an array, it means the width, height, and depth of the node are represented by the array elements in turn */ size?: Size; /** * 当前节点/组合是否展开 * * Whether the current node/combo is expanded */ collapsed?: boolean; /** * 子节点实例 * * The instance of the child node * @remarks * 仅在树图中生效 * * Only valid in the tree graph * @ignore */ childrenNode?: ID[]; /** * 子节点数据 * * The data of the child node * @remarks * 仅在树图中生效。如果当前节点为收起状态,children 可能为空,通过 childrenData 能够获取完整的子元素数据 * * Only valid in the tree graph. If the current node is collapsed, children may be empty, and the complete child element data can be obtained through childrenData * @ignore */ childrenData?: NodeData[]; /** * 是否显示节点标签 * * Whether to show the node label * @defaultValue true */ label?: boolean; /** * 是否显示节点光晕 * * Whether to show the node halo * @defaultValue false */ halo?: boolean; /** * 是否显示节点图标 * * Whether to show the node icon * @defaultValue true */ icon?: boolean; /** * 是否显示节点徽标 * * Whether to show the node badge * @defaultValue true */ badge?: boolean; /** * 是否显示连接桩 * * Whether to show the node port * @defaultValue true */ port?: boolean; /** * 连接桩配置项,支持配置多个连接桩 * * Port configuration, supports configuring multiple ports * @example * ```json * { * port: true, * ports: [ * { key: 'top', placement: [0.5, 0], r: 4, stroke: '#31d0c6', fill: '#fff' }, * { key: 'bottom', placement: [0.5, 1], r: 4, stroke: '#31d0c6', fill: '#fff' }, * ], * } * ``` */ ports?: NodePortStyleProps[]; /** * 徽标 * * Badge * @example * ```json * { * badge: true, * badges: [ * { text: 'A', placement: 'right-top'}, * { text: 'Important', placement: 'right' }, * { text: 'Notice', placement: 'right-bottom' }, * ], * badgePalette: ['#7E92B5', '#F4664A', '#FFBE3A'], * } * ``` */ badges?: NodeBadgeStyleProps[]; /** * 徽标的背景色板 * * Badge background color palette */ badgePalette?: CategoricalPalette; } /** * 节点元素的基类 * * Base node class * @remarks * 自定义节点时,建议将此类作为基类。这样,你只需要关注如何实现 keyShape 的绘制逻辑 * * 设计文档:https://www.yuque.com/antv/g6/gl1iof1xpzg6ed98 * * When customizing a node, it is recommended to use this class as the base class. This way, you can directly focus on how to implement the drawing logic of keyShape * * Design document: https://www.yuque.com/antv/g6/gl1iof1xpzg6ed98 */ export abstract class BaseNode extends BaseElement implements Node { public type = 'node'; static defaultStyleProps: Partial = { x: 0, y: 0, size: 32, droppable: true, draggable: true, port: true, ports: [], portZIndex: 2, portLinkToCenter: false, badge: true, badges: [], badgeZIndex: 3, halo: false, haloDroppable: false, haloLineDash: 0, haloLineWidth: 12, haloStrokeOpacity: 0.25, haloPointerEvents: 'none', haloZIndex: -1, icon: true, iconZIndex: 1, label: true, labelIsBillboard: true, labelMaxWidth: '200%', labelPlacement: 'bottom', labelWordWrap: false, labelZIndex: 0, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: BaseNode.defaultStyleProps }, options)); } protected getSize(attributes = this.attributes) { const { size } = attributes; return parseSize(size); } protected getKeyStyle(attributes: Required) { const style = this.getGraphicStyle(attributes); return Object.assign(omitStyleProps(style, ['label', 'halo', 'icon', 'badge', 'port'])) as any; } protected getLabelStyle(attributes: Required): false | LabelStyleProps { if (attributes.label === false || !attributes.labelText) return false; const { placement, maxWidth, offsetX, offsetY, ...labelStyle } = subStyleProps>( this.getGraphicStyle(attributes), 'label', ); const keyBounds = this.getShape('key').getLocalBounds(); return Object.assign( getTextStyleByPlacement(keyBounds, placement, offsetX, offsetY), { wordWrapWidth: getWordWrapWidthByBox(keyBounds, maxWidth) }, labelStyle, ); } protected getHaloStyle(attributes: Required) { if (attributes.halo === false) return false; const { fill, ...keyStyle } = this.getKeyStyle(attributes); const haloStyle = subStyleProps(this.getGraphicStyle(attributes), 'halo'); return { ...keyStyle, stroke: fill, ...haloStyle } as any; } protected getIconStyle(attributes: Required): false | IconStyleProps { if (attributes.icon === false || (!attributes.iconText && !attributes.iconSrc)) return false; const iconStyle = subStyleProps(this.getGraphicStyle(attributes), 'icon'); return Object.assign(inferIconStyle(attributes.size!, iconStyle), iconStyle); } protected getBadgesStyle(attributes: Required): Record { const badges = subObject(this.shapeMap, 'badge-'); const badgesShapeStyle: Record = {}; Object.keys(badges).forEach((key) => { badgesShapeStyle[key] = false; }); if (attributes.badge === false || !attributes.badges?.length) return badgesShapeStyle; const { badges: badgeOptions = [], badgePalette, opacity = 1, ...restAttributes } = attributes; const colors = getPaletteColors(badgePalette); const badgeStyle = subStyleProps(this.getGraphicStyle(restAttributes), 'badge'); badgeOptions.forEach((option, i) => { badgesShapeStyle[i] = { backgroundFill: colors ? colors[i % colors?.length] : undefined, opacity, ...badgeStyle, ...this.getBadgeStyle(option), }; }); return badgesShapeStyle; } protected getBadgeStyle(style: NodeBadgeStyleProps): NodeBadgeStyleProps { const keyShape = this.getShape('key'); const { placement = 'top', offsetX, offsetY, ...restStyle } = style; const textStyle = getTextStyleByPlacement(keyShape.getLocalBounds(), placement, offsetX, offsetY, true); return { ...textStyle, ...restStyle }; } protected getPortsStyle(attributes: Required): Record { const ports = this.getPorts(); const portsShapeStyle: Record = {}; Object.keys(ports).forEach((key) => { portsShapeStyle[key] = false; }); if (attributes.port === false || !attributes.ports?.length) return portsShapeStyle; const portStyle = subStyleProps(this.getGraphicStyle(attributes), 'port'); const { ports: portOptions = [] } = attributes; portOptions.forEach((option, index) => { const key = option.key || index; const mergedStyle = { ...portStyle, ...option }; if (isSimplePort(mergedStyle)) { portsShapeStyle[key] = false; } else { const [x, y] = this.getPortXY(attributes, option); portsShapeStyle[key] = { transform: [['translate', x, y]], ...mergedStyle }; } }); return portsShapeStyle; } protected getPortXY(attributes: Required, style: NodePortStyleProps): Point { const { placement = 'left' } = style; const keyShape = this.getShape('key'); return getPortXYByPlacement(getBoundsInOffscreen(this.context, keyShape), placement as PortPlacement); } /** * Get the ports for the node. * @returns Ports shape map. */ public getPorts(): Record { return subObject(this.shapeMap, 'port-'); } /** * Get the center point of the node. * @returns The center point of the node. */ public getCenter(): Point { return this.getShape('key').getBounds().center; } /** * Get the point on the outer contour of the node that is the intersection with a line starting in the center the ending in the point `p`. * @param point - The point to intersect with the node. * @param useExtendedLine - Whether to use the extended line. * @returns The intersection point. */ public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); return getRectIntersectPoint(point, keyShapeBounds, useExtendedLine); } protected drawHaloShape(attributes: Required, container: Group): void { const style = this.getHaloStyle(attributes); const keyShape = this.getShape('key'); this.upsert('halo', keyShape.constructor as new (...args: unknown[]) => DisplayObject, style, container); } protected drawIconShape(attributes: Required, container: Group): void { const style = this.getIconStyle(attributes); this.upsert('icon', Icon, style, container); connectImage(this); } protected drawBadgeShapes(attributes: Required, container: Group): void { const badgesStyle = this.getBadgesStyle(attributes); Object.keys(badgesStyle).forEach((key) => { const style = badgesStyle[key]; this.upsert(`badge-${key}`, Badge, style, container); }); } protected drawPortShapes(attributes: Required, container: Group): void { const portsStyle = this.getPortsStyle(attributes); Object.keys(portsStyle).forEach((key) => { const style = portsStyle[key] as CircleStyleProps; const shapeKey = `port-${key}`; this.upsert(shapeKey, GCircle, style, container); }); } protected drawLabelShape(attributes: Required, container: Group): void { const style = this.getLabelStyle(attributes); this.upsert('label', Label, style, container); } protected abstract drawKeyShape(attributes: Required, container: Group): DisplayObject | undefined; // 用于装饰抽象方法 / Used to decorate abstract methods private _drawKeyShape(attributes: Required, container: Group) { return this.drawKeyShape(attributes, container); } public render(attributes = this.parsedAttributes, container: Group = this) { // 1. key shape this._drawKeyShape(attributes, container); if (!this.getShape('key')) return; // 2. halo, use shape same with keyShape this.drawHaloShape(attributes, container); // 3. icon this.drawIconShape(attributes, container); // 4. badges this.drawBadgeShapes(attributes, container); // 5. label this.drawLabelShape(attributes, container); // 6. ports this.drawPortShapes(attributes, container); } public update(attr?: Partial): void { super.update(attr); if (attr && ('x' in attr || 'y' in attr || 'z' in attr)) { dispatchPositionChange(this); } } protected onframe() { this.drawBadgeShapes(this.parsedAttributes, this); this.drawLabelShape(this.parsedAttributes, this); } } /** * 在离屏画布中获取图形包围盒 * * Get the bounding box of the shape in the off-screen canvas * @param context - 运行时上下文 Runtime context * @param shape - 图形实例 Graphic instance * @returns 图形包围盒 Graphic bounding box */ function getBoundsInOffscreen(context: RuntimeContext, shape: DisplayObject) { if (!context) return shape.getLocalBounds(); // 将主图形靠背至全局空间,避免受到父级 transform 的影响 // 合理的操作应该是拷贝至离屏画布,但目前 G 有点问题 // Move the main shape to the global space to avoid being affected by the parent transform // The reasonable operation should be moved to the off-screen canvas, but there is a problem with G at present const canvas = context.canvas.getLayer(); const substitute = shape.cloneNode(); setVisibility(substitute, 'hidden'); canvas.appendChild(substitute); const bounds = substitute.getLocalBounds(); substitute.destroy(); return bounds; } ================================================ FILE: packages/g6/src/elements/nodes/circle.ts ================================================ import type { DisplayObjectConfig, CircleStyleProps as GCircleStyleProps, Group } from '@antv/g'; import { Circle as GCircle } from '@antv/g'; import { ICON_SIZE_RATIO } from '../../constants/element'; import type { Point } from '../../types'; import { getEllipseIntersectPoint } from '../../utils/point'; import { mergeOptions } from '../../utils/style'; import type { IconStyleProps } from '../shapes'; import type { BaseNodeStyleProps } from './base-node'; import { BaseNode } from './base-node'; /** * 圆形节点样式配置项 * * Circle node style props */ export interface CircleStyleProps extends BaseNodeStyleProps {} /** * 圆形节点 * * Circle node */ export class Circle extends BaseNode { static defaultStyleProps: Partial = { size: 32, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Circle.defaultStyleProps }, options)); } protected drawKeyShape(attributes: Required, container: Group) { return this.upsert('key', GCircle, this.getKeyStyle(attributes), container); } protected getKeyStyle(attributes: Required): GCircleStyleProps { const keyStyle = super.getKeyStyle(attributes); return { ...keyStyle, r: Math.min(...this.getSize(attributes)) / 2 }; } protected getIconStyle(attributes: Required): false | IconStyleProps { const style = super.getIconStyle(attributes); const { r } = this.getShape('key').attributes; const size = (r as number) * 2 * ICON_SIZE_RATIO; return style ? ({ width: size, height: size, ...style } as IconStyleProps) : false; } public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); return getEllipseIntersectPoint(point, keyShapeBounds, useExtendedLine); } } ================================================ FILE: packages/g6/src/elements/nodes/diamond.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { Point } from '../../types'; import { getDiamondPoints } from '../../utils/element'; import type { PolygonStyleProps } from '../shapes'; import { Polygon } from '../shapes/polygon'; /** * 菱形节点样式配置项 * * Diamond node style props */ export interface DiamondStyleProps extends PolygonStyleProps {} /** * 菱形节点 * * Diamond node */ export class Diamond extends Polygon { constructor(options: DisplayObjectConfig) { super(options); } protected getPoints(attributes: Required): Point[] { const [width, height] = this.getSize(attributes); return getDiamondPoints(width, height); } } ================================================ FILE: packages/g6/src/elements/nodes/donut.ts ================================================ import { Path } from '@antv/g'; import { isNumber, isString } from '@antv/util'; import { getPaletteColors } from '../../utils/palette'; import { subStyleProps } from '../../utils/prefix'; import { parseSize } from '../../utils/size'; import { mergeOptions } from '../../utils/style'; import { Circle } from './circle'; import type { BaseStyleProps, DisplayObjectConfig, Group } from '@antv/g'; import type { PathArray } from '@antv/util'; import type { CategoricalPalette } from '../../palettes/types'; import type { DonutRound, Prefix } from '../../types'; import type { CircleStyleProps } from './circle'; /** * 甜甜圈节点样式配置项 * * Donut node style props */ export interface DonutStyleProps extends CircleStyleProps, Prefix<'donut', BaseStyleProps> { /** * 内环半径,使用百分比或者像素值 * * Inner ring radius, using percentage or pixel value. * @defaultValue '50%' */ innerR?: string | number; /** * 圆环数据 * * Donut data. */ donuts?: number[] | DonutRound[]; /** * 颜色或者色板名 * * Color or palette. * @defaultValue 'tableau' */ donutPalette?: string | CategoricalPalette; } /** * 甜甜圈节点 * * Donut node */ export class Donut extends Circle { static defaultStyleProps: Partial = { innerR: '50%', donuts: [], donutPalette: 'tableau', }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Donut.defaultStyleProps }, options)); } private parseOuterR() { const { size } = this.parsedAttributes as Required; return Math.min(...parseSize(size)) / 2; } private parseInnerR() { const { innerR } = this.parsedAttributes as Required; return isString(innerR) ? (parseInt(innerR) / 100) * this.parseOuterR() : innerR; } protected drawDonutShape(attributes: Required, container: Group): void { const { donuts } = attributes; if (!donuts?.length) return; const parsedDonuts = donuts.map((round) => (isNumber(round) ? { value: round } : round) as DonutRound); const style = subStyleProps(this.getGraphicStyle(attributes), 'donut'); const colors = getPaletteColors(attributes.donutPalette); if (!colors) return; const sum = parsedDonuts.reduce((acc, cur) => acc + (cur.value ?? 0), 0); const outerR = this.parseOuterR(); const innerR = this.parseInnerR(); let start = 0; parsedDonuts.forEach((round, index) => { const { value = 0, color = colors[index % colors.length], ...roundStyle } = round; const angle = (sum === 0 ? 1 / parsedDonuts.length : value / sum) * 360; this.upsert( `round${index}`, Path, { ...style, d: arc(outerR, innerR, start, start + angle), fill: color, ...roundStyle, }, container, ); start += angle; }); } public render(attributes: Required, container: Group = this) { super.render(attributes, container); this.drawDonutShape(attributes, container); } } const point = (x: number, y: number, r: number, angel: number) => [x + Math.sin(angel) * r, y - Math.cos(angel) * r]; const full = (x: number, y: number, R: number, r: number): PathArray => { if (r <= 0 || R <= r) { return [['M', x - R, y], ['A', R, R, 0, 1, 1, x + R, y], ['A', R, R, 0, 1, 1, x - R, y], ['Z']]; } return [ ['M', x - R, y], ['A', R, R, 0, 1, 1, x + R, y], ['A', R, R, 0, 1, 1, x - R, y], ['Z'], ['M', x + r, y], ['A', r, r, 0, 1, 0, x - r, y], ['A', r, r, 0, 1, 0, x + r, y], ['Z'], ]; }; const part = (x: number, y: number, R: number, r: number, start: number, end: number): PathArray => { const [s, e] = [(start / 360) * 2 * Math.PI, (end / 360) * 2 * Math.PI]; const P = [point(x, y, r, s), point(x, y, R, s), point(x, y, R, e), point(x, y, r, e)]; const flag = e - s > Math.PI ? 1 : 0; return [ ['M', P[0][0], P[0][1]], ['L', P[1][0], P[1][1]], ['A', R, R, 0, flag, 1, P[2][0], P[2][1]], ['L', P[3][0], P[3][1]], ['A', r, r, 0, flag, 0, P[0][0], P[0][1]], ['Z'], ]; }; const arc = (R = 0, r = 0, start: number, end: number): PathArray => { const [x, y] = [0, 0]; if (Math.abs(start - end) % 360 < 0.000001) return full(x, y, R, r); return part(x, y, R, r, start, end); }; ================================================ FILE: packages/g6/src/elements/nodes/ellipse.ts ================================================ import type { DisplayObjectConfig, EllipseStyleProps as GEllipseStyleProps, Group } from '@antv/g'; import { Ellipse as GEllipse } from '@antv/g'; import { ICON_SIZE_RATIO } from '../../constants/element'; import type { Point } from '../../types'; import { getEllipseIntersectPoint } from '../../utils/point'; import { mergeOptions } from '../../utils/style'; import type { IconStyleProps } from '../shapes'; import type { BaseNodeStyleProps } from './base-node'; import { BaseNode } from './base-node'; /** * 椭圆节点样式配置项 * * Ellipse node style props */ export interface EllipseStyleProps extends BaseNodeStyleProps {} /** * 椭圆节点 * * Ellipse node */ export class Ellipse extends BaseNode { static defaultStyleProps: Partial = { size: [45, 35], }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Ellipse.defaultStyleProps }, options)); } protected drawKeyShape(attributes: Required, container: Group) { return this.upsert('key', GEllipse, this.getKeyStyle(attributes), container); } protected getKeyStyle(attributes: Required): GEllipseStyleProps { const keyStyle = super.getKeyStyle(attributes); const [majorAxis, minorAxis] = this.getSize(attributes); return { ...keyStyle, rx: majorAxis / 2, ry: minorAxis / 2, }; } protected getIconStyle(attributes: Required): false | IconStyleProps { const style = super.getIconStyle(attributes); const { rx, ry } = this.getShape('key').attributes; const size = Math.min(+rx, +ry) * 2 * ICON_SIZE_RATIO; return style ? ({ width: size, height: size, ...style } as IconStyleProps) : false; } public getIntersectPoint(point: Point, useExtendedLine = false): Point { const keyShapeBounds = this.getShape('key').getBounds(); return getEllipseIntersectPoint(point, keyShapeBounds, useExtendedLine); } } ================================================ FILE: packages/g6/src/elements/nodes/hexagon.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import { ICON_SIZE_RATIO } from '../../constants/element'; import type { Point } from '../../types'; import { getHexagonPoints } from '../../utils/element'; import type { IconStyleProps, PolygonStyleProps } from '../shapes'; import { Polygon } from '../shapes/polygon'; /** * 六边形节点样式配置项 * * Hexagon node style props */ export interface HexagonStyleProps extends PolygonStyleProps { /** * 外半径,默认为宽高的最小值的一半 * * outer radius, default is half of the minimum value of width and height */ outerR?: number; } /** * 六边形节点 * * Hexagon node */ export class Hexagon extends Polygon { constructor(options: DisplayObjectConfig) { super(options); } private getOuterR(attributes: Required): number { return attributes.outerR || Math.min(...this.getSize(attributes)) / 2; } protected getPoints(attributes: Required): Point[] { return getHexagonPoints(this.getOuterR(attributes)); } protected getIconStyle(attributes: Required): false | IconStyleProps { const style = super.getIconStyle(attributes); const size = this.getOuterR(attributes) * ICON_SIZE_RATIO; return style ? ({ width: size, height: size, ...style } as IconStyleProps) : false; } } ================================================ FILE: packages/g6/src/elements/nodes/html.ts ================================================ import { DisplayObjectConfig, FederatedMouseEvent, FederatedPointerEvent, HTML as GHTML, HTMLStyleProps as GHTMLStyleProps, Group, ICanvas, IEventTarget, Rect, } from '@antv/g'; import { Renderer } from '@antv/g-canvas'; import { isNil, isUndefined, pick, set } from '@antv/util'; import { CommonEvent } from '../../constants'; import type { BaseNodeStyleProps } from './base-node'; import { BaseNode } from './base-node'; /** * HTML 节点样式配置项 * * HTML node style props */ export interface HTMLStyleProps extends BaseNodeStyleProps { /** * HTML 内容,可以为字符串或者 `HTMLElement` * * HTML content, can be a string or `HTMLElement` */ innerHTML: string | HTMLElement; /** * 横行偏移量。HTML 容器默认以左上角为原点,通过 dx 来进行横向偏移 * * Horizontal offset. The HTML container defaults to the upper left corner as the origin, and the horizontal offset is performed through dx * @defaultValue 0 */ dx?: number; /** * 纵向偏移量。HTML 容器默认以左上角为原点,通过 dy 来进行纵向偏移 * * Vertical offset. The HTML container defaults to the upper left corner as the origin, and the vertical offset is performed through dy * @defaultValue 0 */ dy?: number; } /** * HTML 节点 * * HTML node * @see https://github.com/antvis/G/blob/next/packages/g/src/plugins/EventPlugin.ts */ export class HTML extends BaseNode { static defaultStyleProps: Partial = { size: [160, 80], halo: false, icon: false, label: false, pointerEvents: 'auto', }; constructor(options: DisplayObjectConfig) { super({ ...options, style: Object.assign({}, HTML.defaultStyleProps, options.style) }); } private rootPointerEvent = new FederatedPointerEvent(null); private get eventService() { return this.context.canvas.context.eventService; } private get events() { return [ CommonEvent.CLICK, CommonEvent.POINTER_DOWN, CommonEvent.POINTER_MOVE, CommonEvent.POINTER_UP, CommonEvent.POINTER_OVER, CommonEvent.POINTER_LEAVE, ]; } protected getDomElement() { return this.getShape('key').getDomElement(); } /** * @override */ public render(attributes: Required = this.parsedAttributes, container: Group = this): void { this.drawKeyShape(attributes, container); this.drawPortShapes(attributes, container); } protected getKeyStyle(attributes: Required): GHTMLStyleProps { const { dx = 0, dy = 0, ...style } = pick(attributes, ['dx', 'dy', 'innerHTML', 'pointerEvents', 'cursor']) as unknown as HTMLStyleProps; const [width, height] = this.getSize(attributes); return { x: dx, y: dy, ...style, width, height }; } protected drawKeyShape(attributes: Required, container: Group) { const style = this.getKeyStyle(attributes); const { x, y, width = 0, height = 0 } = style; const bounds = this.upsert('key-container', Rect, { x, y, width, height, opacity: 0 }, container)!; return this.upsert('key', GHTML, style, bounds); } public connectedCallback() { // only enable in canvas renderer const renderer = this.context.canvas.getRenderer('main'); const isCanvasRenderer = renderer instanceof Renderer; if (!isCanvasRenderer) return; const element = this.getDomElement(); this.events.forEach((eventName) => { // @ts-expect-error assert event is PointerEvent element.addEventListener(eventName, this.forwardEvents); }); } public attributeChangedCallback(name: any, oldValue: any, newValue: any) { if (name === 'zIndex' && oldValue !== newValue) { this.getDomElement().style.zIndex = newValue; } } public destroy() { const element = this.getDomElement(); this.events.forEach((eventName) => { // @ts-expect-error assert event is PointerEvent element.removeEventListener(eventName, this.forwardEvents); }); super.destroy(); } private forwardEvents = (nativeEvent: PointerEvent) => { const canvas = this.context.canvas; const iCanvas = canvas.context.renderingContext.root!.ownerDocument!.defaultView!; const normalizedEvents = this.normalizeToPointerEvent(nativeEvent, iCanvas); normalizedEvents.forEach((normalizedEvent) => { const event = this.bootstrapEvent(this.rootPointerEvent, normalizedEvent, iCanvas, nativeEvent); // 当点击到 html 元素时,避免触发 pointerupoutside 事件 // When clicking on the html element, avoid triggering the pointerupoutside event set(canvas.context.eventService, 'mappingTable.pointerupoutside', []); canvas.context.eventService.mapEvent(event); }); }; private normalizeToPointerEvent(event: PointerEvent, canvas: ICanvas): PointerEvent[] { const normalizedEvents = []; if (canvas.isTouchEvent(event)) { for (let i = 0; i < event.changedTouches.length; i++) { const touch = event.changedTouches[i] as any; // use changedTouches instead of touches since touchend has no touches // @see https://stackoverflow.com/a/10079076 if (isUndefined(touch.button)) touch.button = 0; if (isUndefined(touch.buttons)) touch.buttons = 1; if (isUndefined(touch.isPrimary)) { touch.isPrimary = event.touches.length === 1 && event.type === 'touchstart'; } if (isUndefined(touch.width)) touch.width = touch.radiusX || 1; if (isUndefined(touch.height)) touch.height = touch.radiusY || 1; if (isUndefined(touch.tiltX)) touch.tiltX = 0; if (isUndefined(touch.tiltY)) touch.tiltY = 0; if (isUndefined(touch.pointerType)) touch.pointerType = 'touch'; // @see https://developer.mozilla.org/zh-CN/docs/Web/API/Touch/identifier if (isUndefined(touch.pointerId)) touch.pointerId = touch.identifier || 0; if (isUndefined(touch.pressure)) touch.pressure = touch.force || 0.5; if (isUndefined(touch.twist)) touch.twist = 0; if (isUndefined(touch.tangentialPressure)) touch.tangentialPressure = 0; touch.isNormalized = true; touch.type = event.type; normalizedEvents.push(touch); } } else if (canvas.isMouseEvent(event)) { const tempEvent = event as any; if (isUndefined(tempEvent.isPrimary)) tempEvent.isPrimary = true; if (isUndefined(tempEvent.width)) tempEvent.width = 1; if (isUndefined(tempEvent.height)) tempEvent.height = 1; if (isUndefined(tempEvent.tiltX)) tempEvent.tiltX = 0; if (isUndefined(tempEvent.tiltY)) tempEvent.tiltY = 0; if (isUndefined(tempEvent.pointerType)) tempEvent.pointerType = 'mouse'; if (isUndefined(tempEvent.pointerId)) tempEvent.pointerId = 1; if (isUndefined(tempEvent.pressure)) tempEvent.pressure = 0.5; if (isUndefined(tempEvent.twist)) tempEvent.twist = 0; if (isUndefined(tempEvent.tangentialPressure)) tempEvent.tangentialPressure = 0; tempEvent.isNormalized = true; normalizedEvents.push(tempEvent); } else { normalizedEvents.push(event); } return normalizedEvents as PointerEvent[]; } private transferMouseData(event: FederatedMouseEvent, nativeEvent: MouseEvent): void { event.isTrusted = nativeEvent.isTrusted; event.srcElement = nativeEvent.srcElement as IEventTarget; event.timeStamp = performance.now(); event.type = nativeEvent.type; event.altKey = nativeEvent.altKey; event.metaKey = nativeEvent.metaKey; event.shiftKey = nativeEvent.shiftKey; event.ctrlKey = nativeEvent.ctrlKey; event.button = nativeEvent.button; event.buttons = nativeEvent.buttons; event.client.x = nativeEvent.clientX; event.client.y = nativeEvent.clientY; event.movement.x = nativeEvent.movementX; event.movement.y = nativeEvent.movementY; event.page.x = nativeEvent.pageX; event.page.y = nativeEvent.pageY; event.screen.x = nativeEvent.screenX; event.screen.y = nativeEvent.screenY; event.relatedTarget = null; } private bootstrapEvent( event: FederatedPointerEvent, normalizedEvent: PointerEvent, view: ICanvas, nativeEvent: PointerEvent | MouseEvent | TouchEvent, ): FederatedPointerEvent { event.view = view; // @ts-ignore event.originalEvent = null; event.nativeEvent = nativeEvent; event.pointerId = normalizedEvent.pointerId; event.width = normalizedEvent.width; event.height = normalizedEvent.height; event.isPrimary = normalizedEvent.isPrimary; event.pointerType = normalizedEvent.pointerType; event.pressure = normalizedEvent.pressure; event.tangentialPressure = normalizedEvent.tangentialPressure; event.tiltX = normalizedEvent.tiltX; event.tiltY = normalizedEvent.tiltY; event.twist = normalizedEvent.twist; this.transferMouseData(event, normalizedEvent); const { x, y } = this.getViewportXY(normalizedEvent); event.viewport.x = x; event.viewport.y = y; const [canvasX, canvasY] = this.context.canvas.getCanvasByViewport([x, y]); event.canvas.x = canvasX; event.canvas.y = canvasY; event.global.copyFrom(event.canvas); event.offset.copyFrom(event.canvas); event.isTrusted = nativeEvent.isTrusted; if (event.type === 'pointerleave') { event.type = 'pointerout'; } return event; } private getViewportXY(nativeEvent: PointerEvent | WheelEvent) { let x: number; let y: number; const { offsetX, offsetY, clientX, clientY } = nativeEvent; if (!isNil(offsetX) && !isNil(offsetY)) { x = offsetX; y = offsetY; } else { const point = this.eventService.client2Viewport({ x: clientX, y: clientY }); x = point.x; y = point.y; } return { x, y }; } protected onframe(): void { super.onframe(); // sync opacity const { opacity } = this.attributes; this.getDomElement().style.opacity = `${opacity}`; } } ================================================ FILE: packages/g6/src/elements/nodes/image.ts ================================================ import type { DisplayObjectConfig, RectStyleProps as GRectStyleProps, Group } from '@antv/g'; import { ImageStyleProps as GImageStyleProps, Rect as GRect } from '@antv/g'; import { ICON_SIZE_RATIO } from '../../constants/element'; import { subStyleProps } from '../../utils/prefix'; import { mergeOptions } from '../../utils/style'; import { add, toVector2 } from '../../utils/vector'; import type { IconStyleProps } from '../shapes'; import { Image as ImageShape } from '../shapes'; import { connectImage, dispatchPositionChange } from '../shapes/image'; import type { BaseNodeStyleProps } from './base-node'; import { BaseNode } from './base-node'; /** * 图片节点样式配置项 * * Image node style props */ export interface ImageStyleProps extends BaseNodeStyleProps { /** * 图片来源,即图片地址字符串 * * Image source, i.e. image address string */ img?: string | HTMLImageElement; /** * 该属性为 img 的别名 * * This property is an alias for img */ src?: string | HTMLImageElement; } /** * 图片节点 * * Image node */ export class Image extends BaseNode { static defaultStyleProps: Partial = { size: 32, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Image.defaultStyleProps }, options)); } protected getKeyStyle(attributes: Required): GImageStyleProps { const [width, height] = this.getSize(attributes); const { fillOpacity, opacity = fillOpacity, ...keyStyle } = super.getKeyStyle(attributes); return { opacity, ...keyStyle, width, height, x: -width / 2, y: -height / 2, }; } public getBounds() { return this.getShape('key').getBounds(); } protected getHaloStyle(attributes: Required): false | GRectStyleProps { if (attributes.halo === false) return false; const { fill: keyStyleFill, stroke: keyStyleStroke, ...keyStyle } = this.getShape('key').attributes; const haloStyle = subStyleProps(this.getGraphicStyle(attributes), 'halo'); const haloLineWidth = Number(haloStyle.lineWidth); const [width, height] = add(toVector2(this.getSize(attributes)), [haloLineWidth, haloLineWidth]); const { lineWidth } = haloStyle; const recalculate = { fill: 'transparent', lineWidth: lineWidth / 2, width: width - lineWidth / 2, height: height - lineWidth / 2, x: -(width - lineWidth / 2) / 2, y: -(height - lineWidth / 2) / 2, }; return { ...haloStyle, ...recalculate }; } protected getIconStyle(attributes: Required): false | IconStyleProps { const style = super.getIconStyle(attributes); const [width, height] = this.getSize(attributes); return style ? ({ width: width * ICON_SIZE_RATIO, height: height * ICON_SIZE_RATIO, ...style, } as IconStyleProps) : false; } protected drawKeyShape(attributes: Required, container: Group): ImageShape | undefined { const image = this.upsert('key', ImageShape, this.getKeyStyle(attributes), container); connectImage(this); return image; } protected drawHaloShape(attributes: Required, container: Group): void { this.upsert('halo', GRect, this.getHaloStyle(attributes), container); } public update(attr?: Partial): void { super.update(attr); if (attr && ('x' in attr || 'y' in attr || 'z' in attr)) { dispatchPositionChange(this); } } } ================================================ FILE: packages/g6/src/elements/nodes/index.ts ================================================ export { BaseNode } from './base-node'; export { Circle } from './circle'; export { Diamond } from './diamond'; export { Donut } from './donut'; export { Ellipse } from './ellipse'; export { Hexagon } from './hexagon'; export { HTML } from './html'; export { Image } from './image'; export { Rect } from './rect'; export { Star } from './star'; export { Triangle } from './triangle'; export type { BaseNodeStyleProps } from './base-node'; export type { CircleStyleProps } from './circle'; export type { DiamondStyleProps } from './diamond'; export type { DonutStyleProps } from './donut'; export type { EllipseStyleProps } from './ellipse'; export type { HexagonStyleProps } from './hexagon'; export type { HTMLStyleProps } from './html'; export type { ImageStyleProps } from './image'; export type { RectStyleProps } from './rect'; export type { StarStyleProps } from './star'; export type { TriangleStyleProps } from './triangle'; ================================================ FILE: packages/g6/src/elements/nodes/rect.ts ================================================ import type { DisplayObjectConfig, RectStyleProps as GRectStyleProps, Group } from '@antv/g'; import { Rect as GRect } from '@antv/g'; import { ICON_SIZE_RATIO } from '../../constants/element'; import type { IconStyleProps } from '../shapes'; import type { BaseNodeStyleProps } from './base-node'; import { BaseNode } from './base-node'; /** * 矩形节点样式配置项 * * Rect node style props */ export interface RectStyleProps extends BaseNodeStyleProps {} type ParsedRectStyleProps = Required; /** * 矩形节点 * * Rect node */ export class Rect extends BaseNode { constructor(options: DisplayObjectConfig) { super(options); } protected getKeyStyle(attributes: ParsedRectStyleProps): GRectStyleProps { const [width, height] = this.getSize(attributes); return { ...super.getKeyStyle(attributes), width, height, x: -width / 2, y: -height / 2, }; } protected getIconStyle(attributes: ParsedRectStyleProps): false | IconStyleProps { const style = super.getIconStyle(attributes); const { width, height } = this.getShape('key').attributes; return style ? ({ width: (width as number) * ICON_SIZE_RATIO, height: (height as number) * ICON_SIZE_RATIO, ...style, } as IconStyleProps) : false; } protected drawKeyShape(attributes: ParsedRectStyleProps, container: Group): GRect | undefined { return this.upsert('key', GRect, this.getKeyStyle(attributes), container); } } ================================================ FILE: packages/g6/src/elements/nodes/star.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import { ICON_SIZE_RATIO } from '../../constants/element'; import type { NodePortStyleProps, Point, StarPortPlacement } from '../../types'; import { getPortXYByPlacement, getStarPoints, getStarPorts } from '../../utils/element'; import type { IconStyleProps, PolygonStyleProps } from '../shapes'; import { Polygon } from '../shapes/polygon'; /** * 五角星节点样式配置项 * * Star node style props */ export interface StarStyleProps extends PolygonStyleProps { /** * 内半径,默认为外半径的 3/8 * * Inner radius, default is 3/8 of the outer radius */ innerR?: number; } /** * 五角星节点 * * Star node */ export class Star extends Polygon { constructor(options: DisplayObjectConfig) { super(options); } private getInnerR(attributes: Required): number { return attributes.innerR || (this.getOuterR(attributes) * 3) / 8; } private getOuterR(attributes: Required): number { return Math.min(...this.getSize(attributes)) / 2; } protected getPoints(attributes: Required): Point[] { return getStarPoints(this.getOuterR(attributes), this.getInnerR(attributes)); } protected getIconStyle(attributes: Required): false | IconStyleProps { const style = super.getIconStyle(attributes); const size = this.getInnerR(attributes) * 2 * ICON_SIZE_RATIO; return style ? ({ width: size, height: size, ...style } as IconStyleProps) : false; } protected getPortXY(attributes: Required, style: NodePortStyleProps): Point { const { placement = 'top' } = style; const bbox = this.getShape('key').getLocalBounds(); const ports = getStarPorts(this.getOuterR(attributes), this.getInnerR(attributes)); return getPortXYByPlacement(bbox, placement as StarPortPlacement, ports, false); } } ================================================ FILE: packages/g6/src/elements/nodes/triangle.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import { isEmpty } from '@antv/util'; import { ICON_SIZE_RATIO } from '../../constants/element'; import type { NodePortStyleProps, Point, TriangleDirection, TrianglePortPlacement } from '../../types'; import { getIncircleRadius, getTriangleCenter } from '../../utils/bbox'; import { getPortXYByPlacement, getTrianglePoints, getTrianglePorts } from '../../utils/element'; import { subStyleProps } from '../../utils/prefix'; import { mergeOptions } from '../../utils/style'; import type { PolygonStyleProps } from '../shapes'; import { IconStyleProps } from '../shapes'; import { Polygon } from '../shapes/polygon'; /** * 三角形节点样式配置项 * * Triangle node style props */ export interface TriangleStyleProps extends PolygonStyleProps { /** * 三角形的方向 * * The direction of the triangle * @defaultValue 'up' */ direction?: TriangleDirection; } /** * 三角形节点 * * Triangle node */ export class Triangle extends Polygon { static defaultStyleProps: Partial = { size: 40, direction: 'up', }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Triangle.defaultStyleProps }, options)); } protected getPoints(attributes: Required): Point[] { const { direction } = attributes; const [width, height] = this.getSize(attributes); return getTrianglePoints(width, height, direction); } protected getPortXY(attributes: Required, style: NodePortStyleProps): Point { const { direction } = attributes; const { placement = 'top' } = style; const bbox = this.getShape('key').getLocalBounds(); const [width, height] = this.getSize(attributes); const ports = getTrianglePorts(width, height, direction); return getPortXYByPlacement(bbox, placement as TrianglePortPlacement, ports, false); } // icon 处于内切三角形的重心 // icon is at the centroid of the triangle protected getIconStyle(attributes: Required): false | IconStyleProps { const { icon, iconText, iconSrc, direction } = attributes; if (icon === false || isEmpty(iconText || iconSrc)) return false; const iconStyle = subStyleProps(this.getGraphicStyle(attributes), 'icon'); const bbox = this.getShape('key').getLocalBounds(); const [x, y] = getTriangleCenter(bbox, direction); const size = getIncircleRadius(bbox, direction) * 2 * ICON_SIZE_RATIO; return { x, y, width: size, height: size, ...iconStyle, }; } } ================================================ FILE: packages/g6/src/elements/shapes/badge.ts ================================================ import type { DisplayObjectConfig, Group } from '@antv/g'; import { mergeOptions } from '../../utils/style'; import { BaseShape } from './base-shape'; import type { LabelStyleProps } from './label'; import { Label } from './label'; /** * 徽标样式 * * Badge style */ export interface BadgeStyleProps extends LabelStyleProps {} /** * 徽标 * * Badge * @remarks * 徽标是一种特殊的标签,通常用于展示数量或状态信息。 * * Badge is a special label, usually used to display quantity or status information. */ export class Badge extends BaseShape { static defaultStyleProps: Partial = { padding: [2, 4, 2, 4], fontSize: 10, wordWrap: false, backgroundRadius: '50%', backgroundOpacity: 1, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Badge.defaultStyleProps }, options)); } protected getBadgeStyle(attributes: Required) { return this.getGraphicStyle(attributes); } public render(attributes: Required = this.parsedAttributes, container: Group = this) { this.upsert('label', Label, this.getBadgeStyle(attributes), container); } public getGeometryBounds() { const labelShape = this.getShape>('label'); const shape = labelShape.getShape('background') || labelShape.getShape('text'); return shape.getGeometryBounds(); } } ================================================ FILE: packages/g6/src/elements/shapes/base-shape.ts ================================================ import type { BaseStyleProps, DisplayObject, DisplayObjectConfig, Group, IAnimation } from '@antv/g'; import { CustomElement } from '@antv/g'; import { isEmpty, isFunction, upperFirst } from '@antv/util'; import { ExtensionCategory } from '../../constants'; import type { Keyframe } from '../../types'; import { createAnimationsProxy, preprocessKeyframes } from '../../utils/animation'; import { setAttributes, updateStyle } from '../../utils/element'; import { subObject } from '../../utils/prefix'; import { format } from '../../utils/print'; import { getSubShapeStyle } from '../../utils/style'; import { replaceTranslateInTransform } from '../../utils/transform'; import { setVisibility } from '../../utils/visibility'; import { getExtension } from './../../registry/get'; export interface BaseShapeStyleProps extends BaseStyleProps {} /** * 图形基类 * * Base class for shapes */ export abstract class BaseShape extends CustomElement { constructor(options: DisplayObjectConfig) { applyTransform(options.style); super(options); this.render(this.attributes as Required, this); this.setVisibility(); this.bindEvents(); } /** * 解析后的属性 * * parsed attributes * @returns 解析后的属性 | parsed attributes * @internal */ protected get parsedAttributes() { return this.attributes as Required; } /** * 图形实例映射表 * * shape instance map * @internal */ protected shapeMap: Record = {}; /** * 动画实例映射表 * * animation instance map * @internal */ protected animateMap: Record = {}; /** * 创建、更新或删除图形 * * create, update or remove shape * @param className - 图形名称 | shape name * @param Ctor - 图形类型 | shape type * @param style - 图形样式。若要删除图形,传入 false | shape style. Pass false to remove the shape * @param container - 容器 | container * @param hooks - 钩子函数 | hooks * @returns 图形实例 | shape instance */ protected upsert( className: string, Ctor: string | { new (...args: any[]): T }, style: T['attributes'] | false, container: DisplayObject, hooks?: UpsertHooks, ): T | undefined { const target = this.shapeMap[className] as T | undefined; // remove // 如果 style 为 false,则删除图形 / remove shape if style is false if (style === false) { if (target) { hooks?.beforeDestroy?.(target); container.removeChild(target); delete this.shapeMap[className]; hooks?.afterDestroy?.(target); } return; } const _Ctor = typeof Ctor === 'string' ? getExtension(ExtensionCategory.SHAPE, Ctor) : Ctor; if (!_Ctor) { throw new Error(format(`Shape ${Ctor} not found`)); } // create if (!target || target.destroyed || !(target instanceof _Ctor)) { if (target) { hooks?.beforeDestroy?.(target); target?.destroy(); hooks?.afterDestroy?.(target); } hooks?.beforeCreate?.(); const instance = new _Ctor({ className, style }); container.appendChild(instance); this.shapeMap[className] = instance; hooks?.afterCreate?.(instance); return instance as T; } // update hooks?.beforeUpdate?.(target); updateStyle(target, style); hooks?.afterUpdate?.(target); return target; } public update(attr: Partial = {}): void { const attributes = Object.assign({}, this.attributes, attr) as Required; applyTransform(attributes); setAttributes(this, attributes); this.render(attributes, this); this.setVisibility(); } /** * 在初始化时会被自动调用 * * will be called automatically when initializing * @param attributes * @param container */ public abstract render(attributes: Required, container: Group): void; public bindEvents() {} /** * 从给定的属性对象中提取图形样式属性。删除特定的属性,如位置、变换和类名 * * Extracts the shape styles from a given attribute object. * Removes specific styles like position, transformation, and class name. * @param style - 属性对象 | attribute object * @returns 仅包含样式属性的对象 | An object containing only the style properties. */ public getGraphicStyle>( style: T, ): Omit { return getSubShapeStyle(style); } /** * Get the prefix pairs for composite shapes used to handle animation * @returns tuples array where each tuple contains a key corresponding to a method `get${key}Style` and its shape prefix * @internal */ protected get compositeShapes(): [string, string][] { return [ ['badges', 'badge-'], ['ports', 'port-'], ]; } public animate(keyframes: Keyframe[], options?: number | KeyframeAnimationOptions): IAnimation | null { if (keyframes.length === 0) return null; const animationMap: IAnimation[] = []; // 如果 keyframes 中存在 x/y/z ,替换为 transform // if x/y/z exists in keyframes, replace them with transform if (keyframes[0].x !== undefined || keyframes[0].y !== undefined || keyframes[0].z !== undefined) { const { x: _x = 0, y: _y = 0, z: _z = 0 } = this.attributes as Record; keyframes.forEach((keyframe) => { const { x = _x, y = _y, z = _z } = keyframe; Object.assign(keyframe, { transform: z ? [['translate3d', x, y, z]] : [['translate', x, y]] }); }); } const result = super.animate(keyframes, options); if (result) { releaseAnimation(this, result); animationMap.push(result); } if (Array.isArray(keyframes) && keyframes.length > 0) { // 如果 keyframes 中仅存在 skippedAttrs 中的属性,则仅更新父元素属性(跳过子图形) // if only skippedAttrs exist in keyframes, only update parent element attributes (skip child shapes) const skippedAttrs = ['transform', 'transformOrigin', 'x', 'y', 'z', 'zIndex']; if (Object.keys(keyframes[0]).some((attr) => !skippedAttrs.includes(attr))) { Object.entries(this.shapeMap).forEach(([key, shape]) => { // 如果存在方法名为 `get${key}Style` 的方法,则使用该方法获取样式,并自动为该图形实例创建动画 // if there is a method named `get${key}Style`, use this method to get style and automatically create animation for the shape instance const methodName = `get${upperFirst(key)}Style` as keyof this; const method = this[methodName]; if (isFunction(method)) { const subKeyframes: Keyframe[] = keyframes.map((style) => method.call(this, { ...this.attributes, ...style }), ); const result = shape.animate(preprocessKeyframes(subKeyframes), options); if (result) { releaseAnimation(shape, result); animationMap.push(result); } } }); const handleCompositeShapeAnimation = (shapeSet: Record, name: string) => { if (!isEmpty(shapeSet)) { const methodName = `get${upperFirst(name)}Style` as keyof this; const method = this[methodName]; if (isFunction(method)) { const itemsKeyframes = keyframes.map((style) => method.call(this, { ...this.attributes, ...style })); Object.entries(itemsKeyframes[0]).map(([key]) => { const subKeyframes = itemsKeyframes.map((styles) => styles[key]); const shape = shapeSet[key]; if (shape) { const result = shape.animate(preprocessKeyframes(subKeyframes), options); if (result) { releaseAnimation(shape, result); animationMap.push(result); } } }); } } }; this.compositeShapes.forEach(([key, prefix]) => { const shapeSet = subObject(this.shapeMap, prefix); handleCompositeShapeAnimation(shapeSet, key); }); } } return createAnimationsProxy(animationMap); } public getShape(name: string): T { return this.shapeMap[name] as T; } private setVisibility() { const { visibility } = this.attributes; setVisibility(this, visibility); } public destroy(): void { this.shapeMap = {}; this.animateMap = {}; super.destroy(); } } /** * 释放动画 * * Release animation * @param target - 目标对象 | target object * @param animation - 动画实例 | animation instance * @description see: https://github.com/antvis/G/issues/1731 */ function releaseAnimation(target: DisplayObject, animation: IAnimation) { animation?.finished.then(() => { // @ts-expect-error private property const index = target.activeAnimations.findIndex((_) => _ === animation); // @ts-expect-error private property if (index > -1) target.activeAnimations.splice(index, 1); }); } /** * 图形 upsert 方法生命周期钩子 * * Shape upsert method lifecycle hooks */ export interface UpsertHooks { /** * 图形创建前 * * Before creating the shape */ beforeCreate?: () => void; /** * 图形创建后 * * After creating the shape * @param instance - 图形实例 | shape instance */ afterCreate?: (instance: DisplayObject) => void; /** * 图形更新前 * * Before updating the shape * @param instance - 图形实例 | shape instance */ beforeUpdate?: (instance: DisplayObject) => void; /** * 图形更新后 * * After updating the shape * @param instance - 图形实例 | shape instance */ afterUpdate?: (instance: DisplayObject) => void; /** * 图形销毁前 * * Before destroying the shape * @param instance - 图形实例 | shape instance */ beforeDestroy?: (instance: DisplayObject) => void; /** * 图形销毁后 * * After destroying the shape * @param instance - 图形实例 | shape instance */ afterDestroy?: (instance: DisplayObject) => void; } /** * 应用 transform * * Apply transform * @param style - 样式 | style * @returns 样式 | style */ function applyTransform(style?: BaseShapeStyleProps) { if (!style) return {}; if ('x' in style || 'y' in style || 'z' in style) { const { x = 0, y = 0, z, transform } = style as any; const newTransform = replaceTranslateInTransform(x, y, z, transform); if (newTransform) style.transform = newTransform; } return style; } ================================================ FILE: packages/g6/src/elements/shapes/contour.ts ================================================ import type { DisplayObjectConfig, Group, PathStyleProps } from '@antv/g'; import { Path } from '@antv/g'; import type { CardinalPlacement, Prefix } from '../../types'; import { getPolygonTextStyleByPlacement } from '../../utils/polygon'; import { subStyleProps } from '../../utils/prefix'; import { mergeOptions } from '../../utils/style'; import { getWordWrapWidthByBox } from '../../utils/text'; import type { LabelStyleProps } from '../shapes'; import { BaseShape } from './base-shape'; import { Label } from './label'; export interface ContourLabelStyleProps extends LabelStyleProps { /** * 标签位置 * * Label position * @defaultValue 'bottom' */ placement?: CardinalPlacement | 'center'; /** * 标签是否贴合轮廓 * * Whether the label is close to the contour * @defaultValue true */ closeToPath?: boolean; /** * 标签是否跟随轮廓旋转,仅在 closeToPath 为 true 时生效 * * Whether the label rotates with the contour. Only effective when closeToPath is true * @defaultValue true */ autoRotate?: boolean; /** * x 轴偏移量 * * Label x-axis offset * @defaultValue 0 */ offsetX?: number; /** * y 轴偏移量 * * Label y-axis offset * @defaultValue 0 */ offsetY?: number; /** * 文本的最大宽度,超出会自动省略 * * The maximum width of the text, which will be automatically ellipsis if exceeded */ maxWidth?: number; } export interface ContourStyleProps extends PathStyleProps, Prefix<'label', ContourLabelStyleProps> { /** * 是否显示标签 * * Whether to display the label * @defaultValue true */ label?: boolean; } type ParsedContourStyleProps = Required; type ContourOptions = DisplayObjectConfig; export class Contour extends BaseShape { static defaultStyleProps: Partial = { label: true, labelPlacement: 'bottom', labelCloseToPath: true, labelAutoRotate: true, labelOffsetX: 0, labelOffsetY: 0, }; constructor(options: ContourOptions) { super(mergeOptions({ style: Contour.defaultStyleProps }, options)); } protected getLabelStyle(attributes: ParsedContourStyleProps): LabelStyleProps | false { if (!attributes.label || !attributes.d || attributes.d.length === 0) return false; const { maxWidth, offsetX, offsetY, autoRotate, placement, closeToPath, ...labelStyle } = subStyleProps< Required >(this.getGraphicStyle(attributes), 'label'); const key = this.shapeMap.key; const keyBounds = key?.getRenderBounds(); return Object.assign( getPolygonTextStyleByPlacement(keyBounds, placement, offsetX, offsetY, closeToPath, attributes.d, autoRotate), { wordWrapWidth: getWordWrapWidthByBox(keyBounds, maxWidth) }, labelStyle, ); } protected getKeyStyle(attributes: ParsedContourStyleProps): PathStyleProps { return this.getGraphicStyle(attributes); } public render(attributes: ParsedContourStyleProps, container: Group): void { this.upsert('key', Path, this.getKeyStyle(attributes), container); this.upsert('label', Label, this.getLabelStyle(attributes), container); } } ================================================ FILE: packages/g6/src/elements/shapes/icon.ts ================================================ import { DisplayObjectConfig, Text as GText, Group, TextStyleProps } from '@antv/g'; import type { BaseShapeStyleProps } from './base-shape'; import { BaseShape } from './base-shape'; import type { ImageStyleProps } from './image'; import { Image as GImage } from './image'; /** * 图标样式 * * Icon style */ export interface IconStyleProps extends BaseShapeStyleProps, Partial, Omit {} /** * 图标 * * Icon * @remarks * 图标是一种特殊的图形,可以是图片或者文字。传入 src 属性时,会渲染图片;传入 text 属性时,会渲染文字。 * * Icon is a special shape, which can be an image or text. When the src attribute is passed in, an image will be rendered; when the text attribute is passed in, text will be rendered. */ export class Icon extends BaseShape { constructor(options: DisplayObjectConfig) { super(options); } private isImage() { const { src } = this.attributes; return !!src; } protected getIconStyle(attributes: IconStyleProps = this.attributes): IconStyleProps { const { width = 0, height = 0 } = attributes; const style = this.getGraphicStyle(attributes); if (this.isImage()) { return { x: -width / 2, y: -height / 2, ...style, }; } return { textBaseline: 'middle', textAlign: 'center', ...style, }; } public render(attributes = this.attributes, container: Group = this): void { this.upsert('icon', (this.isImage() ? GImage : GText) as any, this.getIconStyle(attributes), container); } } ================================================ FILE: packages/g6/src/elements/shapes/image.ts ================================================ import type { DisplayObject, DisplayObjectConfig, ImageStyleProps as GImageStyleProps } from '@antv/g'; import { ElementEvent, Image as GImage, Rect as GRect } from '@antv/g'; import { getAncestorShapes } from '../../utils/shape'; export interface ImageStyleProps extends GImageStyleProps { /** * 圆角半径 * * Radius of the rounded corner */ radius?: number | number[]; } export class Image extends GImage { constructor(options: DisplayObjectConfig) { super(options); current = this; this.isMutationObserved = true; this.addEventListener(ElementEvent.MOUNTED, this.onMounted); this.addEventListener(ElementEvent.ATTR_MODIFIED, this.onAttrModified); } private onMounted = () => { this.handleRadius(); }; private onAttrModified = () => { this.handleRadius(); }; public handleRadius() { const { radius, clipPath, width = 0, height = 0 } = this.attributes as ImageStyleProps; if (radius && width && height) { const [x, y] = this.getBounds().min; const clipPathStyle = { x, y, radius, width, height }; if (clipPath) { Object.assign(this.parsedStyle.clipPath!.style, clipPathStyle); } else { const rect = new GRect({ style: clipPathStyle }); this.style.clipPath = rect; } } else { if (clipPath) this.style.clipPath = null; } } } const ImagesWeakMap = new WeakMap(); let current: Image | null = null; /** * 由于 g clipPath 不支持相对位置,因此当作用的元素发生位置变化时,需要通知 Image 更新 clipPath。 * * 通过 connectImage 创建图形与图片的关联,并结合 dispatchPositionChange 方法触发更新 * * ⚠️ 这是一种临时的、黑盒的解决方案,如果后续 g 支持相对位置,会移除该方法。 * * Since g clipPath does not support relative positions, when the position of the affected element changes, the Image needs to be notified to update the clipPath. * * Use connectImage to create an association between the shape and the image, and combine it with the dispatchPositionChange method to trigger the update. * * ⚠️ This is a temporary, black-box solution, and if g supports relative positions in the future, this method will be removed. * @param target - 目标元素 Target element */ export const connectImage = (target: DisplayObject) => { if (current && getAncestorShapes(current).includes(target)) { const images = ImagesWeakMap.get(target); if (images) { if (!images.includes(current)) images.push(current); } else ImagesWeakMap.set(target, [current]); } }; /** * 触发关联的图片更新位置 * * Trigger the associated image to update its position * @param target - 目标元素 Target element */ export const dispatchPositionChange = (target: DisplayObject) => { const image = ImagesWeakMap.get(target); if (image) { image.forEach((i) => i.handleRadius()); } }; ================================================ FILE: packages/g6/src/elements/shapes/index.ts ================================================ /** * 图形组件,用于构建复合元素 * * Shape components, used to build composite elements */ export { Badge } from './badge'; export { BaseShape } from './base-shape'; export { Contour } from './contour'; export { Icon } from './icon'; export { Image } from './image'; export { Label } from './label'; export type { BadgeStyleProps } from './badge'; export type { BaseShapeStyleProps } from './base-shape'; export type { ContourStyleProps } from './contour'; export type { IconStyleProps } from './icon'; export type { ImageStyleProps } from './image'; export type { LabelStyleProps } from './label'; export type { PolygonStyleProps } from './polygon'; ================================================ FILE: packages/g6/src/elements/shapes/label.ts ================================================ import { DisplayObjectConfig, Group, Rect, RectStyleProps, Text, TextStyleProps } from '@antv/g'; import type { Padding } from '../../types/padding'; import type { Prefix } from '../../types/prefix'; import { parsePadding } from '../../utils/padding'; import { omitStyleProps, startsWith, subStyleProps } from '../../utils/prefix'; import { mergeOptions } from '../../utils/style'; import { BaseShape } from './base-shape'; /** * 标签样式 * * Label style */ export interface LabelStyleProps extends TextStyleProps, Prefix<'background', RectStyleProps> { /** * 是否显示背景 * * Whether to show background */ background?: boolean; /** * 标签内边距 * * Label padding * @defaultValue 0 */ padding?: Padding; } /** * 标签 * * Label * @remarks * 标签是一种具有背景的文本图形。 * * Label is a text shape with background. */ export class Label extends BaseShape { static defaultStyleProps: Partial = { padding: 0, fontSize: 12, fontFamily: 'system-ui, sans-serif', wordWrap: true, maxLines: 1, wordWrapWidth: 128, textOverflow: '...', textBaseline: 'middle', backgroundOpacity: 0.75, backgroundZIndex: -1, backgroundLineWidth: 0, }; constructor(options: DisplayObjectConfig) { super(mergeOptions({ style: Label.defaultStyleProps }, options)); } protected isTextStyle(key: string) { return startsWith(key, 'label'); } protected isBackgroundStyle(key: string) { return startsWith(key, 'background'); } protected getTextStyle(attributes: Required) { const { padding, ...style } = this.getGraphicStyle(attributes); return omitStyleProps(style, 'background'); } protected getBackgroundStyle(attributes: Required) { if (attributes.background === false) return false; const style = this.getGraphicStyle(attributes); const { wordWrap, wordWrapWidth, padding } = style; const backgroundStyle = subStyleProps(style, 'background'); const { min: [minX, minY], center: [centerX, centerY], halfExtents: [halfWidth, halfHeight], } = this.shapeMap.text.getGeometryBounds(); const [top, right, bottom, left] = parsePadding(padding); const totalWidth = halfWidth * 2 + left + right; const { width, height } = backgroundStyle; if (width && height) { Object.assign(backgroundStyle, { x: centerX - Number(width) / 2, y: centerY - Number(height) / 2 }); } else { Object.assign(backgroundStyle, { x: minX - left, y: minY - top, width: wordWrap ? Math.min(totalWidth, wordWrapWidth + left + right) : totalWidth, height: halfHeight * 2 + top + bottom, }); } // parse percentage radius const { radius } = backgroundStyle; // if radius look like '10%', convert it to number if (typeof radius === 'string' && radius.endsWith('%')) { const percentage = Number(radius.replace('%', '')) / 100; backgroundStyle.radius = Math.min(+backgroundStyle.width, +backgroundStyle.height) * percentage; } return backgroundStyle; } public render(attributes: Required = this.parsedAttributes, container: Group = this): void { this.upsert('text', Text, this.getTextStyle(attributes), container); this.upsert('background', Rect, this.getBackgroundStyle(attributes), container); } public getGeometryBounds() { const shape = this.getShape('background') || this.getShape('text'); return shape.getGeometryBounds(); } } ================================================ FILE: packages/g6/src/elements/shapes/polygon.ts ================================================ import type { DisplayObjectConfig, PolygonStyleProps as GPolygonStyleProps, Group } from '@antv/g'; import { Polygon as GPolygon } from '@antv/g'; import type { Point } from '../../types'; import { getPolygonIntersectPoint } from '../../utils/point'; import type { BaseNodeStyleProps } from '../nodes/base-node'; import { BaseNode } from '../nodes/base-node'; /** * 多边形节点样式配置项 * * Polygon node style props */ export interface PolygonStyleProps extends BaseNodeStyleProps { /** * 多边形的顶点坐标 * * The vertex coordinates of the polygon * @internal */ points?: ([number, number] | [number, number, number])[]; } /** * Abstract class for polygon nodes,i.e triangle, diamond, hexagon, etc. */ export abstract class Polygon extends BaseNode { constructor(options: DisplayObjectConfig) { super(options); } public get parsedAttributes() { return this.attributes as unknown as Required; } protected drawKeyShape(attributes: Required, container: Group) { return this.upsert('key', GPolygon, this.getKeyStyle(attributes), container); } protected getKeyStyle(attributes: Required): GPolygonStyleProps { const keyStyle = super.getKeyStyle(attributes); return { ...keyStyle, points: this.getPoints(attributes) }; } protected abstract getPoints(attributes: Required): Point[]; public getIntersectPoint(point: Point, useExtendedLine = false): Point { const { points } = this.getShape('key').attributes; const center: Point = [+(this.attributes?.x || 0), +(this.attributes?.y || 0)]; return getPolygonIntersectPoint(point, center, points!, true, useExtendedLine).point; } } ================================================ FILE: packages/g6/src/exports.ts ================================================ export { AutoAdaptLabel, BaseBehavior, BrushSelect, ClickSelect, CollapseExpand, CreateEdge, DragCanvas, DragElement, DragElementForce, FixElementSize, FocusElement, HoverActivate, LassoSelect, OptimizeViewportTransform, ScrollCanvas, ZoomCanvas, } from './behaviors'; export { CanvasEvent, ComboEvent, CommonEvent, ContainerEvent, EdgeEvent, ExtensionCategory, GraphEvent, HistoryEvent, NodeEvent, } from './constants'; export { BaseCombo, CircleCombo, RectCombo } from './elements/combos'; export { BaseEdge, Cubic, CubicHorizontal, CubicRadial, CubicVertical, Line, Polyline, Quadratic, } from './elements/edges'; export { effect } from './elements/effect'; export { BaseNode, Circle, Diamond, Donut, Ellipse, HTML, Hexagon, Image, Rect, Star, Triangle, } from './elements/nodes'; export { Badge, BaseShape, Icon, Label } from './elements/shapes'; export { AntVDagreLayout, BaseLayout, CircularLayout, ComboCombinedLayout, compactBox as CompactBoxLayout, ConcentricLayout, D3ForceLayout, DagreLayout, dendrogram as DendrogramLayout, FishboneLayout, ForceAtlas2Layout, ForceLayout, FruchtermanLayout, GridLayout, indented as IndentedLayout, MDSLayout, mindmap as MindmapLayout, RadialLayout, RandomLayout, SnakeLayout, } from './layouts'; export { Background, BasePlugin, BubbleSets, CameraSetting, Contextmenu, EdgeBundling, EdgeFilterLens, Fisheye, Fullscreen, GridLine, History, Hull, Legend, Minimap, Snapline, Timebar, Title, Toolbar, Tooltip, Watermark, } 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'; export { idOf } from './utils/id'; export { invokeLayoutMethod } from './utils/layout'; export { positionOf } from './utils/position'; export { omitStyleProps, subStyleProps } from './utils/prefix'; export { Shortcut } from './utils/shortcut'; export { parseSize } from './utils/size'; export { treeToGraphData } from './utils/tree'; export { setVisibility } from './utils/visibility'; export type { BaseStyleProps } from '@antv/g'; export type { AntVDagreLayoutOptions, CircularLayoutOptions, ComboCombinedLayoutOptions, ConcentricLayoutOptions, D3Force3DLayoutOptions, D3ForceLayoutOptions, DagreLayoutOptions, ForceAtlas2LayoutOptions, ForceLayoutOptions, FruchtermanLayoutOptions, GridLayoutOptions, MDSLayoutOptions, RadialLayoutOptions, RandomLayoutOptions, } from '@antv/layout'; export type { PathArray } from '@antv/util'; export type { AnimationContext, AnimationEffectTiming, AnimationExecutor, AnimationOptions } from './animations/types'; export type { AutoAdaptLabelOptions, BaseBehaviorOptions, BrushSelectOptions, ClickSelectOptions, CollapseExpandOptions, CreateEdgeOptions, DragCanvasOptions, DragElementForceOptions, DragElementOptions, FixElementSizeOptions, FocusElementOptions, HoverActivateOptions, LassoSelectOptions, OptimizeViewportTransformOptions, ScrollCanvasOptions, ZoomCanvasOptions, } from './behaviors'; export type { FixShapeConfig } from './behaviors/fix-element-size'; export type { BaseComboStyleProps, CircleComboStyleProps, RectComboStyleProps } from './elements/combos'; export type { BaseEdgeStyleProps, CubicHorizontalStyleProps, CubicRadialStyleProps, CubicStyleProps, CubicVerticalStyleProps, LineStyleProps, PolylineStyleProps, QuadraticStyleProps, } from './elements/edges'; export type { BaseNodeStyleProps, CircleStyleProps, DiamondStyleProps, DonutStyleProps, EllipseStyleProps, HTMLStyleProps, HexagonStyleProps, ImageStyleProps, RectStyleProps, StarStyleProps, TriangleStyleProps, } from './elements/nodes'; export type { BadgeStyleProps, BaseShapeStyleProps, IconStyleProps, LabelStyleProps, PolygonStyleProps, } from './elements/shapes'; export type { UpsertHooks } from './elements/shapes/base-shape'; export type { ContourLabelStyleProps, ContourStyleProps } from './elements/shapes/contour'; export type { FishboneLayoutOptions, SnakeLayoutOptions } from './layouts'; export type { BaseLayoutOptions, WebWorkerLayoutOptions } from './layouts/types'; export type { CategoricalPalette } from './palettes/types'; export type { BackgroundOptions, BasePluginOptions, BubbleSetsOptions, CameraSettingOptions, ContextmenuOptions, EdgeBundlingOptions, EdgeFilterLensOptions, FisheyeOptions, FullscreenOptions, GridLineOptions, HistoryOptions, HullOptions, LegendOptions, MinimapOptions, SnaplineOptions, TimebarOptions, ToolbarOptions, TooltipOptions, WatermarkOptions, } from './plugins'; export type { CanvasConfig, DataURLOptions } from './runtime/canvas'; export type { CollapseExpandNodeOptions } from './runtime/element'; export type { RuntimeContext } from './runtime/types'; export type { BehaviorOptions, CanvasOptions, ComboData, ComboOptions, EdgeData, EdgeOptions, GraphData, GraphOptions, NodeData, NodeOptions, PluginOptions, ThemeOptions, ViewportOptions, } from './spec'; export type { CustomBehaviorOption } from './spec/behavior'; export type { AnimationStage } from './spec/element/animation'; export type { LayoutOptions, STDLayoutOptions, SingleLayoutOptions } from './spec/layout'; export type { CustomPluginOption } from './spec/plugin'; export type { BaseTransformOptions, MapNodeSizeOptions, PlaceRadialLabelsOptions, ProcessParallelEdgesOptions, } from './transforms'; export type { DrawData } from './transforms/types'; export type { CardinalPlacement, CollapsedMarkerStyleProps, Combo, CornerPlacement, DirectionalPlacement, Edge, EdgeArrowStyleProps, EdgeDirection, EdgeLabelStyleProps, Element, ElementDatum, ElementHooks, ElementMethods, ElementType, FitViewOptions, HierarchyKey, IAnimateEvent, ID, IDragEvent, IElementDragEvent, IElementEvent, IElementLifeCycleEvent, IEvent, IGraphLifeCycleEvent, IKeyboardEvent, IPointerEvent, IViewportEvent, IWheelEvent, LoopPlacement, LoopStyleProps, Node, NodeBadgeStyleProps, NodeCentralityOptions, NodeLabelStyleProps, NodeLikeData, NodePortStyleProps, Padding, Placement, Point, PortStyleProps, Prefix, PrefixKey, RelativePlacement, Size, State, TransformOptions, TreeData, TriangleDirection, Vector2, Vector3, ViewportAnimationEffectTiming, } from './types'; export type { Command, CommandData } from './types/history'; export type { ShortcutKey } from './utils/shortcut'; ================================================ FILE: packages/g6/src/global.d.ts ================================================ import '@antv/g'; import type { RuntimeContext } from './runtime/types'; declare module '@antv/g' { interface BaseStyleProps { /** * 图形所在的图层,默认为 'main'。 * * The layer where the shape is located, default is 'main'. */ $layer?: string; } interface DisplayObjectConfig { context?: RuntimeContext; } } ================================================ FILE: packages/g6/src/index.ts ================================================ import './preset'; export * from './exports'; export { version } from './version'; export const iconfont = { css: '//at.alicdn.com/t/a/font_470089_8hnbbf8n4u8.css', js: '//at.alicdn.com/t/a/font_470089_8hnbbf8n4u8.js', }; ================================================ FILE: packages/g6/src/layouts/base-layout.ts ================================================ import type { RuntimeContext } from '../runtime/types'; import type { GraphData } from '../spec'; import type { BaseLayoutOptions } from './types'; /** * 布局的基类 * * Base class for layout */ export abstract class BaseLayout { public abstract id: string; public options: O; protected context: RuntimeContext; constructor(context: RuntimeContext, options?: O) { this.context = context; this.options = options || ({} as O); } public stop?: () => void; public tick?: (iterations?: number) => GraphData; public abstract execute(model: GraphData, options?: O): Promise; } ================================================ FILE: packages/g6/src/layouts/fishbone.ts ================================================ import { isEmpty, memoize } from '@antv/util'; import type { BaseLayoutOptions } from '../layouts/types'; import type { EdgeData, GraphData, NodeData } from '../spec'; import type { ElementDatum, ID, Point, Size, STDSize } from '../types'; import { idOf } from '../utils/id'; import { parseSize } from '../utils/size'; import { BaseLayout } from './base-layout'; export interface FishboneLayoutOptions extends BaseLayoutOptions { /** * 节点大小 * * Node size */ nodeSize?: Size | ((node: NodeData) => Size); /** * 排布方向 * - `'RL'` 从右到左,鱼头在右 * - `'LR'` 从左到右,鱼头在左 * * Layout direction * - `'RL'` From right to left, the fish head is on the right * - `'LR'` From left to right, the fish head is on the left * @defaultValue `'LR'` */ direction?: 'RL' | 'LR'; /** * 获取水平间距 * * Get horizontal spacing */ hGap?: number; /** * 获取垂直间距 * * Get vertical spacing */ vGap?: number; /** * 获取鱼骨间距 * * Get rib separation * @defaultValue () => 60 */ getRibSep?: (node: NodeData) => number; /** * 布局宽度 * * Layout width */ width?: number; /** * 布局高度 * * Layout height */ height?: number; } type NodeResult = { id: ID; x: number; y: number }; type EdgeResult = { id: ID; controlPoints: Point[]; relatedNodeId: ID }; type LayoutResult = { nodes: NodeResult[]; edges: EdgeResult[] }; /** * 鱼骨图布局 * * Fishbone layout * @remarks * [鱼骨图布局](https://en.wikipedia.org/wiki/Ishikawa_diagram)是一种专门用于表示层次结构数据的图形布局方式。它通过模拟鱼骨的形状,将数据节点按照层次结构排列,使得数据的层次关系更加清晰直观。鱼骨图布局特别适用于需要展示因果关系、层次结构或分类信息的数据集。 * * [Fishbone layout](https://en.wikipedia.org/wiki/Ishikawa_diagram) is a graphical layout method specifically designed to represent hierarchical data. By simulating the shape of a fishbone, it arranges data nodes according to their hierarchical structure, making the hierarchical relationships of the data clearer and more intuitive. The fishbone diagram layout is particularly suitable for datasets that need to display causal relationships, hierarchical structures, or classification information. */ export class FishboneLayout extends BaseLayout { id = 'fishbone'; static defaultOptions: Partial = { direction: 'RL', getRibSep: () => 60, }; private getRoot() { const roots = this.context.model.getRootsData(); if (isEmpty(roots) || roots.length > 2) return; return roots[0]; } private formatSize(nodeSize: Size | ((node: NodeData) => Size)): (node: NodeData) => STDSize { const nodeSizeFunc = typeof nodeSize === 'function' ? nodeSize : () => nodeSize; return (node: NodeData) => parseSize(nodeSizeFunc(node)); } private doLayout(root: NodeData, options: Required): LayoutResult { const { hGap, getRibSep, vGap, nodeSize, height } = options; const { model } = this.context; const getSize = this.formatSize(nodeSize); let ribX = getSize(root)[0] + getRibSep(root); const getHorizontalOffset = (node: NodeData, result = 0): number => { result += hGap * ((node.children || []).length + 1); node.children?.forEach((childId) => { const child = model.getNodeLikeDatum(childId) as NodeData; child.children?.forEach((grandChildId) => { const grandChild = model.getNodeLikeDatum(grandChildId) as NodeData; result = getHorizontalOffset(grandChild, result); }); }); return result; }; const getAuxiliaryPoint = (node: NodeData): number => { if (node.depth === 1) return ribX; const parent = model.getParentData(node.id, 'tree') as NodeData; if (isAtEvenDepth(node)) { const ancestor = model.getParentData(parent.id, 'tree') as NodeData; const deltaY = calculateY(node) - calculateY(ancestor); return getAuxiliaryPoint(parent) + (deltaY * hGap) / vGap; } else { const nodeIndex = (parent.children || []).indexOf(node.id); const followingSiblingsIncludeSelf = model.getNodeData((parent.children || []).slice(nodeIndex)); return ( calculateX(parent) - followingSiblingsIncludeSelf.reduce((acc, sibling) => acc + getHorizontalOffset(sibling), 0) - getSize(parent)[0] / 2 ); } }; const calculateX = memoize( (node: NodeData): number => { if (isRoot(node)) return getSize(node)[0] / 2; const parent = model.getParentData(node.id, 'tree') as NodeData; if (isAtEvenDepth(node)) { return getAuxiliaryPoint(node) + getHorizontalOffset(node) + getSize(node)[0] / 2; } else { const deltaY = calculateY(node) - calculateY(parent); const ratio = hGap / vGap; return getAuxiliaryPoint(node) + deltaY * ratio; } }, (node) => node.id, ); const getParentY = (nodeId: ID): number => calculateY(model.getParentData(nodeId, 'tree')!); const calculateY = memoize( (node: NodeData): number => { if (isRoot(node)) return height / 2; if (!isAtEvenDepth(node)) { // If the node has no children, calculate Y based on the parent if (isEmpty(node.children)) return getParentY(node.id) + vGap; // If the last child has no children, calculate Y based on the last child const lastChild = model.getNodeLikeDatum(node.children!.slice(-1)[0]); if (isEmpty(lastChild.children)) return calculateY(lastChild) + vGap; // If the last child has children, calculate Y based on the last descendant of the last child const lastDescendant = model.getDescendantsData(node.id).slice(-1)[0]; return (isAtEvenDepth(lastDescendant) ? getParentY(lastDescendant.id) : calculateY(lastDescendant)) + vGap; } else { // depth > 0 && isAtEvenDepth(node) const parent = model.getParentData(node.id, 'tree') as NodeData; const nodeIndex = parent.children!.indexOf(node.id); // If the node is the first sibling, return Y based on parent if (nodeIndex === 0) return getParentY(parent.id) + vGap; // If the previous sibling has no children, calculate Y based on the previous sibling const prevSibling = model.getNodeLikeDatum(parent.children![nodeIndex - 1]); if (isEmpty(prevSibling.children)) return calculateY(prevSibling) + vGap; // If the previous sibling has children, calculate Y based on the last descendant of the previous sibling const descendants = model.getDescendantsData(prevSibling.id); return ( Math.max( ...descendants.map((descendant) => isAtEvenDepth(descendant) ? getParentY(descendant.id) : calculateY(descendant), ), ) + vGap ); } }, (node) => node.id, ); let tmpRibX = 0; const result: LayoutResult = { nodes: [], edges: [] }; const layout = (node: NodeData) => { node.children?.forEach((childId) => layout(model.getNodeLikeDatum(childId))); const y = calculateY(node); const x = calculateX(node); result.nodes.push({ id: node.id, x, y }); if (isRoot(node)) return; const edge = model.getRelatedEdgesData(node.id, 'in')[0]; const controlPoint = [getAuxiliaryPoint(node), isAtEvenDepth(node) ? y : getParentY(node.id)] as Point; result.edges.push({ id: idOf(edge), controlPoints: [controlPoint], relatedNodeId: node.id }); tmpRibX = Math.max(tmpRibX, x + getRibSep(node)); if (node.depth === 1) ribX = tmpRibX; }; layout(root); return result; } private placeAlterative(result: LayoutResult, root: NodeData) { const oddIndexedRibs = (root.children || []).filter((_, index) => index % 2 !== 0); if (oddIndexedRibs.length === 0) return result; const { model } = this.context; const rootY = result.nodes.find((node) => node.id === root.id)!.y; const shouldFlip = (nodeId: ID) => { const ancestors = model.getAncestorsData(nodeId, 'tree'); if (isEmpty(ancestors)) return false; const ribId = ancestors.length === 1 ? nodeId : ancestors[ancestors.length - 2].id; return oddIndexedRibs.includes(ribId); }; result.nodes.forEach((node) => { if (shouldFlip(node.id)) node.y = 2 * rootY - node.y; }); result.edges.forEach((edge) => { if (shouldFlip(edge.relatedNodeId)) { edge.controlPoints = edge.controlPoints.map((point) => [point[0], 2 * rootY - point[1]]); } }); } private rightToLeft(result: LayoutResult, options: Required) { result.nodes.forEach((node) => (node.x = options.width! - node.x)); result.edges.forEach((edge) => { edge.controlPoints = edge.controlPoints.map((point) => [options.width! - point[0], point[1]]); }); return result; } async execute(data: GraphData, propOptions: FishboneLayoutOptions): Promise { const options = { ...FishboneLayout.defaultOptions, ...this.options, ...propOptions }; const { direction, nodeSize } = options; const root = this.getRoot(); if (!root) return data; const getSize = this.formatSize(nodeSize); options.vGap ||= Math.max(...(data.nodes || []).map((node) => getSize(node)[1])); options.hGap ||= Math.max(...(data.nodes || []).map((node) => getSize(node)[0])); let result = this.doLayout(root, options); this.placeAlterative(result, root); if (direction === 'RL') { result = this.rightToLeft(result, options); } const { model } = this.context; const nodes: NodeData[] = []; const edges: EdgeData[] = []; result.nodes.forEach((node) => { const { id, x, y } = node; const nodeData = model.getNodeLikeDatum(id); nodes.push(assignElementStyle(nodeData, { x, y }) as NodeData); }); result.edges.forEach((edge) => { const { id, controlPoints } = edge; const edgeData = model.getEdgeDatum(id); edges.push(assignElementStyle(edgeData, { controlPoints }) as EdgeData); }); return { nodes, edges }; } } const assignElementStyle = (element: ElementDatum, style: Record) => { return { ...element, style: { ...(element.style || {}), ...style } }; }; const isRoot = (node: NodeData) => node.depth === 0; const isAtEvenDepth = (node: NodeData) => (node.depth ||= 0) % 2 === 0; ================================================ FILE: packages/g6/src/layouts/index.ts ================================================ export { compactBox, dendrogram, indented, mindmap } from '@antv/hierarchy'; export { AntVDagreLayout, CircularLayout, ComboCombinedLayout, ConcentricLayout, D3ForceLayout, DagreLayout, ForceAtlas2Layout, ForceLayout, FruchtermanLayout, GridLayout, MDSLayout, RadialLayout, RandomLayout, } from '@antv/layout'; export { BaseLayout } from './base-layout'; export { FishboneLayout } from './fishbone'; export { SnakeLayout } from './snake'; export type { FishboneLayoutOptions } from './fishbone'; export type { SnakeLayoutOptions } from './snake'; ================================================ FILE: packages/g6/src/layouts/snake.ts ================================================ import type { GraphData, NodeData } from '../spec'; import type { ID, Padding, Size } from '../types'; import { parsePadding } from '../utils/padding'; import { parseSize } from '../utils/size'; import { BaseLayout } from './base-layout'; import type { BaseLayoutOptions } from './types'; export interface SnakeLayoutOptions extends BaseLayoutOptions { /** * 节点尺寸 * * Node size */ nodeSize?: Size | ((node: NodeData) => Size); /** * 内边距,即布局区域与画布边界的距离 * * Padding, the distance between the layout area and the canvas boundary * @defaultValue 0 */ padding?: Padding; /** * 节点排序方法。默认按照在图中的路径顺序进行展示 * * Node sorting method */ sortBy?: (nodeA: NodeData, nodeB: NodeData) => -1 | 0 | 1; /** * 节点列数 * * Number of node columns * @defaultValue 5 */ cols?: number; /** * 节点行之间的间隙大小。默认将根据画布高度和节点总行数自动计算 * * The size of the gap between a node's rows */ rowGap?: number; /** * 节点列之间的间隙大小。默认将根据画布宽度和节点总列数自动计算 * * The size of the gap between a node's columns */ colGap?: number; /** * 节点排布方向是否顺时针 * * Whether the node arrangement direction is clockwise * @defaultValue true * @remarks * 在顺时针排布时,节点从左上角开始,第一行从左到右排列,第二行从右到左排列,依次类推,形成 S 型路径。在逆时针排布时,节点从右上角开始,第一行从右到左排列,第二行从左到右排列,依次类推,形成反向 S 型路径。 * * When arranged clockwise, the nodes start from the upper left corner, the first row is arranged from left to right, the second row is arranged from right to left, and so on, forming an S-shaped path. When arranged counterclockwise, the nodes start from the upper right corner, the first row is arranged from right to left, the second row is arranged from left to right, and so on, forming a reverse S-shaped path. */ clockwise?: boolean; } /** * 蛇形布局 * * Snake layout * @remarks * 蛇形布局(Snake Layout)是一种特殊的图形布局方式,能够在较小的空间内更有效地展示长链结构。需要注意的是,其图数据需要确保节点按照从源节点到汇节点的顺序进行线性排列,形成一条明确的路径。 * * 节点按 S 字型排列,第一个节点位于第一行的起始位置,接下来的节点在第一行向右排列,直到行末尾。到达行末尾后,下一行的节点从右向左反向排列。这个过程重复进行,直到所有节点排列完毕。 * * The Snake layout is a special way of graph layout that can more effectively display long chain structures in a smaller space. Note that the graph data needs to ensure that the nodes are linearly arranged in the order from the source node to the sink node to form a clear path. * * The nodes are arranged in an S-shaped pattern, with the first node at the beginning of the first row, and the following nodes arranged to the right until the end of the row. After reaching the end of the row, the nodes in the next row are arranged in reverse from right to left. This process is repeated until all nodes are arranged. */ export class SnakeLayout extends BaseLayout { public id = 'snake'; static defaultOptions: Partial = { padding: 0, cols: 5, clockwise: true, }; private formatSize(nodes: NodeData[], size?: Size | ((node: NodeData) => Size)): [number, number] { const sizeFn = typeof size === 'function' ? size : ((() => size) as (node: NodeData) => Size); return nodes.reduce( (acc, node) => { const [w, h] = parseSize(sizeFn(node)) || [0, 0]; return [Math.max(acc[0], w), Math.max(acc[1], h)]; }, [0, 0], ); } /** * Validates the graph data to ensure it meets the requirements for linear arrangement. * @param data - Graph data * @returns false if the graph is not connected, has more than one source or sink node, or contains cycles. */ private validate(data: GraphData): boolean { const { nodes = [], edges = [] } = data; const inDegree: { [key: ID]: number } = {}; const outDegree: { [key: ID]: number } = {}; const adjList: { [key: ID]: ID[] } = {}; nodes.forEach((node) => { inDegree[node.id] = 0; outDegree[node.id] = 0; adjList[node.id] = []; }); edges.forEach((edge) => { inDegree[edge.target]++; outDegree[edge.source]++; adjList[edge.source].push(edge.target); }); // 检查图是否连通 // Check if the graph is connected const visited: Set = new Set(); const dfs = (nodeId: ID) => { if (visited.has(nodeId)) return; visited.add(nodeId); adjList[nodeId].forEach(dfs); }; dfs(nodes[0].id); if (visited.size !== nodes.length) return false; // 检查是否有且仅有一个源节点和一个汇节点 // Check if there is exactly one source node and one sink node const sourceNodes = nodes.filter((node) => inDegree[node.id] === 0); const sinkNodes = nodes.filter((node) => outDegree[node.id] === 0); if (sourceNodes.length !== 1 || sinkNodes.length !== 1) return false; // 检查中间节点是否只有一个前驱和一个后继 // Check if the middle nodes have only one predecessor and one successor const middleNodes = nodes.filter((node) => inDegree[node.id] === 1 && outDegree[node.id] === 1); if (middleNodes.length !== nodes.length - 2) return false; return true; } async execute(model: GraphData, options?: SnakeLayoutOptions): Promise { if (!this.validate(model)) return model; const { nodeSize: propNodeSize, padding: propPadding, sortBy, cols, colGap: propColSep, rowGap: propRowSep, clockwise, width, height, } = Object.assign({}, SnakeLayout.defaultOptions, this.options, options) as Required; const [top, right, bottom, left] = parsePadding(propPadding); const nodeSize = this.formatSize(model.nodes || [], propNodeSize); const rows = Math.ceil((model.nodes || []).length / cols); let colSep = propColSep ? propColSep : (width - left - right - cols * nodeSize[0]) / (cols - 1); let rowSep = propRowSep ? propRowSep : (height - top - bottom - rows * nodeSize[1]) / (rows - 1); if (rowSep === Infinity || rowSep < 0) rowSep = 0; if (colSep === Infinity || colSep < 0) colSep = 0; const sortedNodes = sortBy ? model.nodes?.sort(sortBy) : topologicalSort(model); const nodes = (sortedNodes || []).map((node, index) => { const rowIndex = Math.floor(index / cols); const colIndex = index % cols; const actualColIndex = clockwise ? rowIndex % 2 === 0 ? colIndex : cols - 1 - colIndex : rowIndex % 2 === 0 ? cols - 1 - colIndex : colIndex; const x = left + actualColIndex * (nodeSize[0] + colSep) + nodeSize[0] / 2; const y = top + rowIndex * (nodeSize[1] + rowSep) + nodeSize[1] / 2; return { id: node.id, style: { x, y }, }; }); return { nodes }; } } /** * Topological sorting. The nodes are sorted according to the order of the paths(from the start node to the end node). * @param data - Graph data * @returns Sorted nodes */ function topologicalSort(data: GraphData): NodeData[] { const { nodes = [], edges = [] } = data; const inDegree: { [key: ID]: number } = {}; const adjList: { [key: ID]: ID[] } = {}; nodes.forEach((node) => { inDegree[node.id] = 0; adjList[node.id] = []; }); edges.forEach((edge) => { inDegree[edge.target]++; adjList[edge.source].push(edge.target); }); const queue: ID[] = []; const sortedNodes: NodeData[] = []; nodes.forEach((node) => { if (inDegree[node.id] === 0) { queue.push(node.id); } }); while (queue.length > 0) { const nodeId = queue.shift()!; const node = nodes.find((n) => n.id === nodeId)!; sortedNodes.push(node); adjList[nodeId].forEach((neighbor) => { inDegree[neighbor]--; if (inDegree[neighbor] === 0) { queue.push(neighbor); } }); } return sortedNodes; } ================================================ FILE: packages/g6/src/layouts/types.ts ================================================ import type { Graph as IGraph } from '@antv/graphlib'; import type { AntVDagreLayoutOptions, LayoutWithIterations as AntVIterativeLayout, Layout as AntVNonIterativeLayout, CircularLayoutOptions, ConcentricLayoutOptions, D3Force3DLayoutOptions, D3ForceLayoutOptions, DagreLayoutOptions, ForceAtlas2LayoutOptions, ForceLayoutOptions, FruchtermanLayoutOptions, GraphData, GridLayoutOptions, MDSLayoutOptions, RadialLayoutOptions, RandomLayoutOptions, } from '@antv/layout'; import type { ComboData, EdgeData, NodeData } from '../spec/data'; import type { BaseLayout } from './base-layout'; import type { FishboneLayoutOptions } from './fishbone'; import type { SnakeLayoutOptions } from './snake'; export type BuiltInLayoutOptions = | AntVDagreLayout | CircularLayout | ConcentricLayout | D3ForceLayout | D3Force3DLayout | DagreLayout | ForceAtlas2 | ForceLayout | FruchtermanLayout | GridLayout | MDSLayout | RadialLayout | RandomLayout | SnakeLayout | FishboneLayout; export interface BaseLayoutOptions extends AnimationOptions, WebWorkerLayoutOptions { /** * 布局类型 * * Layout type */ type: string; /** * 参与该布局的节点 * * Nodes involved in the layout * @param node - 节点数据 | node data * @returns 是否参与布局 | Whether to participate in the layout */ nodeFilter?: (node: NodeData) => boolean; /** * 参与该布局的combo元素 * * Combos involved in the layout * @param node - combo数据 | combo data * @returns 是否参与布局 | Whether to participate in the layout */ comboFilter?: (combo: ComboData) => boolean; /** * 使用前布局,在初始化元素前计算布局 * * Use pre-layout to calculate the layout before initializing the elements * @remarks * 不适用于流水线布局 * * Not applicable to pipeline layout */ preLayout?: boolean; /** * 不可见节点是否参与布局 * * Whether invisible nodes participate in the layout * @remarks * 当 preLayout 为 true 时生效 * * Takes effect when preLayout is true */ isLayoutInvisibleNodes?: boolean; /** * 布局区域宽度,默认为画布宽度 * * Width of the layout area, default is the canvas width */ width?: number; /** * 布局区域高度,默认为画布高度 * * Height of the layout area, default is the canvas height */ height?: number; [key: string]: unknown; } interface CircularLayout extends BaseLayoutOptions, CircularLayoutOptions { type: 'circular'; } interface RandomLayout extends BaseLayoutOptions, RandomLayoutOptions { type: 'random'; } interface GridLayout extends BaseLayoutOptions, GridLayoutOptions { type: 'grid'; } interface MDSLayout extends BaseLayoutOptions, MDSLayoutOptions { type: 'mds'; } interface ConcentricLayout extends BaseLayoutOptions, ConcentricLayoutOptions { type: 'concentric'; } interface RadialLayout extends BaseLayoutOptions, RadialLayoutOptions { type: 'radial'; } interface FruchtermanLayout extends BaseLayoutOptions, FruchtermanLayoutOptions { type: 'fruchterman' | 'fruchterman-gpu'; } interface D3ForceLayout extends BaseLayoutOptions, D3ForceLayoutOptions { type: 'd3-force'; } interface D3Force3DLayout extends BaseLayoutOptions, D3Force3DLayoutOptions { type: 'd3-force3d'; } interface ForceLayout extends BaseLayoutOptions, ForceLayoutOptions { type: 'force' | 'gforce'; } interface ForceAtlas2 extends BaseLayoutOptions, ForceAtlas2LayoutOptions { type: 'force-atlas2'; } interface AntVDagreLayout extends BaseLayoutOptions, AntVDagreLayoutOptions { type: 'antv-dagre'; } interface DagreLayout extends BaseLayoutOptions, DagreLayoutOptions { type: 'dagre'; } interface SnakeLayout extends BaseLayoutOptions, SnakeLayoutOptions { type: 'snake'; } interface FishboneLayout extends BaseLayoutOptions, FishboneLayoutOptions { type: 'fishbone'; } interface AnimationOptions { /** * 启用布局动画,对于迭代布局,会在两次迭代之间进行动画过渡 * * Enable layout animation, for iterative layout, animation transition will be performed between two iterations */ animation?: boolean; } export interface WebWorkerLayoutOptions { /** * 是否在 WebWorker 中运行布局 * * Whether to run the layout in WebWorker */ enableWorker?: boolean; /** * 迭代布局的迭代次数 * * Iterations for iterable layouts */ iterations?: number; } export type AntVLayout = AntVNonIterativeLayout | AntVIterativeLayout; export type Layout = BaseLayout | AntVLayout; export type AntVGraphData = GraphData; /** Legacy AntV Layout 1.x */ export type LegacyGraph = IGraph; export type LegacyAntVLayout = { id: string; options: T; assign(graph: LegacyGraph, options?: T): Promise; execute(graph: LegacyGraph, options?: T): Promise; tick(iterations?: number): AntVGraphData; stop(): void; }; ================================================ FILE: packages/g6/src/palettes/index.ts ================================================ /** * 内置色板 * * Built-in palettes */ export const spectral = [ 'rgb(158, 1, 66)', 'rgb(213, 62, 79)', 'rgb(244, 109, 67)', 'rgb(253, 174, 97)', 'rgb(254, 224, 139)', 'rgb(255, 255, 191)', 'rgb(230, 245, 152)', 'rgb(171, 221, 164)', 'rgb(102, 194, 165)', 'rgb(50, 136, 189)', 'rgb(94, 79, 162)', ]; export const tableau = [ 'rgb(78, 121, 167)', 'rgb(242, 142, 44)', 'rgb(225, 87, 89)', 'rgb(118, 183, 178)', 'rgb(89, 161, 79)', 'rgb(237, 201, 73)', 'rgb(175, 122, 161)', 'rgb(255, 157, 167)', 'rgb(156, 117, 95)', 'rgb(186, 176, 171)', ]; export const oranges = [ 'rgb(255, 245, 235)', 'rgb(254, 230, 206)', 'rgb(253, 208, 162)', 'rgb(253, 174, 107)', 'rgb(253, 141, 60)', 'rgb(241, 105, 19)', 'rgb(217, 72, 1)', 'rgb(166, 54, 3)', 'rgb(127, 39, 4)', ]; export const greens = [ 'rgb(247, 252, 245)', 'rgb(229, 245, 224)', 'rgb(199, 233, 192)', 'rgb(161, 217, 155)', 'rgb(116, 196, 118)', 'rgb(65, 171, 93)', 'rgb(35, 139, 69)', 'rgb(0, 109, 44)', 'rgb(0, 68, 27)', ]; export const blues = [ 'rgb(247, 251, 255)', 'rgb(222, 235, 247)', 'rgb(198, 219, 239)', 'rgb(158, 202, 225)', 'rgb(107, 174, 214)', 'rgb(66, 146, 198)', 'rgb(33, 113, 181)', 'rgb(8, 81, 156)', 'rgb(8, 48, 107)', ]; ================================================ FILE: packages/g6/src/palettes/types.ts ================================================ export type Palette = string | BuiltInPalette | CategoricalPalette | ContinuousPalette; export type STDPalette = CategoricalPalette | ContinuousPalette; export type BuiltInPalette = 'spectral' | 'oranges' | 'greens' | 'blues'; export type CategoricalPalette = string[]; export type ContinuousPalette = (ratio: number) => string; ================================================ FILE: packages/g6/src/plugins/background/index.ts ================================================ import { omit } from '@antv/util'; import type { RuntimeContext } from '../../runtime/types'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { createPluginContainer } from '../utils/dom'; /** * 背景配置项 * * Background options */ export interface BackgroundOptions extends BasePluginOptions, CSSStyleDeclaration {} /** * 背景图 * * Background image * @remarks * 支持为图画布设置一个背景图片,让画布更有层次感、叙事性。 * * Support setting a background image for the canvas to make the canvas more hierarchical and narrative. */ export class Background extends BasePlugin { static defaultOptions: Partial = { transition: 'background 0.5s', backgroundSize: 'cover', zIndex: '-1', // aviod to cover the other plugin's dom, eg: grid-line. }; private $element: HTMLElement = createPluginContainer('background'); constructor(context: RuntimeContext, options: BackgroundOptions) { super(context, Object.assign({}, Background.defaultOptions, options)); const $container = this.context.canvas.getContainer(); $container!.prepend(this.$element); this.update(options); } /** * 更新背景图配置 * * Update the background image configuration * @param options - 配置项 | Options * @internal */ public async update(options: Partial) { super.update(options); // Set the background style. Object.assign(this.$element.style, omit(this.options, ['key', 'type'])); } /** * 销毁背景图 * * Destroy the background image * @internal */ public destroy(): void { super.destroy(); // Remove the background dom. this.$element.remove(); } } ================================================ FILE: packages/g6/src/plugins/base-plugin.ts ================================================ import { BaseExtension } from '../registry/extension'; import type { CustomPluginOption } from '../spec/plugin'; export interface BasePluginOptions extends CustomPluginOption {} /** * 插件的基类 * * Base class for plugins */ export abstract class BasePlugin extends BaseExtension {} ================================================ FILE: packages/g6/src/plugins/bubble-sets.ts ================================================ import type { PathArray } from '@antv/util'; import { deepMix, isEqual, isFunction } from '@antv/util'; import type { IBubbleSetOptions, ILine, IRectangle } from 'bubblesets-js'; import { BubbleSets as BubbleSetsJS, Line, Rectangle, defaultOptions } from 'bubblesets-js'; import { GraphEvent } from '../constants'; import type { ContourStyleProps } from '../elements/shapes'; import { Contour } from '../elements/shapes'; import type { Graph } from '../runtime/graph'; import type { RuntimeContext } from '../runtime/types'; import type { ID } from '../types'; import { getBBoxHeight, getBBoxWidth } from '../utils/bbox'; import { arrayDiff } from '../utils/diff'; import type { ElementLifeCycleEvent } from '../utils/event'; import { idOf } from '../utils/id'; import { getClosedSpline } from '../utils/path'; import { parsePoint } from '../utils/point'; import type { BasePluginOptions } from './base-plugin'; import { BasePlugin } from './base-plugin'; /** * 气泡集配置项 * * BubbleSets options */ export interface BubbleSetsOptions extends BasePluginOptions, IBubbleSetOptions, ContourStyleProps { /** * 成员元素,包括节点和边 * * Member elements, including nodes and edges */ members: ID[]; /** * 需要避开的元素,在绘制轮廓时不会包含这些元素。目前支持设置节点 * * Elements to avoid, these elements will not be included when drawing the contour, currently only nodes are supported */ avoidMembers?: ID[]; } /** * 气泡集 * * BubbleSets * @remarks * BubbleSets 最初由 Christopher Collins 在 2009 年的论文 "Bubble Sets: Revealing Set Relations with Isocontours over Existing Visualizations" 中提出。 * * 实现原理是通过创建一种类似于气泡的形状来表示集合。每个集合都被表示为一个独特的 "气泡",集合中的元素被包含在这个气泡内部。如果两个集合有交集,那么这两个气泡会有重叠的部分,这个重叠的部分就表示这两个集合的交集。 * * BubbleSets was originally proposed by Christopher Collins in the 2009 paper "Bubble Sets: Revealing Set Relations with Isocontours over Existing Visualizations". * * The principle is to represent sets by creating a shape similar to a bubble. Each set is represented by a unique "bubble", and the elements in the set are contained within this bubble. If two sets have an intersection, then the two bubbles will have an overlapping part, which represents the intersection of the two sets. */ export class BubbleSets extends BasePlugin { private shape?: Contour; private bubbleSets!: BubbleSetsJS; private path: PathArray | null = null; private members: Map = new Map(); private avoidMembers: Map = new Map(); private bubbleSetOptions: IBubbleSetOptions = {}; static defaultOptions: Partial = { members: [], avoidMembers: [], /** shape style */ fill: 'lightblue', fillOpacity: 0.2, stroke: 'blue', strokeOpacity: 0.2, /** bubbleSetJS config */ ...defaultOptions, }; constructor(context: RuntimeContext, options: BubbleSetsOptions) { super(context, deepMix({}, BubbleSets.defaultOptions, options)); this.bindEvents(); this.bubbleSets = new BubbleSetsJS(this.options); } private bindEvents() { this.context.graph.on(GraphEvent.AFTER_RENDER, this.drawBubbleSets); this.context.graph.on(GraphEvent.AFTER_ELEMENT_UPDATE, this.updateBubbleSetsPath); } private init() { this.bubbleSets = new BubbleSetsJS(this.options); this.members.clear(); this.avoidMembers.clear(); this.path = null; } private parseOptions() { const { type, key, members, avoidMembers, ...rest } = this.options; const res = Object.keys(rest).reduce( (acc: { style: ContourStyleProps; bubbleSetOptions: IBubbleSetOptions }, key: string) => { if (key in defaultOptions) { acc.bubbleSetOptions[key as keyof IBubbleSetOptions] = rest[key]; } else { acc.style[key as keyof ContourStyleProps] = rest[key]; } return acc; }, { style: {}, bubbleSetOptions: {} }, ); return { type, key, members, avoidMembers, ...res }; } private drawBubbleSets = () => { const { style, bubbleSetOptions } = this.parseOptions(); if (!isEqual(this.bubbleSetOptions, bubbleSetOptions)) this.init(); this.bubbleSetOptions = { ...bubbleSetOptions }; const finalStyle = { ...style, d: this.getPath() }; if (!this.shape) { this.shape = new Contour({ style: finalStyle }); this.context.canvas.appendChild(this.shape); } else { this.shape.update(finalStyle); } }; private updateBubbleSetsPath = (event: ElementLifeCycleEvent) => { if (!this.shape) return; const id = idOf(event.data); if (![...this.options.members, ...this.options.avoidMembers].includes(id)) return; this.shape.update({ ...this.parseOptions().style, d: this.getPath(id) }); }; private getPath = (forceUpdateId?: ID): PathArray => { const { graph } = this.context; const currMembers = this.options.members; const prevMembers = [...this.members.keys()]; const currAvoidMembers = this.options.avoidMembers; const prevAvoidMembers = [...this.avoidMembers.keys()]; if (currMembers.length === 0 && currAvoidMembers.length === 0) { this.members.clear(); this.avoidMembers.clear(); this.path = [] as unknown as PathArray; return this.path; } if ( !forceUpdateId && this.path && isEqual(currMembers, prevMembers) && isEqual(currAvoidMembers, prevAvoidMembers) ) { return this.path; } const { enter: membersToEnter = [], exit: membersToExit = [] } = arrayDiff(prevMembers, currMembers, (d) => d); const { enter: avoidMembersToEnter = [], exit: avoidMembersToExit = [] } = arrayDiff( prevAvoidMembers, currAvoidMembers, (d) => d, ); if (forceUpdateId) { const isMemberNow = currMembers.includes(forceUpdateId); const isAvoidNow = currAvoidMembers.includes(forceUpdateId); if (isMemberNow) { membersToExit.push(forceUpdateId); membersToEnter.push(forceUpdateId); } if (isAvoidNow) { avoidMembersToExit.push(forceUpdateId); avoidMembersToEnter.push(forceUpdateId); } } const updateBubbleSets = (ids: ID[], isEntering: boolean, isMember: boolean) => { ids.forEach((id) => { const members = isMember ? this.members : this.avoidMembers; const pushMember = isMember ? 'pushMember' : 'pushNonMember'; const removeMember = isMember ? 'removeMember' : 'removeNonMember'; if (isEntering) { let area: IRectangle | ILine; if (graph.getElementType(id) === 'edge') { [area] = convertToLine(graph, id); this.bubbleSets.pushEdge(area); } else { [area] = convertToRectangle(graph, id); this.bubbleSets[pushMember](area); } members.set(id, area); } else { const area = members.get(id); if (area) { if (graph.getElementType(id) === 'edge') { this.bubbleSets.removeEdge(area as ILine); } else { this.bubbleSets[removeMember](area as IRectangle); } members.delete(id); } } }); }; updateBubbleSets(membersToExit, false, true); updateBubbleSets(membersToEnter, true, true); updateBubbleSets(avoidMembersToExit, false, false); updateBubbleSets(avoidMembersToEnter, true, false); const pointPath = this.bubbleSets.compute(); const cleanPath = pointPath.sample(8).simplify(0).bSplines().simplify(0); this.path = getClosedSpline(cleanPath.points.map(parsePoint)); return this.path; }; /** * 添加成员元素 * * Add member elements * @param members - 单个或多个 | single or multiple */ public addMember(members: ID | ID[]) { const membersToAdd = Array.isArray(members) ? members : [members]; if (membersToAdd.some((member) => this.options.avoidMembers.includes(member))) { this.options.avoidMembers = this.options.avoidMembers.filter((id) => !membersToAdd.includes(id)); } this.options.members = [...new Set([...this.options.members, ...membersToAdd])]; this.drawBubbleSets(); } /** * 移除成员元素 * * Remove member elements * @param members - 单个或多个 | single or multiple */ public removeMember(members: ID | ID[]) { const membersToRemove = Array.isArray(members) ? members : [members]; this.options.members = this.options.members.filter((id) => !membersToRemove.includes(id)); this.drawBubbleSets(); } /** * 更新成员元素 * * Update member elements * @param members - 值或者回调函数 | value or callback function */ public updateMember(members: ID[] | ((prev: ID[]) => ID[])) { this.options.members = isFunction(members) ? members(this.options.members) : members; this.drawBubbleSets(); } /** * 获取成员元素 * * Get member elements * @returns 成员元素数组 | member elements array */ public getMember() { return this.options.members; } /** * 添加需要避开的元素 * * Add elements to avoid * @param avoidMembers - 单个或多个 | single or multiple */ public addAvoidMember(avoidMembers: ID | ID[]) { const avoidMembersToAdd = Array.isArray(avoidMembers) ? avoidMembers : [avoidMembers]; if (avoidMembersToAdd.some((avoidMember) => this.options.members.includes(avoidMember))) { this.options.members = this.options.members.filter((id) => !avoidMembersToAdd.includes(id)); } this.options.avoidMembers = [...new Set([...this.options.avoidMembers, ...avoidMembersToAdd])]; this.drawBubbleSets(); } /** * 移除需要避开的元素 * * Remove elements to avoid * @param avoidMembers - 单个或多个 | single or multiple */ public removeAvoidMember(avoidMembers: ID | ID[]) { const avoidMembersToRemove = Array.isArray(avoidMembers) ? avoidMembers : [avoidMembers]; if (this.options.avoidMembers.some((member) => avoidMembersToRemove.includes(member))) { this.options.avoidMembers = this.options.avoidMembers.filter((id) => !avoidMembersToRemove.includes(id)); this.drawBubbleSets(); } } /** * 更新需要避开的元素 * * Update elements to avoid * @param avoidMembers - 单个或多个 | single or multiple */ public updateAvoidMember(avoidMembers: ID | ID[]) { this.options.avoidMembers = Array.isArray(avoidMembers) ? avoidMembers : [avoidMembers]; this.drawBubbleSets(); } /** * 获取需要避开的元素 * * Get elements to avoid * @returns avoidMembers 成员元素数组 | member elements array */ public getAvoidMember() { return this.options.avoidMembers; } /** * 销毁 * * Destroy * @internal */ public destroy(): void { this.context.graph.off(GraphEvent.AFTER_RENDER, this.drawBubbleSets); this.context.graph.off(GraphEvent.AFTER_ELEMENT_UPDATE, this.updateBubbleSetsPath); if (this.shape) { this.shape.destroy(); this.shape = undefined; } super.destroy(); } } /** * 将节点转换为 BubbleSetJS 支持的矩形 * * Convert nodes to rectangles supported by BubbleSetJS * @param graph - 图实例 | graph instance * @param ids - 元素 ID 数组 | element ID array * @returns 矩形数组 | rectangle array */ const convertToRectangle = (graph: Graph, ids: ID | ID[]): IRectangle[] => { const idArr = Array.isArray(ids) ? ids : [ids]; return idArr.map((id) => { const bbox = graph.getElementRenderBounds(id); return new Rectangle(bbox.min[0], bbox.min[1], getBBoxWidth(bbox), getBBoxHeight(bbox)); }); }; /** * 将边转换为 BubbleSetJS 支持的线 * * Convert edges to lines supported by BubbleSetJS * @param graph - 图实例 | graph instance * @param ids - 元素 ID 数组 | element ID array * @returns 线数组 | line array */ const convertToLine = (graph: Graph, ids: ID | ID[]): ILine[] => { const idArr = Array.isArray(ids) ? ids : [ids]; return idArr.map((id) => { const data = graph.getEdgeData(id); const source = graph.getElementPosition(data.source); const target = graph.getElementPosition(data.target); return Line.from({ x1: source[0], y1: source[1], x2: target[0], y2: target[1] }); }); }; ================================================ FILE: packages/g6/src/plugins/camera-setting.ts ================================================ import { GraphEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { BasePluginOptions } from './base-plugin'; import { BasePlugin } from './base-plugin'; export interface CameraSettingOptions extends BasePluginOptions { /** * 投影模式,透视投影仅在 3D 场景下有效 * - `'perspective'` : 透视投影 * - `'orthographic'` : 正交投影 * * Projection mode, perspective projection is only valid in 3D scenes * - `'perspective'` : perspective projection * - `'orthographic'` : Orthogonal projection */ projectionMode?: 'perspective' | 'orthographic'; /** * 相机类型 * - `'orbiting'`: 固定视点,改变相机位置 * - `'exploring'`: 类似 orbiting,但允许相机在北极和南极之间旋转 * - `'tracking'`: 固定相机位置,改变视点 * * Camera type * - `'orbiting'`: Fixed viewpoint, change camera position * - `'exploring'`: Similar to orbiting, but allows the camera to rotate between the North Pole and the South Pole * - `'tracking'`: Fixed camera position, change viewpoint */ cameraType?: 'orbiting' | 'exploring' | 'tracking'; /** * 近平面位置 * * The position of the near plane */ near?: number; /** * 远平面位置 * * The position of the far plane */ far?: number; /** * 相机视角,仅在透视相机下有效 * * Camera field of view, only valid in perspective camera */ fov?: number; /** * 相机视口宽高比,仅在透视相机下有效 * - number : 具体的宽高比 * - `'auto'` : 自动设置为画布的宽高比 * * Camera viewport aspect ratio, only valid in perspective camera. * - number : Specific aspect ratio * - `'auto'` : Automatically set to the aspect ratio of the canvas */ aspect?: number | 'auto'; /** * 相机距离目标的距离 * * The distance from the camera to the target * @defaultValue 500 */ distance?: number; /** * 最小视距 * * Minimum distance */ minDistance?: number; /** * 最大视距 * * Maximum distance */ maxDistance?: number; /** * 滚转角 * * Roll */ roll?: number; /** * 仰角 * * Elevation */ elevation?: number; /** * 方位角 * * Azimuth */ azimuth?: number; } /** * 配置相机参数 * * Configure camera parameters */ export class CameraSetting extends BasePlugin { constructor(context: RuntimeContext, options: CameraSettingOptions) { super(context, options); this.bindEvents(); } /** * 更新相机参数 * * Update camera parameters * @param options - 相机配置项 | Camera configuration options * @internal */ public update(options: Partial): void { this.setOptions(options); super.update(options); } private bindEvents() { this.context.graph.once(GraphEvent.BEFORE_DRAW, () => this.setOptions(this.options)); } private setOptions = (options: Partial) => { const caller = { cameraType: 'setType', near: 'setNear', far: 'setFar', fov: 'setFov', aspect: 'setAspect', // 确保 projectionMode 在 near/far/fov/aspect 之后设置 // Ensure that projectionMode is set after near/far/fov/aspect projectionMode: 'setProjectionMode', distance: 'setDistance', minDistance: 'setMinDistance', maxDistance: 'setMaxDistance', roll: 'setRoll', elevation: 'setElevation', azimuth: 'setAzimuth', } as const; const valueMapper = (key: string, value: string) => { switch (key) { case 'projectionMode': return value === 'perspective' ? 1 : 0; case 'cameraType': return { orbiting: 0, exploring: 1, tracking: 2 }[value]!; case 'aspect': if (typeof value === 'number') return value; return this.getCanvasAspect(); default: return value; } }; Object.entries(caller).forEach(([key, method]) => { const value = options[key]; if (value !== undefined) { const actualValue = valueMapper(key, value); // @ts-expect-error incorrect ts type check this.context.canvas.getCamera()[method](actualValue); } }); }; private getCanvasAspect() { const [width, height] = this.context.viewport!.getCanvasSize(); return width / height; } } ================================================ FILE: packages/g6/src/plugins/contextmenu/index.ts ================================================ import type { RuntimeContext } from '../../runtime/types'; import type { Element } from '../../types'; import type { IElementEvent } from '../../types/event'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { createPluginContainer, insertDOM } from '../utils/dom'; import type { Item } from './util'; import { CONTEXTMENU_CSS, getContentFromItems } from './util'; /** * 上下文菜单配置项 * * Contextmenu options */ export interface ContextmenuOptions extends BasePluginOptions { /** * 给菜单的 DOM 追加的类名 * * The class name appended to the menu DOM for custom styles * @defaultValue 'g6-contextmenu' */ className?: string; /** * 如何触发右键菜单 * - `'click'` : 点击触发 * - `'contextmenu'` : 右键触发 * * How to trigger the context menu * - `'click'` : Click trigger * - `'contextmenu'` : Right-click trigger * @defaultValue 'contextmenu' */ trigger?: 'click' | 'contextmenu'; /** * 菜单显式 X、Y 方向的偏移量 * * The offset X, y direction of the menu * @defaultValue [4, 4] */ offset?: [number, number]; /** * 当菜单被点击后,触发的回调方法 * * The callback method triggered when the menu is clicked */ onClick?: (value: string, target: HTMLElement, current: Element) => void; /** * 返回菜单的项目列表,支持 `Promise` 类型的返回值。是 `getContent` 的快捷配置 * * Return the list of menu items, support the `Promise` type return value. It is a shortcut configuration of `getContent` */ getItems?: (event: IElementEvent) => Item[] | Promise; /** * 返回菜单的内容,支持 `Promise` 类型的返回值,也可以使用 `getItems` 进行快捷配置 * * Return the content of menu, support the `Promise` type return value, you can also use `getItems` for shortcut configuration */ getContent?: (event: IElementEvent) => HTMLElement | string | Promise; /** * 当 `getContent` 返回一个 `Promise` 时,使用的菜单内容 * * The menu content when loading is used when getContent returns a Promise */ loadingContent?: HTMLElement | string; /** * 是否可用,通过参数判断是否支持右键菜单,默认是全部可用 * * Whether the plugin is available, determine whether the right-click menu is supported through parameters, The default is all available * @defaultValue true */ enable?: boolean | ((event: IElementEvent) => boolean); } /** * 上下文菜单 * * Contextmenu * @remarks * 上下文菜单,也被称为右键菜单,是当用户在某个特定区域上点击后出现的一个菜单。支持在点击前后,触发自定义事件。 * * Contextmenu, also known as the right-click menu , is a menu that appears when a user clicks on a specific area. Supports triggering custom events before and after clicking. */ export class Contextmenu extends BasePlugin { static defaultOptions: Partial = { trigger: 'contextmenu', offset: [4, 4], loadingContent: '
Loading...
', getContent: () => 'It is a empty context menu.', enable: () => true, }; private $element!: HTMLElement; private targetElement: Element | null = null; constructor(context: RuntimeContext, options: ContextmenuOptions) { super(context, Object.assign({}, Contextmenu.defaultOptions, options)); this.initElement(); this.update(options); } private initElement() { this.$element = createPluginContainer('contextmenu', false, { zIndex: '99' }); const { className } = this.options; if (className) this.$element.classList.add(className); const $container = this.context.canvas.getContainer(); $container!.appendChild(this.$element); insertDOM('g6-contextmenu-css', 'style', {}, CONTEXTMENU_CSS, document.head); } /** * 显示上下文菜单 * * Show the contextmenu * @param event - 元素指针事件 | Element pointer event * @internal */ public async show(event: IElementEvent) { const { enable, offset } = this.options; if ((typeof enable === 'function' && !enable(event)) || !enable) { this.hide(); return; } const content = await this.getDOMContent(event); if (content instanceof HTMLElement) { this.$element.innerHTML = ''; this.$element.appendChild(content); } else { this.$element.innerHTML = content; } // NOTICE: 为什么事件中的 client 是相对浏览器,而不是画布容器? const clientRect = this.context.graph.getCanvas().getContainer()!.getBoundingClientRect(); this.$element.style.left = `${event.client.x - clientRect.left + offset[0]}px`; this.$element.style.top = `${event.client.y - clientRect.top + offset[1]}px`; this.$element.style.display = 'block'; this.targetElement = event.target; } /** * 隐藏上下文菜单 * * Hide the contextmenu */ public hide() { this.$element.style.display = 'none'; this.targetElement = null; } /** * 更新上下文菜单的配置项 * * Update the contextmenu options * @param options - 配置项 | Options * @internal */ public update(options: Partial) { this.unbindEvents(); super.update(options); this.bindEvents(); } /** * 销毁上下文菜单 * * Destroy the contextmenu * @internal */ public destroy(): void { this.unbindEvents(); super.destroy(); this.$element.remove(); } private async getDOMContent(event: IElementEvent) { const { getContent, getItems } = this.options; if (getItems) { return getContentFromItems(await getItems(event)); } return await getContent(event); } private bindEvents() { const { graph } = this.context; const { trigger } = this.options; graph.on(`canvas:${trigger}`, this.onTriggerEvent); graph.on(`node:${trigger}`, this.onTriggerEvent); graph.on(`edge:${trigger}`, this.onTriggerEvent); graph.on(`combo:${trigger}`, this.onTriggerEvent); document.addEventListener('click', this.onMenuItemClick); } private unbindEvents() { const { graph } = this.context; const { trigger } = this.options; graph.off(`canvas:${trigger}`, this.onTriggerEvent); graph.off(`node:${trigger}`, this.onTriggerEvent); graph.off(`edge:${trigger}`, this.onTriggerEvent); graph.off(`combo:${trigger}`, this.onTriggerEvent); document.removeEventListener('click', this.onMenuItemClick); } private onTriggerEvent = (event: IElementEvent) => { // `contextmenu` 事件默认会触发浏览器的右键菜单,需要阻止默认事件 // `click` 事件不需要阻止默认事件 event.preventDefault?.(); this.show(event); }; private onMenuItemClick = (event: MouseEvent) => { const { onClick, trigger } = this.options; if (event.target instanceof HTMLElement) { if (event.target.className.includes('g6-contextmenu-li')) { const value = event.target.getAttribute('value') as string; onClick?.(value, event.target, this.targetElement!); this.hide(); } } if (trigger !== 'click') this.hide(); }; } ================================================ FILE: packages/g6/src/plugins/contextmenu/util.ts ================================================ /** * 右键菜单显示项目。 * The item of the right-click menu. */ export type Item = { /** * 菜单项显示的名字。 * The name of the menu item. */ name: string; /** * 菜单项对应的值。 * The value corresponding to the menu item. */ value: string; }; /** * Get the content of the right-click menu. * @param items - context menu items * @returns HTML string */ export function getContentFromItems(items: Item[]) { return `
    ${items.map((item) => `
  • ${item.name}
  • `).join('')}
`; } /** * Style of the right-click menu, same with `tooltip`. */ export const CONTEXTMENU_CSS = ` .g6-contextmenu { font-size: 12px; background-color: rgba(255, 255, 255, 0.96); border-radius: 4px; overflow: hidden; box-shadow: rgba(0, 0, 0, 0.12) 0px 6px 12px 0px; transition: visibility 0.2s cubic-bezier(0.23, 1, 0.32, 1) 0s, left 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s, top 0.4s cubic-bezier(0.23, 1, 0.32, 1) 0s; } .g6-contextmenu-ul { max-width: 256px; min-width: 96px; list-style: none; padding: 0; margin: 0; } .g6-contextmenu-li { padding: 8px 12px; cursor: pointer; user-select: none; } .g6-contextmenu-li:hover { background-color: #f5f5f5; cursor: pointer; } `; ================================================ FILE: packages/g6/src/plugins/edge-bundling/index.ts ================================================ import { isEmpty } from '@antv/util'; import { GraphEvent } from '../../constants'; import type { RuntimeContext } from '../../runtime/types'; import type { EdgeData } from '../../spec'; import type { ID, Point } from '../../types'; import { getPolylinePath } from '../../utils/edge'; import { idOf } from '../../utils/id'; import { positionOf } from '../../utils/position'; import { add, distance, divide, dot, multiply, subtract, toVector2 } from '../../utils/vector'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; /** * 边绑定插件的配置项 * * Edge bundling options */ export interface EdgeBundlingOptions extends BasePluginOptions { /** * 边的强度 * * The strength of the edge * @defaultValue 0.1 */ K?: number; /** * 初始步长。在后续的周期中,步长将双倍递增 * * An initial step size. In subsequent cycles, the step size will double incrementally * @defaultValue 0.1 */ lambda?: number; /** * 模拟周期数 * * The number of simulation cycles * @defaultValue 6 */ cycles?: number; /** * 初始切割点数。在后续的周期中,切割点数将根据 `divRate` 逐步递增 * * An initial number of subdivision points for each edge. In subsequent cycles, the number of subdivision points will increase gradually according to `divRate` * @defaultValue 1 */ divisions?: number; /** * 切割点数增长率 * * The rate at which the number of subdivision points increases * @defaultValue 2 */ divRate?: number; /** * 指定在第一个周期中执行的迭代次数。在后续的周期中,迭代次数将根据 `iterRate` 逐步递减 * * The number of iteration steps during the first cycle. In subsequent cycles, the number of iterations will decrease gradually according to `iterRate` * @defaultValue 90 */ iterations?: number; /** * 迭代次数递减率 * * The rate at which the number of iterations decreases * @defaultValue 2 / 3 */ iterRate?: number; /** * 边兼容性阈值,决定了哪些边应该被绑定在一起 * * Edge compatibility threshold, which determines which edges should be bundled together * @defaultValue 0.6 */ bundleThreshold?: number; } /** * 边绑定 * * Edge bundling * @remarks * 边绑定(Edge Bundling)是一种图可视化技术,用于减少复杂网络图中的视觉混乱,并揭示图中的高级别模式和结构。其思想是将相邻的边捆绑在一起。 * * G6 中提供的边绑定插件是基于 FEDB(Force-Directed Edge Bundling for Graph Visualization)一文的实现:将边建模为可以相互吸引的柔性弹簧,通过自组织的方式进行捆绑。 * * Edge bundling is a graph visualization technique used to reduce visual clutter in complex network graphs and reveal high-level patterns and structures in the graph. The idea is to bundle adjacent edges together. * * The edge bundling plugin provided in G6 is based on the implementation of the paper FEDB (Force-Directed Edge Bundling for Graph Visualization): modeling edges as flexible springs that can attract each other and bundling them in a self-organizing way. */ export class EdgeBundling extends BasePlugin { static defaultOptions: Partial = { K: 0.1, lambda: 0.1, divisions: 1, divRate: 2, cycles: 6, iterations: 90, iterRate: 2 / 3, bundleThreshold: 0.6, }; constructor(context: RuntimeContext, options?: EdgeBundlingOptions) { super(context, Object.assign({}, EdgeBundling.defaultOptions, options)); this.bindEvents(); } private edgeBundles: Record = {}; private edgePoints: Record = {}; private get nodeMap(): Record { const nodes = this.context.model.getNodeData(); return Object.fromEntries(nodes.map((node) => [idOf(node), toVector2(positionOf(node))])); } private divideEdges(divisions: number) { const edges = this.context.model.getEdgeData(); edges.forEach((edge) => { const edgeId = idOf(edge); this.edgePoints[edgeId] ||= []; const source = this.nodeMap[edge.source]; const target = this.nodeMap[edge.target]; if (divisions === 1) { this.edgePoints[edgeId].push(source); this.edgePoints[edgeId].push(divide(add(source, target), 2)); this.edgePoints[edgeId].push(target); } else { const edgeLength = this.edgePoints[edgeId].length === 0 ? // edge is a straight line distance(source, target) : // edge is a polyline getEdgeLength(this.edgePoints[edgeId]); const divisionLength = edgeLength / (divisions + 1); let currentDivisionLength = divisionLength; const newEdgePoints: Point[] = [source]; for (let i = 1; i < this.edgePoints[edgeId].length; i++) { const prevEp = this.edgePoints[edgeId][i - 1]; const ep = this.edgePoints[edgeId][i]; let oriDivisionLength = distance(ep, prevEp); while (oriDivisionLength > currentDivisionLength) { const ratio = currentDivisionLength / oriDivisionLength; const edgePoint = add(prevEp, multiply(subtract(ep, prevEp), ratio)); newEdgePoints.push(edgePoint); oriDivisionLength -= currentDivisionLength; currentDivisionLength = divisionLength; } currentDivisionLength -= oriDivisionLength; } newEdgePoints.push(target); this.edgePoints[edgeId] = newEdgePoints; } }); } private getVectorPosition(edge: EdgeData): VectorPosition { const source = this.nodeMap[edge.source]; const target = this.nodeMap[edge.target]; const [vx, vy] = subtract(target, source); const length = distance(source, target); return { source, target, vx, vy, length }; } private measureEdgeCompatibility(edge1: EdgeData, edge2: EdgeData) { const vector1 = this.getVectorPosition(edge1); const vector2 = this.getVectorPosition(edge2); const ac = getAngleCompatibility(vector1, vector2); const sc = getScaleCompatibility(vector1, vector2); const pc = getPositionCompatibility(vector1, vector2); const vc = getVisibilityCompatibility(vector1, vector2); return ac * sc * pc * vc; } private getEdgeBundles() { const edgeBundles: Record = {}; const bundleThreshold = this.options.bundleThreshold; const edges = this.context.model.getEdgeData(); edges.forEach((edge1, i) => { edges.forEach((edge2, j) => { if (j <= i) return; const compatibility = this.measureEdgeCompatibility(edge1, edge2); if (compatibility >= bundleThreshold) { edgeBundles[idOf(edge1)] ||= []; edgeBundles[idOf(edge1)].push(edge2); edgeBundles[idOf(edge2)] ||= []; edgeBundles[idOf(edge2)].push(edge1); } }); }); return edgeBundles; } private getSpringForce(divisions: { pre: Point; cur: Point; next: Point }, kp: number): Point { const { pre, cur, next } = divisions; return multiply(subtract(add(pre, next), multiply(cur, 2)), kp); } private getElectrostaticForce(pidx: number, edge: EdgeData): Point { if (isEmpty(this.edgeBundles)) { this.edgeBundles = this.getEdgeBundles(); } const edgeBundle = this.edgeBundles[idOf(edge)]; let resForce: Point = [0, 0]; edgeBundle?.forEach((eb) => { const p1 = this.edgePoints[idOf(eb)][pidx]; const p2 = this.edgePoints[idOf(edge)][pidx]; const force = subtract(p1, p2); const length = distance(p1, p2); resForce = add(resForce, multiply(force, 1 / length)); }); return resForce; } private getEdgeForces(edge: EdgeData, divisions: number, lambda: number): Point[] { const source = this.nodeMap[edge.source]; const target = this.nodeMap[edge.target]; const kp = this.options.K / (distance(source, target) * (divisions + 1)); const edgePointForces: Point[] = [[0, 0]]; const edgeId = idOf(edge); for (let i = 1; i < divisions; i++) { const spring = this.getSpringForce( { pre: this.edgePoints[edgeId][i - 1], cur: this.edgePoints[edgeId][i], next: this.edgePoints[edgeId][i + 1] || [0, 0], }, kp, ); const electrostatic = this.getElectrostaticForce(i, edge); edgePointForces.push(multiply(add(spring, electrostatic), lambda)); } edgePointForces.push([0, 0]); return edgePointForces; } protected onBundle = () => { const { model, element } = this.context; const edges = model.getEdgeData(); this.divideEdges(this.options.divisions); const { cycles, iterRate, divRate } = this.options; let { lambda, divisions, iterations } = this.options; for (let i = 0; i < cycles; i++) { for (let j = 0; j < iterations; j++) { const forces: Record = {}; edges.forEach((edge) => { if (edge.source === edge.target) return; const edgeId = idOf(edge); forces[edgeId] = this.getEdgeForces(edge, divisions, lambda); for (let p = 0; p < divisions + 1; p++) { this.edgePoints[edgeId] ||= []; this.edgePoints[edgeId][p] = add(this.edgePoints[edgeId][p], forces[edgeId][p]); } }); } // parameters for next cycle lambda /= 2; divisions *= divRate; iterations *= iterRate; this.divideEdges(divisions); } edges.forEach((edge) => { const edgeId = idOf(edge); const edgeEl = element!.getElement(edgeId); edgeEl?.update({ d: getPolylinePath(this.edgePoints[edgeId]) }); }); }; private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.AFTER_RENDER, this.onBundle); } private unbindEvents() { const { graph } = this.context; graph.off(GraphEvent.AFTER_RENDER, this.onBundle); } public destroy(): void { this.unbindEvents(); super.destroy(); } } interface VectorPosition { source: Point; target: Point; vx: number; vy: number; length: number; } // The larger the angle between edges P and Q, the smaller Ca(P,Q). // Ca(P,Q) is 0 if P and Q are orthogonal and 1 if P and Q are parallel. const getAngleCompatibility = (p: VectorPosition, q: VectorPosition): number => { return Math.abs(dot([p.vx, p.vy], [q.vx, q.vy]) / (p.length * q.length)); }; // Cs(P,Q) is 1 if P and Q have equal length and approaches 0 if the ratio between the longest and the shortest edge approaches ∞. const getScaleCompatibility = (p: VectorPosition, q: VectorPosition): number => { const aLength = (p.length + q.length) / 2; return 2 / (aLength / Math.min(p.length, q.length) + Math.max(p.length, q.length) / aLength); }; // Cp(P,Q) is 1 if Pm and Qm coincide and approaches 0 if ||Pm −Qm|| approaches ∞. const getPositionCompatibility = (p: VectorPosition, q: VectorPosition): number => { const aLength = (p.length + q.length) / 2; const pMid = divide(add(p.source, p.target), 2); const qMid = divide(add(q.source, q.target), 2); return aLength / (aLength + distance(pMid, qMid)); }; const projectPointToEdge = (p: Point, e: VectorPosition): Point => { if (e.source[0] === e.target[0]) return [e.source[0], p[1]]; if (e.source[1] === e.target[1]) return [p[0], e.source[1]]; const k = (e.source[1] - e.target[1]) / (e.source[0] - e.target[0]); const x = (k * k * e.source[0] + k * (p[1] - e.source[1]) + p[0]) / (k * k + 1); const y = k * (x - e.source[0]) + e.source[1]; return [x, y]; }; const getEdgeVisibility = (p: VectorPosition, q: VectorPosition): number => { const is = projectPointToEdge(q.source, p); const it = projectPointToEdge(q.target, p); const iMid = divide(add(is, it), 2); const pMid = divide(add(p.source, p.target), 2); if (distance(is, it) === 0) return 0; return Math.max(0, 1 - (2 * distance(pMid, iMid)) / distance(is, it)); }; const getVisibilityCompatibility = (p: VectorPosition, q: VectorPosition): number => { return Math.min(getEdgeVisibility(p, q), getEdgeVisibility(q, p)); }; /** * Calculate the length of a polyline * @param points - The points of the polyline * @returns The length of the polyline */ const getEdgeLength = (points: Point[]): number => { let length = 0; for (let i = 1; i < points.length; i++) { length += distance(points[i], points[i - 1]); } return length; }; ================================================ FILE: packages/g6/src/plugins/edge-filter-lens/index.ts ================================================ import { CommonEvent } from '../../constants'; import { Circle, type CircleStyleProps } from '../../elements'; import type { RuntimeContext } from '../../runtime/types'; import type { EdgeData, GraphData, NodeData } from '../../spec'; import type { EdgeStyle } from '../../spec/element/edge'; import type { NodeStyle } from '../../spec/element/node'; import type { Element, ElementDatum, ElementType, ID, IDragEvent, IPointerEvent, Point, PointObject, } from '../../types'; import { idOf } from '../../utils/id'; import { parsePoint, toPointObject } from '../../utils/point'; import { positionOf } from '../../utils/position'; import { distance } from '../../utils/vector'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; /** * 边过滤镜插件配置项 * * Edge filter lens plugin options */ export interface EdgeFilterLensOptions extends BasePluginOptions { /** * 移动透镜的方式 * - `'pointermove'`:始终跟随鼠标移动 * - `'click'`:鼠标点击时透镜移动 * - `'drag'`:拖拽透镜 * * The way to move the lens * - `'pointermove'`: always follow the mouse movement * - `'click'`: move the lens when the mouse clicks * - `'drag'`: drag the lens * @defaultValue 'pointermove' */ trigger?: 'pointermove' | 'click' | 'drag'; /** * 透镜的半径 * * The radius of the lens * @defaultValue 60 */ r?: number; /** * 透镜的最大半径。只有在 `scaleRBy` 为 `wheel` 时生效 * * The maximum radius of the lens. Only valid when `scaleRBy` is `wheel` * @defaultValue canvas 宽高最小值的一半 */ maxR?: number; /** * 透镜的最小半径。只有在 `scaleRBy` 为 `wheel` 时生效 * * The minimum radius of the lens. Only valid when `scaleRBy` is `wheel` * @defaultValue 0 */ minR?: number; /** * 缩放透镜半径的方式 * - `'wheel'`:通过滚轮缩放透镜的半径 * * The way to scale the radius of the lens * - `'wheel'`: scale the radius of the lens by the wheel * @defaultValue `'wheel'` */ scaleRBy?: 'wheel'; /** * 边显示的条件 * - `'both'`:只有起始节点和目标节点都在透镜中时,边才会显示 * - `'source'`:只有起始节点在透镜中时,边才会显示 * - `'target'`:只有目标节点在透镜中时,边才会显示 * - `'either'`:只要起始节点或目标节点有一个在透镜中时,边就会显示 * * The condition for displaying the edge * - `'both'`: The edge is displayed only when both the source node and the target node are in the lens * - `'source'`: The edge is displayed only when the source node is in the lens * - `'target'`: The edge is displayed only when the target node is in the lens * - `'either'`: The edge is displayed when either the source node or the target node is in the lens * @defaultValue 'both' */ nodeType?: 'both' | 'source' | 'target' | 'either'; /** * 过滤出始终不在透镜中显示的元素 * * Filter elements that are never displayed in the lens * @param id - 元素的 id | The id of the element * @param elementType - 元素的类型 | The type of the element * @returns 是否显示 | Whether to display */ filter?: (id: ID, elementType: ElementType) => boolean; /** * 透镜的样式 * * The style of the lens */ style?: Partial; /** * 在透镜中节点的样式 * * The style of the nodes displayed in the lens */ nodeStyle?: NodeStyle | ((datum: NodeData) => NodeStyle); /** * 在透镜中边的样式 * * The style of the edges displayed in the lens */ edgeStyle?: EdgeStyle | ((datum: EdgeData) => EdgeStyle); /** * 是否阻止默认事件 * * Whether to prevent the default event * @defaultValue true */ preventDefault?: boolean; } const defaultLensStyle: Exclude = { fill: '#fff', fillOpacity: 1, lineWidth: 1, stroke: '#000', strokeOpacity: 0.8, zIndex: -Infinity, }; const DELTA = 0.05; /** * 边过滤镜插件 * * Edge filter lens plugin * @remarks * 边过滤镜可以将关注的边保留在过滤镜范围内,其他边将在该范围内不显示。 * * EdgeFilterLens can keep the focused edges within the lens range, while other edges will not be displayed within that range. */ export class EdgeFilterLens extends BasePlugin { static defaultOptions: Partial = { trigger: 'pointermove', r: 60, nodeType: 'both', filter: () => true, style: { lineWidth: 2 }, nodeStyle: { label: false }, edgeStyle: { label: true }, scaleRBy: 'wheel', preventDefault: true, }; constructor(context: RuntimeContext, options: EdgeFilterLensOptions) { super(context, Object.assign({}, EdgeFilterLens.defaultOptions, options)); this.bindEvents(); } private lens!: Circle; private shapes = new Map(); private r = this.options.r; private get canvas() { return this.context.canvas.getLayer('transient'); } private get isLensOn() { return this.lens && !this.lens.destroyed; } protected onEdgeFilter = (event: IPointerEvent) => { if (this.options.trigger === 'drag' && this.isLensOn) return; const origin = parsePoint(event.canvas as PointObject); this.renderLens(origin); this.renderFocusElements(); }; private renderLens = (origin: Point) => { const style = Object.assign({}, defaultLensStyle, this.options.style); if (!this.isLensOn) { this.lens = new Circle({ style }); this.canvas.appendChild(this.lens); } Object.assign(style, toPointObject(origin), { size: this.r * 2 }); this.lens.update(style); }; private getFilterData = (): 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 getFocusElements = (origin: Point) => { const { nodes, edges } = this.getFilterData(); const focusNodes = nodes.filter((datum) => distance(positionOf(datum), origin) < this.r); const focusNodeIds = focusNodes.map((node) => idOf(node)); const focusEdges = edges.filter((datum) => { const { source, target } = datum; const isSourceFocus = focusNodeIds.includes(source); const isTargetFocus = focusNodeIds.includes(target); switch (this.options.nodeType) { case 'both': return isSourceFocus && isTargetFocus; case 'either': return isSourceFocus !== isTargetFocus; case 'source': return isSourceFocus && !isTargetFocus; case 'target': return !isSourceFocus && isTargetFocus; default: return false; } }); return { nodes: focusNodes, edges: focusEdges }; }; private renderFocusElements = () => { const { element, graph } = this.context; if (!this.isLensOn) return; const origin = this.lens.getCenter(); const { nodes, edges } = this.getFocusElements(origin); const ids = new Set(); const iterate = (datum: ElementDatum) => { const id = idOf(datum); ids.add(id); const shape = element!.getElement(id); if (!shape) return; const cloneShape = this.shapes.get(id) || shape.cloneNode(); cloneShape.setPosition(shape.getPosition()); cloneShape.id = shape.id; if (!this.shapes.has(id)) { this.canvas.appendChild(cloneShape); this.shapes.set(id, cloneShape); } else { Object.entries(shape.attributes).forEach(([key, value]) => { if (cloneShape.style[key] !== value) cloneShape.style[key] = value; }); } const elementType = graph.getElementType(id) as Exclude; const style = this.getElementStyle(elementType, datum); // @ts-ignore cloneShape.update(style); }; nodes.forEach(iterate); edges.forEach(iterate); this.shapes.forEach((shape, id) => { if (!ids.has(id)) { shape.destroy(); this.shapes.delete(id); } }); }; private getElementStyle(elementType: ElementType, datum: ElementDatum) { const styler = elementType === 'node' ? this.options.nodeStyle : this.options.edgeStyle; if (typeof styler === 'function') return styler(datum as any); return styler; } private scaleRByWheel = (event: WheelEvent) => { if (this.options.preventDefault) event.preventDefault(); const { clientX, clientY, deltaX, deltaY } = event; const { graph, canvas } = this.context; const scaleOrigin = graph.getCanvasByClient([clientX, clientY]); const origin = this.lens?.getCenter(); if (!this.isLensOn || distance(scaleOrigin, origin) > this.r) { return; } const { maxR, minR } = this.options; const ratio = deltaX + deltaY > 0 ? 1 / (1 - DELTA) : 1 - DELTA; const canvasR = Math.min(...canvas.getSize()) / 2; this.r = Math.max(minR || 0, Math.min(maxR || canvasR, this.r * ratio)); this.renderLens(origin); this.renderFocusElements(); }; get graphDom() { return this.context.graph.getCanvas().getContextService().getDomElement(); } private isLensDragging = false; private onDragStart = (event: IDragEvent) => { const dragOrigin = parsePoint(event.canvas as PointObject); const origin = this.lens?.getCenter(); if (!this.isLensOn || distance(dragOrigin, origin) > this.r) return; this.isLensDragging = true; }; private onDrag = (event: IDragEvent) => { if (!this.isLensDragging) return; const dragOrigin = parsePoint(event.canvas as PointObject); this.renderLens(dragOrigin); this.renderFocusElements(); }; private onDragEnd = () => { this.isLensDragging = false; }; private bindEvents() { const { graph } = this.context; const { trigger, scaleRBy } = this.options; const canvas = graph.getCanvas().getLayer(); if (['click', 'drag'].includes(trigger)) { canvas.addEventListener(CommonEvent.CLICK, this.onEdgeFilter); } if (trigger === 'pointermove') { canvas.addEventListener(CommonEvent.POINTER_MOVE, this.onEdgeFilter); } else if (trigger === 'drag') { canvas.addEventListener(CommonEvent.DRAG_START, this.onDragStart); canvas.addEventListener(CommonEvent.DRAG, this.onDrag); canvas.addEventListener(CommonEvent.DRAG_END, this.onDragEnd); } if (scaleRBy === 'wheel') { this.graphDom?.addEventListener(CommonEvent.WHEEL, this.scaleRByWheel, { passive: false }); } } private unbindEvents() { const { graph } = this.context; const { trigger, scaleRBy } = this.options; const canvas = graph.getCanvas().getLayer(); if (['click', 'drag'].includes(trigger)) { canvas.removeEventListener(CommonEvent.CLICK, this.onEdgeFilter); } if (trigger === 'pointermove') { canvas.removeEventListener(CommonEvent.POINTER_MOVE, this.onEdgeFilter); } else if (trigger === 'drag') { canvas.removeEventListener(CommonEvent.DRAG_START, this.onDragStart); canvas.removeEventListener(CommonEvent.DRAG, this.onDrag); canvas.removeEventListener(CommonEvent.DRAG_END, this.onDragEnd); } if (scaleRBy === 'wheel') { this.graphDom?.removeEventListener(CommonEvent.WHEEL, this.scaleRByWheel); } } public update(options: Partial) { this.unbindEvents(); super.update(options); this.r = options.r ?? this.r; this.bindEvents(); } public destroy() { this.unbindEvents(); if (this.isLensOn) { this.lens.destroy(); } this.shapes.forEach((shape, id) => { shape.destroy(); this.shapes.delete(id); }); super.destroy(); } } ================================================ FILE: packages/g6/src/plugins/fisheye/index.ts ================================================ import { pick } from '@antv/util'; import { CommonEvent } from '../../constants'; import type { CircleStyleProps } from '../../elements'; import { Circle } from '../../elements'; import type { RuntimeContext } from '../../runtime/types'; import type { NodeData } from '../../spec'; import type { NodeStyle } from '../../spec/element/node'; import type { ID, IDragEvent, IPointerEvent, Node, Point, PointObject } from '../../types'; import { arrayDiff } from '../../utils/diff'; import { idOf } from '../../utils/id'; import { parsePoint, toPointObject } from '../../utils/point'; import { positionOf } from '../../utils/position'; import { distance } from '../../utils/vector'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; /** * 鱼眼放大镜插件配置项 * * Fisheye Plugin Options */ export interface FisheyeOptions extends BasePluginOptions { /** * 移动鱼眼放大镜的方式 * - `'pointermove'`:始终跟随鼠标移动 * - `'click'`:鼠标点击时移动 * - `'drag'`:拖拽移动 * * The way to move the fisheye lens * - `'pointermove'`: always follow the mouse movement * - `'click'`: move when the mouse is clicked * - `'drag'`: move by dragging * @defaultValue `'pointermove'` */ trigger?: 'pointermove' | 'drag' | 'click'; /** * 鱼眼放大镜半径 * * The radius of the fisheye lens * @remarks * * @defaultValue 120 */ r?: number; /** * 鱼眼放大镜可调整的最大半径,配合 `scaleRBy` 使用 * * The maximum radius that the fisheye lens can be adjusted, used with `scaleRBy` * @defaultValue 画布宽高的最小值的一半 */ maxR?: number; /** * 鱼眼放大镜可调整的最小半径,配合 `scaleRBy` 使用 * * The minimum radius that the fisheye lens can be adjusted, used with `scaleRBy` * @defaultValue 0 */ minR?: number; /** * 调整鱼眼放大镜范围半径的方式 * - `'wheel'`:滚轮调整 * - `'drag'`:拖拽调整 * * The way to adjust the range radius of the fisheye lens * - `'wheel'`: adjust by wheel * - `'drag'`: adjust by drag * @remarks * 如果 `trigger`、`scaleRBy` 和 `scaleDBy` 同时设置为 `'drag'`,优先级顺序为 `trigger` > `scaleRBy` > `scaleDBy`,只会为优先级最高的配置项绑定拖拽事件。同理,如果 `scaleRBy` 和 `scaleDBy` 同时设置为 `'wheel'`,只会为 `scaleRBy` 绑定滚轮事件 * * If `trigger`, `scaleRBy`, and `scaleDBy` are set to `'drag'` at the same time, the priority order is `trigger` > `scaleRBy` > `scaleDBy`, and only the configuration item with the highest priority will be bound to the drag event. Similarly, if `scaleRBy` and `scaleDBy` are set to `'wheel'` at the same time, only `scaleRBy` will be bound to the wheel event */ scaleRBy?: 'wheel' | 'drag'; /** * 畸变因子 * * Distortion factor * @remarks * * @defaultValue 1.5 */ d?: number; /** * 鱼眼放大镜可调整的最大畸变因子,配合 `scaleDBy` 使用 * * The maximum distortion factor that the fisheye lens can be adjusted, used with `scaleDBy` * @defaultValue 5 */ maxD?: number; /** * 鱼眼放大镜可调整的最小畸变因子,配合 `scaleDBy` 使用 * * The minimum distortion factor that the fisheye lens can be adjusted, used with `scaleDBy` * @defaultValue 0 */ minD?: number; /** * 调整鱼眼放大镜畸变因子的方式 * - `'wheel'`:滚轮调整 * - `'drag'`:拖拽调整 * * The way to adjust the distortion factor of the fisheye lens * - `'wheel'`: adjust by wheel * - `'drag'`: adjust by drag */ scaleDBy?: 'wheel' | 'drag'; /** * 是否在鱼眼放大镜中显示畸变因子数值 * * Whether to display the value of the distortion factor in the fisheye lens * @defaultValue true */ showDPercent?: boolean; /** * 鱼眼放大镜样式 * * Fisheye Lens Style */ style?: Partial; /** * 在鱼眼放大镜中的节点样式 * * Node style in the fisheye lens */ nodeStyle?: NodeStyle | ((datum: NodeData) => NodeStyle); /** * 是否阻止默认事件 * * Whether to prevent the default event * @defaultValue true */ preventDefault?: boolean; } const defaultLensStyle: Exclude = { fill: '#ccc', fillOpacity: 0.1, lineWidth: 2, stroke: '#000', strokeOpacity: 0.8, labelFontSize: 12, }; const R_DELTA = 0.05; const D_DELTA = 0.1; /** * 鱼眼放大镜 * * Fisheye Distortion * @remarks * Fisheye 鱼眼放大镜是为 focus+context 的探索场景设计的,它能够保证在放大关注区域的同时,保证上下文以及上下文与关注中心的关系不丢失。 * * Fisheye is designed for focus+context exploration, it keeps the context and the relationships between context and the focus while magnifying the focus area. */ export class Fisheye extends BasePlugin { static defaultOptions: Partial = { trigger: 'pointermove', r: 120, d: 1.5, maxD: 5, minD: 0, showDPercent: true, style: {}, nodeStyle: { label: true }, preventDefault: true, }; constructor(context: RuntimeContext, options: FisheyeOptions) { super(context, Object.assign({}, Fisheye.defaultOptions, options)); this.bindEvents(); } private lens!: Circle; private r = this.options.r; private d = this.options.d; private get canvas() { return this.context.canvas.getLayer('transient'); } private get isLensOn() { return this.lens && !this.lens.destroyed; } protected onCreateFisheye = (event: IPointerEvent) => { if (this.options.trigger === 'drag' && this.isLensOn) return; const origin = parsePoint(event.canvas as PointObject); this.onMagnify(origin); }; protected onMagnify = (origin: Point) => { if (origin.some(isNaN)) return; this.renderLens(origin); this.renderFocusElements(); }; private renderLens = (origin: Point) => { const style = Object.assign({}, defaultLensStyle, this.options.style); if (!this.isLensOn) { this.lens = new Circle({ style }); this.canvas.appendChild(this.lens); } Object.assign(style, toPointObject(origin), { size: this.r * 2, label: this.options.showDPercent, labelText: this.getDPercent(), }); this.lens.update(style); }; private getDPercent = () => { const { minD, maxD } = this.options as Required; const percent = Math.round(((this.d - minD) / (maxD - minD)) * 100); return `${percent}%`; }; private prevMagnifiedStyleMap = new Map(); private prevOriginStyleMap = new Map(); private renderFocusElements = () => { if (!this.isLensOn) return; const { graph } = this.context; const origin = this.lens.getCenter(); const molecularParam = (this.d + 1) * this.r; const magnifiedStyleMap = new Map(); const originStyleMap = new Map(); const nodeData = graph.getNodeData(); nodeData.forEach((datum) => { const position = positionOf(datum); const distanceToOrigin = distance(position, origin); if (distanceToOrigin > this.r) return; const magnifiedDistance = (molecularParam * distanceToOrigin) / (this.d * distanceToOrigin + this.r); const [nodeX, nodeY] = position; const [originX, originY] = origin; const cos = (nodeX - originX) / distanceToOrigin; const sin = (nodeY - originY) / distanceToOrigin; const newPoint: Point = [originX + magnifiedDistance * cos, originY + magnifiedDistance * sin]; const nodeId = idOf(datum); const style = this.getNodeStyle(datum); const originStyle = pick(graph.getElementRenderStyle(nodeId), Object.keys(style)); magnifiedStyleMap.set(nodeId, { ...toPointObject(newPoint), ...style }); originStyleMap.set(nodeId, { ...toPointObject(position), ...originStyle }); }); this.updateStyle(magnifiedStyleMap, originStyleMap); }; private getNodeStyle = (datum: NodeData) => { const { nodeStyle } = this.options; return typeof nodeStyle === 'function' ? nodeStyle(datum) : nodeStyle; }; private updateStyle = (magnifiedStyleMap: Map, originStyleMap: Map) => { const { graph, element } = this.context; const { enter, exit, keep } = arrayDiff( Array.from(this.prevMagnifiedStyleMap.keys()), Array.from(magnifiedStyleMap.keys()), (d) => d, ); const relatedEdges = new Set(); const update = (nodeId: ID, style: NodeStyle) => { const node = element!.getElement(nodeId) as Node; node?.update(style); graph.getRelatedEdgesData(nodeId).forEach((datum) => { relatedEdges.add(idOf(datum)); }); }; [...enter, ...keep].forEach((nodeId) => { update(nodeId, magnifiedStyleMap.get(nodeId)!); }); exit.forEach((nodeId) => { update(nodeId, this.prevOriginStyleMap.get(nodeId)!); this.prevOriginStyleMap.delete(nodeId); }); relatedEdges.forEach((edgeId) => { const edge = element!.getElement(edgeId); edge?.update({}); }); this.prevMagnifiedStyleMap = magnifiedStyleMap; originStyleMap.forEach((style, nodeId) => { if (!this.prevOriginStyleMap.has(nodeId)) { this.prevOriginStyleMap.set(nodeId, style); } }); }; private isWheelValid = (event: WheelEvent) => { if (this.options.preventDefault) event.preventDefault(); if (!this.isLensOn) return false; const { clientX, clientY } = event; const scaleOrigin = this.context.graph.getCanvasByClient([clientX, clientY]); const origin = this.lens.getCenter(); if (distance(scaleOrigin, origin) > this.r) return false; return true; }; private scaleR = (positive: boolean) => { const { maxR, minR } = this.options; const ratio = positive ? 1 / (1 - R_DELTA) : 1 - R_DELTA; const canvasR = Math.min(...this.context.canvas.getSize()) / 2; this.r = Math.max(minR || 0, Math.min(maxR || canvasR, this.r * ratio)); }; private scaleD = (positive: boolean) => { const { maxD, minD } = this.options as Required; const newD = positive ? this.d + D_DELTA : this.d - D_DELTA; this.d = Math.max(minD, Math.min(maxD, newD)); }; private scaleRByWheel = (event: WheelEvent) => { if (!this.isWheelValid(event)) return; const { deltaX, deltaY } = event; this.scaleR(deltaX + deltaY > 0); const origin = this.lens.getCenter(); this.onMagnify(origin); }; private scaleDByWheel = (event: WheelEvent) => { if (!this.isWheelValid(event)) return; const { deltaX, deltaY } = event; this.scaleD(deltaX + deltaY > 0); const origin = this.lens.getCenter(); this.onMagnify(origin); }; private isDragValid = (event: IDragEvent) => { if (this.options.preventDefault) event.preventDefault(); if (!this.isLensOn) return false; const dragOrigin = parsePoint(event.canvas as PointObject); const origin = this.lens.getCenter(); if (distance(dragOrigin, origin) > this.r) return false; return true; }; private isLensDragging = false; private onDragStart = (event: IDragEvent) => { if (!this.isDragValid(event)) return; this.isLensDragging = true; }; private onDrag = (event: IDragEvent) => { if (!this.isLensDragging) return; const dragOrigin = parsePoint(event.canvas as PointObject); this.onMagnify(dragOrigin); }; private onDragEnd = () => { this.isLensDragging = false; }; private scaleRByDrag = (event: IDragEvent) => { if (!this.isLensDragging) return; const { dx, dy } = event; this.scaleR(dx - dy > 0); const origin = this.lens.getCenter(); this.onMagnify(origin); }; private scaleDByDrag = (event: IDragEvent) => { if (!this.isLensDragging) return; const { dx, dy } = event; this.scaleD(dx - dy > 0); const origin = this.lens.getCenter(); this.onMagnify(origin); }; get graphDom() { return this.context.graph.getCanvas().getContextService().getDomElement(); } private bindEvents() { const { graph } = this.context; const { trigger, scaleRBy, scaleDBy } = this.options; const canvas = graph.getCanvas().getLayer(); if (['click', 'drag'].includes(trigger)) { canvas.addEventListener(CommonEvent.CLICK, this.onCreateFisheye); } if (trigger === 'pointermove') { canvas.addEventListener(CommonEvent.POINTER_MOVE, this.onCreateFisheye); } if (trigger === 'drag' || scaleRBy === 'drag' || scaleDBy === 'drag') { canvas.addEventListener(CommonEvent.DRAG_START, this.onDragStart); canvas.addEventListener(CommonEvent.DRAG_END, this.onDragEnd); const dragFunc = trigger === 'drag' ? this.onDrag : scaleRBy === 'drag' ? this.scaleRByDrag : this.scaleDByDrag; canvas.addEventListener(CommonEvent.DRAG, dragFunc); } if (scaleRBy === 'wheel' || scaleDBy === 'wheel') { const wheelFunc = scaleRBy === 'wheel' ? this.scaleRByWheel : this.scaleDByWheel; this.graphDom?.addEventListener(CommonEvent.WHEEL, wheelFunc, { passive: false }); } } private unbindEvents() { const { graph } = this.context; const { trigger, scaleRBy, scaleDBy } = this.options; const canvas = graph.getCanvas().getLayer(); if (['click', 'drag'].includes(trigger)) { canvas.removeEventListener(CommonEvent.CLICK, this.onCreateFisheye); } if (trigger === 'pointermove') { canvas.removeEventListener(CommonEvent.POINTER_MOVE, this.onCreateFisheye); } if (trigger === 'drag' || scaleRBy === 'drag' || scaleDBy === 'drag') { canvas.removeEventListener(CommonEvent.DRAG_START, this.onDragStart); canvas.removeEventListener(CommonEvent.DRAG_END, this.onDragEnd); const dragFunc = trigger === 'drag' ? this.onDrag : scaleRBy === 'drag' ? this.scaleRByDrag : this.scaleDByDrag; canvas.removeEventListener(CommonEvent.DRAG, dragFunc); } if (scaleRBy === 'wheel' || scaleDBy === 'wheel') { const wheelFunc = scaleRBy === 'wheel' ? this.scaleRByWheel : this.scaleDByWheel; this.graphDom?.removeEventListener(CommonEvent.WHEEL, wheelFunc); } } public update(options: Partial) { this.unbindEvents(); super.update(options); this.r = options.r ?? this.r; this.d = options.d ?? this.d; this.bindEvents(); } public destroy() { this.unbindEvents(); if (this.isLensOn) { this.lens?.destroy(); } this.prevMagnifiedStyleMap.clear(); this.prevOriginStyleMap.clear(); super.destroy(); } } ================================================ FILE: packages/g6/src/plugins/fullscreen/index.ts ================================================ import type { RuntimeContext } from '../../runtime/types'; import { print } from '../../utils/print'; import type { ShortcutKey } from '../../utils/shortcut'; import { Shortcut } from '../../utils/shortcut'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; /** * 全屏配置项 * * Full screen options */ export interface FullscreenOptions extends BasePluginOptions { /** * 触发全屏的方式 * - `request` : 请求全屏 * - `exit` : 退出全屏 * * The way to trigger full screen * - `request`: request full screen * - `exit`: exit full screen */ trigger?: { request?: ShortcutKey; exit?: ShortcutKey; }; /** * 是否自适应画布尺寸,全屏后画布尺寸会自动适应屏幕尺寸 * * Whether to adapt the canvas size * @defaultValue true */ autoFit?: boolean; /** * 进入全屏后的回调 * * Callback after entering full screen */ onEnter?: () => void; /** * 退出全屏后的回调 * * Callback after exiting full screen */ onExit?: () => void; } /** * 全屏 * * Full screen */ export class Fullscreen extends BasePlugin { static defaultOptions: Partial = { trigger: {}, autoFit: true, }; private shortcut: Shortcut; private style: HTMLStyleElement; private $el = this.context.canvas.getContainer()!; private graphSize: [number, number] = [0, 0]; constructor(context: RuntimeContext, options: FullscreenOptions) { super(context, Object.assign({}, Fullscreen.defaultOptions, options)); this.shortcut = new Shortcut(context.graph); this.bindEvents(); this.style = document.createElement('style'); document.head.appendChild(this.style); this.style.innerHTML = ` :not(:root):fullscreen::backdrop { background: transparent; } `; } private bindEvents() { this.unbindEvents(); this.shortcut.unbindAll(); const { request = [], exit = [] } = this.options.trigger; this.shortcut.bind(request, this.request); this.shortcut.bind(exit, this.exit); const events = ['webkitfullscreenchange', 'mozfullscreenchange', 'fullscreenchange', 'MSFullscreenChange']; events.forEach((eventName) => { document.addEventListener(eventName, this.onFullscreenChange, false); }); } private unbindEvents() { this.shortcut.unbindAll(); const events = ['webkitfullscreenchange', 'mozfullscreenchange', 'fullscreenchange', 'MSFullscreenChange']; events.forEach((eventName) => { document.removeEventListener(eventName, this.onFullscreenChange, false); }); } private setGraphSize(fullScreen = true) { let width, height; if (fullScreen) { width = globalThis.screen?.width || 0; height = globalThis.screen?.height || 0; this.graphSize = this.context.graph.getSize(); } else { [width, height] = this.graphSize; } this.context.graph.setSize(width, height); this.context.graph.render(); } private onFullscreenChange = () => { const isFull = !!document.fullscreenElement; if (this.options.autoFit) this.setGraphSize(isFull); if (isFull) { this.options.onEnter?.(); } else { this.options.onExit?.(); } }; /** * 请求全屏 * * Request full screen */ public request() { if (document.fullscreenElement || !isFullscreenEnabled()) return; this.$el.requestFullscreen().catch((err: Error) => { print.warn(`Error attempting to enable full-screen: ${err.message} (${err.name})`); }); } /** * 退出全屏 * * Exit full screen */ public exit() { if (!document.fullscreenElement) return; document.exitFullscreen(); } /** * 更新配置 * * Update options * @param options - 配置项 | Options * @internal */ public update(options: Partial): void { this.unbindEvents(); super.update(options); this.bindEvents(); } public destroy(): void { this.exit(); this.style.remove(); super.destroy(); } } /** * 判断是否支持全屏 * * Determine whether full screen is enabled * @returns 是否支持全屏 | Whether full screen is enabled */ function isFullscreenEnabled() { return ( document.fullscreenEnabled || // 使用 Reflect 语法规避 ts 检查 | use Reflect to avoid ts checking Reflect.get(document, 'webkitFullscreenEnabled') || Reflect.get(document, 'mozFullscreenEnabled') || Reflect.get(document, 'msFullscreenEnabled') ); } ================================================ FILE: packages/g6/src/plugins/grid-line.ts ================================================ import { isBoolean } from '@antv/util'; import { GraphEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { IViewportEvent, Point } from '../types'; import { ViewportEvent } from '../utils/event'; import { add, mod, multiply } from '../utils/vector'; import { BasePlugin, BasePluginOptions } from './base-plugin'; import { createPluginContainer } from './utils/dom'; /** * 网格线配置项 * * Grid line options */ export interface GridLineOptions extends BasePluginOptions { /** * 网格线颜色 * * Grid line color * @defaultValue '#0001' */ stroke?: string; /** * 网格线宽 * * Grid line width * @defaultValue 1 */ lineWidth?: number | string; /** * 单个网格的大小 * * The size of a single grid * @defaultValue 20 */ size?: number; /** * 是否显示边框 * * Whether to show the border * @defaultValue true */ border?: boolean; /** * 边框线宽 * * Border line width * @defaultValue 1 */ borderLineWidth?: number; /** * 边框颜色 * * Border color * @defaultValue '#0001' * @remarks * 完整属性定义参考 [CSS border-color](https://developer.mozilla.org/zh-CN/docs/Web/CSS/border-color) * * Refer to [CSS border-color](https://developer.mozilla.org/en-US/docs/Web/CSS/border-color) for the complete property definition */ borderStroke?: string; /** * 边框样式 * * Border style * @defaultValue 'solid' * @remarks * 完整属性定义参考 [CSS border-style](https://developer.mozilla.org/zh-CN/docs/Web/CSS/border-style) * * Refer to [CSS border-style](https://developer.mozilla.org/en-US/docs/Web/CSS/border-style) for the complete property definition */ borderStyle?: string; /** * 是否跟随图移动 * * Whether to follow with the graph * @defaultValue false */ follow?: | boolean | { /** * 是否跟随图平移 * * Whether to follow the graph translation */ translate?: boolean; /** * 是否跟随图缩放 * * Whether to follow the graph zoom */ zoom?: boolean; }; } /** * 网格线 * * Grid line * @remarks * 网格线插件,多用于辅助绘图 * * Grid line plugin, often used to auxiliary drawing */ export class GridLine extends BasePlugin { static defaultOptions: Partial = { border: true, borderLineWidth: 1, borderStroke: '#eee', borderStyle: 'solid', lineWidth: 1, size: 20, stroke: '#eee', }; private $element: HTMLElement = createPluginContainer('grid-line', true); private offset: Point = [0, 0]; private currentScale: number = 1; private baseSize: number; constructor(context: RuntimeContext, options: GridLineOptions) { super(context, Object.assign({}, GridLine.defaultOptions, options)); const $container = this.context.canvas.getContainer()!; $container.prepend(this.$element); this.baseSize = this.options.size; this.updateStyle(); this.bindEvents(); } /** * 更新网格线配置 * * Update the configuration of the grid line * @param options - 配置项 | options * @internal */ public update(options: Partial) { super.update(options); if (options.size !== undefined) { this.baseSize = options.size; } this.updateStyle(); } private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.AFTER_TRANSFORM, this.onTransform); } private updateStyle() { const { stroke, lineWidth, border, borderLineWidth, borderStroke, borderStyle } = this.options; const scaledSize = this.baseSize * this.currentScale; Object.assign(this.$element.style, { border: border ? `${borderLineWidth}px ${borderStyle} ${borderStroke}` : 'none', backgroundImage: `linear-gradient(${stroke} ${lineWidth}px, transparent ${lineWidth}px), linear-gradient(90deg, ${stroke} ${lineWidth}px, transparent ${lineWidth}px)`, backgroundSize: `${scaledSize}px ${scaledSize}px`, backgroundRepeat: 'repeat', }); } private updateOffset(delta: Point) { const scaledSize = this.baseSize * this.currentScale; this.offset = mod(add(this.offset, delta), scaledSize); this.$element.style.backgroundPosition = `${this.offset[0]}px ${this.offset[1]}px`; } private followZoom = (event: IViewportEvent) => { const { data: { scale, origin }, } = event; if (!scale || (origin === undefined && this.context.viewport === undefined)) return; const prevScale = this.currentScale; this.currentScale = scale; const deltaScale = scale / prevScale; const positionOffset = multiply(origin || this.context.graph.getCanvasCenter(), 1 - deltaScale); const scaledSize = this.baseSize * scale; const scaledOffset = multiply(this.offset, deltaScale); const modulatedOffset = mod(scaledOffset, scaledSize); const newOffset = add(modulatedOffset, positionOffset); this.$element.style.backgroundSize = `${scaledSize}px ${scaledSize}px`; this.$element.style.backgroundPosition = `${newOffset[0]}px ${newOffset[1]}px`; this.offset = mod(newOffset, scaledSize); }; private followTranslate = (event: IViewportEvent) => { if (!this.options.follow) return; const { data: { translate }, } = event; if (translate) this.updateOffset(translate); }; private parseFollow(follow: GridLineOptions['follow']): { translate: boolean; zoom: boolean } { return isBoolean(follow) ? { translate: follow, zoom: follow } : { translate: follow?.translate ?? false, zoom: follow?.zoom ?? false }; } private onTransform = (event: ViewportEvent) => { const follow = this.parseFollow(this.options.follow); if (follow.zoom) this.followZoom(event); if (follow.translate) this.followTranslate(event); }; /** * 销毁网格线 * * Destroy the grid line * @internal */ public destroy(): void { this.context.graph.off(GraphEvent.AFTER_TRANSFORM, this.onTransform); this.$element.remove(); super.destroy(); } } ================================================ FILE: packages/g6/src/plugins/history/index.ts ================================================ import EventEmitter from '@antv/event-emitter'; import { GraphEvent } from '../../constants'; import { HistoryEvent } from '../../constants/events/history'; import type { RuntimeContext } from '../../runtime/types'; import { DataChange, Loosen } from '../../types'; import type { Command } from '../../types/history'; import type { GraphLifeCycleEvent } from '../../utils/event'; import { idsOf } from '../../utils/id'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { parseCommand } from './util'; /** * 历史记录配置项 * * History options */ export interface HistoryOptions extends BasePluginOptions { /** * 最多记录该数据长度的历史记录 * * The maximum number of history records * @defaultValue 0(不做限制) */ stackSize?: number; /** * 当一个命令被添加到 Undo/Redo 队列前被调用,如果该方法返回 false,那么这个命令将不会被添加到队列中。revert 为 true 时表示撤销操作,为 false 时表示重做操作 * * Called before a command is added to the Undo/Redo queue. If this method returns false, the command will not be added to the queue. revert is true for undo operations and false for redo operations */ beforeAddCommand?: (cmd: Command, revert: boolean) => boolean | void; /** * 当一个命令被添加到 Undo/Redo 队列后被调用。revert 为 true 时表示撤销操作,为 false 时表示重做操作 * * Called after a command is added to the Undo/Redo queue. revert is true for undo operations and false for redo operations */ afterAddCommand?: (cmd: Command, revert: boolean) => void; /** * 执行命令时的回调函数 * * Callback function when executing a command */ executeCommand?: (cmd: Command) => void; } /** * 历史记录 * * History * @remarks * 历史记录用于记录图的数据变化,支持撤销和重做等操作。 * * History is used to record data changes in the graph and supports operations such as undo and redo. */ export class History extends BasePlugin { static defaultOptions: Partial = { stackSize: 0, }; private emitter: EventEmitter; private batchChanges: DataChange[][] | null = null; private batchAnimation = false; public undoStack: Command[] = []; public redoStack: Command[] = []; private freezed = false; constructor(context: RuntimeContext, options: HistoryOptions) { super(context, Object.assign({}, History.defaultOptions, options)); this.emitter = new EventEmitter(); const { graph } = this.context; graph.on(GraphEvent.AFTER_DRAW, this.addCommand); graph.on(GraphEvent.BATCH_START, this.initBatchCommand); graph.on(GraphEvent.BATCH_END, this.addCommand); } /** * 是否可以执行撤销操作 * * Whether undo can be done * @returns 是否可以执行撤销操作 | Whether undo can be done */ public canUndo() { return this.undoStack.length > 0; } /** * 是否可以执行重做操作 * * Whether redo can be done * @returns 是否可以执行重做操作 | Whether redo can be done */ public canRedo() { return this.redoStack.length > 0; } /** * 执行撤销 * * Execute undo * @returns 返回当前实例 | Return the current instance */ public undo() { const cmd = this.undoStack.pop(); if (cmd) { this.executeCommand(cmd); const before = this.options.beforeAddCommand?.(cmd, false); if (before === false) return; this.redoStack.push(cmd); this.options.afterAddCommand?.(cmd, false); this.notify(HistoryEvent.UNDO, cmd); } return this; } /** * 执行重做 * * Execute redo * @returns 返回当前实例 | Return the current instance */ public redo() { const cmd = this.redoStack.pop(); if (cmd) { this.executeCommand(cmd, false); this.undoStackPush(cmd); this.notify(HistoryEvent.REDO, cmd); } return this; } /** * 执行撤销且不计入历史记录 * * Execute undo and do not record in history * @returns 返回当前实例 | Return the current instance */ public undoAndCancel() { const cmd = this.undoStack.pop(); if (cmd) { this.executeCommand(cmd, false); this.redoStack = []; this.notify(HistoryEvent.CANCEL, cmd); } return this; } private executeCommand = (cmd: Command, revert = true) => { this.freezed = true; this.options.executeCommand?.(cmd); const values = revert ? cmd.original : cmd.current; this.context.graph.addData(values.add); this.context.graph.updateData(values.update); this.context.graph.removeData(idsOf(values.remove, false)); this.context.element?.draw({ silence: true, animation: cmd.animation }); this.freezed = false; }; private addCommand = (event: GraphLifeCycleEvent) => { if (this.freezed) return; if (event.type === GraphEvent.AFTER_DRAW) { const { dataChanges = [], animation = true } = (event as GraphLifeCycleEvent).data; if (this.context.batch?.isBatching) { if (!this.batchChanges) return; this.batchChanges.push(dataChanges); this.batchAnimation &&= animation; return; } this.batchChanges = [dataChanges]; this.batchAnimation = animation; } this.undoStackPush(parseCommand(this.batchChanges!.flat(), this.batchAnimation, this.context)); this.notify(HistoryEvent.ADD, this.undoStack[this.undoStack.length - 1]); }; private initBatchCommand = (event: GraphLifeCycleEvent) => { const { initiate } = event.data; this.batchAnimation = false; if (initiate) { this.batchChanges = []; } else { const cmd = this.undoStack.pop(); if (!cmd) this.batchChanges = null; } }; private undoStackPush(cmd: Command): void { const { stackSize } = this.options; if (stackSize !== 0 && this.undoStack.length >= stackSize) { this.undoStack.shift(); } const before = this.options.beforeAddCommand?.(cmd, true); if (before === false) return; this.undoStack.push(cmd); this.options.afterAddCommand?.(cmd, true); } /** * 清空历史记录 * * Clear history */ public clear(): void { this.undoStack = []; this.redoStack = []; this.batchChanges = null; this.batchAnimation = false; this.notify(HistoryEvent.CLEAR, null); } private notify(event: Loosen, cmd: Command | null) { this.emitter.emit(event, { cmd }); this.emitter.emit(HistoryEvent.CHANGE, { cmd }); } /** * 监听历史记录事件 * * Listen to history events * @param event - 事件名称 | Event name * @param handler - 事件处理函数 | Event handler */ public on(event: Loosen, handler: (e: { cmd?: Command | null }) => void): void { this.emitter.on(event, handler); } /** * 销毁 * * Destroy * @internal */ public destroy(): void { const { graph } = this.context; graph.off(GraphEvent.AFTER_DRAW, this.addCommand); graph.off(GraphEvent.BATCH_START, this.initBatchCommand); graph.off(GraphEvent.BATCH_END, this.addCommand); this.emitter.off(); super.destroy(); this.undoStack = []; this.redoStack = []; } } ================================================ FILE: packages/g6/src/plugins/history/util.ts ================================================ import { isObject } from '@antv/util'; import type { RuntimeContext } from '../../runtime/types'; import type { DataChange, DataChanges, ElementDatum } from '../../types'; import type { Command } from '../../types/history'; import { inferDefaultValue } from '../../utils/animation'; import { groupByChangeType, reduceDataChanges } from '../../utils/change'; import { idOf } from '../../utils/id'; /** * 对齐两个对象的字段。若目标对象缺少字段,则会添加默认值。 * * Align the fields of two objects. If the target object lacks fields, default values will be added. * @param refObject - 参考对象 | Reference object * @param targetObject - 目标对象 | Target object */ export function alignFields(refObject: Record, targetObject: Record) { for (const key in refObject) { if (isObject(refObject[key]) && !Array.isArray(refObject[key]) && refObject[key] !== null) { if (!targetObject[key]) targetObject[key] = {}; alignFields(refObject[key], targetObject[key]); } else if (targetObject[key] === undefined) { targetObject[key] = inferDefaultValue(key); } } } /** * 解析数据变更为历史记录命令 * * Parse data changes into history commands * @param changes - 数据变更 | Data changes * @param animation - 是否开启动画 | Whether to enable animation * @param context - 运行时上下文 | Runtime context * @returns 历史记录命令 | History command */ export function parseCommand(changes: DataChange[], animation = false, context?: RuntimeContext): Command { const cmd = { animation, current: { add: {}, update: {}, remove: {} }, original: { add: {}, update: {}, remove: {} }, } as Command; const { add, update, remove } = groupByChangeType(reduceDataChanges(changes)); (['nodes', 'edges', 'combos'] as const).forEach((category) => { if (update[category]) { update[category].forEach((item: DataChanges['update'][typeof category][number]) => { const newValue = { ...item.value }; let newOriginal = { ...item.original }; if (context) { // 特殊处理:获取元素原始 color const itemType = context.graph.getElementType(idOf(item.original)); const colorKey = itemType === 'edge' ? 'stroke' : 'fill'; const style = context.element!.getElementComputedStyle(itemType, item.original); newOriginal = { ...item.original, style: { [colorKey]: style[colorKey], ...item.original.style }, } as ElementDatum; } alignFields(newValue, newOriginal); cmd.current.update[category] ||= []; (cmd.current.update[category] as ElementDatum[]).push(newValue); cmd.original.update[category] ||= []; (cmd.original.update[category] as ElementDatum[]).push(newOriginal); }); } if (add[category]) { add[category].forEach((item: DataChanges['add'][typeof category][number]) => { const newValue = { ...item.value }; cmd.current.add[category] ||= []; (cmd.current.add[category] as ElementDatum[]).push(newValue); cmd.original.remove[category] ||= []; (cmd.original.remove[category] as ElementDatum[]).push(newValue); }); } if (remove[category]) { remove[category].forEach((item: DataChanges['remove'][typeof category][number]) => { const newValue = { ...item.value }; cmd.current.remove[category] ||= []; (cmd.current.remove[category] as ElementDatum[]).push(newValue); cmd.original.add[category] ||= []; (cmd.original.add[category] as ElementDatum[]).push(newValue); }); } }); return cmd; } ================================================ FILE: packages/g6/src/plugins/hull/hull/format.ts ================================================ import type { Point } from '../../../types'; export type PointObject = Record; export type BBox = [number, number, number, number]; export type FormatTuple = [string, string]; export const formatUtil = { toXy(pointset: T[] | Point[], format?: FormatTuple): T[] | Point[] { if (!format) return [...pointset] as Point[]; const xProperty = format[0].slice(1); const yProperty = format[1].slice(1); return (pointset as T[]).map((pt) => [pt[xProperty], pt[yProperty]]) as Point[]; }, fromXy(coordinates: Point[], format?: FormatTuple): Point[] | PointObject[] { if (!format) return [...coordinates]; const xProperty = format[0].slice(1); const yProperty = format[1].slice(1); return coordinates.map(([x, y]) => ({ [xProperty]: x, [yProperty]: y, })); }, }; export type PointConverter = typeof formatUtil; ================================================ FILE: packages/g6/src/plugins/hull/hull/grid_handle.ts ================================================ import type { Point } from '../../../types'; import type { BBox } from './format'; export class Grid { private _cells: Point[][][] = []; private _cellSize: number; private _reverseCellSize: number; constructor(points: Point[], cellSize: number) { this._cellSize = cellSize; this._reverseCellSize = 1 / cellSize; for (const point of points) { const x = this.coordToCellNum(point[0]); const y = this.coordToCellNum(point[1]); if (!this._cells[x]) { this._cells[x] = []; } if (!this._cells[x][y]) { this._cells[x][y] = []; } this._cells[x][y].push(point); } } cellPoints(x: number, y: number): Point[] { return this._cells[x]?.[y] || []; } rangePoints(bbox: BBox): Point[] { const tlCellX = this.coordToCellNum(bbox[0]); const tlCellY = this.coordToCellNum(bbox[1]); const brCellX = this.coordToCellNum(bbox[2]); const brCellY = this.coordToCellNum(bbox[3]); const points: Point[] = []; for (let x = tlCellX; x <= brCellX; x++) { for (let y = tlCellY; y <= brCellY; y++) { const cell = this.cellPoints(x, y); for (const point of cell) { points.push(point); } } } return points; } removePoint(point: Point): Point[] { const cellX = this.coordToCellNum(point[0]); const cellY = this.coordToCellNum(point[1]); const cell = this._cells[cellX][cellY]; const index = cell.findIndex(([px, py]) => px === point[0] && py === point[1]); if (index > -1) { cell.splice(index, 1); } return cell; } private trunc(val: number): number { return Math.trunc(val); } coordToCellNum(x: number): number { return this.trunc(x * this._reverseCellSize); } extendBbox(bbox: BBox, scaleFactor: number): BBox { return [ bbox[0] - scaleFactor * this._cellSize, bbox[1] - scaleFactor * this._cellSize, bbox[2] + scaleFactor * this._cellSize, bbox[3] + scaleFactor * this._cellSize, ]; } } export function grid(points: Point[], cellSize: number): Grid { return new Grid(points, cellSize); } ================================================ FILE: packages/g6/src/plugins/hull/hull/index.ts ================================================ import type { Point } from '../../../types'; import type { BBox, FormatTuple } from './format'; import { formatUtil } from './format'; import type { Grid } from './grid_handle'; import { grid } from './grid_handle'; import { monotoneConvexHull2D as convexHull } from './monotone-convex-hull-2d'; import { segmentsIntersect as intersect } from './robust-segment-intersect'; function _filterDuplicates(pointset: Point[]) { const unique = [pointset[0]]; let lastPoint = pointset[0]; for (let i = 1; i < pointset.length; i++) { const currentPoint = pointset[i]; if (lastPoint[0] !== currentPoint[0] || lastPoint[1] !== currentPoint[1]) { unique.push(currentPoint); } lastPoint = currentPoint; } return unique; } function _sortByX(pointset: Point[]) { return pointset.sort(function (a, b) { return a[0] - b[0] || a[1] - b[1]; }); } function _sqLength(a: Point, b: Point) { return Math.pow(b[0] - a[0], 2) + Math.pow(b[1] - a[1], 2); } function _cos(o: Point, a: Point, b: Point) { const aShifted = [a[0] - o[0], a[1] - o[1]], bShifted = [b[0] - o[0], b[1] - o[1]], sqALen = _sqLength(o, a), sqBLen = _sqLength(o, b), dot = aShifted[0] * bShifted[0] + aShifted[1] * bShifted[1]; return dot / Math.sqrt(sqALen * sqBLen); } function _intersect(segment: [Point, Point], pointset: Point[]) { for (let i = 0; i < pointset.length - 1; i++) { const seg = [pointset[i], pointset[i + 1]]; if ( (segment[0][0] === seg[0][0] && segment[0][1] === seg[0][1]) || (segment[0][0] === seg[1][0] && segment[0][1] === seg[1][1]) ) { continue; } if (intersect(segment[0], segment[1], seg[0], seg[1])) { return true; } } return false; } function _occupiedArea(pointset: Point[]) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (let i = pointset.length - 1; i >= 0; i--) { if (pointset[i][0] < minX) { minX = pointset[i][0]; } if (pointset[i][1] < minY) { minY = pointset[i][1]; } if (pointset[i][0] > maxX) { maxX = pointset[i][0]; } if (pointset[i][1] > maxY) { maxY = pointset[i][1]; } } return [ maxX - minX, // width maxY - minY, // height ]; } function _bBoxAround(edge: [Point, Point]): BBox { return [ Math.min(edge[0][0], edge[1][0]), // left Math.min(edge[0][1], edge[1][1]), // top Math.max(edge[0][0], edge[1][0]), // right Math.max(edge[0][1], edge[1][1]), // bottom ]; } function _midPoint(edge: [Point, Point], innerPoints: Point[], convex: Point[]) { let point = null, angle1Cos = MAX_CONCAVE_ANGLE_COS, angle2Cos = MAX_CONCAVE_ANGLE_COS, a1Cos, a2Cos; for (let i = 0; i < innerPoints.length; i++) { a1Cos = _cos(edge[0], edge[1], innerPoints[i]); a2Cos = _cos(edge[1], edge[0], innerPoints[i]); if ( a1Cos > angle1Cos && a2Cos > angle2Cos && !_intersect([edge[0], innerPoints[i]], convex) && !_intersect([edge[1], innerPoints[i]], convex) ) { angle1Cos = a1Cos; angle2Cos = a2Cos; point = innerPoints[i]; } } return point; } function _concave( convex: Point[], maxSqEdgeLen: number, maxSearchArea: [number, number], grid: Grid, edgeSkipList: Set, ) { let midPointInserted = false; for (let i = 0; i < convex.length - 1; i++) { const edge: [Point, Point] = [convex[i], convex[i + 1]]; // generate a key in the format X0,Y0,X1,Y1 const keyInSkipList = edge[0][0] + ',' + edge[0][1] + ',' + edge[1][0] + ',' + edge[1][1]; if (_sqLength(edge[0], edge[1]) < maxSqEdgeLen || edgeSkipList.has(keyInSkipList)) { continue; } let scaleFactor = 0; let bBoxAround = _bBoxAround(edge); let bBoxWidth; let bBoxHeight; let midPoint; do { bBoxAround = grid.extendBbox(bBoxAround, scaleFactor); bBoxWidth = bBoxAround[2] - bBoxAround[0]; bBoxHeight = bBoxAround[3] - bBoxAround[1]; midPoint = _midPoint(edge, grid.rangePoints(bBoxAround), convex); scaleFactor++; } while (midPoint === null && (maxSearchArea[0] > bBoxWidth || maxSearchArea[1] > bBoxHeight)); if (bBoxWidth >= maxSearchArea[0] && bBoxHeight >= maxSearchArea[1]) { edgeSkipList.add(keyInSkipList); } if (midPoint !== null) { convex.splice(i + 1, 0, midPoint); grid.removePoint(midPoint); midPointInserted = true; } } if (midPointInserted) { return _concave(convex, maxSqEdgeLen, maxSearchArea, grid, edgeSkipList); } return convex; } export function hull(pointset: Point[], concavity: number, format?: FormatTuple): Point[] { const maxEdgeLen = concavity || 20; const points = _filterDuplicates(_sortByX(formatUtil.toXy(pointset, format) as Point[])); if (points.length < 4) { const concave = points.concat([points[0]]); return (format ? formatUtil.fromXy(concave, format) : concave) as Point[]; } const occupiedArea = _occupiedArea(points); const maxSearchArea: [number, number] = [ occupiedArea[0] * MAX_SEARCH_BBOX_SIZE_PERCENT, occupiedArea[1] * MAX_SEARCH_BBOX_SIZE_PERCENT, ]; const convex = convexHull(points) .reverse() .map((idx: number) => points[idx]); // ccw -> cw, indices -> points convex.push(convex[0]); const innerPoints = points.filter(function (pt) { return convex.indexOf(pt) < 0; }); const cellSize = Math.ceil(1 / (points.length / (occupiedArea[0] * occupiedArea[1]))); const concave = _concave(convex, Math.pow(maxEdgeLen, 2), maxSearchArea, grid(innerPoints, cellSize), new Set()); return (format ? formatUtil.fromXy(concave, format) : concave) as Point[]; } const MAX_CONCAVE_ANGLE_COS = Math.cos(90 / (180 / Math.PI)); // angle = 90 deg const MAX_SEARCH_BBOX_SIZE_PERCENT = 0.6; ================================================ FILE: packages/g6/src/plugins/hull/hull/monotone-convex-hull-2d.ts ================================================ 'use strict'; import type { Point } from '../../../types'; import robustOrientation from './robust-orientation'; const orient = robustOrientation[3]; export function monotoneConvexHull2D(points: Point[]): number[] { const n = points.length; // Handle special cases when the input contains fewer than 3 points if (n < 3) { const result = new Array(n); for (let i = 0; i < n; ++i) { result[i] = i; } // Special case for exactly 2 identical points if (n === 2 && points[0][0] === points[1][0] && points[0][1] === points[1][1]) { return [0]; } return result; } // Sort point indices along the x-axis (breaking ties with y-axis) const sorted = new Array(n); for (let i = 0; i < n; ++i) { sorted[i] = i; } sorted.sort((a, b) => { const d = points[a][0] - points[b][0]; if (d) { return d; } return points[a][1] - points[b][1]; }); // Construct the upper and lower hulls const lower: number[] = [sorted[0], sorted[1]]; const upper: number[] = [sorted[0], sorted[1]]; for (let i = 2; i < n; ++i) { const idx = sorted[i]; const p = points[idx]; // Insert into the lower hull let m = lower.length; while (m > 1 && orient(points[lower[m - 2]], points[lower[m - 1]], p) <= 0) { m -= 1; lower.pop(); } lower.push(idx); // Insert into the upper hull m = upper.length; while (m > 1 && orient(points[upper[m - 2]], points[upper[m - 1]], p) >= 0) { m -= 1; upper.pop(); } upper.push(idx); } // Merge the lower and upper hulls into the final result const result = new Array(upper.length + lower.length - 2); let ptr = 0; for (let i = 0, nl = lower.length; i < nl; ++i) { result[ptr++] = lower[i]; } for (let j = upper.length - 2; j > 0; --j) { result[ptr++] = upper[j]; } // Return the final convex hull return result; } ================================================ FILE: packages/g6/src/plugins/hull/hull/robust-orientation.ts ================================================ 'use strict'; import { scaleLinearExpansion as robustScale } from './robust-scale'; import { robustSubtract } from './robust-subtract'; import { linearExpansionSum as robustSum } from './robust-sum'; import { twoProduct } from './two-product'; type PointND = number[]; const NUM_EXPAND = 5; const EPSILON = 1.1102230246251565e-16; const ERR_BOUND_3 = (3.0 + 16.0 * EPSILON) * EPSILON; const ERR_BOUND_4 = (7.0 + 56.0 * EPSILON) * EPSILON; function orientation_3( sum: typeof robustSum, prod: typeof twoProduct, scale: typeof robustScale, sub: typeof robustSubtract, ): (m0: PointND, m1: PointND, m2: PointND) => number { return function orientation3Exact(m0, m1, m2) { const p = sum(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0]))); const n = sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])); const d = sub(p, n); return d[d.length - 1]; }; } function orientation_4( sum: typeof robustSum, prod: typeof twoProduct, scale: typeof robustScale, sub: typeof robustSubtract, ): (m0: PointND, m1: PointND, m2: PointND, m3: PointND) => number { return function orientation4Exact(m0, m1, m2, m3) { const p = sum( sum( scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m1[2]), sum( scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), -m2[2]), scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m3[2]), ), ), sum( scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m3[2]), ), ), ); const n = sum( sum( scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m0[2]), sum( scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), -m2[2]), scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), m3[2]), ), ), sum( scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m2[2]), ), ), ); const d = sub(p, n); return d[d.length - 1]; }; } function orientation_5( sum: typeof robustSum, prod: typeof twoProduct, scale: typeof robustScale, sub: typeof robustSubtract, ): (...args: PointND[]) => number { return function orientation5Exact(m0, m1, m2, m3, m4) { const p = sum( sum( sum( scale( sum( scale(sum(prod(m3[1], m4[0]), prod(-m4[1], m3[0])), m2[2]), sum( scale(sum(prod(m2[1], m4[0]), prod(-m4[1], m2[0])), -m3[2]), scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m4[2]), ), ), m1[3], ), sum( scale( sum( scale(sum(prod(m3[1], m4[0]), prod(-m4[1], m3[0])), m1[2]), sum( scale(sum(prod(m1[1], m4[0]), prod(-m4[1], m1[0])), -m3[2]), scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), m4[2]), ), ), -m2[3], ), scale( sum( scale(sum(prod(m2[1], m4[0]), prod(-m4[1], m2[0])), m1[2]), sum( scale(sum(prod(m1[1], m4[0]), prod(-m4[1], m1[0])), -m2[2]), scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m4[2]), ), ), m3[3], ), ), ), sum( scale( sum( scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m1[2]), sum( scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), -m2[2]), scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m3[2]), ), ), -m4[3], ), sum( scale( sum( scale(sum(prod(m3[1], m4[0]), prod(-m4[1], m3[0])), m1[2]), sum( scale(sum(prod(m1[1], m4[0]), prod(-m4[1], m1[0])), -m3[2]), scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), m4[2]), ), ), m0[3], ), scale( sum( scale(sum(prod(m3[1], m4[0]), prod(-m4[1], m3[0])), m0[2]), sum( scale(sum(prod(m0[1], m4[0]), prod(-m4[1], m0[0])), -m3[2]), scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), m4[2]), ), ), -m1[3], ), ), ), ), sum( sum( scale( sum( scale(sum(prod(m1[1], m4[0]), prod(-m4[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m4[0]), prod(-m4[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m4[2]), ), ), m3[3], ), sum( scale( sum( scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m3[2]), ), ), -m4[3], ), scale( sum( scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m1[2]), sum( scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), -m2[2]), scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m3[2]), ), ), m0[3], ), ), ), sum( scale( sum( scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m0[2]), sum( scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), -m2[2]), scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), m3[2]), ), ), -m1[3], ), sum( scale( sum( scale(sum(prod(m1[1], m3[0]), prod(-m3[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m3[2]), ), ), m2[3], ), scale( sum( scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m2[2]), ), ), -m3[3], ), ), ), ), ); const n = sum( sum( sum( scale( sum( scale(sum(prod(m3[1], m4[0]), prod(-m4[1], m3[0])), m2[2]), sum( scale(sum(prod(m2[1], m4[0]), prod(-m4[1], m2[0])), -m3[2]), scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m4[2]), ), ), m0[3], ), scale( sum( scale(sum(prod(m3[1], m4[0]), prod(-m4[1], m3[0])), m0[2]), sum( scale(sum(prod(m0[1], m4[0]), prod(-m4[1], m0[0])), -m3[2]), scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), m4[2]), ), ), -m2[3], ), ), sum( scale( sum( scale(sum(prod(m2[1], m4[0]), prod(-m4[1], m2[0])), m0[2]), sum( scale(sum(prod(m0[1], m4[0]), prod(-m4[1], m0[0])), -m2[2]), scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), m4[2]), ), ), m3[3], ), scale( sum( scale(sum(prod(m2[1], m3[0]), prod(-m3[1], m2[0])), m0[2]), sum( scale(sum(prod(m0[1], m3[0]), prod(-m3[1], m0[0])), -m2[2]), scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), m3[2]), ), ), -m4[3], ), ), ), sum( sum( scale( sum( scale(sum(prod(m2[1], m4[0]), prod(-m4[1], m2[0])), m1[2]), sum( scale(sum(prod(m1[1], m4[0]), prod(-m4[1], m1[0])), -m2[2]), scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m4[2]), ), ), m0[3], ), scale( sum( scale(sum(prod(m2[1], m4[0]), prod(-m4[1], m2[0])), m0[2]), sum( scale(sum(prod(m0[1], m4[0]), prod(-m4[1], m0[0])), -m2[2]), scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), m4[2]), ), ), -m1[3], ), ), sum( scale( sum( scale(sum(prod(m1[1], m4[0]), prod(-m4[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m4[0]), prod(-m4[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m4[2]), ), ), m2[3], ), scale( sum( scale(sum(prod(m1[1], m2[0]), prod(-m2[1], m1[0])), m0[2]), sum( scale(sum(prod(m0[1], m2[0]), prod(-m2[1], m0[0])), -m1[2]), scale(sum(prod(m0[1], m1[0]), prod(-m1[1], m0[0])), m2[2]), ), ), -m4[3], ), ), ), ); const d = sub(p, n); return d[d.length - 1]; }; } function orientation(n: number) { const fn = n === 3 ? orientation_3 : n === 4 ? orientation_4 : orientation_5; return fn(robustSum, twoProduct, robustScale, robustSubtract); } const orientation3Exact = orientation(3); const orientation4Exact = orientation(4); const CACHED: Array = [ function orientation0() { return 0; }, function orientation1() { return 0; }, function orientation2(a: PointND, b: PointND) { return b[0] - a[0]; }, function orientation3(a: PointND, b: PointND, c: PointND) { const l = (a[1] - c[1]) * (b[0] - c[0]); const r = (a[0] - c[0]) * (b[1] - c[1]); const det = l - r; let s: number; if (l > 0) { if (r <= 0) { return det; } else { s = l + r; } } else if (l < 0) { if (r >= 0) { return det; } else { s = -(l + r); } } else { return det; } const tol = ERR_BOUND_3 * s; if (det >= tol || det <= -tol) { return det; } return orientation3Exact(a, b, c); }, function orientation4(a: PointND, b: PointND, c: PointND, d: PointND) { const adx = a[0] - d[0]; const bdx = b[0] - d[0]; const cdx = c[0] - d[0]; const ady = a[1] - d[1]; const bdy = b[1] - d[1]; const cdy = c[1] - d[1]; const adz = a[2] - d[2]; const bdz = b[2] - d[2]; const cdz = c[2] - d[2]; const bdx_cdy = bdx * cdy; const cdx_bdy = cdx * bdy; const cdx_ady = cdx * ady; const adx_cdy = adx * cdy; const adx_bdy = adx * bdy; const bdx_ady = bdx * ady; const det = adz * (bdx_cdy - cdx_bdy) + bdz * (cdx_ady - adx_cdy) + cdz * (adx_bdy - bdx_ady); const permanent = (Math.abs(bdx_cdy) + Math.abs(cdx_bdy)) * Math.abs(adz) + (Math.abs(cdx_ady) + Math.abs(adx_cdy)) * Math.abs(bdz) + (Math.abs(adx_bdy) + Math.abs(bdx_ady)) * Math.abs(cdz); const tol = ERR_BOUND_4 * permanent; if (det > tol || -det > tol) { return det; } return orientation4Exact(a, b, c, d); }, ]; function slowOrient(args: Array): number { let proc = CACHED[args.length]; if (!proc) { proc = CACHED[args.length] = orientation(args.length); } return proc.apply(undefined, ...args); } function proc( slow: typeof slowOrient, o0: Function, o1: Function, o2: Function, o3: Function, o4: Function, o5: Function, ): (...args: Array) => number { return function getOrientation(...args: Array) { switch (args.length) { case 0: case 1: return 0; case 2: return o2(args[0], args[1]); case 3: return o3(args[0], args[1], args[2]); case 4: return o4(args[0], args[1], args[2], args[3]); case 5: return o5(args[0], args[1], args[2], args[3], args[4]); } return slow(args); }; } function generateOrientationProc(): any { while (CACHED.length <= NUM_EXPAND) { CACHED.push(orientation(CACHED.length)); } // @ts-ignore const orientationProc = proc(undefined, slowOrient, ...CACHED); for (let i = 0; i <= NUM_EXPAND; ++i) { // @ts-ignore orientationProc[i] = CACHED[i]; } return orientationProc; } export default generateOrientationProc(); ================================================ FILE: packages/g6/src/plugins/hull/hull/robust-scale.ts ================================================ 'use strict'; import { twoProduct } from './two-product'; import { fastTwoSum as twoSum } from './two-sum'; export function scaleLinearExpansion(e: number[], scale: number): number[] { const n = e.length; if (n === 1) { const ts = twoProduct(e[0], scale); if (ts[0]) { return ts; } return [ts[1]]; } const g = new Array(2 * n); const q: [number, number] = [0.1, 0.1]; const t: [number, number] = [0.1, 0.1]; let count = 0; twoProduct(e[0], scale, q); if (q[0]) { g[count++] = q[0]; } for (let i = 1; i < n; ++i) { twoProduct(e[i], scale, t); const pq = q[1]; twoSum(pq, t[0], q); if (q[0]) { g[count++] = q[0]; } const a = t[1]; const b = q[1]; const x = a + b; const bv = x - a; const y = b - bv; q[1] = x; if (y) { g[count++] = y; } } if (q[1]) { g[count++] = q[1]; } if (count === 0) { g[count++] = 0.0; } g.length = count; return g; } ================================================ FILE: packages/g6/src/plugins/hull/hull/robust-segment-intersect.ts ================================================ import type { Point } from '../../../types'; import orient from './robust-orientation'; function checkCollinear(a0: Point, a1: Point, b0: Point, b1: Point): boolean { for (let d = 0; d < 2; ++d) { const x0 = a0[d]; const y0 = a1[d]; const [l0, h0] = [Math.min(x0, y0), Math.max(x0, y0)]; const x1 = b0[d]; const y1 = b1[d]; const [l1, h1] = [Math.min(x1, y1), Math.max(x1, y1)]; if (h1 < l0 || h0 < l1) return false; } return true; } export function segmentsIntersect(a0: Point, a1: Point, b0: Point, b1: Point): boolean { const x0 = orient(a0, b0, b1); const y0 = orient(a1, b0, b1); if ((x0 > 0 && y0 > 0) || (x0 < 0 && y0 < 0)) return false; const x1 = orient(b0, a0, a1); const y1 = orient(b1, a0, a1); if ((x1 > 0 && y1 > 0) || (x1 < 0 && y1 < 0)) return false; if (x0 === 0 && y0 === 0 && x1 === 0 && y1 === 0) { return checkCollinear(a0, a1, b0, b1); } return true; } ================================================ FILE: packages/g6/src/plugins/hull/hull/robust-subtract.ts ================================================ 'use strict'; function scalarScalar(a: number, b: number): number[] { const x = a + b; const bv = x - a; const av = x - bv; const br = b - bv; const ar = a - av; const y = ar + br; if (y) { return [y, x]; } return [x]; } export function robustSubtract(e: number[], f: number[]): number[] { const ne = e.length | 0; const nf = f.length | 0; // Base case: scalar subtraction if (ne === 1 && nf === 1) { return scalarScalar(e[0], -f[0]); } const n = ne + nf; const g = new Array(n); let count = 0; let ep_tr = 0; let fp_tr = 0; const abs = Math.abs; let ei = e[ep_tr]; let ea = abs(ei); let fi = -f[fp_tr]; let fa = abs(fi); let a: number, b: number; // Initial comparison to determine `a` and `b` if (ea < fa) { b = ei; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; ea = abs(ei); } } else { b = fi; fp_tr += 1; if (fp_tr < nf) { fi = -f[fp_tr]; fa = abs(fi); } } if ((ep_tr < ne && ea < fa) || fp_tr >= nf) { a = ei; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; ea = abs(ei); } } else { a = fi; fp_tr += 1; if (fp_tr < nf) { fi = -f[fp_tr]; fa = abs(fi); } } let x = a + b; let bv = x - a; let y = b - bv; let q0 = y; let q1 = x; let _x: number, _bv: number, _av: number, _br: number, _ar: number; // Main loop for combining expansions while (ep_tr < ne && fp_tr < nf) { if (ea < fa) { a = ei; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; ea = abs(ei); } } else { a = fi; fp_tr += 1; if (fp_tr < nf) { fi = -f[fp_tr]; fa = abs(fi); } } b = q0; x = a + b; bv = x - a; y = b - bv; if (y) { g[count++] = y; } _x = q1 + x; _bv = _x - q1; _av = _x - _bv; _br = x - _bv; _ar = q1 - _av; q0 = _ar + _br; q1 = _x; } // Handle remaining elements in `e` while (ep_tr < ne) { a = ei; b = q0; x = a + b; bv = x - a; y = b - bv; if (y) { g[count++] = y; } _x = q1 + x; _bv = _x - q1; _av = _x - _bv; _br = x - _bv; _ar = q1 - _av; q0 = _ar + _br; q1 = _x; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; } } // Handle remaining elements in `f` while (fp_tr < nf) { a = fi; b = q0; x = a + b; bv = x - a; y = b - bv; if (y) { g[count++] = y; } _x = q1 + x; _bv = _x - q1; _av = _x - _bv; _br = x - _bv; _ar = q1 - _av; q0 = _ar + _br; q1 = _x; fp_tr += 1; if (fp_tr < nf) { fi = -f[fp_tr]; } } // Finalize the result if (q0) { g[count++] = q0; } if (q1) { g[count++] = q1; } if (!count) { g[count++] = 0.0; } g.length = count; return g; } ================================================ FILE: packages/g6/src/plugins/hull/hull/robust-sum.ts ================================================ 'use strict'; function scalarScalar(a: number, b: number): number[] { const x = a + b; const bv = x - a; const av = x - bv; const br = b - bv; const ar = a - av; const y = ar + br; if (y) { return [y, x]; } return [x]; } export function linearExpansionSum(e: number[], f: number[]): number[] { const ne = e.length | 0; const nf = f.length | 0; if (ne === 1 && nf === 1) { return scalarScalar(e[0], f[0]); } const n = ne + nf; const g = new Array(n); let count = 0; let ep_tr = 0; let fp_tr = 0; const abs = Math.abs; let ei = e[ep_tr]; let ea = abs(ei); let fi = f[fp_tr]; let fa = abs(fi); let a, b; if (ea < fa) { b = ei; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; ea = abs(ei); } } else { b = fi; fp_tr += 1; if (fp_tr < nf) { fi = f[fp_tr]; fa = abs(fi); } } if ((ep_tr < ne && ea < fa) || fp_tr >= nf) { a = ei; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; ea = abs(ei); } } else { a = fi; fp_tr += 1; if (fp_tr < nf) { fi = f[fp_tr]; fa = abs(fi); } } let x = a + b; let bv = x - a; let y = b - bv; let q0 = y; let q1 = x; let _x, _bv, _av, _br, _ar; while (ep_tr < ne && fp_tr < nf) { if (ea < fa) { a = ei; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; ea = abs(ei); } } else { a = fi; fp_tr += 1; if (fp_tr < nf) { fi = f[fp_tr]; fa = abs(fi); } } b = q0; x = a + b; bv = x - a; y = b - bv; if (y) { g[count++] = y; } _x = q1 + x; _bv = _x - q1; _av = _x - _bv; _br = x - _bv; _ar = q1 - _av; q0 = _ar + _br; q1 = _x; } while (ep_tr < ne) { a = ei; b = q0; x = a + b; bv = x - a; y = b - bv; if (y) { g[count++] = y; } _x = q1 + x; _bv = _x - q1; _av = _x - _bv; _br = x - _bv; _ar = q1 - _av; q0 = _ar + _br; q1 = _x; ep_tr += 1; if (ep_tr < ne) { ei = e[ep_tr]; } } while (fp_tr < nf) { a = fi; b = q0; x = a + b; bv = x - a; y = b - bv; if (y) { g[count++] = y; } _x = q1 + x; _bv = _x - q1; _av = _x - _bv; _br = x - _bv; _ar = q1 - _av; q0 = _ar + _br; q1 = _x; fp_tr += 1; if (fp_tr < nf) { fi = f[fp_tr]; } } if (q0) { g[count++] = q0; } if (q1) { g[count++] = q1; } if (!count) { g[count++] = 0.0; } g.length = count; return g; } ================================================ FILE: packages/g6/src/plugins/hull/hull/two-product.ts ================================================ 'use strict'; const SPLITTER: number = +(Math.pow(2, 27) + 1.0); export function twoProduct(a: number, b: number, result?: [number, number]): [number, number] { const x: number = a * b; const c: number = SPLITTER * a; const a_big: number = c - a; const ahi: number = c - a_big; const alo: number = a - ahi; const d: number = SPLITTER * b; const b_big: number = d - b; const bhi: number = d - b_big; const blo: number = b - bhi; const err1: number = x - ahi * bhi; const err2: number = err1 - alo * bhi; const err3: number = err2 - ahi * blo; const y: number = alo * blo - err3; if (result) { result[0] = y; result[1] = x; return result; } return [y, x]; } ================================================ FILE: packages/g6/src/plugins/hull/hull/two-sum.ts ================================================ 'use strict'; export function fastTwoSum(a: number, b: number, result?: [number, number]): [number, number] { const x = a + b; const bv = x - a; const av = x - bv; const br = b - bv; const ar = a - av; if (result) { result[0] = ar + br; result[1] = x; return result; } return [ar + br, x]; } ================================================ FILE: packages/g6/src/plugins/hull/index.ts ================================================ import { PathArray, isEqual, isFunction } from '@antv/util'; import { GraphEvent } from '../../constants'; import type { ContourStyleProps } from '../../elements/shapes'; import { Contour } from '../../elements/shapes'; import type { RuntimeContext } from '../../runtime/types'; import type { ID, Point } from '../../types'; import type { ElementLifeCycleEvent } from '../../utils/event'; import { idOf } from '../../utils/id'; import { positionOf } from '../../utils/position'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { hull } from './hull'; import { computeHullPath } from './util'; /** * Hull 配置项 * * Hull options */ export interface HullOptions extends BasePluginOptions, ContourStyleProps { /** * Hull 内的元素 * * Elements in Hull */ members?: ID[]; /** * 凹度,数值越大凹度越小;默认为 Infinity 代表为 Convex Hull * * Concavity. Default is Infinity, which means Convex Hull * @defaultValue Infinity */ concavity?: number; /** * 内边距 * * Padding * @defaultValue 10 */ padding?: number; /** * 拐角类型 * * Corner type * @defaultValue 'rounded' */ corner?: 'rounded' | 'smooth' | 'sharp'; } /** * 轮廓 * * Hull * @remarks * 轮廓包围(Hull)用于处理和表示一组点的凸多边形包围盒。轮廓包围分为两种形态:凸包和凹包。 * * 凸包(Convex Hull):这是一个凸多边形,它包含所有的点,并且没有任何凹陷。你可以将其想象为一组点的最小包围盒,没有任何点在这个多边形的外部。 * * 凹包(Concave Hull):这是一个凹多边形,它同样包含所有的点,但是可能会有凹陷。凹包的凹陷程度由 concavity 参数控制。concavity 越大,凹陷越小。当 concavity 为 Infinity 时,凹包就变成了凸包。 * * Hull is used to process and represent the convex polygon bounding box of a set of points. Hull has two forms: convex hull and concave hull. * * Convex Hull: This is a convex polygon that contains all points and has no concave. You can think of it as the smallest bounding box of a set of points, with no points outside the polygon. * * Concave Hull: This is a concave polygon that also contains all points, but may have concave. The concavity of the concave hull is controlled by the concavity parameter. The larger the concavity, the smaller the concave. When concavity is Infinity, the concave hull becomes a convex hull. */ export class Hull extends BasePlugin { private shape!: Contour; /** * 在 Hull 上的元素 * * Element Ids on Hull */ private hullMemberIds: ID[] = []; /** * Hull 绘制路径 * * Hull path */ private path!: PathArray; private optionsCache!: HullOptions; static defaultOptions: Partial = { members: [], padding: 10, corner: 'rounded', concavity: Infinity, /** hull style */ fill: 'lightblue', fillOpacity: 0.2, labelOpacity: 1, stroke: 'blue', strokeOpacity: 0.2, }; constructor(context: RuntimeContext, options: HullOptions) { super(context, Object.assign({}, Hull.defaultOptions, options)); this.bindEvents(); } private bindEvents() { this.context.graph.on(GraphEvent.AFTER_RENDER, this.drawHull); this.context.graph.on(GraphEvent.AFTER_ELEMENT_UPDATE, this.updateHullPath); } private unbindEvents() { this.context.graph.off(GraphEvent.AFTER_RENDER, this.drawHull); this.context.graph.off(GraphEvent.AFTER_ELEMENT_UPDATE, this.updateHullPath); } private getHullStyle(forceUpdate?: boolean): ContourStyleProps { const { members, padding, corner, ...style } = this.options; return { ...style, d: this.getHullPath(forceUpdate) }; } private drawHull = () => { if (!this.shape) { this.shape = new Contour({ style: this.getHullStyle() }); this.context.canvas.appendChild(this.shape); } else { const forceUpdate = !isEqual(this.optionsCache, this.options); this.shape.update(this.getHullStyle(forceUpdate)); } this.optionsCache = { ...this.options }; }; private updateHullPath = (event: ElementLifeCycleEvent) => { if (!this.shape) return; if (!this.options.members.includes(idOf(event.data))) return; this.shape.update({ d: this.getHullPath(true) }); }; private getHullPath = (forceUpdate = false): string | PathArray => { const { graph } = this.context; const members = this.getMember(); if (members.length === 0) return ''; const data = members.map((id) => graph.getNodeData(id)); const vertices = hull(data.map(positionOf), this.options.concavity).slice(1).reverse() as Point[]; const hullMemberIds = vertices.flatMap((point) => data.filter((m) => isEqual(positionOf(m), point)).map(idOf)); if (isEqual(hullMemberIds, this.hullMemberIds) && !forceUpdate) return this.path; this.hullMemberIds = hullMemberIds; this.path = computeHullPath(vertices, this.getPadding(), this.options.corner); return this.path; }; private getPadding() { const { graph } = this.context; const memberPadding = this.hullMemberIds.reduce((acc: number, id: ID) => { const { halfExtents } = graph.getElementRenderBounds(id); const size = Math.max(halfExtents[0], halfExtents[1]); return Math.max(acc, size); }, 0); return memberPadding + this.options.padding; } /** * 添加 Hull 成员 * * Add Hull member * @param members - 元素 Ids | Element Ids */ public addMember(members: ID | ID[]) { const membersToAdd = Array.isArray(members) ? members : [members]; this.options.members = [...new Set([...this.options.members, ...membersToAdd])]; this.shape.update({ d: this.getHullPath() }); } /** * 移除 Hull 成员 * * Remove Hull member * @param members - 元素 Ids | Element Ids */ public removeMember(members: ID | ID[]) { const membersToRemove = Array.isArray(members) ? members : [members]; this.options.members = this.options.members.filter((id) => !membersToRemove.includes(id)); if (membersToRemove.some((id) => this.hullMemberIds.includes(id))) { this.shape.update({ d: this.getHullPath() }); } } /** * 更新 Hull 成员 * * Update Hull member * @param members - 元素 Ids | Element Ids */ public updateMember(members: ID[] | ((prev: ID[]) => ID[])) { this.options.members = isFunction(members) ? members(this.options.members) : members; this.shape.update(this.getHullStyle(true)); } /** * 获取 Hull 成员 * * Get Hull member * @returns 元素 Ids | Element Ids */ public getMember() { return this.options.members; } /** * 销毁 Hull * * Destroy Hull * @internal */ public destroy(): void { this.unbindEvents(); this.shape.destroy(); this.hullMemberIds = []; super.destroy(); } } ================================================ FILE: packages/g6/src/plugins/hull/util.ts ================================================ import { type PathArray } from '@antv/util'; import type { Point, Vector2 } from '../../types'; import { getLinesIntersection } from '../../utils/line'; import { getClosedSpline } from '../../utils/path'; import { isCollinear, sortByClockwise } from '../../utils/point'; import { add, angle, normalize, perpendicular, scale, subtract, toVector2, toVector3 } from '../../utils/vector'; import type { HullOptions } from '../hull'; /** * 计算 Hull 路径 * * Compute Hull Path * @param points - 顶点列表 | Vertices of Hull * @param padding - 内边距 | padding * @param corner - 拐角类型,目前支持 'sharp'、'rounded' 和 'smooth' | Corner type, currently supports 'sharp', 'rounded' and 'smooth' * @returns Hull 路径 | Hull Path */ export function computeHullPath(points: Point[], padding: number, corner: 'rounded' | 'smooth' | 'sharp'): PathArray { if (points.length === 1) return genSinglePointHullPath(points[0], padding, corner); if (points.length === 2) return genTwoPointsHullPath(points, padding, corner); if (points.length === 3) { const [p1, p2, p3] = sortByClockwise(points); if (isCollinear(p1, p2, p3)) return genTwoPointsHullPath([p1, p3], padding, corner); } switch (corner) { case 'smooth': return genMultiPointsSmoothHull(points, padding); case 'sharp': return genMultiPointsSharpHull(points, padding); case 'rounded': default: return genMultiPointsRoundedHull(points, padding); } } /** * 生成单点 Hull 路径 * * Generate Hull Path for a single point * @param point - 单点 | Single point * @param padding - 内边距 | Padding * @param corner - 拐角类型 | Corner type * @returns 单点 Hull 路径 | Single point Hull Path */ const genSinglePointHullPath = (point: Point, padding: number, corner: HullOptions['corner']): PathArray => { if (corner === 'sharp') return [ ['M', point[0] - padding, point[1] - padding], ['L', point[0] + padding, point[1] - padding], ['L', point[0] + padding, point[1] + padding], ['L', point[0] - padding, point[1] + padding], ['Z'], ]; const arcData: [number, number, number, number, number] = [padding, padding, 0, 0, 0]; return [ ['M', point[0], point[1] - padding], ['A', ...arcData, point[0], point[1] + padding], ['A', ...arcData, point[0], point[1] - padding], ]; }; /** * 生成两点 Hull 路径 * * Generate Hull Path for two points * @param points - 两点 | Two points * @param padding - 内边距 | Padding * @param corner - 拐角类型 | Corner type * @returns 两点 Hull 路径 | Two points Hull Path */ const genTwoPointsHullPath = (points: Point[], padding: number, corner: HullOptions['corner']): PathArray => { const arcData: [number, number, number, number, number] = [padding, padding, 0, 0, 0]; const point1 = corner === 'sharp' ? add(points[0], scale(normalize(subtract(points[0], points[1])), padding)) : points[0]; const point2 = corner === 'sharp' ? add(points[1], scale(normalize(subtract(points[1], points[0])), padding)) : points[1]; const offsetVector = scale(normalize(perpendicular(subtract(point1, point2) as Vector2, false)), padding); const invOffsetVector = scale(offsetVector, -1); const prev = add(point1, offsetVector); const current = add(point2, offsetVector); const p2 = add(point2, invOffsetVector); const p3 = add(point1, invOffsetVector); if (corner === 'sharp') { return [['M', prev[0], prev[1]], ['L', current[0], current[1]], ['L', p2[0], p2[1]], ['L', p3[0], p3[1]], ['Z']]; } return [ ['M', prev[0], prev[1]], ['L', current[0], current[1]], ['A', ...arcData, p2[0], p2[1]], ['L', p3[0], p3[1]], ['A', ...arcData, prev[0], prev[1]], ]; }; /** * 生成多点 Hull 路径且拐角为圆角 * * Generate Hull Path for multiple points with rounded corners * @param points - 形成 Hull 的点集 | Points that form the Hull * @param padding - 内边距 | Padding * @returns 圆角外壳路径 | Rounded hull path */ const genMultiPointsRoundedHull = (points: Point[], padding: number): PathArray => { const segments = sortByClockwise(points).map((current, i) => { const prev2Index = (i - 2 + points.length) % points.length; const prevIndex = (i - 1 + points.length) % points.length; const nextIndex = (i + 1) % points.length; const prev2 = points[prev2Index]; const prev = points[prevIndex]; const next = points[nextIndex]; const v0 = subtract(prev2, prev) as Vector2; const v1 = subtract(prev, current) as Vector2; const v2 = subtract(current, next) as Vector2; // 判断是否为凹角 | Determine if it is a concave angle const isConcave = (v1: Vector2, v2: Vector2): boolean => { return angle(v1, v2, true) < Math.PI; }; const concavePrev = isConcave(v0, v1); const concaveNext = isConcave(v1, v2); const offsetVector = (v: Vector2) => scale(normalize(perpendicular(v, false)), padding); const offset = offsetVector(v1); return [ { p: toVector2(concavePrev ? add(prev, offsetVector(v0)) : add(prev, offset)), concave: concavePrev && prev, }, { p: toVector2(concaveNext ? add(current, offsetVector(v2)) : add(current, offset)), concave: concaveNext && current, }, ]; }); const arcData = [padding, padding, 0, 0, 0]; const startIndex = segments.findIndex( (segment, i) => !segments[(i - 1 + segments.length) % segments.length][0].concave && !segments[(i - 1 + segments.length) % segments.length][1].concave && !segment[0].concave && !segment[0].concave && !segment[1].concave, ); const sortedSegments = segments.slice(startIndex).concat(segments.slice(0, startIndex)); let concavePoints: Point[] = []; return sortedSegments.flatMap((segment, i) => { const pathFragment = []; const lastSegment = sortedSegments[segments.length - 1]; if (i === 0) pathFragment.push(['M', ...lastSegment[1].p]); if (!segment[0].concave) { pathFragment.push(['A', ...arcData, ...segment[0].p]); } else { concavePoints.push(segment[0].p, segment[1].p); } if (!segment[1].concave) { pathFragment.push(['L', ...segment[1].p]); } else { concavePoints.unshift(segment[1].p); } if (concavePoints.length === 3) { pathFragment.pop(); pathFragment.push(['C', ...concavePoints.flat()]); concavePoints = []; } return pathFragment; }) as PathArray; }; /** * 生成多点 Hull 路径且拐角为平滑 * * Generate Hull Path for multiple points with smooth corners * @param points - 形成 Hull 的点集 | Points that form the Hull * @param padding - 内边距 | Padding * @returns 平滑外壳路径 | Smooth hull path */ const genMultiPointsSmoothHull = (points: Point[], padding: number): PathArray => { const hullPoints = sortByClockwise(points).map((p, i) => { const pNext = points[(i + 1) % points.length]; return { p, v: normalize(subtract(pNext, p)) }; }); // Compute the expanded hull points, and the nearest prior control point for each. hullPoints.forEach((hp, i) => { const priorIndex = i > 0 ? i - 1 : points.length - 1; const prevV = hullPoints[priorIndex].v as Vector2; const extensionVec = normalize(add(prevV, scale(hp.v, angle(prevV, hp.v, true) < Math.PI ? 1 : -1))); hp.p = add(hp.p, scale(extensionVec, padding)); }); return getClosedSpline(hullPoints.map((obj) => obj.p)); }; /** * 生成多点 Hull 路径且拐角为尖锐 * * Generate Hull Path for multiple points with sharp corners * @param points - 形成 Hull 的点集 | Points that form the Hull * @param padding - 内边距 | Padding * @returns 锐角外壳路径 | Sharp hull path */ const genMultiPointsSharpHull = (points: Point[], padding: number): PathArray => { const segments = points.map((current, i) => { const prev = points[i === 0 ? points.length - 1 : i - 1]; const offset = toVector3(scale(normalize(perpendicular(subtract(prev, current) as Vector2, false)), padding)); return [add(prev, offset), add(current, offset)]; }); const arr = segments.flat(); const vertices = arr .map((_, i) => { if (i % 2 === 0) return null; const l1 = [arr[(i - 1) % arr.length], arr[i % arr.length]] as [Point, Point]; const l2 = [arr[(i + 1) % arr.length], arr[(i + 2) % arr.length]] as [Point, Point]; return getLinesIntersection(l1, l2, true); }) .filter(Boolean) as Point[]; return vertices.map((point, i) => [i === 0 ? 'M' : 'L', point[0], point[1]]).concat([['Z']]) as PathArray; }; ================================================ FILE: packages/g6/src/plugins/index.ts ================================================ export { Background } from './background'; export { BasePlugin } from './base-plugin'; export { BubbleSets } from './bubble-sets'; export { CameraSetting } from './camera-setting'; export { Contextmenu } from './contextmenu'; export { EdgeBundling } from './edge-bundling'; export { EdgeFilterLens } from './edge-filter-lens'; export { Fisheye } from './fisheye'; export { Fullscreen } from './fullscreen'; export { GridLine } from './grid-line'; export { History } from './history'; export { Hull } from './hull'; export { Legend } from './legend'; export { Minimap } from './minimap'; export { Snapline } from './snapline'; export { Timebar } from './timebar'; export { Title } from './title'; export { Toolbar } from './toolbar'; export { Tooltip } from './tooltip'; export { Watermark } from './watermark'; export type { BackgroundOptions } from './background'; export type { BasePluginOptions } from './base-plugin'; export type { BubbleSetsOptions } from './bubble-sets'; export type { CameraSettingOptions } from './camera-setting'; export type { ContextmenuOptions } from './contextmenu'; export type { EdgeBundlingOptions } from './edge-bundling'; export type { EdgeFilterLensOptions } from './edge-filter-lens'; export type { FisheyeOptions } from './fisheye'; export type { FullscreenOptions } from './fullscreen'; 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 { SnaplineOptions } from './snapline'; export type { TimebarOptions } from './timebar'; export type { SubTitleStyle, TitleOptions, TitleStyle } from './title'; export type { ToolbarOptions } from './toolbar'; export type { TooltipOptions } from './tooltip'; export type { WatermarkOptions } from './watermark'; ================================================ FILE: packages/g6/src/plugins/legend.ts ================================================ import { Category, Selection } from '@antv/component'; import { CategoryStyleProps } from '@antv/component/lib/ui/legend/types'; import { Canvas } from '@antv/g'; import { get, isFunction } from '@antv/util'; import { GraphEvent } from '../constants'; import type { RuntimeContext } from '../runtime/types'; import type { ElementDatum, ElementType, ID, State } from '../types'; import type { CardinalPlacement } from '../types/placement'; import type { BasePluginOptions } from './base-plugin'; import { BasePlugin } from './base-plugin'; import { createPluginCanvas } from './utils/canvas'; interface Datum extends Record { id?: string; label?: string; color?: string; marker?: string; elementType?: ElementType; } /** * 图例配置项 * * Legend options */ export interface LegendOptions extends BasePluginOptions, Omit { /** * 图例触发行为 * - `'hover'`:鼠标移入图例项时触发 * - `'click'`:鼠标点击图例项时触发 * * Legend trigger behavior * - `'hover'`:mouseover the legend item * - `'click'`:click the legend item * @defaultValue 'hover' */ trigger?: 'hover' | 'click'; /** * 图例在画布中的相对位置,默认为 'bottom',代表在画布正下方 * * Relative position of the legend in the canvas, defaults to 'bottom', representing the bottom of the canvas * @defaultValue 'bottom' */ position?: CardinalPlacement; /** * 图例挂载的容器,无则挂载到 Graph 所在容器 * * The container where the legend is mounted, if not, it will be mounted to the container where the Graph is located */ container?: HTMLElement | string; /** * 图例画布类名,传入外置容器时不生效 * * The class name of the legend canvas, which does not take effect when an external container is passed in */ className?: string; /** * 图例的容器样式,传入外置容器时不生效 * * The style of the legend container, which does not take effect when an external container is passed in */ containerStyle?: Partial; /** * 节点分类标识 * * Node Classification Identifier */ nodeField?: string | ((item: ElementDatum) => string); /** * 边分类标识 * * Edge Classification Identifier */ edgeField?: string | ((item: ElementDatum) => string); /** * 组合分类标识 * * Combo Classification Identifier */ comboField?: string | ((item: ElementDatum) => string); } /** * 图例 * * Legend * @remarks * 图例插件用于展示图中元素的分类信息,支持节点、边、组合的分类信息展示。 * * The legend plugin is used to display the classification information of elements in the graph, and supports the display of classification information of nodes, edges, and combos. */ export class Legend extends BasePlugin { static defaultOptions: Partial = { position: 'bottom', trigger: 'hover', orientation: 'horizontal', layout: 'flex', itemSpacing: 4, rowPadding: 10, colPadding: 10, itemMarkerSize: 16, itemLabelFontSize: 16, width: 240, height: 160, }; private typePrefix = '__data__'; private draw = false; private fieldMap = { node: new Map(), edge: new Map(), combo: new Map(), }; private selectedItems: string[] = []; private category?: Category; private container?: HTMLElement; private canvas?: Canvas; constructor(context: RuntimeContext, options: LegendOptions) { super(context, Object.assign({}, Legend.defaultOptions, options)); this.bindEvents(); } /** * 更新图例配置 * * Update the legend configuration * @param options - 图例配置项 | Legend options * @internal */ public update(options: Partial) { super.update(options); this.clear(); this.createElement(); } private clear() { this.canvas?.destroy(); this.container?.remove(); this.canvas = undefined; this.container = undefined; this.draw = false; } private bindEvents = () => { const { graph } = this.context; graph.on(GraphEvent.AFTER_DRAW, this.createElement); }; private changeState = (el: Selection, state: State | State[]) => { const { graph } = this.context; const { typePrefix } = this; const composeId = get(el, [typePrefix, 'id']); const category = get(el, [typePrefix, 'style', 'labelText']); const [type] = composeId.split('__'); const ids = this.fieldMap[type as keyof typeof this.fieldMap].get(category) || []; graph.setElementState(Object.fromEntries(ids?.map((id) => [id, state]))); }; /** * 图例元素点击事件 * * Legend element click event * @param event - 点击的元素 | The element that is clicked */ public click = (event: Selection) => { if (this.options.trigger === 'hover') return; const composeId = get(event, [this.typePrefix, 'id']); if (!this.selectedItems.includes(composeId)) { this.selectedItems.push(composeId); this.changeState(event, 'selected'); } else { this.selectedItems = this.selectedItems.filter((item) => item !== composeId); this.changeState(event, []); } }; /** * 图例元素移出事件 * * Legend element mouseleave event * @param event - 移出的元素 | The element that is moved out */ public mouseleave = (event: Selection) => { if (this.options.trigger === 'click') return; this.selectedItems = []; this.changeState(event, []); }; /** * 图例元素移入事件 * * Legend element mouseenter event * @param event - 移入的元素 | The element that is moved in */ public mouseenter = (event: Selection) => { if (this.options.trigger === 'click') return; const composeId = get(event, [this.typePrefix, 'id']); if (!this.selectedItems.includes(composeId)) { this.selectedItems.push(composeId); this.changeState(event, 'active'); } else { this.selectedItems = this.selectedItems.filter((item) => item !== composeId); } }; /** * 刷新图例元素状态 * * Refresh the status of the legend element */ public updateElement() { if (!this.category) return; this.category.update({ itemMarkerOpacity: ({ id }) => { if (!this.selectedItems.length || this.selectedItems.includes(id)) return 1; return 0.5; }, itemLabelOpacity: ({ id }) => { if (!this.selectedItems.length || this.selectedItems.includes(id)) return 1; return 0.5; }, }); } private setFieldMap = (field: string, id: ID, type: ElementType) => { if (!field) return; const map = this.fieldMap[type]; if (!map) return; if (!map.has(field)) { map.set(field, [id]); } else { const ids = map.get(field); if (ids) { ids.push(id); map.set(field, ids); } } }; private getEvents = () => { return { mouseenter: this.mouseenter, mouseleave: this.mouseleave, click: this.click, }; }; protected getMarkerData = (field: string | ((item: ElementDatum) => string), elementType: ElementType) => { if (!field) return []; const { model, element } = this.context; const { nodes, edges, combos } = model.getData(); const items: { [key: string]: Datum } = {}; const getField = (item: ElementDatum) => { if (isFunction(field)) return field(item); return field; }; const defaultType = { node: 'circle', edge: 'line', combo: 'rect', }; // 用于将 G6 element 转换为 components 支持的类型 // Used to convert G6 element to types supported by components const markerMapping: { [key: string]: string } = { circle: 'circle', ellipse: 'circle', // 待 components 支持 ellipse image: 'bowtie', rect: 'square', star: 'cross', triangle: 'triangle', diamond: 'diamond', cubic: 'dot', line: 'hyphen', polyline: 'hyphen', quadratic: 'hv', 'cubic-horizontal': 'hyphen', 'cubic-vertical': 'line', }; const getElementStyle = (type: ElementType, datum: ElementDatum) => { const style = element?.getElementComputedStyle(type, datum); return style; }; const getElementModel = (data: ElementDatum[], type: ElementType) => { data.forEach((item) => { const { id } = item; const value = get(item, ['data', getField(item)]); const marker = element?.getElementType(type, item) || 'circle'; const style = getElementStyle(type, item); const color = (type === 'edge' ? style?.stroke : style?.fill) || '#1783ff'; if (id && value && value.replace(/\s+/g, '')) { this.setFieldMap(value, id, type); if (!items[value]) { items[value] = { id: `${type}__${id}`, label: value, marker: markerMapping[marker] || defaultType[type], elementType: type, lineWidth: 1, stroke: color, fill: color, }; } } }); }; switch (elementType) { case 'node': getElementModel(nodes, 'node'); break; case 'edge': getElementModel(edges, 'edge'); break; case 'combo': getElementModel(combos, 'combo'); break; default: return []; } return Object.values(items); }; private upsertCanvas() { if (this.canvas) return this.canvas; const graphCanvas = this.context.canvas; const [canvasWidth, canvasHeight] = graphCanvas.getSize(); const { width = canvasWidth, height = canvasHeight, position, container, containerStyle, className } = this.options; const [$container, canvas] = createPluginCanvas({ width, height, graphCanvas, container, containerStyle, placement: position, className: 'legend', }); this.container = $container; if (className) $container.classList.add(className); this.canvas = canvas; return this.canvas; } private createElement = () => { if (this.draw) { this.updateElement(); return; } const { width, height, nodeField, edgeField, comboField, trigger, position, container, containerStyle, className, ...rest } = this.options; const nodeItems = this.getMarkerData(nodeField, 'node'); const edgeItems = this.getMarkerData(edgeField, 'edge'); const comboItems = this.getMarkerData(comboField, 'combo'); const items = [...nodeItems, ...comboItems, ...edgeItems]; const categoryStyle = Object.assign( { width, height, data: items, itemMarkerLineWidth: ({ lineWidth }: Datum) => lineWidth, itemMarker: ({ marker }: Datum) => marker, itemMarkerStroke: ({ stroke }: Datum) => stroke, itemMarkerFill: ({ fill }: Datum) => fill, gridCol: nodeItems.length, }, rest, this.getEvents(), ); const category = new Category({ className: 'legend', style: categoryStyle, }); this.category = category; const canvas = this.upsertCanvas(); canvas.appendChild(category); this.draw = true; }; /** * 销毁图例 * * Destroy the legend * @internal */ public destroy(): void { this.clear(); this.context.graph.off(GraphEvent.AFTER_DRAW, this.createElement); super.destroy(); } } ================================================ FILE: packages/g6/src/plugins/minimap/index.ts ================================================ import { Canvas, DisplayObject, IRenderer, Landmark } from '@antv/g'; import { debounce, throttle } from '@antv/util'; import { GraphEvent } from '../../constants'; import type { RuntimeContext } from '../../runtime/types'; import { GraphData } from '../../spec'; import type { ElementDatum, ElementType, IGraphLifeCycleEvent, Padding, Placement, Vector3 } from '../../types'; import { isVisible } from '../../utils/element'; import { idOf } from '../../utils/id'; import { parsePadding } from '../../utils/padding'; import { toPointObject } from '../../utils/point'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { createPluginCanvas } from '../utils/canvas'; /** * 缩略图插件配置项 * * Minimap plugin options */ export interface MinimapOptions extends BasePluginOptions { /** * 宽度和高度 * * Width and height * @defaultValue [240, 160] */ 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' 使用元素的主图形作为缩略图形 * - 'icon' 使用元素中心的 icon 作为缩略图形 * - 更多图形名称可查阅 https://g6.antv.antgroup.com/manual/element/node/base-node#style * - 也可以传入一个函数,接收元素的 [id, 类型, 元素节点],返回一个自定义样式的图形 * * * - 'key' uses the key shape of the element as the thumbnail shape * - 'icon' uses the icon shape of the element as the thumbnail shape * - more shape name see https://g6.antv.antgroup.com/manual/element/node/base-node#style * - You can also pass in a function that receives the [id, type of the element, element] and returns a custom shape */ shape?: string | 'key' | 'icon' | ((id: string, elementType: ElementType, element: DisplayObject) => DisplayObject); /** * 缩略图画布类名,传入外置容器时不生效 * * The class name of the minimap canvas, which does not take effect when an external container is passed in */ 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; /** * 延迟更新时间(毫秒),用于性能优化 * * Delay update time(ms), used for performance optimization * @defaultValue 128 */ delay?: number; } /** * 缩略图插件 * * Minimap plugin */ 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', }, delay: 128, }; private canvas!: Canvas; constructor(context: RuntimeContext, options: MinimapOptions) { super(context, Object.assign({}, Minimap.defaultOptions, options)); this.setOnRender(); this.bindEvents(); } public update(options: Partial): void { this.unbindEvents(); super.update(options); if ('delay' in options) this.setOnRender(); this.bindEvents(); } private setOnRender() { this.onRender = debounce(() => { this.renderMinimap(); this.renderMask(); }, this.options.delay); } private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.AFTER_DRAW, this.onDraw); graph.on(GraphEvent.AFTER_RENDER, this.onRender); graph.on(GraphEvent.AFTER_ANIMATE, 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_ANIMATE, this.onRender); graph.off(GraphEvent.AFTER_TRANSFORM, this.onTransform); } private onDraw = (event: IGraphLifeCycleEvent) => { if (event?.data?.render) return; this.onRender(); }; private onRender!: () => void; /** * 创建或更新缩略图 * * 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, element } = this.context; const originData = model.getData(); //过滤那些不存在于elementMap中的数据 const data = { nodes: originData.nodes.filter((node) => element?.getElement(idOf(node))), edges: originData.edges.filter((edge) => { const edgeElement = element?.getElement(idOf(edge)); // 边数据存在且可见时才保留 return edgeElement && isVisible(edgeElement); }), combos: originData.combos.filter((combo) => element?.getElement(idOf(combo))), }; 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; const iterate = (datum: ElementDatum, elType: ElementType) => { const id = idOf(datum); const target = element?.getElement(id); if (!target) return; const keyShape = target.getShape('key'); let cloneShape: DisplayObject; if (typeof shape === 'string') { const shapeName = shape; const miniShape = target.getShape(shapeName); cloneShape = miniShape.cloneNode(); } else { const miniShape = shape(id, elType, target); if (miniShape === target) { cloneShape = miniShape.cloneNode(true); } else { cloneShape = miniShape; } } /** * 这里使用的是 keyShape 的位置 * 对于整个元素的位置而言,使用 keyShape 位置会比较准确 * 也比较合理 */ cloneShape.setPosition(keyShape.getPosition()); // keep zIndex / id if (target.style.zIndex) cloneShape.style.zIndex = target.style.zIndex; cloneShape.id = target.id; canvas.appendChild(cloneShape); }; canvas.removeChildren(); // 注意执行顺序 / Note the execution order edges.forEach((datum) => iterate(datum, 'edge')); combos.forEach((datum) => iterate(datum, 'combo')); nodes.forEach((datum) => iterate(datum, 'node')); } private container!: HTMLElement; private initCanvas() { const { renderer, size: [width, height], } = this.options; if (this.canvas) { const { width: w, height: h } = this.canvas.getConfig(); if (width !== w || height !== h) this.canvas.resize(width, height); if (renderer) this.canvas.setRenderer(renderer); } else { const { className, position, container, containerStyle } = this.options; const [$container, canvas] = createPluginCanvas({ renderer, width, height, placement: position, className: 'minimap', container, containerStyle, graphCanvas: this.context.canvas, }); if (className) $container.classList.add(className); this.container = $container; this.canvas = canvas; } this.setCamera(); return this.canvas; } private landmarkMap = new Map(); private createLandmark(position: Vector3, focalPoint: Vector3, zoom: number) { const key = `${position.join(',')}-${focalPoint.join(',')}-${zoom}`; if (this.landmarkMap.has(key)) return this.landmarkMap.get(key)!; const camera = this.canvas.getCamera(); const landmark = camera.createLandmark(key, { position, focalPoint, zoom, }); this.landmarkMap.set(key, landmark); return landmark; } 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('elements'); 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); const landmark = this.createLandmark(center, center, scale); camera.gotoLandmark(landmark, 0); } 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; return [maskMin.x, maskMin.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.mask.draggable = true; this.mask.addEventListener('dragstart', (event) => event.preventDefault && event.preventDefault()); } 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(); this.container?.remove(); super.destroy(); } } const upper = (value: number, max: number) => Math.min(value, max); const lower = (value: number, min: number) => Math.max(value, min); ================================================ FILE: packages/g6/src/plugins/snapline/index.ts ================================================ import { AABB, BaseStyleProps, DisplayObject, Line, LineStyleProps } from '@antv/g'; import { isEqual } from '@antv/util'; import { NodeEvent } from '../../constants'; import type { RuntimeContext } from '../../runtime/types'; import type { ID, IDragEvent, Node } from '../../types'; import { isVisible } from '../../utils/element'; import { divide } from '../../utils/vector'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; /** * 对齐线插件配置项 * * Snapline plugin options */ export interface SnaplineOptions extends BasePluginOptions { /** * 对齐精度,即移动节点时与目标位置的距离小于 tolerance 时触发显示对齐线 * * The alignment accuracy, that is, when the distance between the moved node and the target position is less than tolerance, the alignment line is displayed * @defaultValue 5 */ tolerance?: number; /** * 对齐线头尾的延伸距离。取值范围:[0, Infinity] * * The extension distance of the snapline. The value range is [0, Infinity] * @defaultValue 20 */ offset?: number; /** * 是否启用自动吸附 * * Whether to enable automatic adsorption * @defaultValue true */ autoSnap?: boolean; /** * 指定元素上的哪个图形作为参照图形 * * Specifies which shape on the element to use as the reference shape * @defaultValue `'key'` * @remarks * * - 'key' 使用元素的主图形作为参照图形 * - 也可以传入一个函数,接收元素对象,返回一个图形 * * * - `'key'` uses the key shape of the element as the reference shape * - You can also pass in a function that receives the element and returns a shape */ shape?: string | ((node: Node) => DisplayObject); /** * 垂直对齐线样式 * * Vertical snapline style * @defaultValue `{ stroke: '#1783FF' }` */ verticalLineStyle?: BaseStyleProps; /** * 水平对齐线样式 * * Horizontal snapline style * @defaultValue `{ stroke: '#1783FF' }` */ horizontalLineStyle?: BaseStyleProps; /** * 过滤器,用于过滤不需要作为参考的节点 * * Filter, used to filter nodes that do not need to be used as references * @defaultValue `() => true` */ filter?: (node: Node) => boolean; } const defaultLineStyle: LineStyleProps = { x1: 0, y1: 0, x2: 0, y2: 0, visibility: 'hidden' }; type Metadata = { verticalX: number | null; verticalMinY: number | null; verticalMaxY: number | null; horizontalY: number | null; horizontalMinX: number | null; horizontalMaxX: number | null; }; /** * 对齐线插件 * * Snapline plugin */ export class Snapline extends BasePlugin { static defaultOptions: Partial = { tolerance: 5, offset: 20, autoSnap: true, shape: 'key', verticalLineStyle: { stroke: '#1783FF' }, horizontalLineStyle: { stroke: '#1783FF' }, filter: () => true, }; private horizontalLine!: Line; private verticalLine!: Line; constructor(context: RuntimeContext, options: SnaplineOptions) { super(context, Object.assign({}, Snapline.defaultOptions, options)); this.bindEvents(); } private initSnapline = () => { const canvas = this.context.canvas.getLayer('transient'); if (!this.horizontalLine) { this.horizontalLine = canvas.appendChild( new Line({ style: { ...defaultLineStyle, ...this.options.horizontalLineStyle } }), ); } if (!this.verticalLine) { this.verticalLine = canvas.appendChild( new Line({ style: { ...defaultLineStyle, ...this.options.verticalLineStyle } }), ); } }; private getNodes(): Node[] { const { filter } = this.options; const allNodes = this.context.element?.getNodes() || []; // 不考虑超出画布视口范围、不可见的节点 // Nodes that are out of the canvas viewport range, invisible are not considered const nodes = allNodes.filter((node) => { return isVisible(node) && this.context.viewport?.isInViewport(node.getRenderBounds()); }); if (!filter) return nodes; return nodes.filter((node) => filter(node)); } private hideSnapline() { this.horizontalLine.style.visibility = 'hidden'; this.verticalLine.style.visibility = 'hidden'; } private getLineWidth(direction: 'horizontal' | 'vertical') { const { lineWidth } = this.options[`${direction}LineStyle`] as LineStyleProps; return +(lineWidth || defaultLineStyle.lineWidth || 1) / this.context.graph.getZoom(); } private updateSnapline(metadata: Metadata) { const { verticalX, verticalMinY, verticalMaxY, horizontalY, horizontalMinX, horizontalMaxX } = metadata; const [canvasWidth, canvasHeight] = this.context.canvas.getSize(); const { offset } = this.options; if (horizontalY !== null) { Object.assign(this.horizontalLine.style, { x1: offset === Infinity ? 0 : horizontalMinX! - offset, y1: horizontalY, x2: offset === Infinity ? canvasWidth : horizontalMaxX! + offset, y2: horizontalY, visibility: 'visible', lineWidth: this.getLineWidth('horizontal'), }); } else { this.horizontalLine.style.visibility = 'hidden'; } if (verticalX !== null) { Object.assign(this.verticalLine.style, { x1: verticalX, y1: offset === Infinity ? 0 : verticalMinY! - offset, x2: verticalX, y2: offset === Infinity ? canvasHeight : verticalMaxY! + offset, visibility: 'visible', lineWidth: this.getLineWidth('vertical'), }); } else { this.verticalLine.style.visibility = 'hidden'; } } private isHorizontalSticking = false; private isVerticalSticking = false; private enableStick = true; private autoSnapToLine = async (nodeId: ID, bbox: AABB, metadata: Metadata) => { const { verticalX, horizontalY } = metadata; const { tolerance } = this.options; const { min: [nodeMinX, nodeMinY], max: [nodeMaxX, nodeMaxY], center: [nodeCenterX, nodeCenterY], } = bbox; let dx = 0; let dy = 0; if (verticalX !== null) { if (distance(nodeMaxX, verticalX) < tolerance) dx = verticalX - nodeMaxX; if (distance(nodeMinX, verticalX) < tolerance) dx = verticalX - nodeMinX; if (distance(nodeCenterX, verticalX) < tolerance) dx = verticalX - nodeCenterX; if (dx !== 0) this.isVerticalSticking = true; } if (horizontalY !== null) { if (distance(nodeMaxY, horizontalY) < tolerance) dy = horizontalY - nodeMaxY; if (distance(nodeMinY, horizontalY) < tolerance) dy = horizontalY - nodeMinY; if (distance(nodeCenterY, horizontalY) < tolerance) dy = horizontalY - nodeCenterY; if (dy !== 0) this.isHorizontalSticking = true; } if (dx !== 0 || dy !== 0) { // Stick to the line await this.context.graph.translateElementBy({ [nodeId]: [dx, dy] }, false); } }; /** * Get the delta of the drag * @param event - drag event object * @returns delta * @internal */ protected getDelta(event: IDragEvent) { const zoom = this.context.graph.getZoom(); return divide([event.dx, event.dy], zoom); } private enableSnap = (event: IDragEvent) => { const { target } = event; const threshold = 0.5; if (this.isHorizontalSticking || this.isVerticalSticking) { const [dx, dy] = this.getDelta(event); if ( this.isHorizontalSticking && this.isVerticalSticking && Math.abs(dx) <= threshold && Math.abs(dy) <= threshold ) { this.context.graph.translateElementBy({ [target.id]: [-dx, -dy] }, false); return false; } else if (this.isHorizontalSticking && Math.abs(dy) <= threshold) { this.context.graph.translateElementBy({ [target.id]: [0, -dy] }, false); return false; } else if (this.isVerticalSticking && Math.abs(dx) <= threshold) { this.context.graph.translateElementBy({ [target.id]: [-dx, 0] }, false); return false; } else { this.isHorizontalSticking = false; this.isVerticalSticking = false; this.enableStick = false; setTimeout(() => { this.enableStick = true; }, 200); } } return this.enableStick; }; private calcSnaplineMetadata = (target: Node, nodeBBox: AABB): Metadata => { const { tolerance, shape } = this.options; const { min: [nodeMinX, nodeMinY], max: [nodeMaxX, nodeMaxY], center: [nodeCenterX, nodeCenterY], } = nodeBBox; let verticalX: number | null = null; let verticalMinY: number | null = null; let verticalMaxY: number | null = null; let horizontalY: number | null = null; let horizontalMinX: number | null = null; let horizontalMaxX: number | null = null; this.getNodes().some((snapNode: Node) => { if (isEqual(target.id, snapNode.id)) return false; const snapBBox = getShape(snapNode, shape).getRenderBounds(); const { min: [snapMinX, snapMinY], max: [snapMaxX, snapMaxY], center: [snapCenterX, snapCenterY], } = snapBBox; if (verticalX === null) { if (distance(snapCenterX, nodeCenterX) < tolerance) { verticalX = snapCenterX; } else if (distance(snapMinX, nodeMinX) < tolerance) { verticalX = snapMinX; } else if (distance(snapMinX, nodeMaxX) < tolerance) { verticalX = snapMinX; } else if (distance(snapMaxX, nodeMaxX) < tolerance) { verticalX = snapMaxX; } else if (distance(snapMaxX, nodeMinX) < tolerance) { verticalX = snapMaxX; } if (verticalX !== null) { verticalMinY = Math.min(snapMinY, nodeMinY); verticalMaxY = Math.max(snapMaxY, nodeMaxY); } } if (horizontalY === null) { if (distance(snapCenterY, nodeCenterY) < tolerance) { horizontalY = snapCenterY; } else if (distance(snapMinY, nodeMinY) < tolerance) { horizontalY = snapMinY; } else if (distance(snapMinY, nodeMaxY) < tolerance) { horizontalY = snapMinY; } else if (distance(snapMaxY, nodeMaxY) < tolerance) { horizontalY = snapMaxY; } else if (distance(snapMaxY, nodeMinY) < tolerance) { horizontalY = snapMaxY; } if (horizontalY !== null) { horizontalMinX = Math.min(snapMinX, nodeMinX); horizontalMaxX = Math.max(snapMaxX, nodeMaxX); } } return verticalX !== null && horizontalY !== null; }); return { verticalX, verticalMinY, verticalMaxY, horizontalY, horizontalMinX, horizontalMaxX }; }; protected onDragStart = () => { this.initSnapline(); }; protected onDrag = async (event: IDragEvent) => { const { target } = event; if (this.options.autoSnap) { const enable = this.enableSnap(event); if (!enable) return; } const nodeBBox = getShape(target, this.options.shape).getRenderBounds(); const metadata = this.calcSnaplineMetadata(target, nodeBBox); this.hideSnapline(); if (metadata.verticalX !== null || metadata.horizontalY !== null) { this.updateSnapline(metadata); } if (this.options.autoSnap) { await this.autoSnapToLine(target.id, nodeBBox, metadata); } }; protected onDragEnd = () => { this.hideSnapline(); }; private async bindEvents() { const { graph } = this.context; graph.on(NodeEvent.DRAG_START, this.onDragStart); graph.on(NodeEvent.DRAG, this.onDrag); graph.on(NodeEvent.DRAG_END, this.onDragEnd); } private unbindEvents() { const { graph } = this.context; graph.off(NodeEvent.DRAG_START, this.onDragStart); graph.off(NodeEvent.DRAG, this.onDrag); graph.off(NodeEvent.DRAG_END, this.onDragEnd); } private destroyElements() { this.horizontalLine?.destroy(); this.verticalLine?.destroy(); } public destroy() { this.destroyElements(); this.unbindEvents(); super.destroy(); } } const distance = (a: number, b: number) => Math.abs(a - b); const getShape = (node: Node, shapeFilter: string | ((node: Node) => DisplayObject)) => { return typeof shapeFilter === 'function' ? shapeFilter(node) : node.getShape(shapeFilter); }; ================================================ FILE: packages/g6/src/plugins/timebar.ts ================================================ import { Timebar as TimebarComponent } from '@antv/component'; import { Canvas } from '@antv/g'; import { isArray, isDate, isNumber } from '@antv/util'; import { idOf } from '../utils/id'; import { parsePadding } from '../utils/padding'; import { BasePlugin } from './base-plugin'; import type { TimebarStyleProps as TimebarComponentStyleProps } from '@antv/component'; import type { RuntimeContext } from '../runtime/types'; import type { GraphData } from '../spec'; import type { ElementDatum, ElementType, ID, Padding } from '../types'; import type { BasePluginOptions } from './base-plugin'; import { createPluginCanvas } from './utils/canvas'; const prospectiveTimeKeys = ['timestamp', 'time', 'date', 'datetime']; /** * Timebar 时间条的配置项。 * The options of the Timebar. */ export interface TimebarOptions extends BasePluginOptions { /** * 给工具栏的 DOM 追加的类名,便于自定义样式 * * The class name appended to the menu DOM for custom styles * @defaultValue 'g6-timebar' */ className?: string; /** * X 位置 * * X position * @remarks * 设置后 `position` 会失效 * * `position` will be invalidated after setting `x` */ x?: number; /** * Y 位置 * * Y position * @remarks * 设置后 `position` 会失效 * * `position` will be invalidated after setting `y` */ y?: number; /** * 时间条宽度 * * Timebar width * @defaultValue 450 */ width?: number; /** * 时间条高度 * * Timebar height * @defaultValue 60 */ height?: number; /** * Timebar 的位置 * * Timebar location * @defaultValue 'bottom' */ position?: 'bottom' | 'top'; /** * 边距 * * Padding */ padding?: Padding; /** * 获取元素时间 * * Get element time */ getTime?: (datum: ElementDatum) => number; /** * 时间数据 * * Time data * @remarks * `timebarType` 为 `'chart'` 时,需要额外传入 `value` 字段作为图表数据 * * When `timebarType` is `'chart'`, you need to pass in the `value` field as chart data */ data: number[] | { time: number; value: number }[]; /** * Timebar 展示类型 * - `'time'`: 显示为时间轴 * - `'chart'`: 显示为趋势图 * * Timebar Displays the type * - `'time'`: Display as a timeline * - `'chart'`: Display as a trend chart * @defaultValue 'time' */ timebarType?: 'time' | 'chart'; /** * 筛选类型 * * Filter element types */ elementTypes?: ElementType[]; /** * 筛选模式 * - `'modify'`: 通过修改图数据进行筛选 * - `'visibility'`: 通过修改元素可见性进行筛选 * * Filter mode * - `'modify'`: Filter by modifying the graph data. * - `'visibility'`: Filter by modifying element visibility * @defaultValue 'modify' */ mode?: 'modify' | 'visibility'; /** * 当前时间值 * * Current time value */ values?: number | [number, number] | Date | [Date, Date]; /** * 图表模式下自定义时间格式化 * * Custom time formatting in chart mode */ labelFormatter?: (time: number | Date) => string; /** * 是否循环播放 * * Whether to loop * @defaultValue false */ loop?: boolean; /** * 时间区间变化时执行的回调 * * Callback executed when the time interval changes */ onChange?: (values: number | [number, number]) => void; /** * 重置时执行的回调 * * Callback executed when reset */ onReset?: () => void; /** * 播放速度变化时执行的回调 * * Callback executed when the playback speed changes */ onSpeedChange?: (speed: number) => void; /** * 开始播放时执行的回调 * * Callback executed when playback starts */ onPlay?: () => void; /** * 暂停时执行的回调 * * Callback executed when paused */ onPause?: () => void; /** * 后退时执行的回调 * * Callback executed when backward */ onBackward?: () => void; /** * 前进时执行的回调 * * Callback executed when forward */ onForward?: () => void; } /** * 时间组件 * * Timebar */ export class Timebar extends BasePlugin { static defaultOptions: Partial = { position: 'bottom', enable: true, timebarType: 'time', className: 'g6-timebar', width: 450, height: 60, zIndex: 3, elementTypes: ['node'], padding: 10, mode: 'modify', getTime: (datum) => inferTime(datum, prospectiveTimeKeys, undefined), loop: false, }; private timebar?: TimebarComponent; private canvas?: Canvas; private container?: HTMLElement; private originalData?: GraphData; private get padding() { return parsePadding(this.options.padding); } constructor(context: RuntimeContext, options: TimebarOptions) { super(context, Object.assign({}, Timebar.defaultOptions, options)); this.backup(); this.upsertTimebar(); } /** * 播放 * * Play */ public play() { this.timebar?.play(); } /** * 暂停 * * Pause */ public pause() { this.timebar?.pause(); } /** * 前进 * * Forward */ public forward() { this.timebar?.forward(); } /** * 后退 * * Backward */ public backward() { this.timebar?.backward(); } /** * 重置 * * Reset */ public reset() { this.timebar?.reset(); } /** * 更新时间条配置项 * * Update timebar configuration options * @param options - 配置项 | Options * @internal */ public update(options: Partial) { super.update(options); this.backup(); this.upsertTimebar(); } /** * 备份数据 * * Backup data */ private backup() { this.originalData = shallowCopy(this.context.graph.getData()); } private upsertTimebar() { const { canvas } = this.context; const { onChange, timebarType, data, x, y, width, height, mode, ...restOptions } = this.options; const canvasSize = canvas.getSize(); const [top] = this.padding; this.upsertCanvas().ready.then(() => { const style: TimebarComponentStyleProps = { x: canvasSize[0] / 2 - width / 2, y: top, onChange: (value) => { const range = (isArray(value) ? value : [value, value]).map((time) => isDate(time) ? time.getTime() : time, ) as [number, number]; if (this.options.mode === 'modify') this.filterElements(range); else this.hiddenElements(range); onChange?.(range); }, ...restOptions, data: data.map((datum) => (isNumber(datum) ? { time: datum, value: 0 } : datum)), width, height, type: timebarType, }; if (!this.timebar) { this.timebar = new TimebarComponent({ style }); this.canvas?.appendChild(this.timebar); } else { this.timebar.update(style); } }); } private upsertCanvas() { if (this.canvas) return this.canvas; const { className, height, position } = this.options; const graphCanvas = this.context.canvas; const [width] = graphCanvas.getSize(); const [top, , bottom] = this.padding; const [$container, canvas] = createPluginCanvas({ width, height: height + top + bottom, graphCanvas, className: 'timebar', placement: position, }); this.container = $container; if (className) $container.classList.add(className); this.canvas = canvas; return this.canvas; } private async filterElements(range: number | [number, number]) { if (!this.originalData) return; const { elementTypes, getTime } = this.options; const { graph, element } = this.context; const newData = shallowCopy(this.originalData); elementTypes.forEach((type) => { const key = `${type}s` as const; newData[key] = (this.originalData![key] || []).filter((datum) => { const time = getTime(datum); if (match(time, range)) return true; return false; }) as any; }); const nodeLikeIds = [...newData.nodes, ...newData.combos].map((datum) => idOf(datum)); newData.edges = newData.edges!.filter((edge) => { const source = edge.source; const target = edge.target; return nodeLikeIds.includes(source) && nodeLikeIds.includes(target); }); graph.setData(newData); await element!.draw({ animation: false, silence: true })?.finished; } private hiddenElements(range: number | [number, number]) { const { graph } = this.context; const { elementTypes, getTime } = this.options; const hideElementId: ID[] = []; const showElementId: ID[] = []; elementTypes.forEach((elementType) => { const key = `${elementType}s` as const; const elementData = this.originalData?.[key] || []; elementData.forEach((elementDatum) => { const id = idOf(elementDatum); const time = getTime(elementDatum); if (match(time, range)) showElementId.push(id); else hideElementId.push(id); }); }); graph.hideElement(hideElementId, false); graph.showElement(showElementId, false); } /** * 销毁时间条 * * Destroy the timebar * @internal */ public destroy(): void { const { graph } = this.context; this.originalData && graph.setData({ ...this.originalData }); this.timebar?.destroy(); this.canvas?.destroy(); this.container?.remove(); this.originalData = undefined; this.container = undefined; this.timebar = undefined; this.canvas = undefined; super.destroy(); } } const shallowCopy = (data: GraphData) => { const { nodes = [], edges = [], combos = [] } = data; return { nodes: [...nodes], edges: [...edges], combos: [...combos], }; }; const match = (time: number, range: number | [number, number]) => { if (isNumber(range)) return time === range; const [start, end] = range; return time >= start && time <= end; }; const inferTime = (datum: ElementDatum, optionsKeys: string[], defaultValue?: any): number => { for (let i = 0; i < optionsKeys.length; i++) { const key = optionsKeys[i]; const val = datum.data?.[key]; if (val) return val as number; } return defaultValue; }; ================================================ FILE: packages/g6/src/plugins/title/index.ts ================================================ import { Canvas } from '@antv/g'; import { GraphEvent } from '../../constants'; import type { LabelStyleProps } from '../../elements/shapes/label'; import { Label } from '../../elements/shapes/label'; import type { RuntimeContext } from '../../runtime/types'; import type { Prefix } from '../../types'; import { parsePadding } from '../../utils/padding'; import { subStyleProps } from '../../utils/prefix'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { createPluginCanvas } from '../utils/canvas'; const commonStyle: Partial = { fill: '#1D2129', wordWrap: true, // 自动换行 maxLines: 1, // 最大行数 textOverflow: 'ellipsis', // 溢出隐藏省略号 textBaseline: 'top', /** * textAlign 需要和 x 结合使用 * 举例: 前提条件: 画布 width = 600 * - textAlign: 'start' | 'left * 需要设 x = 0 * - textAlign: 'end' | 'right' * 需要设 x = 600 (即画布的宽度) * - textAlign: 'center' * 需要设 x = 300 (即画布的宽度 / 2) */ textAlign: 'start', x: 0, }; const defaultTitleStyle: Partial = { ...commonStyle, fillOpacity: 0.9, fontSize: 16, fontWeight: 'bold', }; const defaultSubTitleStyle: Partial = { ...commonStyle, fillOpacity: 0.65, fontSize: 12, fontWeight: 'normal', }; const defaultOptions: Partial = { align: 'left', spacing: 8, size: 44, padding: [16, 24, 0, 24], }; const titleKey = 'title'; const subtitleKey = 'subtitle'; /** * 标题的样式 * * Title styles */ export type TitleStyle = Prefix>; /** * 副标题的样式 * * Subtitle styles */ export type SubTitleStyle = Prefix>; /** * 标题插件配置项 * * Title plugin options */ export interface TitleOptions extends BasePluginOptions, TitleStyle, SubTitleStyle { /** * 整个标题的高度 * * whole title height * @defaultValue 44 */ size?: number; /** * 整个标题位于图的位置 * * The entire title is located at the position of the graph * @defaultValue 'left' */ align?: 'left' | 'center' | 'right'; /** * 主标题、副标题之间的上下间距 * * The y spacing between the title and subtitle * @defaultValue 8 */ spacing?: number; /** * 标题内边距 * * whole title padding * @defaultValue [16, 24, 0, 24] */ padding?: number | number[]; /** * 标题内容 * * title text */ [titleKey]: string; /** * 副标题内容 * * subtitle text */ [subtitleKey]?: string | null; /** * 标题画布类名,传入外置容器时不生效 * * The class name of the title canvas, which does not take effect when an external container is passed in */ className?: string; } export class Title extends BasePlugin { private canvas!: Canvas; private container!: HTMLElement; private get padding() { return parsePadding(this.options.padding); } constructor(context: RuntimeContext, options: TitleOptions) { const combineOption = Object.assign({}, defaultOptions, options); super(context, combineOption); this.bindEvents(); } private onRender = () => { const canvas = this.updateCanvas(); this.renderTitle(canvas); }; private bindEvents() { const { graph } = this.context; graph.on(GraphEvent.AFTER_RENDER, this.onRender); graph.on(GraphEvent.AFTER_ANIMATE, this.onRender); } private unbindEvents() { const { graph } = this.context; graph.off(GraphEvent.AFTER_RENDER, this.onRender); graph.off(GraphEvent.AFTER_ANIMATE, this.onRender); } public destroy(): void { this.unbindEvents(); this.canvas?.destroy(); this.container?.remove(); super.destroy(); } private updateCanvas() { const { size, className, align } = this.options; const [width] = this.context.canvas.getSize(); const [pt = 0, , pb = 0] = this.padding; const height = size + pt + pb; if (this.canvas) { const { width: w, height: h } = this.canvas.getConfig(); if (width !== w || height !== h) this.canvas.resize(width, height); } else { const positions = { left: 'left-top', center: 'top', right: 'right-top', } as const; const [$container, canvas] = createPluginCanvas({ width, height, placement: positions[align] || positions.left, className: 'title-canvas', graphCanvas: this.context.canvas, }); if (className) $container.classList.add(className); this.container = $container; this.canvas = canvas; } return this.canvas; } private renderTitle(canvas: Canvas) { const titles = new TitleComponent({ options: this.options, ctx: this.context, }); canvas.removeChildren(); titles.getTitle().forEach((label) => { if (label) canvas.appendChild(label); }); } } class TitleComponent { private options: TitleOptions; private context: RuntimeContext; private get padding() { return parsePadding(this.options.padding); } constructor(props: { ctx: RuntimeContext; options: TitleOptions }) { const { options, ctx } = props; this.options = options; this.context = ctx; } public getTitle() { const { [titleKey]: propsTitle, [subtitleKey]: propsSubtitle, spacing = 44, padding, align, ...style } = this.options; const titleText = propsTitle; const subTitleText = propsSubtitle; const titleStyle = subStyleProps(style, titleKey) as LabelStyleProps; const subtitleStyle = subStyleProps(style, subtitleKey) as LabelStyleProps; const [topGraphWidth] = this.context.graph.getSize(); const [pt = 0, pr = 0, , pl = 0] = this.padding; const canvasWidth = topGraphWidth; const textWidth = canvasWidth - pl - pr; let subTitle: Label | null = null; let alignX = pl; let textAlign = 'left' as 'left' | 'center' | 'right'; switch (align) { case 'left': alignX = pl; textAlign = 'left'; break; case 'center': alignX = canvasWidth / 2; textAlign = 'center'; break; case 'right': alignX = canvasWidth - pr; textAlign = 'right'; break; default: alignX = pl; textAlign = 'left'; } const title = new Label({ className: titleKey, style: { ...defaultTitleStyle, wordWrapWidth: textWidth - 5, x: alignX, y: pt, textAlign, ...titleStyle, text: titleText, }, }); const titleBBox = title.getBBox(); if (subTitleText) { subTitle = new Label({ className: 'subTitle', style: { ...defaultSubTitleStyle, wordWrapWidth: textWidth - 5, x: alignX, y: titleBBox.height + spacing + pt, textAlign, ...subtitleStyle, text: subTitleText, }, }); } return [title, subTitle]; } } ================================================ FILE: packages/g6/src/plugins/toolbar/index.ts ================================================ import type { RuntimeContext } from '../../runtime/types'; import type { CornerPlacement } from '../../types'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { createPluginContainer, insertDOM } from '../utils/dom'; import type { ToolbarItem } from './util'; import { BUILD_IN_SVG_ICON, TOOLBAR_CSS, parsePositionToStyle } from './util'; /** * Toolbar 工具栏的配置项 * * The options of the Toolbar toolbar */ export interface ToolbarOptions extends BasePluginOptions { /** * 给工具栏的 DOM 追加的 classname,便于自定义样式。默认是包含 `g6-toolbar` * * The classname appended to the menu DOM for custom styles. The default is `g6-toolbar` */ className?: string; /** * Toolbar 的位置,相对于画布,会影响 DOM 的 style 样式 * * The position of the Toolbar relative to the canvas, which will affect the style of the DOM * @defaultValue 'top-left' */ position?: CornerPlacement; /** * 工具栏显式的 style 样式,可以用来设置它相对于画布的位置、背景容器样式等 * * The style style of the Toolbar, which can be used to set its position relative to the canvas */ style?: Partial; /** * 当工具栏被点击后,触发的回调方法 * * The callback method triggered when the toolbar item is clicked */ onClick?: (value: string, target: Element) => void; /** * 返回工具栏的项目列表,支持 `Promise` 类型的返回值 * * Return the list of toolbar items, support return a `Promise` as items */ getItems: () => ToolbarItem[] | Promise; } /** * 工具栏,支持配置工具栏项目,以及点击之后的回调方法 * * Toolbar, support configuration of toolbar items, and callback methods after clicking */ export class Toolbar extends BasePlugin { static defaultOptions: Partial = { position: 'top-left', }; private $element: HTMLElement = createPluginContainer('toolbar', false); constructor(context: RuntimeContext, options: ToolbarOptions) { super(context, Object.assign({}, Toolbar.defaultOptions, options)); const $container = this.context.canvas.getContainer(); this.$element.style.display = 'flex'; $container!.appendChild(this.$element); // 设置样式 insertDOM('g6-toolbar-css', 'style', {}, TOOLBAR_CSS, document.head); insertDOM('g6-toolbar-svgicon', 'div', { display: 'none' }, BUILD_IN_SVG_ICON); this.$element.addEventListener('click', this.onToolbarItemClick); this.update(options); } /** * 更新工具栏的配置项 * * Update the configuration of the toolbar * @param options - 工具栏的配置项 | The options of the toolbar * @internal */ public async update(options: Partial) { super.update(options); const { className, position, style } = this.options; this.$element.className = `g6-toolbar ${className || ''}`; // 设置容器的样式,主要是位置,背景之类的 Object.assign(this.$element.style, style, parsePositionToStyle(position)); this.$element.innerHTML = await this.getDOMContent(); } /** * 销毁工具栏 * * Destroy the toolbar * @internal */ public destroy(): void { this.$element.removeEventListener('click', this.onToolbarItemClick); this.$element.remove(); super.destroy(); } private async getDOMContent() { const items = await this.options.getItems(); return items .map( (item) => `
`, ) .join(''); } private onToolbarItemClick = (e: MouseEvent) => { const { onClick } = this.options; if (e.target instanceof Element) { if (e.target.className.includes('g6-toolbar-item')) { const v = e.target.getAttribute('value') as string; onClick?.(v, e.target); } } }; } ================================================ FILE: packages/g6/src/plugins/toolbar/util.ts ================================================ import type { CornerPlacement } from '../../types'; /** * 工具栏显示项目。 * * The item of the toolbar. */ export interface ToolbarItem { /** * 可以使用 id 来配置内置的工具栏项,可以是 'zoom-in'、'zoom-out'、'auto-fit'、'reset' 等值,也可以配合三方的 iconfont 使用,原理是通过 id 来匹配内置的 svg symbol。See: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol。 * * You can use id to configure the built-in toolbar items, which can be values such as 'zoom-in', 'zoom-out', 'auto-fit', 'reset', etc. One of the two configurations with `marker`. The principle is to match the built-in svg symbol through id. See: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol. */ readonly id: 'zoom-in' | 'zoom-out' | 'redo' | 'undo' | 'edit' | 'delete' | 'auto-fit' | 'export' | 'reset' | string; /** * 工具栏项对应的值,在 onClick 中作为回调参数。 * * The value corresponding to the toolbar item, used as a callback parameter in `onClick`. */ readonly value: string; /** * The title of the toolbar item. The title will be displayed as a tooltip when the mouse hovers over the item. */ readonly title?: string; } /** * 解析 toolbar 的 position 为位置样式。 * * Parse the position of the toolbar into position style. * @param position - position * @returns style */ export function parsePositionToStyle(position: CornerPlacement): Partial { const style: Partial = { top: 'unset', right: 'unset', bottom: 'unset', left: 'unset', }; const directions = position.split('-') as ('top' | 'right' | 'bottom' | 'left')[]; directions.forEach((d) => { style[d] = '8px'; }); style.flexDirection = position.startsWith('top') || position.startsWith('bottom') ? 'row' : 'column'; return style; } /** * 内置默认的 toolbar 的 CSS 样式。 */ export const TOOLBAR_CSS = ` .g6-toolbar { position: absolute; z-index: 100; display: flex; flex-direction: row; align-items: center; justify-content: center; border-radius: 4px; box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.1); opacity: 0.65; } .g6-toolbar .g6-toolbar-item { display: inline-block; width: 16px; height: 16px; padding: 4px; cursor: pointer; box-sizing: content-box; } .g6-toolbar .g6-toolbar-item:hover { background-color: #f0f0f0; } .g6-toolbar .g6-toolbar-item svg { display: inline-block; width: 100%; height: 100%; pointer-events: none; } `; export const BUILD_IN_SVG_ICON = ` `; ================================================ FILE: packages/g6/src/plugins/tooltip.ts ================================================ import type { TooltipStyleProps } from '@antv/component'; import { Tooltip as TooltipComponent } from '@antv/component'; import { get } from '@antv/util'; import type { RuntimeContext } from '../runtime/types'; import type { ElementDatum, ElementType, ID, IElementEvent } from '../types'; import { isToBeDestroyed } from '../utils/element'; import type { BasePluginOptions } from './base-plugin'; import { BasePlugin } from './base-plugin'; /** * 提示框插件配置项 * * Tooltip plugin options */ export interface TooltipOptions extends BasePluginOptions, Pick { /** * 触发行为,可选 hover | click * - `'hover'`:鼠标移入元素时触发 * - `'click'`:鼠标点击元素时触发 * * Trigger behavior, optional hover | click * - `'hover'`:mouse hover element * - `'click'`:mouse click element * @defaultValue 'hover */ trigger?: 'hover' | 'click'; /** * 自定义内容 * * Function for getting tooltip content */ getContent?: (event: IElementEvent, items: ElementDatum[]) => Promise; /** * 是否启用 * * Is enable * @defaultValue true */ enable?: boolean | ((event: IElementEvent, items: ElementDatum[]) => boolean); /** * 显示隐藏的回调 * * Callback executed when visibility of the tooltip card is changed */ onOpenChange: (open: boolean) => void; } /** * 提示框插件 * * Tooltip plugin */ export class Tooltip extends BasePlugin { static defaultOptions: Partial = { trigger: 'hover', position: 'top-right', enterable: false, enable: true, offset: [10, 10], style: { '.tooltip': { visibility: 'hidden', }, }, }; private currentTarget: string | null = null; private tooltipElement: TooltipComponent | null = null; private container: HTMLElement | null = null; constructor(context: RuntimeContext, options: TooltipOptions) { super(context, Object.assign({}, Tooltip.defaultOptions, options)); this.render(); this.bindEvents(); } /** * 获取事件及处理事件的方法 * * Get event and handle event methods * @returns 事件及处理事件的方法 | Event and handling event methods */ private getEvents(): { [key: string]: (event: IElementEvent) => void } { if (this.options.trigger === 'click') { return { 'node:click': this.onClick, 'edge:click': this.onClick, 'combo:click': this.onClick, 'canvas:click': this.onPointerLeave, contextmenu: this.onPointerLeave, drag: this.onPointerLeave, }; } return { 'node:pointerover': this.onPointerOver, 'node:pointermove': this.onPointerMove, 'canvas:pointermove': this.onCanvasMove, 'edge:pointerover': this.onPointerOver, 'edge:pointermove': this.onPointerMove, 'combo:pointerover': this.onPointerOver, 'combo:pointermove': this.onPointerMove, contextmenu: this.onPointerLeave, 'node:drag': this.onPointerLeave, }; } /** * 更新tooltip配置 * * Update the tooltip configuration * @param options - 配置项 | options * @internal */ public update(options: Partial) { this.unbindEvents(); super.update(options); if (this.tooltipElement) { this.container?.removeChild(this.tooltipElement.HTMLTooltipElement); } this.tooltipElement = this.initTooltip(); this.bindEvents(); } private render() { const { canvas } = this.context; const $container = canvas.getContainer(); if (!$container) return; this.container = $container; this.tooltipElement = this.initTooltip(); } private unbindEvents() { const { graph } = this.context; /** The previous event binding needs to be removed when updating the trigger. */ const events = this.getEvents(); Object.keys(events).forEach((eventName) => { graph.off(eventName, events[eventName]); }); } private bindEvents() { const { graph } = this.context; const events = this.getEvents(); Object.keys(events).forEach((eventName) => { graph.on(eventName, events[eventName]); }); } private isEnable = (event: IElementEvent, items: ElementDatum[]) => { const { enable } = this.options; if (typeof enable === 'function') { return enable(event, items); } return enable; }; /** * 点击事件 * * Click event * @param event - 元素 | element */ public onClick = (event: IElementEvent) => { const { target: { id }, } = event; // click the same item twice, tooltip will be hidden if (this.currentTarget === id) { this.hide(event); } else { this.show(event); } }; /** * 在目标元素(node/edge/combo)上移动 * * Move on target element (node/edge/combo) * @param event - 目标元素 | target element */ public onPointerMove = (event: IElementEvent) => { const { target } = event; if (!this.currentTarget || target.id === this.currentTarget) { return; } this.show(event); }; /** * 点击画布/触发拖拽/出现上下文菜单隐藏tooltip * * Hide tooltip when clicking canvas/triggering drag/appearing context menu * @param event - 目标元素 | target element */ public onPointerLeave = (event: IElementEvent) => { this.hide(event); }; /** * 移动画布 * * Move canvas * @param event - 目标元素 | target element */ public onCanvasMove = (event: IElementEvent) => { this.hide(event); }; private onPointerOver = (event: IElementEvent) => { this.show(event); }; /** * 显示目标元素的提示框 * * Show tooltip of target element * @param id - 元素 ID | element ID */ public showById = async (id: ID) => { const event = { target: { id }, } as IElementEvent; await this.show(event); }; private getElementData = (id: ID, targetType: ElementType) => { const { model } = this.context; switch (targetType) { case 'node': return model.getNodeData([id]); case 'edge': return model.getEdgeData([id]); case 'combo': return model.getComboData([id]); default: return []; } }; /** * 在目标元素上显示tooltip * * Show tooltip on target element * @param event - 目标元素 | target element * @internal */ public show = async (event: IElementEvent) => { const { client, target: { id }, } = event; if (isToBeDestroyed(event.target)) return; const targetType = this.context.graph.getElementType(id); const { getContent, title } = this.options; const items: ElementDatum[] = this.getElementData(id, targetType as ElementType); if (!this.tooltipElement) return; // if shown, when is not enable, hide if (!this.isEnable(event, items)) { this.hide(event); return; } let tooltipContent: { [key: string]: unknown } = {}; if (getContent) { tooltipContent.content = await getContent(event, items); if (!tooltipContent.content) return; } else { const style = this.context.graph.getElementRenderStyle(id); const color = targetType === 'node' ? style.fill : style.stroke; tooltipContent = { title: title || targetType, data: items.map((item) => { return { name: 'ID', value: item.id || `${item.source} -> ${item.target}`, color, }; }), }; } this.currentTarget = id; let x; let y; if (client) { x = client.x; y = client.y; } else { const style = get(items, '0.style', { x: 0, y: 0 }); x = style.x; y = style.y; } this.options.onOpenChange?.(true); this.tooltipElement.update({ ...this.tooltipStyleProps, x, y, style: { '.tooltip': { visibility: 'visible', }, }, ...tooltipContent, }); }; /** * 隐藏tooltip * * Hidden tooltip * @param event - 目标元素,不传则为外部调用 | Target element, not passed in as external call */ public hide = (event?: IElementEvent) => { // if e is undefined, hide the tooltip, external call if (!event) { this.options.onOpenChange?.(false); this.tooltipElement?.hide(); this.currentTarget = null; return; } if (!this.tooltipElement) return; // No target node: tooltip has been hidden. No need for duplicated call. if (!this.currentTarget) return; const { client: { x, y }, } = event; this.options.onOpenChange?.(false); this.tooltipElement.hide(x, y); this.currentTarget = null; }; private get tooltipStyleProps() { const { canvas } = this.context; const { center } = canvas.getBounds(); const $container = canvas.getContainer() as HTMLElement; const { top, left } = $container.getBoundingClientRect(); const { style, position, enterable, container = { x: -left, y: -top }, title, offset } = this.options; const [x, y] = center; const [width, height] = canvas.getSize(); return { x, y, container, title, bounding: { x: 0, y: 0, width, height }, position, enterable, offset, style, }; } private initTooltip = () => { const tooltipElement = new TooltipComponent({ className: 'tooltip', style: this.tooltipStyleProps, }); this.container?.appendChild(tooltipElement.HTMLTooltipElement); return tooltipElement; }; /** * 销毁tooltip * * Destroy tooltip * @internal */ public destroy(): void { this.unbindEvents(); if (this.tooltipElement) { this.container?.removeChild(this.tooltipElement.HTMLTooltipElement); } super.destroy(); } } ================================================ FILE: packages/g6/src/plugins/types.ts ================================================ import type { BasePlugin } from './base-plugin'; export type Plugin = BasePlugin; ================================================ FILE: packages/g6/src/plugins/utils/canvas.ts ================================================ import type { IRenderer } from '@antv/g'; import { Canvas as GCanvas } from '@antv/g'; import { Renderer } from '@antv/g-canvas'; import { Canvas } from '../../runtime/canvas'; import type { Placement } from '../../types'; import { parsePlacement } from '../../utils/placement'; import { createPluginContainer } from './dom'; interface Options { /** 插件宽度 | Plugin width */ width: number; /** 插件高度 | Plugin height */ height: number; /** 渲染器 | Render */ renderer?: IRenderer; /** 插件放置位置 | Plugin placement */ placement: Placement; /** 插件类名 | Plugin class name */ className: string; /** 指定插件放置容器 | Specify the plugin placement container */ container?: string | HTMLElement; /** 容器样式 | Container style */ containerStyle?: Partial; /** G6 画布 | G6 canvas */ graphCanvas: Canvas; } /** * 创建插件画布 * * Create a plugin canvas * @param options - 配置项 | options * @returns [容器, 画布] | [container, canvas] */ export function createPluginCanvas(options: Options): [HTMLElement, GCanvas] { const { width, height, renderer } = options; const $container = getContainer(options); const canvas = new GCanvas({ width, height, container: $container, renderer: renderer || new Renderer(), }); return [$container, canvas]; } /** * 获取容器 * * Get container * @param options - 配置项 | options * @returns 容器 | container */ function getContainer(options: Options) { const { container, className, graphCanvas } = options; if (container) { return typeof container === 'string' ? document.getElementById(container)! : container; } const $container = createPluginContainer(className, false); const { width, height, containerStyle } = options; const [x, y] = computePosition(options); Object.assign($container.style, { position: 'absolute', left: x + 'px', top: y + 'px', width: width + 'px', height: height + 'px', ...containerStyle, }); graphCanvas.getContainer()?.appendChild($container); return $container; } /** * 计算容器位置 * * Compute the position of the container * @param options - 配置项 | options * @returns 位置 | position */ function computePosition(options: Options) { const { width, height, placement, graphCanvas } = options; const [W, H] = graphCanvas.getSize(); const [xRatio, yRatio] = parsePlacement(placement); return [xRatio * (W - width), yRatio * (H - height)]; } ================================================ FILE: packages/g6/src/plugins/utils/dom.ts ================================================ /** * 创建插件容器 * * Create a plugin container * @param type - 插件类型 | plugin type * @param cover - 容器是否覆盖整个画布 | Whether the container covers the entire canvas * @param style - 额外样式 | Additional style * @returns 插件容器 | plugin container */ export function createPluginContainer(type: string, cover = true, style?: Partial): HTMLElement { const container = document.createElement('div'); container.setAttribute('class', `g6-${type}`); Object.assign(container.style, { position: 'absolute', display: 'block', }); if (cover) { Object.assign(container.style, { position: 'unset', gridArea: '1 / 1 / 2 / 2', inset: '0px', height: '100%', width: '100%', overflow: 'hidden', pointerEvents: 'none', }); } if (style) Object.assign(container.style, style); return container; } /** * 创建 DOM 元素,如果存在则删除,再创建一个新的 * * Create a DOM element, if exists, remove it and create a new one. * @param id - id | id * @param tag - 标签 | tag * @param style - 样式 | style * @param innerHTML - 内容 | innerHTML * @param container - 容器 | container * @returns 创建的 DOM 元素 | created DOM element */ export function insertDOM( id: string, tag = 'div', style: Partial = {}, innerHTML = '', container: HTMLElement = document.body, ) { const dom = document.getElementById(id); if (dom) dom.remove(); const div = document.createElement(tag); div.innerHTML = innerHTML; div.id = id; Object.assign(div.style, style); container.appendChild(div); return div; } ================================================ FILE: packages/g6/src/plugins/watermark/index.ts ================================================ import type { RuntimeContext } from '../../runtime/types'; import type { BasePluginOptions } from '../base-plugin'; import { BasePlugin } from '../base-plugin'; import { createPluginContainer } from '../utils/dom'; import { getImageWatermark, getTextWatermark } from './util'; /** * 水印配置项 * * Watermark options */ export interface WatermarkOptions extends BasePluginOptions { /** * 水印的宽度(单个) * * The width of watermark(single) * @defaultValue 200 */ width?: number; /** * 水印的高度(单个) * * The height of watermark(single) * @defaultValue 100 */ height?: number; /** * 水印的透明度 * * The opacity of watermark * @defaultValue 0.2 */ opacity?: number; /** * 水印的旋转角度 * * The rotate angle of watermark * @defaultValue Math.PI / 12 */ rotate?: number; /** * 图片地址,如果有值,则使用,否则使用文本 * * The image url, if it has a value, it will be used, otherwise it will use the text */ imageURL?: string; /** * 水印文本 * * The text of watermark */ text?: string; /** * 文本水印的文字颜色 * * The color of text watermark * @defaultValue '#000' */ textFill?: string; /** * 文本水印的文本大小 * * The font size of text watermark * @defaultValue 16 */ textFontSize?: number; /** * 文本水印的文本字体 * * The font of text watermark */ textFontFamily?: string; /** * 文本水印的文本字体粗细 * * The font weight of text watermark */ textFontWeight?: string; /** * 文本水印的文本字体变体 * * The font variant of text watermark */ textFontVariant?: string; /** * 文本水印的文本对齐方式 * * The text align of text watermark * @defaultValue 'center' */ textAlign?: 'center' | 'end' | 'left' | 'right' | 'start'; /** * 文本水印的文本对齐基线 * * The text baseline of text watermark * @defaultValue 'middle' */ textBaseline?: 'alphabetic' | 'bottom' | 'hanging' | 'ideographic' | 'middle' | 'top'; /** * 水印的背景定位行为 * * The background attachment of watermark */ backgroundAttachment?: string; /** * 水印的背景混合 * * The background blend of watermark */ backgroundBlendMode?: string; /** * 水印的背景裁剪 * * The background clip of watermark */ backgroundClip?: string; /** * 水印的背景颜色 * * The background color of watermark */ backgroundColor?: string; /** * 水印的背景图片 * * The background image of watermark */ backgroundImage?: string; /** * 水印的背景原点 * * The background origin of watermark */ backgroundOrigin?: string; /** * 水印的背景位置 * * The background position of watermark */ backgroundPosition?: string; /** * 水印的背景位置-x * * The background position-x of watermark */ backgroundPositionX?: string; /** * 水印的背景位置-y * * The background position-y of watermark */ backgroundPositionY?: string; /** * 水印的背景重复 * * The background repeat of watermark * @defaultValue 'repeat' */ backgroundRepeat?: string; /** * 水印的背景大小 * * The background size of watermark */ backgroundSize?: string; } /** * 水印 * * Watermark * @remarks * 支持使用文本和图片作为水印,实现原理是在 Graph 容器的 div 上加上 `background-image` 属性,然后就可以通过 css 来控制水印的位置和样式。对于文本,会使用隐藏 canvas 转成图片的方式来实现 * * Support using text and image as watermark, the principle is to add the `background-image` property to the div of the Graph container, and then you can control the position and style of the watermark through css. For text, it will be converted to an image using a hidden canvas */ export class Watermark extends BasePlugin { static defaultOptions: Partial = { width: 200, height: 100, opacity: 0.2, rotate: Math.PI / 12, text: '', textFill: '#000', textFontSize: 16, textAlign: 'center', textBaseline: 'middle', backgroundRepeat: 'repeat', }; private $element: HTMLElement = createPluginContainer('watermark'); constructor(context: RuntimeContext, options: WatermarkOptions) { super(context, Object.assign({}, Watermark.defaultOptions, options)); const $container = this.context.canvas.getContainer(); $container!.appendChild(this.$element); this.update(options); } /** * 更新水印配置 * * Update the watermark configuration * @param options - 配置项 | Options * @internal */ public async update(options: Partial) { super.update(options); const { width, height, text, imageURL, ...rest } = this.options; // Set the background style. Object.keys(rest).forEach((key) => { if (key.startsWith('background')) { // @ts-expect-error ignore this.$element.style[key] = options[key]; } }); // Set the background image const base64 = imageURL ? await getImageWatermark(width, height, imageURL, rest) : await getTextWatermark(width, height, text, rest); this.$element.style.backgroundImage = `url(${base64})`; } /** * 销毁水印 * * Destroy the watermark * @internal */ public destroy(): void { super.destroy(); // Remove the background dom. this.$element.remove(); } } ================================================ FILE: packages/g6/src/plugins/watermark/util.ts ================================================ // Only use one instance. let canvas: HTMLCanvasElement; /** * Create a canvas instance. * @param width - width * @param height - height * @returns a new Canvas */ function createCanvas(width: number, height: number): HTMLCanvasElement { if (!canvas) { canvas = document.createElement('canvas'); } canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx!.clearRect(0, 0, width, height); return canvas; } /** * 从文本获取水印的 base64 * @param width - width * @param height - height * @param text - 样式 * @param style - 样式 * @returns 水印的 base64 */ export async function getTextWatermark(width: number, height: number, text: string, style: any) { const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d')!; const { rotate, opacity, textFill, textFontSize, textFontFamily, textFontVariant, textFontWeight, textAlign, textBaseline, } = style; // Set the style. // Default is align center and middle. ctx.textAlign = textAlign; ctx.textBaseline = textBaseline; ctx.translate(width / 2, height / 2); ctx.font = `${textFontSize}px ${textFontFamily} ${textFontVariant} ${textFontWeight}`; rotate && ctx.rotate(rotate); opacity && (ctx.globalAlpha = opacity); if (textFill) { ctx.fillStyle = textFill; // Draw the text. ctx.fillText(`${text}`, 0, 0); } // Return the base64. return canvas.toDataURL(); } /** * Get the image base64 of the watermark. * @param width - width * @param height - height * @param imageURL - image URL * @param style - 样式 * @returns 水印的 base64 */ export async function getImageWatermark(width: number, height: number, imageURL: string, style: any) { const canvas = createCanvas(width, height); const ctx = canvas.getContext('2d')!; const { rotate, opacity } = style; rotate && ctx.rotate(rotate); opacity && (ctx.globalAlpha = opacity); const img = new Image(); img.crossOrigin = 'anonymous'; img.src = imageURL; return new Promise((resolve) => { img.onload = function () { const sepX = width > img.width ? (width - img.width) / 2 : 0; const sepY = height > img.height ? (height - img.height) / 2 : 0; ctx.drawImage(img, 0, 0, img.width, img.height, sepX, sepY, width - sepX * 2, height - sepY * 2); resolve(canvas.toDataURL()); }; }); } ================================================ FILE: packages/g6/src/preset.ts ================================================ import { registerBuiltInExtensions } from './registry/build-in'; registerBuiltInExtensions(); ================================================ FILE: packages/g6/src/registry/build-in.ts ================================================ import { Circle as GCircle, Ellipse as GEllipse, Group as GGroup, HTML as GHTML, Line as GLine, Path as GPath, Polygon as GPolygon, Polyline as GPolyline, Rect as GRect, Text as GText, } from '@antv/g'; import { ComboCollapse, ComboExpand, Fade, NodeCollapse, NodeExpand, PathIn, PathOut, Translate } from '../animations'; import { AutoAdaptLabel, BrushSelect, ClickSelect, CollapseExpand, CreateEdge, DragCanvas, DragElement, DragElementForce, FixElementSize, FocusElement, HoverActivate, LassoSelect, OptimizeViewportTransform, ScrollCanvas, ZoomCanvas, } from '../behaviors'; import { Circle, CircleCombo, Cubic, CubicHorizontal, CubicRadial, CubicVertical, Diamond, Donut, Ellipse, HTML, Hexagon, Image, Line, Polyline, Quadratic, Rect, RectCombo, Star, Triangle, } from '../elements'; import { Badge as BadgeShape, Image as ImageShape, Label as LabelShape } from '../elements/shapes'; import { AntVDagreLayout, CircularLayout, ComboCombinedLayout, ConcentricLayout, D3ForceLayout, DagreLayout, FishboneLayout, ForceAtlas2Layout, ForceLayout, FruchtermanLayout, GridLayout, MDSLayout, RadialLayout, RandomLayout, SnakeLayout, compactBox, dendrogram, indented, mindmap, } from '../layouts'; import { blues, greens, oranges, spectral, tableau } from '../palettes'; import { Background, BubbleSets, Contextmenu, EdgeBundling, EdgeFilterLens, Fisheye, Fullscreen, GridLine, History, Hull, Legend, Minimap, Snapline, Timebar, Title, Toolbar, Tooltip, Watermark, } from '../plugins'; import { dark, light } from '../themes'; import { ArrangeDrawOrder, CollapseExpandCombo, CollapseExpandNode, GetEdgeActualEnds, MapNodeSize, PlaceRadialLabels, ProcessParallelEdges, UpdateRelatedEdge, } from '../transforms'; import type { ExtensionRegistry } from './types'; /** * 内置插件统一在这里注册。 * Built-in extensions are registered here. */ const BUILT_IN_EXTENSIONS: ExtensionRegistry = { animation: { 'combo-collapse': ComboCollapse, 'combo-expand': ComboExpand, 'node-collapse': NodeCollapse, 'node-expand': NodeExpand, 'path-in': PathIn, 'path-out': PathOut, fade: Fade, translate: Translate, }, behavior: { 'brush-select': BrushSelect, 'click-select': ClickSelect, 'collapse-expand': CollapseExpand, 'create-edge': CreateEdge, 'drag-canvas': DragCanvas, 'drag-element-force': DragElementForce, 'drag-element': DragElement, 'fix-element-size': FixElementSize, 'focus-element': FocusElement, 'hover-activate': HoverActivate, 'lasso-select': LassoSelect, 'auto-adapt-label': AutoAdaptLabel, 'optimize-viewport-transform': OptimizeViewportTransform, 'scroll-canvas': ScrollCanvas, 'zoom-canvas': ZoomCanvas, }, combo: { circle: CircleCombo, rect: RectCombo, }, edge: { cubic: Cubic, line: Line, polyline: Polyline, quadratic: Quadratic, 'cubic-horizontal': CubicHorizontal, 'cubic-radial': CubicRadial, 'cubic-vertical': CubicVertical, }, layout: { 'antv-dagre': AntVDagreLayout, 'combo-combined': ComboCombinedLayout, 'compact-box': compactBox as any, 'd3-force': D3ForceLayout, 'force-atlas2': ForceAtlas2Layout, circular: CircularLayout, concentric: ConcentricLayout, dagre: DagreLayout, dendrogram: dendrogram as any, fishbone: FishboneLayout, force: ForceLayout, fruchterman: FruchtermanLayout, grid: GridLayout, indented: indented as any, mds: MDSLayout, mindmap: mindmap as any, radial: RadialLayout, random: RandomLayout, snake: SnakeLayout, }, node: { circle: Circle, diamond: Diamond, ellipse: Ellipse, hexagon: Hexagon, html: HTML, image: Image, rect: Rect, star: Star, donut: Donut, triangle: Triangle, }, palette: { spectral, tableau, oranges, greens, blues, }, theme: { dark, light, }, plugin: { 'bubble-sets': BubbleSets, 'edge-bundling': EdgeBundling, 'edge-filter-lens': EdgeFilterLens, 'grid-line': GridLine, background: Background, contextmenu: Contextmenu, fisheye: Fisheye, fullscreen: Fullscreen, history: History, hull: Hull, legend: Legend, minimap: Minimap, snapline: Snapline, timebar: Timebar, title: Title, toolbar: Toolbar, tooltip: Tooltip, watermark: Watermark, }, transform: { 'arrange-draw-order': ArrangeDrawOrder, 'collapse-expand-combo': CollapseExpandCombo, 'collapse-expand-node': CollapseExpandNode, 'get-edge-actual-ends': GetEdgeActualEnds, 'map-node-size': MapNodeSize, 'place-radial-labels': PlaceRadialLabels, 'process-parallel-edges': ProcessParallelEdges, 'update-related-edges': UpdateRelatedEdge, }, shape: { circle: GCircle, ellipse: GEllipse, group: GGroup, html: GHTML, image: ImageShape, line: GLine, path: GPath, polygon: GPolygon, polyline: GPolyline, rect: GRect, text: GText, label: LabelShape, badge: BadgeShape, }, }; import type { ExtensionCategory } from '../constants'; import { register } from './register'; /** * 注册内置扩展 * * Register built-in extensions */ export function registerBuiltInExtensions() { Object.entries(BUILT_IN_EXTENSIONS).forEach(([category, extensions]) => { Object.entries(extensions).forEach(([type, extension]) => { register(category as ExtensionCategory, type, extension as any); }); }); } ================================================ FILE: packages/g6/src/registry/extension/index.ts ================================================ import type EventEmitter from '@antv/event-emitter'; import type { Graph } from '../../runtime/graph'; import type { RuntimeContext } from '../../runtime/types'; import type { IEvent } from '../../types'; import { arrayDiff } from '../../utils/diff'; import { parseExtensions } from '../../utils/extension'; import { print } from '../../utils/print'; import { getExtension } from '../get'; import type { STDExtensionOption } from './types'; export abstract class ExtensionController> { protected context: RuntimeContext; protected extensions: STDExtensionOption[] = []; protected extensionMap: Record = {}; public abstract category: 'plugin' | 'behavior' | 'transform'; constructor(context: RuntimeContext) { this.context = context; } public setExtensions( extensions: ( | string | { type: string; [keys: string]: unknown } | ((this: Graph) => { type: string; [keys: string]: unknown }) )[], ) { const stdExtensions = parseExtensions(this.context.graph, this.category, extensions) as STDExtensionOption[]; const { enter, update, exit, keep } = arrayDiff(this.extensions, stdExtensions, (extension) => extension.key); this.createExtensions(enter); this.updateExtensions([...update, ...keep]); this.destroyExtensions(exit); this.extensions = stdExtensions; } protected createExtension(extension: STDExtensionOption) { const { category } = this; const { key, type } = extension; const Ctor = getExtension(category, type); if (!Ctor) return print.warn(`The extension ${type} of ${category} is not registered.`); const instance = new Ctor(this.context, extension); instance.initialized = true; this.extensionMap[key] = instance as E; } protected createExtensions(extensions: STDExtensionOption[]) { extensions.forEach((extension) => this.createExtension(extension)); } protected updateExtension(extension: STDExtensionOption) { const { key } = extension; const instance = this.extensionMap[key]; if (instance) { instance.update(extension); } } protected updateExtensions(extensions: STDExtensionOption[]) { extensions.forEach((extension) => this.updateExtension(extension)); } protected destroyExtension(key: string) { const instance = this.extensionMap[key]; if (!instance) return; if (instance.initialized && !instance.destroyed) { instance.destroy(); } delete this.extensionMap[key]; } protected destroyExtensions(extensions: STDExtensionOption[]) { extensions.forEach(({ key }) => this.destroyExtension(key)); } public destroy() { this.destroyExtensions(this.extensions); // @ts-expect-error force delete this.context = {}; this.extensions = []; this.extensionMap = {}; } } /** * 模块实例基类 * * Base class for extension instance */ export class BaseExtension { protected context: RuntimeContext; protected options: Required; protected events: [EventEmitter | HTMLElement, string, (event: IEvent) => void][] = []; public initialized = false; public destroyed = false; constructor(context: RuntimeContext, options: Partial) { this.context = context; this.options = options as Required; } public update(options: Partial) { this.options = Object.assign(this.options, options); } public destroy() { // @ts-expect-error force delete this.context = {}; // @ts-expect-error force delete this.options = {}; this.destroyed = true; } } ================================================ FILE: packages/g6/src/registry/extension/types.ts ================================================ /** * 标准扩展配置项 * * Standard extension options */ export interface STDExtensionOption { /** * 扩展类型 * * Extension type */ type: string; /** * 扩展 key,即唯一标识 * * Extension key, that is, the unique identifier */ key: string; [key: string]: unknown; } ================================================ FILE: packages/g6/src/registry/get.ts ================================================ import { ExtensionCategory } from '../constants'; import type { Loosen } from '../types'; import { EXTENSION_REGISTRY } from './store'; import type { ExtensionRegistry } from './types'; /** * 根据类别和类型获取扩展 * * Get the extension by category and type * @param category - 扩展类别 | Extension category * @param type - 扩展类型 | Extension type * @returns 注册的扩展 | Registered extension * @internal */ export function getExtension( category: Loosen, type: string, ): ExtensionRegistry[T][string] | undefined { const extension = EXTENSION_REGISTRY[category]?.[type]; if (extension) { return extension as ExtensionRegistry[T][string]; } return undefined; } /** * 根据类别获取扩展 * * Get the extension by category and type * @param category - 扩展类别 | Extension category * @returns 注册的扩展 | Registered extension * @internal */ export function getExtensions>(category: T): ExtensionRegistry[T] { return EXTENSION_REGISTRY[category]; } ================================================ FILE: packages/g6/src/registry/register.ts ================================================ import type { ExtensionCategory } from '../constants'; import type { Loosen } from '../types'; import { print } from '../utils/print'; import { EXTENSION_REGISTRY } from './store'; import type { ExtensionRegistry } from './types'; /** * 注册一个新的扩展。 * * Registers a new extension. * @param category * 扩展要注册的分类,目前支持注册的扩展分类有:{@link ExtensionCategory} * * The category under which the extension is to be registered, see {@link ExtensionCategory} * @param type * 要注册的扩展的类型,将作为使用扩展时的标识 * * Extension type that used as an identifier when mounting the extension on a graph * @param Ctor * 要注册的扩展类,在使用时创建实例 * * Whether to override the registered extension * @remarks * 内置扩展在项目导入时会自动注册。对于非内置扩展,可以通过 `register` 方法手动注册。扩展只需要注册一次,即可在项目的任何位置使用。 * * Built-in extensions are automatically registered when the project is imported. For non-built-in extensions, you can manually register them using the `register` method. Extensions only need to be registered once and can be used anywhere in the project. * @example * ```ts * import { register, BaseNode } from '@antv/g6'; * * class CircleNode extends BaseNode {} * * register('node', 'circle-node', CircleNode); * ``` * @public */ export function register( category: Loosen, type: string, Ctor: ExtensionRegistry[T][string], ) { const ext = EXTENSION_REGISTRY[category][type]; if (ext) { print.warn(`The extension ${type} of ${category} has been registered before, and will be overridden.`); } Object.assign(EXTENSION_REGISTRY[category]!, { [type]: Ctor }); } ================================================ FILE: packages/g6/src/registry/store.ts ================================================ import type { ExtensionRegistry } from './types'; /** * 扩展注册表 * * Extension registry */ export const EXTENSION_REGISTRY: ExtensionRegistry = { animation: {}, behavior: {}, combo: {}, edge: {}, layout: {}, node: {}, palette: {}, theme: {}, plugin: {}, transform: {}, shape: {}, }; ================================================ FILE: packages/g6/src/registry/types.ts ================================================ import type { DisplayObject } from '@antv/g'; import type { STDAnimation } from '../animations/types'; import type { Behavior } from '../behaviors/types'; import type { Layout } from '../layouts/types'; import type { STDPalette } from '../palettes/types'; import type { Plugin } from '../plugins/types'; import type { Theme } from '../themes/types'; import type { Transform } from '../transforms/types'; import type { Combo, Edge, Node } from '../types'; /** * 扩展注册表 * * Extension registry */ export interface ExtensionRegistry { node: Record; edge: Record; combo: Record; theme: Record; // theme is a object options palette: Record; layout: Record; behavior: Record; plugin: Record; animation: Record; // animation spec transform: Record; shape: Record; } ================================================ FILE: packages/g6/src/runtime/animation.ts ================================================ import type { IAnimation } from '@antv/g'; import { executor } from '../animations/executor'; import type { AnimationContext, AnimationEffectTiming } from '../animations/types'; import type { ID, Point } from '../types'; import { createAnimationsProxy, getElementAnimationOptions, inferDefaultValue } from '../utils/animation'; import { getCachedStyle } from '../utils/cache'; import type { RuntimeContext } from './types'; export class Animation { private context: RuntimeContext; constructor(context: RuntimeContext) { this.context = context; } private tasks: [AnimationContext, AnimationCallbacks | undefined][] = []; private animations: Set = new Set(); private getTasks() { const tasks = [...this.tasks]; this.tasks = []; return tasks; } public add(context: AnimationContext, callbacks?: AnimationCallbacks) { this.tasks.push([context, callbacks]); } public animate( localAnimation?: AnimationEffectTiming | boolean, callbacks?: AnimationCallbacks, extendOptions?: ExtendOptions, ) { callbacks?.before?.(); const animations = this.getTasks() .map(([context, cb]) => { const { element, elementType, stage } = context; const options = getElementAnimationOptions(this.context.options, elementType, stage, localAnimation); cb?.before?.(); const animation = options.length ? executor(element, this.inferStyle(context, extendOptions), options) : null; if (animation) { cb?.beforeAnimate?.(animation); animation.finished.then(() => { cb?.afterAnimate?.(animation); cb?.after?.(); this.animations.delete(animation); }); } else cb?.after?.(); return animation; }) .filter(Boolean) as IAnimation[]; animations.forEach((animation) => this.animations.add(animation)); const animation = createAnimationsProxy(animations); if (animation) { callbacks?.beforeAnimate?.(animation); animation.finished.then(() => { callbacks?.afterAnimate?.(animation); callbacks?.after?.(); this.release(); }); } else callbacks?.after?.(); return animation; } /** * 推断额外的动画样式 * * Infer additional animation styles * @param context - 动画上下文 | Animation context * @param options - 扩展选项 | Extend options * @returns 始态样式与终态样式 | Initial style and final style */ public inferStyle( context: AnimationContext, options?: ExtendOptions, ): [Record, Record] { const { element, elementType, stage, originalStyle, updatedStyle = {} } = context; if (!context.modifiedStyle) context.modifiedStyle = { ...originalStyle, ...updatedStyle }; const { modifiedStyle } = context; const fromStyle: Record = {}; const toStyle: Record = {}; if (stage === 'enter') { Object.assign(fromStyle, { opacity: 0 }); } else if (stage === 'exit') { Object.assign(toStyle, { opacity: 0 }); } else if (stage === 'show') { Object.assign(fromStyle, { opacity: 0 }); Object.assign(toStyle, { opacity: getCachedStyle(element, 'opacity') ?? inferDefaultValue('opacity') }); } else if (stage === 'hide') { Object.assign(fromStyle, { opacity: getCachedStyle(element, 'opacity') ?? inferDefaultValue('opacity') }); Object.assign(toStyle, { opacity: 0 }); } else if (stage === 'collapse') { const { collapse } = options || {}; const { target, descendants, position } = collapse!; if (elementType === 'node') { // 为即将被删除的元素设置目标位置 // Set the target position for the element to be deleted if (descendants.includes(element.id)) { const [x, y, z] = position; Object.assign(toStyle, { x, y, z }); } } else if (elementType === 'combo') { if (element.id === target || descendants.includes(element.id)) { const [x, y] = position; Object.assign(toStyle, { x, y, childrenNode: originalStyle.childrenNode }); } } else if (elementType === 'edge') { Object.assign(toStyle, { sourceNode: modifiedStyle.sourceNode, targetNode: modifiedStyle.targetNode }); } } else if (stage === 'expand') { const { expand } = options || {}; const { target, descendants, position } = expand!; if (elementType === 'node') { // 设置展开节点的起点位置 // Set the starting position of the expanded node if (element.id === target || descendants.includes(element.id)) { const [x, y, z] = position; Object.assign(fromStyle, { x, y, z }); } } else if (elementType === 'combo') { // 设置展开后的组合子元素 // Set the child elements of the expanded combo if (element.id === target || descendants.includes(element.id)) { const [x, y, z] = position; Object.assign(fromStyle, { x, y, z, childrenNode: modifiedStyle.childrenNode }); } } else if (elementType === 'edge') { // 设置展开后的边的起点和终点 // Set the starting point and end point of the edge after expansion Object.assign(fromStyle, { sourceNode: modifiedStyle.sourceNode, targetNode: modifiedStyle.targetNode }); } } return [ Object.keys(fromStyle).length > 0 ? Object.assign({}, originalStyle, fromStyle) : originalStyle, Object.keys(toStyle).length > 0 ? Object.assign({}, modifiedStyle, toStyle) : modifiedStyle, ]; } public stop() { this.animations.forEach((animation) => animation.cancel()); } public clear() { this.tasks = []; } /** * 释放存量动画对象 * * Release stock animation objects * @description see: https://github.com/antvis/G/issues/1731 */ private release() { const { canvas } = this.context; // @ts-expect-error private property const animationsWithPromises = canvas.document?.timeline?.animationsWithPromises; if (animationsWithPromises) { // @ts-expect-error private property canvas.document.timeline.animationsWithPromises = animationsWithPromises.filter( (animation: IAnimation) => animation.playState !== 'finished', ); } } public destroy() { this.stop(); this.animations.clear(); this.tasks = []; } } interface AnimationCallbacks { before?: () => void; beforeAnimate?: (animation: IAnimation) => void; afterAnimate?: (animation: IAnimation) => void; after?: () => void; } interface ExtendOptions { /** * stage 为 collapse 时,指定当前展开/收起的目标元素及其后代元素 * * When the stage is collapse, specify the target element and its descendants to expand/collapse */ collapse?: { target: ID; descendants: ID[]; position: Point; }; /** * stage 为 expand 时,指定当前展开/收起的目标元素及其后代元素 * * When the stage is expand, specify the target element and its descendants to expand/collapse */ expand?: { target: ID; descendants: ID[]; position: Point; }; } ================================================ FILE: packages/g6/src/runtime/batch.ts ================================================ import { GraphEvent } from '../constants'; import type { BaseEvent } from '../utils/event'; import { GraphLifeCycleEvent } from '../utils/event'; import type { RuntimeContext } from './types'; export class BatchController { private context: RuntimeContext; private batchCount: number = 0; constructor(context: RuntimeContext) { this.context = context; } private emit(event: BaseEvent) { const { graph } = this.context; graph.emit(event.type, event); } public startBatch(initiate = true) { this.batchCount++; if (this.batchCount === 1) this.emit(new GraphLifeCycleEvent(GraphEvent.BATCH_START, { initiate })); } public endBatch() { this.batchCount--; if (this.batchCount === 0) this.emit(new GraphLifeCycleEvent(GraphEvent.BATCH_END)); } public get isBatching() { return this.batchCount > 0; } public destroy() { // @ts-ignore this.context = null; } } ================================================ FILE: packages/g6/src/runtime/behavior.ts ================================================ import type { DisplayObject, FederatedPointerEvent, FederatedWheelEvent } from '@antv/g'; import type { BaseBehavior } from '../behaviors/base-behavior'; import { CommonEvent, ContainerEvent } from '../constants'; import { ExtensionController } from '../registry/extension'; import type { BehaviorOptions, CustomBehaviorOption } from '../spec/behavior'; import type { Target } from '../types'; import { isToBeDestroyed } from '../utils/element'; import { eventTargetOf } from '../utils/event'; import type { RuntimeContext } from './types'; export class BehaviorController extends ExtensionController> { /** * 当前事件的目标 * * The current event target */ private currentTarget: Target | null = null; private currentTargetType: string | null = null; public category = 'behavior' as const; constructor(context: RuntimeContext) { super(context); this.forwardEvents(); this.setBehaviors(this.context.options.behaviors || []); } public setBehaviors(behaviors: BehaviorOptions) { this.setExtensions(behaviors); } private forwardEvents() { const container = this.context.canvas.getContainer(); if (container) { [ContainerEvent.KEY_DOWN, ContainerEvent.KEY_UP].forEach((name) => { container.addEventListener(name, this.forwardContainerEvents); }); } const canvas = this.context.canvas.document; if (canvas) { [ CommonEvent.CLICK, CommonEvent.DBLCLICK, CommonEvent.POINTER_OVER, CommonEvent.POINTER_LEAVE, CommonEvent.POINTER_ENTER, CommonEvent.POINTER_MOVE, CommonEvent.POINTER_OUT, CommonEvent.POINTER_DOWN, CommonEvent.POINTER_UP, CommonEvent.CONTEXT_MENU, CommonEvent.DRAG_START, CommonEvent.DRAG, CommonEvent.DRAG_END, CommonEvent.DRAG_ENTER, CommonEvent.DRAG_OVER, CommonEvent.DRAG_LEAVE, CommonEvent.DROP, CommonEvent.WHEEL, ].forEach((name) => { canvas.addEventListener(name, this.forwardCanvasEvents); }); } } private forwardCanvasEvents = (event: FederatedPointerEvent | FederatedWheelEvent) => { const { target: originalTarget } = event; const target = eventTargetOf(originalTarget as DisplayObject); if (!target) return; const { graph, canvas } = this.context; const { type: targetType, element: targetElement } = target; // 即将销毁或已销毁的元素不再触发事件 // Elements that are about to be destroyed or have been destroyed no longer trigger events if ('destroyed' in targetElement && (isToBeDestroyed(targetElement) || targetElement.destroyed)) return; const { type, detail, button } = event; const stdEvent = { ...event, target: targetElement, targetType, originalTarget }; if (type === CommonEvent.POINTER_MOVE) { if (this.currentTarget !== targetElement) { if (this.currentTarget) { graph.emit(`${this.currentTargetType}:${CommonEvent.POINTER_LEAVE}`, { ...stdEvent, type: CommonEvent.POINTER_LEAVE, target: this.currentTarget, targetType: this.currentTargetType, }); } if (targetElement) { Object.assign(stdEvent, { type: CommonEvent.POINTER_ENTER }); graph.emit(`${targetType}:${CommonEvent.POINTER_ENTER}`, stdEvent); } } this.currentTarget = targetElement; this.currentTargetType = targetType; } // 非右键点击事件 / Click event except right click if (!(type === CommonEvent.CLICK && button === 2)) { graph.emit(`${targetType}:${type}`, stdEvent); graph.emit(type, stdEvent); } // 双击事件 / Double click event if (type === CommonEvent.CLICK && detail === 2) { Object.assign(stdEvent, { type: CommonEvent.DBLCLICK }); graph.emit(`${targetType}:${CommonEvent.DBLCLICK}`, stdEvent); graph.emit(CommonEvent.DBLCLICK, stdEvent); } // 右键菜单 / Contextmenu if (type === CommonEvent.POINTER_DOWN && button === 2) { Object.assign(stdEvent, { type: CommonEvent.CONTEXT_MENU, preventDefault: () => { canvas.getContainer()?.addEventListener(CommonEvent.CONTEXT_MENU, (e) => e.preventDefault(), { once: true, }); }, }); graph.emit(`${targetType}:${CommonEvent.CONTEXT_MENU}`, stdEvent); graph.emit(CommonEvent.CONTEXT_MENU, stdEvent); } }; private forwardContainerEvents = (event: FocusEvent | KeyboardEvent) => { this.context.graph.emit(event.type, event); }; public destroy(): void { const container = this.context.canvas.getContainer(); if (container) { [ContainerEvent.KEY_DOWN, ContainerEvent.KEY_UP].forEach((name) => { container.removeEventListener(name, this.forwardContainerEvents); }); } this.context.canvas.document.removeAllEventListeners(); super.destroy(); } } ================================================ FILE: packages/g6/src/runtime/canvas.ts ================================================ import type { Cursor, DisplayObject, CanvasConfig as GCanvasConfig, IChildNode } from '@antv/g'; import { CanvasEvent, Canvas as GCanvas } from '@antv/g'; import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Plugin as DragNDropPlugin } from '@antv/g-plugin-dragndrop'; import { createDOM } from '@antv/util'; import type { CanvasOptions } from '../spec/canvas'; import type { CanvasLayer, Point } from '../types'; import { getBBoxSize, getCombinedBBox } from '../utils/bbox'; import { parsePoint, toPointObject } from '../utils/point'; export interface CanvasConfig extends Pick< GCanvasConfig, 'container' | 'devicePixelRatio' | 'width' | 'height' | 'cursor' | 'background' > { /** * 渲染器 * * 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 { /** * 导出模式 * - viewport: 导出视口内容 * - overall: 导出整个画布 * * export mode * - viewport: export the content of the viewport * - overall: export the entire canvas */ mode?: 'viewport' | 'overall'; /** * 图片类型 * * image type * @defaultValue 'image/png' */ type: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp'; /** * 图片质量, 仅对 image/jpeg 和 image/webp 有效,取值范围 0 ~ 1 * * image quality, only valid for image/jpeg and image/webp, range 0 ~ 1 */ encoderOptions: number; } 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: { config: CanvasConfig; renderer: CanvasOptions['renderer']; renderers: Record; layers: Record; }; private config: CanvasConfig = { enableMultiLayer: true, }; public getConfig() { return this.config; } public getLayer(layer: CanvasLayer = 'main') { return this.extends.layers[layer] || getMainLayerOf(this.getLayers()); } /** * 获取所有图层 * * Get all layers * @returns 图层 Layer */ public getLayers() { return this.extends.layers; } /** * 获取渲染器 * * Get renderer * @param layer - 图层 Layer * @returns 渲染器 Renderer */ public getRenderer(layer: CanvasLayer) { return this.extends.renderers[layer]; } /** * 获取相机 * * Get camera * @param layer - 图层 Layer * @returns 相机 Camera */ public getCamera(layer: CanvasLayer = 'main') { return this.getLayer(layer).getCamera(); } public getRoot(layer: CanvasLayer = 'main') { return this.getLayer(layer).getRoot(); } public getContextService(layer: CanvasLayer = 'main') { return this.getLayer(layer).getContextService(); } public setCursor(cursor: Cursor): void { this.config.cursor = cursor; this.getLayer().setCursor(cursor); } public get document() { return this.getLayer().document; } public get context() { return this.getLayer().context; } constructor(config: CanvasConfig) { 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: enableMultiLayer, renderer: renderers[layer], background: enableMultiLayer ? (layer === 'background' ? background : undefined) : background, }); return [layer, canvas]; }), ) as Record; configCanvasDom(layers); this.extends = { config: this.config, renderer, renderers, layers, }; } public get ready() { return Promise.all(Object.entries(this.getLayers()).map(([, canvas]) => canvas.ready)); } public resize(width: number, height: number) { Object.assign(this.extends.config, { width, height }); Object.values(this.getLayers()).forEach((canvas) => { const camera = canvas.getCamera(); const position = camera.getPosition(); const focalPoint = camera.getFocalPoint(); canvas.resize(width, height); camera.setPosition(position); camera.setFocalPoint(focalPoint); }); } /** * 获取画布边界 * * Get canvas boundary * @param group * 元素分组 * - undefined: 获取整个画布边界 * - 'elements': 仅获取元素边界 * - 'plugins': 仅获取插件边界 * * Element group * - undefined: Get the entire canvas boundary * - 'elements': Get only the element boundary * - 'plugins': Get only the plugin boundary * @returns 边界 Boundary */ public getBounds(group?: 'elements' | 'plugins') { return getCombinedBBox( Object.values(this.getLayers()) .map((canvas) => { const g = group ? (canvas .getRoot() .childNodes.find((node) => (node as DisplayObject).classList.includes(group)) as DisplayObject) : canvas.getRoot(); return g; }) .filter((el) => el?.childNodes.length > 0) .map((el) => el.getBounds()), ); } public getContainer() { const container = this.extends.config.container!; return typeof container === 'string' ? document.getElementById(container!) : container; } public getSize(): [number, number] { return [this.extends.config.width || 0, this.extends.config.height || 0]; } public appendChild(child: T, index?: number): T { const layer = ((child as unknown as DisplayObject).style?.$layer || 'main') as CanvasLayer; return this.getLayer(layer).appendChild(child, index); } public setRenderer(renderer: CanvasOptions['renderer']) { if (renderer === this.extends.renderer) return; 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()); } public getCanvasByViewport(point: Point): Point { return parsePoint(this.getLayer().viewport2Canvas(toPointObject(point))); } public getViewportByCanvas(point: Point): Point { return parsePoint(this.getLayer().canvas2Viewport(toPointObject(point))); } public getViewportByClient(point: Point): Point { return parsePoint(this.getLayer().client2Viewport(toPointObject(point))); } public getClientByViewport(point: Point): Point { return parsePoint(this.getLayer().viewport2Client(toPointObject(point))); } public getClientByCanvas(point: Point): Point { return this.getClientByViewport(this.getViewportByCanvas(point)); } public getCanvasByClient(point: Point): Point { const main = this.getLayer(); const viewportPoint = main.client2Viewport(toPointObject(point)); return parsePoint(main.viewport2Canvas(viewportPoint)); } public async toDataURL(options: Partial = {}) { const devicePixelRatio = globalThis.devicePixelRatio || 1; const { mode = 'viewport', ...restOptions } = options; let [startX, startY, width, height] = [0, 0, 0, 0]; if (mode === 'viewport') { [width, height] = this.getSize(); } else if (mode === 'overall') { const bounds = this.getBounds(); const size = getBBoxSize(bounds); [startX, startY] = bounds.min; [width, height] = size; } const container: HTMLElement = createDOM('
'); const offscreenCanvas = new GCanvas({ width, height, renderer: new CanvasRenderer(), devicePixelRatio, container, background: this.extends.config.background, }); await offscreenCanvas.ready; offscreenCanvas.appendChild(this.getLayer('background').getRoot().cloneNode(true)); offscreenCanvas.appendChild(this.getRoot().cloneNode(true)); // Handle label canvas const label = this.getLayer('label').getRoot().cloneNode(true); const originCanvasPosition = offscreenCanvas.viewport2Canvas({ x: 0, y: 0 }); const currentCanvasPosition = this.getCanvasByViewport([0, 0]); label.translate([ currentCanvasPosition[0] - originCanvasPosition.x, currentCanvasPosition[1] - originCanvasPosition.y, ]); label.scale(1 / this.getCamera().getZoom()); offscreenCanvas.appendChild(label); offscreenCanvas.appendChild(this.getLayer('transient').getRoot().cloneNode(true)); const camera = this.getCamera(); const offscreenCamera = offscreenCanvas.getCamera(); if (mode === 'viewport') { offscreenCamera.setZoom(camera.getZoom()); offscreenCamera.setPosition(camera.getPosition()); offscreenCamera.setFocalPoint(camera.getFocalPoint()); } else if (mode === 'overall') { const [x, y, z] = offscreenCamera.getPosition(); const [fx, fy, fz] = offscreenCamera.getFocalPoint(); offscreenCamera.setPosition([x + startX, y + startY, z]); offscreenCamera.setFocalPoint([fx + startX, fy + startY, fz]); } const contextService = offscreenCanvas.getContextService(); return new Promise((resolve) => { offscreenCanvas.addEventListener(CanvasEvent.RERENDER, async () => { // 等待图片渲染完成 / Wait for the image to render await new Promise((r) => setTimeout(r, 300)); const url = await contextService.toDataURL(restOptions); resolve(url); }); }); } public destroy() { Object.values(this.getLayers()).forEach((canvas) => { const camera = canvas.getCamera(); camera.cancelLandmarkAnimation(); canvas.destroy(); }); } } /** * 创建渲染器 * * Create renderers * @param renderer - 渲染器创建器 Renderer creator * @param layersName - 图层名称 Layer name * @returns 渲染器 Renderer */ function createRenderers(renderer: CanvasConfig['renderer'], layersName: CanvasLayer[]) { return Object.fromEntries( layersName.map((layer) => { const instance = renderer?.(layer) || new CanvasRenderer(); if (instance instanceof CanvasRenderer) { instance.setConfig({ enableDirtyRectangleRendering: false }); } if (layer === 'main') { instance.registerPlugin( new DragNDropPlugin({ isDocumentDraggable: true, isDocumentDroppable: true, dragstartDistanceThreshold: 10, dragstartTimeThreshold: 100, }), ); } else { instance.unregisterPlugin(instance.getPlugin('dom-interaction')); } return [layer, instance]; }), ) as Record; } /** * 配置画布 DOM * * Configure canvas DOM * @param layers - 画布 Canvas */ function configCanvasDom(layers: Record) { Object.entries(layers).forEach(([layer, canvas]) => { const domElement = canvas.getContextService().getDomElement() as unknown as HTMLElement; // 浏览器环境下,设置画布样式 // Set canvas style in browser environment if (domElement?.style) { domElement.style.gridArea = '1 / 1 / 2 / 2'; domElement.style.outline = 'none'; domElement.tabIndex = 1; if (layer !== 'main') domElement.style.pointerEvents = 'none'; } if (domElement?.parentElement) { domElement.parentElement.style.display = 'grid'; // 给父元素设置独立的层叠上下文,避免外部元素影响内部的层叠逻辑 domElement.parentElement.style.isolation = 'isolate'; } }); } ================================================ FILE: packages/g6/src/runtime/data.ts ================================================ import { Graph as GraphLib } from '@antv/graphlib'; import { isNil, isNumber, uniq } from '@antv/util'; import { COMBO_KEY, ChangeType, TREE_KEY } from '../constants'; import type { ComboData, EdgeData, GraphData, NodeData } from '../spec'; import type { DataAdded, DataChange, DataID, DataRemoved, DataUpdated, ElementDatum, HierarchyKey, ID, NodeLikeData, PartialEdgeData, PartialGraphData, PartialNodeLikeData, Point, State, } from '../types'; import type { EdgeDirection } from '../types/edge'; import type { ElementType } from '../types/element'; import { isCollapsed } from '../utils/collapsibility'; import { cloneElementData, isElementDataEqual, mergeElementsData } from '../utils/data'; import { arrayDiff } from '../utils/diff'; import { toG6Data, toGraphlibData } from '../utils/graphlib'; import { idOf, parentIdOf } from '../utils/id'; import { positionOf } from '../utils/position'; import { format, print } from '../utils/print'; import { dfs } from '../utils/traverse'; import { add } from '../utils/vector'; export class DataController { public model: GraphLib; /** * 最近一次删除的 combo 的 id * * The ids of the last deleted combos * @remarks * 当删除 combo 后,会将其 id 从 comboIds 中移除,此时根据 Graphlib 的 changes 事件获取到的 NodeRemoved 无法区分是 combo 还是 node。 * 因此需要记录最近一次删除的 combo 的 id,并用于 isCombo 的判断 * * When the combo is deleted, its id will be removed from comboIds. At this time, the NodeRemoved obtained according to the changes event of Graphlib cannot distinguish whether it is a combo or a node. * Therefore, it is necessary to record the id of the last deleted combo and use it to judge isCombo */ protected latestRemovedComboIds = new Set(); protected comboIds = new Set(); /** * 获取详细数据变更 * * Get detailed data changes */ private changes: DataChange[] = []; /** * 批处理计数器 * * Batch processing counter */ private batchCount = 0; /** * 是否处于无痕模式 * * Whether it is in traceless mode */ private isTraceless = false; constructor() { this.model = new GraphLib(); } private pushChange(change: DataChange) { if (this.isTraceless) return; const { type } = change; if (type === ChangeType.NodeUpdated || type === ChangeType.EdgeUpdated || type === ChangeType.ComboUpdated) { const { value, original } = change; this.changes.push({ value: cloneElementData(value), original: cloneElementData(original), type } as DataUpdated); } else { this.changes.push({ value: cloneElementData(change.value), type } as DataAdded | DataRemoved); } } public getChanges(): DataChange[] { return this.changes; } public clearChanges() { this.changes = []; } public batch(callback: () => void) { this.batchCount++; this.model.batch(callback); this.batchCount--; } protected isBatching() { return this.batchCount > 0; } /** * 执行操作而不会留下记录 * * Perform operations without leaving records * @param callback - 回调函数 | callback function * @remarks * 通常用于运行时调整元素并同步数据,避免触发数据变更导致重绘 * * Usually used to adjust elements at runtime and synchronize data to avoid triggering data changes and causing redraws */ public silence(callback: () => void) { this.isTraceless = true; callback(); this.isTraceless = false; } public isCombo(id: ID) { return this.comboIds.has(id) || this.latestRemovedComboIds.has(id); } public getData() { return { nodes: this.getNodeData(), edges: this.getEdgeData(), combos: this.getComboData(), }; } public getNodeData(ids?: ID[]) { return this.model.getAllNodes().reduce((acc, node) => { const data = toG6Data(node); if (this.isCombo(idOf(data))) return acc; if (ids === undefined) acc.push(data); else ids.includes(idOf(data)) && acc.push(data); return acc; }, [] as NodeData[]); } public getEdgeDatum(id: ID) { return toG6Data(this.model.getEdge(id)); } public getEdgeData(ids?: ID[]) { return this.model.getAllEdges().reduce((acc, edge) => { const data = toG6Data(edge); if (ids === undefined) acc.push(data); else ids.includes(idOf(data)) && acc.push(data); return acc; }, [] as EdgeData[]); } public getComboData(ids?: ID[]) { return this.model.getAllNodes().reduce((acc, combo) => { const data = toG6Data(combo); if (!this.isCombo(idOf(data))) return acc; if (ids === undefined) acc.push(data as ComboData); else ids.includes(idOf(data)) && acc.push(data as ComboData); return acc; }, [] as ComboData[]); } public getRootsData(hierarchyKey: HierarchyKey = TREE_KEY) { return this.model.getRoots(hierarchyKey).map(toG6Data); } public getAncestorsData(id: ID, hierarchyKey: HierarchyKey): NodeLikeData[] { const { model } = this; if (!model.hasNode(id) || !model.hasTreeStructure(hierarchyKey)) return []; return model.getAncestors(id, hierarchyKey).map(toG6Data); } public getDescendantsData(id: ID): NodeLikeData[] { const root = this.getElementDataById(id) as NodeLikeData; const data: NodeLikeData[] = []; dfs( root, (node) => { if (node !== root) data.push(node); }, (node) => this.getChildrenData(idOf(node)), 'TB', ); return data; } public getParentData(id: ID, hierarchyKey: HierarchyKey): NodeLikeData | undefined { const { model } = this; if (!hierarchyKey) { print.warn('The hierarchy structure key is not specified'); return undefined; } if (!model.hasNode(id) || !model.hasTreeStructure(hierarchyKey)) return undefined; const parent = model.getParent(id, hierarchyKey); return parent ? toG6Data(parent) : undefined; } public getChildrenData(id: ID): NodeLikeData[] { const structureKey = this.getElementType(id) === 'node' ? TREE_KEY : COMBO_KEY; const { model } = this; if (!model.hasNode(id) || !model.hasTreeStructure(structureKey)) return []; return model.getChildren(id, structureKey).map(toG6Data); } /** * 获取指定类型元素的数据 * * Get the data of the specified type of element * @param elementType - 元素类型 | element type * @returns 元素数据 | element data */ public getElementsDataByType(elementType: ElementType) { if (elementType === 'node') return this.getNodeData(); if (elementType === 'edge') return this.getEdgeData(); if (elementType === 'combo') return this.getComboData(); return []; } /** * 根据 ID 获取元素的数据,不用关心元素的类型 * * Get the data of the element by ID, no need to care about the type of the element * @param id - 元素 ID 数组 | element ID array * @returns 元素数据 | data of the element */ public getElementDataById(id: ID): ElementDatum { const type = this.getElementType(id); if (type === 'edge') return this.getEdgeDatum(id); return this.getNodeLikeDatum(id); } /** * 获取节点的数据 * * Get node data * @param id - 节点 ID | node ID * @returns 节点数据 | node data */ public getNodeLikeDatum(id: ID) { const data = this.model.getNode(id); return toG6Data(data); } /** * 获取所有节点和 combo 的数据 * * Get all node and combo data * @param ids - 节点和 combo ID 数组 | node and combo ID array * @returns 节点和 combo 的数据 | node and combo data */ public getNodeLikeData(ids?: ID[]) { return this.model.getAllNodes().reduce((acc, node) => { const data = toG6Data(node); if (ids) ids.includes(idOf(data)) && acc.push(data); else acc.push(data); return acc; }, [] as NodeLikeData[]); } public getElementDataByState(elementType: ElementType, state: string) { const elementData = this.getElementsDataByType(elementType); return elementData.filter((datum) => datum.states?.includes(state)); } public getElementState(id: ID): State[] { return this.getElementDataById(id)?.states || []; } public hasNode(id: ID) { return this.model.hasNode(id) && !this.isCombo(id); } public hasEdge(id: ID) { return this.model.hasEdge(id); } public hasCombo(id: ID) { return this.model.hasNode(id) && this.isCombo(id); } public getRelatedEdgesData(id: ID, direction: EdgeDirection = 'both') { return this.model.getRelatedEdges(id, direction).map(toG6Data) as EdgeData[]; } public getNeighborNodesData(id: ID) { return this.model.getNeighbors(id).map(toG6Data); } public setData(data: GraphData) { const { nodes: modifiedNodes = [], edges: modifiedEdges = [], combos: modifiedCombos = [] } = data; const { nodes: originalNodes, edges: originalEdges, combos: originalCombos } = this.getData(); const nodeDiff = arrayDiff(originalNodes, modifiedNodes, (node) => idOf(node), isElementDataEqual); const edgeDiff = arrayDiff(originalEdges, modifiedEdges, (edge) => idOf(edge), isElementDataEqual); const comboDiff = arrayDiff(originalCombos, modifiedCombos, (combo) => idOf(combo), isElementDataEqual); this.batch(() => { const dataToAdd = { nodes: nodeDiff.enter, edges: edgeDiff.enter, combos: comboDiff.enter, }; this.addData(dataToAdd); this.computeZIndex(dataToAdd, 'add', true); const dataToUpdate = { nodes: nodeDiff.update, edges: edgeDiff.update, combos: comboDiff.update, }; this.updateData(dataToUpdate); this.computeZIndex(dataToUpdate, 'update', true); const dataToRemove = { nodes: nodeDiff.exit.map(idOf), edges: edgeDiff.exit.map(idOf), combos: comboDiff.exit.map(idOf), }; this.removeData(dataToRemove); }); } public addData(data: GraphData) { const { nodes, edges, combos } = data; this.batch(() => { // add combo first this.addComboData(combos); this.addNodeData(nodes); this.addEdgeData(edges); }); this.computeZIndex(data, 'add'); } public addNodeData(nodes: NodeData[] = []) { if (!nodes.length) return; this.model.addNodes( nodes.map((node) => { this.pushChange({ value: node, type: ChangeType.NodeAdded }); return toGraphlibData(node); }), ); this.updateNodeLikeHierarchy(nodes); this.computeZIndex({ nodes }, 'add'); } public addEdgeData(edges: EdgeData[] = []) { if (!edges.length) return; this.model.addEdges( edges.map((edge) => { this.pushChange({ value: edge, type: ChangeType.EdgeAdded }); return toGraphlibData(edge); }), ); this.computeZIndex({ edges }, 'add'); } public addComboData(combos: ComboData[] = []) { if (!combos.length) return; const { model } = this; if (!model.hasTreeStructure(COMBO_KEY)) { model.attachTreeStructure(COMBO_KEY); } model.addNodes( combos.map((combo) => { this.comboIds.add(idOf(combo)); this.pushChange({ value: combo, type: ChangeType.ComboAdded }); return toGraphlibData(combo); }), ); this.updateNodeLikeHierarchy(combos); this.computeZIndex({ combos }, 'add'); } public addChildrenData(parentId: ID, childrenData: NodeData[]) { const parentData = this.getNodeLikeDatum(parentId) as NodeData; const childrenId = childrenData.map(idOf); this.addNodeData(childrenData); this.updateNodeData([{ id: parentId, children: [...(parentData.children || []), ...childrenId] }]); this.addEdgeData(childrenId.map((childId) => ({ source: parentId, target: childId }))); } /** * 计算 zIndex * * Calculate zIndex * @param data - 新增的数据 | newly added data * @param type - 操作类型 | operation type * @param force - 忽略批处理 | ignore batch processing * @remarks * 调用该函数的情况: * - 新增元素 * - 更新节点/组合的 combo * - 更新节点的 children * * The situation of calling this function: * - Add element * - Update the combo of the node/combo * - Update the children of the node */ protected computeZIndex(data: PartialGraphData, type: 'add' | 'update', force = false) { if (!force && this.isBatching()) return; this.batch(() => { const { nodes = [], edges = [], combos = [] } = data; combos.forEach((combo) => { const id = idOf(combo); if (type === 'add' && isNumber(combo.style?.zIndex)) return; if (type === 'update' && !('combo' in combo)) return; const parent = this.getParentData(id, COMBO_KEY); const zIndex = parent ? (parent.style?.zIndex ?? 0) + 1 : 0; this.preventUpdateNodeLikeHierarchy(() => { this.updateComboData([{ id, style: { zIndex } }]); }); }); nodes.forEach((node) => { const id = idOf(node); if (type === 'add' && isNumber(node.style?.zIndex)) return; if (type === 'update' && !('combo' in node) && !('children' in node)) return; let zIndex = 0; const comboParent = this.getParentData(id, COMBO_KEY); if (comboParent) { zIndex = (comboParent.style?.zIndex || 0) + 1; } else { const nodeParent = this.getParentData(id, TREE_KEY); if (nodeParent) zIndex = nodeParent?.style?.zIndex || 0; } this.preventUpdateNodeLikeHierarchy(() => { this.updateNodeData([{ id, style: { zIndex } }]); }); }); edges.forEach((edge) => { if (isNumber(edge.style?.zIndex)) return; let { id, source, target } = edge; if (!id) id = idOf(edge); else { const datum = this.getEdgeDatum(id); source = datum.source; target = datum.target; } if (!source || !target) return; const sourceZIndex = this.getNodeLikeDatum(source)?.style?.zIndex || 0; const targetZIndex = this.getNodeLikeDatum(target)?.style?.zIndex || 0; this.updateEdgeData([{ id: idOf(edge), style: { zIndex: Math.max(sourceZIndex, targetZIndex) - 1 } }]); }); }); } /** * 计算元素置顶后的 zIndex * * Calculate the zIndex after the element is placed on top * @param id - 元素 ID | ID of the element * @returns zIndex | zIndex */ public getFrontZIndex(id: ID) { const elementType = this.getElementType(id); const elementData = this.getElementDataById(id); const data = this.getData(); // 排除当前元素 / Exclude the current element Object.assign(data, { [`${elementType}s`]: data[`${elementType}s`].filter((element) => idOf(element) !== id), }); if (elementType === 'combo') { // 如果 combo 展开,则排除 combo 的子节点/combo 及内部边 // If the combo is expanded, exclude the child nodes/combos of the combo and the internal edges if (!isCollapsed(elementData as ComboData)) { const ancestorIds = new Set(this.getAncestorsData(id, COMBO_KEY).map(idOf)); data.nodes = data.nodes.filter((element) => !ancestorIds.has(idOf(element))); data.combos = data.combos.filter((element) => !ancestorIds.has(idOf(element))); data.edges = data.edges.filter(({ source, target }) => !ancestorIds.has(source) && !ancestorIds.has(target)); } } return Math.max( elementData.style?.zIndex || 0, 0, ...Object.values(data) .flat() .map((datum) => (datum?.style?.zIndex || 0) + 1), ); } protected updateNodeLikeHierarchy(data: NodeLikeData[]) { if (!this.enableUpdateNodeLikeHierarchy) return; const { model } = this; data.forEach((datum) => { const id = idOf(datum); const parent = parentIdOf(datum); if (parent !== undefined) { if (!model.hasTreeStructure(COMBO_KEY)) model.attachTreeStructure(COMBO_KEY); // 解除原父节点的子节点关系,更新原父节点及其祖先的数据 // Remove the child relationship of the original parent node, update the data of the original parent node and its ancestors if (parent === null) { this.refreshComboData(id); } this.setParent(id, parentIdOf(datum), COMBO_KEY); } const children = (datum as NodeData).children || []; if (children.length) { if (!model.hasTreeStructure(TREE_KEY)) model.attachTreeStructure(TREE_KEY); const _children = children.filter((child) => model.hasNode(child)); _children.forEach((child) => this.setParent(child, id, TREE_KEY)); if (_children.length !== children.length) { // 从数据中移除不存在的子节点 // Remove non-existent child nodes from the data this.updateNodeData([{ id, children: _children }]); } } }); } private enableUpdateNodeLikeHierarchy = true; /** * 执行变更时不要更新节点层次结构 * * Do not update the node hierarchy when executing changes * @param callback - 变更函数 | change function */ public preventUpdateNodeLikeHierarchy(callback: () => void) { this.enableUpdateNodeLikeHierarchy = false; callback(); this.enableUpdateNodeLikeHierarchy = true; } public updateData(data: PartialGraphData) { const { nodes, edges, combos } = data; this.batch(() => { this.updateNodeData(nodes); this.updateComboData(combos); this.updateEdgeData(edges); }); this.computeZIndex(data, 'update'); } public updateNodeData(nodes: PartialNodeLikeData[] = []) { if (!nodes.length) return; const { model } = this; this.batch(() => { const modifiedNodes: NodeData[] = []; nodes.forEach((modifiedNode) => { const id = idOf(modifiedNode); const originalNode = toG6Data(model.getNode(id)); if (isElementDataEqual(originalNode, modifiedNode)) return; const value = mergeElementsData(originalNode, modifiedNode); this.pushChange({ value, original: originalNode, type: ChangeType.NodeUpdated }); model.mergeNodeData(id, value); modifiedNodes.push(value); }); this.updateNodeLikeHierarchy(modifiedNodes); }); this.computeZIndex({ nodes }, 'update'); } /** * 将所有数据提交到变更记录中以进行重绘 * * Submit all data to the change record for redrawing */ public refreshData() { const { nodes, edges, combos } = this.getData(); nodes.forEach((node) => { this.pushChange({ value: node, original: node, type: ChangeType.NodeUpdated }); }); edges.forEach((edge) => { this.pushChange({ value: edge, original: edge, type: ChangeType.EdgeUpdated }); }); combos.forEach((combo) => { this.pushChange({ value: combo, original: combo, type: ChangeType.ComboUpdated }); }); } public syncNodeLikeDatum(datum: PartialNodeLikeData) { const { model } = this; const id = idOf(datum); if (!model.hasNode(id)) return; const original = toG6Data(model.getNode(id)); const value = mergeElementsData(original, datum); model.mergeNodeData(id, value); } public syncEdgeDatum(datum: PartialEdgeData) { const { model } = this; const id = idOf(datum); if (!model.hasEdge(id)) return; const original = toG6Data(model.getEdge(id)); const value = mergeElementsData(original, datum); model.mergeEdgeData(id, value); } public updateEdgeData(edges: PartialEdgeData[] = []) { if (!edges.length) return; const { model } = this; this.batch(() => { edges.forEach((modifiedEdge) => { const id = idOf(modifiedEdge); const originalEdge = toG6Data(model.getEdge(id)); if (isElementDataEqual(originalEdge, modifiedEdge)) return; if (modifiedEdge.source && originalEdge.source !== modifiedEdge.source) { model.updateEdgeSource(id, modifiedEdge.source); } if (modifiedEdge.target && originalEdge.target !== modifiedEdge.target) { model.updateEdgeTarget(id, modifiedEdge.target); } const updatedData = mergeElementsData(originalEdge, modifiedEdge); this.pushChange({ value: updatedData, original: originalEdge, type: ChangeType.EdgeUpdated }); model.mergeEdgeData(id, updatedData); }); }); this.computeZIndex({ edges }, 'update'); } public updateComboData(combos: PartialNodeLikeData[] = []) { if (!combos.length) return; const { model } = this; model.batch(() => { const modifiedCombos: ComboData[] = []; combos.forEach((modifiedCombo) => { const id = idOf(modifiedCombo); const originalCombo = toG6Data(model.getNode(id)) as ComboData; if (isElementDataEqual(originalCombo, modifiedCombo)) return; const value = mergeElementsData(originalCombo, modifiedCombo); this.pushChange({ value, original: originalCombo, type: ChangeType.ComboUpdated }); model.mergeNodeData(id, value); modifiedCombos.push(value); }); this.updateNodeLikeHierarchy(modifiedCombos); }); this.computeZIndex({ combos }, 'update'); } /** * 设置节点的父节点 * * Set the parent node of the node * @param id - 节点 ID | node ID * @param parent - 父节点 ID | parent node ID * @param hierarchyKey - 层次结构类型 | hierarchy type * @param update - 添加新/旧父节点数据更新记录 | add new/old parent node data update record */ public setParent(id: ID, parent: ID | undefined | null, hierarchyKey: HierarchyKey, update: boolean = true) { if (id === parent) return; const elementData = this.getNodeLikeDatum(id); const originalParentId = parentIdOf(elementData); if (originalParentId !== parent && hierarchyKey === COMBO_KEY) { const modifiedDatum = { id, combo: parent }; if (this.isCombo(id)) this.syncNodeLikeDatum(modifiedDatum); else this.syncNodeLikeDatum(modifiedDatum); } this.model.setParent(id, parent, hierarchyKey); if (update && hierarchyKey === COMBO_KEY) { uniq([originalParentId, parent]).forEach((pId) => { if (pId !== undefined) this.refreshComboData(pId); }); } } /** * 刷新 combo 数据 * * Refresh combo data * @param id - combo ID | combo ID * @remarks * 不会更改数据,但会触发数据变更事件 * * Will not change the data, but will trigger data change events */ public refreshComboData(id: ID) { const combo = this.getComboData([id])[0]; const ancestors = this.getAncestorsData(id, COMBO_KEY) as ComboData[]; if (combo) this.pushChange({ value: combo, original: combo, type: ChangeType.ComboUpdated }); ancestors.forEach((value) => { this.pushChange({ value: value, original: value, type: ChangeType.ComboUpdated }); }); } public getElementPosition(id: ID): Point { const datum = this.getElementDataById(id) as NodeLikeData; return positionOf(datum); } public translateNodeLikeBy(id: ID, offset: Point) { if (this.isCombo(id)) this.translateComboBy(id, offset); else this.translateNodeBy(id, offset); } public translateNodeLikeTo(id: ID, position: Point) { if (this.isCombo(id)) this.translateComboTo(id, position); else this.translateNodeTo(id, position); } public translateNodeBy(id: ID, offset: Point) { const curr = this.getElementPosition(id); const position = add(curr, [...offset, 0].slice(0, 3) as Point); this.translateNodeTo(id, position); } public translateNodeTo(id: ID, position: Point) { const [x = 0, y = 0, z = 0] = position; this.preventUpdateNodeLikeHierarchy(() => { this.updateNodeData([{ id, style: { x, y, z } }]); }); } public translateComboBy(id: ID, offset: Point) { const [dx = 0, dy = 0, dz = 0] = offset; if ([dx, dy, dz].some(isNaN) || [dx, dy, dz].every((o) => o === 0)) return; const combo = this.getComboData([id])[0]; if (!combo) return; const seenNodeLikeIds = new Set(); dfs( combo, (succeed) => { const succeedID = idOf(succeed); if (seenNodeLikeIds.has(succeedID)) return; seenNodeLikeIds.add(succeedID); const [x, y, z] = positionOf(succeed); const value = mergeElementsData(succeed, { style: { x: x + dx, y: y + dy, z: z + dz }, }); this.pushChange({ value, // @ts-ignore original: succeed, type: this.isCombo(succeedID) ? ChangeType.ComboUpdated : ChangeType.NodeUpdated, }); this.model.mergeNodeData(succeedID, value); }, (node) => this.getChildrenData(idOf(node)), 'BT', ); } public translateComboTo(id: ID, position: Point) { if (position.some(isNaN)) return; const [tx = 0, ty = 0, tz = 0] = position; const combo = this.getComboData([id])?.[0]; if (!combo) return; const [comboX, comboY, comboZ] = positionOf(combo); const dx = tx - comboX; const dy = ty - comboY; const dz = tz - comboZ; dfs( combo, (succeed) => { const succeedId = idOf(succeed); const [x, y, z] = positionOf(succeed); const value = mergeElementsData(succeed, { style: { x: x + dx, y: y + dy, z: z + dz }, }); this.pushChange({ value, // @ts-ignore original: succeed, type: this.isCombo(succeedId) ? ChangeType.ComboUpdated : ChangeType.NodeUpdated, }); this.model.mergeNodeData(succeedId, value); }, (node) => this.getChildrenData(idOf(node)), 'BT', ); } public removeData(data: DataID) { const { nodes, edges, combos } = data; this.batch(() => { // remove edges first this.removeEdgeData(edges); this.removeNodeData(nodes); this.removeComboData(combos); this.latestRemovedComboIds = new Set(combos); }); } public removeNodeData(ids: ID[] = []) { if (!ids.length) return; this.batch(() => { ids.forEach((id) => { // 移除关联边、子节点 // remove related edges and child nodes this.removeEdgeData(this.getRelatedEdgesData(id).map(idOf)); // TODO 树图情况下移除子节点 this.pushChange({ value: this.getNodeData([id])[0], type: ChangeType.NodeRemoved }); this.removeNodeLikeHierarchy(id); }); this.model.removeNodes(ids); }); } public removeEdgeData(ids: ID[] = []) { if (!ids.length) return; ids.forEach((id) => this.pushChange({ value: this.getEdgeData([id])[0], type: ChangeType.EdgeRemoved })); this.model.removeEdges(ids); } public removeComboData(ids: ID[] = []) { if (!ids.length) return; this.batch(() => { ids.forEach((id) => { this.pushChange({ value: this.getComboData([id])[0], type: ChangeType.ComboRemoved }); this.removeNodeLikeHierarchy(id); this.comboIds.delete(id); }); this.model.removeNodes(ids); }); } /** * 移除节点层次结构,将其子节点移动到父节点的 children 列表中 * * Remove the node hierarchy and move its child nodes to the parent node's children list * @param id - 待处理的节点 | node to be processed */ protected removeNodeLikeHierarchy(id: ID) { if (this.model.hasTreeStructure(COMBO_KEY)) { const grandParent = parentIdOf(this.getNodeLikeDatum(id)); // 从父节点的 children 列表中移除 // remove from its parent's children list // 调用 graphlib.setParent,不需要更新数据 this.setParent(id, undefined, COMBO_KEY, false); // 将子节点移动到父节点的 children 列表中 // move the children to the grandparent's children list this.model.getChildren(id, COMBO_KEY).forEach((child) => { const childData = toG6Data(child); const childId = idOf(childData); this.setParent(idOf(childData), grandParent, COMBO_KEY, false); const value = mergeElementsData(childData, { id: idOf(childData), combo: grandParent, }); this.pushChange({ value, original: childData, type: this.isCombo(childId) ? ChangeType.ComboUpdated : ChangeType.NodeUpdated, }); this.model.mergeNodeData(idOf(childData), value); }); if (!isNil(grandParent)) this.refreshComboData(grandParent); } } /** * 获取元素的类型 * * Get the type of the element * @param id - 元素 ID | ID of the element * @returns 元素类型 | type of the element */ public getElementType(id: ID): ElementType { if (this.model.hasNode(id)) { if (this.isCombo(id)) return 'combo'; return 'node'; } if (this.model.hasEdge(id)) return 'edge'; throw new Error(format(`Unknown element type of id: ${id}`)); } public destroy() { const { model } = this; const nodes = model.getAllNodes(); const edges = model.getAllEdges(); model.removeEdges(edges.map((edge) => edge.id)); model.removeNodes(nodes.map((node) => node.id)); // @ts-expect-error force delete this.context = {}; } } ================================================ FILE: packages/g6/src/runtime/element.ts ================================================ /* eslint-disable jsdoc/require-returns */ /* eslint-disable jsdoc/require-param */ import type { BaseStyleProps } from '@antv/g'; import { Group } from '@antv/g'; import { groupBy } from '@antv/util'; import { AnimationType, COMBO_KEY, ChangeType, GraphEvent } from '../constants'; import { ELEMENT_TYPES } from '../constants/element'; import { getExtension } from '../registry/get'; import type { ComboData, EdgeData, GraphData, LayoutOptions, NodeData } from '../spec'; import type { AnimationStage } from '../spec/element/animation'; import type { DrawData, ProcedureData } from '../transforms/types'; import type { Combo, DataChange, Edge, Element, ElementData, ElementDatum, ElementType, ID, Node, NodeLikeData, State, StyleIterationContext, } from '../types'; import { cacheStyle, hasCachedStyle } from '../utils/cache'; import { reduceDataChanges } from '../utils/change'; import { isCollapsed } from '../utils/collapsibility'; import { isOverridable } from '../utils/data'; import { markToBeDestroyed, updateStyle } from '../utils/element'; import type { BaseEvent } from '../utils/event'; import { AnimateEvent, ElementLifeCycleEvent, GraphLifeCycleEvent, emit } from '../utils/event'; import { idOf } from '../utils/id'; import { assignColorByPalette, parsePalette } from '../utils/palette'; import { positionOf } from '../utils/position'; import { print } from '../utils/print'; import { computeElementCallbackStyle } from '../utils/style'; import { themeOf } from '../utils/theme'; import { subtract } from '../utils/vector'; import { setVisibility } from '../utils/visibility'; import type { RuntimeContext } from './types'; export class ElementController { private context: RuntimeContext; private container!: Group; private elementMap: Record = {}; private shapeTypeMap: Record = {}; constructor(context: RuntimeContext) { this.context = context; } public init() { this.initContainer(); } private initContainer() { if (!this.container || this.container.destroyed) { const { canvas } = this.context; this.container = canvas.appendChild(new Group({ className: 'elements' })); } } private emit(event: BaseEvent, context: DrawContext) { if (context.silence) return; emit(this.context.graph, event); } private forEachElementData(callback: (elementType: ElementType, elementData: ElementData) => void) { ELEMENT_TYPES.forEach((elementType) => { const elementData = this.context.model.getElementsDataByType(elementType); callback(elementType, elementData); }); } public getElementType(elementType: ElementType, datum: ElementDatum) { const { options, graph } = this.context; const userDefinedType = isOverridable(datum) ? options[elementType]?.type || datum.type : datum.type; if (!userDefinedType) { if (elementType === 'edge') return 'line'; // node / combo else return 'circle'; } if (typeof userDefinedType === 'string') return userDefinedType; // @ts-expect-error skip type check return userDefinedType.call(graph, datum); } private getTheme(elementType: ElementType) { return themeOf(this.context.options)[elementType] || {}; } public getThemeStyle(elementType: ElementType) { return this.getTheme(elementType).style || {}; } public getThemeStateStyle(elementType: ElementType, states: State[]) { const { state = {} } = this.getTheme(elementType); return Object.assign({}, ...states.map((name) => state[name] || {})); } private paletteStyle: Record = {}; private computePaletteStyle() { const { options } = this.context; this.paletteStyle = {}; this.forEachElementData((elementType, elementData) => { const palette = Object.assign( {}, parsePalette(this.getTheme(elementType)?.palette), parsePalette(options[elementType]?.palette), ); if (palette?.field) { Object.assign(this.paletteStyle, assignColorByPalette(elementData, palette)); } }); } public getPaletteStyle(elementType: ElementType, id: ID): BaseStyleProps { const color = this.paletteStyle[id]; if (!color) return {}; if (elementType === 'edge') return { stroke: color }; return { fill: color }; } private defaultStyle: Record> = {}; /** * 计算单个元素的默认样式 * * compute default style of single element */ private computeElementDefaultStyle(elementType: ElementType, context: StyleIterationContext) { const { options } = this.context; const defaultStyle = options[elementType]?.style || {}; if ('transform' in defaultStyle && Array.isArray(defaultStyle.transform)) { defaultStyle.transform = [...defaultStyle.transform]; } this.defaultStyle[idOf(context.datum)] = computeElementCallbackStyle(defaultStyle as any, context); } private computeElementsDefaultStyle(ids?: ID[]) { const { graph } = this.context; this.forEachElementData((elementType, elementData) => { const length = elementData.length; for (let i = 0; i < length; i++) { const datum = elementData[i]; if (ids === undefined || ids.includes(idOf(datum))) { this.computeElementDefaultStyle(elementType, { datum, graph }); } } }); } public getDefaultStyle(id: ID) { return this.defaultStyle[id] || {}; } private getElementState(id: ID) { try { const { model } = this.context; return model.getElementState(id); } catch { return []; } } private stateStyle: Record> = {}; /** * 获取单个元素的单个状态的样式 * * get single state style of single element */ private getElementStateStyle(elementType: ElementType, state: State, context: StyleIterationContext) { const { options } = this.context; const stateStyle = options[elementType]?.state?.[state] || {}; return computeElementCallbackStyle(stateStyle as any, context); } /** * 计算单个元素的合并状态样式 * * compute merged state style of single element */ private computeElementStatesStyle(elementType: ElementType, states: State[], context: StyleIterationContext) { this.stateStyle[idOf(context.datum)] = Object.assign( {}, ...states.map((state) => this.getElementStateStyle(elementType, state, context)), ); } /** * 计算全部元素的状态样式 * * compute state style of all elements * @param ids - 计算指定元素的状态样式 | compute state style of specified elements */ private computeElementsStatesStyle(ids?: ID[]) { const { graph } = this.context; this.forEachElementData((elementType, elementData) => { const length = elementData.length; for (let i = 0; i < length; i++) { const datum = elementData[i]; if (ids === undefined || ids.includes(idOf(datum))) { const states = this.getElementState(idOf(datum)); this.computeElementStatesStyle(elementType, states, { datum, graph }); } } }); } public getStateStyle(id: ID) { return this.stateStyle[id] || {}; } private computeStyle(stage?: string, ids?: ID[]) { const skip = ['translate', 'zIndex']; if (stage && skip.includes(stage)) return; this.computePaletteStyle(); this.computeElementsDefaultStyle(ids); this.computeElementsStatesStyle(ids); } public getElement(id: ID): T | undefined { return this.elementMap[id] as T; } public getNodes() { return this.context.model.getNodeData().map(({ id }) => this.elementMap[id]) as Node[]; } public getEdges() { return this.context.model.getEdgeData().map((edge) => this.elementMap[idOf(edge)]) as Edge[]; } public getCombos() { return this.context.model.getComboData().map(({ id }) => this.elementMap[id]) as Combo[]; } public getElementComputedStyle(elementType: ElementType, datum: ElementDatum) { const id = idOf(datum); // 优先级(从低到高) Priority (from low to high): const themeStyle = this.getThemeStyle(elementType); const paletteStyle = this.getPaletteStyle(elementType, id); const dataStyle = datum.style || {}; const defaultStyle = this.getDefaultStyle(id); const themeStateStyle = this.getThemeStateStyle(elementType, this.getElementState(id)); const stateStyle = this.getStateStyle(id); const style = isOverridable(datum) ? Object.assign({}, themeStyle, paletteStyle, dataStyle, defaultStyle, themeStateStyle, stateStyle) : Object.assign({}, dataStyle); if (elementType === 'combo') { const childrenData = this.context.model.getChildrenData(id); const isCollapsed = !!style.collapsed; const childrenNode = isCollapsed ? [] : childrenData.map(idOf).filter((id) => this.getElement(id)); Object.assign(style, { childrenNode, childrenData }); } return style; } private getDrawData(context: DrawContext): DrawPayload | null { this.init(); const data = this.computeChangesAndDrawData(context); if (!data) return null; const { type = 'draw', stage = type } = context; this.markDestroyElement(data.drawData); // 计算样式 / Calculate style this.computeStyle(stage); return { type, stage, data }; } /** * 开始绘制流程 * * start render process */ public draw(context: DrawContext = { animation: true }) { const drawData = this.getDrawData(context); if (!drawData) return; const { data: { drawData: { add, update, remove }, }, } = drawData; this.destroyElements(remove, context); this.createElements(add, context); this.updateElements(update, context); return this.setAnimationTask(context, drawData); } public async preLayoutDraw(context: DrawContext = { animation: true }) { const preResult = this.getDrawData(context); if (!preResult) return; const { data: { drawData }, } = preResult; await this.context.layout?.preLayout?.(drawData); const { add, update, remove } = drawData; this.destroyElements(remove, context); this.createElements(add, context); this.updateElements(update, context); return this.setAnimationTask(context, preResult); } private setAnimationTask(context: DrawContext, data: DrawPayload) { const { animation, silence } = context; const { data: { dataChanges, drawData }, stage, type, } = data; return this.context.animation!.animate( animation, silence ? {} : { before: () => this.emit( new GraphLifeCycleEvent(GraphEvent.BEFORE_DRAW, { dataChanges, animation, stage, render: type === 'render', }), 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, stage, render: type === 'render', firstRender: this.context.graph.rendered === false, }), context, ), }, ); } private computeChangesAndDrawData(context: DrawContext) { const { model } = this.context; const dataChanges = model.getChanges(); const tasks = reduceDataChanges(dataChanges); if (tasks.length === 0) return null; const { NodeAdded = [], NodeUpdated = [], NodeRemoved = [], EdgeAdded = [], EdgeUpdated = [], EdgeRemoved = [], ComboAdded = [], ComboUpdated = [], ComboRemoved = [], } = groupBy(tasks, (change) => change.type) as unknown as Record<`${ChangeType}`, DataChange[]>; const moveToAddedIfUnrendered = (updated: DataChange[], added: DataChange[]) => { const keptUpdates: DataChange[] = []; updated.forEach((change) => { const id = idOf(change.value); if (!this.getElement(id)) { added.push(change); } else { keptUpdates.push(change); } }); return keptUpdates; }; const finalNodeUpdated = moveToAddedIfUnrendered(NodeUpdated, NodeAdded); const finalEdgeUpdated = moveToAddedIfUnrendered(EdgeUpdated, EdgeAdded); const finalComboUpdated = moveToAddedIfUnrendered(ComboUpdated, ComboAdded); const dataOf = (data: DataChange[]) => new Map( data.map((datum) => { const data = datum.value; return [idOf(data), data] as [ID, T]; }), ); const input: DrawData = { add: { nodes: dataOf(NodeAdded), edges: dataOf(EdgeAdded), combos: dataOf(ComboAdded), }, update: { nodes: dataOf(finalNodeUpdated), edges: dataOf(finalEdgeUpdated), combos: dataOf(finalComboUpdated), }, remove: { nodes: dataOf(NodeRemoved), edges: dataOf(EdgeRemoved), combos: dataOf(ComboRemoved), }, }; const drawData = this.transformData(input, context); // 清空变更 / Clear changes model.clearChanges(); return { dataChanges, drawData }; } private transformData(input: DrawData, context: DrawContext): DrawData { const transforms = this.context.transform.getTransformInstance(); return Object.values(transforms).reduce((data, transform) => transform.beforeDraw(data, context), input); } private createElement(elementType: ElementType, datum: ElementDatum, context: DrawContext) { const id = idOf(datum); const currentElement = this.getElement(id); if (currentElement) return; const type = this.getElementType(elementType, datum); const style = this.getElementComputedStyle(elementType, datum); // get shape constructor const Ctor = getExtension(elementType, type); if (!Ctor) return print.warn(`The element ${type} of ${elementType} is not registered.`); this.emit(new ElementLifeCycleEvent(GraphEvent.BEFORE_ELEMENT_CREATE, elementType, datum), context); const element = this.container.appendChild( new Ctor({ id, context: this.context, style, }), ) as Element; this.shapeTypeMap[id] = type; this.elementMap[id] = element; const { stage = 'enter' } = context; this.context.animation?.add( { element, elementType, stage, originalStyle: { ...element.attributes }, updatedStyle: style, }, { after: () => { this.emit(new ElementLifeCycleEvent(GraphEvent.AFTER_ELEMENT_CREATE, elementType, datum), context); element.onCreate?.(); }, }, ); } private createElements(data: ProcedureData, context: DrawContext) { const { nodes, edges, combos } = data; const iteration: [ElementType, Map][] = [ ['node', nodes], ['combo', combos], ['edge', edges], ]; iteration.forEach(([elementType, elementData]) => { elementData.forEach((datum) => this.createElement(elementType, datum, context)); }); } private getUpdateStageStyle(elementType: ElementType, datum: ElementDatum, context: DrawContext) { const { stage = 'update' } = context; // 优化 translate 阶段,直接返回 x, y, z,避免计算样式 // Optimize the translate stage, return x, y, z directly to avoid calculating style if (stage === 'translate') { if (elementType === 'node' || elementType === 'combo') { const { style: { x = 0, y = 0, z = 0 } = {} } = datum as NodeLikeData; return { x, y, z }; } else return {}; } return this.getElementComputedStyle(elementType, datum); } private updateElement(elementType: ElementType, datum: ElementDatum, context: DrawContext) { const id = idOf(datum); const { stage = 'update' } = context; const element = this.getElement(id); if (!element) return () => null; this.emit(new ElementLifeCycleEvent(GraphEvent.BEFORE_ELEMENT_UPDATE, elementType, datum), context); const type = this.getElementType(elementType, datum); const style = this.getUpdateStageStyle(elementType, datum, context); // 如果类型不同,需要先销毁原有元素,再创建新元素 // If the type is different, you need to destroy the original element first, and then create a new element if (this.shapeTypeMap[id] !== type) { element.destroy(); delete this.shapeTypeMap[id]; delete this.elementMap[id]; this.createElement(elementType, datum, { animation: false, silence: true }); } const exactStage = stage !== 'visibility' ? stage : style.visibility === 'hidden' ? 'hide' : 'show'; // 避免立即将 visibility 设置为 hidden,导致元素不可见,而是在 after 阶段再设置 // Avoid setting visibility to hidden immediately, causing the element to be invisible, but set it in the after phase if (exactStage === 'hide') delete style['visibility']; this.context.animation?.add( { element, elementType, stage: exactStage, originalStyle: { ...element.attributes }, updatedStyle: style, }, { before: () => { // 通过 elementMap[id] 访问最新的 element,防止 type 不同导致的 element 丢失 // Access the latest element through elementMap[id] to prevent the loss of element caused by different types const element = this.elementMap[id]; if (stage !== 'collapse') updateStyle(element, style); if (stage === 'visibility') { // 缓存原始透明度 / Cache original opacity // 会在 animation controller 中访问该缓存值 / The cached value will be accessed in the animation controller if (!hasCachedStyle(element, 'opacity')) cacheStyle(element, 'opacity'); this.visibilityCache.set(element, exactStage === 'show' ? 'visible' : 'hidden'); if (exactStage === 'show') setVisibility(element, 'visible'); } }, after: () => { const element = this.elementMap[id]; if (stage === 'collapse') updateStyle(element, style); if (exactStage === 'hide') setVisibility(element, this.visibilityCache.get(element)); this.emit(new ElementLifeCycleEvent(GraphEvent.AFTER_ELEMENT_UPDATE, elementType, datum), context); element.onUpdate?.(); }, }, ); } private updateElements(data: ProcedureData, context: DrawContext) { const { nodes, edges, combos } = data; const iteration: [ElementType, Map][] = [ ['node', nodes], ['combo', combos], ['edge', edges], ]; iteration.forEach(([elementType, elementData]) => { elementData.forEach((datum) => this.updateElement(elementType, datum, context)); }); } private visibilityCache = new WeakMap(); /** * 标记销毁元素 * * mark destroy element * @param data - 绘制数据 | draw data */ private markDestroyElement(data: DrawData) { Object.values(data.remove).forEach((elementData) => { elementData.forEach((datum) => { const id = idOf(datum); const element = this.getElement(id); if (element) markToBeDestroyed(element); }); }); } private destroyElement(elementType: ElementType, datum: ElementDatum, context: DrawContext) { const { stage = 'exit' } = context; const id = idOf(datum); const element = this.elementMap[id]; if (!element) return () => null; this.emit(new ElementLifeCycleEvent(GraphEvent.BEFORE_ELEMENT_DESTROY, elementType, datum), context); this.context.animation?.add( { element, elementType, stage, originalStyle: { ...element.attributes }, updatedStyle: {}, }, { after: () => { this.clearElement(id); element.destroy(); element.onDestroy?.(); this.emit(new ElementLifeCycleEvent(GraphEvent.AFTER_ELEMENT_DESTROY, elementType, datum), context); }, }, ); } private destroyElements(data: ProcedureData, context: DrawContext) { const { nodes, edges, combos } = data; const iteration: [ElementType, Map][] = [ ['combo', combos], ['edge', edges], ['node', nodes], ]; iteration.forEach(([elementType, elementData]) => { elementData.forEach((datum) => this.destroyElement(elementType, datum, context)); }); // TODO 重新计算色板样式,如果是分组色板,则不需要重新计算 } private clearElement(id: ID) { delete this.paletteStyle[id]; delete this.defaultStyle[id]; delete this.stateStyle[id]; delete this.elementMap[id]; delete this.shapeTypeMap[id]; } /** * 将布局结果对齐到元素,避免视图偏移。会修改布局结果 * * Align the layout result to the element to avoid view offset. Will modify the layout result * @param layoutResult - 布局结果 | layout result * @param id - 元素 ID | element ID */ private alignLayoutResultToElement(layoutResult: GraphData, id: ID) { const target = layoutResult.nodes?.find((node) => idOf(node) === id); if (target) { const originalPosition = positionOf(this.context.model.getNodeLikeDatum(id)); const modifiedPosition = positionOf(target); const delta = subtract(originalPosition, modifiedPosition); layoutResult.nodes?.forEach((node) => { if (node.style?.x) node.style.x += delta[0]; if (node.style?.y) node.style.y += delta[1]; if (node.style?.z) node.style.z += delta[2] || 0; }); } } /** * 同步布局结果 * * Sync layout result * @param id - 元素 ID | element ID * @param align - 是否对齐 | whether to align */ private async syncLayoutResult(id: ID, align?: boolean) { const { layout, model } = this.context; if (!layout) return; const layoutOptions = this.context.options.layout; const forcePreLayout = (opts: LayoutOptions): LayoutOptions => { if (Array.isArray(opts)) { return opts.map((o) => ({ ...o, preLayout: true })); } return { ...opts, preLayout: true }; }; const layoutResult = await layout.simulate(layoutOptions ? forcePreLayout(layoutOptions) : undefined); if (align) this.alignLayoutResultToElement(layoutResult, id); model.updateData(layoutResult); } /** * 收起节点 * * collapse node * @param id - 元素 ID | element ID * @param options - 选项 | options */ public async collapseNode(id: ID, options: CollapseExpandNodeOptions): Promise { const { animation, align } = options; await this.syncLayoutResult(id, align); // 重新计算数据 / Recalculate data const data = this.computeChangesAndDrawData({ stage: 'collapse', animation }); if (!data) return; const { drawData } = data; const { add, remove, update } = drawData; this.markDestroyElement(drawData); const context = { animation, stage: 'collapse', data: drawData } as const; this.destroyElements(remove, context); this.createElements(add, context); this.updateElements(update, context); await this.context.animation!.animate( animation, { beforeAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.COLLAPSE, animation, drawData), context), afterAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.COLLAPSE, animation, drawData), context), }, { collapse: { target: id, descendants: Array.from(remove.nodes).map(([, node]) => idOf(node)), position: positionOf(update.nodes.get(id)!), }, }, )?.finished; } /** * 展开节点 * * expand node * @param id - 元素 ID | element ID * @param animation - 是否使用动画,默认为 true | Whether to use animation, default is true */ public async expandNode(id: ID, options: CollapseExpandNodeOptions): Promise { const { model } = this.context; const { animation, align } = options; const position = positionOf(model.getNodeData([id])[0]); await this.syncLayoutResult(id, align); // 重新计算数据 / Recalculate data const data = this.computeChangesAndDrawData({ stage: 'expand', animation }); this.createElements(data!.drawData.add, { animation: false, stage: 'expand', target: id }); // 重置动画 / Reset animation this.context.animation!.clear(); this.computeStyle('expand'); if (!data) return; const { drawData } = data; const { update, add } = drawData; const context = { animation, stage: 'expand', data: drawData } as const; // 将新增节点/边添加到更新列表 / Add new nodes/edges to the update list add.edges.forEach((edge) => update.edges.set(idOf(edge), edge)); add.nodes.forEach((node) => update.nodes.set(idOf(node), node)); this.updateElements(update, context); await this.context.animation!.animate( animation, { beforeAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.EXPAND, animation, drawData), context), afterAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.EXPAND, animation, drawData), context), }, { expand: { target: id, descendants: Array.from(add.nodes).map(([, node]) => idOf(node)), position, }, }, )?.finished; } public async collapseCombo(id: ID, animation: boolean): Promise { const { model, element } = this.context; if (model.getAncestorsData(id, COMBO_KEY).some((datum) => isCollapsed(datum))) return; const combo = element!.getElement(id)!; const position = combo.getComboPosition({ ...combo.attributes, collapsed: true, }); const data = this.computeChangesAndDrawData({ stage: 'collapse', animation }); if (!data) return; const { dataChanges, drawData } = data; this.markDestroyElement(drawData); const { update, remove } = drawData; const context = { animation, stage: 'collapse', data: drawData } as const; this.destroyElements(remove, context); this.updateElements(update, context); const idsOf = (data: Map) => Array.from(data).map(([, node]) => idOf(node)); await this.context.animation!.animate( animation, { before: () => this.emit(new GraphLifeCycleEvent(GraphEvent.BEFORE_DRAW, { dataChanges, animation }), context), beforeAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.COLLAPSE, animation, drawData), context), afterAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.COLLAPSE, animation, drawData), context), after: () => this.emit(new GraphLifeCycleEvent(GraphEvent.AFTER_DRAW, { dataChanges, animation }), context), }, { collapse: { target: id, descendants: [...idsOf(remove.nodes), ...idsOf(remove.combos)], position, }, }, )?.finished; } public async expandCombo(id: ID, animation: boolean): Promise { const { model } = this.context; const position = positionOf(model.getComboData([id])[0]); // 重新计算数据 / Recalculate data this.computeStyle('expand'); const data = this.computeChangesAndDrawData({ stage: 'expand', animation }); if (!data) return; const { dataChanges, drawData } = data; const { add, update } = drawData; const context = { animation, stage: 'expand', data: drawData, target: id } as const; this.createElements(add, context); this.updateElements(update, context); const idsOf = (data: Map) => Array.from(data).map(([, node]) => idOf(node)); await this.context.animation!.animate( animation, { before: () => this.emit(new GraphLifeCycleEvent(GraphEvent.BEFORE_DRAW, { dataChanges, animation }), context), beforeAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.EXPAND, animation, drawData), context), afterAnimate: (animation) => this.emit(new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.EXPAND, animation, drawData), context), after: () => this.emit(new GraphLifeCycleEvent(GraphEvent.AFTER_DRAW, { dataChanges, animation }), context), }, { expand: { target: id, descendants: [...idsOf(add.nodes), ...idsOf(add.combos)], position, }, }, )?.finished; } /** * 清空所有元素 * * clear all elements */ public clear() { this.container.destroy(); this.initContainer(); this.elementMap = {}; this.shapeTypeMap = {}; this.defaultStyle = {}; this.stateStyle = {}; this.paletteStyle = {}; } public destroy() { this.clear(); this.container.destroy(); // @ts-expect-error force delete this.context = {}; } } export interface DrawContext { /** 是否使用动画,默认为 true | Whether to use animation, default is true */ 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'; /** 展开阶段的目标元素 id | ID of the target element in the expand stage */ target?: ID; } interface DrawPayload { data: { dataChanges: DataChange[]; drawData: DrawData; }; stage: AnimationStage; type: 'render' | 'draw'; } /** * 展开/收起节点选项 * * Expand / collapse node options */ export interface CollapseExpandNodeOptions { /** * 是否使用动画 * * Whether to use animation */ animation?: boolean; /** * 保证展开/收起的节点位置不变 * * Ensure that the position of the expanded/collapsed node remains unchanged */ align?: boolean; } ================================================ FILE: packages/g6/src/runtime/graph.ts ================================================ import EventEmitter from '@antv/event-emitter'; import type { AABB, BaseStyleProps } from '@antv/g'; import { debounce, isEqual, isFunction, isNumber, isObject, isString, omit } from '@antv/util'; import { COMBO_KEY, GraphEvent } from '../constants'; import type { Plugin } from '../plugins/types'; import type { BehaviorOptions, ComboData, ComboOptions, EdgeData, EdgeOptions, GraphData, GraphOptions, LayoutOptions, NodeData, NodeOptions, PluginOptions, ThemeOptions, TransformOptions, } from '../spec'; import type { UpdateBehaviorOption } from '../spec/behavior'; import type { UpdatePluginOption } from '../spec/plugin'; import type { UpdateTransformOption } from '../spec/transform'; import type { DataID, EdgeDirection, ElementDatum, ElementType, FitViewOptions, HierarchyKey, ID, IEvent, NodeLikeData, PartialEdgeData, PartialGraphData, PartialNodeLikeData, Point, State, Vector2, ViewportAnimationEffectTiming, } from '../types'; import { isCollapsed } from '../utils/collapsibility'; import { sizeOf } from '../utils/dom'; import { getSubgraphRelatedEdges } from '../utils/edge'; import { GraphLifeCycleEvent, emit } from '../utils/event'; import { idOf } from '../utils/id'; import { isPreLayout } from '../utils/layout'; import { format } from '../utils/print'; import { subtract } from '../utils/vector'; import { getZIndexOf } from '../utils/z-index'; import { Animation } from './animation'; import { BatchController } from './batch'; import { BehaviorController } from './behavior'; import type { DataURLOptions } from './canvas'; import { Canvas } from './canvas'; import { DataController } from './data'; import type { CollapseExpandNodeOptions } from './element'; import { ElementController } from './element'; import { LayoutController } from './layout'; import { inferOptions } from './options'; import { PluginController } from './plugin'; import { TransformController } from './transform'; import { RuntimeContext } from './types'; import { ViewportController } from './viewport'; export class Graph extends EventEmitter { private options: GraphOptions = {}; /** * @internal */ static defaultOptions: GraphOptions = { autoResize: false, theme: 'light', rotation: 0, zoom: 1, zoomRange: [0.01, 10], }; /** * 当前图实例是否已经渲染 * * Whether the current graph instance has been rendered */ public rendered = false; /** * 当前图实例是否已经被销毁 * * Whether the current graph instance has been destroyed */ public destroyed = false; // @ts-expect-error will be initialized in createRuntime private context: RuntimeContext = { model: new DataController(), }; constructor(options: GraphOptions) { super(); this._setOptions(Object.assign({}, Graph.defaultOptions, options), true); this.context.graph = this; // Listening resize to autoResize. this.options.autoResize && globalThis.addEventListener?.('resize', this.onResize); } /** * 获取配置项 * * Get options * @returns 配置项 | options * @apiCategory option */ public getOptions(): GraphOptions { return this.options; } /** * 设置配置项 * * Set options * @param options - 配置项 | options * @remarks * 要更新 devicePixelRatio、container 属性请销毁后重新创建实例 * * To update devicePixelRatio and container properties, please destroy and recreate the instance * @apiCategory option */ public setOptions(options: GraphOptions): void { this._setOptions(options, false); } private _setOptions(options: GraphOptions, isInit: boolean) { this.updateCanvas(options); Object.assign(this.options, inferOptions(options)); if (isInit) { const { data } = options; if (data) this.addData(data); return; } const { behaviors, combo, data, edge, layout, node, plugins, theme, transforms } = options; if (behaviors) this.setBehaviors(behaviors); if (data) this.setData(data); if (node) this.setNode(node); if (edge) this.setEdge(edge); if (combo) this.setCombo(combo); if (layout) this.setLayout(layout); if (theme) this.setTheme(theme); if (plugins) this.setPlugins(plugins); if (transforms) this.setTransforms(transforms); } /** * 获取当前画布容器的尺寸 * * Get the size of the current canvas container * @returns 画布尺寸 | canvas size * @apiCategory canvas */ public getSize(): [number, number] { if (this.context.canvas) return this.context.canvas.getSize(); return [this.options.width || 0, this.options.height || 0]; } /** * 设置当前画布容器的尺寸 * * Set the size of the current canvas container * @param width - 画布宽度 | canvas width * @param height - 画布高度 | canvas height * @apiCategory canvas */ public setSize(width: number, height: number): void { if (width) this.options.width = width; if (height) this.options.height = height; this.resize(width, height); } /** * 设置当前图的缩放区间 * * Get the zoom range of the current graph * @param zoomRange - 缩放区间 | zoom range * @apiCategory viewport */ public setZoomRange(zoomRange: GraphOptions['zoomRange']): void { this.options.zoomRange = zoomRange; } /** * 获取当前图的缩放区间 * * Get the zoom range of the current graph * @returns 缩放区间 | zoom range * @apiCategory viewport */ public getZoomRange(): GraphOptions['zoomRange'] { return this.options.zoomRange; } /** * 设置节点样式映射 * * Set node mapper * @param node - 节点配置 | node options * @remarks * 即 `options.node` 的值 * * The value of `options.node` * @apiCategory element */ public setNode(node: NodeOptions): void { this.options.node = node; this.context.model.refreshData(); } /** * 设置边样式映射 * * Set edge mapper * @param edge - 边配置 | edge options * @remarks * 即 `options.edge` 的值 * * The value of `options.edge` * @apiCategory element */ public setEdge(edge: EdgeOptions): void { this.options.edge = edge; this.context.model.refreshData(); } /** * 设置组合样式映射 * * Set combo mapper * @param combo - 组合配置 | combo options * @remarks * 即 `options.combo` 的值 * * The value of `options.combo` * @apiCategory element */ public setCombo(combo: ComboOptions): void { this.options.combo = combo; this.context.model.refreshData(); } /** * 获取主题 * * Get theme * @returns 当前主题 | current theme * @apiCategory theme */ public getTheme(): ThemeOptions { return this.options.theme!; } /** * 设置主题 * * Set theme * @param theme - 主题名 | theme name * @example * ```ts * graph.setTheme('dark'); * ``` * @apiCategory theme */ public setTheme(theme: ThemeOptions | ((prev: ThemeOptions) => ThemeOptions)): void { this.options.theme = isFunction(theme) ? theme(this.getTheme()) : theme; } /** * 设置布局 * * Set layout * @param layout - 布局配置 | layout options * @example * ```ts * graph.setLayout({ * type: 'dagre', * }) * ``` * @apiCategory layout */ public setLayout(layout: LayoutOptions | ((prev: LayoutOptions) => LayoutOptions)): void { this.options.layout = isFunction(layout) ? layout(this.getLayout()) : layout; } /** * 获取布局配置 * * Get layout options * @returns 布局配置 | layout options * @apiCategory layout */ public getLayout(): LayoutOptions { return this.options.layout!; } /** * 设置交互 * * Set behaviors * @param behaviors - 交互配置 | behavior options * @remarks * 设置的交互会全量替换原有的交互,如果需要新增交互可以使用如下方式: * * The set behavior will completely replace the original behavior. If you need to add behavior, you can use the following method: * * ```ts * graph.setBehaviors((behaviors) => [...behaviors, { type: 'zoom-canvas' }]) * ``` * @apiCategory behavior */ public setBehaviors(behaviors: BehaviorOptions | ((prev: BehaviorOptions) => BehaviorOptions)): void { this.options.behaviors = isFunction(behaviors) ? behaviors(this.getBehaviors()) : behaviors; this.context.behavior?.setBehaviors(this.options.behaviors); } /** * 更新指定的交互配置 * * Update specified behavior options * @param behavior - 交互配置 | behavior options * @remarks * 如果要更新一个交互,那么必须在交互配置中指定 key 字段,例如: * * If you want to update a behavior, you must specify the key field in the behavior options, for example: * ```ts * { * behaviors: [{ type: 'zoom-canvas', key: 'zoom-canvas' }] * } * * graph.updateBehavior({ key: 'zoom-canvas', enable: false }) * ``` * @apiCategory behavior */ public updateBehavior(behavior: UpdateBehaviorOption): void { this.setBehaviors((behaviors) => behaviors.map((_behavior) => { if (typeof _behavior === 'object' && _behavior.key === behavior.key) { return { ..._behavior, ...behavior }; } return _behavior; }), ); } /** * 获取交互配置 * * Get behaviors options * @returns 交互配置 | behavior options * @apiCategory behavior */ public getBehaviors(): BehaviorOptions { return this.options.behaviors || []; } /** * 设置插件配置 * * Set plugins options * @param plugins - 插件配置 | plugin options * @remarks * 设置的插件会全量替换原有的插件配置,如果需要新增插件可以使用如下方式: * * The set plugin will completely replace the original plugin configuration. If you need to add a plugin, you can use the following method: * ```ts * graph.setPlugins((plugins) => [...plugins, { key: 'grid-line' }]) * ``` * @apiCategory plugin */ public setPlugins(plugins: PluginOptions | ((prev: PluginOptions) => PluginOptions)): void { this.options.plugins = isFunction(plugins) ? plugins(this.getPlugins()) : plugins; this.context.plugin?.setPlugins(this.options.plugins); } /** * 更新插件配置 * * Update plugin options * @param plugin - 插件配置 | plugin options * @remarks * 如果要更新一个插件,那么必须在插件配置中指定 key 字段,例如: * * If you want to update a plugin, you must specify the key field in the plugin options, for example: * ```ts * { * plugins: [{ key: 'grid-line' }] * } * * graph.updatePlugin({ key: 'grid-line', follow: true }) * ``` * @apiCategory plugin */ public updatePlugin(plugin: UpdatePluginOption): void { this.setPlugins((plugins) => plugins.map((_plugin) => { if (typeof _plugin === 'object' && _plugin.key === plugin.key) { return { ..._plugin, ...plugin }; } return _plugin; }), ); } /** * 获取插件配置 * * Get plugins options * @returns 插件配置 | plugin options * @apiCategory plugin */ public getPlugins(): PluginOptions { return this.options.plugins || []; } /** * 获取插件实例 * * Get plugin instance * @param key - 插件 key(在配置 plugin 时需要手动传入指定) | plugin key(need to be specified manually when configuring plugin) * @returns 插件实例 | plugin instance * @remarks * 一些插件提供了 API 方法可供调用,例如全屏插件可以调用 `request` 和 `exit` 方法来请求和退出全屏 * * Some plugins provide API methods for calling, such as the full-screen plugin can call the `request` and `exit` methods to request and exit full-screen * ```ts * const fullscreen = graph.getPluginInstance('fullscreen'); * * fullscreen.request(); * * fullscreen.exit(); * ``` * @apiCategory plugin */ public getPluginInstance(key: string) { return this.context.plugin!.getPluginInstance(key) as unknown as T; } /** * 设置数据转换器 * * Set data transforms * @param transforms - 数据转换配置 | data transform options * @remarks * 数据转换器能够在图渲染过程中执行数据转换,目前支持在渲染前对绘制数据进行转化。 * * Data transforms can perform data transformation during the rendering process of the graph. Currently, it supports transforming the drawing data before rendering. * @apiCategory transform */ public setTransforms(transforms: TransformOptions | ((prev: TransformOptions) => TransformOptions)): void { this.options.transforms = isFunction(transforms) ? transforms(this.getTransforms()) : transforms; this.context.transform?.setTransforms(this.options.transforms); } /** * 更新数据转换器 * * Update data transform * @param transform - 数据转换器配置 | data transform options * @apiCategory transform */ public updateTransform(transform: UpdateTransformOption): void { this.setTransforms((transforms) => transforms.map((_transform) => { if (typeof _transform === 'object' && _transform.key === transform.key) { return { ..._transform, ...transform }; } return _transform; }), ); this.context.model.refreshData(); } /** * 获取数据转换器配置 * * Get data transforms options * @returns 数据转换配置 | data transform options * @apiCategory transform */ public getTransforms(): TransformOptions { return this.options.transforms || []; } /** * 获取图数据 * * Get graph data * @returns 图数据 | Graph data * 获取当前图的数据,包括节点、边、组合数据 * * Get the data of the current graph, including node, edge, and combo data * @apiCategory data */ public getData(): Required { return this.context.model.getData(); } /** * 判断图中是否存在指定节点 * Determine whether a specified node exists in the graph * @param {ID} id * @returns {boolean} * @remarks 判断图中是否存在指定节点,避免在不存在的节点上进行操作 * Determine whether a specified node exists in the graph and avoid operating on non-existent nodes */ public hasNode(id: ID): boolean { return this.context.model.hasNode(id); } /** * 判断图中是否存在指定边 * Determine whether a specified edge exists in the graph * @param {ID} id * @returns {boolean} * @remarks 判断图中是否存在指定边,避免在不存在的边上进行操作 * Determine whether a specified edge exists in the graph and avoid operating on non-existent edges */ public hasEdge(id: ID): boolean { return this.context.model.hasEdge(id); } /** * 判断图中是否存在指定组合 * Determine whether a specified combo exists in the graph * @param {ID} id * @returns {boolean} * @remarks 判断图中是否存在指定组合,避免在不存在的组合上进行操作 * Determine whether a specified combo exists in the graph and avoid operating on non-existent combos */ public hasCombo(id: ID): boolean { return this.context.model.hasCombo(id); } /** * 获取单个元素数据 * * Get element data by ID * @param id - 元素 ID | element ID * @returns 元素数据 | element data * @remarks * 直接获取元素的数据而不必考虑元素类型 * * Get element data directly without considering the element type * @apiCategory data */ public getElementData(id: ID): ElementDatum; /** * 批量获取多个元素数据 * * Get multiple element data in batch * @param ids - 元素 ID 数组 | element ID array * @remarks * 直接获取元素的数据而不必考虑元素类型 * * Get element data directly without considering the element type * @apiCategory data */ public getElementData(ids: ID[]): ElementDatum[]; public getElementData(ids: ID | ID[]): ElementDatum | ElementDatum[] { if (Array.isArray(ids)) return ids.map((id) => this.context.model.getElementDataById(id)); return this.context.model.getElementDataById(ids); } /** * 获取所有节点数据 * * Get all node data * @returns 节点数据 | node data * @apiCategory data */ public getNodeData(): NodeData[]; /** * 获取单个节点数据 * * Get single node data * @param id - 节点 ID | node ID * @returns 节点数据 | node data * @example * ```ts * const node1 = graph.getNodeData('node-1'); * ``` * @apiCategory data * @remarks * 节点 id 必须存在,否则会抛出异常 * * Node id must exist, otherwise an exception will be thrown */ public getNodeData(id: ID): NodeData; /** * 批量获取多个节点数据 * * Get multiple node data in batch * @param ids - 节点 ID 数组 | node ID array * @returns 节点数据 | node data * @example * ```ts * const [node1, node2] = graph.getNodeData(['node-1', 'node-2']); * ``` * @apiCategory data * @remarks * 数组中的每个节点 id 必须存在,否则将抛出异常 * * Each node id in the array must exist, otherwise an exception will be thrown */ public getNodeData(ids: ID[]): NodeData[]; public getNodeData(id?: ID | ID[]): NodeData | NodeData[] { if (id === undefined) return this.context.model.getNodeData(); if (Array.isArray(id)) return this.context.model.getNodeData(id); return this.context.model.getNodeLikeDatum(id); } /** * 获取所有边数据 * * Get all edge data * @returns 边数据 | edge data * @apiCategory data */ public getEdgeData(): EdgeData[]; /** * 获取单条边数据 * * Get single edge data * @param id - 边 ID | edge ID * @returns 边数据 | edge data * @example * ```ts * const edge1 = graph.getEdgeData('edge-1'); * ``` * @apiCategory data * @remarks * 边 id 必须存在,否则会抛出异常 * * Edge id must exist, otherwise an exception will be thrown */ public getEdgeData(id: ID): EdgeData; /** * 批量获取多条边数据 * * Get multiple edge data in batch * @param ids - 边 ID 数组 | edge ID array * @returns 边数据 | edge data * @example * ```ts * const [edge1, edge2] = graph.getEdgeData(['edge-1', 'edge-2']); * ``` * @apiCategory data * @remarks * 数组中的每个边 id 必须存在,否则将抛出异常 * * Each edge id in the array must exist, otherwise an exception will be thrown */ public getEdgeData(ids: ID[]): EdgeData[]; public getEdgeData(id?: ID | ID[]): EdgeData | EdgeData[] { if (id === undefined) return this.context.model.getEdgeData(); if (Array.isArray(id)) return this.context.model.getEdgeData(id); return this.context.model.getEdgeDatum(id); } /** * 获取所有组合数据 * * Get all combo data * @returns 组合数据 | combo data * @apiCategory data */ public getComboData(): ComboData[]; /** * 获取单个组合数据 * * Get single combo data * @param id - 组合ID | combo ID * @returns 组合数据 | combo data * @example * ```ts * const combo1 = graph.getComboData('combo-1'); * ``` * @apiCategory data * @remarks * 组合 id 必须存在,否则会抛出异常 * * Combo id must exist, otherwise an exception will be thrown */ public getComboData(id: ID): ComboData; /** * 批量获取多个组合数据 * * Get multiple combo data in batch * @param ids - 组合ID 数组 | combo ID array * @returns 组合数据 | combo data * @example * ```ts * const [combo1, combo2] = graph.getComboData(['combo-1', 'combo-2']); * ``` * @apiCategory data * @remarks * 数组中的每个组合 id 必须存在,否则将抛出异常 * * Each combo id in the array must exist, otherwise an exception will be thrown */ public getComboData(ids: ID[]): ComboData[]; public getComboData(id?: ID | ID[]): ComboData | ComboData[] { if (id === undefined) return this.context.model.getComboData(); if (Array.isArray(id)) return this.context.model.getComboData(id); return this.context.model.getNodeLikeDatum(id); } /** * 设置全量数据 * * Set full data * @param data - 数据 | data * @remarks * 设置全量数据会替换当前图中的所有数据,G6 会自动进行数据差异计算 * * Setting full data will replace all data in the current graph, and G6 will automatically calculate the data difference * @apiCategory data */ public setData(data: GraphData | ((prev: GraphData) => GraphData)): void { this.context.model.setData(isFunction(data) ? data(this.getData()) : data); } /** * 新增元素数据 * * Add element data * @param data - 元素数据 | element data * @example * ```ts * graph.addData({ * nodes: [{ id: 'node-1' }, { id: 'node-2' }], * edges: [{ source: 'node-1', target: 'node-2' }], * }); * ``` * @apiCategory data */ public addData(data: GraphData | ((prev: GraphData) => GraphData)): void { this.context.model.addData(isFunction(data) ? data(this.getData()) : data); } /** * 新增节点数据 * * Add node data * @param data - 节点数据 | node data * @example * ```ts * graph.addNodeData([{ id: 'node-1' }, { id: 'node-2' }]); * ``` * @apiCategory data */ public addNodeData(data: NodeData[] | ((prev: NodeData[]) => NodeData[])): void { this.context.model.addNodeData(isFunction(data) ? data(this.getNodeData()) : data); } /** * 新增边数据 * * Add edge data * @param data - 边数据 | edge data * @example * ```ts * graph.addEdgeData([{ source: 'node-1', target: 'node-2' }]); * ``` * @apiCategory data */ public addEdgeData(data: EdgeData[] | ((prev: EdgeData[]) => EdgeData[])): void { this.context.model.addEdgeData(isFunction(data) ? data(this.getEdgeData()) : data); } /** * 新增组合数据 * * Add combo data * @param data - 组合数据 | combo data * @example * ```ts * graph.addComboData([{ id: 'combo-1' }]); * ``` * @apiCategory data */ public addComboData(data: ComboData[] | ((prev: ComboData[]) => ComboData[])): void { this.context.model.addComboData(isFunction(data) ? data(this.getComboData()) : data); } /** * 为树图节点添加子节点数据 * * Add child node data to the tree node * @param parentId - 父节点 ID | parent node ID * @param childrenData - 子节点数据 | child node data * @remarks * 为组合添加子节点使用 addNodeData / addComboData 方法 * * Use addNodeData / addComboData method to add child nodes to the combo * @apiCategory data */ public addChildrenData(parentId: ID, childrenData: NodeData[]) { this.context.model.addChildrenData(parentId, childrenData); } /** * 更新元素数据 * * Update element data * @param data - 元素数据 | element data * @remarks * 只需要传入需要更新的数据即可,不必传入完整的数据 * * Just pass in the data that needs to be updated, no need to pass in the complete data * @example * ```ts * graph.updateData({ * nodes: [{ id: 'node-1', style: { x: 100, y: 100 } }], * edges: [{ id: 'edge-1', style: { lineWidth: 2 } }] * }); * ``` * @apiCategory data */ public updateData(data: PartialGraphData | ((prev: GraphData) => PartialGraphData)): void { this.context.model.updateData(isFunction(data) ? data(this.getData()) : data); } /** * 更新节点数据 * * Update node data * @param data - 节点数据 | node data * @remarks * 只需要传入需要更新的数据即可,不必传入完整的数据 * * Just pass in the data that needs to be updated, no need to pass in the complete data * @example * ```ts * graph.updateNodeData([{ id: 'node-1', style: { x: 100, y: 100 } }]); * ``` * @apiCategory data */ public updateNodeData( data: PartialNodeLikeData[] | ((prev: NodeData[]) => PartialNodeLikeData[]), ): void { this.context.model.updateNodeData(isFunction(data) ? data(this.getNodeData()) : data); } /** * 更新边数据 * * Update edge data * @param data - 边数据 | edge data * @remarks * 只需要传入需要更新的数据即可,不必传入完整的数据 * * Just pass in the data that needs to be updated, no need to pass in the complete data * @example * ```ts * graph.updateEdgeData([{ id: 'edge-1', style: { lineWidth: 2 } }]); * ``` * @apiCategory data */ public updateEdgeData(data: PartialEdgeData[] | ((prev: EdgeData[]) => PartialEdgeData[])): void { this.context.model.updateEdgeData(isFunction(data) ? data(this.getEdgeData()) : data); } /** * 更新组合数据 * * Update combo data * @param data - 组合数据 | combo data * @remarks * 只需要传入需要更新的数据即可,不必传入完整的数据 * * Just pass in the data that needs to be updated, no need to pass in the complete data * @example * ```ts * graph.updateComboData([{ id: 'combo-1', style: { x: 100, y: 100 } }]); * ``` * @apiCategory data */ public updateComboData( data: PartialNodeLikeData[] | ((prev: ComboData[]) => PartialNodeLikeData[]), ): void { this.context.model.updateComboData(isFunction(data) ? data(this.getComboData()) : data); } /** * 删除元素数据 * * Remove element data * @param ids - 元素 ID 数组 | element ID array * @example * ```ts * graph.removeData({ * nodes: ['node-1', 'node-2'], * edges: ['edge-1'], * }); * ``` * @apiCategory data */ public removeData(ids: DataID | ((data: GraphData) => DataID)): void { this.context.model.removeData(isFunction(ids) ? ids(this.getData()) : ids); } /** * 删除节点数据 * * Remove node data * @param ids - 节点 ID 数组 | node ID array * @example * ```ts * graph.removeNodeData(['node-1', 'node-2']); * ``` * @apiCategory data */ public removeNodeData(ids: ID[] | ((data: NodeData[]) => ID[])): void { this.context.model.removeNodeData(isFunction(ids) ? ids(this.getNodeData()) : ids); } /** * 删除边数据 * * Remove edge data * @param ids - 边 ID 数组 | edge ID array * @remarks * 如果传入边数据时仅提供了 source 和 target,那么需要通过 `idOf` 方法获取边的实际 ID * * If only the source and target are provided when passing in the edge data, you need to get the actual ID of the edge through the `idOf` method * @example * ```ts * graph.removeEdgeData(['edge-1']); * ``` * @apiCategory data */ public removeEdgeData(ids: ID[] | ((data: EdgeData[]) => ID[])): void { this.context.model.removeEdgeData(isFunction(ids) ? ids(this.getEdgeData()) : ids); } /** * 删除组合数据 * * Remove combo data * @param ids - 组合 ID 数组 | 组合 ID array * @example * ```ts * graph.removeComboData(['combo-1']); * ``` * @apiCategory data */ public removeComboData(ids: ID[] | ((data: ComboData[]) => ID[])): void { this.context.model.removeComboData(isFunction(ids) ? ids(this.getComboData()) : ids); } /** * 获取元素类型 * * Get element type * @param id - 元素 ID | element ID * @returns 元素类型 | element type * @apiCategory element */ public getElementType(id: ID): ElementType { return this.context.model.getElementType(id); } /** * 获取节点或组合关联边的数据 * * Get edge data related to the node or combo * @param id - 节点或组合ID | node or combo ID * @param direction - 边的方向 | edge direction * @returns 边数据 | edge data * @apiCategory data */ public getRelatedEdgesData(id: ID, direction: EdgeDirection = 'both'): EdgeData[] { return this.context.model.getRelatedEdgesData(id, direction); } /** * 获取节点或组合的一跳邻居节点数据 * * Get the one-hop neighbor node data of the node or combo * @param id - 节点或组合ID | node or combo ID * @returns 邻居节点数据 | neighbor node data * @apiCategory data */ public getNeighborNodesData(id: ID): NodeData[] { return this.context.model.getNeighborNodesData(id); } /** * 获取节点或组合的祖先元素数据 * * Get the ancestor element data of the node or combo * @param id - 节点或组合ID | node or combo ID * @param hierarchy - 指定树图层级关系还是组合层级关系 | specify tree or combo hierarchy relationship * @returns 祖先元素数据 | ancestor element data * @remarks * 数组中的顺序是从父节点到祖先节点 * * The order in the array is from the parent node to the ancestor node * @apiCategory data */ public getAncestorsData(id: ID, hierarchy: HierarchyKey): NodeLikeData[] { return this.context.model.getAncestorsData(id, hierarchy); } /** * 获取节点或组合的父元素数据 * * Get the parent element data of the node or combo * @param id - 节点或组合ID | node or combo ID * @param hierarchy - 指定树图层级关系还是组合层级关系 | specify tree or combo hierarchy relationship * @returns 父元素数据 | parent element data * @apiCategory data */ public getParentData(id: ID, hierarchy: HierarchyKey): NodeLikeData | undefined { return this.context.model.getParentData(id, hierarchy); } /** * 获取节点或组合的子元素数据 * * Get the child element data of the node or combo * @param id - 节点或组合ID | node or combo ID * @returns 子元素数据 | child element data * @apiCategory data */ public getChildrenData(id: ID): NodeLikeData[] { return this.context.model.getChildrenData(id); } /** * 获取节点或组合的后代元素数据 * * Get the descendant element data of the node or combo * @param id - 节点或组合ID | node or combo ID * @returns 后代元素数据 | descendant element data * @apiCategory data */ public getDescendantsData(id: ID): NodeLikeData[] { return this.context.model.getDescendantsData(id); } /** * 获取指定状态下的节点数据 * * Get node data in a specific state * @param state - 状态 | state * @returns 节点数据 | node data * @example * ```ts * const nodes = graph.getElementDataByState('node', 'selected'); * ``` * @apiCategory data */ public getElementDataByState(elementType: 'node', state: State): NodeData[]; /** * 获取指定状态下的边数据 * * Get edge data in a specific state * @param state - 状态 | state * @returns 边数据 | edge data * @example * ```ts * const nodes = graph.getElementDataByState('edge', 'selected'); * ``` * @apiCategory data */ public getElementDataByState(elementType: 'edge', state: State): EdgeData[]; /** * 获取指定状态下的组合数据 * * Get combo data in a specific state * @param state - 状态 | state * @returns 组合数据 | combo data * @example * ```ts * const nodes = graph.getElementDataByState('node', 'selected'); * ``` * @apiCategory data */ public getElementDataByState(elementType: 'combo', state: State): ComboData[]; public getElementDataByState(elementType: ElementType, state: State): ElementDatum[] { return this.context.model.getElementDataByState(elementType, state); } private async initCanvas() { if (this.context.canvas) return await this.context.canvas.ready; const { container = 'container', width, height, renderer, cursor, background, canvas: canvasOptions, devicePixelRatio = globalThis.devicePixelRatio ?? 1, } = this.options; if (container instanceof Canvas) { this.context.canvas = container; if (cursor) container.setCursor(cursor); if (renderer) container.setRenderer(renderer); await container.ready; } else { const $container = isString(container) ? document.getElementById(container!) : container; const containerSize = sizeOf($container!); this.emit(GraphEvent.BEFORE_CANVAS_INIT, { container: $container, width, height }); const options = { ...canvasOptions, container: $container!, width: width || containerSize[0], height: height || containerSize[1], background, renderer, cursor, devicePixelRatio, }; const canvas = new Canvas(options); this.context.canvas = canvas; await canvas.ready; this.emit(GraphEvent.AFTER_CANVAS_INIT, { canvas }); } } private updateCanvas(options: GraphOptions) { const { renderer, cursor, height, width } = options; const canvas = this.context.canvas; if (!canvas) return; if (renderer) { this.emit(GraphEvent.BEFORE_RENDERER_CHANGE, { renderer: this.options.renderer }); canvas.setRenderer(renderer); this.emit(GraphEvent.AFTER_RENDERER_CHANGE, { renderer }); } if (cursor) canvas.setCursor(cursor); if (isNumber(width) || isNumber(height)) this.setSize(width ?? this.options.width ?? 0, height ?? this.options.height ?? 0); } private initRuntime() { this.context.options = this.options; if (!this.context.batch) this.context.batch = new BatchController(this.context); if (!this.context.plugin) this.context.plugin = new PluginController(this.context); if (!this.context.viewport) this.context.viewport = new ViewportController(this.context); if (!this.context.transform) this.context.transform = new TransformController(this.context); if (!this.context.element) this.context.element = new ElementController(this.context); if (!this.context.animation) this.context.animation = new Animation(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 { // 等待同步任务执行完成,避免 render 后立即调用 destroy 导致的问题 // Wait for synchronous tasks to complete, to avoid problems caused by calling destroy immediately after render await Promise.resolve(); if (this.destroyed) { // 如果图实例已经被销毁,则不再执行任何操作 // If the graph instance has been destroyed, no further operations will be performed // eslint-disable-next-line no-console console.error(format('The graph instance has been destroyed')); return; } await this.initCanvas(); this.initRuntime(); } /** * 执行渲染 * * Render * @remarks * 此过程会执行数据更新、绘制元素、执行布局 * * > ⚠️ render 为异步方法,如果需要在 render 后执行一些操作,可以使用 `await graph.render()` 或者监听 GraphEvent.AFTER_RENDER 事件 * * This process will execute data update, element rendering, and layout execution * * > ⚠️ render is an asynchronous method. If you need to perform some operations after render, you can use `await graph.render()` or listen to the GraphEvent.AFTER_RENDER event * @apiCategory render */ public async render(): Promise { await this.prepare(); emit(this, new GraphLifeCycleEvent(GraphEvent.BEFORE_RENDER)); if (!this.options.layout) { const animation = this.context.element!.draw({ type: 'render' }); await Promise.all([animation?.finished, this.autoFit()]); } else if (!this.rendered && isPreLayout(this.options.layout)) { const animation = await this.context.element!.preLayoutDraw({ type: 'render' }); await Promise.all([animation?.finished, this.autoFit()]); } else { const animation = this.context.element!.draw({ type: 'render' }); await Promise.all([animation?.finished, this.context.layout!.postLayout()]); await this.autoFit(); } this.rendered = true; emit(this, new GraphLifeCycleEvent(GraphEvent.AFTER_RENDER)); } /** * 绘制元素 * * Draw elements * @returns 渲染结果 | draw result * @remarks * 仅执行元素绘制,不会重新布局 * * ⚠️ draw 为异步方法,如果需要在 draw 后执行一些操作,可以使用 `await graph.draw()` 或者监听 GraphEvent.AFTER_DRAW 事件 * * Only execute element drawing, no re-layout * * ⚠️ draw is an asynchronous method. If you need to perform some operations after draw, you can use `await graph.draw()` or listen to the GraphEvent.AFTER_DRAW event * @apiCategory render */ public async draw(): Promise { await this.prepare(); await this.context.element!.draw()?.finished; } /** * 执行布局 * * Execute layout * @param layoutOptions - 布局配置项 | Layout options * @apiCategory layout */ public async layout(layoutOptions?: LayoutOptions) { await this.context.layout!.postLayout(layoutOptions); } /** * 停止布局 * * Stop layout * @remarks * 适用于带有迭代动画的布局,目前有 `force` 属于此类布局,即停止力导布局的迭代,一般用于布局迭代时间过长情况下的手动停止迭代动画,例如在点击画布/节点的监听中调用 * * Suitable for layouts with iterative animations. Currently, `force` belongs to this type of layout, that is, stop the iteration of the force-directed layout. It is generally used to manually stop the iteration animation when the layout iteration time is too long, such as calling in the click canvas/node listener * @apiCategory layout */ public stopLayout() { this.context.layout!.stopLayout(); } /** * 清空画布元素 * * Clear canvas elements * @apiCategory canvas */ public async clear(): Promise { const { model, element } = this.context; model.setData({}); model.clearChanges(); element?.clear(); } /** * 销毁当前图实例 * * Destroy the current graph instance * @remarks * 销毁后无法进行任何操作,如果需要重新使用,需要重新创建一个新的图实例 * * After destruction, no operations can be performed. If you need to reuse it, you need to create a new graph instance * @apiCategory instance */ public destroy(): void { emit(this, new GraphLifeCycleEvent(GraphEvent.BEFORE_DESTROY)); const { layout, animation, element, model, canvas, behavior, plugin } = this.context; plugin?.destroy(); behavior?.destroy(); layout?.destroy(); animation?.destroy(); element?.destroy(); model.destroy(); canvas?.destroy(); this.options = {}; // @ts-expect-error force delete this.context = {}; this.off(); globalThis.removeEventListener?.('resize', this.onResize); this.destroyed = true; emit(this, new GraphLifeCycleEvent(GraphEvent.AFTER_DESTROY)); } /** * 获取画布实例 * * Get canvas instance * @returns - 画布实例 | canvas instance * @apiCategory canvas */ public getCanvas(): Canvas { return this.context.canvas; } /** * 调整画布大小为图容器大小 * * Resize the canvas to the size of the graph container * @apiCategory viewport */ public resize(): void; /** * 调整画布大小为指定宽高 * * Resize the canvas to the specified width and height * @param width - 宽度 | width * @param height - 高度 | height * @apiCategory viewport */ public resize(width: number, height: number): void; public resize(width?: number, height?: number): void { const containerSize = sizeOf(this.context.canvas?.getContainer()); const specificSize: Vector2 = [width || containerSize[0], height || containerSize[1]]; if (!this.context.canvas) return; const canvasSize = this.context.canvas!.getSize(); if (isEqual(specificSize, canvasSize)) return; emit(this, new GraphLifeCycleEvent(GraphEvent.BEFORE_SIZE_CHANGE, { size: specificSize })); this.context.canvas.resize(...specificSize); emit(this, new GraphLifeCycleEvent(GraphEvent.AFTER_SIZE_CHANGE, { size: specificSize })); } /** * 将图缩放至合适大小并平移至视口中心 * * Zoom the graph to fit the viewport and move it to the center of the viewport * @param options - 适配配置 | fit options * @param animation - 动画配置 | animation options * @apiCategory viewport */ public async fitView(options?: FitViewOptions, animation?: ViewportAnimationEffectTiming): Promise { await this.context.viewport?.fitView(options, animation); } /** * 将图平移至视口中心 * * Move the graph to the center of the viewport * @param animation - 动画配置 | animation options * @apiCategory viewport */ public async fitCenter(animation?: ViewportAnimationEffectTiming): Promise { await this.context.viewport?.fitCenter({ animation }); } private async autoFit(): Promise { const { autoFit } = this.context.options; if (!autoFit) return; if (isString(autoFit)) { if (autoFit === 'view') await this.fitView(); else if (autoFit === 'center') await this.fitCenter(); } else { const { type, animation } = autoFit; if (type === 'view') await this.fitView(autoFit.options, animation); else if (type === 'center') await this.fitCenter(animation); } } /** * 聚焦元素 * * Focus on element * @param id - 元素 ID | element ID * @param animation - 动画配置 | animation options * @remarks * 移动图,使得元素对齐到视口中心 * * Move the graph so that the element is aligned to the center of the viewport * @apiCategory viewport */ public async focusElement(id: ID | ID[], animation?: ViewportAnimationEffectTiming): Promise { await this.context.viewport?.focusElements(Array.isArray(id) ? id : [id], { animation }); } /** * 基于当前缩放比例进行缩放(相对缩放) * * Zoom based on the current zoom ratio (relative zoom) * @param ratio - 缩放比例 | zoom ratio * @param animation - 动画配置 | animation options * @param origin - 缩放中心(视口坐标) | zoom center(viewport coordinates) * @remarks * * - ratio > 1 放大 * - ratio < 1 缩小 * * * - ratio > 1 zoom in * - ratio < 1 zoom out * @apiCategory viewport */ public async zoomBy(ratio: number, animation?: ViewportAnimationEffectTiming, origin?: Point): Promise { await this.context.viewport!.transform({ mode: 'relative', scale: ratio, origin }, animation); } /** * 缩放画布至指定比例(绝对缩放) * * Zoom the canvas to the specified ratio (absolute zoom) * @param zoom - 指定缩放比例 | specified zoom ratio * @param animation - 动画配置 | animation options * @param origin - 缩放中心(视口坐标) | zoom center(viewport coordinates) * @remarks * * - zoom = 1 默认大小 * - zoom > 1 放大 * - zoom < 1 缩小 * * * - zoom = 1 default size * - zoom > 1 zoom in * - zoom < 1 zoom out * @apiCategory viewport */ public async zoomTo(zoom: number, animation?: ViewportAnimationEffectTiming, origin?: Point): Promise { await this.context.viewport!.transform({ mode: 'absolute', scale: zoom, origin }, animation); } /** * 获取当前缩放比例 * * Get the current zoom ratio * @returns 缩放比例 | zoom ratio * @apiCategory viewport */ public getZoom(): number { return this.context.viewport!.getZoom(); } /** * 基于当前旋转角度进行旋转(相对旋转) * * Rotate based on the current rotation angle (relative rotation) * @param angle - 旋转角度 | rotation angle * @param animation - 动画配置 | animation options * @param origin - 旋转中心(视口坐标) | rotation center(viewport coordinates) * @apiCategory viewport */ public async rotateBy(angle: number, animation?: ViewportAnimationEffectTiming, origin?: Point): Promise { await this.context.viewport!.transform({ mode: 'relative', rotate: angle, origin }, animation); } /** * 旋转画布至指定角度 (绝对旋转) * * Rotate the canvas to the specified angle (absolute rotation) * @param angle - 目标角度 | target angle * @param animation - 动画配置 | animation options * @param origin - 旋转中心(视口坐标) | rotation center(viewport coordinates) * @apiCategory viewport */ public async rotateTo(angle: number, animation?: ViewportAnimationEffectTiming, origin?: Point): Promise { await this.context.viewport!.transform({ mode: 'absolute', rotate: angle, origin }, animation); } /** * 获取当前旋转角度 * * Get the current rotation angle * @returns 旋转角度 | rotation angle * @apiCategory viewport */ public getRotation(): number { return this.context.viewport!.getRotation(); } /** * 将图平移指定距离 (相对平移) * * Translate the graph by the specified distance (relative translation) * @param offset - 偏移量 | offset * @param animation - 动画配置 | animation options * @apiCategory viewport */ public async translateBy(offset: Point, animation?: ViewportAnimationEffectTiming): Promise { await this.context.viewport!.transform({ mode: 'relative', translate: offset }, animation); } /** * 将图平移至指定位置 (绝对平移) * * Translate the graph to the specified position (absolute translation) * @param position - 指定位置 | specified position * @param animation - 动画配置 | animation options * @apiCategory viewport */ public async translateTo(position: Point, animation?: ViewportAnimationEffectTiming): Promise { await this.context.viewport!.transform({ mode: 'absolute', translate: position }, animation); } /** * 获取图的位置 * * Get the position of the graph * @returns 图的位置 | position of the graph * @remarks * 即画布原点在视口坐标系下的位置。默认状态下,图的位置为 [0, 0] * * That is, the position of the canvas origin in the viewport coordinate system. By default, the position of the graph is [0, 0] * @apiCategory viewport */ public getPosition(): Point { return subtract([0, 0], this.getCanvasByViewport([0, 0])); } /** * 将元素平移指定距离 (相对平移) * * Translate the element by the specified distance (relative translation) * @param id - 元素 ID | element ID * @param offset - 偏移量 | offset * @param animation - 是否启用动画 | whether to enable animation * @apiCategory element */ public async translateElementBy(id: ID, offset: Point, animation?: boolean): Promise; /** * 批量将元素平移指定距离 (相对平移) * * Batch translate elements by the specified distance (relative translation) * @param offsets - 偏移量配置 | offset options * @param animation - 是否启用动画 | whether to enable animation * @apiCategory element */ public async translateElementBy(offsets: Record, animation?: boolean): Promise; public async translateElementBy( args1: ID | Record, args2?: Point | boolean, args3: boolean = true, ): Promise { const [config, animation] = isObject(args1) ? [args1, (args2 as boolean) ?? true] : [{ [args1 as ID]: args2 as Point }, args3]; Object.entries(config).forEach(([id, offset]) => this.context.model.translateNodeLikeBy(id, offset)); await this.context.element!.draw({ animation, stage: 'translate' })?.finished; } /** * 将元素平移至指定位置 (绝对平移) * * Translate the element to the specified position (absolute translation) * @param id - 元素 ID | element ID * @param position - 指定位置 | specified position * @param animation - 是否启用动画 | whether to enable animation * @apiCategory element */ public async translateElementTo(id: ID, position: Point, animation?: boolean): Promise; /** * 批量将元素平移至指定位置 (绝对平移) * * Batch translate elements to the specified position (absolute translation) * @param positions - 位置配置 | position options * @param animation - 是否启用动画 | whether to enable animation * @apiCategory element */ public async translateElementTo(positions: Record, animation?: boolean): Promise; public async translateElementTo( args1: ID | Record, args2?: boolean | Point, args3: boolean = true, ): Promise { const [config, animation] = isObject(args1) ? [args1, (args2 as boolean) ?? true] : [{ [args1 as ID]: args2 as Point }, args3]; Object.entries(config).forEach(([id, position]) => this.context.model.translateNodeLikeTo(id, position)); await this.context.element!.draw({ animation, stage: 'translate' })?.finished; } /** * 获取元素位置 * * Get element position * @param id - 元素 ID | element ID * @returns 元素位置 | element position * @apiCategory element */ public getElementPosition(id: ID): Point { return this.context.model.getElementPosition(id); } /** * 获取元素渲染样式 * * Get element rendering style * @param id - 元素 ID | element ID * @returns 元素渲染样式 | element rendering style * @apiCategory element */ public getElementRenderStyle(id: ID): Record { return omit(this.context.element!.getElement(id)!.attributes, ['context']); } /** * 设置元素可见性 * * Set element visibility * @param id - 元素 ID | element ID * @param visibility - 可见性 | visibility * @param animation - 动画配置 | animation options * @remarks * 可见性配置包括 `visible` 和 `hidden` 两种状态 * * Visibility configuration includes two states: `visible` and `hidden` * @apiCategory element */ public async setElementVisibility( id: ID, visibility: BaseStyleProps['visibility'], animation?: boolean, ): Promise; /** * 批量设置元素可见性 * * Batch set element visibility * @param visibility - 可见性配置 | visibility options * @param animation - 动画配置 | animation options * @apiCategory element */ public async setElementVisibility( visibility: Record, animation?: boolean, ): Promise; public async setElementVisibility( args1: ID | Record, args2?: boolean | BaseStyleProps['visibility'], args3: boolean = true, ): Promise { const [config, animation] = isObject(args1) ? [args1, (args2 as boolean) ?? true] : [{ [args1]: args2 as BaseStyleProps['visibility'] }, args3]; const dataToUpdate: Required = { nodes: [], edges: [], combos: [] }; Object.entries(config).forEach(([id, value]) => { const elementType = this.getElementType(id); dataToUpdate[`${elementType}s`].push({ id, style: { visibility: value } }); }); const { model, element } = this.context; model.preventUpdateNodeLikeHierarchy(() => { model.updateData(dataToUpdate); }); await element!.draw({ animation, stage: 'visibility' })?.finished; } /** * 显示元素 * * Show element * @param id - 元素 ID | element ID * @param animation - 是否启用动画 | whether to enable animation * @apiCategory element */ public async showElement(id: ID | ID[], animation?: boolean): Promise { const ids = Array.isArray(id) ? id : [id]; await this.setElementVisibility( Object.fromEntries(ids.map((_id) => [_id, 'visible'] as [ID, BaseStyleProps['visibility']])), animation, ); } /** * 隐藏元素 * * Hide element * @param id - 元素 ID | element ID * @param animation - 是否启用动画 | whether to enable animation * @apiCategory element */ public async hideElement(id: ID | ID[], animation?: boolean): Promise { const ids = Array.isArray(id) ? id : [id]; await this.setElementVisibility( Object.fromEntries(ids.map((_id) => [_id, 'hidden'] as [ID, BaseStyleProps['visibility']])), animation, ); } /** * 获取元素可见性 * * Get element visibility * @param id - 元素 ID | element ID * @returns 元素可见性 | element visibility * @apiCategory element */ public getElementVisibility(id: ID): BaseStyleProps['visibility'] { const element = this.context.element!.getElement(id)!; return element?.style?.visibility ?? 'visible'; } /** * 设置元素层级 * * Set element z-index * @param id - 元素 ID | element ID * @param zIndex - 层级 | z-index * @apiCategory element */ public async setElementZIndex(id: ID, zIndex: number): Promise; /** * 批量设置元素层级 * * Batch set element z-index * @param zIndex - 层级配置 | z-index options * @apiCategory element */ public async setElementZIndex(zIndex: Record): Promise; public async setElementZIndex(args1: ID | Record, args2?: number): Promise { const dataToUpdate: Required = { nodes: [], edges: [], combos: [] }; const config = isObject(args1) ? args1 : { [args1 as ID]: args2 as number }; Object.entries(config).forEach(([id, value]) => { const elementType = this.getElementType(id); dataToUpdate[`${elementType}s`].push({ id, style: { zIndex: value } }); }); const { model, element } = this.context; model.preventUpdateNodeLikeHierarchy(() => model.updateData(dataToUpdate)); await element!.draw({ animation: false, stage: 'zIndex' })?.finished; } /** * 将元素置于最顶层 * * Bring the element to the front * @param id - 元素 ID | element ID * @apiCategory element */ public async frontElement(id: ID | ID[]): Promise { const ids = Array.isArray(id) ? id : [id]; const { model } = this.context; const zIndexes: Record = {}; ids.map((_id) => { const zIndex = model.getFrontZIndex(_id); const elementType = model.getElementType(_id); if (elementType === 'combo') { const ancestor = model.getAncestorsData(_id, COMBO_KEY).at(-1) || this.getComboData(_id); const descendants = [ancestor, ...model.getDescendantsData(idOf(ancestor))]; const delta = zIndex - getZIndexOf(ancestor); descendants.forEach((combo) => { zIndexes[idOf(combo)] = this.getElementZIndex(idOf(combo)) + delta; }); const { internal } = getSubgraphRelatedEdges(descendants.map(idOf), (id) => model.getRelatedEdgesData(id)); internal.forEach((edge) => { const edgeId = idOf(edge); zIndexes[edgeId] = this.getElementZIndex(edgeId) + delta; }); } else zIndexes[_id] = zIndex; }); await this.setElementZIndex(zIndexes); } /** * 获取元素层级 * * Get element z-index * @param id - 元素 ID | element ID * @returns 元素层级 | element z-index * @apiCategory element */ public getElementZIndex(id: ID): number { return getZIndexOf(this.context.model.getElementDataById(id)); } /** * 设置元素状态 * * Set element state * @param id - 元素 ID | element ID * @param state - 状态 | state * @param animation - 动画配置 | animation options * @apiCategory element */ public async setElementState(id: ID, state: State | State[], animation?: boolean): Promise; /** * 批量设置元素状态 * * Batch set element state * @param state - 状态配置 | state options * @param animation - 动画配置 | animation options * @apiCategory element */ public async setElementState(state: Record, animation?: boolean): Promise; public async setElementState( args1: ID | Record, args2?: boolean | State | State[], args3: boolean = true, ): Promise { const [config, animation] = isObject(args1) ? [args1, (args2 as boolean) ?? true] : [{ [args1]: args2 as State | State[] }, args3]; const parseState = (state: State | State[]) => { if (!state) return []; return Array.isArray(state) ? state : [state]; }; const dataToUpdate: Required = { nodes: [], edges: [], combos: [] }; Object.entries(config).forEach(([id, value]) => { const elementType = this.getElementType(id); dataToUpdate[`${elementType}s`].push({ id, states: parseState(value) }); }); this.updateData(dataToUpdate); await this.context.element!.draw({ animation, stage: 'state' })?.finished; } /** * 获取元素状态 * * Get element state * @param id - 元素 ID | element ID * @returns 元素状态 | element state * @apiCategory element */ public getElementState(id: ID): State[] { return this.context.model.getElementState(id); } /** * 获取元素自身以及子节点在世界坐标系下的渲染包围盒 * * Get the rendering bounding box of the element itself and its child nodes in the world coordinate system * @param id - 元素 ID | element ID * @returns 渲染包围盒 | render bounding box * @apiCategory element */ public getElementRenderBounds(id: ID): AABB { return this.context.element!.getElement(id)!.getRenderBounds(); } private isCollapsingExpanding = false; /** * 收起元素 * * Collapse element * @param id - 元素 ID | element ID * @param options - 是否启用动画或者配置收起节点的配置项 | whether to enable animation or the options of collapsing node * @apiCategory element */ public async collapseElement(id: ID, options: boolean | CollapseExpandNodeOptions = true): Promise { const { model, element } = this.context; if (isCollapsed(model.getNodeLikeData([id])[0])) return; if (this.isCollapsingExpanding) return; if (typeof options === 'boolean') options = { animation: options, align: false }; const elementType = model.getElementType(id); await this.frontElement(id); this.isCollapsingExpanding = true; // 更新折叠状态 / Update collapse style model.updateData( elementType === 'node' ? { nodes: [{ id, style: { collapsed: true } }], } : { combos: [{ id, style: { collapsed: true } }], }, ); if (elementType === 'node') await element!.collapseNode(id, options); else if (elementType === 'combo') await element!.collapseCombo(id, !!options.animation); this.isCollapsingExpanding = false; } /** * 展开元素 * * Expand Element * @param id - 元素 ID | element ID * @param animation - 是否启用动画或者配置收起节点的配置项 | whether to enable animation or the options of collapsing node * @param options * @apiCategory element */ public async expandElement(id: ID, options: boolean | CollapseExpandNodeOptions = true): Promise { const { model, element } = this.context; if (!isCollapsed(model.getNodeLikeData([id])[0])) return; if (this.isCollapsingExpanding) return; if (typeof options === 'boolean') options = { animation: options, align: false }; const elementType = model.getElementType(id); this.isCollapsingExpanding = true; // 更新折叠状态 / Update collapse style model.updateData( elementType === 'node' ? { nodes: [{ id, style: { collapsed: false } }], } : { combos: [{ id, style: { collapsed: false } }], }, ); if (elementType === 'node') await element!.expandNode(id, options); else if (elementType === 'combo') await element!.expandCombo(id, !!options.animation); this.isCollapsingExpanding = false; } private setElementCollapsibility(id: ID, collapsed: boolean) { const elementType = this.getElementType(id); if (elementType === 'node') this.updateNodeData([{ id, style: { collapsed } }]); else if (elementType === 'combo') this.updateComboData([{ id, style: { collapsed } }]); } /** * 导出画布内容为 DataURL * * Export canvas content as DataURL * @param options - 导出配置 | export options * @returns DataURL | DataURL * @apiCategory exportImage */ public async toDataURL(options: Partial = {}): Promise { return this.context.canvas!.toDataURL(options); } /** * 给定的视窗 DOM 坐标,转换为画布上的绘制坐标 * * Convert the given viewport DOM coordinates to the drawing coordinates on the canvas * @param point - 视窗坐标 | viewport coordinates * @returns 画布上的绘制坐标 | drawing coordinates on the canvas * @apiCategory viewport */ public getCanvasByViewport(point: Point): Point { return this.context.canvas.getCanvasByViewport(point); } /** * 给定画布上的绘制坐标,转换为视窗 DOM 的坐标 * * Convert the given drawing coordinates on the canvas to the coordinates of the viewport DOM * @param point - 画布坐标 | canvas coordinates * @returns 视窗 DOM 的坐标 | coordinates of the viewport DOM * @apiCategory viewport */ public getViewportByCanvas(point: Point): Point { return this.context.canvas.getViewportByCanvas(point); } /** * 给定画布上的绘制坐标,转换为浏览器坐标 * * Convert the given drawing coordinates on the canvas to browser coordinates * @param point - 画布坐标 | canvas coordinates * @returns 浏览器坐标 | browser coordinates * @apiCategory viewport */ public getClientByCanvas(point: Point): Point { return this.context.canvas.getClientByCanvas(point); } /** * 给定的浏览器坐标,转换为画布上的绘制坐标 * * Convert the given browser coordinates to drawing coordinates on the canvas * @param point - 浏览器坐标 | browser coordinates * @returns 画布上的绘制坐标 | drawing coordinates on the canvas * @apiCategory viewport */ public getCanvasByClient(point: Point): Point { return this.context.canvas.getCanvasByClient(point); } /** * 获取视口中心的画布坐标 * * Get the canvas coordinates of the viewport center * @returns 视口中心的画布坐标 | Canvas coordinates of the viewport center * @apiCategory viewport */ public getViewportCenter(): Point { return this.context.viewport!.getViewportCenter(); } /** * 获取视口中心的视口坐标 * * Get the viewport coordinates of the viewport center * @returns 视口中心的视口坐标 | Viewport coordinates of the viewport center * @apiCategory viewport */ public getCanvasCenter(): Point { return this.context.viewport!.getCanvasCenter(); } private onResize = debounce(() => { this.resize(); }, 300); /** * 监听事件 * * Listen to events * @param eventName - 事件名称 | event name * @param callback - 回调函数 | callback function * @param once - 是否只监听一次 | whether to listen only once * @returns Graph 实例 | Graph instance * @apiCategory event */ public on(eventName: string, callback: (event: T) => void, once?: boolean): this { return super.on(eventName, callback, once); } /** * 一次性监听事件 * * Listen to events once * @param eventName - 事件名称 | event name * @param callback - 回调函数 | callback function * @returns Graph 实例 | Graph instance * @apiCategory event */ public once(eventName: string, callback: (event: T) => void): this { return super.once(eventName, callback); } /** * 移除全部事件监听 * * Remove all event listeners * @returns Graph 实例 | Graph instance * @apiCategory event */ public off(): this; /** * 移除指定事件的全部监听 * * Remove all listeners for the specified event * @param eventName - 事件名称 | event name * @returns Graph 实例 | Graph instance * @apiCategory event */ public off(eventName: string): this; /** * 移除事件监听 * * Remove event listener * @param eventName - 事件名称 | event name * @param callback - 回调函数 | callback function * @returns Graph 实例 | Graph instance * @apiCategory event */ public off(eventName: string, callback: (...args: any[]) => void): this; public off(eventName?: string, callback?: (...args: any[]) => void) { return super.off(eventName, callback); } } ================================================ FILE: packages/g6/src/runtime/layout.ts ================================================ import type { IAnimation } from '@antv/g'; import { Graph as Graphlib } from '@antv/graphlib'; import { isLayoutWithIterations } from '@antv/layout'; import { deepMix } from '@antv/util'; import { COMBO_KEY, GraphEvent, TREE_KEY } from '../constants'; import { BaseLayout } from '../layouts'; import { AntVLayout } from '../layouts/types'; import { getExtension } from '../registry/get'; import type { GraphData, LayoutOptions, NodeData } from '../spec'; import type { STDLayoutOptions } from '../spec/layout'; import type { DrawData } from '../transforms/types'; import type { ID, TreeData } from '../types'; import { getAnimationOptions } from '../utils/animation'; import { isCollapsed } from '../utils/collapsibility'; import { isToBeDestroyed } from '../utils/element'; import { emit, GraphLifeCycleEvent } from '../utils/event'; import { createTreeStructure } from '../utils/graphlib'; import { idOf } from '../utils/id'; import { isLegacyAntVLayout, isTreeLayout, layoutAdapter, legacyLayoutAdapter } from '../utils/layout'; import { print } from '../utils/print'; import { dfs } from '../utils/traverse'; import type { RuntimeContext } from './types'; export class LayoutController { private context: RuntimeContext; private instance?: BaseLayout; private instances: BaseLayout[] = []; private animationResult?: IAnimation | null; private get presetOptions() { return { animation: !!getAnimationOptions(this.context.options, true), }; } private get options() { const { options } = this.context; return options.layout; } constructor(context: RuntimeContext) { this.context = context; } public getLayoutInstance(): BaseLayout[] { return this.instances; } /** * 前布局,即在绘制前执行布局 * * Pre-layout, that is, perform layout before drawing * @param data - 绘制数据 | Draw data * @remarks * 前布局应该只在首次绘制前执行,后续更新不会触发 * * Pre-layout should only be executed before the first drawing, and subsequent updates will not trigger */ public async preLayout(data: DrawData) { const { graph, model } = this.context; const { add } = data; emit(graph, new GraphLifeCycleEvent(GraphEvent.BEFORE_LAYOUT, { type: 'pre' })); const simulate = await this.context.layout?.simulate(); simulate?.nodes?.forEach((l) => { const id = idOf(l); const node = add.nodes.get(id); model.syncNodeLikeDatum(l); if (node) Object.assign(node.style!, l.style); }); simulate?.edges?.forEach((l) => { const id = idOf(l); const edge = add.edges.get(id); model.syncEdgeDatum(l); if (edge) Object.assign(edge.style!, l.style); }); simulate?.combos?.forEach((l) => { const id = idOf(l); const combo = add.combos.get(id); model.syncNodeLikeDatum(l); if (combo) Object.assign(combo.style!, l.style); }); emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_LAYOUT, { type: 'pre' })); this.transformDataAfterLayout('pre', data); } /** * 后布局,即在完成绘制后执行布局 * * Post layout, that is, perform layout after drawing * @param layoutOptions - 布局配置项 | Layout options */ public async postLayout(layoutOptions: LayoutOptions | undefined = this.options) { if (!layoutOptions) return; const pipeline = Array.isArray(layoutOptions) ? layoutOptions : [layoutOptions]; const { graph } = this.context; emit(graph, new GraphLifeCycleEvent(GraphEvent.BEFORE_LAYOUT, { type: 'post' })); for (let index = 0; index < pipeline.length; index++) { const options = pipeline[index]; const data = this.getLayoutData(options); const opts = { ...this.presetOptions, ...options }; emit(graph, new GraphLifeCycleEvent(GraphEvent.BEFORE_STAGE_LAYOUT, { options: opts, index })); const result = await this.stepLayout(data, opts, index); emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_STAGE_LAYOUT, { options: opts, index })); if (!options.animation) { this.updateElementPosition(result, false); } } emit(graph, new GraphLifeCycleEvent(GraphEvent.AFTER_LAYOUT, { type: 'post' })); this.transformDataAfterLayout('post'); } private transformDataAfterLayout(type: 'pre' | 'post', data?: DrawData) { const transforms = this.context.transform.getTransformInstance(); // @ts-expect-error skip type check Object.values(transforms).forEach((transform) => transform.afterLayout(type, data)); } /** * 模拟布局 * * Simulate layout * @param options - 布局配置项 | Layout options * @returns 模拟布局结果 | Simulated layout result */ public async simulate(options: LayoutOptions | undefined = this.options): Promise { if (!options) return {}; const pipeline = Array.isArray(options) ? options : [options]; let simulation: GraphData = {}; for (let index = 0; index < pipeline.length; index++) { const options = pipeline[index]; const data = this.getLayoutData(options); const result = await this.stepLayout(data, { ...this.presetOptions, ...options, animation: false }, index); simulation = result; } return simulation; } public async stepLayout(data: GraphData, options: STDLayoutOptions, index: number): Promise { if (isTreeLayout(options)) return await this.treeLayout(data, options, index); return await this.graphLayout(data, options, index); } private async graphLayout(data: GraphData, options: STDLayoutOptions, index: number): Promise { const { animation, iterations = 300 } = options; const layout = this.initGraphLayout(options); if (!layout) return {}; this.instances[index] = layout; this.instance = layout; if (isLayoutWithIterations(layout)) { // 有动画,基于布局迭代 tick 更新位置 / Update position based on layout iteration tick if (animation) { return await layout.execute(data, { animate: true, maxIteration: iterations, onTick: (tickData: GraphData) => this.updateElementPosition(tickData, false), }); } // 无动画,直接返回终态位置 / No animation, return final position directly layout.execute(data); layout.stop(); return layout.tick(iterations); } // 无迭代的布局,直接返回终态位置 / Layout without iteration, return final position directly const layoutResult = await layout.execute(data); if (animation) { const animationResult = this.updateElementPosition(layoutResult, animation); await animationResult?.finished; } return layoutResult; } private async treeLayout(data: GraphData, options: STDLayoutOptions, index: number): Promise { const { type, animation } = options; // @ts-expect-error @antv/hierarchy 布局格式与 @antv/layout 不一致,其导出的是一个方法,而非 class // The layout format of @antv/hierarchy is inconsistent with @antv/layout, it exports a method instead of a class const layout = getExtension('layout', type) as (tree: TreeData, options: STDLayoutOptions) => TreeData; if (!layout) return {}; const { nodes = [], edges = [] } = data; const model = new Graphlib({ nodes: nodes.map((node) => ({ id: idOf(node), data: node.data || {} })), edges: edges.map((edge) => ({ id: idOf(edge), source: edge.source, target: edge.target, data: edge.data || {} })), }); createTreeStructure(model); const layoutPreset: GraphData = { nodes: [], edges: [] }; const layoutResult: GraphData = { nodes: [], edges: [] }; const roots = model.getRoots(TREE_KEY) as unknown as TreeData[]; roots.forEach((root) => { dfs( root, (node) => { node.children = model.getSuccessors(node.id) as TreeData[]; }, (node) => model.getSuccessors(node.id) as TreeData[], 'TB', ); const result = layout(root, options); const { x: rx, y: ry, z: rz = 0 } = result; // 将布局结果转化为 LayoutMapping 格式 / Convert the layout result to LayoutMapping format dfs( result, (node) => { const { id, x, y, z = 0 } = node; layoutPreset.nodes!.push({ id, style: { x: rx, y: ry, z: rz } }); layoutResult.nodes!.push({ id, style: { x, y, z } }); }, (node) => node.children, 'TB', ); }); const offset = this.inferTreeLayoutOffset(layoutResult); applyTreeLayoutOffset(layoutResult, offset); if (animation) { // 先将所有节点移动到根节点位置 / Move all nodes to the root node position first applyTreeLayoutOffset(layoutPreset, offset); this.updateElementPosition(layoutPreset, false); const animationResult = this.updateElementPosition(layoutResult, animation); await animationResult?.finished; } return layoutResult; } private inferTreeLayoutOffset(data: GraphData) { let [minX, maxX] = [Infinity, -Infinity]; let [minY, maxY] = [Infinity, -Infinity]; data.nodes?.forEach((node) => { const { x = 0, y = 0 } = node.style || {}; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); }); const { canvas } = this.context; const canvasSize = canvas.getSize(); const [x1, y1] = canvas.getCanvasByViewport([0, 0]); const [x2, y2] = canvas.getCanvasByViewport(canvasSize); if (minX >= x1 && maxX <= x2 && minY >= y1 && maxY <= y2) return [0, 0] as [number, number]; const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; return [cx - (minX + maxX) / 2, cy - (minY + maxY) / 2] as [number, number]; } public stopLayout() { if (this.instance && isLayoutWithIterations(this.instance)) { this.instance.stop(); this.instance = undefined; } if (this.animationResult) { this.animationResult.finish(); this.animationResult = undefined; } } public getLayoutData(options: STDLayoutOptions): GraphData { const { nodeFilter = () => true, comboFilter = () => true, preLayout = false, isLayoutInvisibleNodes = false, } = options; const { nodes, edges, combos } = this.context.model.getData(); const { element, model } = this.context; const getElement = (id: ID) => element!.getElement(id); const filterFn = preLayout ? (node: NodeData) => { if (!isLayoutInvisibleNodes) { if (node.style?.visibility === 'hidden') return false; if (model.getAncestorsData(node.id, TREE_KEY).some(isCollapsed)) return false; if (model.getAncestorsData(node.id, COMBO_KEY).some(isCollapsed)) return false; } return nodeFilter(node); } : (node: NodeData) => { const id = idOf(node); const element = getElement(id); if (!element) return false; if (isToBeDestroyed(element)) return false; return nodeFilter(node); }; const nodesToLayout = nodes.filter(filterFn); const combosToLayout = combos.filter(comboFilter); const nodeLikeIdsMap = new Map(nodesToLayout.map((node) => [idOf(node), node])); combosToLayout.forEach((combo) => nodeLikeIdsMap.set(idOf(combo), combo)); const edgesToLayout = edges.filter(({ source, target }) => { return nodeLikeIdsMap.has(source) && nodeLikeIdsMap.has(target); }); return { nodes: nodesToLayout, edges: edgesToLayout, combos: combosToLayout, }; } /** * 创建布局实例 * * Create layout instance * @param options - 布局配置项 | Layout options * @returns 布局对象 | Layout object */ private initGraphLayout(options: STDLayoutOptions) { const { element, viewport } = this.context; const { type, animation, iterations, ...restOptions } = options; const [width, height] = viewport!.getCanvasSize(); const center = [width / 2, height / 2]; const nodeSize: number | ((node: NodeData) => number) = (options?.nodeSize as number) ?? ((node) => { const nodeElement = element?.getElement(node.id); if (nodeElement) return nodeElement.attributes.size; return element?.getElementComputedStyle('node', node).size; }); const Ctor = getExtension('layout', type); if (!Ctor) return print.warn(`The layout of ${type} is not registered.`); const STDCtor = Object.getPrototypeOf(Ctor.prototype) === BaseLayout.prototype ? Ctor : isLegacyAntVLayout(Ctor) ? legacyLayoutAdapter(Ctor, this.context) : layoutAdapter(Ctor as new (options?: Record) => AntVLayout, this.context); const layout = new STDCtor(this.context); const config = { nodeSize, width, height, center }; switch (layout.id) { case 'd3-force': case 'd3-force-3d': Object.assign(config, { center: { x: width / 2, y: height / 2, z: 0 }, }); break; default: break; } deepMix(layout.options, config, restOptions); return layout as unknown as BaseLayout; } private updateElementPosition(layoutResult: GraphData, animation: boolean) { const { model, element } = this.context; if (!element) return null; model.updateData(layoutResult); return element.draw({ animation, silence: true }); } public destroy() { this.stopLayout(); // @ts-expect-error force delete this.context = {}; this.instance = undefined; this.instances = []; this.animationResult = undefined; } } /** * 对树形布局结果应用偏移 * * Apply offset to tree layout result * @param data - 布局数据 | Layout data * @param offset - 偏移量 | Offset */ const applyTreeLayoutOffset = (data: GraphData, offset: [number, number]) => { const [ox, oy] = offset; data.nodes?.forEach((node) => { if (node.style) { const { x = 0, y = 0 } = node.style; node.style.x = x + ox; node.style.y = y + oy; } else { node.style = { x: ox, y: oy }; } }); }; ================================================ FILE: packages/g6/src/runtime/options.ts ================================================ import type { GraphOptions } from '../spec'; /** * 基于用户传入的配置,推断出最终的配置 * * Infer the final configuration based on the configuration passed by the user * @param options - 用户传入的配置 | Configuration passed by the user * @returns 最终的配置 | Final configuration */ export function inferOptions(options: GraphOptions): GraphOptions { const flow = [inferLayoutOptions]; return flow.reduce((acc, infer) => infer(acc), options); } /** * 推断布局配置 * * Infer layout configuration * @param options - 用户传入的配置 | Configuration passed by the user * @returns 最终的配置 | Final configuration */ function inferLayoutOptions(options: GraphOptions): GraphOptions { if (!options.layout) return options; if (Array.isArray(options.layout)) return options; if ('preLayout' in options.layout) return options; if ( [ 'antv-dagre', 'combo-combined', 'compact-box', 'circular', 'concentric', 'dagre', 'fishbone', 'grid', 'indented', 'mds', 'radial', 'random', 'snake', // 下列布局的标签位置待适配,需要手动配置 preLayout false // The label position of the following layouts needs to be adapted, and preLayout needs to be manually configured as false 'dendrogram', 'mindmap', ].includes(options.layout.type) ) { options.layout.preLayout = true; } return options; } ================================================ FILE: packages/g6/src/runtime/plugin.ts ================================================ import type { BasePlugin } from '../plugins/base-plugin'; import { ExtensionController } from '../registry/extension'; import type { CustomPluginOption, PluginOptions } from '../spec/plugin'; import { print } from '../utils/print'; import type { RuntimeContext } from './types'; export class PluginController extends ExtensionController> { public category = 'plugin' as const; constructor(context: RuntimeContext) { super(context); this.setPlugins(this.context.options.plugins || []); } public setPlugins(plugins: PluginOptions) { this.setExtensions(plugins); } public getPluginInstance(key: string) { const exactly = this.extensionMap[key]; if (exactly) return exactly; print.warn(`Cannot find the plugin ${key}, will try to find it by type.`); const fussily = this.extensions.find((extension) => extension.type === key); if (fussily) return this.extensionMap[fussily.key]; } } ================================================ FILE: packages/g6/src/runtime/transform.ts ================================================ import { ExtensionController } from '../registry/extension'; import type { CustomTransformOption, TransformOptions } from '../spec/transform'; import { BaseTransform } from '../transforms'; import type { RuntimeContext } from './types'; export const REQUIRED_TRANSFORMS: TransformOptions = [ 'update-related-edges', 'collapse-expand-node', 'collapse-expand-combo', 'get-edge-actual-ends', 'arrange-draw-order', ]; export class TransformController extends ExtensionController> { public category = 'transform' as const; constructor(context: RuntimeContext) { super(context); this.setTransforms(this.context.options.transforms || []); } protected getTransforms() {} public setTransforms(transforms: TransformOptions) { this.setExtensions([ ...REQUIRED_TRANSFORMS.slice(0, REQUIRED_TRANSFORMS.length - 1), ...transforms, REQUIRED_TRANSFORMS[REQUIRED_TRANSFORMS.length - 1], ]); } public getTransformInstance(): Record; public getTransformInstance(key: string): BaseTransform; public getTransformInstance(key?: string) { return key ? this.extensionMap[key] : this.extensionMap; } } ================================================ FILE: packages/g6/src/runtime/types.ts ================================================ import type { GraphOptions } from '../spec'; import type { Animation } from './animation'; import type { BatchController } from './batch'; import type { BehaviorController } from './behavior'; import type { Canvas } from './canvas'; import type { DataController } from './data'; import type { ElementController } from './element'; import type { Graph } from './graph'; import type { LayoutController } from './layout'; import type { PluginController } from './plugin'; import type { TransformController } from './transform'; import type { ViewportController } from './viewport'; export interface RuntimeContext { /** * 图实例 * * Graph instance */ graph: Graph; /** * 画布实例 * * Canvas instance */ canvas: Canvas; /** * G6 配置项 * * G6 options */ options: GraphOptions; /** * 数据模型 * * Data model */ model: DataController; /** * 数据转换控制器 * * Data transform controller */ transform: TransformController; /** * 元素控制器 * * Element controller * @remarks * 仅在绘制开始后才可用 * * Only available after drawing starts */ element?: ElementController; /** * 元素动画执行器 * * Element animation executor */ animation?: Animation; /** * 视口控制器 * * Viewport controller */ viewport?: ViewportController; /** * 布局控制器 * * Layout controller */ layout?: LayoutController; /** * 行为控制器 * * Behavior controller */ behavior?: BehaviorController; /** * 插件控制器 * * Plugin controller */ plugin?: PluginController; /** * 批量操作控制器 * * Batch operation controller */ batch?: BatchController; } ================================================ FILE: packages/g6/src/runtime/viewport.ts ================================================ import { AABB, ICamera } from '@antv/g'; import { clamp, isNumber, pick } from '@antv/util'; import { AnimationType, GraphEvent } from '../constants'; import type { FitViewOptions, ID, Point, TransformOptions, Vector2, Vector3, ViewportAnimationEffectTiming, } from '../types'; import type { Element } from '../types/element'; import { getAnimationOptions } from '../utils/animation'; import { getBBoxSize, getCombinedBBox, getExpandedBBox, isBBoxInside, isPointInBBox } from '../utils/bbox'; import { AnimateEvent, ViewportEvent, emit } from '../utils/event'; import { isPoint } from '../utils/is'; import { parsePadding } from '../utils/padding'; import { add, divide, subtract } from '../utils/vector'; import type { RuntimeContext } from './types'; export class ViewportController { private context: RuntimeContext; private get padding() { return parsePadding(this.context.options.padding); } private get paddingOffset(): Point { const [top, right, bottom, left] = this.padding; const [offsetX, offsetY, offsetZ] = [(left - right) / 2, (top - bottom) / 2, 0]; return [offsetX, offsetY, offsetZ]; } constructor(context: RuntimeContext) { this.context = context; const [px, py] = this.paddingOffset; const { zoom, rotation, x = px, y = py } = context.options; this.transform({ mode: 'absolute', scale: zoom, translate: [x, y], rotate: rotation }, false); } private get camera() { const { canvas } = this.context; return new Proxy(canvas.getCamera(), { get: (target, prop: keyof ICamera) => { const layers = Object.entries(canvas.getLayers()).filter(([name]) => !['main'].includes(name)); const cameras = layers.map(([, layer]) => layer.getCamera()); const value = target[prop]; if (typeof value === 'function') { return (...args: any[]) => { const result = (value as (...args: any[]) => any).apply(target, args); cameras.forEach((camera) => { (camera[prop] as (...args: any[]) => any).apply(camera, args); }); return result; }; } }, }); } private landmarkCounter = 0; private createLandmark(options: Parameters[1]) { return this.camera.createLandmark(`landmark-${this.landmarkCounter++}`, options); } private getAnimation(animation?: ViewportAnimationEffectTiming) { const finalAnimation = getAnimationOptions(this.context.options, animation); if (!finalAnimation) return false; return pick({ ...finalAnimation }, ['easing', 'duration']) as Exclude; } public getCanvasSize(): [number, number] { const { canvas } = this.context; const { width = 0, height = 0 } = canvas.getConfig(); return [width, height]; } /** * 获取画布中心坐标 * * Get the center coordinates of the canvas * @returns - 画布中心坐标 | Center coordinates of the canvas * @remarks * 基于画布的宽高计算中心坐标,不受视口变换影响 * * Calculate the center coordinates based on the width and height of the canvas, not affected by the viewport transformation */ public getCanvasCenter(): Point { const { canvas } = this.context; const { width = 0, height = 0 } = canvas.getConfig(); return [width / 2, height / 2, 0]; } /** * 当前视口中心坐标 * * Current viewport center coordinates * @returns - 视口中心坐标 | Viewport center coordinates * @remarks * 以画布原点为原点,受到视口变换影响 * * With the origin of the canvas as the origin, affected by the viewport transformation */ public getViewportCenter(): Point { // 理论上应该通过 camera.getFocalPoint() 获取 // 但在 2D 场景下,通过 pan 操作时,focalPoint 不会变化 const [x, y] = this.camera.getPosition(); return [x, y, 0]; } public getGraphCenter(): Point { return this.context.graph.getViewportByCanvas(this.getCanvasCenter()); } public getZoom() { return this.camera.getZoom(); } public getRotation() { return this.camera.getRoll(); } private getTranslateOptions(options: TransformOptions) { const { camera } = this; const { mode, translate = [] } = options; const currentZoom = this.getZoom(); const position = camera.getPosition(); const focalPoint = camera.getFocalPoint(); const [cx, cy] = this.getCanvasCenter(); const [x = 0, y = 0, z = 0] = translate; const delta = divide([-x, -y, -z], currentZoom); return mode === 'relative' ? { position: add(position as Vector3, delta), focalPoint: add(focalPoint as Vector3, delta), } : { position: add([cx, cy, position[2]], delta), focalPoint: add([cx, cy, focalPoint[2]], delta), }; } private getRotateOptions(options: TransformOptions) { const { mode, rotate = 0 } = options; const roll = mode === 'relative' ? this.camera.getRoll() + rotate : rotate; return { roll }; } private getZoomOptions(options: TransformOptions) { const { zoomRange } = this.context.options; const currentZoom = this.camera.getZoom(); const { mode, scale = 1 } = options; return clamp(mode === 'relative' ? currentZoom * scale : scale, ...zoomRange!); } private transformResolver?: () => void; public async transform(options: TransformOptions, animation?: ViewportAnimationEffectTiming): Promise { const { graph } = this.context; const { translate, rotate, scale, origin } = options; this.cancelAnimation(); const _animation = this.getAnimation(animation); emit(graph, new ViewportEvent(GraphEvent.BEFORE_TRANSFORM, options)); // 针对缩放操作,且不涉及平移、旋转、中心点、动画时,直接调用 setZoomByViewportPoint // For zoom operations, and no translation, rotation, center point, and animation involved, call setZoomByViewportPoint directly if (!rotate && scale && !translate && origin && !_animation) { this.camera.setZoomByViewportPoint(this.getZoomOptions(options), origin as Vector2); emit(graph, new ViewportEvent(GraphEvent.AFTER_TRANSFORM, options)); return; } const landmarkOptions: Parameters[1] = {}; if (translate) Object.assign(landmarkOptions, this.getTranslateOptions(options)); if (isNumber(rotate)) Object.assign(landmarkOptions, this.getRotateOptions(options)); if (isNumber(scale)) Object.assign(landmarkOptions, { zoom: this.getZoomOptions(options) }); if (_animation) { emit(graph, new AnimateEvent(GraphEvent.BEFORE_ANIMATE, AnimationType.TRANSFORM, null, options)); return new Promise((resolve) => { this.transformResolver = resolve; this.camera.gotoLandmark(this.createLandmark(landmarkOptions), { ..._animation, onfinish: () => { emit(graph, new AnimateEvent(GraphEvent.AFTER_ANIMATE, AnimationType.TRANSFORM, null, options)); emit(graph, new ViewportEvent(GraphEvent.AFTER_TRANSFORM, options)); this.transformResolver = undefined; resolve(); }, }); }); } else { this.camera.gotoLandmark(this.createLandmark(landmarkOptions), { duration: 0, }); emit(graph, new ViewportEvent(GraphEvent.AFTER_TRANSFORM, options)); } } public async fitView(options?: FitViewOptions, animation?: ViewportAnimationEffectTiming): Promise { const [top, right, bottom, left] = this.padding; const { when = 'always', direction = 'both' } = options || {}; const [width, height] = this.context.canvas.getSize(); const innerWidth = width - left - right; const innerHeight = height - top - bottom; const canvasBounds = this.context.canvas.getBounds(); const bboxInViewPort = this.getBBoxInViewport(canvasBounds); const [contentWidth, contentHeight] = getBBoxSize(bboxInViewPort); const isOverflow = (direction === 'x' && contentWidth >= innerWidth) || (direction === 'y' && contentHeight >= innerHeight) || (direction === 'both' && contentWidth >= innerWidth && contentHeight >= innerHeight); if (when === 'overflow' && !isOverflow) return await this.fitCenter({ animation }); const scaleX = innerWidth / contentWidth; const scaleY = innerHeight / contentHeight; const scale = direction === 'x' ? scaleX : direction === 'y' ? scaleY : Math.min(scaleX, scaleY); const _animation = this.getAnimation(animation); if (!Number.isFinite(scale)) { return; } await this.transform( { mode: 'relative', scale, translate: add( subtract(this.getCanvasCenter(), this.getBBoxInViewport(canvasBounds).center), divide(this.paddingOffset, scale), ), }, _animation, ); } public async fitCenter(options: FocusOptions): Promise { const canvasBounds = this.context.canvas.getBounds(); await this.focus(canvasBounds, options); } public async focusElements(ids: ID[], options: FocusOptions = {}): Promise { const { element } = this.context; if (!element) return; const getBoundsOf = (el: Element) => options.shapes ? el.getShape(options.shapes).getRenderBounds() : el.getRenderBounds(); const elementsBounds = getCombinedBBox(ids.map((id) => getBoundsOf(element.getElement(id)!))); await this.focus(elementsBounds, options); } private async focus(bbox: AABB, options: FocusOptions) { const center = this.context.graph.getViewportByCanvas(bbox.center); const position = options.position || this.getCanvasCenter(); const delta = subtract(position, center); await this.transform({ mode: 'relative', translate: add(delta, this.paddingOffset) }, options.animation); } /** * 获取画布元素在视口中的包围盒 * * Get the bounding box of the canvas element in the viewport * @param bbox - 画布元素包围盒 | Canvas element bounding box * @returns - 视口中的包围盒 | Bounding box in the viewport */ public getBBoxInViewport(bbox: AABB) { const { min, max } = bbox; const { graph } = this.context; const [x1, y1] = graph.getViewportByCanvas(min); const [x2, y2] = graph.getViewportByCanvas(max); const bboxInViewport = new AABB(); bboxInViewport.setMinMax([x1, y1, 0], [x2, y2, 0]); return bboxInViewport; } /** * 判断点或包围盒是否在视口中 * * Determine whether the point or bounding box is in the viewport * @param target - 点或包围盒 | Point or bounding box * @param complete - 是否完全在视口中 | Whether it is completely in the viewport * @param tolerance - 视口外的容差 | Tolerance outside the viewport * @returns - 是否在视口中 | Whether it is in the viewport */ public isInViewport(target: Point | AABB, complete = false, tolerance = 0) { const { graph } = this.context; const size = this.getCanvasSize(); const [x1, y1] = graph.getCanvasByViewport([0, 0]); const [x2, y2] = graph.getCanvasByViewport(size); let viewportBBox = new AABB(); viewportBBox.setMinMax([x1, y1, 0], [x2, y2, 0]); if (tolerance) { viewportBBox = getExpandedBBox(viewportBBox, tolerance); } return isPoint(target) ? isPointInBBox(target, viewportBBox) : !complete ? viewportBBox.intersects(target) : isBBoxInside(target, viewportBBox); } public cancelAnimation() { // @ts-expect-error landmarks is private if (this.camera.landmarks?.length) { this.camera.cancelLandmarkAnimation(); } this.transformResolver?.(); } } export interface FocusOptions { /** * 动画配置 * * Animation configuration */ animation?: ViewportAnimationEffectTiming; /** * 使用子图形计算包围盒 * * Calculate the bounding box by using sub-shapes */ shapes?: string; /** * 对齐位置,默认为画布中心 * * Alignment position, default is the center of the canvas */ position?: Point; } ================================================ FILE: packages/g6/src/spec/behavior.ts ================================================ import type { Graph } from '../runtime/graph'; export type BehaviorOptions = (string | CustomBehaviorOption | ((this: Graph) => CustomBehaviorOption))[]; export interface UpdateBehaviorOption { key: string; [key: string]: unknown; } export interface CustomBehaviorOption extends Record { /** * 交互类型 * * Behavior type */ type: string; /** * 交互 key,即唯一标识 * * Behavior key, that is, the unique identifier * @remarks * 用于标识交互,从而进一步操作此交互 * * Used to identify the behavior for further operations * * ```ts * // Update behavior options * graph.updateBehavior({key: 'key', ...}); * ``` */ key?: string; } ================================================ FILE: packages/g6/src/spec/canvas.ts ================================================ import type { Cursor, IRenderer } from '@antv/g'; import type { Canvas, CanvasConfig } from '../runtime/canvas'; /** * 画布配置项 * * Canvas spec * @public */ export interface CanvasOptions { /** * 画布容器 * * canvas container */ container?: string | HTMLElement | Canvas; /** * 画布宽度 * * canvas width * @remarks * 如果未设置,则会自动获取容器宽度 * * If not set, the container width will be automatically obtained */ width?: number; /** * 画布高度 * * canvas height * @remarks * 如果未设置,则会自动获取容器高度 * * If not set, the container height will be automatically obtained */ height?: number; /** * 手动置顶渲染器 * * manually set renderer * @remarks * G6 采用了分层渲染的方式,分为 background、main、label、transient 四层,用户可以通过该配置项分别设置每层画布的渲染器 * * G6 adopts a layered rendering method, divided into four layers: background, main, label, transient. Users can set the renderer of each layer canvas separately through this configuration item */ renderer?: (layer: 'background' | 'main' | 'label' | 'transient') => IRenderer; /** * 是否自动调整画布大小 * * whether to auto resize canvas * @defaultValue false * @remarks * 基于 window.onresize 事件自动调整画布大小 * * Automatically adjust the canvas size based on the window.onresize event */ autoResize?: boolean; /** * 画布背景色 * * canvas background color * @remarks * 该颜色作为导出图片时的背景色 * * This color is used as the background color when exporting images */ background?: string; /** * 设备像素比 * * device pixel ratio * @remarks * 用于高清屏的设备像素比,默认为 [window.devicePixelRatio](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/devicePixelRatio) * * Device pixel ratio for high-definition screens, default is [window.devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio) */ devicePixelRatio?: number; /** * 指针样式 * * 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; } ================================================ FILE: packages/g6/src/spec/data.ts ================================================ import type { ID, State } from '../types'; import type { ComboStyle } from './element/combo'; import type { EdgeStyle } from './element/edge'; import type { NodeStyle } from './element/node'; /** * 图数据 * * Graph data * @remarks * 图数据(GraphData)是 Graph 接收的数据类型之一,包含节点、边、组合的集合。 * * 一个图数据的示例如下: * * Graph data is one of the data types received by Graph, which contains a collection of nodes, edges, and combos. * * An example of a graph data is as follows: * * ```json * { * "nodes": [ * { "id": "node1", "combo": "combo-1", "style": { "x": 100, "y": 100 } }, * { "id": "node2", "style": { "x": 200, "y": 200 } } * ], * "edges": [{ "source": "node1", "target": "node2" }], * "combos": [{ "id": "combo-1", "style": { "x": 100, "y": 100 } }] * } * ``` */ export interface GraphData { /** * 节点数据 * * node data */ nodes?: NodeData[]; /** * 边数据 * * edge data */ edges?: EdgeData[]; /** * Combo 数据 * * combo data */ combos?: ComboData[]; } /** * 节点数据 * * Node data */ export interface NodeData { /** * 节点 ID * * Node ID */ id: ID; /** * 节点类型 * * Node type */ type?: string; /** * 节点数据 * * Node data * @remarks * 用于存储节点的自定义数据,可以在样式映射中通过回调函数获取 * * Used to store custom data of the node, which can be obtained through callback functions in the style mapping */ data?: Record; /** * 节点样式 * * Node style */ style?: NodeStyle; /** * 节点初始状态 * * Initial state of the node */ states?: State[]; /** * 所属组合 ID * * ID of the combo to which the node belongs */ combo?: ID | null; /** * 子节点 ID * * Child node ID * @remarks * 适用于树图结构 * * Suitable for tree graph structure */ children?: ID[]; /** * 节点深度 * * Node depth * @remarks * 适用于树图结构 * * Suitable for tree graph structure */ depth?: number; [key: string]: unknown; } /** * 组合数据 * * Combo data */ export interface ComboData { /** * Combo ID * * Combo ID */ id: ID; /** * Combo 类型 * * Combo type */ type?: string; /** * Combo 数据 * * Combo data * @remarks * 用于存储 Combo 的自定义数据,可以在样式映射中通过回调函数获取 * * Used to store custom data of the Combo, which can be obtained through callback functions in the style mapping */ data?: Record; /** * Combo 样式 * * Combo style */ style?: ComboStyle; /** * 组合初始状态 * * Initial state of the combo */ states?: State[]; /** * 所属组合 ID * * ID of the combo to which the combo belongs */ combo?: ID | null; [key: string]: unknown; } /** * 边数据 * * Edge data */ export interface EdgeData { /** * 边 ID * * Edge ID */ id?: ID; /** * 边起始节点 ID * * Source node ID */ source: ID; /** * 边目标节点 ID * * Target node ID */ target: ID; /** * 边类型 * * Edge type */ type?: string; /** * 边数据 * * Edge data * @remarks * 用于存储边的自定义数据,可以在样式映射中通过回调函数获取 * * Used to store custom data of the edge, which can be obtained through callback functions in the style mapping */ data?: Record; /** * 边样式 * * Edge style */ style?: EdgeStyle; /** * 边初始状态 * * Initial state of the edge */ states?: State[]; [key: string]: unknown; } ================================================ FILE: packages/g6/src/spec/element/animation.ts ================================================ /** * 元素动画执行阶段 * * Stage of element animation execution */ export type AnimationStage = 'enter' | 'update' | 'exit' | 'show' | 'hide' | 'collapse' | 'expand' | string; ================================================ FILE: packages/g6/src/spec/element/combo.ts ================================================ import type { AnimationOptions } from '../../animations/types'; import type { BaseComboStyleProps } from '../../elements/combos'; import type { Graph } from '../../runtime/graph'; import type { ComboData } from '../data'; import type { AnimationStage } from './animation'; import type { PaletteOptions } from './palette'; /** * Combo 配置项 * * Combo spec */ export interface ComboOptions { /** * 组合类型 * * Combo type */ type?: string | ((this: Graph, datum: ComboData) => string); /** * 组合样式 * * Combo style */ style?: | ComboStyle | ((this: Graph, data: ComboData) => ComboStyle) | { [K in keyof ComboStyle]: ComboStyle[K] | ((this: Graph, data: ComboData) => ComboStyle[K]); }; /** * 组合状态样式 * * Combo state style */ state?: Record< string, | ComboStyle | ((this: Graph, data: ComboData) => ComboStyle) | { [K in keyof ComboStyle]: ComboStyle[K] | ((this: Graph, data: ComboData) => ComboStyle[K]); } >; /** * 组合动画 * * Combo animation */ animation?: false | Record; /** * 色板 * * Palette */ palette?: PaletteOptions; } export interface StaticComboOptions { style?: ComboStyle; state?: Record; animation?: false | Record; palette?: PaletteOptions; } export interface ComboStyle extends Partial { [key: string]: any; } ================================================ FILE: packages/g6/src/spec/element/edge.ts ================================================ import type { AnimationOptions } from '../../animations/types'; import type { BaseEdgeStyleProps } from '../../elements/edges'; import type { Graph } from '../../runtime/graph'; import type { EdgeData } from '../data'; import type { AnimationStage } from './animation'; import type { PaletteOptions } from './palette'; /** * 边配置项 * * Edge spec */ export interface EdgeOptions { /** * 边类型 * * Edge type */ type?: string | ((this: Graph, datum: EdgeData) => string); /** * 边样式 * * Edge style */ style?: | EdgeStyle | ((this: Graph, data: EdgeData) => EdgeStyle) | { [K in keyof EdgeStyle]: EdgeStyle[K] | ((this: Graph, data: EdgeData) => EdgeStyle[K]); }; /** * 边状态样式 * * Edge state style */ state?: Record< string, | EdgeStyle | ((this: Graph, data: EdgeData) => EdgeStyle) | { [K in keyof EdgeStyle]: EdgeStyle[K] | ((this: Graph, data: EdgeData) => EdgeStyle[K]); } >; /** * 边动画 * * Edge animation */ animation?: false | Record; /** * 色板 * * Palette */ palette?: PaletteOptions; } export interface StaticEdgeOptions { style?: EdgeStyle; state?: Record; animation?: false | Record; palette?: PaletteOptions; } export interface EdgeStyle extends Partial { [key: string]: unknown; } ================================================ FILE: packages/g6/src/spec/element/node.ts ================================================ import type { AnimationOptions } from '../../animations/types'; import type { BaseNodeStyleProps } from '../../elements/nodes'; import type { Graph } from '../../runtime/graph'; import type { NodeData } from '../data'; import type { AnimationStage } from './animation'; import type { PaletteOptions } from './palette'; /** * 节点配置项 * * Node spec */ export interface NodeOptions { /** * 节点类型 * * Node type */ type?: string | ((this: Graph, datum: NodeData) => string); /** * 节点样式 * * Node style */ style?: | NodeStyle | ((this: Graph, data: NodeData) => NodeStyle) | { [K in keyof NodeStyle]: NodeStyle[K] | ((this: Graph, data: NodeData) => NodeStyle[K]); }; /** * 节点状态样式 * * Node state style */ state?: Record< string, | NodeStyle | ((this: Graph, data: NodeData) => NodeStyle) | { [K in keyof NodeStyle]: NodeStyle[K] | ((this: Graph, data: NodeData) => NodeStyle[K]); } >; /** * 节点动画 * * Node animation */ animation?: false | Record; /** * 色板 * * Palette */ palette?: PaletteOptions; } export interface StaticNodeOptions { style?: NodeStyle; state?: Record; animation?: false | Record; palette?: PaletteOptions; } export interface NodeStyle extends Partial { [key: string]: unknown; } ================================================ FILE: packages/g6/src/spec/element/palette.ts ================================================ import type { Palette } from '../../palettes/types'; import type { ElementDatum } from '../../types'; /** * 色板配置项 * * Palette options * @public */ export type PaletteOptions = Palette | CategoricalPaletteOptions | ContinuousPaletteOptions; export type STDPaletteOptions = CategoricalPaletteOptions | ContinuousPaletteOptions; interface CategoricalPaletteOptions extends BasePaletteOptions { /** * 分组取色 * * Coloring by group */ type?: 'group'; /** * 分组字段,未指定时不分组 * * Group field, no grouping when not specified */ field?: string | ((datum: ElementDatum) => string); } interface ContinuousPaletteOptions extends BasePaletteOptions { /** * 基于字段值取色 * * Coloring based on field value */ type?: 'value'; /** * 取值字段 * * Value field */ field?: string | ((datum: ElementDatum) => string); } export interface BasePaletteOptions { /** * 色板颜色 * * Palette color */ color?: Palette; /** * 倒序取色 * * Color in reverse order */ invert?: boolean; } ================================================ FILE: packages/g6/src/spec/graph.ts ================================================ import type { AnimationEffectTiming } from '../animations/types'; import type { BehaviorOptions } from './behavior'; import type { CanvasOptions } from './canvas'; import type { GraphData } from './data'; import type { ComboOptions } from './element/combo'; import type { EdgeOptions } from './element/edge'; import type { NodeOptions } from './element/node'; import type { LayoutOptions } from './layout'; import type { PluginOptions } from './plugin'; import type { ThemeOptions } from './theme'; import type { TransformOptions } from './transform'; import type { ViewportOptions } from './viewport'; /** * Graph 配置项 * * Graph options * @remarks * Graph 的初始化通过 `new` 进行实例化,实例化时需要传入参数对象。目前所支持的参数如下: * * The initialization of Graph is instantiated through `new`, and the parameter object needs to be passed in when instantiated. The currently supported parameters are as follows: * * ``` * new G6.Graph(options: GraphOptions) => Graph * ``` */ export interface GraphOptions extends CanvasOptions, ViewportOptions { /** * 启用或关闭全局动画 * * Enable or disable global animation * @remarks * 为动画配置项时,会启用动画,并将该动画配置作为全局动画的基础配置 * * When it is an animation options, the animation will be enabled, and the animation configuration will be used as the basic configuration of the global animation */ animation?: boolean | AnimationEffectTiming; /** * 数据 * * Data * @remarks * 详见 [Data](/api/data/graph-data) * * See [Data](/en/api/data/graph-data) */ data?: GraphData; /** * 布局配置项 * * Layout options * @remarks * 详见 [Layout](/api/layouts/antv-dagre-layout) * * See [Layout](/en/api/layouts/antv-dagre-layout) */ layout?: LayoutOptions; /** * 节点配置项 * * Node options * @remarks * 详见 [Node](/api/elements/nodes/base-node) * * See [Node](/en/api/elements/nodes/base-node) */ node?: NodeOptions; /** * 边配置项 * * Edge options * @remarks * 详见 [Edge](/api/elements/edges/base-edge) * * See [Edge](/en/api/elements/edges/base-edge) */ edge?: EdgeOptions; /** * 组合配置项 * * Combo options * @remarks * 详见 [Combo](/api/elements/combos/base-combo) * * See [Combo](/en/api/elements/combos/base-combo) */ combo?: ComboOptions; /** * 主题 * * Theme */ theme?: ThemeOptions; /** * 启用交互 * * Enable interactions * @remarks * * - 概念:[核心概念 - 交互](/manual/core-concept/behavior) * * - 内置交互: [交互](/api/behaviors/auto-adapt-label) * * - 自定义交互: [自定义扩展 - 自定义交互](/manual/advanced/custom-behavior) * * * - Concept: [Concepts - Behavior](/en/manual/core-concept/behavior) * * - Built-in behaviors: [Behavior](/en/api/behaviors/auto-adapt-label) * * - Custom behaviors: [Custom Extension - Custom Behavior](/en/manual/advanced/custom-behavior) */ behaviors?: BehaviorOptions; /** * 启用插件 * * Enable plugins * @remarks * * - 概念:[核心概念 - 插件](/manual/core-concept/plugin) * * - 内置插件: [插件](/en/api/plugins/background) * * - 自定义插件: [自定义扩展 - 自定义插件](/manual/advanced/custom-plugin) * * * - Concept: [Concepts - Plugin](/en/manual/core-concept/plugin) * * - Built-in plugins: [Plugin](/en/api/plugins/background) * * - Custom plugins: [Custom Extension - Custom Plugin](/en/manual/advanced/custom-plugin) */ plugins?: PluginOptions; /** * 数据转换器 * * Data transforms */ transforms?: TransformOptions; } ================================================ FILE: packages/g6/src/spec/index.ts ================================================ export type { BehaviorOptions } from './behavior'; export type { CanvasOptions } from './canvas'; export type { ComboData, EdgeData, GraphData, NodeData } from './data'; export type { ComboOptions } from './element/combo'; export type { EdgeOptions } from './element/edge'; export type { NodeOptions } from './element/node'; export type { GraphOptions } from './graph'; export type { LayoutOptions } from './layout'; export type { PluginOptions } from './plugin'; export type { ThemeOptions } from './theme'; export type { TransformOptions } from './transform'; export type { ViewportOptions } from './viewport'; ================================================ FILE: packages/g6/src/spec/layout.ts ================================================ import type { BaseLayoutOptions, BuiltInLayoutOptions } from '../layouts/types'; export type LayoutOptions = SingleLayoutOptions | SingleLayoutOptions[]; export type STDLayoutOptions = BaseLayoutOptions; export type SingleLayoutOptions = BuiltInLayoutOptions | BaseLayoutOptions; ================================================ FILE: packages/g6/src/spec/plugin.ts ================================================ import type { Graph } from '../runtime/graph'; export type PluginOptions = (string | CustomPluginOption | ((this: Graph) => CustomPluginOption))[]; export interface UpdatePluginOption { key: string; [key: string]: unknown; } export interface CustomPluginOption extends Record { /** * 插件类型 * * Plugin type */ type: string; /** * 插件 key,即唯一标识 * * Plugin key, that is, the unique identifier * @remarks * 用于标识插件,从而进一步操作此插件 * * Used to identify the plugin for further operations * * ```ts * // Get plugin instance * const plugin = graph.getPluginInstance('key'); * // Update plugin options * graph.updatePlugin({key: 'key', ...}); * ``` */ key?: string; } ================================================ FILE: packages/g6/src/spec/theme.ts ================================================ /** * 主题配置项 * * Theme Options * @public */ export type ThemeOptions = false | 'light' | 'dark' | string; ================================================ FILE: packages/g6/src/spec/transform.ts ================================================ import type { Graph } from '../runtime/graph'; export type TransformOptions = (string | CustomTransformOption | ((this: Graph) => CustomTransformOption))[]; export interface UpdateTransformOption { key: string; [key: string]: unknown; } export interface CustomTransformOption { type: string; key?: string; [key: string]: unknown; } ================================================ FILE: packages/g6/src/spec/viewport.ts ================================================ import type { FitViewOptions, ViewportAnimationEffectTiming } from '../types'; import type { Padding, STDPadding } from '../types/padding'; /** * 视口配置项 * * Viewport * @public */ export interface ViewportOptions { /** * 视口 x 坐标 * * viewport x coordinate */ x?: number; /** * 视口 y 坐标 * * viewport y coordinate */ y?: number; /** * 是否自动适应 * * whether to auto fit * @remarks * 每次执行 `render` 时,都会根据 `autoFit` 进行自适应 * * Every time `render` is executed, it will be adapted according to `autoFit` */ autoFit?: | { type: 'view'; options?: FitViewOptions; animation?: ViewportAnimationEffectTiming } | { type: 'center'; animation?: ViewportAnimationEffectTiming } | 'view' | 'center'; /** * 画布内边距 * * canvas padding * @remarks * 通常在自适应时,会根据内边距进行适配 * * Usually, it will be adapted according to the padding when auto-fitting */ padding?: Padding; /** * 旋转角度 * * rotation angle * @defaultValue 0 */ rotation?: number; /** * 缩放比例 * * zoom ratio * @defaultValue 1 */ zoom?: number; /** * 缩放范围 * * zoom range * @defaultValue [0.01, 10] */ zoomRange?: [number, number]; } /** * @internal */ export interface STDViewportOptions { autoFit?: | { type: 'view'; options?: FitViewOptions; animation?: ViewportAnimationEffectTiming } | { type: 'center'; animation?: ViewportAnimationEffectTiming }; padding?: STDPadding; zoom?: number; zoomRange?: [number, number]; } ================================================ FILE: packages/g6/src/themes/base.ts ================================================ import type { CategoricalPalette } from '../palettes/types'; import type { PaletteOptions } from '../spec/element/palette'; import type { Theme } from './types'; const BADGE_PALETTE: CategoricalPalette = ['#7E92B5', '#F4664A', '#FFBE3A']; const NODE_PALETTE_OPTIONS: PaletteOptions = { type: 'group', color: ['#1783FF', '#00C9C9', '#F08F56', '#D580FF', '#7863FF', '#DB9D0D', '#60C42D', '#FF80CA', '#2491B3', '#17C76F'], }; const EDGE_PALETTE_OPTIONS: PaletteOptions = { type: 'group', color: [ '#99ADD1', '#1783FF', '#00C9C9', '#F08F56', '#D580FF', '#7863FF', '#DB9D0D', '#60C42D', '#FF80CA', '#2491B3', '#17C76F', ], }; type ThemeTokens = { bgColor: string; textColor: string; nodeColor: string; nodeColorDisabled: string; nodeStroke: string; nodeBadgePalette?: string[]; nodePaletteOptions?: PaletteOptions; nodeHaloStrokeOpacityActive?: number; nodeHaloStrokeOpacitySelected?: number; nodeOpacityDisabled?: number; nodeOpacityInactive?: number; nodeIconOpacityInactive?: number; donutPaletteOptions?: PaletteOptions; edgeColor: string; edgeColorDisabled: string; edgeColorInactive: string; edgePaletteOptions?: PaletteOptions; comboColor: string; comboColorDisabled: string; comboStroke: string; comboStrokeDisabled: string; }; /** * 创建主题 * * Create a theme based on the given tokens * @param tokens - 主题配置项 Theme tokens * @returns 主题 Theme */ export function create(tokens: ThemeTokens): Theme { const { bgColor, textColor, nodeColor, nodeColorDisabled, nodeStroke, nodeHaloStrokeOpacityActive = 0.15, nodeHaloStrokeOpacitySelected = 0.25, nodeOpacityDisabled = 0.06, nodeIconOpacityInactive = 0.85, nodeOpacityInactive = 0.25, nodeBadgePalette = BADGE_PALETTE, nodePaletteOptions = NODE_PALETTE_OPTIONS, edgeColor, edgeColorDisabled, edgePaletteOptions = EDGE_PALETTE_OPTIONS, comboColor, comboColorDisabled, comboStroke, comboStrokeDisabled, edgeColorInactive, } = tokens; return { background: bgColor, node: { palette: nodePaletteOptions, style: { donutOpacity: 1, badgeBackgroundOpacity: 1, badgeFill: '#fff', badgeFontSize: 8, badgePadding: [0, 4], badgePalette: nodeBadgePalette, fill: nodeColor, fillOpacity: 1, halo: false, iconFill: '#fff', iconOpacity: 1, labelBackground: false, labelBackgroundFill: bgColor, labelBackgroundLineWidth: 0, labelBackgroundOpacity: 0.75, labelFill: textColor, labelFillOpacity: 0.85, labelLineHeight: 16, labelPadding: [0, 2], labelFontSize: 12, labelFontWeight: 400, labelOpacity: 1, labelOffsetY: 2, lineWidth: 0, portFill: nodeColor, portLineWidth: 1, portStroke: nodeStroke, portStrokeOpacity: 0.65, size: 32, stroke: nodeStroke, strokeOpacity: 1, zIndex: 2, }, state: { selected: { halo: true, haloLineWidth: 24, haloStrokeOpacity: nodeHaloStrokeOpacitySelected, labelFontSize: 12, labelFontWeight: 'bold', lineWidth: 4, stroke: nodeStroke, }, active: { halo: true, haloLineWidth: 12, haloStrokeOpacity: nodeHaloStrokeOpacityActive, }, highlight: { labelFontWeight: 'bold', lineWidth: 4, stroke: nodeStroke, strokeOpacity: 0.85, }, inactive: { badgeBackgroundOpacity: nodeOpacityInactive, donutOpacity: nodeOpacityInactive, fillOpacity: nodeOpacityInactive, iconOpacity: nodeIconOpacityInactive, labelFill: textColor, labelFillOpacity: nodeOpacityInactive, strokeOpacity: nodeOpacityInactive, }, disabled: { badgeBackgroundOpacity: 0.25, donutOpacity: nodeOpacityDisabled, fill: nodeColorDisabled, fillOpacity: nodeOpacityDisabled, iconFill: nodeColorDisabled, iconOpacity: 0.25, labelFill: textColor, labelFillOpacity: 0.25, strokeOpacity: nodeOpacityDisabled, }, }, animation: { enter: 'fade', exit: 'fade', show: 'fade', hide: 'fade', expand: 'node-expand', collapse: 'node-collapse', update: [{ fields: ['x', 'y', 'fill', 'stroke'] }], translate: [{ fields: ['x', 'y'] }], }, }, edge: { palette: edgePaletteOptions, style: { badgeBackgroundFill: edgeColor, badgeFill: '#fff', badgeFontSize: 8, badgeOffsetX: 10, badgeBackgroundOpacity: 1, fillOpacity: 1, halo: false, haloLineWidth: 12, haloStrokeOpacity: 1, increasedLineWidthForHitTesting: 2, labelBackground: false, labelBackgroundFill: bgColor, labelBackgroundLineWidth: 0, labelBackgroundOpacity: 0.75, labelBackgroundPadding: [4, 4, 4, 4], labelFill: textColor, labelFontSize: 12, labelFontWeight: 400, labelOpacity: 1, labelPlacement: 'center', labelTextBaseline: 'middle', lineWidth: 1, stroke: edgeColor, strokeOpacity: 1, zIndex: 1, }, state: { selected: { halo: true, haloStrokeOpacity: 0.25, labelFontSize: 14, labelFontWeight: 'bold', lineWidth: 3, }, active: { halo: true, haloStrokeOpacity: 0.15, }, highlight: { labelFontWeight: 'bold', lineWidth: 3, }, inactive: { stroke: edgeColorInactive, fillOpacity: 0.08, labelOpacity: 0.25, strokeOpacity: 0.08, badgeBackgroundOpacity: 0.25, }, disabled: { stroke: edgeColorDisabled, fillOpacity: 0.45, strokeOpacity: 0.45, labelOpacity: 0.25, badgeBackgroundOpacity: 0.45, }, }, animation: { enter: 'fade', exit: 'fade', expand: 'path-in', collapse: 'path-out', show: 'fade', hide: 'fade', update: [{ fields: ['sourceNode', 'targetNode'] }, { fields: ['stroke'], shape: 'key' }], translate: [{ fields: ['sourceNode', 'targetNode'] }], }, }, combo: { style: { collapsedMarkerFill: bgColor, collapsedMarkerFontSize: 12, collapsedMarkerFillOpacity: 1, collapsedSize: 32, collapsedFillOpacity: 1, fill: comboColor, halo: false, haloLineWidth: 12, haloStroke: comboStroke, haloStrokeOpacity: 0.25, labelBackground: false, labelBackgroundFill: bgColor, labelBackgroundLineWidth: 0, labelBackgroundOpacity: 0.75, labelBackgroundPadding: [2, 4, 2, 4], labelFill: textColor, labelFontSize: 12, labelFontWeight: 400, labelOpacity: 1, lineDash: 0, lineWidth: 1, fillOpacity: 0.04, strokeOpacity: 1, padding: 10, stroke: comboStroke, }, state: { selected: { halo: true, labelFontSize: 14, labelFontWeight: 700, lineWidth: 4, }, active: { halo: true, }, highlight: { labelFontWeight: 700, lineWidth: 4, }, inactive: { fillOpacity: 0.65, labelOpacity: 0.25, strokeOpacity: 0.65, }, disabled: { fill: comboColorDisabled, fillOpacity: 0.25, labelOpacity: 0.25, stroke: comboStrokeDisabled, strokeOpacity: 0.25, }, }, animation: { enter: 'fade', exit: 'fade', show: 'fade', hide: 'fade', expand: 'combo-expand', collapse: 'combo-collapse', update: [{ fields: ['x', 'y'] }, { fields: ['fill', 'stroke', 'lineWidth'], shape: 'key' }], translate: [{ fields: ['x', 'y'] }], }, }, }; } ================================================ FILE: packages/g6/src/themes/dark.ts ================================================ import type { PaletteOptions } from '../spec/element/palette'; import { create } from './base'; import type { Theme } from './types'; const EDGE_PALETTE_OPTIONS: PaletteOptions = { type: 'group', color: [ '#637088', '#0F55A6', '#008383', '#9C5D38', '#8B53A6', '#4E40A6', '#8F6608', '#3E801D', '#A65383', '#175E75', '#0F8248', ], }; const tokens = { bgColor: '#000000', comboColor: '#fdfdfd', comboColorDisabled: '#d0e4ff', comboStroke: '#99add1', comboStrokeDisabled: '#969696', edgeColor: '#637088', edgeColorDisabled: '#637088', edgeColorInactive: '#D0E4FF', edgePaletteOptions: EDGE_PALETTE_OPTIONS, nodeColor: '#1783ff', nodeColorDisabled: '#D0E4FF', nodeHaloStrokeOpacityActive: 0.25, nodeHaloStrokeOpacitySelected: 0.45, nodeIconOpacityInactive: 0.45, nodeOpacityDisabled: 0.25, nodeOpacityInactive: 0.45, nodeStroke: '#d0e4ff', textColor: '#ffffff', }; export const dark: Theme = create(tokens); ================================================ FILE: packages/g6/src/themes/index.ts ================================================ export { dark } from './dark'; export { light } from './light'; ================================================ FILE: packages/g6/src/themes/light.ts ================================================ import { create } from './base'; import type { Theme } from './types'; const tokens = { bgColor: '#ffffff', comboColor: '#99ADD1', comboColorDisabled: '#f0f0f0', comboStroke: '#99add1', comboStrokeDisabled: '#d9d9d9', edgeColor: '#99add1', edgeColorDisabled: '#d9d9d9', edgeColorInactive: '#1B324F', nodeColor: '#1783ff', nodeColorDisabled: '#1B324F', nodeHaloStrokeOpacityActive: 0.15, nodeHaloStrokeOpacitySelected: 0.25, nodeIconOpacityInactive: 0.85, nodeOpacityDisabled: 0.06, nodeOpacityInactive: 0.25, nodeStroke: '#000000', textColor: '#000000', }; export const light: Theme = create(tokens); ================================================ FILE: packages/g6/src/themes/types.ts ================================================ import type { StaticComboOptions } from '../spec/element/combo'; import type { StaticEdgeOptions } from '../spec/element/edge'; import type { StaticNodeOptions } from '../spec/element/node'; export type Theme = { node?: StaticNodeOptions; edge?: StaticEdgeOptions; combo?: StaticComboOptions; background?: string; }; ================================================ FILE: packages/g6/src/transforms/arrange-draw-order.ts ================================================ import type { ComboData } from '../spec'; import type { ID } from '../types'; import { idOf } from '../utils/id'; import { BaseTransform } from './base-transform'; import type { DrawData } from './types'; /** * 调整元素绘制顺序 * * Adjust the drawing order of elements */ export class ArrangeDrawOrder extends BaseTransform { public beforeDraw(input: DrawData): DrawData { const { model } = this.context; const combosToAdd = input.add.combos; const arrangeCombo = (combos: Map): Map => { // id, data, zIndex const order: [ID, ComboData, number][] = []; combos.forEach((combo, id) => { const ancestors = model.getAncestorsData(id, 'combo'); const path = ancestors.map((ancestor) => idOf(ancestor)).reverse(); // combo 的 zIndex 为距离根 combo 的深度 // The zIndex of the combo is the depth from the root combo order.push([id, combo, path.length]); }); return new Map( order // 基于 zIndex 降序排序,优先绘制子 combo / Sort based on zIndex in descending order, draw child combo first .sort(([, , zIndex1], [, , zIndex2]) => zIndex2 - zIndex1) .map(([id, datum]) => [id, datum]), ); }; input.add.combos = arrangeCombo(combosToAdd); input.update.combos = arrangeCombo(input.update.combos); return input; } } ================================================ FILE: packages/g6/src/transforms/base-transform.ts ================================================ import { BaseExtension } from '../registry/extension'; import type { DrawContext } from '../runtime/element'; import type { CustomBehaviorOption } from '../spec/behavior'; import type { DrawData } from './types'; export type BaseTransformOptions = CustomBehaviorOption; /** * 数据转换的基类 * * Base class for data transforms */ export abstract class BaseTransform extends BaseExtension { public beforeDraw(data: DrawData, context: DrawContext): DrawData { return data; } public afterLayout(type: 'pre', data: DrawData): void; public afterLayout(type: 'post', data?: undefined): void; public afterLayout(type: 'pre' | 'post', data?: DrawData) {} } ================================================ FILE: packages/g6/src/transforms/collapse-expand-combo.ts ================================================ import { COMBO_KEY } from '../constants'; import type { DrawContext } from '../runtime/element'; import type { ComboData } from '../spec'; import { isCollapsed } from '../utils/collapsibility'; import { getSubgraphRelatedEdges } from '../utils/edge'; import { idOf } from '../utils/id'; import { BaseTransform } from './base-transform'; import type { DrawData } from './types'; import { reassignTo } from './utils'; /** * 处理组合的收起和展开 * * Process the collapse and expand of combos */ export class CollapseExpandCombo extends BaseTransform { public beforeDraw(input: DrawData, context: DrawContext): DrawData { if (context.stage === 'visibility') return input; if (!this.context.model.model.hasTreeStructure(COMBO_KEY)) return input; const { model } = this.context; const { add, update } = input; // combo 添加和更新的顺序为先子后父,因此采用倒序遍历 // The order of adding and updating combos is first child and then parent, so reverse traversal is used const combos = [...input.update.combos.entries(), ...input.add.combos.entries()]; while (combos.length) { const [id, combo] = combos.pop()!; if (isCollapsed(combo)) { const descendants = model.getDescendantsData(id); const descendantIds = descendants.map(idOf); const { internal, external } = getSubgraphRelatedEdges(descendantIds, (id) => model.getRelatedEdgesData(id)); // 移除所有后代元素 / Remove all descendant elements descendants.forEach((descendant) => { const descendantId = idOf(descendant); // 不再处理当前 combo 的后代 combo // No longer process the descendant combo of the current combo const comboIndex = combos.findIndex(([id]) => id === descendantId); if (comboIndex !== -1) combos.splice(comboIndex, 1); const elementType = model.getElementType(descendantId); reassignTo(input, 'remove', elementType, descendant); }); // 如果是内部边/节点 销毁 // If it is an internal edge/node, destroy it internal.forEach((edge) => reassignTo(input, 'remove', 'edge', edge)); // 如果是外部边,连接到收起对象上 // If it is an external edge, connect to the collapsed object external.forEach((edge) => { const id = idOf(edge); const edgeElement = this.context.element?.getElement(id); if (edgeElement) update.edges.set(id, edge); else add.edges.set(id, edge); }); } else { const children = model.getChildrenData(id); const childrenIds = children.map(idOf); const { edges } = getSubgraphRelatedEdges(childrenIds, (id) => model.getRelatedEdgesData(id)); [...children, ...edges].forEach((descendant) => { const id = idOf(descendant); const elementType = model.getElementType(id); const element = this.context.element?.getElement(id); // 如果节点不存在,则添加到新增列表,如果存在,添加到更新列表 // If the node does not exist, add it to the new list, if it exists, add it to the update list if (element) reassignTo(input, 'update', elementType, descendant); else reassignTo(input, 'add', elementType, descendant); // 继续展开子节点 / Continue to expand child nodes if (elementType === 'combo') combos.push([id, descendant as ComboData]); }); } } return input; } } ================================================ FILE: packages/g6/src/transforms/collapse-expand-node.ts ================================================ import { TREE_KEY } from '../constants'; import type { NodeData } from '../spec'; import type { ElementDatum, ElementType, ID } from '../types'; import { isCollapsed } from '../utils/collapsibility'; import { idOf } from '../utils/id'; import { BaseTransform } from './base-transform'; import type { DrawData, ProcedureData } from './types'; import { reassignTo } from './utils'; // 如果在任务列表中不存在,则添加到任务列表 // If it does not exist in the task list, add it to the task list const weakAssignTo = (input: DrawData, type: 'add' | 'update', elementType: ElementType, datum: ElementDatum) => { const typeName = `${elementType}s` as keyof ProcedureData; const id = idOf(datum); if (!input.add[typeName].has(id) && !input.update[typeName].has(id)) { input[type][typeName].set(idOf(datum), datum as any); } }; /** * 处理(树图)节点的收起和展开 * * Process the collapse and expand of (tree)nodes */ export class CollapseExpandNode extends BaseTransform { private getElement(id: ID) { return this.context.element!.getElement(id); } private handleExpand(node: NodeData, input: DrawData) { weakAssignTo(input, 'add', 'node', node); if (isCollapsed(node)) return; const id = idOf(node); weakAssignTo(input, 'add', 'node', node); const relatedEdges = this.context.model.getRelatedEdgesData(id); relatedEdges.forEach((edge) => { reassignTo(input, 'add', 'edge', edge); }); const children = this.context.model.getChildrenData(id); children.forEach((child) => { this.handleExpand(child, input); }); } public beforeDraw(input: DrawData): DrawData { const { graph, model } = this.context; if (!model.model.hasTreeStructure(TREE_KEY)) return input; const { add: { nodes: nodesToAdd, edges: edgesToAdd }, update: { nodes: nodesToUpdate }, } = input; const nodesToCollapse = new Map(); const nodesToExpand = new Map(); nodesToAdd.forEach((node, id) => { if (isCollapsed(node)) nodesToCollapse.set(id, node); }); // 如果创建了一条连接到收起的节点的边,则将其添加到待展开列表 // If an edge is created that connects to a collapsed node, add it to the list to be expanded edgesToAdd.forEach((edge) => { if (graph.getElementType(edge.source) !== 'node') return; const source = graph.getNodeData(edge.source); if (isCollapsed(source)) nodesToCollapse.set(edge.source, source); }); nodesToUpdate.forEach((node, id) => { const nodeElement = this.getElement(id); if (!nodeElement) return; const isCurrentCollapsed = nodeElement.attributes.collapsed; if (isCollapsed(node)) { if (!isCurrentCollapsed) nodesToCollapse.set(id, node); } else { if (isCurrentCollapsed) nodesToExpand.set(id, node); } }); const handledNodes = new Set(); nodesToCollapse.forEach((node, id) => { // 将子节点添加到待删除列表,并删除关联的边 // Add child nodes to the list to be deleted,and delete the associated edges const descendants = model.getDescendantsData(id); descendants.forEach((descendant) => { const id = idOf(descendant); if (handledNodes.has(id)) return; reassignTo(input, 'remove', 'node', descendant); const relatedEdges = model.getRelatedEdgesData(id); relatedEdges.forEach((edge) => { reassignTo(input, 'remove', 'edge', edge); }); handledNodes.add(id); }); }); nodesToExpand.forEach((node, id) => { const ancestors = model.getAncestorsData(id, TREE_KEY); // 如果祖先节点是收起的,添加到移除列表 // If the ancestor node is collapsed, add it to the removal list if (ancestors.some(isCollapsed)) { reassignTo(input, 'remove', 'node', node); return; } this.handleExpand(node, input); }); return input; } } ================================================ FILE: packages/g6/src/transforms/get-edge-actual-ends.ts ================================================ import { COMBO_KEY } from '../constants'; import { idOf } from '../exports'; import { DataController } from '../runtime/data'; import type { EdgeData } from '../spec'; import type { NodeLikeData } from '../types'; import { findActualConnectNodeData } from '../utils/edge'; import { BaseTransform } from './base-transform'; import type { DrawData } from './types'; /** * 获取边的实际端点 * * Get the actual endpoints of the edge */ export class GetEdgeActualEnds extends BaseTransform { public beforeDraw(input: DrawData): DrawData { const { add, update } = input; const { model } = this.context; [...add.edges.entries(), ...update.edges.entries()].forEach(([, edge]) => { getEdgeEndsContext(model, edge); }); return input; } } export const getEdgeEndsContext = (model: DataController, edge: EdgeData) => { const { source, target } = edge; const sourceNodeData = model.getElementDataById(source) as NodeLikeData; const targetNodeData = model.getElementDataById(target) as NodeLikeData; const actualSourceNode = findActualConnectNodeData(sourceNodeData, (id) => model.getParentData(id, COMBO_KEY)); const actualTargetNode = findActualConnectNodeData(targetNodeData, (id) => model.getParentData(id, COMBO_KEY)); const sourceNode = idOf(actualSourceNode); const targetNode = idOf(actualTargetNode); const ends = { sourceNode, targetNode }; if (edge.style) { Object.assign(edge.style, ends); } else edge.style = ends; return edge; }; ================================================ FILE: packages/g6/src/transforms/index.ts ================================================ export { ArrangeDrawOrder } from './arrange-draw-order'; export { BaseTransform } from './base-transform'; export { CollapseExpandCombo } from './collapse-expand-combo'; export { CollapseExpandNode } from './collapse-expand-node'; export { GetEdgeActualEnds } from './get-edge-actual-ends'; export { MapNodeSize } from './map-node-size'; export { PlaceRadialLabels } from './place-radial-labels'; export { ProcessParallelEdges } from './process-parallel-edges'; export { UpdateRelatedEdge } from './update-related-edge'; export type { BaseTransformOptions } from './base-transform'; export type { MapNodeSizeOptions } from './map-node-size'; export type { PlaceRadialLabelsOptions } from './place-radial-labels'; export type { ProcessParallelEdgesOptions } from './process-parallel-edges'; ================================================ FILE: packages/g6/src/transforms/map-node-size.ts ================================================ import { deepMix, pick } from '@antv/util'; import type { RuntimeContext } from '../runtime/types'; import type { GraphData, NodeData } from '../spec'; import type { NodeStyle } from '../spec/element/node'; import type { ID, Node, NodeCentralityOptions, Size, STDSize } from '../types'; import type { CentralityResult } from '../utils/centrality'; import { getNodeCentralities } from '../utils/centrality'; import { idOf } from '../utils/id'; import { getVerticalPadding } from '../utils/padding'; import { linear, log, pow, sqrt } from '../utils/scale'; import { parseSize } from '../utils/size'; import type { BaseTransformOptions } from './base-transform'; import { BaseTransform } from './base-transform'; import type { DrawData } from './types'; import { isStyleEqual, reassignTo } from './utils'; export interface MapNodeSizeOptions extends BaseTransformOptions { /** * 节点中心性的度量方法 * - `'degree'`:度中心性,通过节点的度数(连接的边的数量)来衡量其重要性。度中心性高的节点通常具有较多的直接连接,在网络中可能扮演着重要的角色 * - `'betweenness'`:介数中心性,通过节点在所有最短路径中出现的次数来衡量其重要性。介数中心性高的节点通常在网络中起到桥梁作用,控制着信息的流动 * - `'closeness'`:接近中心性,通过节点到其他所有节点的最短路径长度总和的倒数来衡量其重要性。接近中心性高的节点通常能够更快地到达网络中的其他节点 * - `'eigenvector'`:特征向量中心性,通过节点与其他中心节点的连接程度来衡量其重要性。特征向量中心性高的节点通常连接着其他重要节点 * - `'pagerank'`:PageRank 中心性,通过节点被其他节点引用的次数来衡量其重要性,常用于有向图。PageRank 中心性高的节点通常在网络中具有较高的影响力,类似于网页排名算法 * - 自定义中心性计算方法:`(graphData: GraphData) => Map`,其中 `graphData` 为图数据,`Map` 为节点 ID 到中心性值的映射 * * The method of measuring the node centrality * - `'degree'`: Degree centrality, measures centrality by the degree (number of connected edges) of a node. Nodes with high degree centrality usually have more direct connections and may play important roles in the network * - `'betweenness'`: Betweenness centrality, measures centrality by the number of times a node appears in all shortest paths. Nodes with high betweenness centrality usually act as bridges in the network, controlling the flow of information * - `'closeness'`: Closeness centrality, measures centrality by the reciprocal of the average shortest path length from a node to all other nodes. Nodes with high closeness centrality usually can reach other nodes in the network more quickly * - `'eigenvector'`: Eigenvector centrality, measures centrality by the degree of connection between a node and other central nodes. Nodes with high eigenvector centrality usually connect to other important nodes * - `'pagerank'`: PageRank centrality, measures centrality by the number of times a node is referenced by other nodes, commonly used in directed graphs. Nodes with high PageRank centrality usually have high influence in the network, similar to the page ranking algorithm * - Custom centrality calculation method: `(graphData: GraphData) => Map`, where `graphData` is the graph data, and `Map` is the mapping from node ID to centrality value * @defaultValue { type: 'eigenvector' } */ centrality?: NodeCentralityOptions | ((graphData: GraphData) => Map); /** * 节点最大尺寸 * * The maximum size of the node * @defaultValue 80 */ maxSize?: Size; /** * 节点最小尺寸 * * The minimum size of the node * @defaultValue 20 */ minSize?: Size; /** * 插值函数,用于将节点中心性映射到节点大小 * - `'linear'`:线性插值函数,将一个值从一个范围线性映射到另一个范围,常用于处理中心性值的差异较小的情况 * - `'log'`:对数插值函数,将一个值从一个范围对数映射到另一个范围,常用于处理中心性值的差异较大的情况 * - `'pow'`:幂律插值函数,将一个值从一个范围幂律映射到另一个范围,常用于处理中心性值的差异较大的情况 * - `'sqrt'`:平方根插值函数,将一个值从一个范围平方根映射到另一个范围,常用于处理中心性值的差异较大的情况 * - 自定义插值函数:`(value: number, domain: [number, number], range: [number, number]) => number`,其中 `value` 为需要映射的值,`domain` 为输入值的范围,`range` 为输出值的范围 * * Scale type * - `'linear'`: Linear scale, maps a value from one range to another range linearly, commonly used for cases where the difference in centrality values is small * - `'log'`: Logarithmic scale, maps a value from one range to another range logarithmically, commonly used for cases where the difference in centrality values is large * - `'pow'`: Power-law scale, maps a value from one range to another range using power law, commonly used for cases where the difference in centrality values is large * - `'sqrt'`: Square root scale, maps a value from one range to another range using square root, commonly used for cases where the difference in centrality values is large * - Custom scale: `(value: number, domain: [number, number], range: [number, number]) => number`,where `value` is the value to be mapped, `domain` is the input range, and `range` is the output range * @defaultValue 'log' */ scale?: | 'linear' | 'log' | 'pow' | 'sqrt' | ((value: number, domain: [number, number], range: [number, number]) => number); /** * 是否同步调整标签大小 * * Whether to map label size synchronously * @defaultValue false */ mapLabelSize?: boolean | [number, number]; } /** * 根据节点重要性调整节点的大小 * * Map node size based on node importance * @remarks * 在图可视化中,节点的大小通常用于传达节点的重要性或影响力。通过根据节点中心性调整节点的大小,我们可以更直观地展示网络中各个节点的重要性,从而帮助用户更好地理解和分析复杂的网络结构。 * * In graph visualization, the size of a node is usually used to convey the importance or influence of the node. By adjusting the size of the node based on the centrality of the node, we can more intuitively show the importance of each node in the network, helping users better understand and analyze complex network structures. */ export class MapNodeSize extends BaseTransform { static defaultOptions: Partial = { centrality: { type: 'degree' }, maxSize: 80, minSize: 20, scale: 'linear', mapLabelSize: false, }; constructor(context: RuntimeContext, options: MapNodeSizeOptions) { super(context, deepMix({}, MapNodeSize.defaultOptions, options)); } public beforeDraw(input: DrawData): DrawData { const { model } = this.context; const nodes = model.getNodeData(); const maxSize = parseSize(this.options.maxSize); const minSize = parseSize(this.options.minSize); const centralities = this.getCentralities(this.options.centrality); const maxCentrality = centralities.size > 0 ? Math.max(...centralities.values()) : 0; const minCentrality = centralities.size > 0 ? Math.min(...centralities.values()) : 0; nodes.forEach((datum) => { const size = this.assignSizeByCentrality( centralities.get(idOf(datum)) || 0, minCentrality, maxCentrality, minSize, maxSize, this.options.scale, ); const element = this.context.element?.getElement(idOf(datum)); const style: NodeStyle = { size }; this.assignLabelStyle(style, size, datum, element); if (!element || !isStyleEqual(style, element.attributes)) { reassignTo(input, element ? 'update' : 'add', 'node', deepMix(datum, { style }), true); } }); return input; } private assignLabelStyle(style: NodeStyle, size: STDSize, datum: NodeData, element?: Node) { const configStyle = element ? element.config.style : this.context.element?.getElementComputedStyle('node', datum); Object.assign(style, pick(configStyle, ['labelFontSize', 'labelLineHeight'])); if (this.options.mapLabelSize) { const fontSize = this.getLabelSizeByNodeSize(size, Infinity, Number(style.labelFontSize)); Object.assign(style, { labelFontSize: fontSize, labelLineHeight: fontSize + getVerticalPadding(style.labelPadding), }); } return style; } private getLabelSizeByNodeSize(size: STDSize, defaultMaxFontSize: number, defaultMinFontSize: number): number { const fontSize = Math.min(...size) / 2; const [minFontSize, maxFontSize] = !Array.isArray(this.options.mapLabelSize) ? [defaultMinFontSize, defaultMaxFontSize] : this.options.mapLabelSize; return Math.min(maxFontSize, Math.max(fontSize, minFontSize)); } private getCentralities(centrality: Required['centrality']): CentralityResult { const { model } = this.context; const graphData = model.getData(); if (typeof centrality === 'function') return centrality(graphData); const getRelatedEdgesData = model.getRelatedEdgesData.bind(model); return getNodeCentralities(graphData, getRelatedEdgesData, centrality); } private assignSizeByCentrality = ( centrality: number, minCentrality: number, maxCentrality: number, minSize: STDSize, maxSize: STDSize, scale: MapNodeSizeOptions['scale'], ): STDSize => { const domain: [number, number] = [minCentrality, maxCentrality]; const rangeX: [number, number] = [minSize[0], maxSize[0]]; const rangeY: [number, number] = [minSize[1], maxSize[1]]; const rangeZ: [number, number] = [minSize[2], maxSize[2]]; const interpolate = (centrality: number, range: [number, number]): number => { if (typeof scale === 'function') { return scale(centrality, domain, range); } switch (scale) { case 'linear': return linear(centrality, domain, range); case 'log': return log(centrality, domain, range); case 'pow': return pow(centrality, domain, range, 2); case 'sqrt': return sqrt(centrality, domain, range); default: return range[0]; } }; return [interpolate(centrality, rangeX), interpolate(centrality, rangeY), interpolate(centrality, rangeZ)]; }; } ================================================ FILE: packages/g6/src/transforms/place-radial-labels.ts ================================================ import type { TransformArray } from '@antv/g'; import { rad2deg } from '@antv/g'; import type { RuntimeContext } from '../runtime/types'; import type { NodeData } from '../spec'; import { idOf } from '../utils/id'; import { positionOf } from '../utils/position'; import { parseSize } from '../utils/size'; import { rad, subtract } from '../utils/vector'; import type { BaseTransformOptions } from './base-transform'; import { BaseTransform } from './base-transform'; /** * 根据径向布局自动调整节点标签样式的配置项 * * Options for automatically adjusting the style of node labels according to the radial layout */ export interface PlaceRadialLabelsOptions extends BaseTransformOptions { /** * 偏移量 * * Offset */ offset?: number; } /** * 根据径向布局自动调整节点标签样式,包括位置和旋转角度 * * Automatically adjust the style of node labels according to the radial layout, including position and rotation angle */ export class PlaceRadialLabels extends BaseTransform { static defaultOptions: Partial = { offset: 5, }; constructor(context: RuntimeContext, options: PlaceRadialLabelsOptions) { super(context, Object.assign({}, PlaceRadialLabels.defaultOptions, options)); } private get ref(): NodeData { return this.context.model.getRootsData()[0]; } public afterLayout() { const refPoint = positionOf(this.ref); const { graph, model } = this.context; const data = model.getData(); data.nodes?.forEach((datum) => { if (idOf(datum) === idOf(this.ref)) return; const radian = rad(subtract(positionOf(datum), refPoint)); const isLeft = Math.abs(radian) > Math.PI / 2; const isLeaf = !datum.children || datum.children.length === 0; const nodeId = idOf(datum); const node = this.context.element?.getElement(nodeId); if (!node || !node.isVisible()) return; const nodeHalfWidth = parseSize(graph.getElementRenderStyle(nodeId).size)[0] / 2; const offset = (isLeaf ? 1 : -1) * (nodeHalfWidth + this.options.offset); const labelTransform: TransformArray = [ ['translate', offset * Math.cos(radian), offset * Math.sin(radian)], ['rotate', isLeft ? rad2deg(radian) + 180 : rad2deg(radian)], ]; model.updateNodeData([ { id: idOf(datum), style: { labelTextAlign: isLeft === isLeaf ? 'right' : 'left', labelTextBaseline: 'middle', labelTransform, }, }, ]); }); graph.draw(); } } ================================================ FILE: packages/g6/src/transforms/process-parallel-edges.ts ================================================ import type { PathStyleProps } from '@antv/g'; import { isBoolean, isEmpty, isEqual, isFunction } from '@antv/util'; import type { RuntimeContext } from '../runtime/types'; import type { EdgeData } from '../spec'; import type { EdgeStyle } from '../spec/element/edge'; import type { ID, LoopPlacement, NodeLikeData } from '../types'; import { groupByChangeType, reduceDataChanges } from '../utils/change'; import { idOf } from '../utils/id'; import type { BaseTransformOptions } from './base-transform'; import { BaseTransform } from './base-transform'; import { getEdgeEndsContext } from './get-edge-actual-ends'; import type { DrawData } from './types'; import { isStyleEqual, reassignTo } from './utils'; const CUBIC_EDGE_TYPE = 'quadratic'; const CUBIC_LOOP_PLACEMENTS: LoopPlacement[] = [ 'top', 'top-right', 'right', 'right-bottom', 'bottom', 'bottom-left', 'left', 'left-top', ]; export interface ProcessParallelEdgesOptions extends BaseTransformOptions { /** * 处理模式 * - `'merge'`: 将平行边合并为一条边,适用于不需要区分平行边的情况 * - '`bundle`': 每条边都会与其他所有平行边捆绑在一起,并通过改变曲率与其他边分开。如果一组平行边的数量是奇数,那么中心的边将被绘制为直线,其他的边将被绘制为曲线 * * Processing mode * - '`merge`': Merge parallel edges into one edge which is suitable for cases where parallel edges do not need to be distinguished * - '`bundle`': Each edge will be bundled with all other parallel edges and separated from them by varying the curvature. If the number of parallel edges in a group is odd, the central edge will be drawn as a straight line, and the others will be drawn as curves * @defaultValue 'bundle' */ mode: 'bundle' | 'merge'; /** * 考虑要处理的边,默认为全部边 * * The edges to be handled, all edges by default */ edges?: ID[]; /** * 边之间的距离,仅在捆绑模式下有效 * * The distance between edges, only valid for bundling mode */ distance?: number; /** * 合并边的样式,仅在合并模式下有效 * * The style of the merged edge, only valid for merging mode */ style?: PathStyleProps | ((prev: EdgeData[]) => PathStyleProps); } /** * 处理平行边,即多条边共享同一源节点和目标节点 * * Process parallel edges which share the same source and target nodes * @remarks * 平行边(Parallel Edges)是指在图结构中,两个节点之间存在多条边。这些边共享相同的源节点和目标节点,但可能代表不同的关系或属性。为了避免边的重叠和混淆,提供了两种处理平行边的方式:(1) 捆绑模式(bundle):将平行边捆绑在一起,通过改变曲率与其他边分开;(2) 合并模式(merge):将平行边合并为一条聚合。 * * Parallel Edges refer to multiple edges existing between two nodes in a graph structure. These edges share the same source and target nodes but may represent different relationships or attributes. To avoid edge overlap and confusion, two methods are provided for handling parallel edges: (1) Bundle Mode: Bundles parallel edges together and separates them from other edges by altering their curvature; (2) Merge Mode: Merges parallel edges into a single aggregated edge. */ export class ProcessParallelEdges extends BaseTransform { static defaultOptions: Partial = { mode: 'bundle', distance: 15, // only valid for bundling mode }; private cacheMergeStyle: Map = new Map(); constructor(context: RuntimeContext, options: ProcessParallelEdgesOptions) { super(context, Object.assign({}, ProcessParallelEdges.defaultOptions, options)); } /** * 在每次绘制前处理平行边 * * Process parallel edges before each drawing * @param input */ public beforeDraw(input: DrawData): DrawData { const edges = this.getAffectedParallelEdges(input); if (edges.size === 0) return input; this.options.mode === 'bundle' ? this.applyBundlingStyle(input, edges, this.options.distance) : this.applyMergingStyle(input, edges); return input; } /** * 获取受影响的平行边 * * Get affected parallel edges * @param input */ private getAffectedParallelEdges = (input: DrawData): Map => { const { add: { edges: edgesToAdd }, update: { nodes: nodesToUpdate, edges: edgesToUpdate, combos: combosToUpdate }, remove: { edges: edgesToRemove }, } = input; const { model } = this.context; const edges: Map = new Map(); const addRelatedEdges = (_: NodeLikeData, id: ID) => { const relatedEdgesData = model.getRelatedEdgesData(id); relatedEdgesData.forEach((edge) => !edges.has(idOf(edge)) && edges.set(idOf(edge), edge)); }; nodesToUpdate.forEach(addRelatedEdges); combosToUpdate.forEach(addRelatedEdges); const pushParallelEdges = (edge: EdgeData) => { // 获取已被标记删除的边ID集合 // Get the set of edge IDs that have been marked for removal const removedEdgeIds = new Set(input.remove.edges.keys()); // 过滤掉已删除的边,避免重定向后重新添加(修复combo收起时内部边变成loop边的问题) // Filter out removed edges to prevent them from being re-added after redirection (fixes the issue where internal edges become loop edges when combo collapses) const validEdgeData = model .getEdgeData() .filter((edge) => !removedEdgeIds.has(idOf(edge))) .map((edge) => getEdgeEndsContext(model, edge)); // 查找平行边并添加到处理列表,确保只处理有效的边 // Find parallel edges and add them to the processing list, ensuring only valid edges are processed getParallelEdges(edge, validEdgeData, true).forEach((e) => { const id = idOf(e); if (!edges.has(id)) edges.set(id, e); }); }; if (edgesToRemove.size) edgesToRemove.forEach(pushParallelEdges); if (edgesToAdd.size) edgesToAdd.forEach(pushParallelEdges); if (edgesToUpdate.size) { const changes = groupByChangeType(reduceDataChanges(model.getChanges())).update.edges; edgesToUpdate.forEach((edge) => { pushParallelEdges(edge); // 当边的端点发生变化时,将原始边及其平行边一并添加到更新列表 | Add the original edge and its parallel edges to the update list when the endpoints of the edge change const originalEdge = changes.find((e) => idOf(e.value) === idOf(edge))?.original; if (originalEdge && !isParallelEdges(edge, originalEdge)) { pushParallelEdges(originalEdge); } }); } if (!isEmpty(this.options.edges)) { edges.forEach((_: EdgeData, id: ID) => !this.options.edges.includes(id) && edges.delete(id)); } // 按照用户指定的顺序排序,防止捆绑时的抖动 // Sort by user-set order to prevent jitter during bundling const edgeIds = model.getEdgeData().map(idOf); return new Map([...edges].sort((a, b) => edgeIds.indexOf(a[0]) - edgeIds.indexOf(b[0]))); }; protected applyBundlingStyle = (input: DrawData, edges: Map, distance: number) => { const { edgeMap, reverses } = groupByEndpoints(edges); edgeMap.forEach((arcEdges) => { arcEdges.forEach((edge, i, edgeArr) => { const length = edgeArr.length; const style: EdgeStyle = edge.style || {}; if (edge.source === edge.target) { const len = CUBIC_LOOP_PLACEMENTS.length; style.loopPlacement = CUBIC_LOOP_PLACEMENTS[i % len]; style.loopDist = Math.floor(i / len) * distance + 50; } else if (length === 1) { style.curveOffset = 0; } else { const sign = (i % 2 === 0 ? 1 : -1) * (reverses[`${edge.source}|${edge.target}|${i}`] ? -1 : 1); style.curveOffset = length % 2 === 1 ? sign * Math.ceil(i / 2) * distance * 2 : sign * (Math.floor(i / 2) * distance * 2 + distance); } const mergedEdgeData = Object.assign(edge, { type: CUBIC_EDGE_TYPE, style }); const element = this.context.element?.getElement(idOf(edge)); if (!element || !isStyleEqual(mergedEdgeData.style, element.attributes)) { reassignTo(input, element ? 'update' : 'add', 'edge', mergedEdgeData, true); } }); }); }; private resetEdgeStyle = (edge: EdgeData) => { const style = edge.style || {}; const cacheStyle = this.cacheMergeStyle.get(idOf(edge)) || {}; Object.keys(cacheStyle).forEach((key) => { if (isEqual(style[key], (cacheStyle as any)[key])) { if (edge[key]) { style[key] = edge[key]; } else { delete style[key]; } } }); return Object.assign(edge, { style }); }; protected applyMergingStyle = (input: DrawData, edges: Map) => { const { edgeMap, reverses } = groupByEndpoints(edges); edgeMap.forEach((edges) => { if (edges.length === 1) { const edge = edges[0]; const element = this.context.element?.getElement(idOf(edge)); const edgeStyle = this.resetEdgeStyle(edge); if (!element || !isStyleEqual(edgeStyle, element.attributes)) { reassignTo(input, element ? 'update' : 'add', 'edge', edgeStyle); } return; } const mergedStyle = edges .map(({ source, target, style = {} }, i) => { const { startArrow, endArrow } = style; const newStyle: EdgeData['style'] = {}; const [start, end] = reverses[`${source}|${target}|${i}`] ? ['endArrow', 'startArrow'] : ['startArrow', 'endArrow']; if (isBoolean(startArrow)) newStyle[start] = startArrow; if (isBoolean(endArrow)) newStyle[end] = endArrow; return newStyle; }) .reduce((acc, style) => ({ ...acc, ...style }), {}); edges.forEach((edge, i, edges) => { if (i !== 0) { reassignTo(input, 'remove', 'edge', edge); return; } const parsedStyle = Object.assign( {}, isFunction(this.options.style) ? this.options.style(edges) : this.options.style, { childrenData: edges }, ); this.cacheMergeStyle.set(idOf(edge), parsedStyle); const mergedEdgeData = { ...edge, type: 'line', style: { ...edge.style, ...mergedStyle, ...parsedStyle }, }; const element = this.context.element?.getElement(idOf(edge)); if (!element || !isStyleEqual(mergedEdgeData.style, element.attributes)) { reassignTo(input, element ? 'update' : 'add', 'edge', mergedEdgeData, true); } }); }); }; } /** * 优化的按照端点分组方法,时间复杂度O(n) * * Optimized method to group by endpoints, time complexity O(n) * @param edges - 边集合 | Edges * @returns 端点分组后的边集合 | Edges grouped by endpoints */ export const groupByEndpoints = (edges: Map) => { const edgeMap = new Map(); const processedEdgesSet = new Set(); const reverses: Record = {}; const includedEdgesInGroup = new Map>(); for (const [id, edge] of edges) { if (processedEdgesSet.has(id)) continue; const { source, target } = edge; const sourceTarget = `${source}-${target}`; if (!edgeMap.has(sourceTarget)) { edgeMap.set(sourceTarget, []); includedEdgesInGroup.set(sourceTarget, new Set()); } const sourceTargetEdges = edgeMap.get(sourceTarget); const includedEdges = includedEdgesInGroup.get(sourceTarget); if (sourceTargetEdges && includedEdges && !includedEdges.has(id)) { sourceTargetEdges.push(edge); includedEdges.add(id); processedEdgesSet.add(id); } for (const [otherId, sedge] of edges) { if (processedEdgesSet.has(otherId) || otherId === id) continue; if (isParallelEdges(edge, sedge)) { const groupEdges = edgeMap.get(sourceTarget); const includedGroupEdges = includedEdgesInGroup.get(sourceTarget); if (groupEdges && includedGroupEdges && !includedGroupEdges.has(otherId)) { groupEdges.push(sedge); includedGroupEdges.add(otherId); if (source === sedge.target && target === sedge.source) { reverses[`${sedge.source}|${sedge.target}|${groupEdges.length - 1}`] = true; } processedEdgesSet.add(otherId); } } } } return { edgeMap, reverses }; }; /** * 获取平行边 * * Get parallel edges * @param edge - 目标边 | Target edge * @param edges - 边集合 | Edges * @param containsSelf - 输出结果是否包含目标边 | Whether the output result contains the target edge * @returns 平行边集合 | Parallel edges */ export const getParallelEdges = (edge: EdgeData, edges: EdgeData[], containsSelf?: boolean): EdgeData[] => { return edges.filter((e) => (containsSelf || idOf(e) !== idOf(edge)) && isParallelEdges(e, edge)); }; /** * 判断两条边是否平行 * * Determine whether two edges are parallel * @param edge1 - 边1 | Edge 1 * @param edge2 - 边2 | Edge 2 * @returns 是否平行 | Whether is parallel */ export const isParallelEdges = (edge1: EdgeData, edge2: EdgeData) => { const { sourceNode: src1, targetNode: tgt1 } = edge1.style || {}; const { sourceNode: src2, targetNode: tgt2 } = edge2.style || {}; return (src1 === src2 && tgt1 === tgt2) || (src1 === tgt2 && tgt1 === src2); }; ================================================ FILE: packages/g6/src/transforms/types.ts ================================================ import type { ComboData, EdgeData, NodeData } from '../spec'; import type { ID } from '../types'; import type { BaseTransform } from './base-transform'; export type Transform = BaseTransform; /** * 在 Element Controller 中,为了提高查询性能,统一使用 Map 存储数据 * * In Element Controller, in order to improve query performance, use Map to store data uniformly */ export type ProcedureData = { nodes: Map; edges: Map; combos: Map; }; export type DrawData = { add: ProcedureData; update: ProcedureData; remove: ProcedureData; }; ================================================ FILE: packages/g6/src/transforms/update-related-edge.ts ================================================ import type { DrawContext } from '../runtime/element'; import type { ID, NodeLikeData } from '../types'; import { idOf } from '../utils/id'; import { BaseTransform } from './base-transform'; import type { DrawData } from './types'; /** * 如果更新了节点 / combo,需要更新连接的边 * If the node / combo is updated, the connected edge and the combo it is in need to be updated */ export class UpdateRelatedEdge extends BaseTransform { public beforeDraw(input: DrawData, context: DrawContext): DrawData { const { stage } = context; if (stage === 'visibility') return input; const { model } = this.context; const { update: { nodes, edges, combos }, } = input; const addRelatedEdges = (_: NodeLikeData, id: ID) => { const relatedEdgesData = model.getRelatedEdgesData(id); relatedEdgesData.forEach((edge) => !edges.has(idOf(edge)) && edges.set(idOf(edge), edge)); }; nodes.forEach(addRelatedEdges); combos.forEach(addRelatedEdges); return input; } } ================================================ FILE: packages/g6/src/transforms/utils.ts ================================================ import type { ElementDatum, ElementType } from '../types'; import { idOf } from '../utils/id'; import type { DrawData, ProcedureData } from './types'; /** * 重新分配绘制任务 * * Reassign drawing tasks * @param input - 绘制数据 | DrawData * @param type - 类型 | type * @param elementType - 元素类型 | element type * @param datum - 数据 | data * @param overwrite - 是否覆盖现有数据 | whether to overwrite existing data */ export function reassignTo( input: DrawData, type: 'add' | 'update' | 'remove', elementType: ElementType, datum: ElementDatum, overwrite?: boolean, ) { const id = idOf(datum); const typeName = `${elementType}s` as keyof ProcedureData; const exitsDatum: any = overwrite ? datum : input.add[typeName].get(id) || input.update[typeName].get(id) || input.remove[typeName].get(id) || datum; Object.entries(input).forEach(([_type, value]) => { if (type === _type) value[typeName].set(id, exitsDatum); else value[typeName].delete(id); }); } /** * 判断样式是否与原始样式一致 * * Determine whether the style is consistent with the original style * @param style - 样式 | style * @param originalStyle - 原始样式 | original style * @returns 是否一致 | Whether it is consistent */ export function isStyleEqual(style: Record, originalStyle: Record) { return Object.keys(style).every((key) => style[key] === originalStyle[key]); } ================================================ FILE: packages/g6/src/types/anchor.ts ================================================ import type { Vector2, Vector3 } from './vector'; export type Anchor = string | Vector2 | Vector3; export type STDAnchor = Vector2; ================================================ FILE: packages/g6/src/types/animation.ts ================================================ import type { IAnimation } from '@antv/g'; export type Keyframe = { [key: string]: any; }; export type AnimationTask = () => () => IAnimation | null; ================================================ FILE: packages/g6/src/types/canvas.ts ================================================ export type CanvasLayer = 'background' | 'main' | 'label' | 'transient'; ================================================ FILE: packages/g6/src/types/centrality.ts ================================================ import type { EdgeDirection } from './edge'; export type NodeCentralityOptions = | { type: 'degree'; direction?: EdgeDirection } | { type: 'betweenness'; directed?: boolean; weightPropertyName?: string } | { type: 'closeness'; directed?: boolean; weightPropertyName?: string } | { type: 'eigenvector'; directed?: boolean } | { type: 'pagerank'; epsilon?: number; linkProb?: number }; ================================================ FILE: packages/g6/src/types/change.ts ================================================ import { ChangeType } from '../constants'; import type { ComboData, EdgeData, NodeData } from '../spec/data'; import { Loosen } from './enum'; /** * 数据变更 * * Data change */ export type DataChange = DataAdded | DataUpdated | DataRemoved; export type DataAdded = NodeAdded | EdgeAdded | ComboAdded; export type DataUpdated = NodeUpdated | EdgeUpdated | ComboUpdated; export type DataRemoved = NodeRemoved | EdgeRemoved | ComboRemoved; export type NodeAdded = { type: Loosen; value: NodeData; }; export type NodeUpdated = { type: Loosen; value: NodeData; original: NodeData; }; export type NodeRemoved = { type: Loosen; value: NodeData; }; export type EdgeAdded = { type: Loosen; value: EdgeData; }; export type EdgeUpdated = { type: Loosen; value: EdgeData; original: EdgeData; }; export type EdgeRemoved = { type: Loosen; value: EdgeData; }; export type ComboAdded = { type: Loosen; value: ComboData; }; export type ComboUpdated = { type: Loosen; value: ComboData; original: ComboData; }; export type ComboRemoved = { type: Loosen; value: ComboData; }; export type DataChanges = { add: { nodes: NodeAdded[]; edges: EdgeAdded[]; combos: ComboAdded[]; }; update: { nodes: NodeUpdated[]; edges: EdgeUpdated[]; combos: ComboUpdated[]; }; remove: { nodes: NodeRemoved[]; edges: EdgeRemoved[]; combos: ComboRemoved[]; }; }; ================================================ FILE: packages/g6/src/types/combo.ts ================================================ import type { IconStyleProps } from '../elements/shapes'; import type { NodeLikeData } from './data'; /** * 组合收起时显示的标记样式配置项 * * Style properties of the marker displayed when the combo is collapsed */ export interface CollapsedMarkerStyleProps extends IconStyleProps { /** * 组合收起时显示的标记类型 * - `'child-count'`: 子元素数量(包括 Node 和 Combo) * - `'descendant-count'`: 后代元素数量(包括 Node 和 Combo) * - `'node-count'`: 后代元素数量(只包括 Node) * - `(children: NodeLikeData[]) => string`: 自定义处理逻辑 * * The type of marker displayed when the combo is collapsed * - `'child-count'`: Number of child elements (including Nodes and Combos) * - `'descendant-count'`: Number of descendant elements (including Nodes and Combos) * - `'node-count'`: Number of descendant elements (only Nodes) * - `(children: NodeLikeData[]) => string`: Custom processing logic */ type?: 'child-count' | 'descendant-count' | 'node-count' | ((children: NodeLikeData[]) => string); } ================================================ FILE: packages/g6/src/types/data.ts ================================================ import type { ComboData, EdgeData, NodeData } from '../spec/data'; import type { ID } from '../types'; export type DataID = { nodes?: ID[]; edges?: ID[]; combos?: ID[]; }; export type NodeLikeData = NodeData | ComboData; export type ElementDatum = NodeData | EdgeData | ComboData; export type ElementData = NodeData[] | EdgeData[] | ComboData[]; /** * 节点、边更新可选数据 * * Node, edge update optional data * @remarks * 必须包含 id 字段,其他字段可选 * * Must contain the id field, other fields are optional */ export type PartialNodeLikeData = Partial & Pick; /** * 边更新可选数据 * * Edge update optional data * @remarks * 包含两种情况: * 1. 必须包含 source、target 字段,其他字段可选 * 2. 必须包含 id 字段,其他字段可选 * * Contains two cases: * 1. Must contain the source and target fields, other fields are optional * 2. Must contain the id field, other fields are optional */ export type PartialEdgeData = | (Partial & Pick) | (Partial & Pick); /** * G6 数据更新可选数据 * * G6 data update optional data */ export type PartialGraphData = { nodes?: PartialNodeLikeData[]; edges?: PartialEdgeData[]; combos?: PartialNodeLikeData[]; }; /** * 层级结构类别 * * Hierarchy structure category * @remarks * G6 中树形层级结构和组合层级结构是相互独立的,分别对应不同的数据结构 * 一些 API 需要指定层级结构类别,例如 getAncestorsData、getParentData * * The tree hierarchy structure and the combo hierarchy structure in G6 are independent of each other, corresponding to different data structures * Some APIs need to specify the hierarchy structure category, such as getAncestorsData, getParentData */ export type HierarchyKey = 'tree' | 'combo'; ================================================ FILE: packages/g6/src/types/edge.ts ================================================ import { ImageStyleProps, Line, Path, PathStyleProps, Polyline } from '@antv/g'; import { PathArray } from '@antv/util'; import type { BadgeStyleProps, LabelStyleProps } from '../elements/shapes'; import type { CardinalPlacement, CornerPlacement } from './placement'; import { Size } from './size'; /** * 边的方向 * - `'in'`: 入边 * - `'out'`: 出边 * - `'both'`: 双向边 * * Edge direction * - `'in'`: Inbound edge * - `'out'`: Outbound edge * - `'both'`: Bidirectional edge */ export type EdgeDirection = 'in' | 'out' | 'both'; export type EdgeKey = Line | Path | Polyline; /** * 边上标签样式配置项 * * Edge label style properties */ export interface EdgeLabelStyleProps extends LabelStyleProps { /** * 标签相对于边的位置。取值范围为 'start'、'center'、'end' 或特定比率(数字 0-1) * * Label position relative to the edge (keyShape) that can be 'start', 'center', 'end' or a specific ratio (number 0-1) * @defaultValue 'center' */ placement?: 'start' | 'center' | 'end' | number; /** * 标签平行于边的水平偏移量 * * The horizontal offset of the label parallel to the edge * @defaultValue 4 */ offsetX?: number; /** * 标签垂直于边的垂直偏移量 * * The vertical offset of the label perpendicular to the edge * @defaultValue 0 */ offsetY?: number; /** * 是否自动旋转,保持与边的方向一致 * * Indicates whether to automatically rotate the label to keep it consistent with the direction of the edge * @defaultValue true */ autoRotate?: boolean; /** * 标签最大宽度(需要 [prefix]WordWrap 为 true) * - string: 表示以相对于边长度的百分比形式定义最大宽度。例如 `"50%"` 表示标签宽度不超过边长度的一半 * - number: 表示以像素值为单位定义最大宽度。例如 `100` 表示标签的最大宽度为 100 像素 * * The maximum width of the label(need [prefix]WordWrap to be true) * - string: When set to a string, it defines the maximum width as a percentage of the edge length. For example, `"50%"` means the label width does not exceed half of the edge length * - number: When set to a number, it defines the maximum width in pixels. For example, `100` means the maximum width of the label is 100 pixels * @defaultValue '80%' */ maxWidth?: string | number; } /** * 边上徽标样式配置项 * * Edge badge style properties */ export interface EdgeBadgeStyleProps extends BadgeStyleProps { /** * 徽标的位置 * - `'prefix'`: 置于标签前 * - `'suffix'`: 置于标签后 * * The position of the badge * - `'prefix'`: Placed before the label * - `'suffix'`: Placed after the label */ placement: 'prefix' | 'suffix'; /** * 徽标在 X 轴上的偏移量 * * The offset of the badge on the X-axis */ offsetX?: number; /** * 徽标在 Y 轴上的偏移量 * * The offset of the badge on the Y-axis */ offsetY?: number; } /** * 边上箭头的样式配置项 * * Edge arrow style properties */ export interface EdgeArrowStyleProps extends PathStyleProps, Omit, Record { /** * 箭头大小 * * Arrow size * @defaultValue 8 */ size?: Size; /** * 箭头类型 * * Arrow type * @defaultValue 'triangle' */ type?: | 'triangle' | 'circle' | 'diamond' | 'vee' | 'rect' | 'triangleRect' | 'simple' | ((width: number, height: number) => PathArray); } export type LoopPlacement = CardinalPlacement | CornerPlacement; /** * 自环样式配置项 * * Loop style properties */ export interface LoopStyleProps { /** * 边的位置 * * The position of the edge * @defaultValue 'top' */ placement?: LoopPlacement; /** * 指定是否顺时针绘制环 * * Specify whether to draw the loop clockwise * @defaultValue true */ clockwise?: boolean; /** * 从节点 keyShape 边缘到自环顶部的距离,用于指定自环的曲率,默认为宽度或高度的最大值 * * Determine the position from the edge of the node keyShape to the top of the self-loop, used to specify the curvature of the self-loop, the default value is the maximum of the width or height */ dist?: number; } ================================================ FILE: packages/g6/src/types/element.ts ================================================ import type { DisplayObject } from '@antv/g'; import type { ComboOptions, EdgeOptions, NodeOptions } from '../spec'; import type { Point, Port } from '../types'; /** * 节点类型 * * Node type */ export interface Node extends DisplayObject, ElementHooks, ElementMethods { /** * 获取连接桩 * * Get the ports */ getPorts(): Record; /** * 获取节点中心位置 * * Get the center position of the node */ getCenter(): Point; /** * 获取交点位置 * * Get the intersection point * @param point - 外部位置 | external position * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点位置 | intersection point * @remarks * 给定一个外部位置,返回当前节点与该位置的连边与节点的交点位置 * * Given an external position, return the intersection point of the edge between the current node and the position and the node */ getIntersectPoint(point: Point, useExtendedLine?: boolean): Point; } /** * 边类型 * * Edge type */ export interface Edge extends DisplayObject, ElementHooks, ElementMethods {} /** * 组合类型 * * Combo type */ export interface Combo extends Node { /** * 获取组合的位置 * * Get the position of the combo * @param attributes - 组合属性 | combo attributes */ getComboPosition(attributes: Record): Point; } export type Element = Node | Edge | Combo; export type ElementType = 'node' | 'edge' | 'combo'; export type ElementOptions = NodeOptions | EdgeOptions | ComboOptions; /** * 元素方法 * * Element methods */ export interface ElementMethods { /** * 更新元素属性 * * Update element attributes * @param attr - 属性 | Attributes */ update(attr: any): void; /** * 获取当前元素内的子图形 * * Get the subgraph in the current element * @param shapeID - 子图形 ID | Subgraph ID * @returns 子图形 | Subgraph */ getShape(shapeID: string): T; } /** * 元素钩子方法 * * Element hooks */ export interface ElementHooks { /** * 在元素完成创建并执行完入场动画后调用 * * Called after the element is created and the entrance animation is completed * @override */ onCreate?: () => void; /** * 在元素更新并执行完过渡动画后调用 * * Called after the element is updated and the transition animation is completed * @override */ onUpdate?: () => void; /** * 在元素完成退场动画并销毁后调用 * * Called after the element completes the exit animation and is destroyed * @override */ onDestroy?: () => void; } ================================================ FILE: packages/g6/src/types/enum.ts ================================================ export type Loosen = `${T}`; ================================================ FILE: packages/g6/src/types/event.ts ================================================ import type { DisplayObject, Document, FederatedEvent, FederatedPointerEvent, FederatedWheelEvent, IAnimation, } from '@antv/g'; import type { AnimationType } from '../constants'; import type { ElementDatum } from './data'; import type { Element, ElementType } from './element'; import type { TransformOptions } from './viewport'; export type IEvent = | IGraphLifeCycleEvent | IAnimateEvent | IElementLifeCycleEvent | IViewportEvent | IPointerEvent | IWheelEvent | IKeyboardEvent | IDragEvent; export interface IPointerEvent extends TargetedEvent {} export interface IWheelEvent extends TargetedEvent {} export interface IKeyboardEvent extends KeyboardEvent {} export interface IElementEvent extends IPointerEvent {} export interface IElementDragEvent extends IDragEvent {} export interface IDragEvent extends TargetedEvent { dx: number; dy: number; } export interface IGraphLifeCycleEvent extends NativeEvent { data?: any; } export interface IElementLifeCycleEvent extends NativeEvent { elementType: ElementType; data: ElementDatum; } export interface IViewportEvent extends NativeEvent { data: TransformOptions; } export interface IAnimateEvent extends NativeEvent { animationType: AnimationType; animation: IAnimation | null; data?: any; } /** * G6 原生事件 * * G6 native event */ interface NativeEvent { type: string; } /** * 具有目标的事件 * * Event with target */ type TargetedEvent = Omit & { originalTarget: DisplayObject; target: T; targetType: 'canvas' | 'node' | 'edge' | 'combo'; }; export type Target = Document | Element; ================================================ FILE: packages/g6/src/types/graphlib.ts ================================================ import type { EdgeAdded, EdgeDataUpdated, EdgeRemoved, EdgeUpdated, NodeAdded, NodeDataUpdated, NodeRemoved, TreeStructureAttached, TreeStructureChanged, TreeStructureDetached, } from '@antv/graphlib'; import type { EdgeData } from '../spec'; import type { NodeLikeData } from './data'; export type GraphLibGroupedChanges = { NodeRemoved: NodeRemoved[]; EdgeRemoved: EdgeRemoved[]; NodeAdded: NodeAdded[]; EdgeAdded: EdgeAdded[]; NodeDataUpdated: NodeDataUpdated[]; EdgeUpdated: EdgeUpdated[]; EdgeDataUpdated: EdgeDataUpdated[]; TreeStructureChanged: TreeStructureChanged[]; ComboStructureChanged: TreeStructureChanged[]; TreeStructureAttached: TreeStructureAttached[]; TreeStructureDetached: TreeStructureDetached[]; }; ================================================ FILE: packages/g6/src/types/history.ts ================================================ import type { GraphData } from '../spec'; /** * 单条历史记录命令 * * Single history record command */ export interface Command { /** * 当前数据 * * Current data */ current: CommandData; /** * 原始数据 * * Original data */ original: CommandData; /** * 是否开启动画 * * Whether to enable animation */ animation: boolean; } /** * 单条历史记录命令数据 * * Single history record command data */ export interface CommandData { /** * 新增的数据 * * Added data */ add: GraphData; /** * 更新的数据 * * Updated data */ update: GraphData; /** * 移除的数据 * * Removed data */ remove: GraphData; } ================================================ FILE: packages/g6/src/types/id.ts ================================================ export type ID = string; ================================================ FILE: packages/g6/src/types/index.ts ================================================ export type * from './anchor'; export type * from './animation'; export type * from './canvas'; export type * from './centrality'; export type * from './change'; export type * from './combo'; 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 './id'; export type * from './layout'; export type * from './node'; export type * from './padding'; export type * from './placement'; export type * from './point'; export type * from './prefix'; export type * from './router'; export type * from './size'; export type * from './state'; export type * from './style'; export type * from './tree'; export type * from './vector'; export type * from './viewport'; ================================================ FILE: packages/g6/src/types/layout.ts ================================================ import type { AntVLayout, LegacyAntVLayout } from '../layouts/types'; import type { GraphData } from '../spec/data'; import type { STDLayoutOptions } from '../spec/layout'; export interface AdaptiveLayout { instance: AntVLayout | LegacyAntVLayout; execute(model: GraphData, options?: STDLayoutOptions): Promise; } ================================================ FILE: packages/g6/src/types/node.ts ================================================ import type { BaseStyleProps, CircleStyleProps, DisplayObject } from '@antv/g'; import type { BadgeStyleProps, LabelStyleProps } from '../elements/shapes'; import type { CardinalPlacement, CornerPlacement, DirectionalPlacement, Placement, RelativePlacement, } from './placement'; import type { Point } from './point'; export type PortPlacement = RelativePlacement | CardinalPlacement; export type StarPortPlacement = RelativePlacement | 'top' | 'left' | 'right' | 'left-bottom' | 'right-bottom'; export type TrianglePortPlacement = RelativePlacement | CardinalPlacement; /** * 三角形指向 * * Triangle direction */ export type TriangleDirection = 'up' | 'left' | 'right' | 'down'; /** * 节点标签样式配置项 * * Node label style props */ export interface NodeLabelStyleProps extends LabelStyleProps { /** * 标签相对于节点(keyShape)的位置 * * Label position relative to the node (keyShape) * @defaultValue 'bottom' */ placement?: DirectionalPlacement; /** * 标签最大宽度(需要 [prefix]WordWrap 为 true) * - string: 表示以相对于节点宽度的百分比形式定义最大宽度。例如 `"50%"` 表示标签宽度不超过节点宽度的一半 * - number: 表示以像素值为单位定义最大宽度。例如 `100` 表示标签的最大宽度为 100 像素 * * The maximum width of the label(need [prefix]WordWrap to be true) * - string: When set to a string, it defines the maximum width as a percentage of the node width. For example, `"50%"` means the label width does not exceed half of the node width * - number: When set to a number, it defines the maximum width in pixels. For example, `100` means the maximum width of the label is 100 pixels * @defaultValue '200%' */ maxWidth?: string | number; /** * 标签在 x 轴方向上的偏移量 * * The offset of the label in the x-axis direction */ offsetX?: number; /** * 标签在 y 轴方向上的偏移量 * * The offset of the label in the y-axis direction */ offsetY?: number; } /** * 节点徽标样式配置项 * * Node badge style props */ export interface NodeBadgeStyleProps extends BadgeStyleProps { /** * 徽标相对于节点(keyShape)的位置 * * Badge position relative to the node (keyShape) */ placement?: CardinalPlacement | CornerPlacement; /** * 徽标在 x 轴方向上的偏移量 * * The offset of the badge in the x-axis direction */ offsetX?: number; /** * 徽标在 y 轴方向上的偏移量 * * The offset of the badge in the y-axis direction */ offsetY?: number; } /** * 连接桩样式配置项 * * Port style props */ export interface PortStyleProps extends Omit { /** * 边是否连接到连接桩的中心 * - 若为 `true`,则边连接到连接桩的中心 * - 若为 `false`,则边连接到连接桩的边缘 * * Whether the edge is connected to the center of the port * - If `true`, the edge is connected to the center of the port * - If `false`, the edge is connected to the edge of the port * @defaultValue false */ linkToCenter?: boolean; /** * 连接桩半径 * - 如果设置为 `undefined`,则连接桩被视为一个点,不在画布上显示但存在,边会优先连接到最近的连接桩 * - 如果设置为数字,则连接桩被视为一个圆,圆的半径由此处指定 * * The radius of the port * - If set to `undefined`, the port is treated as a point, not displayed on the canvas but exists, and the edge will be connected to the nearest port first * - If set to a number, the port is treated as a circle, and the radius of the circle is specified here */ r?: number; } export type Port = DisplayObject | Point; /** * 节点连接桩样式配置项 * * Node port style props */ export interface NodePortStyleProps extends PortStyleProps { /** * 连接桩的键值,默认为连接桩的索引 * * The key of the port. Default is the index of the port */ key?: string; /** * 连接桩相对于节点(keyShape)的位置。值可以是字符串或两个数字的元组。 * * The position of the port relative to the node (keyShape). The value can be a string or a tuple of two numbers. * - If the value is a string, it will be treated as the position direction. * - If the value is a tuple of two numbers, it will be treated as the position coordinates(0 ~ 1). */ placement: Placement; } /** * 甜甜圈节点中的圆环样式配置项 * * Ring style props in the donut node */ export interface DonutRound extends BaseStyleProps { /** * 数值,用于计算比例 * * Numerical value used to calculate the scale. */ value: number; /** * 颜色 * * Color. */ color?: string; } ================================================ FILE: packages/g6/src/types/padding.ts ================================================ export type Padding = number | number[]; export type STDPadding = [number, number, number, number]; ================================================ FILE: packages/g6/src/types/placement.ts ================================================ export type CardinalPlacement = 'left' | 'right' | 'top' | 'bottom'; export type CornerPlacement = | 'left-top' | 'left-bottom' | 'right-top' | 'right-bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; export type RelativePlacement = [number, number]; export type DirectionalPlacement = CardinalPlacement | CornerPlacement | 'center'; export type Placement = RelativePlacement | DirectionalPlacement; ================================================ FILE: packages/g6/src/types/plugin.ts ================================================ import type { DisplayObject, FederatedEvent } from '@antv/g'; export type PluginEvent = Omit & { targetType: 'canvas' | 'node' | 'edge' | 'combo'; target: DisplayObject; }; ================================================ FILE: packages/g6/src/types/point.ts ================================================ export type Point = [number, number] | [number, number, number] | Float32Array; export type PointObject = { x: number; y: number; z?: number; }; ================================================ FILE: packages/g6/src/types/prefix.ts ================================================ export type PrefixKey

= `${P}${Capitalize}`; /** * @remarks * `Prefix` 是一种类型模式,用于将特定的前缀 `P` 应用到类型 `T` 上。在我们的配置中,这意味着我们将为一组属性或行为添加一个前缀,以表示它们属于某个具体的上下文或分类。 * * 当你在文档中看到类似 `Prefix<'label', StyleProps>` 的表达式,它表示给 `StyleProps` 类型的属性加上 `label` 前缀,以形成新的属性名称。例如,`color` 属性将以 `labelColor` 形式使用。 * * `Prefix` is a type pattern used to apply a specific prefix `P` to the type `T`. In our configuration, this means adding a prefix to a set of properties or behaviors to indicate that they belong to a specific context or category. * * When you see expressions like `Prefix<'label', StyleProps>` in the document, it means adding the `label` prefix to the properties of the `StyleProps` type to form a new property name. For example, the `color` property will be used in the form of `labelColor`. */ export type Prefix

= { [K in keyof T as K extends string ? PrefixKey : never]?: T[K]; }; export type ReplacePrefix = { [K in keyof T as K extends `${OldPrefix}${infer Rest}` ? `${NewPrefix}${Rest}` : K]: T[K]; }; ================================================ FILE: packages/g6/src/types/router.ts ================================================ import type { Padding } from './padding'; import type { CardinalPlacement } from './placement'; import { Point } from './point'; export type Direction = CardinalPlacement; export type PolylineRouter = false | OrthRouter | ShortestPathRouter; export interface OrthRouter extends OrthRouterOptions { /** * 正交路由,通过在路径上添加额外的控制点,使得边的每一段都保持水平或垂直 * * Orthogonal routing that adds additional control points on the path to ensure each segment of the edge horizontal or vertical * @remarks * 采用基于节点的相对位置和专家经验得出的寻径算法来模糊计算控制点,非最优解但计算速度快。该路由支持 `controlPoints` 来作为额外的控制点,但不支持自动避障。 * * It uses a pathfinding algorithm based on the relative position of the nodes and expert experience to calculate the control points, which is not the optimal solution but is fast to calculate. This routing supports `controlPoints` as additional control points, but does not support automatic obstacle avoidance. */ type: 'orth'; } export interface ShortestPathRouter extends ShortestPathRouterOptions { /** * 最短路径路由,是正交路由 `'orth'` 的智能版本。该路由由水平或垂直的正交线段组成。采用 A* 算法计算最短路径,并支持自动避开路径上的其他节点(障碍) * * The shortest path routing is an intelligent version of the orthogonal routing `'orth'`. The routing consists of horizontal or vertical orthogonal line segments. It uses the A* algorithm to calculate the shortest path and supports automatic avoidance of other nodes (obstacles) on the path. */ type: 'shortest-path'; } export type RouterOptions = OrthRouterOptions | ShortestPathRouterOptions; export interface OrthRouterOptions { /** * 指定节点连接点与转角的最小距离 * * The minimum distance between the node connection point and the corner */ padding?: Padding; } export interface ShortestPathRouterOptions { /** * 节点锚点与转角的最小距离 * * The minimum distance between the node anchor point and the corner */ offset?: Padding; /** * grid 格子大小 * * grid size */ gridSize?: number; /** * 支持的最大旋转角度(弧度) * * Maximum allowable rotation angle (radian) */ maxAllowedDirectionChange?: number; /** * 节点的可能起始方向 * * Possible starting directions from a node */ startDirections?: Direction[]; /** * 节点的可能结束方向 * * Possible ending directions from a node */ endDirections?: Direction[]; /** * 指定可移动的方向 * * Allowed edge directions */ directionMap?: { [key in Direction]: { stepX: number; stepY: number }; }; /** * 表示在路径搜索过程中某些路径的额外代价。key 为弧度值,value 为代价 * * Penalties for direction changes. Key is the radian value, value is the penalty */ penalties?: { [key: string]: number; }; /** * 指定计算两点之间距离的函数 * * Function to calculate the distance between two points */ distFunc?: (p1: Point, p2: Point) => number; /** * 最大迭代次数 * * Maximum loops */ maximumLoops?: number; /** * 是否开启避障 * * Whether to enable obstacle avoidance while computing the path */ enableObstacleAvoidance?: boolean; } ================================================ FILE: packages/g6/src/types/size.ts ================================================ import type { Vector2, Vector3 } from './vector'; export type Size = number | Vector2 | Vector3; export type STDSize = Vector3; ================================================ FILE: packages/g6/src/types/state.ts ================================================ export type State = string; ================================================ FILE: packages/g6/src/types/style.ts ================================================ import type { Graph } from '../runtime/graph'; import type { ElementDatum } from './data'; /** * 样式计算迭代上下文 * * Style iteration context */ export type StyleIterationContext = { datum: ElementDatum; graph: Graph; }; ================================================ FILE: packages/g6/src/types/tree.ts ================================================ export type TreeData = { id: string; children?: TreeData[]; depth?: number; [key: string]: any; }; ================================================ FILE: packages/g6/src/types/utility.ts ================================================ export type UnknownStruct = Record; ================================================ FILE: packages/g6/src/types/vector.ts ================================================ export type Vector2 = [number, number] | Float32Array; export type Vector3 = [number, number, number] | Float32Array; ================================================ FILE: packages/g6/src/types/viewport.ts ================================================ import type { Point } from './point'; export type ViewportAnimationEffectTiming = | boolean | { easing?: string; duration?: number; }; export interface TransformOptions { mode: 'relative' | 'absolute'; origin?: Point; translate?: Point; rotate?: number; scale?: number; } export interface FitViewOptions { /** * 在以下情况下进行适配 * - 'overflow' 仅当图内容超出视口时进行适配 * - 'always' 总是进行适配 * * Fit the view in the following cases * - 'overflow' Only fit when the graph content exceeds the viewport * - 'always' Always fit */ when?: 'overflow' | 'always'; /** * 仅对指定方向进行适配 * - 'x' 仅适配 x 方向 * - 'y' 仅适配 y 方向 * - 'both' 适配 x 和 y 方向 * * Only adapt to the specified direction * - 'x' Only adapt to the x direction * - 'y' Only adapt to the y direction * - 'both' Adapt to the x and y directions */ direction?: 'x' | 'y' | 'both'; } ================================================ FILE: packages/g6/src/utils/anchor.ts ================================================ import type { Anchor, STDAnchor } from '../types/anchor'; import { isBetween } from './math'; /** * 解析原点(锚点) * * Parse the origin/anchor * @param anchor - 原点 | Anchor * @returns 标准原点 | Standard anchor */ export function parseAnchor(anchor: Anchor): STDAnchor { const parsedAnchor = ( typeof anchor === 'string' ? anchor.split(' ').map((v) => parseFloat(v)) : anchor.slice(0, 2) ) as [number, number]; if (!isBetween(parsedAnchor[0], 0, 1) || !isBetween(parsedAnchor[1], 0, 1)) { return [0.5, 0.5]; } return parsedAnchor; } ================================================ FILE: packages/g6/src/utils/animation.ts ================================================ import type { IAnimation } from '@antv/g'; import { isEqual, isNil, isObject } from '@antv/util'; import type { AnimationEffectTiming, AnimationOptions, STDAnimation } from '../animations/types'; import { DEFAULT_ANIMATION_OPTIONS, DEFAULT_ELEMENTS_ANIMATION_OPTIONS, ExtensionCategory } from '../constants'; import { getExtension } from '../registry/get'; import type { GraphOptions } from '../spec'; import type { AnimationStage } from '../spec/element/animation'; import type { ElementType, Keyframe } from '../types'; import { print } from './print'; import { themeOf } from './theme'; export function createAnimationsProxy(animations: IAnimation[]): IAnimation | null; export function createAnimationsProxy(sourceAnimation: IAnimation, targetAnimations: IAnimation[]): IAnimation; /** * 创建动画代理,对一个动画实例的操作同步到多个动画实例上 * * create animation proxy, synchronize animation to multiple animation instances * @param args1 - 源动画实例 | source animation instance * @param args2 - 目标动画实例 | target animation instance * @returns 动画代理 | animation proxy */ export function createAnimationsProxy(args1: IAnimation | IAnimation[], args2?: IAnimation[]): IAnimation | null { if (Array.isArray(args1) && args1.length === 0) return null; const sourceAnimation = Array.isArray(args1) ? args1[0] : args1; const targetAnimations = Array.isArray(args1) ? args1.slice(1) : args2 || []; return new Proxy(sourceAnimation, { get(target, propKey: keyof IAnimation) { if (typeof target[propKey] === 'function' && !['onframe', 'onfinish'].includes(propKey)) { return (...args: unknown[]) => { (target[propKey] as any)(...args); targetAnimations.forEach((animation) => (animation[propKey] as any)?.(...args)); }; } if (propKey === 'finished') { return Promise.all([sourceAnimation.finished, ...targetAnimations.map((animation) => animation.finished)]); } return Reflect.get(target, propKey); }, set(target, propKey: keyof IAnimation, value) { // onframe 和 onfinish 特殊处理,不用同步到所有动画实例上 // onframe and onfinish are specially processed and do not need to be synchronized to all animation instances if (!['onframe', 'onfinish'].includes(propKey)) { targetAnimations.forEach((animation) => { (animation[propKey] as any) = value; }); } return Reflect.set(target, propKey, value); }, }); } /** * 预处理关键帧,过滤掉无用动画的属性 * * Preprocess keyframes, filter out the properties of useless animations * @param keyframes - 关键帧 | keyframes * @returns 关键帧 | keyframes */ export function preprocessKeyframes(keyframes: Keyframe[]): Keyframe[] { // 转化为 PropertyIndexedKeyframes 格式方便后续处理 // convert to PropertyIndexedKeyframes format for subsequent processing const propertyIndexedKeyframes: Record = keyframes.reduce((acc, kf) => { Object.entries(kf).forEach(([key, value]) => { if (acc[key] === undefined) acc[key] = [value]; else acc[key].push(value); }); return acc; }, {}); // 过滤掉无用动画的属性(属性值为 undefined、或者值完全一致) // filter out useless animation properties (property value is undefined, or value is exactly the same) Object.entries(propertyIndexedKeyframes).forEach(([key, values]) => { if ( // 属性值必须在每一帧都存在 / property value must exist in every frame values.length !== keyframes.length || // 属性值不能为空 / property value cannot be empty values.some((value) => isNil(value)) || // 属性值必须不完全一致 / property value must not be exactly the same // 属性值可以是保留属性 / property value can be the reserved property values.every((value) => !['sourceNode', 'targetNode', 'childrenNode'].includes(key) && isEqual(value, values[0])) ) { delete propertyIndexedKeyframes[key]; } }); // 将 PropertyIndexedKeyframes 转化为 Keyframe 格式 // convert PropertyIndexedKeyframes to Keyframe format const output = Object.entries(propertyIndexedKeyframes).reduce((acc, [key, values]) => { values.forEach((value, index) => { if (!acc[index]) acc[index] = { [key]: value }; else acc[index][key] = value; }); return acc; }, [] as Keyframe[]); // 如果处理后所有的属性都被过滤掉,则添加一个没有实际作用的属性用于触发动画 // If all properties are filtered out after processing, add a property that has no actual effect to trigger the animation if (keyframes.length !== 0 && output.length === 0) output.push(...[{ _: 0 }, { _: 0 }]); return output; } /** * 获取属性的默认值 * * Get default value of attribute * @param name - 属性名 | Attribute name * @returns 属性默认值 | Attribute default value * @remarks * 执行动画过程中,一些属性没有显式指定属性值,但实际上在 G 中存在属性值,因此通过该方法获取其实际默认值 * * During the animation, some attributes do not explicitly specify the attribute value, but in fact there is an attribute value in G, so use this method to get the actual default value */ export function inferDefaultValue(name: string) { switch (name) { case 'opacity': return 1; case 'x': case 'y': case 'z': case 'zIndex': return 0; case 'visibility': return 'visible'; case 'collapsed': return false; case 'states': return []; default: return undefined; } } /** * 获取动画配置 * * Get global animation configuration * @param options - G6 配置项(用于获取全局动画配置) | G6 configuration(used to get global animation configuration) * @param localAnimation - 局部动画配置 | local animation configuration * @returns 动画配置 | animation configuration */ export function getAnimationOptions( options: GraphOptions, localAnimation: boolean | AnimationEffectTiming | undefined, ): false | AnimationEffectTiming { const { animation } = options; if (animation === false || localAnimation === false) return false; const effectTiming: AnimationEffectTiming = { ...DEFAULT_ANIMATION_OPTIONS }; if (isObject(animation)) Object.assign(effectTiming, animation); if (isObject(localAnimation)) Object.assign(effectTiming, localAnimation); return effectTiming; } /** * 获取动画配置 * * Get animation configuration * @param options - 动画配置项 | animation configuration * @returns 动画配置 | animation configuration */ function animationOf(options: string | AnimationOptions[]): STDAnimation { if (typeof options === 'string') { const animation = getExtension(ExtensionCategory.ANIMATION, options); if (animation) return animation; print.warn(`The animation of ${options} is not registered.`); return []; } return options; } /** * 获取元素的动画 * * Get element animation * @param options - G6 配置项 | G6 configuration * @param elementType - 元素类型 | element type * @param stage - 动画阶段 | animation stage * @param localAnimation - 局部动画配置 | local animation configuration * @returns 动画时序配置 | animation timing configuration */ export function getElementAnimationOptions( options: GraphOptions, elementType: ElementType, stage: AnimationStage, localAnimation?: AnimationEffectTiming | boolean, ): STDAnimation { const { animation: globalAnimation } = options; if (globalAnimation === false || localAnimation === false) return []; const userElementAnimation = options?.[elementType]?.animation; if (userElementAnimation === false) return []; const useElementStageAnimation = userElementAnimation?.[stage]; if (useElementStageAnimation === false) return []; // 优先级:用户局部动画配置 > 用户动画配置 > 全局动画配置 > 主题动画配置 > 默认动画配置 // Priority: user local animation configuration > user animation configuration > global animation configuration > theme animation configuration > default animation configuration const themeElementAnimation = themeOf(options)[elementType]?.animation; const combine = (_: string | AnimationOptions[] = []) => animationOf(_).map((animation) => ({ ...DEFAULT_ELEMENTS_ANIMATION_OPTIONS, ...(isObject(globalAnimation) && globalAnimation), ...animation, ...(isObject(localAnimation) && localAnimation), })); if (useElementStageAnimation) return combine(useElementStageAnimation); if (!themeElementAnimation) return []; // 此时取决于主题动画配置 // At this time, it depends on the theme animation configuration const themeElementStageAnimation = themeElementAnimation[stage]; if (themeElementStageAnimation === false) return []; return combine(themeElementStageAnimation); } ================================================ FILE: packages/g6/src/utils/array.ts ================================================ /** * 数组去重 * * deduplicate array * @param arr - 数组 | array * @param by - 通过某个属性去重 | deduplicate by some property * @returns 去重后的数组 | deduplicated array */ export function deduplicate(arr: T[], by: (item: T) => unknown = (item) => item) { const set = new Set(); return arr.filter((item) => { const key = by ? by(item) : item; return set.has(key) ? false : set.add(key); }) as T[]; } ================================================ FILE: packages/g6/src/utils/bbox.ts ================================================ import { AABB } from '@antv/g'; import { clone } from '@antv/util'; import type { Node, Padding, Point, TriangleDirection } from '../types'; import { isPoint } from './is'; import { isBetween } from './math'; import { parsePadding } from './padding'; /** * 获取包围盒的宽度 * * Retrieves the width of a bounding box * @param bbox - 包围盒 | Bounding box * @returns 包围盒的宽度 | Width of box */ export function getBBoxWidth(bbox: AABB): number { return bbox.max[0] - bbox.min[0]; } /** * 获取包围盒的高度 * * Retrieve the height of a bounding box * @param bbox - 包围盒 | Bounding box * @returns 包围盒的高度 | Height of box */ export function getBBoxHeight(bbox: AABB): number { return bbox.max[1] - bbox.min[1]; } /** * 获取包围盒的尺寸 * @param bbox - 包围盒 | Bounding box * @returns 包围盒的尺寸 | Size of box */ export function getBBoxSize(bbox: AABB): [number, number] { return [getBBoxWidth(bbox), getBBoxHeight(bbox)]; } /** * 获取节点的包围盒,兼容节点为点的情况 * * Get the bounding box of the node, compatible with the case where the node is a point * @param node - 节点或者点 | node or point * @param padding - 内边距 | padding * @returns 包围盒 | bounding box */ export function getNodeBBox(node: Point | Node, padding?: Padding): AABB { const bbox = isPoint(node) ? getPointBBox(node) : node.getShape('key').getBounds(); return padding ? getExpandedBBox(bbox, padding) : bbox; } /** * 获取单点的包围盒 * * Get the bounding box of a single point * @param point - 点 | Point * @returns 包围盒 | Bounding box */ export function getPointBBox(point: Point): AABB { const [x, y, z = 0] = point; const bbox = new AABB(); bbox.setMinMax([x, y, z], [x, y, z]); return bbox; } /** * 获取扩大后的包围盒 * * Get the expanded bounding box * @param bbox - 包围盒 | Bounding box * @param padding - 内边距 | Padding * @returns 扩大后的包围盒 | The expanded bounding box */ export function getExpandedBBox(bbox: AABB, padding: Padding): AABB { const [top, right, bottom, left] = parsePadding(padding); const [minX, minY, minZ] = bbox.min; const [maxX, maxY, maxZ] = bbox.max; const eBbox = new AABB(); eBbox.setMinMax([minX - left, minY - top, minZ], [maxX + right, maxY + bottom, maxZ]); return eBbox; } /** * 计算整体包围盒 * * Calculate the overall bounding box * @param bboxes - 包围盒列表 | List of bounding boxes * @returns 整体包围盒 | Overall bounding box */ export function getCombinedBBox(bboxes: AABB[]): AABB { if (bboxes.length === 0) return new AABB(); if (bboxes.length === 1) return bboxes[0]; const bbox = new AABB(); bbox.setMinMax(bboxes[0].min, bboxes[0].max); for (let i = 1; i < bboxes.length; i++) { const b2 = bboxes[i]; bbox.setMinMax( [Math.min(bbox.min[0], b2.min[0]), Math.min(bbox.min[1], b2.min[1]), Math.min(bbox.min[2], b2.min[2])], [Math.max(bbox.max[0], b2.max[0]), Math.max(bbox.max[1], b2.max[1]), Math.max(bbox.max[2], b2.max[2])], ); } return bbox; } /** * 判断 bbox1 是否完全包含在 bbox2 内 * * Determine whether bbox1 is completely contained in bbox2 * @param bbox1 - 目标包围盒 | Target bounding box * @param bbox2 - 参考包围盒 | Reference bounding box * @returns 如果 bbox1 完全包含在 bbox2 内返回 true,否则返回 false | Returns true if bbox1 is completely contained in bbox2, false otherwise */ export function isBBoxInside(bbox1: AABB, bbox2: AABB): boolean { const [minX1, minY1] = bbox1.min; const [maxX1, maxY1] = bbox1.max; const [minX2, minY2] = bbox2.min; const [maxX2, maxY2] = bbox2.max; return minX1 >= minX2 && maxX1 <= maxX2 && minY1 >= minY2 && maxY1 <= maxY2; } /** * 判断点是否在给定的包围盒内 * * Whether the point is contained in the given box * @param point - 点 | Point * @param bbox - 包围盒 | Bounding box * @returns 如果点在包围盒内返回 true,否则返回 false | Returns true if the point is inside the bounding box, false otherwise */ export function isPointInBBox(point: Point, bbox: AABB) { return isBetween(point[0], bbox.min[0], bbox.max[0]) && isBetween(point[1], bbox.min[1], bbox.max[1]); } /** * 判断点是否在给定的包围盒的边界或边界的延长线上 * * Whether the point is on the boundary or extension line of the given box * @param point - 点 | Point * @param bbox - 包围盒 | Bounding box * @param extended - 是否判断边界的延长线 | Whether to judge the extension line of the boundary * @returns 如果点在包围盒的边界或边界的延长线上返回 true,否则返回 false | Returns true if the point is on the boundary or extension line of the bounding box, false otherwise */ export function isPointOnBBoxBoundary(point: Point, bbox: AABB, extended = false): boolean { const { min: [minX, minY], max: [maxX, maxY], } = bbox; const onTopOrBottomLine = (point[1] === minY || point[1] === maxY) && (extended || isBetween(point[0], minX, maxX)); const onLeftOrRightLine = (point[0] === minX || point[0] === maxX) && (extended || isBetween(point[1], minY, maxY)); return onTopOrBottomLine || onLeftOrRightLine; } /** * 判断点是否在给定的包围盒外 * * Whether the point is outside the given box * @param point - 点 | Point * @param bbox - 包围盒 | Bounding box * @returns 如果点在包围盒外返回 true,否则返回 false | Returns true if the point is outside the bounding box, false otherwise */ export function isPointOutsideBBox(point: Point, bbox: AABB) { return !isPointInBBox(point, bbox); } /** * 判断点是否位于包围盒中心 * * When the point is at the center of the bounding box * @param point - 点 | Point * @param bbox - 包围盒 | Bounding box * @returns 如果点在包围盒中心返回 true,否则返回 false | Returns true if the point is at the center of the bounding box, false otherwise */ export function isPointBBoxCenter(point: Point, bbox: AABB) { const { center } = bbox; return point[0] === center[0] && point[1] === center[1]; } /** * 获取包围盒上离点 `p` 最近的边 * * Get a side of the boundary which is nearest to the point `p` * @param bbox - 包围盒 | Bounding box * @param p - 点 | Point * @returns 离点 `p` 最近的边 | The side nearest to the point `p` */ export function getNearestBoundarySide(p: Point, bbox: AABB): 'left' | 'right' | 'top' | 'bottom' { const [x, y] = p; const [minX, minY] = bbox.min; const [maxX, maxY] = bbox.max; const left = x - minX; const right = maxX - x; const top = y - minY; const bottom = maxY - y; const min = Math.min(left, right, top, bottom); return min === left ? 'left' : min === right ? 'right' : min === top ? 'top' : min === bottom ? 'bottom' : 'left'; } /** * 获取包围盒上离点 `p` 最近的边界点 * * Get a point on the boundary nearest to the point `p` * @param bbox - 包围盒 | Bounding box * @param p - 点 | Point * @returns 离点 `p` 最近的点 | The point nearest to the point `p` */ export function getNearestBoundaryPoint(p: Point, bbox: AABB): Point { const ref = clone(p); if (isPointInBBox(p, bbox)) { const side = getNearestBoundarySide(p, bbox); switch (side) { case 'left': ref[0] = bbox.min[0]; break; case 'right': ref[0] = bbox.max[0]; break; case 'top': ref[1] = bbox.min[1]; break; case 'bottom': ref[1] = bbox.max[1]; break; } } else { const [x, y] = p; const [minX, minY] = bbox.min; const [maxX, maxY] = bbox.max; ref[0] = isBetween(x, minX, maxX) ? x : x < minX ? minX : maxX; ref[1] = isBetween(y, minY, maxY) ? y : y < minY ? minY : maxY; } return ref; } /** * The triangle center point of the bounding box * @param bbox - bounding box * @param direction - direction * @returns Point */ export function getTriangleCenter(bbox: AABB, direction: TriangleDirection): Point { // todo 算法只对矩形有效 const { center } = bbox; const [width, height] = getBBoxSize(bbox); const x = direction === 'up' || direction === 'down' ? center[0] : direction === 'right' ? center[0] - width / 6 : center[0] + width / 6; const y = direction === 'left' || direction === 'right' ? center[1] : direction === 'down' ? center[1] - height / 6 : center[1] + height / 6; return [x, y]; } /** * Get incircle radius * @param bbox - bounding box * @param direction - direction * @returns number */ export function getIncircleRadius(bbox: AABB, direction: TriangleDirection): number { let [w, h] = getBBoxSize(bbox); [w, h] = direction === 'up' || direction === 'down' ? [w, h] : [h, w]; // 三角形的内切圆半径 return (h ** 2 - (Math.sqrt((w / 2) ** 2 + h ** 2) - w / 2) ** 2) / (2 * h); } /** * 获取包围盒的四条边,顺序依次为上、右、下、左 * * Get the four segments of the bounding box, in order from top, right, bottom, left * @param bbox - 包围盒 | Bounding box * @returns 包围盒的四条边 | The four segments of the bounding box */ export function getBBoxSegments(bbox: AABB): [Point, Point][] { const { min: [minX, minY], max: [maxX, maxY], } = bbox; const topLeftCorner: Point = [minX, maxY]; const topRightCorner: Point = [maxX, maxY]; const bottomRightCorner: Point = [maxX, minY]; const bottomLeftCorner: Point = [minX, minY]; const top = [topLeftCorner, topRightCorner]; const right = [topRightCorner, bottomRightCorner]; const bottom = [bottomRightCorner, bottomLeftCorner]; const left = [bottomLeftCorner, topLeftCorner]; return [top, right, bottom, left] as [Point, Point][]; } ================================================ FILE: packages/g6/src/utils/cache.ts ================================================ import type { DisplayObject } from '@antv/g'; import { get, set } from '@antv/util'; const CacheTargetKey = 'cachedStyle'; const getStyleCacheKey = (name: string) => `__${name}__`; /** * 缓存图形样式 * * Cache shape style * @param element - 图形元素 | shape element * @param name - 样式名 | style name */ export function cacheStyle(element: DisplayObject, name: string | string[]) { const names = Array.isArray(name) ? name : [name]; if (!get(element, CacheTargetKey)) set(element, CacheTargetKey, {}); names.forEach((n) => { set(get(element, CacheTargetKey), getStyleCacheKey(n), element.attributes[n]); }); } /** * 获取缓存的样式 * * Get cached style * @param element - 图形元素 | shape element * @param name - 样式名 | style name * @returns 样式值 | style value */ export function getCachedStyle(element: DisplayObject, name: string) { return get(element, [CacheTargetKey, getStyleCacheKey(name)]); } /** * 是否有缓存的样式 * * Whether there is a cached style * @param element - 图形元素 | shape element * @param name - 样式名 | style name * @returns 是否有缓存的样式 | Whether there is a cached style */ export function hasCachedStyle(element: DisplayObject, name: string) { return getStyleCacheKey(name) in (get(element, CacheTargetKey) || {}); } /** * 设置缓存的样式 * * Set cached style * @param element - 图形元素 | shape element * @param name - 样式名 | style name * @param value - 样式值 | style value */ export function setCacheStyle(element: DisplayObject, name: string, value: any) { set(element, [CacheTargetKey, getStyleCacheKey(name)], value); } ================================================ FILE: packages/g6/src/utils/centrality.ts ================================================ import { findShortestPath, pageRank } from '@antv/algorithm'; import type { EdgeData, GraphData } from '../spec'; import type { EdgeDirection, ID, NodeCentralityOptions } from '../types'; import { idOf } from './id'; export type CentralityResult = Map; export const getNodeCentralities = ( graphData: GraphData, getRelatedEdgesData: (id: ID, direction?: EdgeDirection) => EdgeData[], centrality: NodeCentralityOptions, ) => { switch (centrality.type) { case 'degree': { const centralityResult = new Map(); graphData.nodes?.forEach((node) => { const degree = getRelatedEdgesData(idOf(node), centrality.direction).length; centralityResult.set(idOf(node), degree); }); return centralityResult; } case 'betweenness': return computeNodeBetweennessCentrality(graphData, centrality.directed, centrality.weightPropertyName); case 'closeness': return computeNodeClosenessCentrality(graphData, centrality.directed, centrality.weightPropertyName); case 'eigenvector': return computeNodeEigenvectorCentrality(graphData, centrality.directed); case 'pagerank': return computeNodePageRankCentrality(graphData, centrality.epsilon, centrality.linkProb); default: return initCentralityResult(graphData); } }; export const initCentralityResult = (graphData: GraphData): CentralityResult => { const centralityResult = new Map(); graphData.nodes?.forEach((node) => { centralityResult.set(idOf(node), 0); }); return centralityResult; }; /** * 计算图中每个节点的中介中心性 * * Calculate the betweenness centrality for each node in the graph * @param graphData - 图数据 | Graph data * @param directed - 是否为有向图 | Whether the graph is directed * @param weightPropertyName - 边的权重属性名 | The weight property name of the edge * @returns 每个节点的中介中心性值 | The betweenness centrality for each node */ export const computeNodeBetweennessCentrality = ( graphData: GraphData, directed?: boolean, weightPropertyName?: string, ): CentralityResult => { const centralityResult = initCentralityResult(graphData); const { nodes = [] } = graphData; nodes.forEach((source) => { nodes.forEach((target) => { if (source !== target) { const { allPath } = findShortestPath(graphData, idOf(source), idOf(target), directed, weightPropertyName); const pathCount = allPath.length; (allPath as ID[][]).flat().forEach((nodeId) => { if (nodeId !== idOf(source) && nodeId !== idOf(target)) { centralityResult.set(nodeId, centralityResult.get(nodeId)! + 1 / pathCount); } }); } }); }); return centralityResult; }; /** * 计算图中每个节点的接近中心性 * * Calculate the closeness centrality for each node in the graph * @param graphData - 图数据 | Graph data * @param directed - 是否为有向图 | Whether the graph is directed * @param weightPropertyName - 边的权重属性名 | The weight property name of the edge * @returns 每个节点的接近中心性值 | The closeness centrality for each node */ export const computeNodeClosenessCentrality = ( graphData: GraphData, directed?: boolean, weightPropertyName?: string, ): CentralityResult => { const centralityResult = new Map(); const { nodes = [] } = graphData; nodes.forEach((source) => { const totalLength = nodes.reduce((acc, target) => { if (source !== target) { const { length } = findShortestPath(graphData, idOf(source), idOf(target), directed, weightPropertyName); acc += length; } return acc; }, 0); centralityResult.set(idOf(source), 1 / totalLength); }); return centralityResult; }; /** * 计算图中每个节点的 PageRank 中心性 * * Calculate the PageRank centrality for each node in the graph * @param graphData - 图数据 | Graph data * @param epsilon - PageRank 算法的收敛容差 | The convergence tolerance of the PageRank algorithm * @param linkProb - PageRank 算法的阻尼系数,指任意时刻,用户访问到某节点后继续访问该节点链接的下一个节点的概率,经验值 0.85 | The damping factor of the PageRank algorithm, which refers to the probability that a user will continue to visit the next node linked to a node at any time, with an empirical value of 0.85 * @returns 每个节点的 PageRank 中心性值 | The PageRank centrality for each node */ export const computeNodePageRankCentrality = ( graphData: GraphData, epsilon?: number, linkProb?: number, ): CentralityResult => { const centralityResult = new Map(); const data = pageRank(graphData, epsilon, linkProb); graphData.nodes?.forEach((node) => { centralityResult.set(idOf(node), data[idOf(node)]); }); return centralityResult; }; /** * 计算图中每个节点的特征向量中心性 * * Calculate the eigenvector centrality for each node in the graph. * @param graphData - 图数据 | Graph data * @param directed - 是否为有向图 | Whether the graph is directed * @returns 每个节点的特征向量中心性值 The eigenvector centrality for each node. */ export const computeNodeEigenvectorCentrality = (graphData: GraphData, directed?: boolean): CentralityResult => { const { nodes = [] } = graphData; const adjacencyMatrix = createAdjacencyMatrix(graphData, directed); const eigenvector = powerIteration(adjacencyMatrix, nodes.length); const centralityResult = new Map(); nodes.forEach((node, index) => { centralityResult.set(idOf(node), eigenvector[index]); }); return centralityResult; }; /** * 创建图的邻接矩阵 * * Create the adjacency matrix for the graph. * @param graphData - 图数据 | Graph data * @param directed - 是否为有向图 | Whether the graph is directed * @returns 邻接矩阵 | The adjacency matrix */ export const createAdjacencyMatrix = (graphData: GraphData, directed?: boolean): number[][] => { const { nodes = [], edges = [] } = graphData; const matrix: number[][] = Array(nodes.length) .fill(null) .map(() => Array(nodes.length).fill(0)); edges.forEach(({ source, target }) => { const uIndex = nodes.findIndex((node) => idOf(node) === source); const vIndex = nodes.findIndex((node) => idOf(node) === target); if (directed) { matrix[uIndex][vIndex] = 1; } else { matrix[uIndex][vIndex] = 1; matrix[vIndex][uIndex] = 1; } }); return matrix; }; /** * 使用幂迭代法计算主特征向量 * * Calculate the principal eigenvector using the power iteration method * @see https://en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors * @param matrix - 邻接矩阵 | The adjacency matrix * @param numNodes - 节点数量 | The number of nodes * @param maxIterations - 最大迭代次数 | The maximum number of iterations * @param tolerance - 收敛容差 | The convergence tolerance * @returns 主特征向量 | The principal eigenvector */ const powerIteration = (matrix: number[][], numNodes: number, maxIterations = 100, tolerance = 1e-6): number[] => { let eigenvector = Array(numNodes).fill(1); let diff = Infinity; for (let iter = 0; iter < maxIterations && diff > tolerance; iter++) { const newEigenvector = Array(numNodes).fill(0); for (let i = 0; i < numNodes; i++) { for (let j = 0; j < numNodes; j++) { newEigenvector[i] += matrix[i][j] * eigenvector[j]; } } const norm = Math.sqrt(newEigenvector.reduce((sum, val) => sum + val * val, 0)); for (let i = 0; i < numNodes; i++) { newEigenvector[i] /= norm; } diff = Math.sqrt(newEigenvector.reduce((sum, val, index) => sum + (val - eigenvector[index]) * val, 0)); eigenvector = newEigenvector; } return eigenvector; }; ================================================ FILE: packages/g6/src/utils/change.ts ================================================ import { groupBy } from '@antv/util'; import { ChangeType } from '../constants'; import type { DataAdded, DataChange, DataChanges, DataRemoved, DataUpdated, ID } from '../types'; import { idOf } from './id'; /** * 对数据操作进行约简 * * Reduce data changes * @param changes - 数据操作 | data changes * @returns 约简后的数据操作 | reduced data changes */ export function reduceDataChanges(changes: DataChange[]): DataChange[] { const results = { Added: new Map(), Updated: new Map(), Removed: new Map(), }; changes.forEach((change) => { const { type, value } = change; const id = idOf(value); if (type === 'NodeAdded' || type === 'EdgeAdded' || type === 'ComboAdded') { results.Added.set(id, change); } else if (type === 'NodeUpdated' || type === 'EdgeUpdated' || type === 'ComboUpdated') { // 如果存在 Added,将当前操作置为 Added 操作 // If there is an Added operation, set the current operation to Added if (results.Added.has(id)) { results.Added.set(id, { type: type.replace('Updated', 'Added'), value } as DataAdded); } // 如果存在 Updated,将当前操作置为 Updated 操作,但使用更早版本的 original // If there is an Updated operation, set the current operation to Updated, but use an earlier version of original else if (results.Updated.has(id)) { const { original } = results.Updated.get(id)!; results.Updated.set(id, { type, value, original } as DataUpdated); } else if (results.Removed.has(id)) { // 如果存在 Removed,不做任何操作 // If there is a Removed operation, do nothing } else results.Updated.set(id, change); } else if (type === 'NodeRemoved' || type === 'EdgeRemoved' || type === 'ComboRemoved') { // 如果存在 Added 或者 Updated 的操作,删除 Removed 操作 // If there is an Added or Updated operation, delete the Removed operation if (results.Added.has(id)) { results.Added.delete(id); } else if (results.Updated.has(id)) { results.Updated.delete(id); results.Removed.set(id, change); } else { results.Removed.set(id, change); } } }); // 顺序并不重要 // The order is not important return [ ...Array.from(results.Added.values()), ...Array.from(results.Updated.values()), ...Array.from(results.Removed.values()), ]; } /** * 对数据操作进行分类 * * Classify data changes * @param changes - 数据操作 | data changes * @returns 分类后的数据操作 | classified data changes */ export function groupByChangeType(changes: DataChange[]): DataChanges { const { NodeAdded = [], NodeUpdated = [], NodeRemoved = [], EdgeAdded = [], EdgeUpdated = [], EdgeRemoved = [], ComboAdded = [], ComboUpdated = [], ComboRemoved = [], } = groupBy(changes, (change) => change.type) as unknown as Record<`${ChangeType}`, DataChange[]>; return { add: { nodes: NodeAdded, edges: EdgeAdded, combos: ComboAdded, }, update: { nodes: NodeUpdated, edges: EdgeUpdated, combos: ComboUpdated, }, remove: { nodes: NodeRemoved, edges: EdgeRemoved, combos: ComboRemoved, }, } as DataChanges; } ================================================ FILE: packages/g6/src/utils/collapsibility.ts ================================================ import type { NodeLikeData } from '../types'; /** * 判断节点/组合是否收起 * * Determine whether the node/combo is collapsed * @param nodeLike - 节点/组合数据 | Node/Combo data * @returns 是否收起 | Whether it is collapsed */ export function isCollapsed(nodeLike: NodeLikeData) { return !!nodeLike.style?.collapsed; } ================================================ FILE: packages/g6/src/utils/data.ts ================================================ import { get } from '@antv/util'; import type { ComboData, EdgeData, GraphData, NodeData } from '../spec'; import type { ElementDatum, ID } from '../types'; /** * 合并两个 节点/边/Combo 的数据 * * Merge the data of two nodes/edges/combos * @param original - 原始数据 | original data * @param modified - 待合并的数据 | data to be merged * @returns 合并后的数据 | merged data * @remarks * 只会合并第一层的数据,data、style 下的二级数据会被覆盖 * * Only the first level of data will be merged, the second level of data under data and style will be overwritten */ export function mergeElementsData(original: T, modified: Partial): T { const { data: originalData, style: originalStyle, ...originalAttrs } = original; const { data: modifiedData, style: modifiedStyle, ...modifiedAttrs } = modified; const result = { ...originalAttrs, ...modifiedAttrs, }; if (originalData || modifiedData) { Object.assign(result, { data: { ...originalData, ...modifiedData } }); } if (originalStyle || modifiedStyle) { Object.assign(result, { style: { ...originalStyle, ...modifiedStyle } }); } return result as T; } /** * 克隆元素数据 * * Clone clement data * @param data - 待克隆的数据 | data to be cloned * @returns 克隆后的数据 | cloned data * @remarks * 只会克隆到第二层(data、style) * * Only clone to the second level (data, style) */ export function cloneElementData(data: T): T { const { data: customData, style, ...restAttrs } = data; const clonedData = restAttrs as T; if (customData) clonedData.data = { ...customData }; if (style) clonedData.style = { ...style }; return clonedData; } /** * 判断数据是否为空 * * Determine if the data is empty * @param data - 图数据 | graph data * @returns 是否为空 | is empty */ export function isEmptyData(data: GraphData) { return !get(data, ['nodes', 'length']) && !get(data, ['edges', 'length']) && !get(data, ['combos', 'length']); } /** * 判断两个元素数据是否相等 * * Determine if two element data are equal * @param original - 原始数据 | original data * @param modified - 修改后的数据 | modified data * @returns 是否相等 | is equal * @remarks * 相比于 isEqual,这个方法不会比较更下层的数据 * * Compared to isEqual, this method does not compare data at a lower level */ export function isElementDataEqual(original: Partial = {}, modified: Partial = {}) { const { states: originalStates = [], data: originalData = {}, style: originalStyle = {}, children: originalChildren = [], ...originalAttrs } = original; const { states: modifiedStates = [], data: modifiedData = {}, style: modifiedStyle = {}, children: modifiedChildren = [], ...modifiedAttrs } = modified; const isArrayEqual = (arr1: unknown[], arr2: unknown[]) => { if (arr1.length !== arr2.length) return false; return arr1.every((item, index) => item === arr2[index]); }; const isObjectEqual = (obj1: Record, obj2: Record) => { const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return false; return keys1.every((key) => obj1[key] === obj2[key]); }; if (!isObjectEqual(originalAttrs, modifiedAttrs)) return false; if (!isArrayEqual(originalChildren as ID[], modifiedChildren as ID[])) return false; if (!isArrayEqual(originalStates, modifiedStates)) return false; if (!isObjectEqual(originalData, modifiedData)) return false; if (!isObjectEqual(originalStyle, modifiedStyle)) return false; return true; } export const OVERRIDE_KEY = '__internal_override__'; /** * 判断元素数据是否允许被覆盖 * * Determine whether the element data can be overridden * @param datum - 元素数据 | element data * @returns 是否允许被覆盖 | is overridable */ export function isOverridable(datum: ElementDatum): boolean { return datum[OVERRIDE_KEY] !== false; } ================================================ FILE: packages/g6/src/utils/diff.ts ================================================ import { isEqual } from '@antv/util'; /** * 比较两个数组的差异 * * compare the difference between two arrays * @param original - 原始数组 | original array * @param modified - 修改后的数组 | modified array * @param key - 比较的 key | key to compare * @param comparator - 比较函数 | compare function * @returns 数组差异 | array diff */ export function arrayDiff( original: T[], modified: T[], key: (d: T) => string | number, comparator: (a?: T, b?: T) => boolean = isEqual, ) { const originalMap = new Map(original.map((d) => [key(d), d])); const modifiedMap = new Map(modified.map((d) => [key(d), d])); const originalSet = new Set(originalMap.keys()); const modifiedSet = new Set(modifiedMap.keys()); const enter: T[] = []; const update: T[] = []; const exit: T[] = []; const keep: T[] = []; modifiedSet.forEach((key) => { if (originalSet.has(key)) { if (!comparator(originalMap.get(key), modifiedMap.get(key))) { update.push(modifiedMap.get(key)!); } else { keep.push(modifiedMap.get(key)!); } } else { enter.push(modifiedMap.get(key)!); } }); originalSet.forEach((key) => { if (!modifiedSet.has(key)) { exit.push(originalMap.get(key)!); } }); return { enter, exit, keep, update }; } ================================================ FILE: packages/g6/src/utils/dom.ts ================================================ import { isNumber } from '@antv/util'; const parseInt10 = (d: string) => (d ? parseInt(d) : 0); /** * Get the container's bounding size. * @param container dom element. * @returns the container width and height */ function getContainerSize(container: HTMLElement): [number, number] { const style = getComputedStyle(container); const wrapperWidth = container.clientWidth || parseInt10(style.width); const wrapperHeight = container.clientHeight || parseInt10(style.height); const widthPadding = parseInt10(style.paddingLeft) + parseInt10(style.paddingRight); const heightPadding = parseInt10(style.paddingTop) + parseInt10(style.paddingBottom); return [wrapperWidth - widthPadding, wrapperHeight - heightPadding]; } /** * Get the size of Graph. * @param container container of Graph * @returns Size of Graph */ export function sizeOf(container: HTMLElement | null): [number, number] { if (!container) return [0, 0]; let effectiveWidth = 640; let effectiveHeight = 480; const [containerWidth, containerHeight] = getContainerSize(container); effectiveWidth = containerWidth || effectiveWidth; effectiveHeight = containerHeight || effectiveHeight; /** Minimum width */ const MIN_CHART_WIDTH = 1; /** Minimum height */ const MIN_CHART_HEIGHT = 1; return [ Math.max(isNumber(effectiveWidth) ? effectiveWidth : MIN_CHART_WIDTH, MIN_CHART_WIDTH), Math.max(isNumber(effectiveHeight) ? effectiveHeight : MIN_CHART_HEIGHT, MIN_CHART_HEIGHT), ]; } ================================================ FILE: packages/g6/src/utils/edge.ts ================================================ import type { AABB, DisplayObject, TransformArray } from '@antv/g'; import type { PathArray } from '@antv/util'; import { isEqual, isNumber } from '@antv/util'; import type { EdgeData } from '../spec'; import type { EdgeBadgeStyleProps, EdgeKey, EdgeLabelStyleProps, ID, LoopPlacement, Node, NodeLikeData, Point, Port, Size, Vector2, } from '../types'; import { getBBoxHeight, getBBoxSize, getBBoxWidth, getNearestBoundarySide, getNodeBBox } from './bbox'; import { isCollapsed } from './collapsibility'; import { getAllPorts, getNodeConnectionPoint, getPortConnectionPoint, getPortPosition } from './element'; import { idOf } from './id'; import { isCollinear, isHorizontal, moveTo, parsePoint } from './point'; import { freeJoin } from './router/orth'; import { add, distance, manhattanDistance, multiply, normalize, perpendicular, subtract } from './vector'; /** * 获取标签的位置样式 * * Get the style of the label's position * @param key - 边对象 | The edge object * @param placement - 标签位置 | Position of the label * @param autoRotate - 是否自动旋转 | Whether to auto-rotate * @param offsetX - 标签相对于边的水平偏移量 | Horizontal offset of the label relative to the edge * @param offsetY - 标签相对于边的垂直偏移量 | Vertical offset of the label relative to the edge * @returns 标签的位置样式 | Returns the style of the label's position */ export function getLabelPositionStyle( key: EdgeKey, placement: EdgeLabelStyleProps['placement'], autoRotate: boolean, offsetX: number, offsetY: number, ): Partial { const START_RATIO = 0; const MIDDLE_RATIO = 0.5; const END_RATIO = 0.99; let ratio = typeof placement === 'number' ? placement : MIDDLE_RATIO; if (placement === 'start') ratio = START_RATIO; if (placement === 'end') ratio = END_RATIO; const point = parsePoint(key.getPoint(ratio)); const pointOffset = parsePoint(key.getPoint(ratio + 0.01)); let textAlign: 'left' | 'right' | 'center' = placement === 'start' ? 'left' : placement === 'end' ? 'right' : 'center'; if (isHorizontal(point, pointOffset) || !autoRotate) { const [x, y] = getXYByPlacement(key, ratio, offsetX, offsetY); return { transform: [['translate', x, y]], textAlign }; } let angle = Math.atan2(pointOffset[1] - point[1], pointOffset[0] - point[0]); const isRevert = pointOffset[0] < point[0]; if (isRevert) { textAlign = textAlign === 'center' ? textAlign : textAlign === 'left' ? 'right' : 'left'; offsetX! *= -1; angle += Math.PI; } const [x, y] = getXYByPlacement(key, ratio, offsetX, offsetY, angle); const transform: TransformArray = [ ['translate', x, y], ['rotate', (angle / Math.PI) * 180], ]; return { textAlign, transform, }; } /** * 获取边上徽标的位置样式 * * Get the position style of the badge on the edge * @param shapeMap - 边上的图形映射 | Shape map on the edge * @param placement - 徽标位置 | Badge position * @param labelPlacement - 标签位置 | Label position * @param offsetX - 水平偏移量 | Horizontal offset * @param offsetY - 垂直偏移量 | Vertical offset * @returns 徽标的位置样式 | Position style of the badge */ export function getBadgePositionStyle( shapeMap: Record>, placement: EdgeBadgeStyleProps['placement'], labelPlacement: EdgeLabelStyleProps['placement'], offsetX: number, offsetY: number, ) { const badgeWidth = shapeMap.badge?.getGeometryBounds().halfExtents[0] * 2 || 0; const labelWidth = shapeMap.label?.getGeometryBounds().halfExtents[0] * 2 || 0; return getLabelPositionStyle( shapeMap.key as EdgeKey, labelPlacement, true, (labelWidth ? (labelWidth / 2 + badgeWidth / 2) * (placement === 'suffix' ? 1 : -1) : 0) + offsetX, offsetY, ); } /** * 获取给定边上的指定位置的坐标 * * Get the coordinates at the specified position on the given edge * @param key - 边实例 | Edge instance * @param ratio - 位置比率 | Position ratio * @param offsetX - 水平偏移量 | Horizontal offset * @param offsetY - 垂直偏移量 | Vertical offset * @param angle - 旋转角度 | Rotation angle * @returns 坐标 | Coordinates */ function getXYByPlacement(key: EdgeKey, ratio: number, offsetX: number, offsetY: number, angle?: number) { const [pointX, pointY] = parsePoint(key.getPoint(ratio)); let actualOffsetX = offsetX; let actualOffsetY = offsetY; if (angle) { actualOffsetX = offsetX * Math.cos(angle) - offsetY * Math.sin(angle); actualOffsetY = offsetX * Math.sin(angle) + offsetY * Math.cos(angle); } return [pointX + actualOffsetX, pointY + actualOffsetY]; } /** ==================== Curve Edge =========================== */ /** * 计算曲线的控制点 * * Calculate the control point of the curve * @param sourcePoint - 起点 | Source point * @param targetPoint - 终点 | Target point * @param curvePosition - 控制点在连线上的相对位置(取值范围为 0-1) | The relative position of the control point on the line (value range from 0 to 1) * @param curveOffset - 控制点距离两端点连线的距离 | The distance between the control point and the line * @returns 控制点 | Control points */ export function getCurveControlPoint( sourcePoint: Point, targetPoint: Point, curvePosition: number, curveOffset: number, ): Point { if (isEqual(sourcePoint, targetPoint)) return sourcePoint; const lineVector = subtract(targetPoint, sourcePoint); const controlPoint: Point = [ sourcePoint[0] + curvePosition * lineVector[0], sourcePoint[1] + curvePosition * lineVector[1], ]; const perpVector = normalize(perpendicular(lineVector as Vector2, false)); controlPoint[0] += curveOffset * perpVector[0]; controlPoint[1] += curveOffset * perpVector[1]; return controlPoint; } /** * 解析控制点距离两端点连线的距离 `curveOffset` * * parse the distance of the control point from the line `curveOffset` * @param curveOffset - curveOffset | curveOffset * @returns 标准 curveOffset | standard curveOffset */ export function parseCurveOffset(curveOffset: number | [number, number]): [number, number] { if (isNumber(curveOffset)) return [curveOffset, -curveOffset]; return curveOffset; } /** * 解析控制点在两端点连线上的相对位置 `curvePosition`,范围为`0-1` * * parse the relative position of the control point on the line `curvePosition` * @param curvePosition - curvePosition | curvePosition * @returns 标准 curvePosition | standard curvePosition */ export function parseCurvePosition(curvePosition: number | [number, number]): [number, number] { if (isNumber(curvePosition)) return [curvePosition, 1 - curvePosition]; return curvePosition; } /** * 获取二次贝塞尔曲线绘制路径 * * Calculate the path for drawing a quadratic Bessel curve * @param sourcePoint - 边的起点 | Source point * @param targetPoint - 边的终点 | Target point * @param controlPoint - 控制点 | Control point * @returns 返回绘制曲线的路径 | Returns curve path */ export function getQuadraticPath(sourcePoint: Point, targetPoint: Point, controlPoint: Point): PathArray { return [ ['M', sourcePoint[0], sourcePoint[1]], ['Q', controlPoint[0], controlPoint[1], targetPoint[0], targetPoint[1]], ]; } /** * 获取三次贝塞尔曲线绘制路径 * * Calculate the path for drawing a cubic Bessel curve * @param sourcePoint - 边的起点 | Source point * @param targetPoint - 边的终点 | Target point * @param controlPoints - 控制点 | Control point * @returns 返回绘制曲线的路径 | Returns curve path */ export function getCubicPath(sourcePoint: Point, targetPoint: Point, controlPoints: [Point, Point]): PathArray { return [ ['M', sourcePoint[0], sourcePoint[1]], [ 'C', controlPoints[0][0], controlPoints[0][1], controlPoints[1][0], controlPoints[1][1], targetPoint[0], targetPoint[1], ], ]; } /** ==================== Polyline Edge =========================== */ /** * 获取折线的绘制路径 * * Calculates the path for drawing a polyline * @param points - 折线的顶点 | The vertices of the polyline * @param radius - 圆角半径 | Radius of the rounded corner * @param z - 路径是否闭合 | Whether the path is closed * @returns 返回绘制折线的路径 | Returns the path for drawing a polyline */ export function getPolylinePath(points: Point[], radius = 0, z = false): PathArray { const targetIndex = points.length - 1; const sourcePoint = points[0]; const targetPoint = points[targetIndex]; const controlPoints = points.slice(1, targetIndex); const pathArray: PathArray = [['M', sourcePoint[0], sourcePoint[1]]]; controlPoints.forEach((midPoint, i) => { const prevPoint = controlPoints[i - 1] || sourcePoint; const nextPoint = controlPoints[i + 1] || targetPoint; if (!isCollinear(prevPoint, midPoint, nextPoint) && radius) { const [ps, pt] = getBorderRadiusPoints(prevPoint, midPoint, nextPoint, radius); pathArray.push(['L', ps[0], ps[1]], ['Q', midPoint[0], midPoint[1], pt[0], pt[1]], ['L', pt[0], pt[1]]); } else { pathArray.push(['L', midPoint[0], midPoint[1]]); } }); pathArray.push(['L', targetPoint[0], targetPoint[1]]); if (z) pathArray.push(['Z']); return pathArray; } /** * 根据给定的半径计算出不共线的三点生成贝塞尔曲线的控制点,以模拟接近圆弧 * * Calculates the control points of the Bezier curve generated by three non-collinear points according to the given radius to simulate an arc * @param prevPoint - 前一个点 | Previous point * @param midPoint - 中间点 | Middle point * @param nextPoint - 后一个点 | Next point * @param radius - 圆角半径 | Radius of the rounded corner * @returns 返回控制点 | Returns control points */ export function getBorderRadiusPoints( prevPoint: Point, midPoint: Point, nextPoint: Point, radius: number, ): [Point, Point] { const d0 = manhattanDistance(prevPoint, midPoint); const d1 = manhattanDistance(nextPoint, midPoint); // 取给定的半径和最小半径之间的较小值 | use the smaller value between the given radius and the minimum radius const r = Math.min(radius, Math.min(d0, d1) / 2); const ps: Point = [ midPoint[0] - (r / d0) * (midPoint[0] - prevPoint[0]), midPoint[1] - (r / d0) * (midPoint[1] - prevPoint[1]), ]; const pt: Point = [ midPoint[0] - (r / d1) * (midPoint[0] - nextPoint[0]), midPoint[1] - (r / d1) * (midPoint[1] - nextPoint[1]), ]; return [ps, pt]; } /** ==================== Loop Edge =========================== */ export const getRadians = (bbox: AABB): Record => { const halfPI = Math.PI / 2; const halfHeight = getBBoxHeight(bbox) / 2; const halfWidth = getBBoxWidth(bbox) / 2; const angleWithX = Math.atan2(halfHeight, halfWidth) / 2; const angleWithY = Math.atan2(halfWidth, halfHeight) / 2; return { top: [-halfPI - angleWithY, -halfPI + angleWithY], 'top-right': [-halfPI + angleWithY, -angleWithX], 'right-top': [-halfPI + angleWithY, -angleWithX], right: [-angleWithX, angleWithX], 'bottom-right': [angleWithX, halfPI - angleWithY], 'right-bottom': [angleWithX, halfPI - angleWithY], bottom: [halfPI - angleWithY, halfPI + angleWithY], 'bottom-left': [halfPI + angleWithY, Math.PI - angleWithX], 'left-bottom': [halfPI + angleWithY, Math.PI - angleWithX], left: [Math.PI - angleWithX, Math.PI + angleWithX], 'top-left': [Math.PI + angleWithX, -halfPI - angleWithY], 'left-top': [Math.PI + angleWithX, -halfPI - angleWithY], }; }; /** * 获取环形边的起点和终点 * * Get the start and end points of the loop edge * @param node - 节点实例 | Node instance * @param placement - 环形边相对于节点位置 | Loop position relative to the node * @param clockwise - 是否顺时针 | Whether to draw the loop clockwise * @param sourcePort - 起点连接桩 | Source port * @param targetPort - 终点连接桩 | Target port * @returns 起点和终点 | Start and end points */ export function getLoopEndpoints( node: Node, placement: LoopPlacement, clockwise: boolean, sourcePort?: Port, targetPort?: Port, ): [Point, Point] { const bbox = getNodeBBox(node); const center = node.getCenter(); let sourcePoint = sourcePort && getPortPosition(sourcePort); let targetPoint = targetPort && getPortPosition(targetPort); if (!sourcePoint || !targetPoint) { const radians = getRadians(bbox); const angle1 = radians[placement][0]; const angle2 = radians[placement][1]; const [width, height] = getBBoxSize(bbox); const r = Math.max(width, height); const point1: Point = add(center, [r * Math.cos(angle1), r * Math.sin(angle1), 0]); const point2: Point = add(center, [r * Math.cos(angle2), r * Math.sin(angle2), 0]); sourcePoint = getNodeConnectionPoint(node, point1); targetPoint = getNodeConnectionPoint(node, point2); if (!clockwise) { [sourcePoint, targetPoint] = [targetPoint, sourcePoint]; } } return [sourcePoint, targetPoint]; } /** * 获取环形边的绘制路径 * * Get the path of the loop edge * @param node - 节点实例 | Node instance * @param placement - 环形边相对于节点位置 | Loop position relative to the node * @param clockwise - 是否顺时针 | Whether to draw the loop clockwise * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @param sourcePortKey - 起点连接桩 key | Source port key * @param targetPortKey - 终点连接桩 key | Target port key * @returns 返回绘制环形边的路径 | Returns the path of the loop edge */ export function getCubicLoopPath( node: Node, placement: LoopPlacement, clockwise: boolean, dist: number, sourcePortKey?: string, targetPortKey?: string, ) { const sourcePort = node.getPorts()[(sourcePortKey || targetPortKey)!]; const targetPort = node.getPorts()[(targetPortKey || sourcePortKey)!]; // 1. 获取起点和终点 | Get the start and end points let [sourcePoint, targetPoint] = getLoopEndpoints(node, placement, clockwise, sourcePort, targetPort); // 2. 获取控制点 | Get the control points const controlPoints = getCubicLoopControlPoints(node, sourcePoint, targetPoint, dist); // 3. 如果定义了连接桩,调整端点以与连接桩边界相交 | If the port is defined, adjust the endpoint to intersect with the port boundary if (sourcePort) sourcePoint = getPortConnectionPoint(sourcePort, controlPoints[0]); if (targetPort) targetPoint = getPortConnectionPoint(targetPort, controlPoints.at(-1) as Point); return getCubicPath(sourcePoint, targetPoint, controlPoints); } /** * 获取环形边的控制点 * * Get the control points of the loop edge * @param node - 节点实例 | Node instance * @param sourcePoint - 起点 | Source point * @param targetPoint - 终点 | Target point * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @returns 控制点 | Control points */ export function getCubicLoopControlPoints( node: Node, sourcePoint: Point, targetPoint: Point, dist: number, ): [Point, Point] { const center = node.getCenter(); if (isEqual(sourcePoint, targetPoint)) { const direction = subtract(sourcePoint, center); const adjustment: Point = [ dist * Math.sign(direction[0]) || dist / 2, dist * Math.sign(direction[1]) || -dist / 2, 0, ]; return [add(sourcePoint, adjustment), add(targetPoint, multiply(adjustment, [1, -1, 1]))]; } return [ moveTo(center, sourcePoint, distance(center, sourcePoint) + dist), moveTo(center, targetPoint, distance(center, targetPoint) + dist), ]; } /** * 获取环形折线边的绘制路径 * * Get the path of the loop polyline edge * @param node - 节点实例 | Node instance * @param radius - 圆角半径 | Radius of the rounded corner * @param placement - 环形边相对于节点位置 | Loop position relative to the node * @param clockwise - 是否顺时针 | Whether to draw the loop clockwise * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @param sourcePortKey - 起点连接桩 key | Source port key * @param targetPortKey - 终点连接桩 key | Target port key * @returns 返回绘制环形折线边的路径 | Returns the path of the loop polyline edge */ export function getPolylineLoopPath( node: Node, radius: number, placement: LoopPlacement, clockwise: boolean, dist: number, sourcePortKey?: string, targetPortKey?: string, ) { const allPortsMap = getAllPorts(node); const sourcePort = allPortsMap[(sourcePortKey || targetPortKey)!]; const targetPort = allPortsMap[(targetPortKey || sourcePortKey)!]; // 1. 获取起点和终点 | Get the start and end points let [sourcePoint, targetPoint] = getLoopEndpoints(node, placement, clockwise, sourcePort, targetPort); // 2. 获取控制点 | Get the control points const controlPoints = getPolylineLoopControlPoints(node, sourcePoint, targetPoint, dist); // 3. 如果定义了连接桩,调整端点以与连接桩边界相交 | If the port is defined, adjust the endpoint to intersect with the port boundary if (sourcePort) sourcePoint = getPortConnectionPoint(sourcePort, controlPoints[0]); if (targetPort) targetPoint = getPortConnectionPoint(targetPort, controlPoints.at(-1) as Point); return getPolylinePath([sourcePoint, ...controlPoints, targetPoint], radius); } /** * 获取环形折线边的控制点 * * Get the control points of the loop polyline edge * @param node - 节点实例 | Node instance * @param sourcePoint - 起点 | Source point * @param targetPoint - 终点 | Target point * @param dist - 从节点 keyShape 边缘到自环顶部的距离 | The distance from the edge of the node keyShape to the top of the self-loop * @returns 控制点 | Control points */ export function getPolylineLoopControlPoints(node: Node, sourcePoint: Point, targetPoint: Point, dist: number) { const controlPoints: Point[] = []; const bbox = getNodeBBox(node); // 1. 起点和终点相同 | The start and end points are the same if (isEqual(sourcePoint, targetPoint)) { const side = getNearestBoundarySide(sourcePoint, bbox); switch (side) { case 'left': controlPoints.push([sourcePoint[0] - dist, sourcePoint[1]]); controlPoints.push([sourcePoint[0] - dist, sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0], sourcePoint[1] + dist]); break; case 'right': controlPoints.push([sourcePoint[0] + dist, sourcePoint[1]]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0], sourcePoint[1] + dist]); break; case 'top': controlPoints.push([sourcePoint[0], sourcePoint[1] - dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1] - dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1]]); break; case 'bottom': controlPoints.push([sourcePoint[0], sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1] + dist]); controlPoints.push([sourcePoint[0] + dist, sourcePoint[1]]); break; } } else { const sourceSide = getNearestBoundarySide(sourcePoint, bbox); const targetSide = getNearestBoundarySide(targetPoint, bbox); // 2. 起点与终点同边 | The start and end points are on the same side if (sourceSide === targetSide) { const side = sourceSide; let x, y; switch (side) { case 'left': x = Math.min(sourcePoint[0], targetPoint[0]) - dist; controlPoints.push([x, sourcePoint[1]]); controlPoints.push([x, targetPoint[1]]); break; case 'right': x = Math.max(sourcePoint[0], targetPoint[0]) + dist; controlPoints.push([x, sourcePoint[1]]); controlPoints.push([x, targetPoint[1]]); break; case 'top': y = Math.min(sourcePoint[1], targetPoint[1]) - dist; controlPoints.push([sourcePoint[0], y]); controlPoints.push([targetPoint[0], y]); break; case 'bottom': y = Math.max(sourcePoint[1], targetPoint[1]) + dist; controlPoints.push([sourcePoint[0], y]); controlPoints.push([targetPoint[0], y]); break; } } else { // 3. 起点与终点不同边 | The start and end points are on different sides const getPointOffSide = (side: 'left' | 'right' | 'top' | 'bottom', point: Point): Point => { return { left: [point[0] - dist, point[1]], right: [point[0] + dist, point[1]], top: [point[0], point[1] - dist], bottom: [point[0], point[1] + dist], }[side] as Point; }; const p1 = getPointOffSide(sourceSide, sourcePoint); const p2 = getPointOffSide(targetSide, targetPoint); const p3 = freeJoin(p1, p2, bbox); controlPoints.push(p1, p3, p2); } } return controlPoints; } /** * 获取子图内的所有边,并按照内部边和外部边分组 * * Get all the edges in the subgraph and group them into internal and external edges * @param ids - 节点 ID 数组 | Node ID array * @param getRelatedEdges - 获取节点邻边 | Get node edges * @returns 子图边 | Subgraph edges */ export function getSubgraphRelatedEdges(ids: ID[], getRelatedEdges: (id: ID) => EdgeData[]) { const edges = new Set(); const internal = new Set(); const external = new Set(); ids.forEach((id) => { const relatedEdges = getRelatedEdges(id); relatedEdges.forEach((edge) => { edges.add(edge); if (ids.includes(edge.source) && ids.includes(edge.target)) internal.add(edge); else external.add(edge); }); }); return { edges: Array.from(edges), internal: Array.from(internal), external: Array.from(external) }; } /** * 获取边的实际连接节点 * * Get the actual connected object of the edge * @param node - 逻辑连接节点数据 | Logical connection node data * @param getParentData - 获取父节点数据 | Get parent node data * @returns 实际连接节点数据 | Actual connected node data */ export function findActualConnectNodeData(node: NodeLikeData, getParentData: (id: ID) => NodeLikeData | undefined) { const path: NodeLikeData[] = []; let current = node; while (current) { path.push(current); const parent = getParentData(idOf(current)); if (parent) current = parent; else break; } if (path.some((n) => n.style?.collapsed)) { const index = path.reverse().findIndex(isCollapsed); return path[index] || path.at(-1); } return node; } /** * 获取箭头大小,若用户未指定,则根据线宽自动计算 * * Get the size of the arrow * @param lineWidth - 箭头所在边的线宽 | The line width of the edge where the arrow is located * @param size - 自定义箭头大小 | Custom arrow size * @returns 箭头大小 | Arrow size */ export function getArrowSize(lineWidth: number, size?: Size): Size { if (size) return size; if (lineWidth < 4) return 10; if (lineWidth === 4) return 12; return lineWidth * 2.5; } ================================================ FILE: packages/g6/src/utils/element.ts ================================================ import type { AABB, DisplayObject, TextStyleProps } from '@antv/g'; import { get, isNumber, isString, set } from '@antv/util'; import { BaseCombo, BaseEdge, BaseNode } from '../elements'; import type { BaseShape, BaseShapeStyleProps } from '../elements/shapes'; import type { Combo, Edge, Element, Node, NodePortStyleProps, Placement, Point, TriangleDirection } from '../types'; import type { NodeLabelStyleProps, Port } from '../types/node'; import { getBBoxHeight, getBBoxWidth } from './bbox'; import { isPoint } from './is'; import { findNearestPoints, getEllipseIntersectPoint } from './point'; import { getXYByPlacement } from './position'; /** * 判断是否是 Node 的实例 * * Judge whether the instance is Node * @param shape - 实例 | instance * @returns 是否是 Node 的实例 | whether the instance is Node */ export function isNode(shape: DisplayObject | Port): shape is Node { return shape instanceof BaseNode && shape.type === 'node'; } /** * 判断是否是 Edge 的实例 * * Judge whether the instance is Edge * @param shape - 实例 | instance * @returns 是否是 Edge 的实例 | whether the instance is Edge */ export function isEdge(shape: DisplayObject): shape is Edge { return shape instanceof BaseEdge; } /** * 判断是否是 Combo 的实例 * * Judge whether the instance is Combo * @param shape - 实例 | instance * @returns 是否是 Combo 的实例 | whether the instance is Combo */ export function isCombo(shape: any): shape is Combo { return shape instanceof BaseCombo; } /** * 判断是否是 Element 的实例 * * Judge whether the instance is Element * @param shape - 实例 | instance * @returns 是否是 Element 的实例 | whether the instance is Element */ export function isElement(shape: any): shape is Element { return isNode(shape) || isEdge(shape) || isCombo(shape); } /** * 判断两个节点是否相同 * * Whether the two nodes are the same * @param node1 - 节点1 | Node1 * @param node2 - 节点2 | Node2 * @returns 是否相同 | Whether the same */ export function isSameNode(node1: Node, node2: Node): boolean { if (!node1 || !node2) return false; return node1 === node2; } const PORT_MAP: Record = { top: [0.5, 0], right: [1, 0.5], bottom: [0.5, 1], left: [0, 0.5], 'left-top': [0, 0], 'top-left': [0, 0], 'left-bottom': [0, 1], 'bottom-left': [0, 1], 'right-top': [1, 0], 'top-right': [1, 0], 'right-bottom': [1, 1], 'bottom-right': [1, 1], default: [0.5, 0.5], }; /** * Get the Port x, y by `position`. * @param bbox - BBox of element. * @param placement - The position relative with element. * @param portMap - The map of position. * @param isRelative - Whether the position in MAP is relative. * @returns [x, y] */ export function getPortXYByPlacement( bbox: AABB, placement?: Placement, portMap: Record = PORT_MAP, isRelative = true, ): Point { const DEFAULT = [0.5, 0.5]; const p: [number, number] = isString(placement) ? get(portMap, placement.toLocaleLowerCase(), DEFAULT) : placement; if (!isRelative && isString(placement)) return p; const [x, y] = p || DEFAULT; return [bbox.min[0] + getBBoxWidth(bbox) * x, bbox.min[1] + getBBoxHeight(bbox) * y]; } /** * 获取节点上的所有连接桩 * * Get all ports * @param node - 节点 | Node * @returns 所有连接桩 | All Ports */ export function getAllPorts(node: Node): Record { if (!node) return {}; // 1. 需要绘制的连接桩 | Get the ports that need to be drawn const ports = node.getPorts(); // 2. 不需要额外绘制的连接桩 | Get the ports that do not need to be drawn const portsStyle = node.attributes.ports || []; portsStyle.forEach((portStyle: NodePortStyleProps, i: number) => { const { key, placement } = portStyle; if (isSimplePort(portStyle)) { ports[key || i] ||= getXYByPlacement(node.getShape('key').getBounds(), placement); } }); return ports; } /** * 是否为简单连接桩,如果是则不会额外绘制图形 * * Whether it is a simple port, which will not draw additional graphics * @param portStyle - 连接桩样式 | Port Style * @returns 是否是简单连接桩 | Whether it is a simple port */ export function isSimplePort(portStyle: NodePortStyleProps): boolean { const { r } = portStyle; return !r || Number(r) === 0; } /** * 获取连接桩的位置 * * Get the position of the port * @param port - 连接桩 | Port * @returns 连接桩的位置 | Port Position */ export function getPortPosition(port: Port): Point { return isPoint(port) ? port : (port.getPosition() as Point); } /** * 查找起始连接桩和目标连接桩 * * Find the source port and target port * @param sourceNode - 起始节点 | Source Node * @param targetNode - 目标节点 | Target Node * @param sourcePortKey - 起始连接桩的 key | Source Port Key * @param targetPortKey - 目标连接桩的 key | Target Port Key * @returns 起始连接桩和目标连接桩 | Source Port and Target Port */ export function findPorts( sourceNode: Node, targetNode: Node, sourcePortKey?: string, targetPortKey?: string, ): [Port | undefined, Port | undefined] { const sourcePort = findPort(sourceNode, targetNode, sourcePortKey, targetPortKey); const targetPort = findPort(targetNode, sourceNode, targetPortKey, sourcePortKey); return [sourcePort, targetPort]; } /** * 查找节点上的最有可能连接的连接桩 * * Find the most likely connected port on the node * @remarks * 1. If `portKey` is specified, return the port. * 2. If `portKey` is not specified, return the port closest to the opposite connection points. * 3. If the node has no ports, return undefined. * @param node - 节点 | Node * @param oppositeNode - 对端节点 | Opposite Node * @param portKey - 连接桩的键值(key) | Port Key * @param oppositePortKey - 对端连接桩的 key | Opposite Port Key * @returns 连接桩 | Port */ export function findPort(node: Node, oppositeNode: Node, portKey?: string, oppositePortKey?: string): Port | undefined { const portsMap = getAllPorts(node); if (portKey) return portsMap[portKey]; const ports = Object.values(portsMap); if (ports.length === 0) return undefined; const positions = ports.map((port) => getPortPosition(port)); const oppositePositions = findConnectionPoints(oppositeNode, oppositePortKey); const [nearestPosition] = findNearestPoints(positions, oppositePositions); return ports.find((port) => getPortPosition(port) === nearestPosition); } /** * 寻找节点上所有可能的连接点 * * Find all possible connection points on the node * @remarks * 1. If `portKey` is specified, return the position of the port. * 2. If `portKey` is not specified, return positions of all ports. * 3. If the node has no ports, return the center of the node. * @param node - 节点 | Node * @param portKey - 连接桩的键值(key),如不指定则返回所有 | Port Key, return all if not specified * @returns 连接点 | Connection Point */ function findConnectionPoints(node: Node, portKey?: string): Point[] { const allPortsMap = getAllPorts(node); if (portKey) return [getPortPosition(allPortsMap[portKey])]; const oppositePorts = Object.values(allPortsMap); return oppositePorts.length > 0 ? oppositePorts.map((port) => getPortPosition(port)) : [node.getCenter()]; } /** * 获取连接点, 即从节点或连接桩中心到另一端的连线在节点或连接桩边界上的交点 * * Get the connection point * @param node - 节点或连接桩 | Node or Port * @param opposite - 对端的具体点或节点 | Opposite Point or Node * @returns 连接点 | Connection Point */ export function getConnectionPoint(node: Port | Node | Combo, opposite: Node | Port): Point { return isCombo(node) || isNode(node) ? getNodeConnectionPoint(node, opposite) : getPortConnectionPoint(node, opposite); } /** * 获取连接桩的连接点,即从连接桩中心到另一端的连线在连接桩边界上的交点 * * Get the connection point of the port * @param port - 连接桩 | Port * @param opposite - 对端的具体点或节点 | Opposite Point or Node * // @param oppositePort - 对端连接桩 | Opposite Port * @returns 连接桩的连接点 | Port Point */ export function getPortConnectionPoint(port: Port, opposite: Node | Port): Point { if (!port || !opposite) return [0, 0, 0]; if (isPoint(port)) return port; // 1. linkToCenter 为 true,则返回连接桩的中心 | If linkToCenter is true, return the center of the port if (port.attributes.linkToCenter) return port.getPosition() as Point; // 2. 推导对端的具体点:如果是连接桩或节点,则返回它的中心;如果是具体点,则直接返回 // 2. Get a specific opposite point: if it is a port or a node, return its center; if it is a specific point, return directly const oppositePosition = isPoint(opposite) ? opposite : isNode(opposite) ? opposite.getCenter() : opposite.getPosition(); // 3. 返回连接桩边界上的交点 | Return the intersection point on the port boundary return getEllipseIntersectPoint(oppositePosition as Point, port.getBounds()); } /** * 获取节点的连接点 * * Get the Node Connection Point * @param nodeLike - 节点或组合 | Node or Combo * @param opposite - 对端的具体点或节点 | Opposite Point or Node * // @param oppositePort - 对端连接桩 | Opposite Port * @returns 节点的连接点 | Node Point */ export function getNodeConnectionPoint(nodeLike: Node | Combo, opposite: Node | Port): Point { if (!nodeLike || !opposite) return [0, 0, 0]; const oppositePosition = isPoint(opposite) ? opposite : isNode(opposite) ? opposite.getCenter() : (opposite.getPosition() as Point); return nodeLike.getIntersectPoint(oppositePosition) || nodeLike.getCenter(); } /** * Get the Text style by `position`. * @param bbox - BBox of element. * @param placement - The position relative with element. * @param offsetX - The offset x. * @param offsetY - The offset y. * @param isReverseBaseline - Whether reverse the baseline. * @returns Partial */ export function getTextStyleByPlacement( bbox: AABB, placement: NodeLabelStyleProps['placement'] = 'bottom', offsetX: number = 0, offsetY: number = 0, isReverseBaseline = false, ): Partial { const direction = placement.split('-'); const [x, y] = getXYByPlacement(bbox, placement); const [top, bottom]: TextStyleProps['textBaseline'][] = isReverseBaseline ? ['bottom', 'top'] : ['top', 'bottom']; const textBaseline: TextStyleProps['textBaseline'] = direction.includes('top') ? bottom : direction.includes('bottom') ? top : 'middle'; const textAlign: TextStyleProps['textAlign'] = direction.includes('left') ? 'right' : direction.includes('right') ? 'left' : 'center'; return { transform: [['translate', x + offsetX, y + offsetY]], textBaseline, textAlign, }; } /** * 获取五角星的顶点 * * Get Star Points * @param outerR - 外半径 | outer radius * @param innerR - 内半径 | inner radius * @returns 五角星的顶点 | Star Points */ export function getStarPoints(outerR: number, innerR: number): Point[] { return [ [0, -outerR], [innerR * Math.cos((3 * Math.PI) / 10), -innerR * Math.sin((3 * Math.PI) / 10)], [outerR * Math.cos(Math.PI / 10), -outerR * Math.sin(Math.PI / 10)], [innerR * Math.cos(Math.PI / 10), innerR * Math.sin(Math.PI / 10)], [outerR * Math.cos((3 * Math.PI) / 10), outerR * Math.sin((3 * Math.PI) / 10)], [0, innerR], [-outerR * Math.cos((3 * Math.PI) / 10), outerR * Math.sin((3 * Math.PI) / 10)], [-innerR * Math.cos(Math.PI / 10), innerR * Math.sin(Math.PI / 10)], [-outerR * Math.cos(Math.PI / 10), -outerR * Math.sin(Math.PI / 10)], [-innerR * Math.cos((3 * Math.PI) / 10), -innerR * Math.sin((3 * Math.PI) / 10)], ]; } /** * Get Star Port Point. * @param outerR - outer radius * @param innerR - inner radius * @returns Port points for Star. */ export function getStarPorts(outerR: number, innerR: number): Record { const r: Record = {}; r['top'] = [0, -outerR]; r['left'] = [-outerR * Math.cos(Math.PI / 10), -outerR * Math.sin(Math.PI / 10)]; r['left-bottom'] = [-outerR * Math.cos((3 * Math.PI) / 10), outerR * Math.sin((3 * Math.PI) / 10)]; r['bottom'] = [0, innerR]; r['right-bottom'] = [outerR * Math.cos((3 * Math.PI) / 10), outerR * Math.sin((3 * Math.PI) / 10)]; r['right'] = r['default'] = [outerR * Math.cos(Math.PI / 10), -outerR * Math.sin(Math.PI / 10)]; return r; } /** * 获取三角形的顶点 * * Get the points of a triangle * @param width - 宽度 | width * @param height - 高度 | height * @param direction - 三角形的方向 | The direction of the triangle * @returns 矩形的顶点 | The points of a rectangle */ export function getTrianglePoints(width: number, height: number, direction: TriangleDirection): Point[] { const halfHeight = height / 2; const halfWidth = width / 2; const MAP: Record = { up: [ [-halfWidth, halfHeight], [halfWidth, halfHeight], [0, -halfHeight], ], left: [ [-halfWidth, 0], [halfWidth, halfHeight], [halfWidth, -halfHeight], ], right: [ [-halfWidth, halfHeight], [-halfWidth, -halfHeight], [halfWidth, 0], ], down: [ [-halfWidth, -halfHeight], [halfWidth, -halfHeight], [0, halfHeight], ], }; return MAP[direction] || MAP['up']; } /** * 获取三角形的连接桩 * * Get the Ports of Triangle. * @param width - 宽度 | width * @param height - 高度 | height * @param direction - 三角形的方向 | The direction of the triangle * @returns 三角形的连接桩 | The Ports of Triangle */ export function getTrianglePorts(width: number, height: number, direction: TriangleDirection): Record { const halfHeight = height / 2; const halfWidth = width / 2; const ports: Record = {}; if (direction === 'down') { ports['bottom'] = ports['default'] = [0, halfHeight]; ports['right'] = [halfWidth, -halfHeight]; ports['left'] = [-halfWidth, -halfHeight]; } else if (direction === 'left') { ports['top'] = [halfWidth, -halfHeight]; ports['bottom'] = [halfWidth, halfHeight]; ports['left'] = ports['default'] = [-halfWidth, 0]; } else if (direction === 'right') { ports['top'] = [-halfWidth, -halfHeight]; ports['bottom'] = [-halfWidth, halfHeight]; ports['right'] = ports['default'] = [halfWidth, 0]; } else { //up ports['left'] = [-halfWidth, halfHeight]; ports['top'] = ports['default'] = [0, -halfHeight]; ports['right'] = [halfWidth, halfHeight]; } return ports; } /** * 获取矩形的顶点 * * Get the points of a rectangle * @param width - 宽度 | width * @param height - 高度 | height * @returns 矩形的顶点 | The points of a rectangle */ export function getBoundingPoints(width: number, height: number): Point[] { return [ [width / 2, -height / 2], [width / 2, height / 2], [-width / 2, height / 2], [-width / 2, -height / 2], ]; } /** * Get Diamond PathArray. * @param width - diamond width * @param height - diamond height * @returns The PathArray for G */ export function getDiamondPoints(width: number, height: number): Point[] { return [ [0, -height / 2], [width / 2, 0], [0, height / 2], [-width / 2, 0], ]; } /** * 元素是否可见 * * Whether the element is visible * @param element - 元素 | element * @returns 是否可见 | whether the element is visible */ export function isVisible(element: DisplayObject) { return get(element, ['style', 'visibility']) !== 'hidden'; } /** * 设置元素属性(优化性能) * * Set element attributes (optimize performance) * @param element - 元素 | element * @param style - 样式 | style */ export function setAttributes(element: BaseShape, style: Partial & Record) { const { zIndex, transform, transformOrigin, visibility, cursor, clipPath, component, ...rest } = style; Object.assign(element.attributes, rest); if (transform) element.setAttribute('transform', transform); if (isNumber(zIndex)) element.setAttribute('zIndex', zIndex); if (transformOrigin) element.setAttribute('transformOrigin', transformOrigin); if (visibility) element.setAttribute('visibility', visibility); if (cursor) element.setAttribute('cursor', cursor); if (clipPath) element.setAttribute('clipPath', clipPath); if (component) element.setAttribute('component', component); } /** * 更新图形样式 * * Update shape style * @param shape - 图形 | shape * @param style - 样式 | style */ export function updateStyle(shape: T, style: Record) { if ('update' in shape) (shape.update as (style: Record) => void)(style); else shape.attr(style); } /** * Get Hexagon PathArray * @param outerR - 外接圆半径 | the radius of circumscribed circle * @returns The PathArray for G */ export function getHexagonPoints(outerR: number): Point[] { return [ [0, outerR], [(outerR * Math.sqrt(3)) / 2, outerR / 2], [(outerR * Math.sqrt(3)) / 2, -outerR / 2], [0, -outerR], [(-outerR * Math.sqrt(3)) / 2, -outerR / 2], [(-outerR * Math.sqrt(3)) / 2, outerR / 2], ]; } /** * 将图形标记为即将销毁,用于在 element controller 中识别要销毁的元素 * * Mark the element as to be destroyed, used to identify the element to be destroyed in the element controller * @param element - 图形 | element */ export function markToBeDestroyed(element: DisplayObject) { set(element, '__to_be_destroyed__', true); } /** * 判断图形是否即将销毁 * * Determine whether the element is to be destroyed * @param element - 图形 | element * @returns 是否即将销毁 | whether the element is to be destroyed */ export function isToBeDestroyed(element: DisplayObject | unknown) { return get(element, '__to_be_destroyed__', false); } ================================================ FILE: packages/g6/src/utils/event/events.ts ================================================ import type { IAnimation } from '@antv/g'; import type { AnimationType, GraphEvent } from '../../constants'; import type { ElementDatum, ElementType, IAnimateEvent, IElementLifeCycleEvent, IGraphLifeCycleEvent, IViewportEvent, TransformOptions, } from '../../types'; export class BaseEvent { constructor(public type: string) {} } export class GraphLifeCycleEvent extends BaseEvent implements IGraphLifeCycleEvent { constructor( type: | GraphEvent.BEFORE_RENDER | GraphEvent.AFTER_RENDER | GraphEvent.BEFORE_DRAW | GraphEvent.AFTER_DRAW | GraphEvent.BEFORE_LAYOUT | GraphEvent.AFTER_LAYOUT | GraphEvent.BEFORE_STAGE_LAYOUT | GraphEvent.AFTER_STAGE_LAYOUT | GraphEvent.BEFORE_SIZE_CHANGE | GraphEvent.AFTER_SIZE_CHANGE | GraphEvent.BATCH_START | GraphEvent.BATCH_END | GraphEvent.BEFORE_DESTROY | GraphEvent.AFTER_DESTROY, public data?: any, ) { super(type); } } export class AnimateEvent extends BaseEvent implements IAnimateEvent { constructor( type: GraphEvent.BEFORE_ANIMATE | GraphEvent.AFTER_ANIMATE, public animationType: AnimationType, public animation: IAnimation | null, public data?: any, ) { super(type); } } export class ElementLifeCycleEvent extends BaseEvent implements IElementLifeCycleEvent { constructor( type: | GraphEvent.BEFORE_ELEMENT_CREATE | GraphEvent.AFTER_ELEMENT_CREATE | GraphEvent.BEFORE_ELEMENT_UPDATE | GraphEvent.AFTER_ELEMENT_UPDATE | GraphEvent.BEFORE_ELEMENT_DESTROY | GraphEvent.AFTER_ELEMENT_DESTROY, public elementType: ElementType, public data: ElementDatum, ) { super(type); } } export class ViewportEvent extends BaseEvent implements IViewportEvent { constructor( type: GraphEvent.BEFORE_TRANSFORM | GraphEvent.AFTER_TRANSFORM, public data: TransformOptions, ) { super(type); } } ================================================ FILE: packages/g6/src/utils/event/index.ts ================================================ import type EventEmitter from '@antv/event-emitter'; import type { DisplayObject } from '@antv/g'; import { Document } from '@antv/g'; import { Target } from '../../types'; import { isCombo, 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 * @remarks * 事件响应大多数情况会命中元素的内部图形,通过该方法可以获取到其所属元素 * * 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 }; if (isCombo(element)) return { type: 'combo', element }; element = element.parentElement as DisplayObject | null; } return null; } ================================================ FILE: packages/g6/src/utils/extension.ts ================================================ import type { STDExtensionOption } from '../registry/extension/types'; import type { Graph } from '../runtime/graph'; import type { TransformOptions } from '../spec/transform'; /** * 将模块配置项转换为标准模块格式 * * Convert extension options to standard format * @param graph - 图实例 graph instance * @param category - 模块类型 extension type * @param extensions - 模块配置项 extension options * @returns 标准模块配置项 Standard extension options */ export function parseExtensions(graph: Graph, category: string, extensions: TransformOptions): STDExtensionOption[] { const counter: Record = {}; const getKey = (type: string) => { if (!(type in counter)) counter[type] = 0; return `${category}-${type}-${counter[type]++}`; }; return extensions.map((extension) => { if (typeof extension === 'string') { return { type: extension, key: getKey(extension) }; } if (typeof extension === 'function') { return extension.call(graph); } if (extension.key) return extension; return { ...extension, key: getKey(extension.type!) }; }) as STDExtensionOption[]; } ================================================ FILE: packages/g6/src/utils/graphlib.ts ================================================ import type { Edge, Graph as Graphlib, Node } from '@antv/graphlib'; import { TREE_KEY } from '../constants'; import type { ComboData, EdgeData, NodeData } from '../spec'; import { NodeLikeData } from '../types/data'; import { idOf } from './id'; import { isEdgeData } from './is'; export function toGraphlibData(datums: EdgeData): Edge; export function toGraphlibData(datums: NodeLikeData): Node; /** * 将 NodeData、EdgeData、ComboData 转换为 graphlib 的数据结构 * * Transform NodeData, EdgeData, ComboData to graphlib data structure * @param data - 节点、边、combo 数据 | node, combo data * @returns graphlib 数据 | graphlib data */ export function toGraphlibData(data: NodeData | EdgeData | ComboData): Node | Edge { const { id = idOf(data), style, data: customData, ...rest } = data; const _data = { ...data, style: { ...style }, data: { ...customData } }; if (isEdgeData(data)) return { id, data: _data, ...rest } as Edge; return { id, data: _data } as Node; } export function toG6Data(data: Edge): T; export function toG6Data(data: Node): T; /** * 将 Node、Edge、Combo 转换为 G6 的数据结构 * * Transform Node, Edge, Combo to G6 data structure * @param data - graphlib 节点、边、Combo 数据 | graphlib node, edge, combo data * @returns G6 数据 | G6 data */ export function toG6Data(data: Node | Edge): T { return data.data; } /** * 创建树形结构 * * Create tree structure * @param model - 数据模型 | data model */ export function createTreeStructure(model: Graphlib) { if (model.hasTreeStructure(TREE_KEY)) return; model.attachTreeStructure(TREE_KEY); const edges = model.getAllEdges(); for (const edge of edges) { const { source, target } = edge; model.setParent(target, source, TREE_KEY); } } ================================================ FILE: packages/g6/src/utils/id.ts ================================================ import type { ComboData, EdgeData, GraphData, NodeData } from '../spec'; import type { DataID, ID } from '../types'; import { format } from './print'; /** * 获取节点/边/Combo 的 ID * * get the id of node/edge/combo * @param data - 节点/边/Combo 的数据 | data of node/edge/combo * @returns 节点/边/Combo 的 ID | ID of node/edge/combo */ export function idOf(data: Partial): ID { if (data.id !== undefined) return data.id; if (data.source !== undefined && data.target !== undefined) return `${data.source}-${data.target}`; throw new Error(format('The datum does not have available id.')); } /** * 获取节点/Combo 的父节点 ID * * get the parent id of node/combo * @param data - 节点/Combo 的数据 | data of node/combo * @returns 节点/Combo 的父节点 ID | parent id of node/combo */ export function parentIdOf(data: Partial) { return data.combo; } export function idsOf(data: GraphData, flat: true): ID[]; export function idsOf(data: GraphData, flat: false): DataID; /** * 获取图数据中所有节点/边/Combo 的 ID * * Get the IDs of all nodes/edges/combos in the graph data * @param data - 图数据 | graph data * @param flat - 是否扁平化返回 | Whether to return flat * @returns - 返回元素 ID 数组 | Returns an array of element IDs */ export function idsOf(data: GraphData, flat: boolean): ID[] | DataID { const ids = { nodes: (data.nodes || []).map(idOf), edges: (data.edges || []).map(idOf), combos: (data.combos || []).map(idOf), }; return flat ? Object.values(ids).flat() : ids; } ================================================ FILE: packages/g6/src/utils/is.ts ================================================ import type { EdgeData } from '../spec'; import type { ElementDatum, Point, Vector2, Vector3 } from '../types'; /** * 判断是否为边数据 * * judge whether the data is edge data * @param data - 元素数据 | element data * @returns - 是否为边数据 | whether the data is edge data */ export function isEdgeData(data: Partial): data is EdgeData { if ('source' in data && 'target' in data) return true; return false; } /** * 判断是否为二维向量 * * Judge whether the vector is 2d * @param vector - 向量 | vector * @returns 是否为二维向量 | whether the vector is 2d */ export function isVector2(vector: Point): vector is Vector2 { return vector.length === 2; } /** * 判断是否为三维向量 * * Judge whether the vector is 3d * @param vector - 向量 | vector * @returns 是否为三维向量 | whether the vector is 3d */ export function isVector3(vector: Point): vector is Vector3 { return vector.length === 3; } /** * 判断是否为点 * * Judge whether the point is valid * @param p - 点 | point * @returns 是否为点 | whether the point is valid */ export function isPoint(p: any): p is Point { if (p instanceof Float32Array) return true; if (Array.isArray(p) && (p.length === 2 || p.length === 3)) { return p.every((elem) => typeof elem === 'number'); } return false; } ================================================ FILE: packages/g6/src/utils/layout.ts ================================================ import { Edge, Graph as Graphlib, Node } from '@antv/graphlib'; import { deepMix, isNumber } from '@antv/util'; import { COMBO_KEY } from '../constants'; import { BaseLayout } from '../layouts/base-layout'; import { idOf } from './id'; import type { AntVGraphData, AntVLayout, LegacyAntVLayout, LegacyGraph } from '../layouts/types'; import type { RuntimeContext } from '../runtime/types'; import type { EdgeData, GraphData, NodeData } from '../spec/data'; import type { NodeStyle } from '../spec/element/node'; import type { LayoutOptions, STDLayoutOptions } from '../spec/layout'; import type { AdaptiveLayout, ID } from '../types'; import { parsePoint } from './point'; /** * 判断是否是 combo 布局 * * Determine if it is a combo layout * @param options - 布局配置项 | Layout options * @returns 是否是 combo 布局 | Whether it is a combo layout */ export function isComboLayout(options: STDLayoutOptions) { const { type } = options; if (['comboCombined', 'comboForce'].includes(type)) return true; if (type === 'antv-dagre' && options.sortByCombo) return true; return false; } /** * 判断是否是树图布局 * * Determine if it is a tree layout * @param options - 布局配置项 | Layout options * @returns 是否是树图布局 | Whether it is a tree layout */ export function isTreeLayout(options: STDLayoutOptions) { const { type } = options; return ['compact-box', 'mindmap', 'dendrogram', 'indented'].includes(type); } /** * 数据中是否指定了位置 * * Is the position specified in the data * @param data - 数据 | Data * @returns 是否指定了位置 | Is the position specified */ export function isPositionSpecified(data: Record) { return isNumber(data.x) && isNumber(data.y); } /** * 是否是前布局 * * Is pre-layout * @remarks * 前布局是指在初始化元素前计算布局,适用于一些布局需要提前计算位置的场景。 * * Pre-layout refers to calculating the layout before initializing the elements, which is suitable for some layouts that need to calculate the position in advance. * @param options - 布局配置项 | Layout options * @returns 是否是前布局 | Is it a pre-layout */ export function isPreLayout(options?: LayoutOptions) { return !Array.isArray(options) && options?.preLayout; } /** * 将 @antv/layout 布局适配为 G6 布局 * * Adapt @antv/layout layout to G6 layout * @param Ctor - 布局类 | Layout class * @param context - 运行时上下文 | Runtime context * @returns G6 布局类 | G6 layout class */ export function layoutAdapter( Ctor: new (options: Record) => AntVLayout, context: RuntimeContext, ): new (context: RuntimeContext, options?: Record) => BaseLayout { class AdaptLayout extends BaseLayout implements AdaptiveLayout { public instance: AntVLayout; public id: string; constructor(context: RuntimeContext, options?: Record) { super(context, options); this.instance = new Ctor({}); this.id = this.instance.id; if ('stop' in this.instance && 'tick' in this.instance) { const instance = this.instance; this.stop = instance.stop.bind(instance); this.tick = (iterations?: number) => { instance.tick(iterations); return this.getLayoutResult(instance); }; } } public async execute(model: GraphData, options?: STDLayoutOptions): Promise { await this.instance.execute( this.graphData2LayoutModel(model), this.transformOptions(deepMix({}, this.options, options)), ); return this.getLayoutResult(this.instance); } private graphData2LayoutModel(data: GraphData): AntVGraphData { const { nodes = [], edges = [], combos = [] } = data; return { nodes: [...nodes, ...combos], edges, }; } private transformOptions(options: STDLayoutOptions) { const isCombo = (id: string) => context.model.isCombo(id); const defaultNode = (datum: NodeData) => { const { style } = datum || {}; const parentId = 'combo' in (datum || {}) ? (datum.combo ?? null) : null; const id = idOf(datum); return { id, ...(isNumber(style?.x) ? { x: style.x } : {}), ...(isNumber(style?.y) ? { y: style.y } : {}), ...(isNumber(style?.z) ? { z: style.z } : {}), parentId, ...(isCombo(id) ? { isCombo: true } : {}), }; }; const defaultEdge = (datum: EdgeData) => ({ id: idOf(datum), source: datum.source, target: datum.target }); options.node = defaultNode; options.edge = defaultEdge; if (!('onTick' in options)) return options; const onTick = options.onTick as (data: GraphData) => void; options.onTick = (layout: AntVLayout) => onTick(this.getLayoutResult(layout)); return options; } private getLayoutResult(layout: AntVLayout): GraphData { const { model } = this.context; const result: GraphData = { nodes: [], edges: [], combos: [] }; layout.forEachNode((node) => { const nodeId = String(node.id); const target = model.isCombo(nodeId) ? result.combos : result.nodes; const style = { x: node.x, y: node.y, z: node.z ?? 0, ...(node.size ? { size: node.size } : {}) } as NodeStyle; target?.push({ id: nodeId, style }); }); layout.forEachEdge((edge) => { const style = { controlPoints: edge.points || [] }; result.edges!.push({ id: String(edge.id), source: String(edge.source), target: String(edge.target), style }); }); return result; } } return AdaptLayout; } /** * 将图布局结果转换为 G6 数据 * * Convert the layout result to G6 data * @param layoutMapping - 布局映射 | Layout mapping * @returns G6 数据 | G6 data */ export function layoutMapping2GraphData(layoutMapping: AntVGraphData): GraphData { const { nodes, edges = [] } = layoutMapping; const data: GraphData = { nodes: [], edges: [], combos: [] }; nodes.forEach((nodeLike) => { const target = nodeLike.data._isCombo ? data.combos : data.nodes; const { x, y, z = 0 } = nodeLike.data; target?.push({ id: nodeLike.id as ID, style: { x, y, z }, }); }); edges.forEach((edge) => { const { id, source, target, data: { points = [], controlPoints = points.slice(1, points.length - 1) }, } = edge; data.edges!.push({ id: id as ID, source: source as ID, target: target as ID, style: { /** * antv-dagre 返回 controlPoints,dagre 返回 points * antv-dagre returns controlPoints, dagre returns points */ ...(controlPoints?.length ? { controlPoints: controlPoints.map(parsePoint) } : {}), }, }); }); return data; } /** * 判断是否为 AntV Layout 1.x * * Determine if it is AntV Layout 1.x * @param Ctor - 布局类 | Layout class * @returns 是否为 AntV Layout 1.x | Whether it is AntV Layout 1.x */ export function isLegacyAntVLayout( Ctor: new (options: Record) => unknown, ): Ctor is new (options: Record) => LegacyAntVLayout { return !('forEachNode' in Ctor.prototype) && !('forEachEdge' in Ctor.prototype); } /** * 将 @antv/layout 布局适配为 G6 布局 * * Adapt @antv/layout layout to G6 layout * @param Ctor - 布局类 | Layout class * @param context - 运行时上下文 | Runtime context * @returns G6 布局类 | G6 layout class */ export function legacyLayoutAdapter( Ctor: new (options: Record) => LegacyAntVLayout, context: RuntimeContext, ): new (context: RuntimeContext, options?: Record) => BaseLayout { class AdaptLayout extends BaseLayout implements AdaptiveLayout { public instance: LegacyAntVLayout; public id: string; constructor(context: RuntimeContext, options?: Record) { super(context, options); this.instance = new Ctor({}); this.id = this.instance.id; if ('stop' in this.instance && 'tick' in this.instance) { const instance = this.instance; this.stop = instance.stop.bind(instance); this.tick = (iterations?: number) => { const tickResult = instance.tick?.(iterations); return layoutMapping2GraphData(tickResult); }; } } public async execute(model: GraphData, options?: STDLayoutOptions): Promise { return layoutMapping2GraphData( await this.instance.execute( this.graphData2LayoutModel(model), this.transformOptions(deepMix({}, this.options, options)), ), ); } private transformOptions(options: STDLayoutOptions) { if (!('onTick' in options)) return options; const onTick = options.onTick as (data: GraphData) => void; options.onTick = (data: AntVGraphData) => onTick(layoutMapping2GraphData(data)); return options; } private graphData2LayoutModel(data: GraphData): LegacyGraph { const { nodes = [], edges = [], combos = [] } = data; const nodesToLayout: Node[] = nodes.map((datum) => { const id = idOf(datum); const { data, style, combo, ...rest } = datum; const result = { id, data: { // grid 布局会直接读取 data[sortBy],兼容处理,需要避免用户 data 下使用 data, style 等字段 // The grid layout will directly read data[sortBy], compatible processing, need to avoid users using data, style and other fields under data ...data, data, // antv-dagre 会读取 data.parentId // antv-dagre will read data.parentId ...(combo ? { parentId: combo } : {}), style, ...rest, }, }; // 一些布局会从 data 中读取位置信息 if (style?.x) Object.assign(result.data, { x: style.x }); if (style?.y) Object.assign(result.data, { y: style.y }); if (style?.z) Object.assign(result.data, { z: style.z }); return result; }); const nodesIdMap = new Map(nodesToLayout.map((node) => [node.id, node])); const edgesToLayout = edges .filter((edge) => { const { source, target } = edge; return nodesIdMap.has(source) && nodesIdMap.has(target); }) .map((edge) => { const { source, target, data, style } = edge; return { id: idOf(edge), source, target, data: { ...data }, style: { ...style }, } as unknown as Edge; }); const combosToLayout: Node[] = combos.map((combo) => { return { id: idOf(combo), data: { _isCombo: true, ...combo.data }, style: { ...combo.style }, } as unknown as Node; }); const layoutModel = new Graphlib({ nodes: [...nodesToLayout, ...combosToLayout], edges: edgesToLayout, }); if (context.model.model.hasTreeStructure(COMBO_KEY)) { layoutModel.attachTreeStructure(COMBO_KEY); // 同步层级关系 / Synchronize hierarchical relationships nodesToLayout.forEach((node) => { const parent = context.model.model.getParent(node.id, COMBO_KEY); if (parent && layoutModel.hasNode(parent.id)) { layoutModel.setParent(node.id, parent.id, COMBO_KEY); } }); } return layoutModel; } } return AdaptLayout; } /** * 调用布局成员方法 * * Call layout member methods * @remarks * 提供一种通用的调用方式来调用 G6 布局和 \@antv/layout 布局上的方法 * * Provide a common way to call methods on G6 layout and \@antv/layout layout * @param layout - 布局实例 | Layout instance * @param method - 方法名 | Method name * @param args - 参数 | Arguments * @returns 返回值 | Return value */ export function invokeLayoutMethod(layout: BaseLayout, method: string, ...args: unknown[]) { if (method in layout) { return (layout as any)[method](...args); } // invoke AdaptLayout method if ('instance' in layout) { const instance = (layout as any).instance; if (method in instance) return instance[method](...args); } return null; } /** * 获取布局成员属性 * * Get layout member properties * @param layout - 布局实例 | Layout instance * @param name - 属性名 | Property name * @returns 返回值 | Return value */ export function getLayoutProperty(layout: BaseLayout, name: string) { if (name in layout) return (layout as any)[name]; if ('instance' in layout) { const instance = (layout as any).instance; if (name in instance) return instance[name]; } return null; } ================================================ FILE: packages/g6/src/utils/line.ts ================================================ import type { Point } from '../types'; import { isBetween } from './math'; import { cross, subtract } from './vector'; export type LineSegment = [Point, Point]; /** * 判断两条线段是否平行 * * Judge whether two line segments are parallel * @param l1 - 第一条线段 | the first line segment * @param l2 - 第二条线段 | the second line segment * @returns 是否平行 | whether parallel or not */ export function isLinesParallel(l1: LineSegment, l2: LineSegment): boolean { const [p1, p2] = l1; const [p3, p4] = l2; const v1 = subtract(p1, p2); const v2 = subtract(p3, p4); return cross(v1, v2).every((v) => v === 0); } /** * 获取两条线段的交点 * * Get the intersection of two line segments * @param l1 - 第一条线段 | the first line segment * @param l2 - 第二条线段 | the second line segment * @param extended - 是否包含延长线上的交点 | whether to include the intersection on the extension line * @returns 交点 | intersection */ export function getLinesIntersection(l1: LineSegment, l2: LineSegment, extended = false): Point | undefined { if (isLinesParallel(l1, l2)) return undefined; const [p1, p2] = l1; const [p3, p4] = l2; const t = ((p1[0] - p3[0]) * (p3[1] - p4[1]) - (p1[1] - p3[1]) * (p3[0] - p4[0])) / ((p1[0] - p2[0]) * (p3[1] - p4[1]) - (p1[1] - p2[1]) * (p3[0] - p4[0])); const u = p4[0] - p3[0] ? (p1[0] - p3[0] + t * (p2[0] - p1[0])) / (p4[0] - p3[0]) : (p1[1] - p3[1] + t * (p2[1] - p1[1])) / (p4[1] - p3[1]); if (!extended && (!isBetween(t, 0, 1) || !isBetween(u, 0, 1))) return undefined; return [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])]; } ================================================ FILE: packages/g6/src/utils/math.ts ================================================ /** * 判断值是否在区间内 * * Judge whether the value is in the interval * @param value - 值 | value * @param min - 最小值 | minimum value * @param max - 最大值 | maximum value * @returns 是否在区间内 | whether in the interval */ export function isBetween(value: number, min: number, max: number): boolean { return value >= min && value <= max; } ================================================ FILE: packages/g6/src/utils/node.ts ================================================ import type { IconStyleProps } from '../elements/shapes'; import type { Size } from '../types'; import { parseSize } from './size'; /** * 如果没有手动指定图标大小,则根据主图形尺寸自动推断 * * Infer the icon size according to key size if icon size is not manually specified * @param size - 主图形尺寸 | Key size * @param iconStyle - 图标样式 | Icon style * @returns 图标样式 | Icon style */ export function inferIconStyle(size: Size, iconStyle: IconStyleProps): IconStyleProps { const stdSize = parseSize(size); let style = {}; if (iconStyle.text && !iconStyle.fontSize) style = { fontSize: Math.min(...stdSize) * 0.5 }; if (iconStyle.src && (!iconStyle.width || !iconStyle.height)) style = { width: stdSize[0] * 0.5, height: stdSize[1] * 0.5 }; return style; } ================================================ FILE: packages/g6/src/utils/padding.ts ================================================ import type { Padding, STDPadding } from '../types/padding'; /** * 解析 padding * * parse padding * @param padding - padding | padding * @returns 标准 padding | standard padding */ export function parsePadding(padding: Padding = 0): STDPadding { if (Array.isArray(padding)) { const [top = 0, right = top, bottom = top, left = right] = padding; return [top, right, bottom, left]; } return [padding, padding, padding, padding]; } /** * 获取在垂直方向上的 padding * * get vertical padding * @param padding - padding | padding * @returns 垂直方向上的 padding | vertical padding */ export function getVerticalPadding(padding: Padding = 0): number { const parsedPadding = parsePadding(padding); return parsedPadding[0] + parsedPadding[2]; } /** * 获取在水平方向上的 padding * * get horizontal padding * @param padding - padding | padding * @returns 水平方向上的 padding | horizontal padding */ export function getHorizontalPadding(padding: Padding = 0): number { const parsedPadding = parsePadding(padding); return parsedPadding[1] + parsedPadding[3]; } ================================================ FILE: packages/g6/src/utils/palette.ts ================================================ import { groupBy } from '@antv/util'; import type { CategoricalPalette } from '../palettes/types'; import { getExtension } from '../registry/get'; import type { PaletteOptions, STDPaletteOptions } from '../spec/element/palette'; import type { ID } from '../types'; import type { ElementData, ElementDatum } from '../types/data'; import { idOf } from './id'; import { format } from './print'; /** * 解析色板配置 * * Parse palette options * @param palette - 色板配置 | PaletteOptions options * @returns 标准色板配置 | Standard palette options */ export function parsePalette(palette?: PaletteOptions): STDPaletteOptions | undefined { if (!palette) return undefined; if ( // 色板名 palette name typeof palette === 'string' || // 插值函数 interpolate function typeof palette === 'function' || // 颜色数组 color array Array.isArray(palette) ) { // 默认为离散色板 // Default to discrete palette, default group field is id return { type: 'group', field: (d: any) => d.id, color: palette, invert: false, }; } return palette; } /** * 根据色板分配颜色 * * Assign colors according to the palette * @param data - 元素数据 | Element data * @param palette - 色板配置 | PaletteOptions options * @returns 元素颜色 | Element color * @remarks * 返回值结果是一个以元素 id 为 key,颜色值为 value 的对象 * * The return value is an object with element id as key and color value as value */ export function assignColorByPalette(data: ElementData, palette?: STDPaletteOptions) { if (!palette) return {}; const { type, color: colorPalette, field, invert } = palette; const assignColor = (args: [ID, number][]): Record => { const palette = typeof colorPalette === 'string' ? getExtension('palette', colorPalette) : colorPalette; if (typeof palette === 'function') { // assign by continuous const result: Record = {}; args.forEach(([id, value]) => { result[id] = palette(invert ? 1 - value : value); }); return result; } else if (Array.isArray(palette)) { // assign by discrete const colors = invert ? [...palette].reverse() : palette; const result: Record = {}; args.forEach(([id, index]) => { result[id] = colors[index % palette.length]; }); return result; } return {}; }; const parseField = (field: STDPaletteOptions['field'], datum: ElementDatum) => typeof field === 'string' ? datum.data?.[field] : field?.(datum); if (type === 'group') { const groupData = groupBy(data, (datum) => { if (!field) return 'default'; const key = parseField(field, datum); return key ? String(key) : 'default'; }); const groupKeys = Object.keys(groupData); const assignResult = assignColor(groupKeys.map((key, index) => [key, index])); const result: Record = {}; Object.entries(groupData).forEach(([groupKey, groupData]) => { groupData.forEach((datum) => { result[idOf(datum)] = assignResult[groupKey]; }); }); return result; } else if (type === 'value') { const [min, max] = data.reduce( ([min, max], datum) => { const value = parseField(field, datum); if (typeof value !== 'number') throw new Error(format(`Palette field ${field} is not a number`)); return [Math.min(min, value), Math.max(max, value)]; }, [Infinity, -Infinity], ); const range = max - min; return assignColor( data.map((datum) => [datum.id, ((parseField(field, datum) as number) - min) / range]) as [ID, number][], ); } } /** * 获取离散色板配色 * * Get discrete palette colors * @param colorPalette - 色板名或着颜色数组 | Palette name or color array * @returns 色板上具体颜色 | Specific color on the palette */ export function getPaletteColors(colorPalette?: string | CategoricalPalette): CategoricalPalette | undefined { const palette = typeof colorPalette === 'string' ? getExtension('palette', colorPalette) : colorPalette; if (typeof palette === 'function') return undefined; return palette; } ================================================ FILE: packages/g6/src/utils/path.ts ================================================ import type { PathArray, PathCommand } from '@antv/util'; import type { Point } from '../types'; /** * points 转化为 path 路径 * * points transform path. * @param points Point[] * @param isClose boolean * @returns path string[][] */ export function pointsToPath(points: Point[], isClose = true): PathArray { const path = []; points.forEach((point, index) => { path.push([index === 0 ? 'M' : 'L', ...point]); }); if (isClose) { path.push(['Z']); } return path as PathArray; } const PATH_COMMANDS: Record = { M: ['x', 'y'], m: ['dx', 'dy'], H: ['x'], h: ['dx'], V: ['y'], v: ['dy'], L: ['x', 'y'], l: ['dx', 'dy'], Z: [], z: [], C: ['x1', 'y1', 'x2', 'y2', 'x', 'y'], c: ['dx1', 'dy1', 'dx2', 'dy2', 'dx', 'dy'], S: ['x2', 'y2', 'x', 'y'], s: ['dx2', 'dy2', 'dx', 'dy'], Q: ['x1', 'y1', 'x', 'y'], q: ['dx1', 'dy1', 'dx', 'dy'], T: ['x', 'y'], t: ['dx', 'dy'], A: ['rx', 'ry', 'rotation', 'large-arc', 'sweep', 'x', 'y'], a: ['rx', 'ry', 'rotation', 'large-arc', 'sweep', 'dx', 'dy'], }; /** * 将路径字符串转换为路径段数组 * * Convert a path string to an array of path segments. * @param path - 路径字符串 | path string * @returns 路径段数组 | path segment array */ export function parsePath(path: string): PathArray { const items = path .replace(/[\n\r]/g, '') .replace(/-/g, ' -') .replace(/(\d*\.)(\d+)(?=\.)/g, '$1$2 ') .trim() .split(/\s*,|\s+/); const segments = []; let currentCommand = '' as PathCommand; let currentElement: Record = {}; while (items.length > 0) { let it = items.shift()!; if (it in PATH_COMMANDS) { currentCommand = it as PathCommand; } else { items.unshift(it); } currentElement = { type: currentCommand }; PATH_COMMANDS[currentCommand].forEach((prop) => { it = items.shift()!; // TODO sanity check currentElement[prop] = it; }); if (currentCommand === 'M') { currentCommand = 'L'; } else if (currentCommand === 'm') { currentCommand = 'l'; } const [type, ...values] = Object.values(currentElement); segments.push([type, ...values.map(Number)]); } return segments as unknown as PathArray; } /** * 将路径转换为点数组 * * Convert path to points array * @param path - 路径数组 path array * @returns */ export function pathToPoints(path: string | PathArray): Point[] { const points: Point[] = []; const segments = typeof path === 'string' ? parsePath(path) : path; segments.forEach((seg) => { const command = seg[0]; if (command === 'Z') { points.push(points[0]); return; } if (command !== 'A') { for (let i = 1; i < seg.length; i = i + 2) { points.push([seg[i] as number, seg[i + 1] as number, 0]); } } else { const length = seg.length; points.push([seg[length - 2] as number, seg[length - 1] as number, 0]); } }); return points; } /** * 生成平滑闭合曲线 * * Generate smooth closed curves * @param points - 点集 | points * @returns 平滑闭合曲线 | smooth closed curves */ export const getClosedSpline = (points: Point[]): PathArray => { if (points.length < 2) return [ ['M', 0, 0], ['L', 0, 0], ]; const first = points[0]; const second = points[1]; const last = points[points.length - 1]; const lastSecond = points[points.length - 2]; points.unshift(lastSecond, last); points.push(first, second); const closedPath = [['M', last[0], last[1]]]; for (let i = 1; i < points.length - 2; i += 1) { const [x0, y0] = points[i - 1]; const [x1, y1] = points[i]; const [x2, y2] = points[i + 1]; const [x3, y3] = i !== points.length - 2 ? points[i + 2] : [x2, y2]; const cp1x = x1 + (x2 - x0) / 6; const cp1y = y1 + (y2 - y0) / 6; const cp2x = x2 - (x3 - x1) / 6; const cp2y = y2 - (y3 - y1) / 6; closedPath.push(['C', cp1x, cp1y, cp2x, cp2y, x2, y2]); } return closedPath as PathArray; }; ================================================ FILE: packages/g6/src/utils/pinch.ts ================================================ import EventEmitter from '@antv/event-emitter'; import { CommonEvent } from '../constants'; import { IPointerEvent } from '../types'; /** * 表示指针位置的点坐标 * * Represents the coordinates of a pointer position */ export interface PointerPoint { x: number; y: number; pointerId: number; } /** * 捏合事件参数 * * Pinch event parameters * @remarks * 包含与捏合手势相关的参数,当前支持缩放比例,未来可扩展中心点坐标、旋转角度等参数 * * Contains parameters related to pinch gestures, currently supports scale factor, * can be extended with center coordinates, rotation angle etc. in the future */ export interface PinchEventOptions { /** * 缩放比例因子,>1 表示放大,<1 表示缩小 * * Scaling factor, >1 indicates zoom in, <1 indicates zoom out */ scale: number; } /** * 捏合手势阶段类型 * Pinch gesture phase type * @remarks * 包含三个手势阶段: * - start: 手势开始 * - move: 手势移动中 * - end: 手势结束 * * Contains three gesture phases: * - pinchstart: Gesture started * - pinchmove: Gesture in progress * - pinchend: Gesture ended */ export type PinchEvent = 'pinchstart' | 'pinchmove' | 'pinchend'; /** * 捏合手势回调函数类型 * * Pinch gesture callback function type * @param event - 原始指针事件对象 | Original pointer event object * @param options - 捏合事件参数对象 | Pinch event parameters object */ export type PinchCallback = (event: IPointerEvent, options: PinchEventOptions) => void; /** * 捏合手势处理器 * * Pinch gesture handler * @remarks * 处理双指触摸事件,计算缩放比例并触发回调。通过跟踪两个触摸点的位置变化,计算两点间距离变化率来确定缩放比例。 * * Handles two-finger touch events, calculates zoom ratio and triggers callbacks. Tracks position changes of two touch points to determine zoom ratio based on distance variation. */ export class PinchHandler { /** * 是否处于 Pinch 阶段 * * Whether it is in the Pinch stage */ public static isPinching: boolean = false; /** * 当前跟踪的触摸点集合 * * Currently tracked touch points collection */ private pointerByTouch: PointerPoint[] = []; /** * 初始两点间距离 * * Initial distance between two points */ private initialDistance: number | null = null; private emitter: EventEmitter; private static instance: PinchHandler | null = null; private static callbacks: { pinchstart: PinchCallback[]; pinchmove: PinchCallback[]; pinchend: PinchCallback[]; } = { pinchstart: [], pinchmove: [], pinchend: [] }; constructor( emitter: EventEmitter, private phase: PinchEvent, callback: PinchCallback, ) { this.emitter = emitter; if (PinchHandler.instance) { PinchHandler.callbacks[this.phase].push(callback); return PinchHandler.instance; } this.onPointerDown = this.onPointerDown.bind(this); this.onPointerMove = this.onPointerMove.bind(this); this.onPointerUp = this.onPointerUp.bind(this); this.bindEvents(); PinchHandler.instance = this; PinchHandler.callbacks[this.phase].push(callback); } private bindEvents() { const { emitter } = this; emitter.on(CommonEvent.POINTER_DOWN, this.onPointerDown); emitter.on(CommonEvent.POINTER_MOVE, this.onPointerMove); emitter.on(CommonEvent.POINTER_UP, this.onPointerUp); } /** * 更新指定指针的位置 * * Update position of specified pointer * @param pointerId - 指针唯一标识符 | Pointer unique identifier1 * @param x - 新的X坐标 | New X coordinate * @param y - 新的Y坐标 | New Y coordinate */ private updatePointerPosition(pointerId: number, x: number, y: number) { const index = this.pointerByTouch.findIndex((p) => p.pointerId === pointerId); if (index >= 0) { this.pointerByTouch[index] = { x, y, pointerId }; } } /** * 处理指针按下事件 * * Handle pointer down event * @param event - 指针事件对象 | Pointer event object * @remarks * 当检测到两个触摸点时记录初始距离 * * Record initial distance when detecting two touch points */ onPointerDown(event: IPointerEvent) { const { x, y } = event.client || {}; if (x === undefined || y === undefined) return; this.pointerByTouch.push({ x, y, pointerId: event.pointerId }); if (event.pointerType === 'touch' && this.pointerByTouch.length === 2) { PinchHandler.isPinching = true; const dx = this.pointerByTouch[0].x - this.pointerByTouch[1].x; const dy = this.pointerByTouch[0].y - this.pointerByTouch[1].y; this.initialDistance = Math.sqrt(dx * dx + dy * dy); PinchHandler.callbacks.pinchstart.forEach((cb) => cb(event, { scale: 0 })); } } /** * 处理指针移动事件 * * Handle pointer move event * @param event - 指针事件对象 | Pointer event object * @remarks * 当存在两个有效触摸点时计算缩放比例 * * Calculate zoom ratio when two valid touch points exist */ onPointerMove(event: IPointerEvent) { if (this.pointerByTouch.length !== 2 || this.initialDistance === null) return; const { x, y } = event.client || {}; if (x === undefined || y === undefined) return; this.updatePointerPosition(event.pointerId, x, y); const dx = this.pointerByTouch[0].x - this.pointerByTouch[1].x; const dy = this.pointerByTouch[0].y - this.pointerByTouch[1].y; const currentDistance = Math.sqrt(dx * dx + dy * dy); const ratio = currentDistance / this.initialDistance; PinchHandler.callbacks.pinchmove.forEach((cb) => cb(event, { scale: (ratio - 1) * 5 })); } /** * 处理指针抬起事件 * * Handle pointer up event * @param event * @remarks * 重置触摸状态和初始距离 * * Reset touch state and initial distance */ onPointerUp(event: IPointerEvent) { PinchHandler.callbacks.pinchend.forEach((cb) => cb(event, { scale: 0 })); PinchHandler.isPinching = false; this.initialDistance = null; this.pointerByTouch = []; PinchHandler.instance?.tryDestroy(); } /** * 销毁捏合手势相关监听 * * Destroy pinch gesture listeners * @remarks * 移除指针按下、移动、抬起事件的监听 * * Remove listeners for pointer down, move, and up events */ public destroy() { this.emitter.off(CommonEvent.POINTER_DOWN, this.onPointerDown); this.emitter.off(CommonEvent.POINTER_MOVE, this.onPointerMove); this.emitter.off(CommonEvent.POINTER_UP, this.onPointerUp); PinchHandler.instance = null; } /** * 解绑指定阶段的手势回调 * Unregister gesture callback for specific phase * @param phase - 手势阶段:开始(pinchstart)/移动(pinchmove)/结束(pinchend) | Gesture phase: start/move/end * @param callback - 要解绑的回调函数 | Callback function to unregister * @remarks * 从指定阶段的回调列表中移除特定回调,当所有回调都解绑后自动销毁事件监听 * Remove specific callback from the phase's callback list, auto-destroy event listeners when all callbacks are unregistered */ public off(phase: PinchEvent, callback: PinchCallback) { const index = PinchHandler.callbacks[phase].indexOf(callback); if (index > -1) PinchHandler.callbacks[phase].splice(index, 1); this.tryDestroy(); } /** * 尝试销毁手势处理器 * Attempt to destroy the gesture handler * @remarks * 当所有阶段(开始/移动/结束)的回调列表都为空时,执行实际销毁操作 * Perform actual destruction when all phase (pinchstart/pinchmove/pinchend) callback lists are empty * 自动解除事件监听并重置单例实例 * Automatically remove event listeners and reset singleton instance */ private tryDestroy() { if (Object.values(PinchHandler.callbacks).every((arr) => arr.length === 0)) { this.destroy(); } } } ================================================ FILE: packages/g6/src/utils/placement.ts ================================================ import type { Placement, RelativePlacement } from '../types'; import { isBetween } from './math'; /** * 解析位置 * * Parse position * @param placement - 位置 | placement * @returns 相对位置 | relative placement */ export function parsePlacement(placement: Placement): RelativePlacement { if (Array.isArray(placement)) { return isBetween(placement[0], 0, 1) && isBetween(placement[1], 0, 1) ? placement : [0.5, 0.5]; } const direction = placement.split('-'); const x = direction.includes('left') ? 0 : direction.includes('right') ? 1 : 0.5; const y = direction.includes('top') ? 0 : direction.includes('bottom') ? 1 : 0.5; return [x, y]; } ================================================ FILE: packages/g6/src/utils/point.ts ================================================ import type { AABB } from '@antv/g'; import { isEqual } from '@antv/util'; import type { Point, PointObject } from '../types'; import { getBBoxHeight, getBBoxWidth } from './bbox'; import type { LineSegment } from './line'; import { getLinesIntersection, isLinesParallel } from './line'; import { getXYByPlacement } from './position'; import { add, distance, divide, normalize, subtract, toVector2 } from './vector'; /** * 将对象坐标转换为数组坐标 * Convert object coordinates to array coordinates * @param point - 对象坐标 | object coordinates * @returns 数组坐标 | array coordinates */ export function parsePoint(point: PointObject): Point { return [point.x, point.y, point.z ?? 0]; } /** * 将数组坐标转换为对象坐标 * * Convert array coordinates to object coordinates * @param point - 数组坐标 | array coordinates * @returns 对象坐标 | object coordinates */ export function toPointObject(point: Point): PointObject { return { x: point[0], y: point[1], z: point[2] ?? 0 }; } /** * 根据 X 轴坐标排序 * Sort by X-axis coordinates * @param points - 点集 | point set * @returns 排序后的点集 | sorted point set */ export function sortByX(points: Point[]): Point[] { return points.sort((a, b) => a[0] - b[0] || a[1] - b[1]); } /** * 点集去重 * * Deduplicate point set * @param points - 点集 | pointset * @returns 去重后的点集 | deduplicated pointset */ export function deduplicate(points: Point[]): Point[] { const set = new Set(); return points.filter((p) => { const key = p.join(','); if (set.has(key)) return false; set.add(key); return true; }); } /** * 对点格式化,精确到 `digits` 位的数字 * * Round the point to the given precision * @param point - 要舍入的点 | the point to round * @param digits - 小数点后的位数 | the number of digits after the decimal point * @returns 舍入后的点 | the rounded point */ export function round(point: Point, digits = 0): Point { return point.map((p) => parseFloat(p.toFixed(digits))) as Point; } /** * 移动点,将点朝向参考点移动一定的距离 * * Move `p` point along the line starting from `ref` to this point by a certain `distance` * @param p - 要移动的点 | the point to move * @param ref - 参考点 | the reference point * @param distance - 移动的距离 | the distance to move * @param reverse * @returns 移动后的点 | the moved point */ export function moveTo(p: Point, ref: Point, distance: number, reverse = false): Point { if (isEqual(p, ref)) return p; const direction = reverse ? subtract(p, ref) : subtract(ref, p); const normalizedDirection = normalize(direction); const moveVector: Point = [normalizedDirection[0] * distance, normalizedDirection[1] * distance]; return add(toVector2(p), moveVector); } /** * 判断两个点是否在同一水平线上 * * whether two points are on the same horizontal line * @param p1 - 第一个点 | the first point * @param p2 - 第二个点 | the second point * @returns 返回两个点是否在同一水平线上 | is horizontal or not */ export function isHorizontal(p1: Point, p2: Point): boolean { return p1[1] === p2[1]; } /** * 判断两个点是否在同一垂直线上 * * whether two points are on the same vertical line * @param p1 - 第一个点 | the first point * @param p2 - 第二个点 | the second point * @returns 返回两个点是否在同一垂直线上 | is vertical or not */ export function isVertical(p1: Point, p2: Point): boolean { return p1[0] === p2[0]; } /** * 判断两个点是否正交,即是否在同一水平线或垂直线上 * * Judges whether two points are orthogonal, that is, whether they are on the same horizontal or vertical line * @param p1 - 第一个点 | the first point * @param p2 - 第二个点 | the second point * @returns 是否正交 | whether orthogonal or not */ export function isOrthogonal(p1: Point, p2: Point): boolean { return isHorizontal(p1, p2) || isVertical(p1, p2); } /** * 判断是否三点共线 * * Judge whether three points are collinear * @param p1 - 第一个点 | the first point * @param p2 - 第二个点 | the second point * @param p3 - 第三个点 | the third point * @returns 是否三点共线 | whether three points are collinear */ export function isCollinear(p1: Point, p2: Point, p3: Point): boolean { return isLinesParallel([p1, p2], [p2, p3]); } /** * 计算一个点相对于另一个点的中心对称点 * * Calculate the center symmetric point of a point relative to another point * @param p - 要计算的点 | the point to calculate * @param center - 中心点 | the center point * @returns 中心对称点 | the center symmetric point */ export function getSymmetricPoint(p: Point, center: Point): Point { return [2 * center[0] - p[0], 2 * center[1] - p[1]]; } /** * 获取从多边形中心到给定点的连线与多边形边缘的交点 * * Gets the intersection point between the line from the center of a polygon to a given point and the polygon's edge * @param p - 从多边形中心到多边形边缘的连线的外部点 | The point outside the polygon from which the line to the polygon's center is drawn * @param center - 多边形中心 | the center of the polygon * @param points - 多边形顶点 | the vertices of the polygon * @param isRelativePos - 顶点坐标是否相对中心点 | whether the vertex coordinates are relative to the center point * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点与相交线段 | intersection and intersecting line segment */ export function getPolygonIntersectPoint( p: Point, center: Point, points: Point[], isRelativePos = true, useExtendedLine = false, ): { point: Point; line?: LineSegment } { for (let i = 0; i < points.length; i++) { let start = points[i]; let end = points[(i + 1) % points.length]; if (isRelativePos) { start = add(center, start); end = add(center, end); } const refP = useExtendedLine ? getSymmetricPoint(p, center) : p; const intersect = getLinesIntersection([center, refP], [start, end]); if (intersect) { return { point: intersect, line: [start, end], }; } } return { point: center, line: undefined, }; } /** * 判断点是否在多边形内部 * * Whether point is inside the polygon (ray algo) * @param point - 点 | point * @param points - 多边形顶点 | polygon vertices * @param start - 起始索引 | start index * @param end - 结束索引 | end index * @returns 是否在多边形内部 | whether inside the polygon */ export function isPointInPolygon(point: Point, points: Point[], start?: number, end?: number): boolean { const x = point[0]; const y = point[1]; let inside = false; if (start === undefined) start = 0; if (end === undefined) end = points.length; const len = end - start; for (let i = 0, j = len - 1; i < len; j = i++) { const xi = points[i + start][0]; const yi = points[i + start][1]; const xj = points[j + start][0]; const yj = points[j + start][1]; const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } /** * 获取给定点到矩形中心的连线与矩形边缘的交点 * * Gets the intersection point between the line from the center of a rectangle to a given point and the rectangle's edge * @param p - 从矩形中心到矩形边缘的连线的外部点 | The point outside the rectangle from which the line to the rectangle's center is drawn * @param bbox - 矩形包围盒 | the bounding box of the rectangle * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点 | intersection */ export function getRectIntersectPoint(p: Point, bbox: AABB, useExtendedLine = false): Point { const center = getXYByPlacement(bbox, 'center'); const corners = [ getXYByPlacement(bbox, 'left-top'), getXYByPlacement(bbox, 'right-top'), getXYByPlacement(bbox, 'right-bottom'), getXYByPlacement(bbox, 'left-bottom'), ]; return getPolygonIntersectPoint(p, center, corners, false, useExtendedLine).point; } /** * 获取给定点到椭圆中心的连线与椭圆边缘的交点 * * Gets the intersection point between the line from the center of an ellipse to a given point and the ellipse's edge * @param p - 从椭圆中心到椭圆边缘的连线的外部点 | The point outside the ellipse from which the line to the ellipse's center is drawn * The point outside the ellipse from which the line to the ellipse's center is drawn. * @param bbox - 椭圆包围盒 | the bounding box of the ellipse * @param useExtendedLine - 是否使用延长线 | whether to use the extended line * @returns 交点 | intersection */ export function getEllipseIntersectPoint(p: Point, bbox: AABB, useExtendedLine = false): Point { const center = bbox.center; const refP = useExtendedLine ? getSymmetricPoint(p, center) : p; const vec = subtract(refP, bbox.center); const angle = Math.atan2(vec[1], vec[0]); if (isNaN(angle)) return center; const rx = getBBoxWidth(bbox) / 2; const ry = getBBoxHeight(bbox) / 2; const intersectX = center[0] + rx * Math.cos(angle); const intersectY = center[1] + ry * Math.sin(angle); return [intersectX, intersectY]; } /** * 从两组点中找到距离最近的两个点 * @param group1 - 第一组点 | the first group of points * @param group2 - 第二组点 | the second group of points * @returns 距离最近的两个点 | the nearest two points */ export function findNearestPoints(group1: Point[], group2: Point[]): [Point, Point] { let minDistance = Infinity; let nearestPoints: [Point, Point] = [group1[0], group2[0]]; group1.forEach((p1) => { group2.forEach((p2) => { const dist = distance(p1, p2); if (dist < minDistance) { minDistance = dist; nearestPoints = [p1, p2]; } }); }); return nearestPoints; } /** * 从一组线段中找到距离给定点最近的线段 * * Find the line segment closest to the given point from a group of line segments * @param point - 给定点 | the given point * @param lines - 一组线段 | a group of line segments * @returns 距离最近的线段 | the nearest line segment */ export function findNearestLine(point: Point, lines: LineSegment[]) { let minDistance = Infinity; let nearestLine: [Point, Point] = [ [0, 0], [0, 0], ]; lines.forEach((line) => { const distance = getDistanceToLine(point, line); if (distance < minDistance) { minDistance = distance; nearestLine = line; } }); return nearestLine; } /** * 获取点到线段的距离 * * Get the distance from a point to a line segment * @param point - 点 | the point * @param line - 线段 | the line segment * @returns 点到线段的距离 | the distance from the point to the line segment */ export function getDistanceToLine(point: Point, line: LineSegment) { const nearestPoint = findNearestPointOnLine(point, line); return distance(point, nearestPoint); } /** * 获取线段上距离给定点最近的点 * * Get the point on the line segment closest to the given point * @param point - 给定点 | the given point * @param line - 线段 | the line segment * @returns 线段上距离给定点最近的点 | the point on the line segment closest to the given point */ export function findNearestPointOnLine(point: Point, line: LineSegment): Point { const [x1, y1] = line[0]; const [x2, y2] = line[1]; const [x3, y3] = point; const px = x2 - x1; const py = y2 - y1; // 若线段实际上是一个点 | If the line segment is actually a point if (px === 0 && py === 0) { return [x1, y1]; } let u = ((x3 - x1) * px + (y3 - y1) * py) / (px * px + py * py); if (u > 1) { u = 1; } else if (u < 0) { u = 0; } const x = x1 + u * px; const y = y1 + u * py; return [x, y]; } /** * 获取点集的中心点 * * Get the center point of a set of points * @param points - 点集 | point set * @returns 中心点 | center point */ export function centerOf(points: Point[]): Point { const totalPosition = points.reduce((acc, p) => add(acc, p), [0, 0]); return divide(totalPosition, points.length); } /** * 按顺时针或逆时针方向对点集排序 * * Sort the point set in a clockwise or counterclockwise direction * @param points - 点集 | point set * @param clockwise - 是否顺时针 | whether clockwise * @returns 排序后的点集 | sorted point set */ export function sortByClockwise(points: Point[], clockwise = true): Point[] { const center = centerOf(points); return points.sort(([x1, y1], [x2, y2]) => { const angle1 = Math.atan2(y1 - center[1], x1 - center[0]); const angle2 = Math.atan2(y2 - center[1], x2 - center[0]); return clockwise ? angle2 - angle1 : angle1 - angle2; }); } /** * 给定的起点和终点,返回一个由这两个点和它们的对角点组成的数组 * @param start - 起点 | start point * @param end - 终点 | end point * @returns 由这两个点和它们的对角点组成的数组 | an array consisting of these two points and their diagonal points */ export function getBoundingPoints(start: Point, end: Point): Point[] { return [start, [start[0], end[1]], end, [end[0], start[1]]]; } ================================================ FILE: packages/g6/src/utils/polygon.ts ================================================ import type { AABB, TextStyleProps } from '@antv/g'; import type { PathArray } from '@antv/util'; import { isEqual } from '@antv/util'; import type { CardinalPlacement, Point } from '../types'; import { pathToPoints } from './path'; import { findNearestLine, findNearestPointOnLine } from './point'; import { getXYByPlacement } from './position'; /** * 计算文本位置样式 * * Calculate text position style * @param bounds - 外包围盒 | contour bounds * @param placement - 位置 | placement * @param offsetX - x轴偏移 | x-axis offset * @param offsetY - y轴偏移 | y-axis offset * @param closeToContour - 标签位置是否贴合轮廓 | whether the label position is close to the contour * @param path - 路径 | path * @param autoRotate - 是否跟随轮廓旋转 | whether to rotate with the contour * @returns 文本样式 | text style */ export function getPolygonTextStyleByPlacement( bounds: AABB, placement: CardinalPlacement | 'center', offsetX: number, offsetY: number, closeToContour: boolean, path: PathArray | string, autoRotate: boolean, ) { const [x, y] = getXYByPlacement(bounds, placement); const style: Partial = { textAlign: placement === 'left' ? 'right' : placement === 'right' ? 'left' : 'center', textBaseline: placement === 'top' ? 'bottom' : placement === 'bottom' ? 'top' : 'middle', transform: [['translate', x + offsetX, y + offsetY]], }; if (placement === 'center' || !closeToContour) return style; const points = pathToPoints(path); if (!points || points.length <= 3) return style; const lines = points .map((point, index) => { const p1 = point; const p2 = points[(index + 1) % points.length]; if (isEqual(p1, p2)) return null; return [p1, p2]; }) .filter(Boolean) as [Point, Point][]; const line = findNearestLine([x, y], lines); const intersection = findNearestPointOnLine([x, y], line); if (intersection && line) { style.transform = [['translate', intersection[0] + offsetX, intersection[1] + offsetY]]; if (autoRotate) { const angle = Math.atan((line[0][1] - line[1][1]) / (line[0][0] - line[1][0])); style.transform.push(['rotate', (angle / Math.PI) * 180]); style.textAlign = 'center'; if (placement === 'right' || placement === 'left') { if (angle > 0) { style.textBaseline = placement === 'right' ? 'bottom' : 'top'; } else { style.textBaseline = placement === 'right' ? 'top' : 'bottom'; } } } } return style; } ================================================ FILE: packages/g6/src/utils/position.ts ================================================ import type { AABB } from '@antv/g'; import type { Anchor, NodeLikeData, Placement, Point, RelativePlacement } from '../types'; import { parseAnchor } from './anchor'; import { parsePlacement } from './placement'; /** * 获取节点/ combo 的位置坐标 * * Get the position of node/combo * @param datum - 节点/ combo 的数据 | data of node/combo * @returns - 坐标 | position */ export function positionOf(datum: NodeLikeData): Point { const { x = 0, y = 0, z = 0 } = datum.style || {}; return [+x, +y, +z]; } /** * 检查数据是否有位置坐标 * * Check if the data has a position coordinate * @param datum - 节点/ combo 的数据 | data of node/combo * @returns - 是否有位置坐标 | Whether there is a position coordinate */ export function hasPosition(datum: NodeLikeData): boolean { const { x, y, z } = datum.style || {}; return x !== undefined || y !== undefined || z !== undefined; } /** * 获取相对位置坐标 * * Get position by relative placement * @param bbox - 元素包围盒 | element bounding box * @param placement - 相对于元素的位置 | Point relative to element * @returns - 坐标 | position */ export function getXYByRelativePlacement(bbox: AABB, placement: RelativePlacement): Point { const [x, y] = placement; const { min, max } = bbox; return [min[0] + x * (max[0] - min[0]), min[1] + y * (max[1] - min[1])]; } /** * 获取位置坐标 * * Get position by placement * @param bbox - 元素包围盒 | element bounding box * @param placement - 相对于元素的位置 | Point relative to element * @returns - 坐标 | position */ export function getXYByPlacement(bbox: AABB, placement: Placement = 'center'): Point { const relativePlacement = parsePlacement(placement); return getXYByRelativePlacement(bbox, relativePlacement); } /** * 获取锚点坐标 * * Get anchor position * @param bbox - 元素包围盒 | element bounding box * @param anchor - 锚点位置 | Anchor * @returns - 坐标 | position */ export function getXYByAnchor(bbox: AABB, anchor: Anchor): Point { const parsedAnchor = parseAnchor(anchor); return getXYByRelativePlacement(bbox, parsedAnchor as RelativePlacement); } /** * 通过 rect points 路径点获取 position 方位配置. * * The rect points command is used to obtain the position and orientation configuration. * @param points Points * @returns `{ left: number; right: number; top: number; bottom: number }` */ export const getPositionByRectPoints = (points: Point[]) => { const [p1, p2] = points; return { left: Math.min(p1[0], p2[0]), right: Math.max(p1[0], p2[0]), top: Math.min(p1[1], p2[1]), bottom: Math.max(p1[1], p2[1]), }; }; ================================================ FILE: packages/g6/src/utils/prefix.ts ================================================ import { lowerFirst, upperFirst } from '@antv/util'; import type { ReplacePrefix } from '../types'; /** * 是否以某个前缀开头 * * 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; const nextChart = str[prefix.length]; return nextChart >= 'A' && nextChart <= 'Z'; } /** * 添加前缀 * * 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 */ export function removePrefix(string: string, prefix?: string, lowercaseFirstLetter: boolean = true) { if (!prefix) return string; if (!startsWith(string, prefix)) return string; const str = string.slice(prefix.length); return lowercaseFirstLetter ? lowerFirst(str) : str; } /** * 从样式中提取子样式 * * Extract sub style from style * @param style - 样式 | style * @param prefix - 子样式前缀 | sub style prefix * @returns 子样式 | sub style */ export function subStyleProps>(style: object, prefix: string) { const subStyle = Object.entries(style).reduce((acc, [key, value]) => { if (key === 'className' || key === 'class') return acc; if (startsWith(key, prefix)) { Object.assign(acc, { [removePrefix(key, prefix)]: value }); } return acc; }, {} as T); // 向下传递透明度,但避免覆盖子样式中的透明度属性 // Pass down opacity, but avoid overwriting the opacity property in the sub-style if ('opacity' in style) { const subOpacityKey = addPrefix('opacity', prefix) as keyof typeof style; const opacity = style.opacity as number; if (subOpacityKey in style) { const subOpacity = style[subOpacityKey] as number; Object.assign(subStyle, { opacity: opacity * subOpacity }); } else Object.assign(subStyle, { opacity }); } return subStyle; } /** * 从对象中提取指定前缀的属性,并移除前缀 * * Extract properties with the specified prefix from the object and remove the prefix * @param obj - 对象 | object * @param prefix - 前缀 | prefix * @returns 新对象 | new object */ export function subObject(obj: Record, prefix: string): Record { const prefixLength = prefix.length; return Object.keys(obj).reduce( (acc, key) => { if (key.startsWith(prefix)) { const newKey = key.slice(prefixLength); acc[newKey] = obj[key]; } return acc; }, {} as Record, ); } /** * 从样式中排除子样式 * * Omit sub style from style * @param style - 样式 | style * @param prefix - 子样式前缀 | sub style prefix * @returns 排除子样式后的样式 | style without sub style */ export function omitStyleProps>(style: Record, prefix: string | string[]) { const prefixArray = typeof prefix === 'string' ? [prefix] : prefix; const omitStyle: Record = {}; Object.keys(style).forEach((key) => { if (!prefixArray.find((p) => key.startsWith(p))) { omitStyle[key] = style[key]; } }); return omitStyle as T; } /** * 替换前缀 * * 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( (acc, [key, value]) => { if (startsWith(key, oldPrefix)) { acc[addPrefix(removePrefix(key, oldPrefix, false), newPrefix) as keyof typeof acc] = value; } else { acc[key as keyof typeof acc] = value; } return acc; }, {} as ReplacePrefix, ); } ================================================ FILE: packages/g6/src/utils/print.ts ================================================ /* global console */ /* eslint no-console: "off" */ import { version } from '../version'; const BRAND = 'G6'; /** * 格式化打印 * * Format print * @param message - 消息 | Message * @returns 格式化后的消息 | Formatted message */ export function format(message: string) { return `[${BRAND} v${version}] ${message}`; } export const print = { mute: false, debug: (message: string): void => { !print.mute && console.debug(format(message)); }, info: (message: string): void => { !print.mute && console.info(format(message)); }, warn: (message: string): void => { !print.mute && console.warn(format(message)); }, error: (message: string): void => { !print.mute && console.error(format(message)); }, }; ================================================ FILE: packages/g6/src/utils/relation.ts ================================================ import type { Graph } from '../runtime/graph'; import type { EdgeDirection, ElementType, ID } from '../types'; import { idOf } from './id'; import { bfs } from './traverse'; /** * 获取指定元素在 n 度关系内的所有元素的 ID * * Get the IDs of all elements within an n-degree relationship from the specified element * @remarks * 对于节点,0 度关系是节点本身,1 度关系包括节点的直接相邻节点和边,以此类推。 * 对于边,0 度关系是边本身,1 度关系包括边本身及其两个端点,以此类推。 * * For a node, 0-degree relationship includes the node itself; 1-degree relationship includes the node's direct neighbors and their connecting edges, etc. * For an edge, 0-degree relationship includes the edge itself; 1-degree relationship includes the edge and its two endpoints, etc * @param graph - 图实例 | graph instance * @param elementType - 元素类型 | element type * @param elementId - 起始元素的 ID | start element ID * @param degree - 指定的度数 | the specified degree * @param direction - 边的方向 | edge direction * @returns - 返回节点和边的 ID 数组 | Returns an array of node and edge IDs */ export function getElementNthDegreeIds( graph: Graph, elementType: ElementType, elementId: ID, degree: number, direction: EdgeDirection = 'both', ): ID[] { if (elementType === 'combo' || elementType === 'node') { return getNodeNthDegreeIds(graph, elementId, degree, direction); } const edgeData = graph.getEdgeData(elementId); if (!edgeData) return []; const sourceRelations = getNodeNthDegreeIds(graph, edgeData.source, degree - 1, direction); const targetRelations = getNodeNthDegreeIds(graph, edgeData.target, degree - 1, direction); return Array.from(new Set([...sourceRelations, ...targetRelations, elementId])); } /** * 获取指定节点在 n 度关系内的所有元素的 ID * * Get all elements IDs within n-degree relationship of the specified node * @remarks * 节点的 0 度关系是节点本身,1 度关系是节点的直接相邻节点和边,以此类推 * @param direction * 0-degree relationship of a node is the node itself; 1-degree relationship is the node's neighboring nodes and related edges, etc * @param graph - 图实例 | graph instance * @param startNodeId - 起始节点的 ID | The ID of the starting node * @param degree - 指定的度数 | The specified degree * @param direction - 边的方向 | The direction of the edge * @returns - 返回节点和边的 ID 数组 | Returns an array of node and edge IDs */ export function getNodeNthDegreeIds( graph: Graph, startNodeId: ID, degree: number, direction: EdgeDirection = 'both', ): ID[] { const visitedNodes = new Set(); const visitedEdges = new Set(); const relations = new Set(); bfs( startNodeId, (nodeId: ID, depth: number) => { if (depth > degree) return; relations.add(nodeId); graph.getRelatedEdgesData(nodeId, direction).forEach((edge) => { const edgeId = idOf(edge); if (!visitedEdges.has(edgeId) && depth < degree) { relations.add(edgeId); visitedEdges.add(edgeId); } }); }, (nodeId: ID) => { return graph .getRelatedEdgesData(nodeId, direction) .map((edge) => (edge.source === nodeId ? edge.target : edge.source)) .filter((neighborNodeId) => { if (!visitedNodes.has(neighborNodeId)) { visitedNodes.add(neighborNodeId); return true; } return false; }); }, ); return Array.from(relations); } ================================================ FILE: packages/g6/src/utils/router/orth.ts ================================================ import type { AABB } from '@antv/g'; import { difference, isEqual } from '@antv/util'; import type { Node, OrthRouterOptions, Point } from '../../types'; import { getBBoxHeight, getBBoxWidth, getCombinedBBox, getNearestBoundaryPoint, getNearestBoundarySide, getNodeBBox, isPointBBoxCenter, isPointInBBox, isPointOnBBoxBoundary, isPointOutsideBBox, } from '../bbox'; import { isOrthogonal, moveTo, round } from '../point'; import { angle, distance, subtract, toVector2, toVector3 } from '../vector'; export type Direction = 'N' | 'S' | 'W' | 'E' | null; type Route = { points: Point[]; direction: Direction; }; const defaultOptions: OrthRouterOptions = { padding: 10, }; /** * 获取两点之间的正交线段路径 * * Get orthogonal line segments between two points * @param sourcePoint - 起始点 | start point * @param targetPoint - 终止点 | end point * @param sourceNode - 起始节点 | source node * @param targetNode - 终止节点 | target node * @param controlPoints - 控制点 | control points * @param options - 配置项 | options * @returns 路径点集 | vertices */ export function orth( sourcePoint: Point, targetPoint: Point, sourceNode: Node, targetNode: Node, controlPoints: Point[], options: OrthRouterOptions, ) { const { padding } = Object.assign(defaultOptions, options); const sourceBBox = getNodeBBox(sourceNode, padding); const targetBBox = getNodeBBox(targetNode, padding); const points: Point[] = [sourcePoint, ...controlPoints, targetPoint]; // direction of previous route segment let direction: Direction = null; const result: Point[] = []; for (let fromIdx = 0, len = points.length; fromIdx < len - 1; fromIdx++) { const toIdx = fromIdx + 1; const from = points[fromIdx]; const to = points[toIdx]; const isOrth = isOrthogonal(from, to); let route = null; if (fromIdx === 0) { if (toIdx === len - 1) { // source -> target if (sourceBBox.intersects(targetBBox)) { route = insideNode(from, to, sourceBBox, targetBBox); } else if (!isPointBBoxCenter(from, sourceBBox) && !isPointBBoxCenter(to, targetBBox)) { const fromWithPadding = getNearestBoundaryPoint(from, sourceBBox); const toWithPadding = getNearestBoundaryPoint(to, targetBBox); route = pointToPoint(fromWithPadding, toWithPadding, getDirection(fromWithPadding, toWithPadding)); route.points.unshift(fromWithPadding); route.points.push(toWithPadding); } else if (!isOrth) { route = nodeToNode(from, to, sourceBBox, targetBBox); } } else { // source -> point if (isPointInBBox(to, sourceBBox)) { route = insideNode(from, to, sourceBBox, getNodeBBox(to, padding), direction); } else if (!isOrth) { route = nodeToPoint(from, to, sourceBBox); } } } else if (toIdx === len - 1) { // point -> target if (isPointInBBox(from, targetBBox)) { route = insideNode(from, to, getNodeBBox(from, padding), targetBBox, direction); } else if (!isOrth) { route = pointToNode(from, to, targetBBox, direction); } } else if (!isOrth) { // point -> point route = pointToPoint(from, to, direction); } // set direction for next iteration if (route) { result.push(...route.points); direction = route.direction; } else { // orthogonal route and not looped direction = getDirection(from, to); } if (toIdx < len - 1) result.push(to); } return result.map(toVector2); } /** * Direction to opposites direction map */ const opposites = { N: 'S', S: 'N', W: 'E', E: 'W', }; /** * Direction to radians map */ const radians = { N: -Math.PI / 2, S: Math.PI / 2, E: 0, W: Math.PI, }; /** * 获取两点之间的方向,从 `from` 到 `to` 的方向 * * Get the direction between two points, the direction from `from` to `to` * @param from - 起始点 | start point * @param to - 终止点 | end point * @returns 方向 | direction */ export function getDirection(from: Point, to: Point): Direction | null { const [fx, fy] = from; const [tx, ty] = to; if (fx === tx) { return fy > ty ? 'N' : 'S'; } if (fy === ty) { return fx > tx ? 'W' : 'E'; } return null; } /** * 获取包围盒的尺寸,根据方向返回宽度或者高度 * * Get the size of the bounding box, return the width or height according to the direction * @param bbox - 包围盒 | bounding box * @param direction - 方向 | direction * @returns 尺寸 | size */ export function getBBoxSize(bbox: AABB, direction: Direction): number { return direction === 'N' || direction === 'S' ? getBBoxHeight(bbox) : getBBoxWidth(bbox); } /** * 从一个点到另一个点计算正交路由 * * Calculate orthogonal route from one point to another * @param from - 起始点 | start point * @param to - 终止点 | end point * @param direction - 前一条线段的方向 | direction of the previous segment * @returns 正交路由 | orthogonal route */ export function pointToPoint(from: Point, to: Point, direction: Direction): Route { const p1: Point = [from[0], to[1]]; const p2: Point = [to[0], from[1]]; const d1 = getDirection(from, p1); const d2 = getDirection(from, p2); const opposite = direction ? opposites[direction] : null; const p = d1 === direction || (d1 !== opposite && d2 !== direction) ? p1 : p2; return { points: [p], direction: getDirection(p, to) }; } /** * 从节点到点计算正交路由 * * Calculate orthogonal route from node to point * @param from - 起始点 | start point * @param to - 终止点 | end point * @param fromBBox - 起始节点的包围盒 | bounding box of the start node * @returns 正交路由 | orthogonal route */ export function nodeToPoint(from: Point, to: Point, fromBBox: AABB): Route { if (isPointBBoxCenter(from, fromBBox)) { const p = freeJoin(from, to, fromBBox); return { points: [p], direction: getDirection(p, to) }; } else { const fromWithPadding = getNearestBoundaryPoint(from, fromBBox); const isHorizontal = ['left', 'right'].includes(getNearestBoundarySide(from, fromBBox)); const p: Point = isHorizontal ? [to[0], fromWithPadding[1]] : [fromWithPadding[0], to[1]]; return { points: [p], direction: getDirection(p, to) }; } } /** * 从点到节点计算正交路由 * * Calculate orthogonal route from point to node * @param from - 起始点 | start point * @param to - 终止点 | end point * @param toBBox - 终止节点的包围盒 | bounding box of the end node * @param direction - 前一条线段的方向 | direction of the previous segment * @returns 正交路由 | orthogonal route */ export function pointToNode(from: Point, to: Point, toBBox: AABB, direction: Direction): Route { const toWithPadding = isPointBBoxCenter(to, toBBox) ? to : getNearestBoundaryPoint(to, toBBox); const points: Point[] = [ [toWithPadding[0], from[1]], [from[0], toWithPadding[1]], ]; const freePoints = points.filter((p) => isPointOutsideBBox(p, toBBox) && !isPointOnBBoxBoundary(p, toBBox, true)); const freeDirectionPoints = freePoints.filter((p) => getDirection(p, from) !== direction); if (freeDirectionPoints.length > 0) { // Pick a point which bears the same direction as the previous segment. const p = freeDirectionPoints.find((p) => getDirection(from, p) === direction) || freeDirectionPoints[0]; return { points: [p], direction: getDirection(p, to), }; } else { // Here we found only points which are either contained in the element or they would create // a link segment going in opposites direction from the previous one. // We take the point inside element and move it outside the element in the direction the // route is going. Now we can join this point with the current end (using freeJoin). const p = difference(points, freePoints)[0]; const p2 = moveTo(to, p, getBBoxSize(toBBox, direction) / 2); const p1 = freeJoin(p2, from, toBBox); return { points: [p1, p2], direction: getDirection(p2, to), }; } } /** * 从节点到节点计算正交路由 * * Calculate orthogonal route from node to node * @param from - 起始点 | start point * @param to - 终止点 | end point * @param fromBBox - 起始节点的包围盒 | bounding box of the start node * @param toBBox - 终止节点的包围盒 | bounding box of the end node * @returns 正交路由 | orthogonal route */ export function nodeToNode(from: Point, to: Point, fromBBox: AABB, toBBox: AABB): Route { let route = nodeToPoint(from, to, fromBBox); const p1 = toVector3(route.points[0]); if (isPointInBBox(p1, toBBox)) { route = nodeToPoint(to, from, toBBox); const p2 = toVector3(route.points[0]); if (isPointInBBox(p2, fromBBox)) { const fromBorder = moveTo(from, p1, getBBoxSize(fromBBox, getDirection(from, p1)) / 2); const toBorder = moveTo(to, p2, getBBoxSize(toBBox, getDirection(to, p2)) / 2); const midPoint: Point = [(fromBorder[0] + toBorder[0]) / 2, (fromBorder[1] + toBorder[1]) / 2]; const startRoute = nodeToPoint(from, midPoint, fromBBox); const endRoute = pointToNode(midPoint, to, toBBox, startRoute.direction); route.points = [startRoute.points[0], endRoute.points[0]]; route.direction = endRoute.direction; } } return route; } /** * 在两个节点内部计算路由 * * Calculate route inside two nodes * @param from - 起始点 | start point * @param to - 终止点 | end point * @param fromBBox - 起始节点的包围盒 | bounding box of the start node * @param toBBox - 终止节点的包围盒 | bounding box of the end node * @param direction - 方向 | direction * @returns 正交路由 | orthogonal route */ export function insideNode(from: Point, to: Point, fromBBox: AABB, toBBox: AABB, direction?: Direction): Route { const DEFAULT_OFFSET = 0.01; const boundary = getCombinedBBox([fromBBox, toBBox]); const reversed = distance(to, boundary.center) > distance(from, boundary.center); const [start, end] = reversed ? [to, from] : [from, to]; const halfPerimeter = getBBoxHeight(boundary) + getBBoxWidth(boundary); let p1: Point; if (direction) { const ref: Point = [ start[0] + halfPerimeter * Math.cos(radians[direction]), start[1] + halfPerimeter * Math.sin(radians[direction]), ]; // `getNearestBoundaryPoint` returns a point on the boundary, so we need to move it a bit to ensure it's outside the element and then get the correct `p2` via `freeJoin`. p1 = moveTo(getNearestBoundaryPoint(ref, boundary), ref, DEFAULT_OFFSET); } else { p1 = moveTo(getNearestBoundaryPoint(start, boundary), start, -DEFAULT_OFFSET); } let p2 = freeJoin(p1, end, boundary); let points = [round(p1, 2), round(p2, 2)]; if (isEqual(round(p1), round(p2))) { const rad = angle(subtract(p1, start), [1, 0, 0]) + Math.PI / 2; p2 = [end[0] + halfPerimeter * Math.cos(rad), end[1] + halfPerimeter * Math.sin(rad), 0]; p2 = round(moveTo(getNearestBoundaryPoint(p2, boundary), end, -DEFAULT_OFFSET), 2); const p3 = freeJoin(p1, p2, boundary); points = [p1, p3, p2]; } return { points: reversed ? points.reverse() : points, direction: reversed ? getDirection(p1, to) : getDirection(p2, to), }; } /** * 返回一个点 `p`,使得线段 p,p1 和 p,p2 互相垂直,p 尽可能不在给定的包围盒内 * * Returns a point `p` where lines p,p1 and p,p2 are perpendicular and p is not contained in the given box * @param p1 - 点 | point * @param p2 - 点 | point * @param bbox - 包围盒 | bounding box * @returns 点 | point */ export function freeJoin(p1: Point, p2: Point, bbox: AABB): Point { let p: Point = [p1[0], p2[1]]; if (isPointInBBox(p, bbox)) { p = [p2[0], p1[1]]; } return p; } ================================================ FILE: packages/g6/src/utils/router/shortest-path.ts ================================================ import { isNumber } from '@antv/util'; import type { Direction, ID, Node, Point, ShortestPathRouterOptions } from '../../types'; import { getBBoxSegments, getBBoxSize, getExpandedBBox, isPointInBBox, isPointOnBBoxBoundary } from '../bbox'; import { getLinesIntersection } from '../line'; import { add, manhattanDistance, toVector2 } from '../vector'; const defaultCfg: ShortestPathRouterOptions = { enableObstacleAvoidance: false, offset: 10, maxAllowedDirectionChange: Math.PI / 2, maximumLoops: 3000, gridSize: 5, startDirections: ['top', 'right', 'bottom', 'left'], endDirections: ['top', 'right', 'bottom', 'left'], directionMap: { right: { stepX: 1, stepY: 0 }, left: { stepX: -1, stepY: 0 }, bottom: { stepX: 0, stepY: 1 }, top: { stepX: 0, stepY: -1 }, }, penalties: { 0: 0, 90: 0 }, distFunc: manhattanDistance, }; const keyOf = (point: Point) => `${Math.round(point[0])}|||${Math.round(point[1])}`; function alignToGrid(p: Point, gridSize: number): Point; function alignToGrid(p: number, gridSize: number): number; /** * 将坐标对齐到网格 * * Align to the grid * @param p - 坐标 | point * @param gridSize - 网格大小 | grid size * @returns 对齐后的坐标 | aligned point */ function alignToGrid(p: number | Point, gridSize: number): number | Point { const align = (value: number) => Math.round(value / gridSize); if (isNumber(p)) return align(p); return p.map(align) as Point; } /** * 获取两个角度的变化方向,并确保小于 180 度 * * Get changed direction angle and make sure less than 180 degrees * @param angle1 - 第一个角度 | the first angle * @param angle2 - 第二个角度 | the second angle * @returns 两个角度的变化方向 | changed direction angle */ function getAngleDiff(angle1: number, angle2: number) { const directionChange = Math.abs(angle1 - angle2); return directionChange > Math.PI ? 2 * Math.PI - directionChange : directionChange; } /** * 获取从 p1 指向 p2 的向量与 x 轴正方向之间的夹角,单位为弧度 * * Get the angle between the vector from p1 to p2 and the positive direction of the x-axis, in radians * @param p1 - 点 p1 | point p1 * @param p2 - 点 p2 | point p2 * @returns 夹角 | angle */ function getDirectionAngle(p1: Point, p2: Point) { const deltaX = p2[0] - p1[0]; const deltaY = p2[1] - p1[1]; if (!deltaX && !deltaY) return 0; return Math.atan2(deltaY, deltaX); } /** * 获取两个点之间的方向变化 * * Get direction change between two points * @param current - 当前点 | current point * @param neighbor - 邻居点 | neighbor point * @param cameFrom - 来源点 | source point * @param scaleStartPoint - 缩放后的起点 | scaled start point * @returns 方向变化 | direction change */ function getDirectionChange( current: Point, neighbor: Point, cameFrom: Record, scaleStartPoint: Point, ): number { const directionAngle = getDirectionAngle(current, neighbor); const currentCameFrom = cameFrom[keyOf(current)]; const prev = !currentCameFrom ? scaleStartPoint : currentCameFrom; const prevDirectionAngle = getDirectionAngle(prev, current); return getAngleDiff(prevDirectionAngle, directionAngle); } /** * 获取障碍物地图 * * Get obstacle map * @param nodes - 图上所有节点 | all nodes on the graph * @param options - 路由配置 | router options * @returns 障碍物地图 | obstacle map */ const getObstacleMap = (nodes: Node[], options: Required) => { const { offset, gridSize } = options; const obstacleMap: Record = {}; nodes.forEach((item: Node) => { if (!item || item.destroyed || !item.isVisible()) return; const bbox = getExpandedBBox(item.getRenderBounds(), offset); for (let x = alignToGrid(bbox.min[0], gridSize); x <= alignToGrid(bbox.max[0], gridSize); x += 1) { for (let y = alignToGrid(bbox.min[1], gridSize); y <= alignToGrid(bbox.max[1], gridSize); y += 1) { obstacleMap[`${x}|||${y}`] = true; } } }); return obstacleMap; }; /** * 估算从起点到多个锚点的最小代价 * * Estimate minimum cost from the starting point to multiple anchor points * @param from - 起点 | source point * @param anchors - 锚点 | anchor points * @param distFunc - 距离函数 | distance function * @returns 最小成本 | minimum cost */ export function estimateCost(from: Point, anchors: Point[], distFunc: (p1: Point, p2: Point) => number) { return Math.min(...anchors.map((anchor) => distFunc(from, anchor))); } /** * 已知一个点集与一个参考点,从点集中找到距离参考点最近的点 * * Given a set of points and a reference point, find the point closest to the reference point from the set of points * @param points - 点集 | set of points * @param refPoint - 参考点 | reference point * @param distFunc - 距离函数 | distance function * @returns 最近的点 | nearest point */ export function getNearestPoint(points: Point[], refPoint: Point, distFunc: (p1: Point, p2: Point) => number): Point { let nearestPoint = points[0]; let minDistance = distFunc(points[0], refPoint); for (let i = 0; i < points.length; i++) { const point = points[i]; const dis = distFunc(point, refPoint); if (dis < minDistance) { nearestPoint = point; minDistance = dis; } } return nearestPoint; } /** * Calculate the connection points on the expanded BBox * @param point * @param node * @param directions * @param options */ const getBoxPoints = ( point: Point, node: Node, directions: Direction[], options: Required, ): Point[] => { // create-edge 生成边的过程中,endNode 为 null if (!node) return [point]; const { directionMap, offset } = options; const expandedBBox = getExpandedBBox(node.getRenderBounds(), offset); const points = (Object.keys(directionMap) as Direction[]).reduce((res, directionKey) => { if (directions.includes(directionKey)) { const direction = directionMap[directionKey]; const [width, height] = getBBoxSize(expandedBBox); const otherPoint: Point = [point[0] + direction.stepX * width, point[1] + direction.stepY * height]; const segments = getBBoxSegments(expandedBBox); for (let i = 0; i < segments.length; i++) { const intersectP = getLinesIntersection([point, otherPoint], segments[i]); if (intersectP && isPointOnBBoxBoundary(intersectP, expandedBBox)) { res.push(intersectP); } } } return res; }, []); if (!isPointInBBox(point, expandedBBox)) { points.push(point); } return points.map((point) => alignToGrid(point, options.gridSize)); }; const getControlPoints = ( current: Point, cameFrom: Record, scaleStartPoint: Point, endPoint: Point, startPoints: Point[], scaleEndPoint: Point, gridSize: number, ) => { const controlPoints: Point[] = []; // append endPoint let pointZero: Point = [ scaleEndPoint[0] === endPoint[0] ? endPoint[0] : current[0] * gridSize, scaleEndPoint[1] === endPoint[1] ? endPoint[1] : current[1] * gridSize, ]; controlPoints.unshift(pointZero); let _current = current; let _currentCameFrom = cameFrom[keyOf(_current)]; while (_currentCameFrom) { const prePoint = _currentCameFrom; const point = _current; const directionChange = getDirectionChange(prePoint, point, cameFrom, scaleStartPoint); if (directionChange) { pointZero = [ prePoint[0] === point[0] ? pointZero[0] : prePoint[0] * gridSize, prePoint[1] === point[1] ? pointZero[1] : prePoint[1] * gridSize, ]; controlPoints.unshift(pointZero); } _currentCameFrom = cameFrom[keyOf(prePoint)]; _current = prePoint; } // append startPoint const realStartPoints = startPoints.map((point) => [point[0] * gridSize, point[1] * gridSize] as Point); const startPoint = getNearestPoint(realStartPoints, pointZero, manhattanDistance); controlPoints.unshift(startPoint); return controlPoints; }; /** * Find the shortest path between a given source node to a destination node according to A* Search Algorithm:https://www.geeksforgeeks.org/a-search-algorithm/ * @param sourceNode - 源节点 | source node * @param targetNode - 目标节点 | target node * @param nodes - 图上所有节点 | all nodes on the graph * @param config - 路由配置 | router options * @returns 控制点数组 | control point array */ export function aStarSearch( sourceNode: Node, targetNode: Node, nodes: Node[], config: ShortestPathRouterOptions, ): Point[] { const startPoint = toVector2(sourceNode.getCenter()); const endPoint = toVector2(targetNode.getCenter()); const options = Object.assign(defaultCfg, config) as Required; const { gridSize } = options; const obstacles = options.enableObstacleAvoidance ? nodes : [sourceNode, targetNode]; const obstacleMap = getObstacleMap(obstacles, options); const scaleStartPoint = alignToGrid(startPoint, gridSize); const scaleEndPoint = alignToGrid(endPoint, gridSize); const startPoints = getBoxPoints(startPoint, sourceNode, options.startDirections, options); const endPoints = getBoxPoints(endPoint, targetNode, options.endDirections, options); startPoints.forEach((point) => delete obstacleMap[keyOf(point)]); endPoints.forEach((point) => delete obstacleMap[keyOf(point)]); const openList: Record = {}; const closedList: Record = {}; const cameFrom: Record = {}; // The movement cost to move from the starting point to the current point on the grid. const gScore: Record = {}; // The estimated movement cost to move from the starting point to the end point after passing through the current point. // f = g + h const fScore: Record = {}; const sortedOpenSet = new SortedArray(); for (let i = 0; i < startPoints.length; i++) { const firstStep = startPoints[i]; const key = keyOf(firstStep); openList[key] = firstStep; gScore[key] = 0; fScore[key] = estimateCost(firstStep, endPoints, options.distFunc); // Push start point to sortedOpenSet sortedOpenSet.add({ id: key, value: fScore[key], }); } const endPointsKeys = endPoints.map((point) => keyOf(point)); let remainLoops = options.maximumLoops; let current: Point; let curCost = Infinity; for (const [id, value] of Object.entries(openList)) { if (fScore[id] <= curCost) { curCost = fScore[id]; current = value; } } while (Object.keys(openList).length > 0 && remainLoops > 0) { const minId = sortedOpenSet.minId(false); if (minId) { current = openList[minId]; } else { break; } const key = keyOf(current); // If currentNode is final, return the successful path if (endPointsKeys.includes(key)) { return getControlPoints(current, cameFrom, scaleStartPoint, endPoint, startPoints, scaleEndPoint, gridSize); } // Set currentNode as closed delete openList[key]; sortedOpenSet.remove(key); closedList[key] = true; // Get the neighbor points of the next step for (const dir of Object.values(options.directionMap)) { const neighbor = add(current, [dir.stepX, dir.stepY]); const neighborId = keyOf(neighbor); if (closedList[neighborId]) continue; const directionChange = getDirectionChange(current, neighbor, cameFrom, scaleStartPoint); if (directionChange > options.maxAllowedDirectionChange) continue; if (obstacleMap[neighborId]) continue; // skip if intersects // Add neighbor points to openList, and calculate the cost of each neighbor point if (!openList[neighborId]) { openList[neighborId] = neighbor; } const directionPenalties = options.penalties[directionChange]; const neighborCost = options.distFunc(current, neighbor) + (isNaN(directionPenalties) ? gridSize : directionPenalties); const costFromStart = gScore[key] + neighborCost; const neighborGScore = gScore[neighborId]; if (neighborGScore && costFromStart >= neighborGScore) continue; cameFrom[neighborId] = current; gScore[neighborId] = costFromStart; fScore[neighborId] = costFromStart + estimateCost(neighbor, endPoints, options.distFunc); sortedOpenSet.add({ id: neighborId, value: fScore[neighborId], }); } remainLoops -= 1; } return []; } type Item = { id: string; value: number; }; /** * 有序数组,按升序排列 * * Sorted array, sorted in ascending order */ export class SortedArray { public arr: Item[] = []; private map: Record = {}; constructor() { this.arr = []; this.map = {}; } private _innerAdd(item: Item, length: number) { let low = 0, high = length - 1; while (high - low > 1) { const mid = Math.floor((low + high) / 2); if (this.arr[mid].value > item.value) { high = mid; } else if (this.arr[mid].value < item.value) { low = mid; } else { this.arr.splice(mid, 0, item); this.map[item.id] = true; return; } } this.arr.splice(high, 0, item); this.map[item.id] = true; } /** * 将新项添加到适当的索引位置 * * Add the new item to the appropriate index * @param item - 新项 | new item */ public add(item: Item) { // 已经存在,先移除 // If exists, remove it delete this.map[item.id]; const length = this.arr.length; // 如果为空或者最后一个元素小于当前元素,直接添加到最后 // If empty or the last element is less than the current element, add to the end if (!length || this.arr[length - 1].value < item.value) { this.arr.push(item); this.map[item.id] = true; return; } // 按照升序排列,找到合适的位置插入 // Find the appropriate position to insert in ascending order this._innerAdd(item, length); } public remove(id: string) { if (!this.map[id]) return; delete this.map[id]; } private _clearAndGetMinId() { let res; for (let i = this.arr.length - 1; i >= 0; i--) { if (this.map[this.arr[i].id]) res = this.arr[i].id; else this.arr.splice(i, 1); } return res; } private _findFirstId() { while (this.arr.length) { const first = this.arr.shift()!; if (this.map[first.id]) return first.id; } } public minId(clear: boolean) { if (clear) { return this._clearAndGetMinId(); } else { return this._findFirstId(); } } } ================================================ FILE: packages/g6/src/utils/scale.ts ================================================ /** * 将一个值从一个范围线性映射到另一个范围 * * Linearly maps a value from one range to another range * @param value - 需要映射的值 | The value to be mapped * @param domain - 输入值的范围 [最小值, 最大值] | The input range [min, max] * @param range - 输出值的范围 [最小值, 最大值] | The output range [min, max] * @returns 映射后的值 | The mapped value */ export const linear = (value: number, domain: [number, number], range: [number, number]) => { const [d0, d1] = domain; const [r0, r1] = range; if (d1 === d0) return r0; const ratio = (value - d0) / (d1 - d0); return r0 + ratio * (r1 - r0); }; /** * 将一个值从一个范围对数映射到另一个范围 * * Logarithmically maps a value from one range to another range * @param value - 需要映射的值 | The value to be mapped * @param domain - 输入值的范围 [最小值, 最大值] | The input range [min, max] * @param range - 输出值的范围 [最小值, 最大值] | The output range [min, max] * @returns 映射后的值 | The mapped value */ export const log = (value: number, domain: [number, number], range: [number, number]) => { const [d0, d1] = domain; const [r0, r1] = range; const ratio = Math.log(value - d0 + 1) / Math.log(d1 - d0 + 1); return r0 + ratio * (r1 - r0); }; /** * 将一个值从一个范围幂映射到另一个范围 * * Maps a value from one range to another range * @param value - 需要映射的值 | The value to be mapped * @param domain - 输入值的范围 [最小值, 最大值] | The input range [min, max] * @param range - 输出值的范围 [最小值, 最大值] | The output range [min, max] * @param exponent - 幂指数 | The exponent * @returns 映射后的值 | The mapped value */ export const pow = (value: number, domain: [number, number], range: [number, number], exponent: number = 2): number => { const [d0, d1] = domain; const [r0, r1] = range; const ratio = Math.pow((value - d0) / (d1 - d0), exponent); return r0 + ratio * (r1 - r0); }; /** * 将一个值从一个范围平方根映射到另一个范围 * * Maps a value from one range to another range using square root * @param value - 需要映射的值 | The value to be mapped * @param domain - 输入值的范围 [最小值, 最大值] | The input range [min, max] * @param range - 输出值的范围 [最小值, 最大值] | The output range [min, max] * @returns 映射后的值 | The mapped value */ export const sqrt = (value: number, domain: [number, number], range: [number, number]) => { const [d0, d1] = domain; const [r0, r1] = range; const ratio = Math.sqrt((value - d0) / (d1 - d0)); return r0 + ratio * (r1 - r0); }; ================================================ FILE: packages/g6/src/utils/shape.ts ================================================ import type { DisplayObject } from '@antv/g'; /** * 获取图形的所有子元素 * * Get all the child elements of the shape * @param shape - 图形元素 | shape * @returns 子元素数组 | child elements array */ export function getDescendantShapes(shape: T) { const succeeds: DisplayObject[] = []; // 遍历所有子元素,并将子元素的子元素加入到数组中 const traverse = (shape: DisplayObject) => { if (shape?.children.length) { (shape.children as DisplayObject[]).forEach((child) => { succeeds.push(child); traverse(child); }); } }; traverse(shape); return succeeds; } /** * 获取图形的所有祖先元素 * * Get all the ancestor elements of the shape * @param shape - 图形元素 | shape * @returns 祖先元素数组 | ancestor elements array */ export function getAncestorShapes(shape: T) { const ancestors: DisplayObject[] = []; let currentNode = shape.parentNode as DisplayObject; while (currentNode) { ancestors.push(currentNode); currentNode = currentNode.parentNode as DisplayObject; } return ancestors; } ================================================ FILE: packages/g6/src/utils/shortcut.ts ================================================ import EventEmitter from '@antv/event-emitter'; import type { FederatedMouseEvent } from '@antv/g'; import { isEqual, isString } from '@antv/util'; import { CommonEvent } from '../constants'; import type { PinchCallback } from './pinch'; import { PinchHandler } from './pinch'; export interface ShortcutOptions {} export type ShortcutKey = string[]; type Handler = (event: any) => void; const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Meta', 'Shift']); function isModifierKey(key: string) { return MODIFIER_KEYS.has(key); } const lowerCaseKeys = (keys: ShortcutKey) => keys.map((key) => (isString(key) ? key.toLocaleLowerCase() : key)); export class Shortcut { private map: Map = new Map(); public pinchHandler: PinchHandler | undefined; private boundHandlePinch: PinchCallback = () => {}; private emitter: EventEmitter; private recordKey = new Set(); constructor(emitter: EventEmitter) { this.emitter = emitter; this.bindEvents(); } public bind(key: ShortcutKey, handler: Handler) { if (key.length === 0) return; if (key.includes(CommonEvent.PINCH) && !this.pinchHandler) { this.boundHandlePinch = this.handlePinch.bind(this); this.pinchHandler = new PinchHandler(this.emitter, 'pinchmove', this.boundHandlePinch); } this.map.set(key, handler); } public unbind(key: ShortcutKey, handler?: Handler) { this.map.forEach((h, k) => { if (isEqual(k, key)) { if (!handler || handler === h) this.map.delete(k); } }); } public unbindAll() { this.map.clear(); } /** * Check whether the given keys are being held down currently. * @param key - array of keys to check * @returns true if the given keys are being held down, false otherwise. */ public match(key: ShortcutKey) { // 排序 const recordKeyList = lowerCaseKeys(Array.from(this.recordKey)).sort(); const keyList = lowerCaseKeys(key).sort(); return isEqual(recordKeyList, keyList); } private bindEvents() { const { emitter } = this; // These window listeners are added purely to listen to modifier keys at the window level, // and the key presses are only recorded into this.recordKey for the purpose of matching // in the match() function. This allows just these keypresses alone to be registered // before the canvas is focused, which prevents a problem where when shortcuts involving // a modifier and clicking on the canvas are bound, match() will return false for that // modifier key because the canvas has not been clicked (and therefore focused) yet. window.addEventListener(CommonEvent.KEY_DOWN, this.onKeyDownWindow); window.addEventListener(CommonEvent.KEY_UP, this.onKeyUpWindow); emitter.on(CommonEvent.KEY_DOWN, this.onKeyDown); emitter.on(CommonEvent.KEY_UP, this.onKeyUp); emitter.on(CommonEvent.WHEEL, this.onWheel); emitter.on(CommonEvent.DRAG, this.onDrag); // 窗口重新获得焦点后清空按键,避免按键状态异常 // Clear the keys when the window regains focus to avoid abnormal key states globalThis.addEventListener?.('focus', this.onFocus); } private onKeyDown = (event: KeyboardEvent) => { if (!event?.key) return; this.recordKey.add(event.key); this.trigger(event); }; private onKeyUp = (event: KeyboardEvent) => { if (!event?.key) return; this.recordKey.delete(event.key); }; private onKeyDownWindow = (event: KeyboardEvent) => { if (!isModifierKey(event.key)) return; this.recordKey.add(event.key); }; private onKeyUpWindow = (event: KeyboardEvent) => { if (!isModifierKey(event.key)) return; this.recordKey.delete(event.key); }; private trigger(event: KeyboardEvent) { this.map.forEach((handler, key) => { if (this.match(key)) handler(event); }); } /** * 扩展 wheel, drag 操作 * * Extend wheel, drag operations * @param eventType - event name * @param event - event */ private triggerExtendKey(eventType: CommonEvent, event: unknown) { this.map.forEach((handler, key) => { if (key.includes(eventType)) { if ( isEqual( Array.from(this.recordKey), key.filter((k) => k !== eventType), ) ) { handler(event); } } }); } private onWheel = (event: WheelEvent) => { this.triggerExtendKey(CommonEvent.WHEEL, event); }; private onDrag = (event: FederatedMouseEvent) => { this.triggerExtendKey(CommonEvent.DRAG, event); }; private handlePinch: PinchCallback = (event, options) => { this.triggerExtendKey(CommonEvent.PINCH, { ...event, ...options }); }; private onFocus = () => { this.recordKey.clear(); }; public destroy() { this.unbindAll(); this.emitter.off(CommonEvent.KEY_DOWN, this.onKeyDown); this.emitter.off(CommonEvent.KEY_UP, this.onKeyUp); window.removeEventListener(CommonEvent.KEY_DOWN, this.onKeyDownWindow); window.removeEventListener(CommonEvent.KEY_UP, this.onKeyUpWindow); this.emitter.off(CommonEvent.WHEEL, this.onWheel); this.emitter.off(CommonEvent.DRAG, this.onDrag); this.pinchHandler?.off('pinchmove', this.boundHandlePinch); globalThis.removeEventListener?.('focus', this.onFocus); } } ================================================ FILE: packages/g6/src/utils/size.ts ================================================ import type { STDSize, Size } from '../types'; /** * 解析尺寸配置 * * Parse size configuration * @param size - 尺寸配置 | size configuration * @returns 标准尺寸格式 | standard size format */ export function parseSize(size: Size = 0): STDSize { if (typeof size === 'number') return [size, size, size] as STDSize; const [x, y = x, z = x] = size; return [x, y, z]; } ================================================ FILE: packages/g6/src/utils/state.ts ================================================ import type { ElementDatum } from '../types'; /** * 获取元素的状态 * * Get the state of the element * @param datum - 元素数据 Element data * @returns 状态列表 State list */ export function statesOf(datum: ElementDatum) { return datum.states || []; } ================================================ FILE: packages/g6/src/utils/style.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { Graph } from '../runtime/graph'; import type { ElementDatum, StyleIterationContext } from '../types'; /** * 计算支持回调的动态样式 * * compute dynamic style that supports callback * @param callableStyle - 动态样式 | dynamic style * @param context - 样式计算迭代上下文 | style iteration context * @returns 静态样式 | static style */ export function computeElementCallbackStyle( callableStyle: | Record | ((this: Graph, datum: ElementDatum) => Record) | { [key: string]: (this: Graph, datum: ElementDatum) => unknown; }, context: StyleIterationContext, ) { const { datum, graph } = context; if (typeof callableStyle === 'function') return callableStyle.call(graph, datum); return Object.fromEntries( Object.entries(callableStyle).map(([key, style]) => { if (typeof style === 'function') return [key, style.call(graph, datum)]; return [key, style]; }), ); } /** * 合并图形配置项 * * Merge shape configuration * @param defaultOptions - 配置项1 | configuration 1 * @param modifiedOptions - 配置项2 | configuration 2 * @returns 合并后的配置项 | merged configuration */ export function mergeOptions( defaultOptions: DisplayObjectConfig, modifiedOptions: DisplayObjectConfig, ): DisplayObjectConfig { const s1 = defaultOptions?.style || {}; const s2 = modifiedOptions?.style || {}; for (const key in s1) { if (!(key in s2)) s2[key] = s1[key]; } return Object.assign({}, defaultOptions, modifiedOptions, { style: s2, }); } /** * 获取图形子图形样式 * * Get the style of the sub-shape of the shape * @param style - 图形样式 | shape style * @returns 子图形样式 | sub-shape style * @remarks * 从给定的属性对象中提取图形样式属性。删除特定的属性,如位置、变换和类名 * * Extracts the shape styles from a given attribute object. * Removes specific styles like position, transformation, and class name. */ export function getSubShapeStyle>( style: T, ): Omit { const { x, y, z, class: cls, className, transform, transformOrigin, zIndex, visibility, ...rest } = style; return rest; } ================================================ FILE: packages/g6/src/utils/symbol.ts ================================================ /* eslint-disable jsdoc/require-returns */ /* eslint-disable jsdoc/require-param */ import type { PathArray } from '@antv/util'; export type SymbolFactor = (width: number, height: number) => PathArray; /** * ○ */ export const circle: SymbolFactor = (width: number, height: number) => { const r = Math.max(width, height) / 2; return [['M', -width / 2, 0], ['A', r, r, 0, 1, 0, 2 * r - width / 2, 0], ['A', r, r, 0, 1, 0, -width / 2, 0], ['Z']]; }; /** * ▷ */ export const triangle: SymbolFactor = (width: number, height: number) => { return [['M', -width / 2, 0], ['L', width / 2, -height / 2], ['L', width / 2, height / 2], ['Z']]; }; /** * ◇ */ export const diamond: SymbolFactor = (width: number, height: number) => { return [['M', -width / 2, 0], ['L', 0, -height / 2], ['L', width / 2, 0], ['L', 0, height / 2], ['Z']]; }; /** * >> */ export const vee: SymbolFactor = (width: number, height: number) => { return [ ['M', -width / 2, 0], ['L', width / 2, -height / 2], ['L', (4 * width) / 5 - width / 2, 0], ['L', width / 2, height / 2], ['Z'], ]; }; /** * □ */ export const rect: SymbolFactor = (width: number, height: number) => { return [ ['M', -width / 2, -height / 2], ['L', width / 2, -height / 2], ['L', width / 2, height / 2], ['L', -width / 2, height / 2], ['Z'], ]; }; /** * □▷ */ export const triangleRect: SymbolFactor = (width: number, height: number) => { const tWidth = width / 2; const rWidth = width / 7; const rBeginX = width - rWidth; return [ ['M', -tWidth, 0], ['L', 0, -height / 2], ['L', 0, height / 2], ['Z'], ['M', rBeginX - tWidth, -height / 2], ['L', rBeginX + rWidth - tWidth, -height / 2], ['L', rBeginX + rWidth - tWidth, height / 2], ['L', rBeginX - tWidth, height / 2], ['Z'], ]; }; /** * > */ export const simple: SymbolFactor = (width: number, height: number) => { return [ ['M', width / 2, -height / 2], ['L', -width / 2, 0], ['L', width / 2, 0], ['L', -width / 2, 0], ['L', width / 2, height / 2], ]; }; ================================================ FILE: packages/g6/src/utils/text.ts ================================================ import { AABB } from '@antv/g'; import type { Point } from '../types'; import { distance } from './vector'; /** * Get WordWrapWidth for a text according the the length of the label and 'maxWidth'. * @param length - length * @param maxWidth - maxWidth * @returns wordWrapWidth */ export function getWordWrapWidthWithBase(length: number, maxWidth: string | number): number { let wordWrapWidth = 2 * length; if (typeof maxWidth === 'string') { wordWrapWidth = (length * Number(maxWidth.replace('%', ''))) / 100; } else if (typeof maxWidth === 'number') { wordWrapWidth = maxWidth; } if (isNaN(wordWrapWidth)) wordWrapWidth = 2 * length; return wordWrapWidth; } /** * Get the proper wordWrapWidth for a labelShape according the the 'maxWidth' of keyShape. * @param keyShapeBox - keyShapeBox * @param maxWidth - maxWidth * @param zoom - zoom * @param enableBalanceShape - enableBalanceShape * @returns Get WordWrapWidth by bbox */ export function getWordWrapWidthByBox( keyShapeBox: AABB, maxWidth: string | number, zoom = 1, enableBalanceShape = false, ): number { const balanceZoom = enableBalanceShape ? zoom : 1; const keyShapeWidth = (keyShapeBox.max[0] - keyShapeBox.min[0]) * balanceZoom; return getWordWrapWidthWithBase(keyShapeWidth, maxWidth); } /** * Get the proper wordWrapWidth for a labelShape according the the distance between two end points and 'maxWidth'. * @param points - points * @param maxWidth - maxWidth * @param zoom - zoom * @returns - wordWrapWidth for text */ export function getWordWrapWidthByEnds(points: [Point, Point], maxWidth: string | number, zoom = 1): number { const dist = distance(points[0], points[1]) * zoom; return getWordWrapWidthWithBase(dist, maxWidth); } ================================================ FILE: packages/g6/src/utils/theme.ts ================================================ import { ExtensionCategory } from '../constants'; import { getExtension } from '../registry/get'; import type { GraphOptions } from '../spec'; import { print } from './print'; /** * 获取主题配置 * * Get theme options * @param options - 图配置项 graph options * @returns 主题配置 theme options */ export function themeOf(options: GraphOptions) { const { theme } = options; if (!theme) return {}; const themeOptions = getExtension(ExtensionCategory.THEME, theme); if (themeOptions) return themeOptions; print.warn(`The theme of ${theme} is not registered.`); return {}; } ================================================ FILE: packages/g6/src/utils/transform.ts ================================================ import type { TransformArray } from '@antv/g'; import { isNumber } from '@antv/util'; /** * 从 transform 字符串中替换 translate 部分 * * replace the translate part from the transform string * @param x - x | x * @param y - y | y * @param z - z | z * @param transform - transform 字符串 | transform string * @returns 替换后的 transform 字符串,返回 null 表示无需替换 | the replaced transform string, return null means no need to replace */ export function replaceTranslateInTransform( x: number, y: number, z?: number, transform: string | TransformArray = [], ): string | TransformArray | null { if (!transform && x === 0 && y === 0 && z === 0) return null; if (Array.isArray(transform)) { let translateIndex = -1; const newTransform: TransformArray = []; for (let i = 0; i < transform.length; i++) { const t = transform[i]; if (t[0] === 'translate') { if (t[1] === x && t[2] === y) return null; translateIndex = i; newTransform.push(['translate', x, y]); } else if (t[0] === 'translate3d') { if (t[1] === x && t[2] === y && t[3] === z) return null; translateIndex = i; newTransform.push(['translate3d', x, y, z ?? 0]); } else { newTransform.push(t); } } if (translateIndex === -1) { newTransform.splice(0, 0, isNumber(z) ? ['translate3d', x, y, z ?? 0] : ['translate', x, y]); } if (newTransform.length === 0) return null; return newTransform; } const removedTranslate = transform ? transform.replace(/translate(3d)?\([^)]*\)/g, '') : ''; if (z === 0) { return `translate(${x}, ${y})${removedTranslate}`; } else { return `translate3d(${x}, ${y}, ${z})${removedTranslate}`; } } ================================================ FILE: packages/g6/src/utils/traverse.ts ================================================ export type HierarchyStructure = T & { children?: HierarchyStructure[]; }; /** * 执行深度优先遍历 * * perform depth first traversal * @param node - 起始节点 | start node * @param visitor - 访问节点函数 | visitor function * @param navigator - 获取子节点函数 | get children function * @param mode - 访问模式,BT: 自底向上访问,TB: 自顶向下访问 | traverse mode, BT: bottom to top, TB: top to bottom * @param depth - 当前深度 | current depth */ export function dfs( node: N, visitor: (node: N, depth: number) => void, navigator: (node: N) => N[] | undefined, mode: 'BT' | 'TB', depth: number = 0, ) { if (mode === 'TB') visitor(node, depth); const children = navigator(node); if (children) { for (const child of children) { dfs(child, visitor, navigator, mode, depth + 1); } } if (mode === 'BT') visitor(node, depth); } /** * 执行广度优先遍历 * * perform breadth first traversal * @param node - 起始节点 | start node * @param visitor - 访问节点函数 | visitor function * @param navigator - 获取子节点函数 | get children function */ export function bfs(node: N, visitor: (node: N, depth: number) => void, navigator: (node: N) => N[] | undefined) { const queue: [N, number][] = [[node, 0]]; while (queue.length) { const [current, depth] = queue.shift()!; visitor(current, depth); const children = navigator(current); if (children) { for (const child of children) { queue.push([child, depth + 1]); } } } } ================================================ FILE: packages/g6/src/utils/tree.ts ================================================ import type { EdgeData, GraphData, NodeData } from '../spec'; import type { TreeData } from '../types'; import { dfs } from './traverse'; type TreeDataGetter = { getNodeData?: (datum: TreeData, depth: number) => NodeData; getEdgeData?: (source: TreeData, target: TreeData) => EdgeData; getChildren?: (datum: TreeData) => TreeData[]; }; /** * 将树数据转换为图数据 * * Convert tree data to graph data * @param treeData - 树数据 | Tree data * @param getter - 获取节点和边的方法 | Methods to get nodes and edges * @returns 图数据 | Graph data */ export function treeToGraphData(treeData: TreeData, getter?: TreeDataGetter): GraphData { const { getNodeData = (datum: TreeData, depth: number) => { datum.depth = depth; if (!datum.children) return datum as NodeData; const { children, ...restDatum } = datum; return { ...restDatum, children: children.map((child) => child.id) } as NodeData; }, getEdgeData = (source: TreeData, target: TreeData) => ({ source: source.id, target: target.id }), getChildren = (datum: TreeData) => datum.children || [], } = getter || {}; const nodes: NodeData[] = []; const edges: EdgeData[] = []; dfs( treeData, (node, depth) => { nodes.push(getNodeData(node, depth)); const children = getChildren(node); for (const child of children) { edges.push(getEdgeData(node, child)); } }, (node) => getChildren(node), 'TB', ); return { nodes, edges }; } ================================================ FILE: packages/g6/src/utils/vector.ts ================================================ import type { Vector2, Vector3 } from '../types'; import { isVector2, isVector3 } from './is'; import { format } from './print'; const VECTOR_ZERO: Vector3 = [0, 0, 0]; /** * 填充两个向量至相同维度 * * Pads two vectors to the same dimension * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量填充后的结果 | The result of padded vectors */ function padVectors(a: Vector2 | Vector3, b: Vector2 | Vector3): [Vector2 | Vector3, Vector2 | Vector3] { if (a.length == b.length) { return [a, b]; } else { if ((isVector3(a) && a[2] !== 0) || (isVector3(b) && b[2] !== 0)) { throw new Error(format('Vectors could not operate due to different dimensions.')); } return [toVector2(a), toVector2(b)]; } } /** * 两个向量求和 * * Adds two vectors * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量的和 | The sum of the two vectors */ export function add(a: Vector2 | Vector3, b: Vector2 | Vector3): Vector2 | Vector3 { [a, b] = padVectors(a, b); return a.map((v, i) => v + b[i]) as Vector2 | Vector3; } /** * 两个向量求差 * * Subtracts two vectors * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量的差 | The difference of the two vectors */ export function subtract(a: Vector2 | Vector3, b: Vector2 | Vector3): Vector2 | Vector3 { [a, b] = padVectors(a, b); return a.map((v, i) => v - b[i]) as Vector2 | Vector3; } /** * 两个向量求积或者向量和标量求积 * * Multiplies two vectors or a vector and a scalar * @param a - 向量 | The vector * @param b - 向量或者标量 | The vector or scalar * @returns 两个向量的积或者向量和标量的积 | The product of the two vectors or the product of the vector and scalar */ export function multiply(a: Vector2 | Vector3, b: number | Vector2 | Vector3): Vector2 | Vector3 { if (typeof b === 'number') return a.map((v) => v * (b as number)) as Vector2 | Vector3; [a, b] = padVectors(a, b); return a.map((v, i) => v * b[i]) as Vector2 | Vector3; } /** * 两个向量求商或者向量和标量求商 * * Divides two vectors or a vector and a scalar * @param a - 向量 | The vector * @param b - 向量或者标量 | The vector or scalar * @returns 两个向量的商或者向量和标量的商 | The quotient of the two vectors or the quotient of the vector and scalar */ export function divide(a: Vector2 | Vector3, b: number | Vector2 | Vector3): Vector2 | Vector3 { if (typeof b === 'number') return a.map((v) => v / (b as number)) as Vector2 | Vector3; [a, b] = padVectors(a, b); return a.map((v, i) => { if (b[i] == 0) { throw new Error(format('Vector could not be divided by zero')); } return v / b[i]; }) as Vector2 | Vector3; } /** * 两个向量求点积 * * Calculates the dot product of two vectors * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量的点积 | The dot product of the two vectors */ export function dot(a: Vector2 | Vector3, b: Vector2 | Vector3): number { [a, b] = padVectors(a, b); return (a as number[]).reduce((sum, v, i) => sum + v * b[i], 0); } /** * 两个二维向量求叉积 * * Calculates the cross product of two vectors in three-dimensional Euclidean space * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量的叉积 | The cross product of the two vectors */ export function cross(a: Vector2 | Vector3, b: Vector2 | Vector3): Vector3 { const a2 = toVector3(a); const b2 = toVector3(b); return [a2[1] * b2[2] - a2[2] * b2[1], a2[2] * b2[0] - a2[0] * b2[2], a2[0] * b2[1] - a2[1] * b2[0]]; } /** * 向量缩放 * * Scales a vector by a scalar number * @param a - 向量 | The vector to scale * @param s - 缩放系数 | Scale factor * @returns 缩放后的向量 | The scaled vector */ export function scale(a: Vector2 | Vector3, s: number): Vector2 | Vector3 { return a.map((v) => v * s) as Vector2 | Vector3; } /** * 计算两个向量间的欧几里得距离 * * Calculates the Euclidean distance between two vectors * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量间的距离 | The distance between the two vectors */ export function distance(a: Vector2 | Vector3, b: Vector2 | Vector3): number { [a, b] = padVectors(a, b); return Math.sqrt((a as number[]).reduce((sum, v, i) => sum + (v - b[i]) ** 2, 0)); } /** * 计算两个向量间的曼哈顿距离 * * Calculates the Manhattan distance between two vectors * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns 两个向量间的距离 | The distance between the two vectors */ export function manhattanDistance(a: Vector2 | Vector3, b: Vector2 | Vector3): number { [a, b] = padVectors(a, b); return (a as number[]).reduce((sum, v, i) => sum + Math.abs(v - b[i]), 0); } /** * 标准化向量(使长度为 1) * * Normalizes a vector (making its length 1) * @param a - 要标准化的向量 | The vector to normalize * @returns 标准化后的向量 | The normalized vector */ export function normalize(a: Vector2 | Vector3): Vector2 | Vector3 { const length = (a as number[]).reduce((sum, v) => sum + v ** 2, 0); return a.map((v) => v / Math.sqrt(length)) as Vector2 | Vector3; } /** * 计算两个向量间的夹角,输出为锐角余弦值 * * Get the angle between two vectors * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @param clockwise - 是否顺时针 | Whether to calculate the angle in a clockwise direction * @returns 弧度值 | The angle in radians */ export function angle(a: Vector2 | Vector3, b: Vector2 | Vector3, clockwise = false): number { [a, b] = padVectors(a, b); const determinant = a[0] * b[1] - a[1] * b[0]; let angle = Math.acos( (multiply(a, b) as number[]).reduce((sum: number, v: number) => sum + v, 0) / (distance(a, VECTOR_ZERO) * distance(b, VECTOR_ZERO)), ); // If clockwise is true and determinant is negative, adjust the angle if (clockwise && determinant < 0) { angle = 2 * Math.PI - angle; } return angle; } /** * 判断两个向量是否完全相等(使用 === 比较) * * Returns whether or not the vectors exactly have the same elements in the same position (when compared with ===) * @param a - 第一个向量 | The first vector * @param b - 第二个向量 | The second vector * @returns - 是否相等 | Whether or not the vectors are equal */ export function exactEquals(a: Vector2 | Vector3, b: Vector2 | Vector3): boolean { return (a as number[]).every((v, i) => v === b[i]); } /** * 计算向量的垂直向量 * * Calculates the perpendicular vector to a given vector * @param a - 原始向量 | The original vector * @param clockwise - 是否顺时针 | Whether to calculate the perpendicular vector in a clockwise direction * @returns 原始向量的垂直向量 | The perpendicular vector to the original vector */ export function perpendicular(a: Vector2, clockwise = true): Vector2 { return clockwise ? [-a[1], a[0]] : [a[1], -a[0]]; } /** * 计算向量的模 * * Calculates the modulus of a vector * @param a - 原始向量 | The original vector * @param b - 模 | The modulus * @returns - 向量的模 | The modulus of the vector */ export function mod(a: Vector2 | Vector3, b: number): Vector2 | Vector3 { return a.map((v) => v % b) as Vector2 | Vector3; } /** * 向量强制转换为二维向量 * * Force vector to be two-dimensional * @param a - 原始向量 | The original vector * @returns 二维向量 | Two-dimensional vector */ export function toVector2(a: Vector2 | Vector3): Vector2 { return [a[0], a[1]]; } /** * 向量强制转换为三维向量 * * Force vector to be three-dimensional * @param a - 原始向量 | The original vector * @returns - 三维向量 | Three-dimensional vector */ export function toVector3(a: Vector2 | Vector3): Vector3 { return isVector2(a) ? [a[0], a[1], 0] : a; } /** * 计算向量与 x 轴正方向的夹角(弧度制) * * The angle between the vector and the positive direction of the x-axis (radians) * @param a - 向量 | The vector * @returns 弧度值 | The angle in radians */ export function rad(a: Vector2 | Vector3): number { const [x, y] = a; if (!x && !y) return 0; return Math.atan2(y, x); } /** * 旋转向量(角度制) * * Rotational vector (Angle system) * @param a - 向量 | The vector * @param angle - 旋转角度 | The rotation angle * @returns 向量 | The vector */ export function rotate(a: Vector2, angle: number): Vector2 { const [dx, dy] = a; if (angle % 360 === 0) return [dx, dy]; const rad = (angle * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); return [dx * cos - dy * sin, dx * sin + dy * cos]; } ================================================ FILE: packages/g6/src/utils/visibility.ts ================================================ import type { BaseStyleProps, DisplayObject } from '@antv/g'; /** * 设置图形实例的可见性 * * Set the visibility of the shape instance * @param shape - 图形实例 | shape instance * @param value - 可见性 | visibility * @param filter - 筛选出需要设置可见性的图形 | Filter out the shapes that need to set visibility * @remarks * 在设置 enableCSSParsing 为 false 的情况下,复合图形无法继承父属性,因此需要对所有子图形应用相同的可见性 * * After setting enableCSSParsing to false, the compound shape cannot inherit the parent attribute, so the same visibility needs to be applied to all child shapes */ export function setVisibility( shape: DisplayObject, value: BaseStyleProps['visibility'], filter?: (shape: DisplayObject) => boolean, ) { const callback = (node: DisplayObject) => { if (filter && !filter(node)) return; node.style.visibility = value; }; shape.forEach((node) => { callback(node as DisplayObject); }); } ================================================ FILE: packages/g6/src/utils/z-index.ts ================================================ import { ElementDatum } from '../types'; /** * 获取元素的 zIndex * Get the zIndex of the element * @param datum - 元素数据 | element data * @returns - zIndex | zIndex */ export function getZIndexOf(datum: ElementDatum): number { return datum?.style?.zIndex || 0; } ================================================ FILE: packages/g6/src/version.ts ================================================ export const version = '5.1.0'; ================================================ FILE: packages/g6/tsconfig.build.json ================================================ { "compilerOptions": { "paths": {} }, "include": ["src/**/*"], "extends": "./tsconfig.json" } ================================================ FILE: packages/g6/tsconfig.json ================================================ { "compilerOptions": { "baseUrl": ".", "experimentalDecorators": true, "lib": ["DOM", "ESNext"], "outDir": "lib", "paths": { "@/*": ["./*"], "@@/*": ["__tests__/*"], "@antv/g6": ["./src/index.ts"] } }, "exclude": ["node_modules", "dist", "lib", "esm"], "extends": "../../tsconfig.json", "include": ["src/**/*", "__tests__/**/*"] } ================================================ FILE: packages/g6/tsdoc.json ================================================ { "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", "noStandardTags": true, "tagDefinitions": [ { "tagName": "@alpha", "syntaxKind": "modifier" }, { "tagName": "@beta", "syntaxKind": "modifier" }, { "tagName": "@defaultValue", "syntaxKind": "block" }, { "tagName": "@decorator", "syntaxKind": "block", "allowMultiple": true }, { "tagName": "@deprecated", "syntaxKind": "block" }, { "tagName": "@eventProperty", "syntaxKind": "modifier" }, { "tagName": "@example", "syntaxKind": "block", "allowMultiple": true }, { "tagName": "@experimental", "syntaxKind": "modifier" }, { "tagName": "@inheritDoc", "syntaxKind": "inline" }, { "tagName": "@internal", "syntaxKind": "modifier" }, { "tagName": "@label", "syntaxKind": "inline" }, { "tagName": "@link", "syntaxKind": "inline", "allowMultiple": true }, { "tagName": "@override", "syntaxKind": "modifier" }, { "tagName": "@packageDocumentation", "syntaxKind": "modifier" }, { "tagName": "@param", "syntaxKind": "block", "allowMultiple": true }, { "tagName": "@privateRemarks", "syntaxKind": "block" }, { "tagName": "@public", "syntaxKind": "modifier" }, { "tagName": "@readonly", "syntaxKind": "modifier" }, { "tagName": "@remarks", "syntaxKind": "block" }, { "tagName": "@returns", "syntaxKind": "block" }, { "tagName": "@sealed", "syntaxKind": "modifier" }, { "tagName": "@see", "syntaxKind": "block" }, { "tagName": "@throws", "syntaxKind": "block", "allowMultiple": true }, { "tagName": "@typeParam", "syntaxKind": "block", "allowMultiple": true }, { "tagName": "@virtual", "syntaxKind": "modifier" }, { "tagName": "@betaDocumentation", "syntaxKind": "modifier" }, { "tagName": "@internalRemarks", "syntaxKind": "block" }, { "tagName": "@preapproved", "syntaxKind": "modifier" }, { "tagName": "@apiCategory", "syntaxKind": "block" } ], "supportForTags": { "@alpha": true, "@beta": true, "@defaultValue": true, "@decorator": true, "@deprecated": true, "@eventProperty": true, "@example": true, "@experimental": true, "@inheritDoc": true, "@internal": true, "@label": true, "@link": true, "@override": true, "@packageDocumentation": true, "@param": true, "@privateRemarks": true, "@public": true, "@readonly": true, "@remarks": true, "@returns": true, "@sealed": true, "@see": true, "@throws": true, "@typeParam": true, "@virtual": true, "@betaDocumentation": true, "@internalRemarks": true, "@preapproved": true, "@apiCategory": true }, "reportUnsupportedHtmlElements": false } ================================================ FILE: packages/g6/vite.config.js ================================================ import path from 'path'; import { defineConfig } from 'vite'; export default defineConfig({ root: './__tests__', server: { port: 8080, open: '/', }, optimizeDeps: { // @see https://github.com/vitejs/vite/issues/10839#issuecomment-1345193175 // @see https://vitejs.dev/guide/dep-pre-bundling.html#customizing-the-behavior // @see https://vitejs.dev/config/dep-optimization-options.html#optimizedeps-exclude exclude: [], }, plugins: [ { name: 'isolation', configureServer(server) { // The multithreads version of @antv/layout-wasm needs to use SharedArrayBuffer, which should be used in a secure context. // @see https://gist.github.com/mizchi/afcc5cf233c9e6943720fde4b4579a2b server.middlewares.use((_req, res, next) => { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); res.setHeader('Cross-Origin-Embedder-Policy', 'same-origin'); next(); }); }, }, ], resolve: { alias: { '@': path.resolve(__dirname, '.'), '@@': path.resolve(__dirname, './__tests__'), '@antv/g6': path.resolve(__dirname, './src'), }, }, }); ================================================ FILE: packages/g6-extension-3d/README.md ================================================ ## 3D extension for G6 This extension package provides 3D elements, behaviors and plugins for G6. ## Usage 1. Install ```bash npm install @antv/g6-extension-3d ``` 2. Import and Register > Where renderer, elements and lighting are necessary ```js import { ExtensionCategory, register } from '@antv/g6'; import { DragCanvas3D, Light, Line3D, Sphere, renderer } from '@antv/g6-extension-3d'; // 3d light plugin register(ExtensionCategory.PLUGIN, '3d-light', Light); // sphere node element register(ExtensionCategory.NODE, 'sphere', Sphere); // line edge element register(ExtensionCategory.EDGE, 'line3d', Line3D); // drag canvas in 3d scene register(ExtensionCategory.BEHAVIOR, 'drag-canvas-3d', DragCanvas3D); // camera setting plugin register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); ``` 3. Use ```js import { Graph } from '@antv/g6'; const graph = new Graph({ container: 'container', renderer, // use 3d renderer data: { // your data }, node: { type: 'sphere', // use sphere node }, edge: { type: 'line3d', // use 3d line edge }, behaviors: ['drag-canvas-3d'], plugins: [ // set camera configs, see: https://g.antv.antgroup.com/en/api/camera/intro { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, // add directional light { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); ``` ## Resources - [Lite Solar System](https://g6.antv.antgroup.com/en/examples/feature/default/#lite-solar-system) - [3D Node](https://g6.antv.antgroup.com/en/examples/element/node/#3d-node) ================================================ FILE: packages/g6-extension-3d/__tests__/.eslintrc ================================================ { "rules": { "no-console": "off" } } ================================================ FILE: packages/g6-extension-3d/__tests__/dataset/cubic.json ================================================ { "nodes": [ { "id": "0", "style": { "x": 0, "y": 0, "labelText": "center" } }, { "id": "1", "style": { "x": -50, "y": -50, "z": -50, "labelText": "(-1, -1, -1)" } }, { "id": "2", "style": { "x": -50, "y": 50, "z": -50, "labelText": "(-1, 1, -1)" } }, { "id": "3", "style": { "x": 50, "y": 50, "z": -50, "labelText": "(1, 1, -1)" } }, { "id": "4", "style": { "x": 50, "y": -50, "z": -50, "labelText": "(1, -1, -1)" } }, { "id": "5", "style": { "x": -50, "y": -50, "z": 50, "labelText": "(-1, -1, 1)" } }, { "id": "6", "style": { "x": -50, "y": 50, "z": 50, "labelText": "(-1, 1, 1)" } }, { "id": "7", "style": { "x": 50, "y": 50, "z": 50, "labelText": "(1, 1, 1)" } }, { "id": "8", "style": { "x": 50, "y": -50, "z": 50, "labelText": "(1, -1, 1)" } } ] } ================================================ FILE: packages/g6-extension-3d/__tests__/dataset/force-3d.json ================================================ { "nodes": [ { "id": "Myriel", "data": { "group": 1 } }, { "id": "Napoleon", "data": { "group": 1 } }, { "id": "Mlle.Baptistine", "data": { "group": 1 } }, { "id": "Mme.Magloire", "data": { "group": 1 } }, { "id": "CountessdeLo", "data": { "group": 1 } }, { "id": "Geborand", "data": { "group": 1 } }, { "id": "Champtercier", "data": { "group": 1 } }, { "id": "Cravatte", "data": { "group": 1 } }, { "id": "Count", "data": { "group": 1 } }, { "id": "OldMan", "data": { "group": 1 } }, { "id": "Labarre", "data": { "group": 2 } }, { "id": "Valjean", "data": { "group": 2 } }, { "id": "Marguerite", "data": { "group": 3 } }, { "id": "Mme.deR", "data": { "group": 2 } }, { "id": "Isabeau", "data": { "group": 2 } }, { "id": "Gervais", "data": { "group": 2 } }, { "id": "Tholomyes", "data": { "group": 3 } }, { "id": "Listolier", "data": { "group": 3 } }, { "id": "Fameuil", "data": { "group": 3 } }, { "id": "Blacheville", "data": { "group": 3 } }, { "id": "Favourite", "data": { "group": 3 } }, { "id": "Dahlia", "data": { "group": 3 } }, { "id": "Zephine", "data": { "group": 3 } }, { "id": "Fantine", "data": { "group": 3 } }, { "id": "Mme.Thenardier", "data": { "group": 4 } }, { "id": "Thenardier", "data": { "group": 4 } }, { "id": "Cosette", "data": { "group": 5 } }, { "id": "Javert", "data": { "group": 4 } }, { "id": "Fauchelevent", "data": { "group": 0 } }, { "id": "Bamatabois", "data": { "group": 2 } }, { "id": "Perpetue", "data": { "group": 3 } }, { "id": "Simplice", "data": { "group": 2 } }, { "id": "Scaufflaire", "data": { "group": 2 } }, { "id": "Woman1", "data": { "group": 2 } }, { "id": "Judge", "data": { "group": 2 } }, { "id": "Champmathieu", "data": { "group": 2 } }, { "id": "Brevet", "data": { "group": 2 } }, { "id": "Chenildieu", "data": { "group": 2 } }, { "id": "Cochepaille", "data": { "group": 2 } }, { "id": "Pontmercy", "data": { "group": 4 } }, { "id": "Boulatruelle", "data": { "group": 6 } }, { "id": "Eponine", "data": { "group": 4 } }, { "id": "Anzelma", "data": { "group": 4 } }, { "id": "Woman2", "data": { "group": 5 } }, { "id": "MotherInnocent", "data": { "group": 0 } }, { "id": "Gribier", "data": { "group": 0 } }, { "id": "Jondrette", "data": { "group": 7 } }, { "id": "Mme.Burgon", "data": { "group": 7 } }, { "id": "Gavroche", "data": { "group": 8 } }, { "id": "Gillenormand", "data": { "group": 5 } }, { "id": "Magnon", "data": { "group": 5 } }, { "id": "Mlle.Gillenormand", "data": { "group": 5 } }, { "id": "Mme.Pontmercy", "data": { "group": 5 } }, { "id": "Mlle.Vaubois", "data": { "group": 5 } }, { "id": "Lt.Gillenormand", "data": { "group": 5 } }, { "id": "Marius", "data": { "group": 8 } }, { "id": "BaronessT", "data": { "group": 5 } }, { "id": "Mabeuf", "data": { "group": 8 } }, { "id": "Enjolras", "data": { "group": 8 } }, { "id": "Combeferre", "data": { "group": 8 } }, { "id": "Prouvaire", "data": { "group": 8 } }, { "id": "Feuilly", "data": { "group": 8 } }, { "id": "Courfeyrac", "data": { "group": 8 } }, { "id": "Bahorel", "data": { "group": 8 } }, { "id": "Bossuet", "data": { "group": 8 } }, { "id": "Joly", "data": { "group": 8 } }, { "id": "Grantaire", "data": { "group": 8 } }, { "id": "MotherPlutarch", "data": { "group": 9 } }, { "id": "Gueulemer", "data": { "group": 4 } }, { "id": "Babet", "data": { "group": 4 } }, { "id": "Claquesous", "data": { "group": 4 } }, { "id": "Montparnasse", "data": { "group": 4 } }, { "id": "Toussaint", "data": { "group": 5 } }, { "id": "Child1", "data": { "group": 10 } }, { "id": "Child2", "data": { "group": 10 } }, { "id": "Brujon", "data": { "group": 4 } }, { "id": "Mme.Hucheloup", "data": { "group": 8 } } ], "edges": [ { "id": "Napoleon-Myriel", "source": "Napoleon", "target": "Myriel", "data": { "value": 1 } }, { "id": "Mlle.Baptistine-Myriel", "source": "Mlle.Baptistine", "target": "Myriel", "data": { "value": 8 } }, { "id": "Mme.Magloire-Myriel", "source": "Mme.Magloire", "target": "Myriel", "data": { "value": 10 } }, { "id": "Mme.Magloire-Mlle.Baptistine", "source": "Mme.Magloire", "target": "Mlle.Baptistine", "data": { "value": 6 } }, { "id": "CountessdeLo-Myriel", "source": "CountessdeLo", "target": "Myriel", "data": { "value": 1 } }, { "id": "Geborand-Myriel", "source": "Geborand", "target": "Myriel", "data": { "value": 1 } }, { "id": "Champtercier-Myriel", "source": "Champtercier", "target": "Myriel", "data": { "value": 1 } }, { "id": "Cravatte-Myriel", "source": "Cravatte", "target": "Myriel", "data": { "value": 1 } }, { "id": "Count-Myriel", "source": "Count", "target": "Myriel", "data": { "value": 2 } }, { "id": "OldMan-Myriel", "source": "OldMan", "target": "Myriel", "data": { "value": 1 } }, { "id": "Valjean-Labarre", "source": "Valjean", "target": "Labarre", "data": { "value": 1 } }, { "id": "Valjean-Mme.Magloire", "source": "Valjean", "target": "Mme.Magloire", "data": { "value": 3 } }, { "id": "Valjean-Mlle.Baptistine", "source": "Valjean", "target": "Mlle.Baptistine", "data": { "value": 3 } }, { "id": "Valjean-Myriel", "source": "Valjean", "target": "Myriel", "data": { "value": 5 } }, { "id": "Marguerite-Valjean", "source": "Marguerite", "target": "Valjean", "data": { "value": 1 } }, { "id": "Mme.deR-Valjean", "source": "Mme.deR", "target": "Valjean", "data": { "value": 1 } }, { "id": "Isabeau-Valjean", "source": "Isabeau", "target": "Valjean", "data": { "value": 1 } }, { "id": "Gervais-Valjean", "source": "Gervais", "target": "Valjean", "data": { "value": 1 } }, { "id": "Listolier-Tholomyes", "source": "Listolier", "target": "Tholomyes", "data": { "value": 4 } }, { "id": "Fameuil-Tholomyes", "source": "Fameuil", "target": "Tholomyes", "data": { "value": 4 } }, { "id": "Fameuil-Listolier", "source": "Fameuil", "target": "Listolier", "data": { "value": 4 } }, { "id": "Blacheville-Tholomyes", "source": "Blacheville", "target": "Tholomyes", "data": { "value": 4 } }, { "id": "Blacheville-Listolier", "source": "Blacheville", "target": "Listolier", "data": { "value": 4 } }, { "id": "Blacheville-Fameuil", "source": "Blacheville", "target": "Fameuil", "data": { "value": 4 } }, { "id": "Favourite-Tholomyes", "source": "Favourite", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "Favourite-Listolier", "source": "Favourite", "target": "Listolier", "data": { "value": 3 } }, { "id": "Favourite-Fameuil", "source": "Favourite", "target": "Fameuil", "data": { "value": 3 } }, { "id": "Favourite-Blacheville", "source": "Favourite", "target": "Blacheville", "data": { "value": 4 } }, { "id": "Dahlia-Tholomyes", "source": "Dahlia", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "Dahlia-Listolier", "source": "Dahlia", "target": "Listolier", "data": { "value": 3 } }, { "id": "Dahlia-Fameuil", "source": "Dahlia", "target": "Fameuil", "data": { "value": 3 } }, { "id": "Dahlia-Blacheville", "source": "Dahlia", "target": "Blacheville", "data": { "value": 3 } }, { "id": "Dahlia-Favourite", "source": "Dahlia", "target": "Favourite", "data": { "value": 5 } }, { "id": "Zephine-Tholomyes", "source": "Zephine", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "Zephine-Listolier", "source": "Zephine", "target": "Listolier", "data": { "value": 3 } }, { "id": "Zephine-Fameuil", "source": "Zephine", "target": "Fameuil", "data": { "value": 3 } }, { "id": "Zephine-Blacheville", "source": "Zephine", "target": "Blacheville", "data": { "value": 3 } }, { "id": "Zephine-Favourite", "source": "Zephine", "target": "Favourite", "data": { "value": 4 } }, { "id": "Zephine-Dahlia", "source": "Zephine", "target": "Dahlia", "data": { "value": 4 } }, { "id": "Fantine-Tholomyes", "source": "Fantine", "target": "Tholomyes", "data": { "value": 3 } }, { "id": "Fantine-Listolier", "source": "Fantine", "target": "Listolier", "data": { "value": 3 } }, { "id": "Fantine-Fameuil", "source": "Fantine", "target": "Fameuil", "data": { "value": 3 } }, { "id": "Fantine-Blacheville", "source": "Fantine", "target": "Blacheville", "data": { "value": 3 } }, { "id": "Fantine-Favourite", "source": "Fantine", "target": "Favourite", "data": { "value": 4 } }, { "id": "Fantine-Dahlia", "source": "Fantine", "target": "Dahlia", "data": { "value": 4 } }, { "id": "Fantine-Zephine", "source": "Fantine", "target": "Zephine", "data": { "value": 4 } }, { "id": "Fantine-Marguerite", "source": "Fantine", "target": "Marguerite", "data": { "value": 2 } }, { "id": "Fantine-Valjean", "source": "Fantine", "target": "Valjean", "data": { "value": 9 } }, { "id": "Mme.Thenardier-Fantine", "source": "Mme.Thenardier", "target": "Fantine", "data": { "value": 2 } }, { "id": "Mme.Thenardier-Valjean", "source": "Mme.Thenardier", "target": "Valjean", "data": { "value": 7 } }, { "id": "Thenardier-Mme.Thenardier", "source": "Thenardier", "target": "Mme.Thenardier", "data": { "value": 13 } }, { "id": "Thenardier-Fantine", "source": "Thenardier", "target": "Fantine", "data": { "value": 1 } }, { "id": "Thenardier-Valjean", "source": "Thenardier", "target": "Valjean", "data": { "value": 12 } }, { "id": "Cosette-Mme.Thenardier", "source": "Cosette", "target": "Mme.Thenardier", "data": { "value": 4 } }, { "id": "Cosette-Valjean", "source": "Cosette", "target": "Valjean", "data": { "value": 31 } }, { "id": "Cosette-Tholomyes", "source": "Cosette", "target": "Tholomyes", "data": { "value": 1 } }, { "id": "Cosette-Thenardier", "source": "Cosette", "target": "Thenardier", "data": { "value": 1 } }, { "id": "Javert-Valjean", "source": "Javert", "target": "Valjean", "data": { "value": 17 } }, { "id": "Javert-Fantine", "source": "Javert", "target": "Fantine", "data": { "value": 5 } }, { "id": "Javert-Thenardier", "source": "Javert", "target": "Thenardier", "data": { "value": 5 } }, { "id": "Javert-Mme.Thenardier", "source": "Javert", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "Javert-Cosette", "source": "Javert", "target": "Cosette", "data": { "value": 1 } }, { "id": "Fauchelevent-Valjean", "source": "Fauchelevent", "target": "Valjean", "data": { "value": 8 } }, { "id": "Fauchelevent-Javert", "source": "Fauchelevent", "target": "Javert", "data": { "value": 1 } }, { "id": "Bamatabois-Fantine", "source": "Bamatabois", "target": "Fantine", "data": { "value": 1 } }, { "id": "Bamatabois-Javert", "source": "Bamatabois", "target": "Javert", "data": { "value": 1 } }, { "id": "Bamatabois-Valjean", "source": "Bamatabois", "target": "Valjean", "data": { "value": 2 } }, { "id": "Perpetue-Fantine", "source": "Perpetue", "target": "Fantine", "data": { "value": 1 } }, { "id": "Simplice-Perpetue", "source": "Simplice", "target": "Perpetue", "data": { "value": 2 } }, { "id": "Simplice-Valjean", "source": "Simplice", "target": "Valjean", "data": { "value": 3 } }, { "id": "Simplice-Fantine", "source": "Simplice", "target": "Fantine", "data": { "value": 2 } }, { "id": "Simplice-Javert", "source": "Simplice", "target": "Javert", "data": { "value": 1 } }, { "id": "Scaufflaire-Valjean", "source": "Scaufflaire", "target": "Valjean", "data": { "value": 1 } }, { "id": "Woman1-Valjean", "source": "Woman1", "target": "Valjean", "data": { "value": 2 } }, { "id": "Woman1-Javert", "source": "Woman1", "target": "Javert", "data": { "value": 1 } }, { "id": "Judge-Valjean", "source": "Judge", "target": "Valjean", "data": { "value": 3 } }, { "id": "Judge-Bamatabois", "source": "Judge", "target": "Bamatabois", "data": { "value": 2 } }, { "id": "Champmathieu-Valjean", "source": "Champmathieu", "target": "Valjean", "data": { "value": 3 } }, { "id": "Champmathieu-Judge", "source": "Champmathieu", "target": "Judge", "data": { "value": 3 } }, { "id": "Champmathieu-Bamatabois", "source": "Champmathieu", "target": "Bamatabois", "data": { "value": 2 } }, { "id": "Brevet-Judge", "source": "Brevet", "target": "Judge", "data": { "value": 2 } }, { "id": "Brevet-Champmathieu", "source": "Brevet", "target": "Champmathieu", "data": { "value": 2 } }, { "id": "Brevet-Valjean", "source": "Brevet", "target": "Valjean", "data": { "value": 2 } }, { "id": "Brevet-Bamatabois", "source": "Brevet", "target": "Bamatabois", "data": { "value": 1 } }, { "id": "Chenildieu-Judge", "source": "Chenildieu", "target": "Judge", "data": { "value": 2 } }, { "id": "Chenildieu-Champmathieu", "source": "Chenildieu", "target": "Champmathieu", "data": { "value": 2 } }, { "id": "Chenildieu-Brevet", "source": "Chenildieu", "target": "Brevet", "data": { "value": 2 } }, { "id": "Chenildieu-Valjean", "source": "Chenildieu", "target": "Valjean", "data": { "value": 2 } }, { "id": "Chenildieu-Bamatabois", "source": "Chenildieu", "target": "Bamatabois", "data": { "value": 1 } }, { "id": "Cochepaille-Judge", "source": "Cochepaille", "target": "Judge", "data": { "value": 2 } }, { "id": "Cochepaille-Champmathieu", "source": "Cochepaille", "target": "Champmathieu", "data": { "value": 2 } }, { "id": "Cochepaille-Brevet", "source": "Cochepaille", "target": "Brevet", "data": { "value": 2 } }, { "id": "Cochepaille-Chenildieu", "source": "Cochepaille", "target": "Chenildieu", "data": { "value": 2 } }, { "id": "Cochepaille-Valjean", "source": "Cochepaille", "target": "Valjean", "data": { "value": 2 } }, { "id": "Cochepaille-Bamatabois", "source": "Cochepaille", "target": "Bamatabois", "data": { "value": 1 } }, { "id": "Pontmercy-Thenardier", "source": "Pontmercy", "target": "Thenardier", "data": { "value": 1 } }, { "id": "Boulatruelle-Thenardier", "source": "Boulatruelle", "target": "Thenardier", "data": { "value": 1 } }, { "id": "Eponine-Mme.Thenardier", "source": "Eponine", "target": "Mme.Thenardier", "data": { "value": 2 } }, { "id": "Eponine-Thenardier", "source": "Eponine", "target": "Thenardier", "data": { "value": 3 } }, { "id": "Anzelma-Eponine", "source": "Anzelma", "target": "Eponine", "data": { "value": 2 } }, { "id": "Anzelma-Thenardier", "source": "Anzelma", "target": "Thenardier", "data": { "value": 2 } }, { "id": "Anzelma-Mme.Thenardier", "source": "Anzelma", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "Woman2-Valjean", "source": "Woman2", "target": "Valjean", "data": { "value": 3 } }, { "id": "Woman2-Cosette", "source": "Woman2", "target": "Cosette", "data": { "value": 1 } }, { "id": "Woman2-Javert", "source": "Woman2", "target": "Javert", "data": { "value": 1 } }, { "id": "MotherInnocent-Fauchelevent", "source": "MotherInnocent", "target": "Fauchelevent", "data": { "value": 3 } }, { "id": "MotherInnocent-Valjean", "source": "MotherInnocent", "target": "Valjean", "data": { "value": 1 } }, { "id": "Gribier-Fauchelevent", "source": "Gribier", "target": "Fauchelevent", "data": { "value": 2 } }, { "id": "Mme.Burgon-Jondrette", "source": "Mme.Burgon", "target": "Jondrette", "data": { "value": 1 } }, { "id": "Gavroche-Mme.Burgon", "source": "Gavroche", "target": "Mme.Burgon", "data": { "value": 2 } }, { "id": "Gavroche-Thenardier", "source": "Gavroche", "target": "Thenardier", "data": { "value": 1 } }, { "id": "Gavroche-Javert", "source": "Gavroche", "target": "Javert", "data": { "value": 1 } }, { "id": "Gavroche-Valjean", "source": "Gavroche", "target": "Valjean", "data": { "value": 1 } }, { "id": "Gillenormand-Cosette", "source": "Gillenormand", "target": "Cosette", "data": { "value": 3 } }, { "id": "Gillenormand-Valjean", "source": "Gillenormand", "target": "Valjean", "data": { "value": 2 } }, { "id": "Magnon-Gillenormand", "source": "Magnon", "target": "Gillenormand", "data": { "value": 1 } }, { "id": "Magnon-Mme.Thenardier", "source": "Magnon", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "Mlle.Gillenormand-Gillenormand", "source": "Mlle.Gillenormand", "target": "Gillenormand", "data": { "value": 9 } }, { "id": "Mlle.Gillenormand-Cosette", "source": "Mlle.Gillenormand", "target": "Cosette", "data": { "value": 2 } }, { "id": "Mlle.Gillenormand-Valjean", "source": "Mlle.Gillenormand", "target": "Valjean", "data": { "value": 2 } }, { "id": "Mme.Pontmercy-Mlle.Gillenormand", "source": "Mme.Pontmercy", "target": "Mlle.Gillenormand", "data": { "value": 1 } }, { "id": "Mme.Pontmercy-Pontmercy", "source": "Mme.Pontmercy", "target": "Pontmercy", "data": { "value": 1 } }, { "id": "Mlle.Vaubois-Mlle.Gillenormand", "source": "Mlle.Vaubois", "target": "Mlle.Gillenormand", "data": { "value": 1 } }, { "id": "Lt.Gillenormand-Mlle.Gillenormand", "source": "Lt.Gillenormand", "target": "Mlle.Gillenormand", "data": { "value": 2 } }, { "id": "Lt.Gillenormand-Gillenormand", "source": "Lt.Gillenormand", "target": "Gillenormand", "data": { "value": 1 } }, { "id": "Lt.Gillenormand-Cosette", "source": "Lt.Gillenormand", "target": "Cosette", "data": { "value": 1 } }, { "id": "Marius-Mlle.Gillenormand", "source": "Marius", "target": "Mlle.Gillenormand", "data": { "value": 6 } }, { "id": "Marius-Gillenormand", "source": "Marius", "target": "Gillenormand", "data": { "value": 12 } }, { "id": "Marius-Pontmercy", "source": "Marius", "target": "Pontmercy", "data": { "value": 1 } }, { "id": "Marius-Lt.Gillenormand", "source": "Marius", "target": "Lt.Gillenormand", "data": { "value": 1 } }, { "id": "Marius-Cosette", "source": "Marius", "target": "Cosette", "data": { "value": 21 } }, { "id": "Marius-Valjean", "source": "Marius", "target": "Valjean", "data": { "value": 19 } }, { "id": "Marius-Tholomyes", "source": "Marius", "target": "Tholomyes", "data": { "value": 1 } }, { "id": "Marius-Thenardier", "source": "Marius", "target": "Thenardier", "data": { "value": 2 } }, { "id": "Marius-Eponine", "source": "Marius", "target": "Eponine", "data": { "value": 5 } }, { "id": "Marius-Gavroche", "source": "Marius", "target": "Gavroche", "data": { "value": 4 } }, { "id": "BaronessT-Gillenormand", "source": "BaronessT", "target": "Gillenormand", "data": { "value": 1 } }, { "id": "BaronessT-Marius", "source": "BaronessT", "target": "Marius", "data": { "value": 1 } }, { "id": "Mabeuf-Marius", "source": "Mabeuf", "target": "Marius", "data": { "value": 1 } }, { "id": "Mabeuf-Eponine", "source": "Mabeuf", "target": "Eponine", "data": { "value": 1 } }, { "id": "Mabeuf-Gavroche", "source": "Mabeuf", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Enjolras-Marius", "source": "Enjolras", "target": "Marius", "data": { "value": 7 } }, { "id": "Enjolras-Gavroche", "source": "Enjolras", "target": "Gavroche", "data": { "value": 7 } }, { "id": "Enjolras-Javert", "source": "Enjolras", "target": "Javert", "data": { "value": 6 } }, { "id": "Enjolras-Mabeuf", "source": "Enjolras", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "Enjolras-Valjean", "source": "Enjolras", "target": "Valjean", "data": { "value": 4 } }, { "id": "Combeferre-Enjolras", "source": "Combeferre", "target": "Enjolras", "data": { "value": 15 } }, { "id": "Combeferre-Marius", "source": "Combeferre", "target": "Marius", "data": { "value": 5 } }, { "id": "Combeferre-Gavroche", "source": "Combeferre", "target": "Gavroche", "data": { "value": 6 } }, { "id": "Combeferre-Mabeuf", "source": "Combeferre", "target": "Mabeuf", "data": { "value": 2 } }, { "id": "Prouvaire-Gavroche", "source": "Prouvaire", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Prouvaire-Enjolras", "source": "Prouvaire", "target": "Enjolras", "data": { "value": 4 } }, { "id": "Prouvaire-Combeferre", "source": "Prouvaire", "target": "Combeferre", "data": { "value": 2 } }, { "id": "Feuilly-Gavroche", "source": "Feuilly", "target": "Gavroche", "data": { "value": 2 } }, { "id": "Feuilly-Enjolras", "source": "Feuilly", "target": "Enjolras", "data": { "value": 6 } }, { "id": "Feuilly-Prouvaire", "source": "Feuilly", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "Feuilly-Combeferre", "source": "Feuilly", "target": "Combeferre", "data": { "value": 5 } }, { "id": "Feuilly-Mabeuf", "source": "Feuilly", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "Feuilly-Marius", "source": "Feuilly", "target": "Marius", "data": { "value": 1 } }, { "id": "Courfeyrac-Marius", "source": "Courfeyrac", "target": "Marius", "data": { "value": 9 } }, { "id": "Courfeyrac-Enjolras", "source": "Courfeyrac", "target": "Enjolras", "data": { "value": 17 } }, { "id": "Courfeyrac-Combeferre", "source": "Courfeyrac", "target": "Combeferre", "data": { "value": 13 } }, { "id": "Courfeyrac-Gavroche", "source": "Courfeyrac", "target": "Gavroche", "data": { "value": 7 } }, { "id": "Courfeyrac-Mabeuf", "source": "Courfeyrac", "target": "Mabeuf", "data": { "value": 2 } }, { "id": "Courfeyrac-Eponine", "source": "Courfeyrac", "target": "Eponine", "data": { "value": 1 } }, { "id": "Courfeyrac-Feuilly", "source": "Courfeyrac", "target": "Feuilly", "data": { "value": 6 } }, { "id": "Courfeyrac-Prouvaire", "source": "Courfeyrac", "target": "Prouvaire", "data": { "value": 3 } }, { "id": "Bahorel-Combeferre", "source": "Bahorel", "target": "Combeferre", "data": { "value": 5 } }, { "id": "Bahorel-Gavroche", "source": "Bahorel", "target": "Gavroche", "data": { "value": 5 } }, { "id": "Bahorel-Courfeyrac", "source": "Bahorel", "target": "Courfeyrac", "data": { "value": 6 } }, { "id": "Bahorel-Mabeuf", "source": "Bahorel", "target": "Mabeuf", "data": { "value": 2 } }, { "id": "Bahorel-Enjolras", "source": "Bahorel", "target": "Enjolras", "data": { "value": 4 } }, { "id": "Bahorel-Feuilly", "source": "Bahorel", "target": "Feuilly", "data": { "value": 3 } }, { "id": "Bahorel-Prouvaire", "source": "Bahorel", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "Bahorel-Marius", "source": "Bahorel", "target": "Marius", "data": { "value": 1 } }, { "id": "Bossuet-Marius", "source": "Bossuet", "target": "Marius", "data": { "value": 5 } }, { "id": "Bossuet-Courfeyrac", "source": "Bossuet", "target": "Courfeyrac", "data": { "value": 12 } }, { "id": "Bossuet-Gavroche", "source": "Bossuet", "target": "Gavroche", "data": { "value": 5 } }, { "id": "Bossuet-Bahorel", "source": "Bossuet", "target": "Bahorel", "data": { "value": 4 } }, { "id": "Bossuet-Enjolras", "source": "Bossuet", "target": "Enjolras", "data": { "value": 10 } }, { "id": "Bossuet-Feuilly", "source": "Bossuet", "target": "Feuilly", "data": { "value": 6 } }, { "id": "Bossuet-Prouvaire", "source": "Bossuet", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "Bossuet-Combeferre", "source": "Bossuet", "target": "Combeferre", "data": { "value": 9 } }, { "id": "Bossuet-Mabeuf", "source": "Bossuet", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "Bossuet-Valjean", "source": "Bossuet", "target": "Valjean", "data": { "value": 1 } }, { "id": "Joly-Bahorel", "source": "Joly", "target": "Bahorel", "data": { "value": 5 } }, { "id": "Joly-Bossuet", "source": "Joly", "target": "Bossuet", "data": { "value": 7 } }, { "id": "Joly-Gavroche", "source": "Joly", "target": "Gavroche", "data": { "value": 3 } }, { "id": "Joly-Courfeyrac", "source": "Joly", "target": "Courfeyrac", "data": { "value": 5 } }, { "id": "Joly-Enjolras", "source": "Joly", "target": "Enjolras", "data": { "value": 5 } }, { "id": "Joly-Feuilly", "source": "Joly", "target": "Feuilly", "data": { "value": 5 } }, { "id": "Joly-Prouvaire", "source": "Joly", "target": "Prouvaire", "data": { "value": 2 } }, { "id": "Joly-Combeferre", "source": "Joly", "target": "Combeferre", "data": { "value": 5 } }, { "id": "Joly-Mabeuf", "source": "Joly", "target": "Mabeuf", "data": { "value": 1 } }, { "id": "Joly-Marius", "source": "Joly", "target": "Marius", "data": { "value": 2 } }, { "id": "Grantaire-Bossuet", "source": "Grantaire", "target": "Bossuet", "data": { "value": 3 } }, { "id": "Grantaire-Enjolras", "source": "Grantaire", "target": "Enjolras", "data": { "value": 3 } }, { "id": "Grantaire-Combeferre", "source": "Grantaire", "target": "Combeferre", "data": { "value": 1 } }, { "id": "Grantaire-Courfeyrac", "source": "Grantaire", "target": "Courfeyrac", "data": { "value": 2 } }, { "id": "Grantaire-Joly", "source": "Grantaire", "target": "Joly", "data": { "value": 2 } }, { "id": "Grantaire-Gavroche", "source": "Grantaire", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Grantaire-Bahorel", "source": "Grantaire", "target": "Bahorel", "data": { "value": 1 } }, { "id": "Grantaire-Feuilly", "source": "Grantaire", "target": "Feuilly", "data": { "value": 1 } }, { "id": "Grantaire-Prouvaire", "source": "Grantaire", "target": "Prouvaire", "data": { "value": 1 } }, { "id": "MotherPlutarch-Mabeuf", "source": "MotherPlutarch", "target": "Mabeuf", "data": { "value": 3 } }, { "id": "Gueulemer-Thenardier", "source": "Gueulemer", "target": "Thenardier", "data": { "value": 5 } }, { "id": "Gueulemer-Valjean", "source": "Gueulemer", "target": "Valjean", "data": { "value": 1 } }, { "id": "Gueulemer-Mme.Thenardier", "source": "Gueulemer", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "Gueulemer-Javert", "source": "Gueulemer", "target": "Javert", "data": { "value": 1 } }, { "id": "Gueulemer-Gavroche", "source": "Gueulemer", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Gueulemer-Eponine", "source": "Gueulemer", "target": "Eponine", "data": { "value": 1 } }, { "id": "Babet-Thenardier", "source": "Babet", "target": "Thenardier", "data": { "value": 6 } }, { "id": "Babet-Gueulemer", "source": "Babet", "target": "Gueulemer", "data": { "value": 6 } }, { "id": "Babet-Valjean", "source": "Babet", "target": "Valjean", "data": { "value": 1 } }, { "id": "Babet-Mme.Thenardier", "source": "Babet", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "Babet-Javert", "source": "Babet", "target": "Javert", "data": { "value": 2 } }, { "id": "Babet-Gavroche", "source": "Babet", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Babet-Eponine", "source": "Babet", "target": "Eponine", "data": { "value": 1 } }, { "id": "Claquesous-Thenardier", "source": "Claquesous", "target": "Thenardier", "data": { "value": 4 } }, { "id": "Claquesous-Babet", "source": "Claquesous", "target": "Babet", "data": { "value": 4 } }, { "id": "Claquesous-Gueulemer", "source": "Claquesous", "target": "Gueulemer", "data": { "value": 4 } }, { "id": "Claquesous-Valjean", "source": "Claquesous", "target": "Valjean", "data": { "value": 1 } }, { "id": "Claquesous-Mme.Thenardier", "source": "Claquesous", "target": "Mme.Thenardier", "data": { "value": 1 } }, { "id": "Claquesous-Javert", "source": "Claquesous", "target": "Javert", "data": { "value": 1 } }, { "id": "Claquesous-Eponine", "source": "Claquesous", "target": "Eponine", "data": { "value": 1 } }, { "id": "Claquesous-Enjolras", "source": "Claquesous", "target": "Enjolras", "data": { "value": 1 } }, { "id": "Montparnasse-Javert", "source": "Montparnasse", "target": "Javert", "data": { "value": 1 } }, { "id": "Montparnasse-Babet", "source": "Montparnasse", "target": "Babet", "data": { "value": 2 } }, { "id": "Montparnasse-Gueulemer", "source": "Montparnasse", "target": "Gueulemer", "data": { "value": 2 } }, { "id": "Montparnasse-Claquesous", "source": "Montparnasse", "target": "Claquesous", "data": { "value": 2 } }, { "id": "Montparnasse-Valjean", "source": "Montparnasse", "target": "Valjean", "data": { "value": 1 } }, { "id": "Montparnasse-Gavroche", "source": "Montparnasse", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Montparnasse-Eponine", "source": "Montparnasse", "target": "Eponine", "data": { "value": 1 } }, { "id": "Montparnasse-Thenardier", "source": "Montparnasse", "target": "Thenardier", "data": { "value": 1 } }, { "id": "Toussaint-Cosette", "source": "Toussaint", "target": "Cosette", "data": { "value": 2 } }, { "id": "Toussaint-Javert", "source": "Toussaint", "target": "Javert", "data": { "value": 1 } }, { "id": "Toussaint-Valjean", "source": "Toussaint", "target": "Valjean", "data": { "value": 1 } }, { "id": "Child1-Gavroche", "source": "Child1", "target": "Gavroche", "data": { "value": 2 } }, { "id": "Child2-Gavroche", "source": "Child2", "target": "Gavroche", "data": { "value": 2 } }, { "id": "Child2-Child1", "source": "Child2", "target": "Child1", "data": { "value": 3 } }, { "id": "Brujon-Babet", "source": "Brujon", "target": "Babet", "data": { "value": 3 } }, { "id": "Brujon-Gueulemer", "source": "Brujon", "target": "Gueulemer", "data": { "value": 3 } }, { "id": "Brujon-Thenardier", "source": "Brujon", "target": "Thenardier", "data": { "value": 3 } }, { "id": "Brujon-Gavroche", "source": "Brujon", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Brujon-Eponine", "source": "Brujon", "target": "Eponine", "data": { "value": 1 } }, { "id": "Brujon-Claquesous", "source": "Brujon", "target": "Claquesous", "data": { "value": 1 } }, { "id": "Brujon-Montparnasse", "source": "Brujon", "target": "Montparnasse", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Bossuet", "source": "Mme.Hucheloup", "target": "Bossuet", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Joly", "source": "Mme.Hucheloup", "target": "Joly", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Grantaire", "source": "Mme.Hucheloup", "target": "Grantaire", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Bahorel", "source": "Mme.Hucheloup", "target": "Bahorel", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Courfeyrac", "source": "Mme.Hucheloup", "target": "Courfeyrac", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Gavroche", "source": "Mme.Hucheloup", "target": "Gavroche", "data": { "value": 1 } }, { "id": "Mme.Hucheloup-Enjolras", "source": "Mme.Hucheloup", "target": "Enjolras", "data": { "value": 1 } } ] } ================================================ FILE: packages/g6-extension-3d/__tests__/demos/behavior-drag-canvas.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { DragCanvas3D, Light, Line3D, Sphere, renderer } from '../../src'; import data from '../dataset/cubic.json'; export const behaviorDragCanvas: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'drag-canvas-3d', DragCanvas3D); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); const graph = new Graph({ ...context, renderer, data, node: { type: 'sphere', style: { materialType: 'phong', labelText: '', x: (d) => +d.style!.x! + 250, y: (d) => +d.style!.y! + 250, }, palette: 'spectral', }, edge: { type: 'line3d', }, behaviors: [ 'drag-canvas-3d', { type: 'drag-canvas-3d', trigger: { up: ['ArrowUp'], down: ['ArrowDown'], right: ['ArrowRight'], left: ['ArrowLeft'], }, }, ], plugins: [ { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/behavior-observe-canvas.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { Light, Line3D, ObserveCanvas3D, Sphere, ZoomCanvas3D, renderer } from '../../src'; import data from '../dataset/cubic.json'; export const behaviorObserveCanvas: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'observe-canvas-3d', ObserveCanvas3D); register(ExtensionCategory.BEHAVIOR, 'zoom-canvas-3d', ZoomCanvas3D); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); const graph = new Graph({ ...context, renderer, data, node: { type: 'sphere', style: { materialType: 'phong', labelText: '', x: (d) => +d.style!.x! + 250, y: (d) => +d.style!.y! + 250, }, palette: 'spectral', }, edge: { type: 'line3d', }, behaviors: ['zoom-canvas-3d', { key: 'observe-canvas-3d', type: 'observe-canvas-3d' }], plugins: [ { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); await graph.render(); behaviorObserveCanvas.form = (panel) => [ panel.add({ mode: 'orbiting' }, 'mode', ['orbiting', 'exploring', 'tracking']).onChange((mode: string) => { graph.updateBehavior({ key: 'observe-canvas-3d', type: 'observe-canvas-3d', mode, }); }), ]; return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/behavior-roll-canvas.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { Light, Line3D, RollCanvas3D, Sphere, renderer } from '../../src'; import data from '../dataset/cubic.json'; export const behaviorRollCanvas: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'roll-canvas-3d', RollCanvas3D); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); const graph = new Graph({ ...context, renderer, data, node: { type: 'sphere', style: { materialType: 'phong', labelText: '', x: (d) => +d.style!.x! + 250, y: (d) => +d.style!.y! + 250, }, palette: 'spectral', }, edge: { type: 'line3d', }, behaviors: ['roll-canvas-3d'], plugins: [ { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/behavior-zoom-canvas.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { Light, Line3D, Sphere, ZoomCanvas3D, renderer } from '../../src'; import data from '../dataset/cubic.json'; export const behaviorZoomCanvas: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'zoom-canvas-3d', ZoomCanvas3D); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); const graph = new Graph({ ...context, renderer, data, node: { type: 'sphere', style: { materialType: 'phong', labelText: '', x: (d) => +d.style!.x! + 250, y: (d) => +d.style!.y! + 250, }, palette: 'spectral', }, edge: { type: 'line3d', }, behaviors: ['zoom-canvas-3d'], plugins: [ { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/index.ts ================================================ export * from './behavior-drag-canvas'; export * from './behavior-observe-canvas'; export * from './behavior-roll-canvas'; export * from './behavior-zoom-canvas'; export * from './layer-top'; export * from './layout-d3-force-3d'; export { massiveElements } from './massive-elements'; export * from './position'; export * from './shapes'; export * from './solar-system'; export { switchRenderer } from './switch-renderer'; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/layer-top.ts ================================================ import type { GraphData } from '@antv/g6'; import { ExtensionCategory, Graph, register } from '@antv/g6'; import { Light, Line3D, ObserveCanvas3D, Plane, Sphere, renderer } from '../../src'; export const layerTop: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.NODE, 'plane', Plane); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'observe-canvas-3d', ObserveCanvas3D); const result = await fetch('https://assets.antv.antgroup.com/g6/3-layer-top.json'); const { nodes, edges } = await result.json(); const colors = ['rgb(240, 134, 82)', 'rgb(30, 160, 230)', 'rgb(122, 225, 116)']; const data: GraphData = {}; data.nodes = nodes.map(({ name, pos, layer }: any) => ({ id: name, data: { layer }, type: 'sphere', style: { radius: 10, color: colors[layer - 1], materialType: 'phong', ...pos, }, })); new Array(3).fill(0).forEach((_, i) => { data.nodes!.push({ id: `plane-${i + 1}`, type: 'plane', style: { size: 1000, color: colors[i], y: -300 + 300 * i + 10, }, }); }); data.edges = edges.map(({ source, target }: any) => ({ source, target, })); const graph = new Graph({ ...context, renderer, x: 100, y: 100, data, zoom: 0.4, edge: { type: 'line3d', style: { lineWidth: 5, }, }, behaviors: ['observe-canvas-3d'], plugins: [ { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 50000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/layout-d3-force-3d.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { D3Force3DLayout, Light, Line3D, ObserveCanvas3D, Sphere, ZoomCanvas3D, renderer } from '../../src'; import data from '../dataset/force-3d.json'; export const layoutD3Force3D: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.LAYOUT, 'd3-force-3d', D3Force3DLayout as any); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); register(ExtensionCategory.BEHAVIOR, 'zoom-canvas-3d', ZoomCanvas3D); register(ExtensionCategory.BEHAVIOR, 'observe-canvas-3d', ObserveCanvas3D); const graph = new Graph({ ...context, animation: true, renderer, data, layout: { type: 'd3-force-3d', }, node: { type: 'sphere', style: { materialType: 'phong', }, palette: { color: 'tableau', type: 'group', field: 'group', }, }, edge: { type: 'line3d', }, behaviors: ['observe-canvas-3d', 'zoom-canvas-3d'], plugins: [ { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/massive-elements.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { Light, Line3D, ObserveCanvas3D, Sphere, ZoomCanvas3D, renderer } from '../../src'; export const massiveElements: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); register(ExtensionCategory.BEHAVIOR, 'zoom-canvas-3d', ZoomCanvas3D); register(ExtensionCategory.BEHAVIOR, 'observe-canvas-3d', ObserveCanvas3D); const data = await fetch('https://assets.antv.antgroup.com/g6/eva-3d-data.json').then((res) => res.json()); const graph = new Graph({ ...context, animation: false, renderer, data, node: { type: 'sphere', style: { materialType: 'phong', size: 50, x: (d) => d.data!.x, y: (d) => d.data!.y, z: (d) => d.data!.z, }, palette: { color: 'tableau', type: 'group', field: 'cluster', }, }, edge: { type: 'line3d', }, behaviors: ['observe-canvas-3d', 'zoom-canvas-3d'], plugins: [ { type: 'camera-setting', projectionMode: 'orthographic', near: 1, far: 10000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], }); console.time('time'); await graph.draw(); console.timeEnd('time'); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/position.ts ================================================ import { CameraSetting, ExtensionCategory, Graph, register } from '@antv/g6'; import { DragCanvas3D, Light, Line3D, Sphere, renderer } from '../../src'; export const positionValidate: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'drag-canvas-3d', DragCanvas3D); register(ExtensionCategory.PLUGIN, 'camera-setting', CameraSetting); const graph = new Graph({ ...context, renderer, data: { nodes: [ { id: '0', style: { labelText: 'center' } }, { id: '1', style: { x: -50, y: -50, z: -50, labelText: '(-1, -1, -1)' } }, { id: '2', style: { x: -50, y: 50, z: -50, labelText: '(-1, 1, -1)' } }, { id: '3', style: { x: 50, y: 50, z: -50, labelText: '(1, 1, -1)' } }, { id: '4', style: { x: 50, y: -50, z: -50, labelText: '(1, -1, -1)' } }, { id: '5', style: { x: -50, y: -50, z: 50, labelText: '(-1, -1, 1)' } }, { id: '6', style: { x: -50, y: 50, z: 50, labelText: '(-1, 1, 1)' } }, { id: '7', style: { x: 50, y: 50, z: 50, labelText: '(1, 1, 1)' } }, { id: '8', style: { x: 50, y: -50, z: 50, labelText: '(1, -1, 1)' } }, ], edges: [ // { source: '1', target: '2' }, // { source: '2', target: '3' }, // { source: '1', target: '3' }, ], }, node: { type: 'sphere', style: { materialType: 'phong', labelText: '', }, palette: 'spectral', }, edge: { type: 'line3d', }, behaviors: ['drag-canvas-3d'], plugins: [ { type: '3d-light', directional: { direction: [0, 0, 1], }, }, { type: 'camera-setting', projectionMode: 'perspective', near: 0.1, far: 1000, fov: 45, aspect: 1, }, ], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/shapes.ts ================================================ import type { NodeData } from '@antv/g6'; import { ExtensionCategory, Graph, register } from '@antv/g6'; import { Capsule, Cone, Cube, Cylinder, Light, ObserveCanvas3D, Plane, Sphere, Torus, renderer } from '../../src'; export const shapes: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.NODE, 'plane', Plane); register(ExtensionCategory.NODE, 'cylinder', Cylinder); register(ExtensionCategory.NODE, 'cone', Cone); register(ExtensionCategory.NODE, 'cube', Cube); register(ExtensionCategory.NODE, 'capsule', Capsule); register(ExtensionCategory.NODE, 'torus', Torus); register(ExtensionCategory.BEHAVIOR, 'observe-canvas-3d', ObserveCanvas3D); const nodes: NodeData[] = [ { id: '1', type: 'sphere', style: { texture: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*cdTdTI2bNl8AAAAAAAAAAAAADmJ7AQ/original', }, }, { id: '2', type: 'plane', style: { size: 50 } }, { id: '3', type: 'cylinder' }, { id: '4', type: 'cone' }, { id: '5', type: 'cube', style: { texture: 'https://gw.alipayobjects.com/mdn/rms_6ae20b/afts/img/A*8TlCRIsKeUkAAAAAAAAAAAAAARQnAQ', }, }, { id: '6', type: 'capsule' }, { id: '7', type: 'torus' }, ]; const graph = new Graph({ ...context, renderer, data: { nodes, }, node: { style: { materialType: 'phong', x: (d) => 100 + (nodes.findIndex((n) => n.id === d.id) % 5) * 100, y: (d) => 100 + Math.floor(nodes.findIndex((n) => n.id === d.id) / 5) * 100, }, }, plugins: [ { type: '3d-light', directional: { direction: [0, 0, 1], }, }, ], behaviors: ['observe-canvas-3d'], }); await graph.render(); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/solar-system.ts ================================================ import type { DisplayObject } from '@antv/g'; import type { Vector3 } from '@antv/g6'; import { Graph, register } from '@antv/g6'; import { Light, Sphere, renderer } from '../../src'; export const solarSystem: TestCase = async (context) => { register('plugin', '3d-light', Light); register('node', 'sphere', Sphere); const graph = new Graph({ ...context, renderer, data: { nodes: [ { id: 'sum', style: { x: 300, y: 300, radius: 100, texture: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*-mZfQr8LtPUAAAAAAAAAAAAADmJ7AQ/original', }, }, { id: 'mars', style: { x: 430, y: 300, z: 0, radius: 20, texture: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*mniGTZktpecAAAAAAAAAAAAADmJ7AQ/original', }, }, { id: 'earth', style: { x: 500, y: 300, z: 0, radius: 30, texture: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*cdTdTI2bNl8AAAAAAAAAAAAADmJ7AQ/original', }, }, { id: 'jupiter', style: { x: 600, y: 300, z: 0, radius: 50, texture: 'https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*t_mQSZYAT70AAAAAAAAAAAAADmJ7AQ/original', }, }, ], }, node: { type: 'sphere', style: { materialShininess: 0, labelText: (d) => d.id, }, }, plugins: [ { type: '3d-light', directional: { direction: [0, 0, 1], }, }, { type: 'background', background: 'black', }, ], }); await graph.render(); // @ts-expect-error graph is private const element = graph.context.element!; const sum = element.getElement('sum')!; const mars = element.getElement('mars')!; const earth = element.getElement('earth')!; const jupiter = element.getElement('jupiter')!; const setRotation = (element: DisplayObject, speed: number) => { setInterval(() => { element.rotate(0, -speed, 0); }, 30); }; setRotation(sum, 0.1); setRotation(mars, 0.8); setRotation(earth, 1); setRotation(jupiter, 0.5); const setRevolution = (element: DisplayObject, center: Vector3, speed: number) => { setInterval(() => { const [x, y, z] = element.getPosition(); const [cx, cy, cz] = center; const angle = (speed * Math.PI) / 180; const newX = (x - cx) * Math.cos(angle) + (z - cz) * Math.sin(angle) + cx; const newZ = -(x - cx) * Math.sin(angle) + (z - cz) * Math.cos(angle) + cz; element.setPosition(newX, y, newZ); }, 30); }; setRevolution(mars, [300, 300, 0], 1.5); setRevolution(earth, [300, 300, 0], 1); setRevolution(jupiter, [300, 300, 0], 0.5); return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/demos/switch-renderer.ts ================================================ import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import type { NodeData } from '@antv/g6'; import { ExtensionCategory, Graph, register } from '@antv/g6'; import { Light, Line3D, ObserveCanvas3D, Sphere, ZoomCanvas3D, renderer } from '../../src'; export const switchRenderer: TestCase = async (context) => { register(ExtensionCategory.PLUGIN, '3d-light', Light); register(ExtensionCategory.NODE, 'sphere', Sphere); register(ExtensionCategory.EDGE, 'line3d', Line3D); register(ExtensionCategory.BEHAVIOR, 'observe-canvas-3d', ObserveCanvas3D); register(ExtensionCategory.BEHAVIOR, 'zoom-canvas-3d', ZoomCanvas3D); const nodes: NodeData[] = [{ id: '1' }, { id: '2' }]; const graph = new Graph({ ...context, data: { nodes, }, layout: { type: 'grid', }, }); await graph.render(); switchRenderer.form = (panel) => { panel.add({ renderer: '2d' }, 'renderer', ['2d', '3d']).onChange((name: string) => { if (name === '2d') { graph.setOptions({ renderer: () => new CanvasRenderer(), behaviors: [ 'zoom-canvas', 'drag-canvas', 'drag-element', { type: 'hover-activate', degree: 1, state: 'highlight', }, ], node: { type: 'circle', state: { highlight: { fill: '#D580FF', }, }, }, edge: { style: { labelBackgroundFill: '#FFF', labelBackground: true, }, }, layout: { type: 'force', preventOverlap: true, animation: false, }, }); } else { graph.setOptions({ renderer, node: { type: 'sphere', style: { materialType: 'phong', }, }, edge: { type: 'line3d', }, plugins: [ { type: 'camera-setting', projectionMode: 'orthographic', near: 1, far: 10000, fov: 45, aspect: 1, }, { type: '3d-light', directional: { direction: [0, 0, 1], }, }, { type: 'background', backgroundImage: 'url(https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*M_OaRrzIZOEAAAAAAAAAAAAADmJ7AQ/original)', backgroundPosition: 'center', }, ], behaviors: ['observe-canvas-3d', 'zoom-canvas-3d'], }); } graph.draw(); }); return []; }; return graph; }; ================================================ FILE: packages/g6-extension-3d/__tests__/index.html ================================================ @antv/g6-extension-3d

================================================ FILE: packages/g6-extension-3d/__tests__/main.ts ================================================ import type { Controller } from 'lil-gui'; import GUI from 'lil-gui'; import * as demos from './demos'; const demoNames = Object.keys(demos); const options = { demo: '', }; const customForm: Controller[] = []; const panel = new GUI({ autoPlace: true }); const __STORAGE__ = '__G6_EXTENSION_3D_DEMO__'; const load = () => { const data = localStorage.getItem(__STORAGE__); if (data) panel.load(JSON.parse(data)); }; const save = () => { localStorage.setItem(__STORAGE__, JSON.stringify(panel.save())); }; panel .add(options, 'demo', demoNames) .name('Demo') .onChange((name: string) => { render(name); save(); }); load(); function initContainer() { const container = document.getElementById('container')!; container.innerHTML = ''; return container; } function initContext() { const container = initContainer(); return { container, width: 500, height: 500 }; } async function render(name: string) { destroyForm(); const context = initContext(); const demo = demos[name as keyof typeof demos]; const graph = await demo(context); customForm.push(...(demo?.form?.(panel) || [])); Object.assign(window, { graph }); } function destroyForm() { customForm.forEach((controller) => controller.destroy()); customForm.length = 0; } ================================================ FILE: packages/g6-extension-3d/__tests__/types.d.ts ================================================ import type { G6Spec, Graph } from '@antv/g6'; import type { Controller, GUI } from 'lil-gui'; declare global { export interface TestCase { (context: G6Spec): Promise; form?: (gui: GUI) => Controller[]; } export type TestContext = G6Spec; } ================================================ FILE: packages/g6-extension-3d/__tests__/unit/default.spec.ts ================================================ describe('suite', () => { it('case', () => { expect(1).toBe(1); }); }); ================================================ FILE: packages/g6-extension-3d/__tests__/unit/utils/cache.spec.ts ================================================ import { getCacheKey } from '../../../src/utils/cache'; describe('cache', () => { it('getCacheKey plain', () => { const key = Symbol.for('latitudeBands:16 longitudeBands:16 radius:10'); expect( getCacheKey({ radius: 10, latitudeBands: 16, longitudeBands: 16, }), ).toBe(key); expect( getCacheKey({ longitudeBands: 16, latitudeBands: 16, radius: 10, }), ).toBe(key); }); it('getCacheKey object', () => { const object = { a: { b: 1 } }; const key1 = getCacheKey(object); const key2 = getCacheKey(object); expect(key1).not.toBe(key2); }); }); ================================================ FILE: packages/g6-extension-3d/__tests__/unit/utils/geometry.spec.ts ================================================ import { CubeGeometry } from '@antv/g-plugin-3d'; import { createGeometry } from '../../../src/utils/geometry'; describe('geometry', () => { it('createGeometry', () => { const device: any = {}; const geometry1 = createGeometry('cube', device, CubeGeometry, { width: 1, height: 1, depth: 1 }); const geometry2 = createGeometry('cube', device, CubeGeometry, { depth: 1, height: 1, width: 1 }); const geometry3 = createGeometry('cube', device, CubeGeometry, { width: 2, height: 2, depth: 2 }); expect(geometry1).toBe(geometry2); expect(geometry1).not.toBe(geometry3); }); }); ================================================ FILE: packages/g6-extension-3d/__tests__/unit/utils/map.spec.ts ================================================ import { TupleMap } from '../../../src/utils/map'; describe('map', () => { it('TupleMap', () => { const map = new TupleMap(); const key1 = new Date(); const key2 = 'key2'; map.set(key1, key2, 1); expect(map.has(key1, key2)).toBe(true); expect(map.get(key1, key2)).toBe(1); map.set(key1, key2, 2); expect(map.get(key1, key2)).toBe(2); const key3 = 'key3'; expect(map.has(key1, key3)).toBe(false); expect(map.get(key1, key3)).toBe(undefined); }); }); ================================================ FILE: packages/g6-extension-3d/__tests__/unit/utils/material.spec.ts ================================================ import { createMaterial } from '../../../src/utils/material'; describe('material', () => { it('createMaterial', () => { const plugin: any = { loadTexture: () => new Object(), getDevice: () => ({}), }; const materialWithoutTexture = createMaterial(plugin, { type: 'basic' }); const materialWithTexture = createMaterial(plugin, { type: 'basic' }, 'texture'); const image: any = new Object(); const materialWithImage = createMaterial(plugin, { type: 'basic' }, image); expect(materialWithoutTexture).toBe(createMaterial(plugin, { type: 'basic' })); expect(materialWithTexture).not.toBe(materialWithoutTexture); expect(materialWithImage).not.toBe(materialWithTexture); expect(materialWithTexture).toBe(createMaterial(plugin, { type: 'basic' }, 'texture')); expect(materialWithImage).toBe(createMaterial(plugin, { type: 'basic' }, image)); }); }); ================================================ FILE: packages/g6-extension-3d/__tests__/unit/utils/texture.spec.ts ================================================ import { createTexture } from '../../../src/utils/texture'; describe('texture', () => { it('createTexture', () => { const img1 = 'texture1'; const img2 = 'texture2'; const plugin: any = { loadTexture: () => new Object(), }; const texture1 = createTexture(plugin, img1); const texture2 = createTexture(plugin, img2); expect(texture1).toBe(createTexture(plugin, img1)); expect(texture2).not.toBe(texture1); }); }); ================================================ FILE: packages/g6-extension-3d/jest.config.js ================================================ module.exports = { transform: { '^.+\\.[tj]s$': ['@swc/jest'], }, testRegex: '(/__tests__/.*\\.(test|spec))\\.(ts|tsx|js)$', collectCoverageFrom: ['src/**/*.ts'], moduleFileExtensions: ['ts', 'js', 'json'], transformIgnorePatterns: [`/node_modules/.pnpm/(?!(d3-*))`], }; ================================================ FILE: packages/g6-extension-3d/package.json ================================================ { "name": "@antv/g6-extension-3d", "version": "0.1.22", "description": "3D extension for G6", "keywords": [ "antv", "g6", "extension", "3d" ], "license": "MIT", "author": "Aarebecca", "main": "lib/index.js", "module": "esm/index.js", "types": "lib/index.d.ts", "files": [ "src", "esm", "lib", "dist", "README" ], "scripts": { "build": "run-p build:*", "build:cjs": "rimraf ./lib && tsc --module commonjs --outDir lib -p tsconfig.build.json", "build:esm": "rimraf ./esm && tsc --module ESNext --outDir esm -p tsconfig.build.json", "build:umd": "rimraf ./dist && rollup -c", "ci": "run-s lint type-check build test", "dev": "vite", "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-device-api": "^1.6.13", "@antv/g-plugin-3d": "^2.0.45", "@antv/g-plugin-device-renderer": "^2.2.22", "@antv/g-plugin-dragndrop": "^2.0.35", "@antv/g-webgl": "^2.0.47", "@antv/layout": "1.2.14-beta.8", "@antv/util": "^3.3.10" }, "devDependencies": { "@antv/g": "^6.1.24", "@antv/g-canvas": "^2.0.43", "@antv/g6": "workspace:^" }, "peerDependencies": { "@antv/g": "^6.1.2", "@antv/g-canvas": "^2.0.18", "@antv/g6": "workspace:^" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/g6-extension-3d/rollup.config.mjs ================================================ import commonjs from '@rollup/plugin-commonjs'; 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'; import { visualizer } from 'rollup-plugin-visualizer'; const isBundleVis = !!process.env.BUNDLE_VIS; export default [ { input: 'src/index.ts', output: { file: 'dist/g6-extension-3d.min.js', name: 'G6Extension3D', format: 'umd', sourcemap: false, }, plugins: [ nodePolyfills(), resolve(), commonjs(), typescript({ tsconfig: 'tsconfig.build.json', }), terser(), ...(isBundleVis ? [visualizer()] : []), ], }, ]; ================================================ FILE: packages/g6-extension-3d/src/behaviors/drag-canvas-3d.ts ================================================ import type { DragCanvasOptions, Vector2, ViewportAnimationEffectTiming } from '@antv/g6'; import { DragCanvas } from '@antv/g6'; /** * 拖拽 3D 画布交互 * * Drag 3D canvas behavior */ export interface DragCanvas3DOptions extends DragCanvasOptions {} /** * 平移画布 * * Pan canvas */ export class DragCanvas3D extends DragCanvas { protected async translate(offset: Vector2, animation?: ViewportAnimationEffectTiming | undefined) { this.context.canvas.getCamera().pan(-offset[0], -offset[1]); } } ================================================ FILE: packages/g6-extension-3d/src/behaviors/index.ts ================================================ export { DragCanvas3D } from './drag-canvas-3d'; export { ObserveCanvas3D } from './observe-canvas-3d'; export { RollCanvas3D } from './roll-canvas-3d'; export { ZoomCanvas3D } from './zoom-canvas-3d'; export type { DragCanvas3DOptions } from './drag-canvas-3d'; export type { ObserveCanvas3DOptions } from './observe-canvas-3d'; export type { RollCanvas3DOptions } from './roll-canvas-3d'; export type { ZoomCanvas3DOptions } from './zoom-canvas-3d'; ================================================ FILE: packages/g6-extension-3d/src/behaviors/observe-canvas-3d.ts ================================================ import { CameraType } from '@antv/g'; import type { BaseBehaviorOptions, IDragEvent, RuntimeContext, ShortcutKey } from '@antv/g6'; import { BaseBehavior, GraphEvent, Shortcut } from '@antv/g6'; /** * 观察 3D 画布交互配置项 * * Observe 3D canvas options */ export interface ObserveCanvas3DOptions extends BaseBehaviorOptions { enable?: boolean; /** * 相机模式 * - `orbiting` 固定视点(`focalPoint`),改变相机位置。不能跨越南北极 * - `exploring` 固定视点(`focalPoint`),改变相机位置。可以跨越南北极 * - `tracking` 第一人称模式,固定相机位置,改变视点(`focalPoint`)位置 * * Camera mode * - `orbiting` Fixed viewpoint(`focalPoint`), change camera position. Cannot cross the north and south poles * - `exploring` Fixed viewpoint(`focalPoint`), change camera position. Can cross the north and south poles * - `tracking` First-person mode, fixed camera position, change viewpoint(`focalPoint`) position */ mode?: 'orbiting' | 'exploring' | 'tracking'; /** * 按下该快捷键配合指针观察场景 * * Press this shortcut key to observe the scene with the pointer */ trigger?: ShortcutKey; /** * 灵敏度 * * Sensitivity */ sensitivity?: number; } /** * 3D 场景控制器,提供缩放、平移、旋转等能力 * * 3D scene controller, providing zoom, pan, rotate and other capabilities */ export class ObserveCanvas3D extends BaseBehavior { static defaultOptions: Partial = { enable: true, mode: 'orbiting', trigger: [], }; private shortcut: Shortcut; private get camera() { return this.context.canvas.getCamera(); } constructor(context: RuntimeContext, options: ObserveCanvas3DOptions) { super(context, { ...ObserveCanvas3D.defaultOptions, ...options }); this.shortcut = new Shortcut(context.graph); this.bindEvents(); } public update(options: Partial): void { super.update(options); this.setCameraType(); } private setCameraType = () => { const { mode } = this.options; const CameraModeMap = { orbiting: CameraType.ORBITING, exploring: CameraType.EXPLORING, tracking: CameraType.TRACKING, }; this.camera.setType(CameraModeMap[mode]); }; // tracking 模式下需要减速,否则容易出现抖动 // Deceleration is required in tracking mode, otherwise jitter is easy to occur private getRatio() { const { sensitivity, mode } = this.options; if (sensitivity) return sensitivity / 10; if (mode === 'tracking') return 0.1; return 1; } private onDrag = (event: IDragEvent) => { if (!this.options.enable) return; const { x, y } = event.movement; const ratio = this.getRatio(); this.camera.rotate(x * ratio, -y * ratio, 0); }; private bindEvents() { const { graph } = this.context; graph.once(GraphEvent.BEFORE_DRAW, this.setCameraType); this.shortcut.unbindAll(); this.shortcut.bind([...this.options.trigger, 'drag'], this.onDrag); } public destroy() { this.shortcut.destroy(); super.destroy(); } } ================================================ FILE: packages/g6-extension-3d/src/behaviors/roll-canvas-3d.ts ================================================ import type { BaseBehaviorOptions, IWheelEvent, RuntimeContext, ShortcutKey } from '@antv/g6'; import { BasePlugin, Shortcut } from '@antv/g6'; /** * 滚动画布配置项 * * Roll Canvas Options */ export interface RollCanvas3DOptions extends BaseBehaviorOptions { enable?: boolean; /** * 按下该快捷键配合滚轮操作进行旋转 * * Press this shortcut key to rotate with the mouse wheel */ trigger?: ShortcutKey; /** * 灵敏度 * * Sensitivity */ sensitivity?: number; } /** * 滚动画布 * * Roll Canvas */ export class RollCanvas3D extends BasePlugin { static defaultOptions: Partial = { enable: true, trigger: ['wheel'], sensitivity: 1, }; private shortcut: Shortcut; private get camera() { return this.context.canvas.getCamera(); } constructor(context: RuntimeContext, options: RollCanvas3DOptions) { super(context, { ...RollCanvas3D.defaultOptions, ...options }); this.shortcut = new Shortcut(context.graph); this.bindEvents(); } private getAngle(delta: number): number { const { sensitivity } = this.options; return -(delta * sensitivity) / 10; } private onRoll = (event: IWheelEvent) => { const roll = this.camera.getRoll(); const delta = event.deltaY; this.camera.setRoll(roll + this.getAngle(delta)); }; private bindEvents() { const { trigger } = this.options; this.shortcut.unbindAll(); this.shortcut.bind([...trigger, 'wheel'], this.onRoll); } } ================================================ FILE: packages/g6-extension-3d/src/behaviors/zoom-canvas-3d.ts ================================================ import type { IKeyboardEvent, IPointerEvent, IWheelEvent, ViewportAnimationEffectTiming, ZoomCanvasOptions, } from '@antv/g6'; import { ZoomCanvas } from '@antv/g6'; import { clamp } from '@antv/util'; /** * 缩放画布配置项 * * Zoom Canvas Options */ export interface ZoomCanvas3DOptions extends ZoomCanvasOptions {} /** * 缩放画布 * * Zoom Canvas */ export class ZoomCanvas3D extends ZoomCanvas { protected zoom = async ( value: number, event: IWheelEvent | IKeyboardEvent | IPointerEvent, animation: ViewportAnimationEffectTiming | undefined, ) => { if (!this.validate(event)) return; const { graph } = this.context; const { sensitivity, onFinish } = this.options; const ratio = 1 + (clamp(value, -50, 50) * sensitivity) / 100; const zoom = graph.getZoom(); this.context.canvas.getCamera().setZoom(zoom * ratio); onFinish?.(); }; } ================================================ FILE: packages/g6-extension-3d/src/elements/base-node-3d.ts ================================================ import type { BaseStyleProps, DisplayObjectConfig, Group } from '@antv/g'; import type { ProceduralGeometry as GGeometry, Material as GMaterial } from '@antv/g-plugin-3d'; import { Mesh } from '@antv/g-plugin-3d'; import type { IMaterial, Plugin } from '@antv/g-plugin-device-renderer'; import type { BaseNodeStyleProps, Prefix } from '@antv/g6'; import { BaseNode, omitStyleProps, subStyleProps } from '@antv/g6'; import { deepMix } from '@antv/util'; import { Material } from '../types'; import { createMaterial } from '../utils/material'; /** * 3D 节点样式 * * 3D node style props */ export interface BaseNode3DStyleProps extends BaseNodeStyleProps, Prefix<'material', IMaterial> { geometry?: GGeometry; material?: GMaterial; texture?: string | TexImageSource; materialType?: Material['type']; } /** * 3D 节点基类 * * 3D node base class */ export abstract class BaseNode3D extends BaseNode { static defaultStyleProps: Partial = { materialType: 'basic', }; public type = 'node-3d'; protected get plugin() { const renderer = this.context.canvas.getRenderer('main'); const plugin = renderer.getPlugin('device-renderer'); return plugin as unknown as Plugin; } protected get device() { return this.plugin.getDevice(); } constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: BaseNode3D.defaultStyleProps }, options)); } public render(attributes: Required, container: Group) { super.render(attributes, container); } protected getKeyStyle(attributes: Required): MeshStyleProps { const style = omitStyleProps(super.getKeyStyle(attributes), 'material'); const geometry = this.getGeometry(attributes); const material = this.getMaterial(attributes); return { x: 0, y: 0, z: 0, ...style, geometry, material }; } protected drawKeyShape(attributes: Required, container: Group = this) { return this.upsert('key', Mesh, this.getKeyStyle(attributes), container); } protected abstract getGeometry(attributes: Required): GGeometry; protected getMaterial(attributes: Required): GMaterial { const { texture } = attributes; const materialStyle = subStyleProps(attributes, 'material'); return createMaterial(this.plugin, materialStyle, texture); } } export interface MeshStyleProps extends BaseStyleProps { x?: number | string; y?: number | string; z?: number | string; geometry: GGeometry; material: GMaterial; } ================================================ FILE: packages/g6-extension-3d/src/elements/capsule.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { CapsuleGeometryProps, ProceduralGeometry as GGeometry } from '@antv/g-plugin-3d'; import { CapsuleGeometry } from '@antv/g-plugin-3d'; import type { Vector3 } from '@antv/g6'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 胶囊节点样式 * * Capsule Node Style Props */ export type CapsuleStyleProps = BaseNode3DStyleProps & CapsuleGeometryProps; /** * 胶囊节点 * * Capsule Node */ export class Capsule extends BaseNode3D { static defaultStyleProps: Partial = { // radius, height size: [24, 48], heightSegments: 1, sides: 20, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Capsule.defaultStyleProps }, options)); } protected getSize(attributes: CapsuleStyleProps = this.attributes): Vector3 { const { size } = attributes; if (typeof size === 'number') return [size / 4, size, size]; return super.getSize(); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { radius = size[0], height = size[1], heightSegments, sides } = attributes; return createGeometry('capsule', this.device, CapsuleGeometry, { radius, height, heightSegments, sides }); } } ================================================ FILE: packages/g6-extension-3d/src/elements/cone.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { ConeGeometryProps, ProceduralGeometry as GGeometry } from '@antv/g-plugin-3d'; import { ConeGeometry } from '@antv/g-plugin-3d'; import type { Vector3 } from '@antv/g6'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 圆锥节点样式 * * Cone Node Style Props */ export type ConeStyleProps = BaseNode3DStyleProps & ConeGeometryProps; /** * 圆锥节点 * * Cone Node */ export class Cone extends BaseNode3D { static defaultStyleProps: Partial = { // baseRadius, peakRadius, height size: [24, 0, 48], heightSegments: 5, capSegments: 20, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Cone.defaultStyleProps }, options)); } protected getSize(attributes: ConeStyleProps = this.attributes): Vector3 { const { size } = attributes; if (typeof size === 'number') return [size / 2, 0, size]; return super.getSize(); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { baseRadius = size[0], peakRadius = size[1], height = size[2], heightSegments, capSegments } = attributes; return createGeometry('cone', this.device, ConeGeometry, { baseRadius, peakRadius, height, heightSegments, capSegments, }); } } ================================================ FILE: packages/g6-extension-3d/src/elements/cube.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { CubeGeometryProps, ProceduralGeometry as GGeometry } from '@antv/g-plugin-3d'; import { CubeGeometry } from '@antv/g-plugin-3d'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 立方体节点样式 * * Cube Node Style Props */ export type CubeStyleProps = BaseNode3DStyleProps & CubeGeometryProps; /** * 立方体节点 * * Cube Node */ export class Cube extends BaseNode3D { static defaultStyleProps: Partial = { widthSegments: 1, heightSegments: 1, depthSegments: 1, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Cube.defaultStyleProps }, options)); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { width = size[0], height = size[1], depth = size[2], widthSegments, heightSegments, depthSegments, } = attributes; return createGeometry('cube', this.device, CubeGeometry, { width, height, depth, widthSegments, heightSegments, depthSegments, }); } } ================================================ FILE: packages/g6-extension-3d/src/elements/cylinder.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { CylinderGeometryProps, ProceduralGeometry as GGeometry } from '@antv/g-plugin-3d'; import { CylinderGeometry } from '@antv/g-plugin-3d'; import type { Vector3 } from '@antv/g6'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 圆柱节点样式 * * Cylinder Node Style Props */ export type CylinderStyleProps = BaseNode3DStyleProps & CylinderGeometryProps; /** * 圆柱节点 * * Cylinder Node */ export class Cylinder extends BaseNode3D { static defaultStyleProps: Partial = { // radius, height size: [24, 48], heightSegments: 5, capSegments: 20, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Cylinder.defaultStyleProps }, options)); } protected getSize(attributes: CylinderStyleProps = this.attributes): Vector3 { const { size } = attributes; if (typeof size === 'number') return [size / 2, size, 0]; return super.getSize(); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { radius = size[0], height = size[1], heightSegments, capSegments } = attributes; return createGeometry('cylinder', this.device, CylinderGeometry, { radius, height, heightSegments, capSegments }); } } ================================================ FILE: packages/g6-extension-3d/src/elements/index.ts ================================================ export { BaseNode3D } from './base-node-3d'; export { Capsule } from './capsule'; export { Cone } from './cone'; export { Cube } from './cube'; export { Cylinder } from './cylinder'; export { Line3D } from './line-3d'; export { Plane } from './plane'; export { Sphere } from './sphere'; export { Torus } from './torus'; export type { BaseNode3DStyleProps } from './base-node-3d'; export type { CapsuleStyleProps } from './capsule'; export type { ConeStyleProps } from './cone'; export type { CubeStyleProps } from './cube'; export type { CylinderStyleProps } from './cylinder'; export type { Line3DStyleProps } from './line-3d'; export type { PlaneStyleProps } from './plane'; export type { SphereStyleProps } from './sphere'; export type { TorusStyleProps } from './torus'; ================================================ FILE: packages/g6-extension-3d/src/elements/line-3d.ts ================================================ import type { Group } from '@antv/g'; import { Line } from '@antv/g'; import type { BaseEdgeStyleProps } from '@antv/g6'; import { BaseEdge } from '@antv/g6'; /** * 3D 直线样式 * * 3D Line Style Props */ export interface Line3DStyleProps extends BaseEdgeStyleProps {} /** * 直线 * * Line Edge */ export class Line3D extends BaseEdge { protected getKeyPath(): any { return []; } protected getKeyStyle(attributes: Required): any { const { sourceNode, targetNode } = this; const [x1, y1, z1] = sourceNode.getPosition(); const [x2, y2, z2] = targetNode.getPosition(); // omit path const { d, ...style } = super.getKeyStyle(attributes); return { x1, y1, z1, x2, y2, z2, ...style }; } protected drawKeyShape(attributes = this.parsedAttributes, container: Group = this): any { return this.upsert('key', Line, this.getKeyStyle(attributes), container); } } ================================================ FILE: packages/g6-extension-3d/src/elements/plane.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { ProceduralGeometry as GGeometry, PlaneGeometryProps } from '@antv/g-plugin-3d'; import { CullMode, PlaneGeometry } from '@antv/g-plugin-3d'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 平面节点样式 * * Plane Node Style Props */ export type PlaneStyleProps = BaseNode3DStyleProps & PlaneGeometryProps; /** * 平面节点 * * Plane Node */ export class Plane extends BaseNode3D { static defaultStyleProps: Partial = { materialCullMode: CullMode.NONE, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Plane.defaultStyleProps }, options)); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { width = size[0], depth = size[1], widthSegments, depthSegments } = attributes; return createGeometry('plane', this.device, PlaneGeometry, { width, depth, widthSegments, depthSegments }); } } ================================================ FILE: packages/g6-extension-3d/src/elements/sphere.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { ProceduralGeometry as GGeometry, SphereGeometryProps } from '@antv/g-plugin-3d'; import { SphereGeometry } from '@antv/g-plugin-3d'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 球体节点样式 * * Sphere Node Style Props */ export type SphereStyleProps = BaseNode3DStyleProps & SphereGeometryProps; /** * 球体节点 * * Sphere Node */ export class Sphere extends BaseNode3D { static defaultStyleProps: Partial = { // radius size: 24, latitudeBands: 16, longitudeBands: 16, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Sphere.defaultStyleProps }, options)); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { radius = size[0] / 2, latitudeBands, longitudeBands } = attributes; return createGeometry('sphere', this.device, SphereGeometry, { radius, latitudeBands, longitudeBands }); } } ================================================ FILE: packages/g6-extension-3d/src/elements/torus.ts ================================================ import type { DisplayObjectConfig } from '@antv/g'; import type { ProceduralGeometry as GGeometry, TorusGeometryProps } from '@antv/g-plugin-3d'; import { TorusGeometry } from '@antv/g-plugin-3d'; import type { Vector3 } from '@antv/g6'; import { deepMix } from '@antv/util'; import { createGeometry } from '../utils/geometry'; import type { BaseNode3DStyleProps } from './base-node-3d'; import { BaseNode3D } from './base-node-3d'; /** * 圆环节点样式 * * Torus Node Style Props */ export type TorusStyleProps = BaseNode3DStyleProps & TorusGeometryProps; /** * 圆环节点 * * Torus Node */ export class Torus extends BaseNode3D { static defaultStyleProps: Partial = { // tubeRadius, ringRadius size: [8, 48], segments: 30, sides: 20, }; constructor(options: DisplayObjectConfig) { super(deepMix({}, { style: Torus.defaultStyleProps }, options)); } protected getSize(attributes: TorusStyleProps = this.attributes): Vector3 { const { size } = attributes; if (typeof size === 'number') return [size / 8, size / 2, 0]; return super.getSize(); } protected getGeometry(attributes: Required): GGeometry { const size = this.getSize(); const { tubeRadius = size[0], ringRadius = size[1], segments, sides } = attributes; return createGeometry('torus', this.device, TorusGeometry, { tubeRadius, ringRadius, segments, sides }); } } ================================================ FILE: packages/g6-extension-3d/src/exports.ts ================================================ export { D3Force3DLayout } from '@antv/layout'; export { DragCanvas3D, ObserveCanvas3D, RollCanvas3D, ZoomCanvas3D } from './behaviors'; export { BaseNode3D, Capsule, Cone, Cube, Cylinder, Line3D, Plane, Sphere, Torus } from './elements'; export { Light } from './plugins'; export { renderer } from './renderer'; export type { DragCanvas3DOptions, ObserveCanvas3DOptions, RollCanvas3DOptions, ZoomCanvas3DOptions, } from './behaviors'; export type { BaseNode3DStyleProps, CapsuleStyleProps, ConeStyleProps, CubeStyleProps, CylinderStyleProps, Line3DStyleProps, PlaneStyleProps, SphereStyleProps, TorusStyleProps, } from './elements'; export type { LightOptions } from './plugins'; ================================================ FILE: packages/g6-extension-3d/src/index.ts ================================================ export * from './exports'; ================================================ FILE: packages/g6-extension-3d/src/plugins/index.ts ================================================ export { Light } from './light'; export type { LightOptions } from './light'; ================================================ FILE: packages/g6-extension-3d/src/plugins/light.ts ================================================ import type { AmbientLightProps, DirectionalLightProps } from '@antv/g-plugin-3d'; import { AmbientLight, DirectionalLight } from '@antv/g-plugin-3d'; import type { BasePluginOptions, RuntimeContext } from '@antv/g6'; import { BasePlugin, GraphEvent } from '@antv/g6'; import { deepMix } from '@antv/util'; /** * 光照插件配置项 * * Light plugin options */ export interface LightOptions extends BasePluginOptions { /** * 环境光 * * Ambient light */ ambient?: AmbientLightProps; /** * 平行光 * * Directional light */ directional?: DirectionalLightProps; } /** * 光照插件 * * Light plugin */ export class Light extends BasePlugin { static defaultOptions: Partial = { ambient: { fill: '#fff', intensity: Math.PI * 2, }, directional: { fill: '#fff', direction: [-1, 0, 1], intensity: Math.PI * 0.7, }, }; private ambient?: AmbientLight; private directional?: DirectionalLight; constructor(context: RuntimeContext, options: LightOptions) { super(context, deepMix({}, Light.defaultOptions, options)); this.bindEvents(); } private bindEvents() { this.context.graph.on(GraphEvent.BEFORE_DRAW, this.setLight); } private unbindEvents() { this.context.graph.off(GraphEvent.BEFORE_DRAW, this.setLight); } private setLight = () => { const { ambient, directional } = this.options; this.upsertLight('directional', directional); this.upsertLight('ambient', ambient); }; private upsertLight(type: 'ambient', options?: AmbientLightProps): void; private upsertLight(type: 'directional', options?: DirectionalLightProps): void; private upsertLight(type: 'ambient' | 'directional', options?: AmbientLightProps | DirectionalLightProps) { if (options) { const light = this[type]; if (light) light.attr(options); else { const Ctor = type === 'ambient' ? AmbientLight : DirectionalLight; const light = new Ctor({ style: options }); this[type] = light as any; this.context.canvas.appendChild(light); } } else this[type]?.remove(); } /** * 销毁插件 * * Destroy the plugin * @internal */ public destroy() { this.ambient?.remove(); this.directional?.remove(); this.unbindEvents(); super.destroy(); } } ================================================ FILE: packages/g6-extension-3d/src/renderer.ts ================================================ import { Renderer as CanvasRenderer } from '@antv/g-canvas'; import { Plugin as Plugin3D } from '@antv/g-plugin-3d'; import { Renderer as WebGLRenderer } from '@antv/g-webgl'; import type { CanvasOptions } from '@antv/g6'; /** * 3D 渲染器 * * 3D renderer * @param layer - 图层 | Layer * @returns 渲染器实例 | Renderer instance */ export const renderer: CanvasOptions['renderer'] = (layer) => { if (layer === 'label') { return new CanvasRenderer(); } const renderer = new WebGLRenderer(); if (layer === 'main') { renderer.registerPlugin(new Plugin3D()); } return renderer; }; ================================================ FILE: packages/g6-extension-3d/src/types/index.ts ================================================ export * from './material'; ================================================ FILE: packages/g6-extension-3d/src/types/material.ts ================================================ import type { IPointMaterial } from '@antv/g-plugin-3d'; import { IMeshBasicMaterial, IMeshLambertMaterial, IMeshPhongMaterial } from '@antv/g-plugin-3d'; export type Material = PointMaterial | BasicMaterial | LambertMaterial | PhongMaterial; export interface PointMaterial extends Partial> { type: 'point'; map?: string | TexImageSource; } interface BasicMaterial extends Partial> { type: 'basic'; map?: string | TexImageSource; } interface LambertMaterial extends Partial> { type: 'lambert'; map?: string | TexImageSource; // aoMap?: string | Texture; } interface PhongMaterial extends Partial> { type: 'phong'; map?: string | TexImageSource; // aoMap?: string | Texture; } ================================================ FILE: packages/g6-extension-3d/src/utils/cache.ts ================================================ /** * 生成对象配置的缓存键 * * Generate cache key of geometry configuration * @param props - 对象配置 geometry configuration * @returns 缓存键 cache key */ export function getCacheKey(props: Record): symbol { const entries = Object.entries(props); if (entries.some(([, value]) => typeof value === 'object')) { return Symbol(); } const str = entries .sort((a, b) => a[0].localeCompare(b[0])) .map(([key, value]) => { return `${key}:${value}`; }) .join(' '); return Symbol.for(str); } ================================================ FILE: packages/g6-extension-3d/src/utils/geometry.ts ================================================ import type { Device } from '@antv/g-device-api'; import type { ProceduralGeometry } from '@antv/g-plugin-3d'; let DEVICE: Device; const GEOMETRY_CACHE = new Map(); /** * 创建几何体 * * Create geometry * @param type - 几何体类型 geometry type * @param device - 设备对象 device object * @param Ctor - 几何体构造函数 geometry constructor * @param style - 几何体样式 geometry style * @returns 几何体对象 geometry object */ export function createGeometry>( type: string, device: Device, Ctor: new (...args: any[]) => T, style: Record, ) { if (!DEVICE) DEVICE = device; else if (DEVICE !== device) { DEVICE = device; GEOMETRY_CACHE.clear(); } const cacheKey = type + '|' + Object.entries(style) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}:${v}`) .join(','); if (GEOMETRY_CACHE.has(cacheKey)) { return GEOMETRY_CACHE.get(cacheKey) as T; } const geometry = new Ctor(device, style); GEOMETRY_CACHE.set(cacheKey, geometry); return geometry; } ================================================ FILE: packages/g6-extension-3d/src/utils/map.ts ================================================ export class TupleMap { private map = new Map>(); has(key1: K1, key2: K2) { return this.map.has(key1) && this.map.get(key1)!.has(key2); } get(key1: K1, key2: K2) { return this.map.get(key1)?.get(key2); } set(key1: K1, key2: K2, value: V) { if (!this.map.has(key1)) { this.map.set(key1, new Map()); } this.map.get(key1)!.set(key2, value); } clear() { this.map.clear(); } } ================================================ FILE: packages/g6-extension-3d/src/utils/material.ts ================================================ import type { Material as GMaterial } from '@antv/g-plugin-3d'; import { MeshBasicMaterial, MeshLambertMaterial, MeshPhongMaterial, PointMaterial } from '@antv/g-plugin-3d'; import type { Plugin } from '@antv/g-plugin-device-renderer'; import { get, set } from '@antv/util'; import type { Material } from '../types'; import { getCacheKey } from './cache'; import { TupleMap } from './map'; import { createTexture } from './texture'; type MaterialCache = TupleMap; const MATERIAL_CACHE_KEY = '__MATERIAL_CACHE__'; const MATERIAL_MAP = { basic: MeshBasicMaterial, point: PointMaterial, lambert: MeshLambertMaterial, phong: MeshPhongMaterial, }; /** * 基于配置创建材质,支持缓存 * * Create material based on configuration, support cache * @param plugin - 插件对象 plugin * @param options - 材质配置 material configuration * @param texture - 纹理 texture * @returns 材质对象 material object */ export function createMaterial(plugin: Plugin, options: Material, texture?: string | TexImageSource): GMaterial { let cache: MaterialCache = get(plugin, MATERIAL_CACHE_KEY); if (!cache) { cache = new TupleMap(); set(plugin, MATERIAL_CACHE_KEY, cache); } const key = getCacheKey(options); if (cache.has(key, texture)) { return cache.get(key, texture)!; } const device = plugin.getDevice(); const { type, map = texture, ...opts } = options; const Ctor = MATERIAL_MAP[type]; // @ts-expect-error ignore const material = new Ctor(device, { map: createTexture(plugin, map), ...opts }); cache.set(key, texture, material); return material; } ================================================ FILE: packages/g6-extension-3d/src/utils/texture.ts ================================================ import type { Texture } from '@antv/g-device-api'; import type { Plugin } from '@antv/g-plugin-device-renderer'; import { get, set } from '@antv/util'; type TextureCache = Map; const TEXTURE_CACHE_KEY = '__TEXTURE_CACHE__'; // const TEXTURE_CACHE = new Map(); /** * 创建纹理,支持缓存 * * Create texture, support cache * @param plugin - 插件对象 plugin * @param src - 纹理路径或者图片对象 texture path or image object * @returns 纹理对象 texture object */ export function createTexture(plugin: Plugin, src?: string | TexImageSource): Texture | undefined { if (!src) return; let cache: TextureCache = get(plugin, TEXTURE_CACHE_KEY); if (!cache) { cache = new Map(); set(plugin, TEXTURE_CACHE_KEY, cache); } if (cache.has(src)) { return cache.get(src); } const texture = plugin.loadTexture(src); cache.set(src, texture); return texture; } ================================================ FILE: packages/g6-extension-3d/tsconfig.build.json ================================================ { "compilerOptions": { "paths": {} }, "include": ["src/**/*"], "extends": "./tsconfig.json" } ================================================ FILE: packages/g6-extension-3d/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "outDir": "lib", "paths": { "@antv/g6": ["../g6/src/index.ts"] } }, "extends": "../../tsconfig.json", "include": ["src/**/*", "__tests__/**/*"] } ================================================ FILE: packages/g6-extension-3d/tsconfig.test.json ================================================ { "compilerOptions": { "paths": {} }, "include": ["src/**/*", "__tests__/**/*"], "extends": "./tsconfig.json" } ================================================ FILE: packages/g6-extension-3d/vite.config.js ================================================ import path from 'path'; import { defineConfig } from 'vite'; export default defineConfig({ root: './__tests__', server: { port: 8081, open: '/', }, plugins: [{ name: 'isolation' }], resolve: { alias: { '@antv/g6': path.resolve(__dirname, '../g6/src'), }, }, }); ================================================ FILE: packages/g6-extension-react/README.md ================================================ ## React extension for G6 This extension allows you to define G6 node by React component and JSX syntax. ## Usage 1. Install ```bash npm install @antv/g6-extension-react ``` 2. Import and Register ```js import { ExtensionCategory, register } from '@antv/g6'; import { ReactNode } from '@antv/g6-extension-react'; register(ExtensionCategory.NODE, 'react', ReactNode); ``` 3. Define Node React Node: ```jsx const ReactNode = () => { return
node
; }; ``` G Node: ```jsx import { Group, Rect, Text } from '@antv/g6-extension-react'; const GNode = () => { return }; ``` 4. Use Use ReactNode: ```jsx const graph = new Graph({ // ... other options node: { type: 'react', style: { component: () => , }, }, }); ``` Use GNode: ```jsx const graph = new Graph({ // ... other options node: { type: 'g', style: { component: () => , }, }, }); ``` ## Q&A 1. Difference between ReactNode and GNode ReactNode is a React component, while GNode support jsx syntax but can only use G tag node. ## Resources - [React node](https://g6.antv.antgroup.com/examples/element/custom-node/#react-node) - [G node with JSX syntax](https://g6.antv.antgroup.com/en/examples/element/custom-node/#react-g) ================================================ FILE: packages/g6-extension-react/__tests__/.eslintrc ================================================ { "rules": { "no-console": "off" } } ================================================ FILE: packages/g6-extension-react/__tests__/dataset/euro-cup.json ================================================ { "nodes": [ { "id": "50251337", "x": 50, "y": 68, "isTeamA": "1", "player_id": "50251337", "player_shirtnumber": "19", "player_enName": "Justin Kluivert", "player_name": "尤斯廷-克鲁伊维特" }, { "id": "50436685", "x": 25, "y": 68, "isTeamA": "1", "player_id": "50436685", "player_shirtnumber": "24", "player_enName": "Antoine Semenyo", "player_name": "塞门约" }, { "id": "50204813", "x": 50, "y": 89, "isTeamA": "1", "player_id": "50204813", "player_shirtnumber": "9", "player_enName": "Dominic Solanke", "player_name": "索兰克" }, { "id": "50250175", "x": 75, "y": 68, "isTeamA": "1", "player_id": "50250175", "player_shirtnumber": "16", "player_enName": "Marcus Tavernier", "player_name": "塔韦尼耶" }, { "id": "50213675", "x": 65, "y": 48, "isTeamA": "1", "player_id": "50213675", "player_shirtnumber": "4", "player_enName": "Lewis Cook", "player_name": "刘易斯-库克" }, { "id": "50186648", "x": 35, "y": 48, "isTeamA": "1", "player_id": "50186648", "player_shirtnumber": "10", "player_enName": "Ryan Christie", "player_name": "克里斯蒂" }, { "id": "50279448", "x": 38, "y": 28, "isTeamA": "1", "player_id": "50279448", "player_shirtnumber": "6", "player_enName": "Chris Mepham", "player_name": "迈帕姆" }, { "id": "50061646", "x": 15, "y": 28, "isTeamA": "1", "player_id": "50061646", "player_shirtnumber": "15", "player_enName": "Adam Smith", "player_name": "亚当-史密斯" }, { "id": "50472140", "x": 62, "y": 28, "isTeamA": "1", "player_id": "50472140", "player_shirtnumber": "27", "player_enName": "Ilya Zabarnyi", "player_name": "扎巴尔尼" }, { "id": "50544346", "x": 85, "y": 28, "isTeamA": "1", "player_id": "50544346", "player_shirtnumber": "3", "player_enName": "Milos Kerkez", "player_name": "科尔克兹" }, { "id": "50062598", "x": 50, "y": 7, "isTeamA": "1", "player_id": "50062598", "player_shirtnumber": "1", "player_enName": "Neto", "player_name": "内托" } ] } ================================================ FILE: packages/g6-extension-react/__tests__/demos/euro-cup.tsx ================================================ import { ExtensionCategory, register } from '@antv/g6'; import { ReactNode } from '@antv/g6-extension-react'; import styled from 'styled-components'; import data from '../dataset/euro-cup.json'; import { Graph } from '../graph'; const Player = styled.div` width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; `; const Shirt = styled.div` width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; position: relative; img { width: 40px; position: absolute; left: 0; top: 0; } `; const Number = styled.div` color: #fff; font-family: 'DingTalk-JinBuTi'; font-size: 10px; top: 20px; left: 15px; z-index: 1; margin-top: 16px; margin-left: -2px; `; const Label = styled.div` max-width: 120px; padding: 0 8px; color: #fff; font-size: 10px; background-image: url('https://mdn.alipayobjects.com/huamei_92awrc/afts/img/A*s2csQ48M0AkAAAAAAAAAAAAADsvfAQ/original'); background-repeat: no-repeat; background-size: cover; display: flex; justify-content: center; overflow: hidden; text-overflow: ellipsis; `; const PlayerNode = ({ playerInfo }: any) => { const { isTeamA, player_shirtnumber, player_name } = playerInfo; return ( {player_shirtnumber} ); }; register(ExtensionCategory.NODE, 'react', ReactNode); export const EuroCup = () => { return (
d.x * 3.5, y: (d: any) => d.y * 3.5, fill: 'transparent', component: (data: any) => , }, }, plugins: [ { type: 'background', width: '480px', height: '720px', backgroundImage: 'url(https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*EmPXQLrX2xIAAAAAAAAAAAAADmJ7AQ/original)', backgroundRepeat: 'no-repeat', backgroundSize: 'contain', opacity: 1, }, ], }} />
); }; ================================================ FILE: packages/g6-extension-react/__tests__/demos/graph.tsx ================================================ import { Graph } from '../graph'; export const G6Graph = () => { return ( { console.log('render'); }} onDestroy={() => { console.log('destroy'); }} /> ); }; ================================================ FILE: packages/g6-extension-react/__tests__/demos/index.tsx ================================================ export * from './euro-cup'; export * from './graph'; export * from './performance-diagnosis'; export * from './react-node'; ================================================ FILE: packages/g6-extension-react/__tests__/demos/performance-diagnosis.tsx ================================================ import { BugOutlined } from '@ant-design/icons'; import type { EdgeData, Element, GraphData, GraphOptions, IPointerEvent, NodeData } from '@antv/g6'; import { ExtensionCategory, HoverActivate, idOf, register } from '@antv/g6'; import { Flex, Typography } from 'antd'; import { CSSProperties, useEffect, useState } from 'react'; import { Graph } from '../graph'; const { Text } = Typography; const ACTIVE_COLOR = '#f6c523'; const COLOR_MAP: Record = { 'pre-inspection': '#3fc1c9', problem: '#8983f3', inspection: '#f48db4', solution: '#ffaa64', }; class HoverElement extends HoverActivate { protected getActiveIds(event: IPointerEvent) { const { model, graph } = this.context; const elementId = event.target.id; const { targetType: elementType } = event; const ids = [elementId]; if (elementType === 'edge') { const edge = model.getEdgeDatum(elementId); ids.push(edge.source, edge.target); } else if (elementType === 'node') { ids.push(...model.getRelatedEdgesData(elementId).map(idOf)); } graph.frontElement(ids); return ids; } } register(ExtensionCategory.BEHAVIOR, 'hover-element', HoverElement); const Node = ({ data }: { data: NodeData }) => { const { text, type } = data.data as { text: string; type: string }; const isHovered = data.states?.includes('active'); const isSelected = data.states?.includes('selected'); const color = isHovered ? ACTIVE_COLOR : COLOR_MAP[type]; const containerStyle: CSSProperties = { width: '100%', height: '100%', background: color, border: `3px solid ${color}`, borderRadius: 16, cursor: 'pointer', }; if (isSelected) { Object.assign(containerStyle, { border: `3px solid #000` }); } return ( {type === 'problem' && } {text} ); }; export const PerformanceDiagnosis = () => { const [data, setData] = useState(); useEffect(() => { fetch('https://assets.antv.antgroup.com/g6/performance-diagnosis.json') .then((res) => res.json()) .then(setData); }, []); const options: GraphOptions = { data, animation: false, width: 800, height: 600, autoFit: 'view', node: { type: 'react', style: (d: NodeData) => { const style: NodeData['style'] = { component: , ports: [{ placement: 'top' }, { placement: 'bottom' }], }; const size = { 'pre-inspection': [240, 120], problem: [200, 120], inspection: [330, 100], solution: [200, 120], }[d.data!.type as string] || [200, 80]; Object.assign(style, { size, dx: -size[0] / 2, dy: -size[1] / 2, }); return style; }, state: { active: { halo: false, }, selected: { halo: false, }, }, }, edge: { type: 'polyline', style: { lineWidth: 3, radius: 20, stroke: '#8b9baf', endArrow: true, labelText: (d: EdgeData) => d.data!.text as string, labelFill: '#8b9baf', labelFontWeight: 600, labelBackground: true, labelBackgroundFill: '#f8f8f8', labelBackgroundOpacity: 1, labelBackgroundLineWidth: 3, labelBackgroundStroke: '#8b9baf', labelPadding: [1, 10], labelBackgroundRadius: 4, router: { type: 'orth', }, }, state: { active: { stroke: ACTIVE_COLOR, labelBackgroundStroke: ACTIVE_COLOR, halo: false, }, }, }, layout: { type: 'antv-dagre', }, behaviors: ['zoom-canvas', 'drag-canvas', 'hover-element', 'click-select'], }; return ; }; ================================================ FILE: packages/g6-extension-react/__tests__/demos/react-node.tsx ================================================ import { DatabaseFilled } from '@ant-design/icons'; import type { Graph as G6Graph, GraphOptions, NodeData } from '@antv/g6'; import { ExtensionCategory, register } from '@antv/g6'; import { ReactNode } from '@antv/g6-extension-react'; import { Badge, Button, Flex, Form, Input, Layout, Select, Table, Tag, Typography } from 'antd'; import { useRef, useState } from 'react'; import { Graph } from '../graph'; const { Content, Footer } = Layout; const { Text } = Typography; register(ExtensionCategory.NODE, 'react', ReactNode); type Datum = { name: string; status: 'success' | 'error' | 'warning'; type: 'local' | 'remote'; url: string; }; const Node = ({ data, onChange }: { data: NodeData; onChange?: (value: string) => void }) => { const { status, type } = data.data as Datum; return ( Server {type} {data.id} *URL: { const url = event.target.value; onChange?.(url); }} /> ); }; export const ReactNodeDemo = () => { const graphRef = useRef(null); const [form] = Form.useForm(); const isValidUrl = (url: string) => { return /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/.test( url, ); }; const [options, setOptions] = useState({ data: { nodes: [ { id: 'local-server-1', data: { status: 'success', type: 'local', url: 'http://localhost:3000' }, style: { x: 50, y: 50 }, }, { id: 'remote-server-1', data: { status: 'warning', type: 'remote' }, style: { x: 350, y: 50 }, }, ], edges: [{ source: 'local-server-1', target: 'remote-server-1' }], }, node: { type: 'react', style: { size: [240, 100], component: (data: NodeData) => ( { setOptions((prev) => { if (!graphRef.current || graphRef.current.destroyed) return prev; const nodes = graphRef.current.getNodeData(); const index = nodes.findIndex((node) => node.id === data.id); const node = nodes[index]; const datum = { ...node.data, url, status: url === '' ? 'warning' : isValidUrl(url) ? 'success' : 'error', } as Datum; nodes[index] = { ...node, data: datum }; return { ...prev, data: { ...prev.data, nodes } }; }); }} /> ), }, }, behaviors: ['drag-element', 'zoom-canvas', 'drag-canvas'], }); return ( (graphRef.current = graph)} />