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; +}