From bfcfa95517e2971ef4c7b338c29eddbf8bb903f3 Mon Sep 17 00:00:00 2001 From: Yuxin <55794321+yvonneyx@users.noreply.github.com> Date: Wed, 18 Sep 2024 13:40:14 +0800 Subject: [PATCH] feat(graphs): add mindmap (#2697) * refactor: extract options logic into upper component * feat: icon supports placement, offsetX, offsetY * feat: mindmap * fix: fix ci issues * feat: custom styled mindmap * refactor: change collapsible option to transform * fix: fix ci issues * refactor: rename infer-react-style to translate-react-node-origin * refactor: modify field names * fix: fix ci issues --- .../components/hierarchical-graph/index.tsx | 64 +++++++-- packages/graphs/src/components/index.ts | 5 +- .../graphs/src/components/mind-map/index.tsx | 90 +++++++++++++ packages/graphs/src/core/base-graph.tsx | 61 ++++----- packages/graphs/src/core/constants/index.ts | 1 + packages/graphs/src/core/constants/options.ts | 5 + .../src/core/hoc/with-collapsible-node.tsx | 54 +++++--- packages/graphs/src/core/hooks/index.ts | 1 - packages/graphs/src/core/hooks/useOptions.tsx | 66 ---------- packages/graphs/src/core/nodes/index.ts | 1 + .../graphs/src/core/nodes/mind-map-node.tsx | 81 ++++++++++++ .../core/nodes/organization-chart-node.tsx | 26 ++-- packages/graphs/src/core/registry/build-in.ts | 6 +- .../core/transform/assign-color-by-branch.ts | 47 +++++++ .../transform/collapse-expand-react-node.tsx | 121 ++++++++++++++++++ packages/graphs/src/core/transform/index.ts | 6 +- ...tyle.ts => translate-react-node-origin.ts} | 2 +- packages/graphs/src/core/utils/data.tsx | 19 +++ .../graphs/src/core/utils/measure-text.ts | 21 +++ packages/graphs/src/core/utils/node.tsx | 112 ++-------------- packages/graphs/src/core/utils/options.ts | 52 ++++---- packages/graphs/src/index.ts | 6 +- packages/graphs/src/types.ts | 49 ++----- .../tests/datasets/algorithm-category.json | 52 ++++++++ .../graphs/tests/datasets/mind-mapping.json | 25 ++++ packages/graphs/tests/demos/index.tsx | 2 + packages/graphs/tests/demos/mind-map.tsx | 23 ++++ packages/graphs/tests/demos/mind-map2.tsx | 90 +++++++++++++ .../graphs/tests/demos/organization-chart.tsx | 5 +- packages/plots/src/core/utils/index.ts | 1 - packages/plots/src/core/utils/measure-text.ts | 19 --- packages/plots/src/index.ts | 4 +- packages/util/package.json | 3 + packages/util/src/index.ts | 1 + .../src/core => util/src}/utils/context.ts | 0 packages/util/src/utils/measure-text.ts | 34 +++++ 36 files changed, 817 insertions(+), 338 deletions(-) create mode 100644 packages/graphs/src/components/mind-map/index.tsx create mode 100644 packages/graphs/src/core/constants/index.ts create mode 100644 packages/graphs/src/core/constants/options.ts delete mode 100644 packages/graphs/src/core/hooks/index.ts delete mode 100644 packages/graphs/src/core/hooks/useOptions.tsx create mode 100644 packages/graphs/src/core/nodes/mind-map-node.tsx create mode 100644 packages/graphs/src/core/transform/assign-color-by-branch.ts create mode 100644 packages/graphs/src/core/transform/collapse-expand-react-node.tsx rename packages/graphs/src/core/transform/{infer-react-style.ts => translate-react-node-origin.ts} (91%) create mode 100644 packages/graphs/src/core/utils/data.tsx create mode 100644 packages/graphs/src/core/utils/measure-text.ts create mode 100644 packages/graphs/tests/datasets/algorithm-category.json create mode 100644 packages/graphs/tests/datasets/mind-mapping.json create mode 100644 packages/graphs/tests/demos/mind-map.tsx create mode 100644 packages/graphs/tests/demos/mind-map2.tsx delete mode 100644 packages/plots/src/core/utils/measure-text.ts rename packages/{plots/src/core => util/src}/utils/context.ts (100%) create mode 100644 packages/util/src/utils/measure-text.ts diff --git a/packages/graphs/src/components/hierarchical-graph/index.tsx b/packages/graphs/src/components/hierarchical-graph/index.tsx index f98841617..0b6f9be36 100644 --- a/packages/graphs/src/components/hierarchical-graph/index.tsx +++ b/packages/graphs/src/components/hierarchical-graph/index.tsx @@ -1,14 +1,60 @@ -import type { Graph } from '@antv/g6'; -import React, { forwardRef, ForwardRefExoticComponent, PropsWithChildren, PropsWithoutRef, RefAttributes } from 'react'; +import type { Graph, NodeData } from '@antv/g6'; +import { idOf } from '@antv/g6'; +import React, { + forwardRef, + ForwardRefExoticComponent, + PropsWithChildren, + PropsWithoutRef, + RefAttributes, + useMemo, +} from 'react'; import { BaseGraph } from '../../core/base-graph'; +import { COMMON_OPTIONS } from '../../core/constants'; +import { PlainNode } from '../../core/nodes'; +import { mergeOptions } from '../../core/utils/options'; import type { GraphOptions } from '../../types'; -const HierarchicalGraph: ForwardRefExoticComponent< +const DEFAULT_OPTIONS: GraphOptions = { + node: { + type: 'react', + style: { + component: (data: NodeData) => , + size: [80, 40], + ports: [{ placement: 'top' }, { placement: 'bottom' }], + }, + state: { + active: { + halo: false, + }, + selected: { + halo: false, + }, + }, + }, + edge: { + type: 'polyline', + style: { + router: { + type: 'orth', + }, + }, + }, + layout: { + type: 'antv-dagre', + rankdir: 'TB', + }, + transforms: ['translate-react-node-origin'], + animation: false, +}; + +export const HierarchicalGraph: ForwardRefExoticComponent< PropsWithoutRef> & RefAttributes -> = forwardRef>(({ children, ...props }, ref) => ( - - {children} - -)); +> = forwardRef>(({ children, ...props }, ref) => { + const options = useMemo(() => mergeOptions(COMMON_OPTIONS, DEFAULT_OPTIONS, props), [props]); -export default HierarchicalGraph; + return ( + + {children} + + ); +}); diff --git a/packages/graphs/src/components/index.ts b/packages/graphs/src/components/index.ts index b6a0316cc..4af07c0b0 100644 --- a/packages/graphs/src/components/index.ts +++ b/packages/graphs/src/components/index.ts @@ -1,3 +1,2 @@ -import HierarchicalGraph from './hierarchical-graph'; - -export { HierarchicalGraph }; +export { HierarchicalGraph } from './hierarchical-graph'; +export { MindMap } from './mind-map'; diff --git a/packages/graphs/src/components/mind-map/index.tsx b/packages/graphs/src/components/mind-map/index.tsx new file mode 100644 index 000000000..f6a31d008 --- /dev/null +++ b/packages/graphs/src/components/mind-map/index.tsx @@ -0,0 +1,90 @@ +import type { Graph, NodeData } from '@antv/g6'; +import { idOf } from '@antv/g6'; +import React, { + forwardRef, + ForwardRefExoticComponent, + PropsWithChildren, + PropsWithoutRef, + RefAttributes, + useMemo, +} from 'react'; +import { BaseGraph } from '../../core/base-graph'; +import { COMMON_OPTIONS } from '../../core/constants'; +import { measureMindMapNodeSize, MindMapNode } from '../../core/nodes'; +import { getNodeSide } from '../../core/utils/node'; +import { mergeOptions } from '../../core/utils/options'; +import type { GraphOptions } from '../../types'; + +const DEFAULT_OPTIONS: GraphOptions = { + node: { + type: 'react', + style: { + component: (data: NodeData) => ( + + ), + size: (data: NodeData) => measureMindMapNodeSize(data), + dx: function (data: NodeData) { + const parentData = (this as unknown as Graph).getParentData(idOf(data), 'tree'); + const side = getNodeSide(data, parentData); + const size = measureMindMapNodeSize(data); + return side === 'left' ? -size[0] : side === 'center' ? -size[0] / 2 : 0; + }, + ports: [{ placement: 'left' }, { placement: 'right' }], + }, + state: { + active: { + halo: false, + }, + selected: { + halo: false, + }, + }, + }, + edge: { + type: 'cubic-horizontal', + style: { + stroke: function (data) { + return (this.getNodeData(data.source).style!.color as string) || '#99ADD1'; + }, + lineWidth: 2, + }, + }, + layout: { + type: 'mindmap', + direction: 'H', + getWidth: () => 120, + getHeight: (data) => measureMindMapNodeSize(data)[1], + getVGap: () => 28, + getHGap: () => 64, + animation: false, + }, + transforms: (prev) => [ + ...prev, + 'assign-color-by-branch', + { + type: 'collapse-expand-react-node', + key: 'collapse-expand-react-node', + trigger: 'node', + iconPlacement: function (data: NodeData) { + const parentData = (this as unknown as Graph).getParentData(idOf(data), 'tree'); + const side = getNodeSide(data, parentData); + return side === 'left' ? 'left' : 'right'; + }, + }, + ], + animation: { + duration: 500, + }, +}; + +export const MindMap: ForwardRefExoticComponent< + PropsWithoutRef> & RefAttributes +> = forwardRef>(({ children, ...props }, ref) => { + const options = useMemo(() => mergeOptions(COMMON_OPTIONS, DEFAULT_OPTIONS, props), [props]); + + return ( + + {children} + + ); +}); diff --git a/packages/graphs/src/core/base-graph.tsx b/packages/graphs/src/core/base-graph.tsx index d50bee9fc..38d9b11cf 100644 --- a/packages/graphs/src/core/base-graph.tsx +++ b/packages/graphs/src/core/base-graph.tsx @@ -1,6 +1,7 @@ import { ChartLoading, ErrorBoundary } from '@ant-design/charts-util'; -import type { Graph } from '@antv/g6'; +import type { Graph, GraphOptions as G6GraphOptions } from '@antv/g6'; import { Graphin } from '@antv/graphin'; +import { isEmpty } from 'lodash'; import React, { forwardRef, ForwardRefExoticComponent, @@ -10,53 +11,35 @@ import React, { useImperativeHandle, useRef, } from 'react'; -import type { GraphOptions, GraphType } from '../types'; -import { useOptions } from './hooks'; - -interface BaseGraphProps extends GraphOptions { - /** - * 内部属性,只读 - */ - readonly type: GraphType; -} +import type { GraphOptions } from '../types'; export const BaseGraph: ForwardRefExoticComponent< - PropsWithoutRef> & RefAttributes -> = forwardRef>(({ children, ...props }, ref) => { - const { - type, - containerStyle, - className, - onInit, - onReady, - onDestroy, - errorTemplate, - loading, - loadingTemplate, - ...propOptions - } = props; + PropsWithoutRef> & RefAttributes +> = forwardRef>(({ children, ...props }, ref) => { + const { containerStyle, className, onInit, onReady, onDestroy, errorTemplate, loading, loadingTemplate, ...options } = + 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} - + {!isEmpty(options.data) && ( + { + graphRef.current = ref; + }} + className={className} + style={containerStyle} + options={options as G6GraphOptions} + onInit={onInit} + onReady={onReady} + onDestroy={onDestroy} + > + {children} + + )} ); }); diff --git a/packages/graphs/src/core/constants/index.ts b/packages/graphs/src/core/constants/index.ts new file mode 100644 index 000000000..1d9707146 --- /dev/null +++ b/packages/graphs/src/core/constants/index.ts @@ -0,0 +1 @@ +export { COMMON_OPTIONS } from './options'; diff --git a/packages/graphs/src/core/constants/options.ts b/packages/graphs/src/core/constants/options.ts new file mode 100644 index 000000000..b926dae9a --- /dev/null +++ b/packages/graphs/src/core/constants/options.ts @@ -0,0 +1,5 @@ +import type { GraphOptions } from '../../types'; + +export const COMMON_OPTIONS: GraphOptions = { + behaviors: ['drag-canvas', 'zoom-canvas'], +}; diff --git a/packages/graphs/src/core/hoc/with-collapsible-node.tsx b/packages/graphs/src/core/hoc/with-collapsible-node.tsx index ea6768737..e500e52b2 100644 --- a/packages/graphs/src/core/hoc/with-collapsible-node.tsx +++ b/packages/graphs/src/core/hoc/with-collapsible-node.tsx @@ -1,33 +1,46 @@ +import type { CardinalPlacement } from '@antv/g6'; 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 styled, { css } from 'styled-components'; import type { NodeProps } from '../nodes/types'; +import type { CollapseExpandReactNodeOptions } from '../transform'; 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; +const StyledIcon = styled.div<{ placement: CardinalPlacement; offsetX: number; offsetY: number }>` + position: absolute; + transform: translate(-50%, -50%); + z-index: 1; - &:hover { - cursor: pointer; - } + &:hover { + cursor: pointer; } + + ${({ placement, offsetX, offsetY }) => { + const positions = { + top: `left: calc(50% + ${offsetX}px); top: ${offsetY}px;`, + bottom: `left: calc(50% + ${offsetX}px); top: calc(100% + ${offsetY}px);`, + right: `left: calc(100% + ${offsetX}px); top: calc(50% + ${offsetY}px);`, + left: `left: ${offsetX}px; top: calc(50% + ${offsetY}px);`, + }; + + return css` + ${positions[placement]} + `; + }} `; -interface CollapsibleNodeProps extends NodeProps, CollapsibleOptions {} +interface CollapsibleNodeProps extends NodeProps, CollapseExpandReactNodeOptions {} export const withCollapsibleNode = (NodeComponent: React.FC) => { return (props: CollapsibleNodeProps) => { - const { data, graph, trigger, iconRender, iconClassName = '', iconStyle } = props; + const { data, graph, trigger, iconRender, iconPlacement, iconOffsetX, iconOffsetY, iconClassName, iconStyle } = + props as Required; const [isCollapsed, setIsCollapsed] = useState(get(data, 'style.collapsed', false)); const wrapperRef = useRef(null); const iconRef = useRef(null); @@ -53,12 +66,23 @@ export const withCollapsibleNode = (NodeComponent: React.FC) => { }; }, [trigger, isCollapsed]); + const computeCallbackStyle = (callableStyle: Function | number | string) => { + return typeof callableStyle === 'function' ? callableStyle.call(graph, data) : callableStyle; + }; + 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 deleted file mode 100644 index 52227a29a..000000000 --- a/packages/graphs/src/core/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useOptions } from './useOptions'; diff --git a/packages/graphs/src/core/hooks/useOptions.tsx b/packages/graphs/src/core/hooks/useOptions.tsx deleted file mode 100644 index 9875b73dc..000000000 --- a/packages/graphs/src/core/hooks/useOptions.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 index 9ad1a4e2b..06f9960bd 100644 --- a/packages/graphs/src/core/nodes/index.ts +++ b/packages/graphs/src/core/nodes/index.ts @@ -1,2 +1,3 @@ +export { measureMindMapNodeSize, MindMapNode } from './mind-map-node'; export { OrganizationChartNode } from './organization-chart-node'; export { PlainNode } from './plain-node'; diff --git a/packages/graphs/src/core/nodes/mind-map-node.tsx b/packages/graphs/src/core/nodes/mind-map-node.tsx new file mode 100644 index 000000000..f2a7a8622 --- /dev/null +++ b/packages/graphs/src/core/nodes/mind-map-node.tsx @@ -0,0 +1,81 @@ +import type { NodeData } from '@antv/g6'; +import { idOf } from '@antv/g6'; +import React from 'react'; +import styled, { css } from 'styled-components'; +import { measureTextSize } from '../utils/measure-text'; + +const StyledWrapper = styled.div<{ $depth: number; $color: string }>` + --border-width: 2px; + + position: relative; + height: calc(100% - 2 * var(--border-width)); + width: calc(100% - 2 * var(--border-width)); + border: var(--border-width) solid #99add1; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + overflow-wrap: anywhere; + + ${({ $depth, $color }) => { + if ($depth === 0) { + // main-topic + return css` + border-color: #f1f4f5; + background-color: #f1f4f5; + font-color: #252525; + font-size: 20px; + padding: 6px; + transform: translate(-3px, -3px); + `; + } else if ($depth === 1) { + // brainstorming-topic + return css` + color: #fff; + background-color: ${$color}; + border-color: ${$color}; + font-size: 18px; + `; + } else { + // sub-topic + return css` + color: ${$color}; + background-color: #fff; + border-color: ${$color}; + `; + } + }} +`; + +interface MindMapNodeProps extends Pick, 'className' | 'style'> { + text: string; + depth: number; + color?: string; +} + +export const MindMapNode: React.FC = (props) => { + const { className, style, text, depth, color = '#1783ff' } = props; + + return ( + + {text} + + ); +}; + +export const getMindMapNodeFont = (depth: number) => { + const fontSize = depth === 0 ? 20 : depth === 1 ? 18 : 16; + const font = { fontWeight: 'bold', fontSize, fontFamily: 'PingFang SC' }; + return font; +}; + +/** + * 计算思维导图节点的尺寸,这里节点的尺寸是根据节点的文本内容来计算的 + * @param data - 节点数据 + * @returns 节点的尺寸 + */ +export const measureMindMapNodeSize = (data: NodeData) => { + const font = getMindMapNodeFont(data.data!.depth as number); + return measureTextSize(idOf(data), font, 120, 240, [12, 36]); +}; diff --git a/packages/graphs/src/core/nodes/organization-chart-node.tsx b/packages/graphs/src/core/nodes/organization-chart-node.tsx index 47b60fe68..4b445a77d 100644 --- a/packages/graphs/src/core/nodes/organization-chart-node.tsx +++ b/packages/graphs/src/core/nodes/organization-chart-node.tsx @@ -21,7 +21,7 @@ interface OrganizationChartNodeProps extends Pick` +const StyledWrapper = styled.div<{ $color?: string; $isActive?: boolean }>` height: inherit; width: inherit; border-radius: 4px; @@ -31,22 +31,22 @@ const StyledWrapper = styled.div<{ color?: string; isActive?: boolean }>` background-color: #fff; ${(props) => - props.isActive && + props.$isActive && css` border: 3px solid #1783ff; `} - .line { + .org-chart-node-line { position: absolute; top: 0; left: 0; width: 100%; height: 6px; - background-color: ${(props) => props.color}; + background-color: ${(props) => props.$color}; border-radius: 2px 2px 0 0; } - .node-content { + .org-chart-node-content { height: inherit; margin: 4px 16px 8px; @@ -54,7 +54,7 @@ const StyledWrapper = styled.div<{ color?: string; isActive?: boolean }>` width: 40px; height: 40px; margin-right: 16px; - background-color: ${(props) => props.color}; + background-color: ${(props) => props.$color}; font-weight: 600; font-size: 18px; } @@ -87,13 +87,13 @@ export const OrganizationChartNode: React.FC = (prop }; return ( - -
- - {name.slice(0, 1)} - -
{name}
-
{position}
+ +
+ + {name.slice(0, 1)} + +
{name}
+
{position}
diff --git a/packages/graphs/src/core/registry/build-in.ts b/packages/graphs/src/core/registry/build-in.ts index fe4deff02..43caa8938 100644 --- a/packages/graphs/src/core/registry/build-in.ts +++ b/packages/graphs/src/core/registry/build-in.ts @@ -1,11 +1,13 @@ import { ReactNode } from '@antv/g6-extension-react'; -import { InferReactStyle } from '../transform'; +import { AssignColorByBranch, CollapseExpandReactNode, TranslateReactNodeOrigin } from '../transform'; export const BUILT_IN_EXTENSIONS = { node: { react: ReactNode, }, transform: { - 'infer-react-style': InferReactStyle, + 'translate-react-node-origin': TranslateReactNodeOrigin, + 'collapse-expand-react-node': CollapseExpandReactNode, + 'assign-color-by-branch': AssignColorByBranch, }, }; diff --git a/packages/graphs/src/core/transform/assign-color-by-branch.ts b/packages/graphs/src/core/transform/assign-color-by-branch.ts new file mode 100644 index 000000000..581a9109d --- /dev/null +++ b/packages/graphs/src/core/transform/assign-color-by-branch.ts @@ -0,0 +1,47 @@ +import type { BaseTransformOptions, CategoricalPalette, DrawData, RuntimeContext } from '@antv/g6'; +import { BaseTransform } from '@antv/g6'; + +export interface AssignColorByBranchOptions extends BaseTransformOptions { + colors?: CategoricalPalette; +} + +export class AssignColorByBranch extends BaseTransform { + static defaultOptions: Partial = { + colors: [ + '#1783FF', + '#F08F56', + '#D580FF', + '#00C9C9', + '#7863FF', + '#DB9D0D', + '#60C42D', + '#FF80CA', + '#2491B3', + '#17C76F', + ], + }; + + constructor(runtime: RuntimeContext, options: AssignColorByBranchOptions) { + super(runtime, 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.data?.depth === 1).forEach((rootNode) => dfs(rootNode.id)); + + return input; + } +} diff --git a/packages/graphs/src/core/transform/collapse-expand-react-node.tsx b/packages/graphs/src/core/transform/collapse-expand-react-node.tsx new file mode 100644 index 000000000..37b4e6635 --- /dev/null +++ b/packages/graphs/src/core/transform/collapse-expand-react-node.tsx @@ -0,0 +1,121 @@ +import type { BaseTransformOptions, CardinalPlacement, Graph, NodeData, RuntimeContext } from '@antv/g6'; +import { BaseTransform, idOf } from '@antv/g6'; +import { get, has, set } from 'lodash'; +import React from 'react'; +import { withCollapsibleNode } from '../hoc'; +import { getNeighborNodeIds } from '../utils/data'; + +export interface CollapseExpandReactNodeOptions extends BaseTransformOptions { + /** + * 点击指定元素,触发节点收起/展开 + * - '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; + /** + * 图标相对于节点的位置 + * @default 'bottom' + */ + iconPlacement?: CardinalPlacement | ((this: Graph, data: NodeData) => CardinalPlacement); + /** + * 图标相对于节点的水平偏移量 + * @default 0 + */ + iconOffsetX?: number | ((this: Graph, data: NodeData) => number); + /** + * 图标相对于节点的垂直偏移量 + * @default 0 + */ + iconOffsetY?: number | ((this: Graph, data: NodeData) => number); + /** + * 指定图标的 CSS 类名 + */ + iconClassName?: string; + /** + * 指定图标的内联样式 + */ + iconStyle?: React.CSSProperties; +} + +const defaultIconRender = (isCollapsed: boolean) => ( +
+ {isCollapsed ? '+' : '-'} +
+); + +export class CollapseExpandReactNode extends BaseTransform { + static defaultOptions: Partial = { + trigger: 'icon', + direction: 'out', + iconRender: defaultIconRender, + iconPlacement: 'bottom', + iconOffsetX: 0, + iconOffsetY: 0, + iconClassName: '', + iconStyle: {}, + }; + + constructor(context: RuntimeContext, options: CollapseExpandReactNodeOptions) { + super(context, Object.assign({}, CollapseExpandReactNode.defaultOptions, options)); + } + + public afterLayout() { + const { graph, element, model } = this.context; + const { nodes = [], edges = [] } = graph.getData(); + const options = this.options; + + nodes.forEach((datum) => { + const nodeId = idOf(datum); + + const node = element!.getElement(nodeId); + if (!node || (datum.children && datum.children.length > 0)) return; + + const children = getNeighborNodeIds(nodeId, edges, this.options.direction); + if (children.length === 0) return; + + model.updateNodeData([{ id: nodeId, children }]); + }); + + const nodeMapper = graph.getOptions().node!; + + if (has(nodeMapper, 'style.component')) { + const component = get(nodeMapper, 'style.component'); + set(nodeMapper, 'style.component', function (data: NodeData) { + const CollapsibleNode = withCollapsibleNode(component); + // @ts-ignore this 指向 G6 Graph 实例 + return ; + }); + } + + graph.setNode(nodeMapper); + graph.draw(); + } +} diff --git a/packages/graphs/src/core/transform/index.ts b/packages/graphs/src/core/transform/index.ts index 0383a631a..36f881477 100644 --- a/packages/graphs/src/core/transform/index.ts +++ b/packages/graphs/src/core/transform/index.ts @@ -1 +1,5 @@ -export { InferReactStyle } from './infer-react-style'; +export { AssignColorByBranch } from './assign-color-by-branch'; +export type { AssignColorByBranchOptions } from './assign-color-by-branch'; +export { CollapseExpandReactNode } from './collapse-expand-react-node'; +export type { CollapseExpandReactNodeOptions } from './collapse-expand-react-node'; +export { TranslateReactNodeOrigin } from './translate-react-node-origin'; diff --git a/packages/graphs/src/core/transform/infer-react-style.ts b/packages/graphs/src/core/transform/translate-react-node-origin.ts similarity index 91% rename from packages/graphs/src/core/transform/infer-react-style.ts rename to packages/graphs/src/core/transform/translate-react-node-origin.ts index ff0da5993..b85171488 100644 --- a/packages/graphs/src/core/transform/infer-react-style.ts +++ b/packages/graphs/src/core/transform/translate-react-node-origin.ts @@ -1,6 +1,6 @@ import { BaseTransform, idOf } from '@antv/g6'; -export class InferReactStyle extends BaseTransform { +export class TranslateReactNodeOrigin extends BaseTransform { public afterLayout() { const { graph, model, element } = this.context; diff --git a/packages/graphs/src/core/utils/data.tsx b/packages/graphs/src/core/utils/data.tsx new file mode 100644 index 000000000..0595882ab --- /dev/null +++ b/packages/graphs/src/core/utils/data.tsx @@ -0,0 +1,19 @@ +import type { EdgeData, EdgeDirection, ID } from '@antv/g6'; + +/** + * 获取邻居节点 + * @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)); +}; diff --git a/packages/graphs/src/core/utils/measure-text.ts b/packages/graphs/src/core/utils/measure-text.ts new file mode 100644 index 000000000..ba3269a73 --- /dev/null +++ b/packages/graphs/src/core/utils/measure-text.ts @@ -0,0 +1,21 @@ +import { measureTextHeight, measureTextWidth } from '@ant-design/charts-util'; +import type { Size } from '@antv/g6'; + +/** + * 计算文本尺寸 + * @param text - 文本内容 + * @param font - 字体样式 + * @param minWidth - 最小宽度,默认为 0 + * @param maxWith - 最大宽度,默认为 Infinity;超出部分会被自动换行 + * @param offset - 水平和垂直偏移量,默认为 [0, 0],用于调整文本节点的大小 + * @returns 文本尺寸(包括宽度和高度) + */ +export function measureTextSize(text: string, font: any = {}, minWidth = 0, maxWith = Infinity, offset = [0, 0]): Size { + const height = measureTextHeight(text, font); + const width = measureTextWidth(text, font); + + const lineNumber = Math.ceil(width / maxWith); + const [offsetWidth, offsetHeight] = offset; + + return [Math.max(minWidth, Math.min(maxWith, width)) + offsetWidth, offsetHeight + height * 1.5 * (lineNumber - 1)]; +} diff --git a/packages/graphs/src/core/utils/node.tsx b/packages/graphs/src/core/utils/node.tsx index bbfea0fbf..bf6df6ca5 100644 --- a/packages/graphs/src/core/utils/node.tsx +++ b/packages/graphs/src/core/utils/node.tsx @@ -1,106 +1,16 @@ -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'; +import type { NodeData } from '@antv/g6'; +import { positionOf } from '@antv/g6'; /** - * 判断是否为 React 组件节点 - * @param options - 图配置项 - * @returns 是否为 React 组件节点 + * 获取节点相对于根节点的位置 + * @param nodeData - 节点数据 + * @param parentData - 父节点数据 + * @returns 节点相对于根节点的位置 */ -export function isReactNode(options: GraphOptions) { - return has(options, 'node.style.component'); -} +export const getNodeSide = (nodeData: NodeData, parentData?: NodeData): 'center' | 'left' | 'right' => { + if (!parentData) return 'center'; -/** - * 判断是否可展开/收起 - * @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)); + const nodePositionX = positionOf(nodeData)[0]; + const parentPositionX = positionOf(parentData)[0]; + return parentPositionX > nodePositionX ? 'left' : 'right'; }; - -/** - * 插入或更新子节点数据,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 index 0e90b6cc5..8dd5f087e 100644 --- a/packages/graphs/src/core/utils/options.ts +++ b/packages/graphs/src/core/utils/options.ts @@ -2,34 +2,38 @@ import { isPlainObject } from 'lodash'; import type { GraphOptions, ParsedGraphOptions } from '../../types'; /** - * 将用户配置项与默认配置项合并 - * @param options - 用户配置项 - * @param defaultOptions - 默认配置项 + * 合并多个图配置项,优先级从左到右递增 + * @param options 图配置项列表 * @returns 最后用于渲染的配置项 */ -export function mergeOptions(options: GraphOptions, defaultOptions: GraphOptions): ParsedGraphOptions { - const merged = { ...defaultOptions }; +export function mergeOptions(...options: GraphOptions[]): ParsedGraphOptions { + if (options.length === 0) return {} as ParsedGraphOptions; - for (const key in options) { - if (options.hasOwnProperty(key)) { - const propValue = options[key]; - const defaultValue = defaultOptions[key]; + const merged = { ...options[0] }; - 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; + for (let i = 1; i < options.length; i++) { + const currentOptions = options[i]; + + for (const key in currentOptions) { + if (currentOptions.hasOwnProperty(key)) { + const currValue = currentOptions[key]; + const prevValue = merged[key]; + + if (['component', 'data'].includes(key)) { + merged[key] = currValue; + } else if (typeof currValue === 'function') { + merged[key] = function (datum) { + if (['plugins', 'behaviors', 'transforms'].includes(key)) return currValue(prevValue || []); + + const value = currValue.call(this, datum); + if (isPlainObject(value) && value !== null) return mergeOptions(prevValue, value); + return value; + }; + } else if (isPlainObject(currValue) && isPlainObject(prevValue) && currValue !== null && prevValue !== null) { + merged[key] = mergeOptions(prevValue, currValue); + } else { + merged[key] = currValue; + } } } } diff --git a/packages/graphs/src/index.ts b/packages/graphs/src/index.ts index 914c70800..9fa71aa05 100644 --- a/packages/graphs/src/index.ts +++ b/packages/graphs/src/index.ts @@ -1,7 +1,9 @@ import * as G6 from '@antv/g6'; import './preset'; -export { HierarchicalGraph } from './components'; -export { OrganizationChartNode, PlainNode } from './core/nodes'; +export { HierarchicalGraph, MindMap } from './components'; +export { MindMapNode, OrganizationChartNode, PlainNode } from './core/nodes'; +export { measureTextSize } from './core/utils/measure-text'; +export { getNodeSide } from './core/utils/node'; export type { GraphOptions } from './types'; export { G6 }; diff --git a/packages/graphs/src/types.ts b/packages/graphs/src/types.ts index fddd10f9b..2f046d7d2 100644 --- a/packages/graphs/src/types.ts +++ b/packages/graphs/src/types.ts @@ -1,14 +1,8 @@ import type { ContainerConfig } from '@ant-design/charts-util'; -import type { Graph, GraphOptions as G6GraphOptions } from '@antv/g6'; +import type { BehaviorOptions, Graph, GraphOptions as G6GraphOptions, PluginOptions } from '@antv/g6'; +import type { TransformOptions } from '@antv/g6/lib/spec'; -export type GraphType = 'hierarchical-graph'; - -export interface GraphOptions extends ContainerConfig, G6GraphOptions { - /** - * 是否支持节点收起/展开 - * @default true - */ - collapsible?: boolean | CollapsibleOptions; +export interface GraphOptions extends ContainerConfig, Omit { /** * 当图初始化完成后(即 new Graph() 之后)执行回调 */ @@ -21,39 +15,18 @@ export interface GraphOptions extends ContainerConfig, G6GraphOptions { * 当图销毁后(即 graph.destroy() 之后)执行回调 */ onDestroy?: () => void; -} - -export type ParsedGraphOptions = Required; - -export interface CollapsibleOptions { /** - * 点击指定元素,触发节点收起/展开 - * - 'icon': 点击内置图标 - * - 'node': 点击节点 - * - HTMLElement: 自定义元素 - * @default 'icon' + * 交互 */ - trigger?: 'icon' | 'node' | HTMLElement; + behaviors?: BehaviorOptions | ((prev: BehaviorOptions) => BehaviorOptions); /** - * 收起/展开指定方向上的邻居节点 - * - 'in': 前驱节点 - * - 'out': 后继节点 - * - 'both': 前驱和后继节点 - * @default 'out' + * 画布插件 */ - direction?: 'in' | 'out' | 'both'; + plugins?: PluginOptions | ((prev: PluginOptions) => PluginOptions); /** - * 渲染函数,用于自定义收起/展开图标 - * @param isCollapsed - 当前节点是否已收起 - * @returns 自定义图标 + * 数据转换器 */ - iconRender?: (isCollapsed: boolean) => React.ReactNode; - /** - * 指定图标的 CSS 类名 - */ - iconClassName?: string; - /** - * 指定图标的内联样式 - */ - iconStyle?: React.CSSProperties; + transforms?: TransformOptions | ((prev: TransformOptions) => TransformOptions); } + +export type ParsedGraphOptions = Required; diff --git a/packages/graphs/tests/datasets/algorithm-category.json b/packages/graphs/tests/datasets/algorithm-category.json new file mode 100644 index 000000000..eba800d84 --- /dev/null +++ b/packages/graphs/tests/datasets/algorithm-category.json @@ -0,0 +1,52 @@ +{ + "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" } + ] + } + ] +} diff --git a/packages/graphs/tests/datasets/mind-mapping.json b/packages/graphs/tests/datasets/mind-mapping.json new file mode 100644 index 000000000..9d8652a32 --- /dev/null +++ b/packages/graphs/tests/datasets/mind-mapping.json @@ -0,0 +1,25 @@ +{ + "id": "Mind Mapping", + "children": [ + { + "id": "Benefits", + "children": [{ "id": "Overview" }, { "id": "Easy to memorize" }, { "id": "Simple, fast & fun" }] + }, + { + "id": "Collaboration", + "children": [{ "id": "Teamwork" }, { "id": "Sharing" }, { "id": "Colleagues" }] + }, + { + "id": "Productivity", + "children": [{ "id": "More efficient" }, { "id": "Intuitive" }] + }, + { + "id": "Planning", + "children": [{ "id": "Projects" }, { "id": "Goals" }, { "id": "Strategies" }] + }, + { + "id": "Creativity", + "children": [{ "id": "Ideas" }, { "id": "Innovation" }, { "id": "Thoughts" }] + } + ] +} diff --git a/packages/graphs/tests/demos/index.tsx b/packages/graphs/tests/demos/index.tsx index 096b3e706..f6502bff8 100644 --- a/packages/graphs/tests/demos/index.tsx +++ b/packages/graphs/tests/demos/index.tsx @@ -1,2 +1,4 @@ export { HierarchicalGraph } from './hierarchical-graph'; +export { MindMap } from './mind-map'; +export { MindMap2 } from './mind-map2'; export { OrganizationChart } from './organization-chart'; diff --git a/packages/graphs/tests/demos/mind-map.tsx b/packages/graphs/tests/demos/mind-map.tsx new file mode 100644 index 000000000..c23fadbda --- /dev/null +++ b/packages/graphs/tests/demos/mind-map.tsx @@ -0,0 +1,23 @@ +import type { GraphOptions } from '@ant-design/graphs'; +import { MindMap as MindMapComponent } from '@ant-design/graphs'; +import type { NodeData, TreeData } from '@antv/g6'; +import { treeToGraphData } from '@antv/g6'; +import React from 'react'; +import data from '../datasets/mind-mapping.json'; + +export const MindMap = () => { + const options: GraphOptions = { + autoFit: 'view', + data: treeToGraphData(data, { + getNodeData: (datum: TreeData, depth: number) => { + datum.data ||= {}; + datum.data.depth = depth; + if (!datum.children) return datum as NodeData; + const { children, ...restDatum } = datum; + return { ...restDatum, children: children.map((child) => child.id) } as NodeData; + }, + }), + }; + + return ; +}; diff --git a/packages/graphs/tests/demos/mind-map2.tsx b/packages/graphs/tests/demos/mind-map2.tsx new file mode 100644 index 000000000..e973ba8e6 --- /dev/null +++ b/packages/graphs/tests/demos/mind-map2.tsx @@ -0,0 +1,90 @@ +import type { GraphOptions } from '@ant-design/graphs'; +import { getNodeSide, measureTextSize, MindMap as MindMapComponent, MindMapNode } from '@ant-design/graphs'; +import type { Graph, NodeData, TreeData } from '@antv/g6'; +import { idOf, treeToGraphData } from '@antv/g6'; +import React from 'react'; +import data from '../datasets/algorithm-category.json'; + +const measureMindMapNodeSize = (data: NodeData) => { + const depth = data.data!.depth; + const fontSize = depth === 1 ? 18 : 16; + const font = { fontSize: fontSize, fontFamily: 'PingFang SC' }; + if (depth === 0) { + Object.assign(font, { fontWeight: 'bold', fontSize: 20 }); + } + return measureTextSize(idOf(data), font, 0, 360, [0, 36]); +}; + +export const MindMap2 = () => { + const options: GraphOptions = { + autoFit: 'view', + padding: 20, + data: treeToGraphData(data, { + getNodeData: (datum: TreeData, depth: number) => { + datum.data ||= {}; + datum.data.depth = depth; + if (!datum.children) return datum as NodeData; + const { children, ...restDatum } = datum; + return { ...restDatum, children: children.map((child) => child.id) } as NodeData; + }, + }), + node: { + type: 'react', + style: { + component: (data) => { + const depth = data.data?.depth; + const color = data.style?.color; + const style = { + height: 'inherit', + width: 'inherit', + border: 0, + borderBottom: `2px solid ${color}`, + transform: 'translateY(-1px)', + }; + + if (depth > 0) { + Object.assign(style, { + borderRadius: 0, + padding: 0, + background: 'transparent', + color: '#252525', + justifyContent: 'left', + fontWeight: 'normal', + }); + } + + return ; + }, + size: (data: NodeData) => measureMindMapNodeSize(data), + dx: function (data: NodeData) { + const parentData = (this as unknown as Graph).getParentData(idOf(data), 'tree'); + const side = getNodeSide(data, parentData); + const size = measureMindMapNodeSize(data); + return side === 'left' ? -size[0] : side === 'center' ? -size[0] / 2 : 0; + }, + ports: function (data: NodeData) { + const parentData = (this as unknown as Graph).getParentData(idOf(data), 'tree'); + const side = getNodeSide(data, parentData); + return side === 'center' + ? [{ placement: 'left' }, { placement: 'right' }] + : [{ placement: 'left-bottom' }, { placement: 'right-bottom' }]; + }, + }, + }, + edge: { + style: { + stroke: function (data) { + return (this.getNodeData(data.target).style!.color as string) || '#99ADD1'; + }, + }, + }, + layout: { + type: 'mindmap', + getVGap: () => 10, + getWidth: () => 120, + getHeight: (data) => measureMindMapNodeSize(data)[1], + }, + }; + + return ; +}; diff --git a/packages/graphs/tests/demos/organization-chart.tsx b/packages/graphs/tests/demos/organization-chart.tsx index 93e8a24c3..fabee92b9 100644 --- a/packages/graphs/tests/demos/organization-chart.tsx +++ b/packages/graphs/tests/demos/organization-chart.tsx @@ -20,7 +20,6 @@ export const OrganizationChart = () => { }, []); const options: GraphOptions = { - collapsible: true, data, padding: 20, autoFit: 'view', @@ -46,6 +45,10 @@ export const OrganizationChart = () => { nodesep: 24, ranksep: -20, }, + transforms: (prev) => [ + ...prev, + { type: 'collapse-expand-react-node', key: 'collapse-expand-react-node', iconOffsetY: 24 }, + ], }; return ; diff --git a/packages/plots/src/core/utils/index.ts b/packages/plots/src/core/utils/index.ts index 01c69102f..56b4f2231 100644 --- a/packages/plots/src/core/utils/index.ts +++ b/packages/plots/src/core/utils/index.ts @@ -35,5 +35,4 @@ export { deleteExcessKeys } from './delete-excess-keys'; export { filterTransformed } from './filter-transformed'; export { conversionTagFormatter } from './conversion'; export { mergeWithArrayCoverage } from './merge-with-array-coverage'; -export { measureTextWidth } from './measure-text'; export { fieldAdapter } from './field-adapter'; diff --git a/packages/plots/src/core/utils/measure-text.ts b/packages/plots/src/core/utils/measure-text.ts deleted file mode 100644 index db3b3f3dc..000000000 --- a/packages/plots/src/core/utils/measure-text.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { isString, memoize, values } from '../utils'; -import { getCanvasContext } from './context'; - -/** - * 计算文本在画布中的宽度 - * @param text 文本 - * @param font 字体 - */ -export const measureTextWidth = memoize( - (text: string, font: any = {}): number => { - const { fontSize, fontFamily = 'sans-serif', fontWeight, fontStyle, fontVariant } = font; - const ctx = getCanvasContext(); - // @see https://developer.mozilla.org/zh-CN/docs/Web/CSS/font - ctx.font = [fontStyle, fontWeight, fontVariant, `${fontSize}px`, fontFamily].join(' '); - const metrics = ctx.measureText(isString(text) ? text : ''); - return metrics.width; - }, - (text: string, font = {}) => [text, ...values(font)].join(''), -); diff --git a/packages/plots/src/index.ts b/packages/plots/src/index.ts index 1e67f65fc..5158d1a70 100644 --- a/packages/plots/src/index.ts +++ b/packages/plots/src/index.ts @@ -1,7 +1,7 @@ import * as G2 from '@antv/g2'; +/** utils */ +export { measureTextWidth } from '@ant-design/charts-util'; export * from './components'; export * from './interface'; -/** utils */ -export { measureTextWidth } from './core/utils'; export { G2 }; diff --git a/packages/util/package.json b/packages/util/package.json index 301589e15..7f252b23a 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -46,5 +46,8 @@ "@types/react-dom": "^18.0.6", "npm-run-all": "^4.1.5", "rimraf": "^3.0.2" + }, + "dependencies": { + "lodash": "^4.17.21" } } diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index ea75206a3..f8214b8ce 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -1,4 +1,5 @@ export * from './rc'; export * from './react'; export type { ContainerConfig } from './types'; +export { measureTextHeight, measureTextWidth } from './utils/measure-text'; export { uuid } from './uuid'; diff --git a/packages/plots/src/core/utils/context.ts b/packages/util/src/utils/context.ts similarity index 100% rename from packages/plots/src/core/utils/context.ts rename to packages/util/src/utils/context.ts diff --git a/packages/util/src/utils/measure-text.ts b/packages/util/src/utils/measure-text.ts new file mode 100644 index 000000000..6ba8786dc --- /dev/null +++ b/packages/util/src/utils/measure-text.ts @@ -0,0 +1,34 @@ +import { isString, memoize, values } from 'lodash'; +import { getCanvasContext } from './context'; + +/** + * 计算文本在画布中的相关信息(例如它的宽度) + * @see https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/measureText + */ +export const measureText = memoize( + (text: string, font: any = {}): TextMetrics => { + const { fontSize, fontFamily = 'sans-serif', fontWeight, fontStyle, fontVariant } = font; + const ctx = getCanvasContext(); + // @see https://developer.mozilla.org/zh-CN/docs/Web/CSS/font + ctx.font = [fontStyle, fontWeight, fontVariant, `${fontSize}px`, fontFamily].join(' '); + return ctx.measureText(isString(text) ? text : ''); + }, + (text: string, font = {}) => [text, ...values(font)].join(''), +); + +/** + * 计算文本在画布中的宽度 + * @param text 文本 + * @param font 字体 + */ +export const measureTextWidth = (text: string, font: any = {}): number => measureText(text, font).width; + +/** + * 计算文本在画布中的实际高度 + * @param text 文本 + * @param font 字体 + */ +export const measureTextHeight = (text: string, font: any = {}): number => { + const metrics = measureText(text, font); + return metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; +};