From 6528d95ae68faa9eb8c5e311c722171c054ae5f3 Mon Sep 17 00:00:00 2001
From: Yuxin <55794321+yvonneyx@users.noreply.github.com>
Date: Wed, 11 Sep 2024 19:08:22 +0800
Subject: [PATCH] feat(graphs): add hierarchical graph (#2692)
* feat: add hierarchical graph
* chore: add prettier plugins
* feat: add organization chart demo based on hierarchical graph
* fix: remove react strict mode
* fix: fix cr issues
* fix: remove @ant-design/icons
---
.prettierrc.js | 1 +
package.json | 2 +
packages/graphs/package.json | 29 ++---
.../components/HierarchicalGraph/index.tsx | 5 -
.../components/hierarchical-graph/index.tsx | 14 +++
packages/graphs/src/components/index.ts | 4 +-
packages/graphs/src/core/base-graph.tsx | 62 ++++++++++
.../src/core/behaviors/hover-element.ts | 21 ++++
packages/graphs/src/core/behaviors/index.ts | 1 +
packages/graphs/src/core/hoc/index.ts | 1 +
.../src/core/hoc/with-collapsible-node.tsx | 67 +++++++++++
packages/graphs/src/core/hooks/index.ts | 1 +
packages/graphs/src/core/hooks/useOptions.tsx | 66 +++++++++++
packages/graphs/src/core/nodes/index.ts | 2 +
.../core/nodes/organization-chart-node.tsx | 101 +++++++++++++++++
packages/graphs/src/core/nodes/plain-node.tsx | 46 ++++++++
packages/graphs/src/core/nodes/types.ts | 12 ++
packages/graphs/src/core/registry/build-in.ts | 11 ++
packages/graphs/src/core/registry/register.ts | 11 ++
packages/graphs/src/core/transform/index.ts | 1 +
.../src/core/transform/infer-react-style.ts | 30 +++++
packages/graphs/src/core/utils/node.tsx | 106 ++++++++++++++++++
packages/graphs/src/core/utils/options.ts | 38 +++++++
packages/graphs/src/index.ts | 5 +-
packages/graphs/src/preset.ts | 3 +
packages/graphs/src/types.ts | 59 ++++++++++
.../graphs/tests/demos/error-boundary.tsx | 5 -
.../graphs/tests/demos/hierarchical-graph.tsx | 27 +++++
packages/graphs/tests/demos/index.tsx | 3 +-
.../graphs/tests/demos/organization-chart.tsx | 52 +++++++++
packages/graphs/tests/main.tsx | 8 +-
packages/graphs/tsconfig.json | 6 +-
packages/graphs/vite.config.ts | 1 +
packages/plots/src/interface.ts | 50 +--------
packages/util/src/index.ts | 3 +-
packages/util/src/types.ts | 46 ++++++++
36 files changed, 818 insertions(+), 82 deletions(-)
delete mode 100644 packages/graphs/src/components/HierarchicalGraph/index.tsx
create mode 100644 packages/graphs/src/components/hierarchical-graph/index.tsx
create mode 100644 packages/graphs/src/core/base-graph.tsx
create mode 100644 packages/graphs/src/core/behaviors/hover-element.ts
create mode 100644 packages/graphs/src/core/behaviors/index.ts
create mode 100644 packages/graphs/src/core/hoc/index.ts
create mode 100644 packages/graphs/src/core/hoc/with-collapsible-node.tsx
create mode 100644 packages/graphs/src/core/hooks/index.ts
create mode 100644 packages/graphs/src/core/hooks/useOptions.tsx
create mode 100644 packages/graphs/src/core/nodes/index.ts
create mode 100644 packages/graphs/src/core/nodes/organization-chart-node.tsx
create mode 100644 packages/graphs/src/core/nodes/plain-node.tsx
create mode 100644 packages/graphs/src/core/nodes/types.ts
create mode 100644 packages/graphs/src/core/registry/build-in.ts
create mode 100644 packages/graphs/src/core/registry/register.ts
create mode 100644 packages/graphs/src/core/transform/index.ts
create mode 100644 packages/graphs/src/core/transform/infer-react-style.ts
create mode 100644 packages/graphs/src/core/utils/node.tsx
create mode 100644 packages/graphs/src/core/utils/options.ts
create mode 100644 packages/graphs/src/preset.ts
create mode 100644 packages/graphs/src/types.ts
delete mode 100644 packages/graphs/tests/demos/error-boundary.tsx
create mode 100644 packages/graphs/tests/demos/hierarchical-graph.tsx
create mode 100644 packages/graphs/tests/demos/organization-chart.tsx
create mode 100644 packages/util/src/types.ts
diff --git a/.prettierrc.js b/.prettierrc.js
index f30f82663..305cdad3a 100644
--- a/.prettierrc.js
+++ b/.prettierrc.js
@@ -3,4 +3,5 @@ const fabric = require('@umijs/fabric');
module.exports = {
...fabric.prettier,
printWidth: 120,
+ plugins: [require.resolve('prettier-plugin-organize-imports'), require.resolve('prettier-plugin-packagejson')],
};
diff --git a/package.json b/package.json
index bbe5cc15e..1b83e6b73 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,8 @@
"np": "*",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.7",
+ "prettier-plugin-organize-imports": "^4.0.0",
+ "prettier-plugin-packagejson": "^2.5.2",
"pretty-quick": "^3.0.1",
"react-dev-utils": "^12.0.1",
"style-loader": "^3.3.0",
diff --git a/packages/graphs/package.json b/packages/graphs/package.json
index aaf6b4d41..87a0f8f71 100644
--- a/packages/graphs/package.json
+++ b/packages/graphs/package.json
@@ -2,6 +2,16 @@
"name": "@ant-design/graphs",
"version": "2.0.0-alpha.0",
"description": "A React graph library based on Graphin",
+ "keywords": [
+ "antv",
+ "g6",
+ "graph",
+ "graph analysis",
+ "graph editor",
+ "graph visualization",
+ "relational data",
+ "react"
+ ],
"bugs": {
"url": "https://github.com/ant-design/ant-design-charts/issues"
},
@@ -9,6 +19,7 @@
"type": "git",
"url": "git+https://github.com/ant-design/ant-design-charts.git"
},
+ "license": "MIT",
"main": "lib/index.js",
"unpkg": "dist/graphs.min.js",
"module": "es/index.js",
@@ -35,20 +46,13 @@
"start": "npm run build:esm --w",
"test": "jest"
},
- "keywords": [
- "antv",
- "g6",
- "graph",
- "graph analysis",
- "graph editor",
- "graph visualization",
- "relational data",
- "react"
- ],
"dependencies": {
+ "@ant-design/charts-util": "workspace:*",
"@antv/g6": "^5.0.21",
+ "@antv/g6-extension-react": "^0.1.6",
"@antv/graphin": "^3.0.2",
- "lodash-es": "^4.17.21"
+ "lodash": "^4.17.21",
+ "styled-components": "^6.1.13"
},
"devDependencies": {
"@types/jest": "^26.0.0",
@@ -62,6 +66,5 @@
"peerDependencies": {
"react": ">=16.8.4",
"react-dom": ">=16.8.4"
- },
- "license": "MIT"
+ }
}
diff --git a/packages/graphs/src/components/HierarchicalGraph/index.tsx b/packages/graphs/src/components/HierarchicalGraph/index.tsx
deleted file mode 100644
index 16bbb3d86..000000000
--- a/packages/graphs/src/components/HierarchicalGraph/index.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import React from 'react';
-
-export const HierarchicalGraph = () => {
- return
HierarchicalGraph
;
-};
diff --git a/packages/graphs/src/components/hierarchical-graph/index.tsx b/packages/graphs/src/components/hierarchical-graph/index.tsx
new file mode 100644
index 000000000..f98841617
--- /dev/null
+++ b/packages/graphs/src/components/hierarchical-graph/index.tsx
@@ -0,0 +1,14 @@
+import type { Graph } from '@antv/g6';
+import React, { forwardRef, ForwardRefExoticComponent, PropsWithChildren, PropsWithoutRef, RefAttributes } from 'react';
+import { BaseGraph } from '../../core/base-graph';
+import type { GraphOptions } from '../../types';
+
+const HierarchicalGraph: ForwardRefExoticComponent<
+ PropsWithoutRef> & RefAttributes
+> = forwardRef>(({ children, ...props }, ref) => (
+
+ {children}
+
+));
+
+export default HierarchicalGraph;
diff --git a/packages/graphs/src/components/index.ts b/packages/graphs/src/components/index.ts
index 83e25a1f1..b6a0316cc 100644
--- a/packages/graphs/src/components/index.ts
+++ b/packages/graphs/src/components/index.ts
@@ -1 +1,3 @@
-export { HierarchicalGraph } from './HierarchicalGraph';
+import HierarchicalGraph from './hierarchical-graph';
+
+export { HierarchicalGraph };
diff --git a/packages/graphs/src/core/base-graph.tsx b/packages/graphs/src/core/base-graph.tsx
new file mode 100644
index 000000000..d50bee9fc
--- /dev/null
+++ b/packages/graphs/src/core/base-graph.tsx
@@ -0,0 +1,62 @@
+import { ChartLoading, ErrorBoundary } from '@ant-design/charts-util';
+import type { Graph } from '@antv/g6';
+import { Graphin } from '@antv/graphin';
+import React, {
+ forwardRef,
+ ForwardRefExoticComponent,
+ PropsWithChildren,
+ PropsWithoutRef,
+ RefAttributes,
+ useImperativeHandle,
+ useRef,
+} from 'react';
+import type { GraphOptions, GraphType } from '../types';
+import { useOptions } from './hooks';
+
+interface BaseGraphProps extends GraphOptions {
+ /**
+ * 内部属性,只读
+ */
+ readonly type: GraphType;
+}
+
+export const BaseGraph: ForwardRefExoticComponent<
+ PropsWithoutRef> & RefAttributes
+> = forwardRef>(({ children, ...props }, ref) => {
+ const {
+ type,
+ containerStyle,
+ className,
+ onInit,
+ onReady,
+ onDestroy,
+ errorTemplate,
+ loading,
+ loadingTemplate,
+ ...propOptions
+ } = props;
+ const graphRef = useRef(null);
+
+ const options = useOptions(type, propOptions);
+
+ useImperativeHandle(ref, () => graphRef.current!);
+
+ return (
+
+ {loading && }
+ {
+ graphRef.current = ref;
+ }}
+ className={className}
+ style={containerStyle}
+ options={options}
+ onInit={onInit}
+ onReady={onReady}
+ onDestroy={onDestroy}
+ >
+ {children}
+
+
+ );
+});
diff --git a/packages/graphs/src/core/behaviors/hover-element.ts b/packages/graphs/src/core/behaviors/hover-element.ts
new file mode 100644
index 000000000..37ac626f0
--- /dev/null
+++ b/packages/graphs/src/core/behaviors/hover-element.ts
@@ -0,0 +1,21 @@
+import { HoverActivate, idOf } from '@antv/g6';
+
+export class HoverElement extends HoverActivate {
+ getActiveIds(event) {
+ const { model, graph } = this.context;
+ const targetId = event.target.id;
+ const targetType = graph.getElementType(targetId);
+
+ const ids = [targetId];
+ if (targetType === 'edge') {
+ const edge = model.getEdgeDatum(targetId);
+ ids.push(edge.source, edge.target);
+ } else if (targetType === 'node') {
+ ids.push(...model.getRelatedEdgesData(targetId).map(idOf));
+ }
+
+ graph.frontElement(ids);
+
+ return ids;
+ }
+}
diff --git a/packages/graphs/src/core/behaviors/index.ts b/packages/graphs/src/core/behaviors/index.ts
new file mode 100644
index 000000000..e4aa98e60
--- /dev/null
+++ b/packages/graphs/src/core/behaviors/index.ts
@@ -0,0 +1 @@
+export { HoverElement } from './hover-element';
diff --git a/packages/graphs/src/core/hoc/index.ts b/packages/graphs/src/core/hoc/index.ts
new file mode 100644
index 000000000..00e1a9ea4
--- /dev/null
+++ b/packages/graphs/src/core/hoc/index.ts
@@ -0,0 +1 @@
+export { withCollapsibleNode } from './with-collapsible-node';
diff --git a/packages/graphs/src/core/hoc/with-collapsible-node.tsx b/packages/graphs/src/core/hoc/with-collapsible-node.tsx
new file mode 100644
index 000000000..ea6768737
--- /dev/null
+++ b/packages/graphs/src/core/hoc/with-collapsible-node.tsx
@@ -0,0 +1,67 @@
+import { idOf } from '@antv/g6';
+import { get, isEmpty } from 'lodash';
+import React, { useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+import type { CollapsibleOptions } from '../../types';
+import type { NodeProps } from '../nodes/types';
+
+const StyledWrapper = styled.div`
+ position: relative;
+ height: inherit;
+ width: inherit;
+
+ .collapsible-icon {
+ position: absolute;
+ left: 50%;
+ top: calc(100% + 24px);
+ transform: translate(-50%, -50%);
+ z-index: 1;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+`;
+
+interface CollapsibleNodeProps extends NodeProps, CollapsibleOptions {}
+
+export const withCollapsibleNode = (NodeComponent: React.FC) => {
+ return (props: CollapsibleNodeProps) => {
+ const { data, graph, trigger, iconRender, iconClassName = '', iconStyle } = props;
+ const [isCollapsed, setIsCollapsed] = useState(get(data, 'style.collapsed', false));
+ const wrapperRef = useRef(null);
+ const iconRef = useRef(null);
+
+ const isIconShown = trigger === 'icon' && !isEmpty(data.children);
+
+ const handleClickCollapse = async (e) => {
+ e.stopPropagation();
+
+ const toggleExpandCollapse = isCollapsed ? 'expandElement' : 'collapseElement';
+ await graph[toggleExpandCollapse](idOf(data));
+ setIsCollapsed((prev) => !prev);
+
+ await graph.layout();
+ };
+
+ useEffect(() => {
+ const target = trigger === 'icon' ? iconRef.current : trigger === 'node' ? wrapperRef.current : trigger;
+
+ target?.addEventListener('click', handleClickCollapse);
+ return () => {
+ target?.removeEventListener('click', handleClickCollapse);
+ };
+ }, [trigger, isCollapsed]);
+
+ return (
+
+ {isIconShown && (
+
+ {iconRender?.(isCollapsed)}
+
+ )}
+ {NodeComponent.call(graph, data)}
+
+ );
+ };
+};
diff --git a/packages/graphs/src/core/hooks/index.ts b/packages/graphs/src/core/hooks/index.ts
new file mode 100644
index 000000000..52227a29a
--- /dev/null
+++ b/packages/graphs/src/core/hooks/index.ts
@@ -0,0 +1 @@
+export { useOptions } from './useOptions';
diff --git a/packages/graphs/src/core/hooks/useOptions.tsx b/packages/graphs/src/core/hooks/useOptions.tsx
new file mode 100644
index 000000000..9875b73dc
--- /dev/null
+++ b/packages/graphs/src/core/hooks/useOptions.tsx
@@ -0,0 +1,66 @@
+import type { NodeData } from '@antv/g6';
+import { idOf } from '@antv/g6';
+import React, { useMemo } from 'react';
+import type { GraphOptions, GraphType } from '../../types';
+import { PlainNode } from '../nodes';
+import { inferCollapsibleStyle, isCollapsible, isReactNode, parseCollapsible, upsertChildrenData } from '../utils/node';
+import { mergeOptions } from '../utils/options';
+
+const COMMON_OPTIONS: GraphOptions = {
+ node: {
+ type: 'react',
+ style: {},
+ state: {
+ active: {
+ halo: false,
+ },
+ selected: {
+ halo: false,
+ },
+ },
+ },
+ transforms: ['infer-react-style'],
+};
+
+const hierarchicalGraphOptions: GraphOptions = {
+ node: {
+ style: {
+ component: (data: NodeData) => ,
+ size: [80, 40],
+ ports: [{ placement: 'top' }, { placement: 'bottom' }],
+ },
+ },
+ edge: {
+ type: 'polyline',
+ style: {
+ router: {
+ type: 'orth',
+ },
+ },
+ },
+ layout: {
+ type: 'antv-dagre',
+ rankdir: 'TB',
+ },
+ animation: false,
+};
+
+const TEMPLATE_OPTIONS_MAP: Record = {
+ 'hierarchical-graph': hierarchicalGraphOptions,
+};
+
+export const useOptions = (type: GraphType, propOptions: GraphOptions) => {
+ return useMemo(() => {
+ const defaultOptions = mergeOptions(TEMPLATE_OPTIONS_MAP[type] || {}, COMMON_OPTIONS);
+ const options = mergeOptions(propOptions, defaultOptions);
+
+ if (isCollapsible(options)) {
+ const { direction } = parseCollapsible(options.collapsible);
+ options.data = upsertChildrenData(options.data, direction!);
+
+ if (isReactNode(options)) inferCollapsibleStyle(options);
+ }
+
+ return options;
+ }, [type, propOptions]);
+};
diff --git a/packages/graphs/src/core/nodes/index.ts b/packages/graphs/src/core/nodes/index.ts
new file mode 100644
index 000000000..9ad1a4e2b
--- /dev/null
+++ b/packages/graphs/src/core/nodes/index.ts
@@ -0,0 +1,2 @@
+export { OrganizationChartNode } from './organization-chart-node';
+export { PlainNode } from './plain-node';
diff --git a/packages/graphs/src/core/nodes/organization-chart-node.tsx b/packages/graphs/src/core/nodes/organization-chart-node.tsx
new file mode 100644
index 000000000..47b60fe68
--- /dev/null
+++ b/packages/graphs/src/core/nodes/organization-chart-node.tsx
@@ -0,0 +1,101 @@
+import { Avatar, Flex } from 'antd';
+import React from 'react';
+import styled, { css } from 'styled-components';
+
+interface OrganizationChartNodeProps extends Pick, 'className' | 'style'> {
+ /**
+ * Name of the person
+ */
+ name: string;
+ /**
+ * Position of the person
+ */
+ position: string;
+ /**
+ * Working status of the person
+ */
+ status: string;
+ /**
+ * Whether the node is hovered
+ */
+ isActive?: boolean;
+}
+
+const StyledWrapper = styled.div<{ color?: string; isActive?: boolean }>`
+ height: inherit;
+ width: inherit;
+ border-radius: 4px;
+ box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.1);
+ position: relative;
+ border: 3px solid transparent;
+ background-color: #fff;
+
+ ${(props) =>
+ props.isActive &&
+ css`
+ border: 3px solid #1783ff;
+ `}
+
+ .line {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 6px;
+ background-color: ${(props) => props.color};
+ border-radius: 2px 2px 0 0;
+ }
+
+ .node-content {
+ height: inherit;
+ margin: 4px 16px 8px;
+
+ &-avatar {
+ width: 40px;
+ height: 40px;
+ margin-right: 16px;
+ background-color: ${(props) => props.color};
+ font-weight: 600;
+ font-size: 18px;
+ }
+
+ &-detail {
+ flex: 1;
+ }
+
+ &-name {
+ color: #242424;
+ font-weight: 600;
+ font-size: 18px;
+ margin-bottom: 5px;
+ }
+
+ &-post {
+ color: #616161;
+ font-size: 14px;
+ }
+ }
+`;
+
+export const OrganizationChartNode: React.FC = (props) => {
+ const { name, position, status, isActive, className, style } = props;
+
+ const colorMap = {
+ online: '#1783FF',
+ busy: '#00C9C9',
+ offline: '#F08F56',
+ };
+
+ return (
+
+
+
+ {name.slice(0, 1)}
+
+ {name}
+ {position}
+
+
+
+ );
+};
diff --git a/packages/graphs/src/core/nodes/plain-node.tsx b/packages/graphs/src/core/nodes/plain-node.tsx
new file mode 100644
index 000000000..dea61acde
--- /dev/null
+++ b/packages/graphs/src/core/nodes/plain-node.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import styled, { css } from 'styled-components';
+
+const StyledWrapper = styled.div<{ $isActive?: boolean }>`
+ --default-color: #1783ff;
+
+ position: relative;
+ height: inherit;
+ width: inherit;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--default-color);
+ color: #fff;
+ border-radius: 8px;
+ cursor: pointer;
+ border: 2px solid var(--default-color);
+
+ ${(props) =>
+ props.$isActive &&
+ css`
+ border: 2px solid #000;
+ `}
+`;
+
+interface PlainNodeProps extends Pick, 'className' | 'style'> {
+ /**
+ * Text to display
+ */
+ text: string;
+ /**
+ * Whether the node is active
+ * @default false
+ */
+ isActive?: boolean;
+}
+
+export const PlainNode: React.FC = (props) => {
+ const { text, isActive, className, style } = props;
+
+ return (
+
+ {text}
+
+ );
+};
diff --git a/packages/graphs/src/core/nodes/types.ts b/packages/graphs/src/core/nodes/types.ts
new file mode 100644
index 000000000..9dbc8cf6c
--- /dev/null
+++ b/packages/graphs/src/core/nodes/types.ts
@@ -0,0 +1,12 @@
+import type { Graph, NodeData } from '@antv/g6';
+
+export interface NodeProps {
+ /**
+ * Node data
+ */
+ data: NodeData;
+ /**
+ * G6 Graph instance
+ */
+ graph: Graph;
+}
diff --git a/packages/graphs/src/core/registry/build-in.ts b/packages/graphs/src/core/registry/build-in.ts
new file mode 100644
index 000000000..fe4deff02
--- /dev/null
+++ b/packages/graphs/src/core/registry/build-in.ts
@@ -0,0 +1,11 @@
+import { ReactNode } from '@antv/g6-extension-react';
+import { InferReactStyle } from '../transform';
+
+export const BUILT_IN_EXTENSIONS = {
+ node: {
+ react: ReactNode,
+ },
+ transform: {
+ 'infer-react-style': InferReactStyle,
+ },
+};
diff --git a/packages/graphs/src/core/registry/register.ts b/packages/graphs/src/core/registry/register.ts
new file mode 100644
index 000000000..29636706c
--- /dev/null
+++ b/packages/graphs/src/core/registry/register.ts
@@ -0,0 +1,11 @@
+import type { ExtensionCategory } from '@antv/g6';
+import { register } from '@antv/g6';
+import { BUILT_IN_EXTENSIONS } from './build-in';
+
+export function registerBuiltInExtensions() {
+ Object.entries(BUILT_IN_EXTENSIONS).forEach(([category, extensions]) => {
+ Object.entries(extensions).forEach(([type, extension]) => {
+ register(category as ExtensionCategory, type, extension);
+ });
+ });
+}
diff --git a/packages/graphs/src/core/transform/index.ts b/packages/graphs/src/core/transform/index.ts
new file mode 100644
index 000000000..0383a631a
--- /dev/null
+++ b/packages/graphs/src/core/transform/index.ts
@@ -0,0 +1 @@
+export { InferReactStyle } from './infer-react-style';
diff --git a/packages/graphs/src/core/transform/infer-react-style.ts b/packages/graphs/src/core/transform/infer-react-style.ts
new file mode 100644
index 000000000..ff0da5993
--- /dev/null
+++ b/packages/graphs/src/core/transform/infer-react-style.ts
@@ -0,0 +1,30 @@
+import { BaseTransform, idOf } from '@antv/g6';
+
+export class InferReactStyle extends BaseTransform {
+ public afterLayout() {
+ const { graph, model, element } = this.context;
+
+ graph.getNodeData().forEach((datum) => {
+ const nodeId = idOf(datum);
+
+ const node = element!.getElement(nodeId);
+ if (!node) return;
+
+ const style = graph.getElementRenderStyle(nodeId);
+ const { size } = style;
+
+ model.updateNodeData([
+ {
+ id: nodeId,
+ // HTML 元素的默认原点位置在左上角,而 G6 的默认原点位置在中心,所以需要调整 dx 和 dy
+ style: {
+ dx: -size[0] / 2,
+ dy: -size[1] / 2,
+ },
+ },
+ ]);
+ });
+
+ graph.draw();
+ }
+}
diff --git a/packages/graphs/src/core/utils/node.tsx b/packages/graphs/src/core/utils/node.tsx
new file mode 100644
index 000000000..bbfea0fbf
--- /dev/null
+++ b/packages/graphs/src/core/utils/node.tsx
@@ -0,0 +1,106 @@
+import type { EdgeData, EdgeDirection, GraphData, ID, NodeData } from '@antv/g6';
+import { ReactNodeStyleProps } from '@antv/g6-extension-react';
+import { get, has, isObject, set } from 'lodash';
+import React from 'react';
+import type { CollapsibleOptions, GraphOptions } from '../../types';
+import { withCollapsibleNode } from '../hoc';
+
+/**
+ * 判断是否为 React 组件节点
+ * @param options - 图配置项
+ * @returns 是否为 React 组件节点
+ */
+export function isReactNode(options: GraphOptions) {
+ return has(options, 'node.style.component');
+}
+
+/**
+ * 判断是否可展开/收起
+ * @param options - 图配置项
+ * @returns 是否可展开/收起
+ */
+export function isCollapsible(options: GraphOptions) {
+ return Boolean(get(options, 'collapsible', false));
+}
+
+/**
+ * 解析展开/收起配置项
+ * @param collapsible - 用户配置的展开/收起配置项
+ * @returns 展开/收起配置项
+ */
+export function parseCollapsible(collapsible: GraphOptions['collapsible']): CollapsibleOptions {
+ const defaultOptions: CollapsibleOptions = {
+ trigger: 'icon',
+ direction: 'out',
+ iconRender: (isCollapsed: boolean) => (
+
+ {isCollapsed ? '+' : '-'}
+
+ ),
+ };
+
+ return collapsible === true ? defaultOptions : isObject(collapsible) ? { ...defaultOptions, ...collapsible } : {};
+}
+
+/**
+ * 推断可折叠节点的样式
+ * @param options - 图配置项
+ */
+export function inferCollapsibleStyle(options: GraphOptions) {
+ const config = parseCollapsible(options.collapsible);
+ const { component } = options.node!.style as unknown as ReactNodeStyleProps;
+
+ set(options, 'node.style.component', function (data: NodeData) {
+ const CollapsibleNode = withCollapsibleNode(component);
+ // @ts-ignore this 指向 G6 Graph 实例
+ return ;
+ });
+}
+
+/**
+ * 获取邻居节点
+ * @param nodeId - 节点 ID
+ * @param edges - 边数据
+ * @param direction - 边的方向
+ * @returns 邻居节点 ID
+ */
+export const getNeighborNodeIds = (nodeId: ID, edges: EdgeData[], direction: EdgeDirection): ID[] => {
+ const getSuccessorNodeIds = (reverse = false): ID[] => {
+ const [source, target] = reverse ? ['target', 'source'] : ['source', 'target'];
+ return edges.filter((edge) => edge[source] === nodeId).map((edge) => edge[target]) as ID[];
+ };
+
+ if (direction === 'out') return getSuccessorNodeIds();
+ if (direction === 'in') return getSuccessorNodeIds(true);
+ return getSuccessorNodeIds().concat(getSuccessorNodeIds(true));
+};
+
+/**
+ * 插入或更新子节点数据,children 字段在展开/折叠节点时被消费
+ * @param data - 图数据
+ * @param direction - 边的方向
+ * @returns 更新后的图数据
+ */
+export function upsertChildrenData(data: GraphData, direction: EdgeDirection): GraphData {
+ const { nodes = [], edges = [] } = data || {};
+
+ return {
+ ...data,
+ nodes: nodes.map((node) => ({
+ ...node,
+ children: getNeighborNodeIds(node.id, edges, direction),
+ })),
+ };
+}
diff --git a/packages/graphs/src/core/utils/options.ts b/packages/graphs/src/core/utils/options.ts
new file mode 100644
index 000000000..0e90b6cc5
--- /dev/null
+++ b/packages/graphs/src/core/utils/options.ts
@@ -0,0 +1,38 @@
+import { isPlainObject } from 'lodash';
+import type { GraphOptions, ParsedGraphOptions } from '../../types';
+
+/**
+ * 将用户配置项与默认配置项合并
+ * @param options - 用户配置项
+ * @param defaultOptions - 默认配置项
+ * @returns 最后用于渲染的配置项
+ */
+export function mergeOptions(options: GraphOptions, defaultOptions: GraphOptions): ParsedGraphOptions {
+ const merged = { ...defaultOptions };
+
+ for (const key in options) {
+ if (options.hasOwnProperty(key)) {
+ const propValue = options[key];
+ const defaultValue = defaultOptions[key];
+
+ if (['component', 'data'].includes(key)) {
+ merged[key] = propValue;
+ } else if (typeof propValue === 'function') {
+ merged[key] = function (datum) {
+ return mergeOptions(propValue.call(this, datum), defaultValue);
+ };
+ } else if (
+ isPlainObject(propValue) &&
+ isPlainObject(defaultValue) &&
+ propValue !== null &&
+ defaultValue !== null
+ ) {
+ merged[key] = mergeOptions(propValue, defaultValue);
+ } else {
+ merged[key] = propValue;
+ }
+ }
+ }
+
+ return merged as ParsedGraphOptions;
+}
diff --git a/packages/graphs/src/index.ts b/packages/graphs/src/index.ts
index 484a28982..914c70800 100644
--- a/packages/graphs/src/index.ts
+++ b/packages/graphs/src/index.ts
@@ -1,4 +1,7 @@
import * as G6 from '@antv/g6';
+import './preset';
-export * from './components';
+export { HierarchicalGraph } from './components';
+export { OrganizationChartNode, PlainNode } from './core/nodes';
+export type { GraphOptions } from './types';
export { G6 };
diff --git a/packages/graphs/src/preset.ts b/packages/graphs/src/preset.ts
new file mode 100644
index 000000000..b65997092
--- /dev/null
+++ b/packages/graphs/src/preset.ts
@@ -0,0 +1,3 @@
+import { registerBuiltInExtensions } from './core/registry/register';
+
+registerBuiltInExtensions();
diff --git a/packages/graphs/src/types.ts b/packages/graphs/src/types.ts
new file mode 100644
index 000000000..fddd10f9b
--- /dev/null
+++ b/packages/graphs/src/types.ts
@@ -0,0 +1,59 @@
+import type { ContainerConfig } from '@ant-design/charts-util';
+import type { Graph, GraphOptions as G6GraphOptions } from '@antv/g6';
+
+export type GraphType = 'hierarchical-graph';
+
+export interface GraphOptions extends ContainerConfig, G6GraphOptions {
+ /**
+ * 是否支持节点收起/展开
+ * @default true
+ */
+ collapsible?: boolean | CollapsibleOptions;
+ /**
+ * 当图初始化完成后(即 new Graph() 之后)执行回调
+ */
+ onInit?: (graph: Graph) => void;
+ /**
+ * 当图渲染完成后(即 graph.render() 之后)执行回调
+ */
+ onReady?: (graph: Graph) => void;
+ /**
+ * 当图销毁后(即 graph.destroy() 之后)执行回调
+ */
+ onDestroy?: () => void;
+}
+
+export type ParsedGraphOptions = Required;
+
+export interface CollapsibleOptions {
+ /**
+ * 点击指定元素,触发节点收起/展开
+ * - 'icon': 点击内置图标
+ * - 'node': 点击节点
+ * - HTMLElement: 自定义元素
+ * @default 'icon'
+ */
+ trigger?: 'icon' | 'node' | HTMLElement;
+ /**
+ * 收起/展开指定方向上的邻居节点
+ * - 'in': 前驱节点
+ * - 'out': 后继节点
+ * - 'both': 前驱和后继节点
+ * @default 'out'
+ */
+ direction?: 'in' | 'out' | 'both';
+ /**
+ * 渲染函数,用于自定义收起/展开图标
+ * @param isCollapsed - 当前节点是否已收起
+ * @returns 自定义图标
+ */
+ iconRender?: (isCollapsed: boolean) => React.ReactNode;
+ /**
+ * 指定图标的 CSS 类名
+ */
+ iconClassName?: string;
+ /**
+ * 指定图标的内联样式
+ */
+ iconStyle?: React.CSSProperties;
+}
diff --git a/packages/graphs/tests/demos/error-boundary.tsx b/packages/graphs/tests/demos/error-boundary.tsx
deleted file mode 100644
index 0376b99d1..000000000
--- a/packages/graphs/tests/demos/error-boundary.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import React from 'react';
-
-export const ErrorBoundary = () => {
- return ErrorBoundary
;
-};
diff --git a/packages/graphs/tests/demos/hierarchical-graph.tsx b/packages/graphs/tests/demos/hierarchical-graph.tsx
new file mode 100644
index 000000000..850e8c268
--- /dev/null
+++ b/packages/graphs/tests/demos/hierarchical-graph.tsx
@@ -0,0 +1,27 @@
+import { GraphOptions, HierarchicalGraph as HierarchicalGraphComponent } from '@ant-design/graphs';
+import { Graph } from '@antv/g6';
+import React, { useEffect, useState } from 'react';
+
+export const HierarchicalGraph = () => {
+ const [graph, setGraph] = useState(null);
+ const [data, setData] = useState(undefined);
+
+ useEffect(() => {
+ fetch('https://assets.antv.antgroup.com/g6/organization-chart.json')
+ .then((res) => res.json())
+ .then(setData);
+ }, []);
+
+ const options: GraphOptions = {
+ autoFit: 'view',
+ data,
+ };
+
+ useEffect(() => {
+ if (graph) {
+ console.log(graph);
+ }
+ }, [graph]);
+
+ return ;
+};
diff --git a/packages/graphs/tests/demos/index.tsx b/packages/graphs/tests/demos/index.tsx
index 538d0b512..096b3e706 100644
--- a/packages/graphs/tests/demos/index.tsx
+++ b/packages/graphs/tests/demos/index.tsx
@@ -1 +1,2 @@
-export { ErrorBoundary } from './error-boundary';
+export { HierarchicalGraph } from './hierarchical-graph';
+export { OrganizationChart } from './organization-chart';
diff --git a/packages/graphs/tests/demos/organization-chart.tsx b/packages/graphs/tests/demos/organization-chart.tsx
new file mode 100644
index 000000000..93e8a24c3
--- /dev/null
+++ b/packages/graphs/tests/demos/organization-chart.tsx
@@ -0,0 +1,52 @@
+import {
+ GraphOptions,
+ HierarchicalGraph as HierarchicalGraphComponent,
+ OrganizationChartNode,
+} from '@ant-design/graphs';
+import React, { useEffect, useState } from 'react';
+
+export const OrganizationChart = () => {
+ const [data, setData] = useState(undefined);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ fetch('https://assets.antv.antgroup.com/g6/organization-chart.json')
+ .then((res) => res.json())
+ .then((data) => {
+ setData(data);
+ setLoading(false);
+ });
+ }, []);
+
+ const options: GraphOptions = {
+ collapsible: true,
+ data,
+ padding: 20,
+ autoFit: 'view',
+ node: {
+ style: {
+ component: (d) => {
+ const { name, position, status } = d.data || {};
+ const isActive = d.states?.includes('active');
+ return ;
+ },
+ size: [240, 80],
+ },
+ },
+ edge: {
+ style: {
+ radius: 16,
+ lineWidth: 2,
+ endArrow: true,
+ },
+ },
+ layout: {
+ type: 'antv-dagre',
+ nodesep: 24,
+ ranksep: -20,
+ },
+ };
+
+ return ;
+};
diff --git a/packages/graphs/tests/main.tsx b/packages/graphs/tests/main.tsx
index a81980fe9..8b1e0433a 100644
--- a/packages/graphs/tests/main.tsx
+++ b/packages/graphs/tests/main.tsx
@@ -1,7 +1,7 @@
import { Alert, Flex, Select } from 'antd';
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { Outlet, RouterProvider, createBrowserRouter, useMatch, useNavigate } from 'react-router-dom';
+import { createBrowserRouter, Outlet, RouterProvider, useMatch, useNavigate } from 'react-router-dom';
import * as demos from './demos';
const App = () => {
@@ -40,8 +40,4 @@ const router = createBrowserRouter([
const container = document.getElementById('root')!;
const root = createRoot(container);
-root.render(
-
-
- ,
-);
+root.render();
diff --git a/packages/graphs/tsconfig.json b/packages/graphs/tsconfig.json
index 24fbbfd32..ae7aad3bb 100644
--- a/packages/graphs/tsconfig.json
+++ b/packages/graphs/tsconfig.json
@@ -4,7 +4,11 @@
"pretty": true,
"resolveJsonModule": true,
"strict": true,
- "paths": {}
+ "baseUrl": ".",
+ "paths": {
+ "@ant-design/graphs": ["./src/index.ts"],
+ "@ant-design/charts-util": ["../util/src/index.ts"]
+ }
},
"exclude": ["node_modules", "dist", "lib", "es"],
"extends": "../../tsconfig.json",
diff --git a/packages/graphs/vite.config.ts b/packages/graphs/vite.config.ts
index ef0ef287e..c53edf895 100644
--- a/packages/graphs/vite.config.ts
+++ b/packages/graphs/vite.config.ts
@@ -11,6 +11,7 @@ export default defineConfig({
resolve: {
alias: {
'@ant-design/graphs': path.resolve(__dirname, './src'),
+ '@ant-design/graphs-util': path.resolve(__dirname, '../../util/lib/index.js'),
},
},
});
diff --git a/packages/plots/src/interface.ts b/packages/plots/src/interface.ts
index 58c955fde..449c39f6e 100644
--- a/packages/plots/src/interface.ts
+++ b/packages/plots/src/interface.ts
@@ -1,4 +1,5 @@
-import type { TooltipComponent, Data as G2Data } from '@antv/g2';
+import type { ContainerConfig } from '@ant-design/charts-util';
+import type { Data as G2Data, TooltipComponent } from '@antv/g2';
import { Options, Spec } from './core';
/**
@@ -14,53 +15,6 @@ export interface Chart extends Plot {
downloadImage?: (name?: string, type?: string, encoderOptions?: number) => string;
}
-export interface ContainerConfig {
- /**
- * @title 图表样式
- * @description 配置容器样式
- * @title.en_US Chart containerStyle
- * @description.en_US Configure chart container styles
- */
- containerStyle?: React.CSSProperties;
- /**
- * @title 容器自定义属性
- * @description 配置容器自定义属性
- * @title.en_US Chart containerAttr
- * @description.en_US Configure chart container attributes
- */
- containerAttributes?: Record;
- /**
- * @title 容器class
- * @description 类名添加
- * @title.en_US Container class name
- * @description.en_US Class name addition
- */
- className?: string;
- /**
- * @title 加载状态
- * @description 是否加载中
- * @default false
- * @title.en_US Loading status
- * @description.en_US Is it loading
- * @default.en_US false
- */
- loading?: boolean;
- /**
- * @title 加载模板
- * @description 加载模板
- * @title.en_US Load template
- * @description.en_US Load template
- */
- loadingTemplate?: React.ReactElement;
- /**
- * @title 出错模板
- * @description 出错时占位模板
- * @title.en_US error template
- * @description.en_US Error placeholder template
- */
- errorTemplate?: (e: Error) => React.ReactNode;
-}
-
export interface AttachConfig {
/**
* @title 浮窗提示
diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts
index 987766a78..ea75206a3 100644
--- a/packages/util/src/index.ts
+++ b/packages/util/src/index.ts
@@ -1,3 +1,4 @@
-export * from './react';
export * from './rc';
+export * from './react';
+export type { ContainerConfig } from './types';
export { uuid } from './uuid';
diff --git a/packages/util/src/types.ts b/packages/util/src/types.ts
new file mode 100644
index 000000000..8ee10bf19
--- /dev/null
+++ b/packages/util/src/types.ts
@@ -0,0 +1,46 @@
+export interface ContainerConfig {
+ /**
+ * @title 图表样式
+ * @description 配置容器样式
+ * @title.en_US Chart containerStyle
+ * @description.en_US Configure chart container styles
+ */
+ containerStyle?: React.CSSProperties;
+ /**
+ * @title 容器自定义属性
+ * @description 配置容器自定义属性
+ * @title.en_US Chart containerAttr
+ * @description.en_US Configure chart container attributes
+ */
+ containerAttributes?: Record;
+ /**
+ * @title 容器class
+ * @description 类名添加
+ * @title.en_US Container class name
+ * @description.en_US Class name addition
+ */
+ className?: string;
+ /**
+ * @title 加载状态
+ * @description 是否加载中
+ * @default false
+ * @title.en_US Loading status
+ * @description.en_US Is it loading
+ * @default.en_US false
+ */
+ loading?: boolean;
+ /**
+ * @title 加载模板
+ * @description 加载模板
+ * @title.en_US Load template
+ * @description.en_US Load template
+ */
+ loadingTemplate?: React.ReactElement;
+ /**
+ * @title 出错模板
+ * @description 出错时占位模板
+ * @title.en_US error template
+ * @description.en_US Error placeholder template
+ */
+ errorTemplate?: (e: Error) => React.ReactNode;
+}