From 7787d26822fd2bf2ca7c1aeead61b5ba59792de9 Mon Sep 17 00:00:00 2001 From: Yuxin <55794321+yvonneyx@users.noreply.github.com> Date: Thu, 17 Oct 2024 16:19:23 +0800 Subject: [PATCH] feat: add product launch flow graph demo (#2727) * refactor: extract node style calculation into a common function * feat: add product activation flow graph demo * docs: add demo * refactor: rename --- .../src/components/indented-tree/options.tsx | 50 ++--- .../src/components/mind-map/options.tsx | 54 ++--- packages/graphs/src/core/utils/tree.ts | 28 +++ .../graphs/tests/datasets/product-launch.json | 56 +++++ .../tests/demos/flow-graph-product-launch.tsx | 198 ++++++++++++++++++ packages/graphs/tests/demos/index.tsx | 1 + .../relations/flow-graph/demo/meta.json | 8 + .../demo/product-launch-flow-graph.js | 190 +++++++++++++++++ 8 files changed, 521 insertions(+), 64 deletions(-) create mode 100644 packages/graphs/src/core/utils/tree.ts create mode 100644 packages/graphs/tests/datasets/product-launch.json create mode 100644 packages/graphs/tests/demos/flow-graph-product-launch.tsx create mode 100644 site/examples/relations/flow-graph/demo/product-launch-flow-graph.js diff --git a/packages/graphs/src/components/indented-tree/options.tsx b/packages/graphs/src/components/indented-tree/options.tsx index 7e912ba5a..3f4460b86 100644 --- a/packages/graphs/src/components/indented-tree/options.tsx +++ b/packages/graphs/src/components/indented-tree/options.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { CollapseExpandIcon, RCNode, TextNodeProps } from '../../core/base'; import { measureTextSize } from '../../core/utils/measure-text'; import { getNodeSide } from '../../core/utils/node'; +import { getBoxedTextNodeStyle, getLinearTextNodeStyle } from '../../core/utils/tree'; import type { IndentedTreeOptions } from './types'; const { ArrowCountIcon } = CollapseExpandIcon; @@ -86,17 +87,6 @@ export const getIndentedTreeOptions = ({ const maxWidth = nodeMaxWidth || 300; if (type === 'boxed') { - const getNodeFont = (depth: number) => { - const fontSize = depth === 0 ? 20 : depth === 1 ? 18 : 16; - return { fontWeight: 'bold', fontSize, fontFamily: 'PingFang SC' }; - }; - - const measureNodeSize = (data: NodeData) => { - const offset = data.depth === 0 ? [24, 36] : [12, 24]; - const font = getNodeFont(data.depth!); - return measureTextSize(idOf(data), offset, font, minWidth, maxWidth); - }; - options = { node: { style: { @@ -108,7 +98,7 @@ export const getIndentedTreeOptions = ({ text: idOf(data), color: depth === 0 ? '#f1f4f5' : color, maxWidth, - font: getNodeFont(depth), + font: getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, depth).font, style: { textAlign: getNodeTextAlign(this as unknown as Graph, data), ...(depth === 0 ? { color: '#252525' } : {}), @@ -116,7 +106,8 @@ export const getIndentedTreeOptions = ({ }; return ; }, - size: (data: NodeData) => measureNodeSize(data), + size: (data: NodeData) => + getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size, }, }, edge: { @@ -137,31 +128,25 @@ export const getIndentedTreeOptions = ({ layout: { type: 'indented', indent: (node) => getIndent(node, 20), - getWidth: (data) => measureNodeSize(data)[0], - getHeight: (data) => measureNodeSize(data)[1], + getWidth: (data) => getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size[0], + getHeight: (data) => + getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size[1], getVGap: () => 14, }, }; } else if (type === 'linear') { - const getNodeFont = (depth: number) => { - const fontSize = depth === 0 ? 20 : 16; - return { fontWeight: 'bold', fontSize, fontFamily: 'PingFang SC' }; - }; - - const measureNodeSize = (data: NodeData) => { - const { depth } = data.data as { depth: number }; - const offset = depth === 0 ? [24, 36] : [12, 12]; - const font = getNodeFont(depth); - return measureTextSize(idOf(data), offset, font, minWidth, maxWidth); - }; - options = { node: { style: { component: function (data: NodeData) { const depth = data.depth as number; const color = data.style?.color as string; - const props = { text: idOf(data), color, maxWidth, font: getNodeFont(depth) } as TextNodeProps; + const props = { + text: idOf(data), + color, + maxWidth, + font: getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, depth).font, + } as TextNodeProps; Object.assign( props, depth === 0 @@ -173,7 +158,8 @@ export const getIndentedTreeOptions = ({ ); return ; }, - size: (data: NodeData) => measureNodeSize(data), + size: (data: NodeData) => + getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size, ports: function (data: NodeData) { const side = getNodeSide(this as unknown as Graph, data); return side === 'left' @@ -195,8 +181,10 @@ export const getIndentedTreeOptions = ({ layout: { type: 'indented', indent: (node) => getIndent(node, 20), - getWidth: (data) => measureNodeSize(data)[0], - getHeight: (data) => measureNodeSize(data)[1], + getWidth: (data) => + getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size[0], + getHeight: (data) => + getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size[1], getVGap: () => 12, }, transforms: (prev) => [ diff --git a/packages/graphs/src/components/mind-map/options.tsx b/packages/graphs/src/components/mind-map/options.tsx index 5ff98cb63..c4f38d923 100644 --- a/packages/graphs/src/components/mind-map/options.tsx +++ b/packages/graphs/src/components/mind-map/options.tsx @@ -5,6 +5,7 @@ import type { TextNodeProps } from '../../core/base'; import { CollapseExpandIcon, RCNode } from '../../core/base'; import { measureTextSize } from '../../core/utils/measure-text'; import { getNodeSide } from '../../core/utils/node'; +import { getBoxedTextNodeStyle, getLinearTextNodeStyle } from '../../core/utils/tree'; import type { MindMapOptions } from './types'; const { ArrowCountIcon } = CollapseExpandIcon; @@ -85,25 +86,18 @@ export function getMindMapOptions({ const minWidth = nodeMinWidth || 120; const maxWidth = nodeMaxWidth || 300; - const getNodeFont = (depth: number) => { - const fontSize = depth === 0 ? 20 : depth === 1 ? 18 : 16; - return { fontWeight: 'bold', fontSize, fontFamily: 'PingFang SC' }; - }; - - const measureNodeSize = (data: NodeData) => { - const depth = data.depth as number; - const offset = depth === 0 ? [24, 36] : [12, 24]; - const font = getNodeFont(depth); - return measureTextSize(idOf(data), offset, font, minWidth, maxWidth); - }; - options = { node: { style: { component: (data: NodeData) => { const depth = data.depth as number; const color = data.style?.color as string; - const props = { text: idOf(data), color, maxWidth, font: getNodeFont(depth) } as TextNodeProps; + const props = { + text: idOf(data), + color, + maxWidth, + font: getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, depth).font, + } as TextNodeProps; Object.assign( props, depth === 0 @@ -114,10 +108,11 @@ export function getMindMapOptions({ ); return ; }, - size: (data: NodeData) => measureNodeSize(data), + size: (data: NodeData) => + getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size, dx: function (data: NodeData) { const side = getNodeSide(this as unknown as Graph, data); - const size = measureNodeSize(data); + const size = getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size; return side === 'left' ? -size[0] : side === 'center' ? -size[0] / 2 : 0; }, ports: [{ placement: 'left' }, { placement: 'right' }], @@ -139,7 +134,8 @@ export function getMindMapOptions({ ], layout: { type: 'mindmap', - getHeight: (data) => measureNodeSize(data)[1], + getHeight: (data) => + getBoxedTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size[1], getVGap: () => 14, }, }; @@ -147,18 +143,6 @@ export function getMindMapOptions({ const minWidth = nodeMinWidth || 0; const maxWidth = nodeMaxWidth || 300; - const getNodeFont = (depth: number) => { - const fontSize = depth === 0 ? 20 : 16; - return { fontWeight: 'bold', fontSize, fontFamily: 'PingFang SC' }; - }; - - const measureNodeSize = (data: NodeData) => { - const { depth } = data.data as { depth: number }; - const offset = depth === 0 ? [24, 36] : [12, 12]; - const font = getNodeFont(depth); - return measureTextSize(idOf(data), offset, font, minWidth, maxWidth); - }; - options = { node: { style: { @@ -166,7 +150,8 @@ export function getMindMapOptions({ const side = getNodeSide(this as unknown as Graph, data); const depth = data.depth as number; const color = data.style?.color as string; - const props = { text: idOf(data), color, maxWidth, font: getNodeFont(depth) } as TextNodeProps; + const { font } = getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, depth); + const props = { text: idOf(data), color, maxWidth, font } as TextNodeProps; Object.assign( props, depth === 0 @@ -178,10 +163,11 @@ export function getMindMapOptions({ ); return ; }, - size: (data: NodeData) => measureNodeSize(data), + size: (data: NodeData) => + getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size, dx: function (data: NodeData) { const side = getNodeSide(this as unknown as Graph, data); - const size = measureNodeSize(data); + const size = getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size; return side === 'left' ? -size[0] : side === 'center' ? -size[0] / 2 : 0; }, ports: function (data: NodeData) { @@ -201,7 +187,8 @@ export function getMindMapOptions({ }, layout: { type: 'mindmap', - getHeight: (data) => measureNodeSize(data)[1], + getHeight: (data) => + getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size[1], getVGap: () => 12, }, transforms: (prev) => [ @@ -214,7 +201,8 @@ export function getMindMapOptions({ ...(prev.find((t) => (t as any).key === 'collapse-expand-react-node') as any), iconOffsetY: (data) => { if (data.depth === 0) return 0; - return measureNodeSize(data)[1] / 2; + const size = getLinearTextNodeStyle(idOf(data), minWidth, maxWidth, data.depth as number).size; + return size[1] / 2; }, }, ], diff --git a/packages/graphs/src/core/utils/tree.ts b/packages/graphs/src/core/utils/tree.ts new file mode 100644 index 000000000..ae21e9e08 --- /dev/null +++ b/packages/graphs/src/core/utils/tree.ts @@ -0,0 +1,28 @@ +import { memoize } from 'lodash'; +import { measureTextSize } from './measure-text'; + +export const getLinearTextNodeStyle = memoize( + (text: string, minWidth: number, maxWidth: number, depth: number = 0) => { + const font = { + fontWeight: 'bold', + fontSize: depth === 0 ? 20 : 16, + fontFamily: 'PingFang SC', + }; + const offset = depth === 0 ? [24, 36] : [12, 12]; + const size = measureTextSize(text, offset, font, minWidth, maxWidth); + return { font, size }; + }, +); + +export const getBoxedTextNodeStyle = memoize( + (text: string, minWidth: number, maxWidth: number, depth: number = 0) => { + const font = { + fontWeight: 'bold', + fontSize: depth === 0 ? 20 : depth === 1 ? 18 : 16, + fontFamily: 'PingFang SC', + }; + const offset = depth === 0 ? [24, 36] : [12, 24]; + const size = measureTextSize(text, offset, font, minWidth, maxWidth); + return { font, size }; + }, +); diff --git a/packages/graphs/tests/datasets/product-launch.json b/packages/graphs/tests/datasets/product-launch.json new file mode 100644 index 000000000..c58bfff9c --- /dev/null +++ b/packages/graphs/tests/datasets/product-launch.json @@ -0,0 +1,56 @@ +{ + "nodes": [ + { + "id": "start", + "data": { "name": "流程开始" } + }, + { + "id": "submit-agreement", + "data": { "name": "提交协议", "elapsed_time": "11秒" } + }, + { + "id": "contract-review", + "data": { + "name": "合约审核", + "elapsed_time": "1.63分", + "status": "running", + "children": [ + { "name": "风控审核", "elapsed_time": "39秒" }, + { "name": "财务审核", "elapsed_time": "0秒" }, + { "name": "法务审核", "elapsed_time": "0秒" }, + { "name": "销售初评", "elapsed_time": "0秒" }, + { "name": "主管审核", "elapsed_time": "0秒" }, + { "name": "销售终审", "elapsed_time": "0秒" } + ] + } + }, + { + "id": "merchant-confirmation", + "data": { "name": "商户确认", "elapsed_time": "0秒" } + }, + { + "id": "end", + "data": { "name": "流程结束" } + } + ], + "edges": [ + { + "source": "start", + "target": "submit-agreement" + }, + { + "source": "submit-agreement", + "target": "contract-review", + "data": { "elapsed_time": "11秒" } + }, + { + "source": "contract-review", + "target": "merchant-confirmation", + "data": { "elapsed_time": "0秒" } + }, + { + "source": "merchant-confirmation", + "target": "end" + } + ] +} diff --git a/packages/graphs/tests/demos/flow-graph-product-launch.tsx b/packages/graphs/tests/demos/flow-graph-product-launch.tsx new file mode 100644 index 000000000..95c483e87 --- /dev/null +++ b/packages/graphs/tests/demos/flow-graph-product-launch.tsx @@ -0,0 +1,198 @@ +import { FlowGraph as FlowGraphComponent, type FlowGraphOptions, type G6 } from '@ant-design/graphs'; +import { isBoolean } from 'lodash'; +import React, { FC } from 'react'; +import styled from 'styled-components'; +import data from '../datasets/product-launch.json'; + +interface StepData { + name: string; + status?: string; + elapsed_time?: string; +} + +interface NodeData extends G6.NodeData { + data: StepData & { + children?: StepData[]; + [key: string]: unknown; + }; +} + +interface EdgeData extends G6.EdgeData { + data: { + elapsed_time?: string; + [key: string]: unknown; + }; +} + +const StyledStepCardWrapper = styled.div` + height: 58px; + width: 120px; + background: #ecf2fe; + border-radius: 4px; + box-sizing: border-box; + padding: 6px 12px; + font-size: 10px; + font-weight: 500; + color: #252525; + display: flex; + flex-direction: column; + justify-content: center; + + .elapsed-time { + margin-top: 8px; + + &-title { + color: #aaa; + font-size: 8px; + } + } +`; + +const StyledStepGroupCardWrapper = styled.div<{ $isCollapsed: boolean }>` + width: inherit; + height: inherit; + border-radius: 4px; + box-sizing: border-box; + border: 1px solid #eee; + + .header { + height: 32px; + line-height: 32px; + background-color: #3875f7; + color: #fff; + border-radius: ${({ $isCollapsed }) => ($isCollapsed ? '4px' : '4px 4px 0 0')}; + display: flex; + font-size: 10px; + padding: 0 12px; + gap: 2px; + + &-content { + flex: 1; + display: flex; + justify-content: space-between; + + .elapsed-time { + display: flex; + gap: 2px; + font-size: 9px; + + &-title { + color: #acc7fb; + } + } + } + + &-extra { + cursor: pointer; + width: fit-content; + color: #acc7fb; + } + } + + .step-card-group { + display: flex; + gap: 8px; + flex-direction: column; + align-items: center; + padding: 16px 0; + } +`; + +const StepCard: FC = ({ name, elapsed_time }) => { + return ( + +
{name}
+ {elapsed_time && ( +
+
80分位耗时
+
{elapsed_time}
+
+ )} +
+ ); +}; + +const StepGroupCard: FC void }> = ( + props, +) => { + const { name, elapsed_time, children, isCollapsed, toggleCollapse } = props; + return ( + +
+
+
{name}
+ {elapsed_time && ( +
+
80分位耗时
+
{elapsed_time}
+
+ )} +
+
+ {isCollapsed ? '展开' : '收起'} +
+
+ {!isCollapsed && ( +
+ {children?.map((child, index) => ( + + ))} +
+ )} +
+ ); +}; + +function isGroupCollapsed(data: NodeData) { + return isBoolean(data.style?.collapsed) ? data.style?.collapsed : data.data.status === 'finished'; +} + +function isSingleStep(data: NodeData) { + return !data.data.children; +} + +export const FlowGraphProductLaunch = () => { + const options: FlowGraphOptions = { + autoFit: 'view', + data, + node: { + style: { + component: function (data: NodeData) { + if (isSingleStep(data)) return ; + const toggleCollapse = async () => { + const graph = this as unknown as G6.Graph; + graph.updateNodeData([{ id: data.id, style: { collapsed: !isGroupCollapsed(data) } }]); + await graph.render(); + }; + return ; + }, + // @ts-ignore + size: (data: NodeData) => { + if (isSingleStep(data)) return [120, 58]; + const GAP = 8; + const height = isGroupCollapsed(data) ? 32 : 56 + (58 + GAP) * (data.data?.children?.length || 0); + return [200, height]; + }, + }, + }, + edge: { + style: { + lineWidth: 1, + labelBackground: true, + labelBackgroundOpacity: 1, + labelFill: '#aaa', + labelFontSize: 8, + labelFontWeight: 500, + // @ts-ignore + labelText: (data: EdgeData) => (data.data?.elapsed_time ? `80分位耗时\n${data.data.elapsed_time}` : ''), + }, + }, + layout: { + type: 'dagre', + nodeSize: (data: NodeData) => (isSingleStep(data) ? 160 : 400), + animation: false, + }, + }; + + return ; +}; diff --git a/packages/graphs/tests/demos/index.tsx b/packages/graphs/tests/demos/index.tsx index 162e3dcfa..5c5a41769 100644 --- a/packages/graphs/tests/demos/index.tsx +++ b/packages/graphs/tests/demos/index.tsx @@ -1,5 +1,6 @@ export { Dendrogram } from './dendrogram'; export { FlowGraph } from './flow-graph'; +export { FlowGraphProductLaunch } from './flow-graph-product-launch'; export { FlowGraphTaskScheduling } from './flow-graph-task-scheduling'; export { IndentedTreeBoxed } from './indented-tree-boxed'; export { IndentedTree } from './indented-tree-default'; diff --git a/site/examples/relations/flow-graph/demo/meta.json b/site/examples/relations/flow-graph/demo/meta.json index 18029d10d..686a76f78 100644 --- a/site/examples/relations/flow-graph/demo/meta.json +++ b/site/examples/relations/flow-graph/demo/meta.json @@ -27,6 +27,14 @@ "en": "Task Scheduling Flow Graph" }, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*yd-WSLmyxAkAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "product-launch-flow-graph.js", + "title": { + "zh": "产品开通动线图", + "en": "Product Launch Flow Graph" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*n9JgQIGi9BQAAAAAAAAAAAAADmJ7AQ/original" } ] } diff --git a/site/examples/relations/flow-graph/demo/product-launch-flow-graph.js b/site/examples/relations/flow-graph/demo/product-launch-flow-graph.js new file mode 100644 index 000000000..b3a1d5073 --- /dev/null +++ b/site/examples/relations/flow-graph/demo/product-launch-flow-graph.js @@ -0,0 +1,190 @@ +import { FlowGraph } from '@ant-design/graphs'; +import insertCss from 'insert-css'; +import { isBoolean } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; + +insertCss(` + .step-card-wrapper { + height: 58px; + width: 120px; + background: #ecf2fe; + border-radius: 4px; + box-sizing: border-box; + padding: 6px 12px; + font-size: 10px; + font-weight: 500; + color: #252525; + display: flex; + flex-direction: column; + justify-content: center; + + .elapsed-time { + margin-top: 8px; + + .elapsed-time-title { + color: #aaa; + font-size: 8px; + } + } + } + + .step-group-card-wrapper { + width: inherit; + height: inherit; + border-radius: 4px; + box-sizing: border-box; + border: 1px solid #eee; + + .header { + height: 32px; + line-height: 32px; + background-color: #3875f7; + color: #fff; + border-radius: 4px 4px 0 0; + display: flex; + font-size: 10px; + padding: 0 12px; + gap: 2px; + + .header-content { + flex: 1; + display: flex; + justify-content: space-between; + + .elapsed-time { + display: flex; + gap: 2px; + font-size: 9px; + + &-title { + color: #acc7fb; + } + } + } + + .header-extra { + cursor: pointer; + width: fit-content; + color: #acc7fb; + } + } + + .header-collapsed { + border-radius: 4px; + } + + .step-card-group { + display: flex; + gap: 8px; + flex-direction: column; + align-items: center; + padding: 16px 0; + } + } +`); + +const StepCard = ({ name, elapsed_time }) => { + return ( +
+
{name}
+ {elapsed_time && ( +
+
80分位耗时
+
{elapsed_time}
+
+ )} +
+ ); +}; + +const StepGroupCard = (props) => { + const { name, elapsed_time, children, isCollapsed, toggleCollapse } = props; + return ( +
+
+
+
{name}
+ {elapsed_time && ( +
+
80分位耗时
+
{elapsed_time}
+
+ )} +
+
+ {isCollapsed ? '展开' : '收起'} +
+
+ {!isCollapsed && ( +
+ {children?.map((child, index) => ( + + ))} +
+ )} +
+ ); +}; + +function isGroupCollapsed(data) { + return isBoolean(data.style?.collapsed) ? data.style?.collapsed : data.data.status === 'finished'; +} + +function isSingleStep(data) { + return !data.data.children; +} + +const DemoFlowGraph = () => { + const [data, setData] = useState(undefined); + + useEffect(() => { + fetch('https://assets.antv.antgroup.com/antd-charts/product-activation.json') + .then((res) => res.json()) + .then(setData); + }, []); + + const options = { + autoFit: 'view', + data, + node: { + style: { + component: function (data) { + if (isSingleStep(data)) return ; + const toggleCollapse = async () => { + const graph = this; + graph.updateNodeData([{ id: data.id, style: { collapsed: !isGroupCollapsed(data) } }]); + await graph.render(); + }; + return ; + }, + size: (data) => { + if (isSingleStep(data)) return [120, 58]; + const GAP = 8; + const height = isGroupCollapsed(data) ? 32 : 56 + (58 + GAP) * (data.data?.children?.length || 0); + return [200, height]; + }, + }, + }, + edge: { + style: { + lineWidth: 1, + labelBackground: true, + labelBackgroundOpacity: 1, + labelFill: '#aaa', + labelFontSize: 8, + labelFontWeight: 500, + labelText: (data) => (data.data?.elapsed_time ? `80分位耗时\n${data.data.elapsed_time}` : ''), + }, + }, + layout: { + type: 'dagre', + nodeSize: (data) => (isSingleStep(data) ? 160 : 400), + animation: false, + }, + }; + + return ; +}; + +ReactDOM.render(, document.getElementById('container'));