diff --git a/packages/demo-app-ts/src/Demo.css b/packages/demo-app-ts/src/Demo.css index 2dd72142..b93877a5 100644 --- a/packages/demo-app-ts/src/Demo.css +++ b/packages/demo-app-ts/src/Demo.css @@ -59,7 +59,9 @@ fill: var(--pf-v5-global--palette--light-blue-200); } -.pf-v5-c-page__main-section#\/topology-demo-page-section, .pf-v5-c-page__main-section#\/topology-pipelines-demo-page-section { +.pf-v5-c-page__main-section#\/topology-demo-page-section, +.pf-v5-c-page__main-section#\/topology-pipelines-demo-page-section, +.pf-v5-c-page__main-section#\/topology-pipelines-groups-demo-page-section { --pf-v5-c-page__main-section--PaddingTop: 0; --pf-v5-c-page__main-section--PaddingRight: 0; --pf-v5-c-page__main-section--PaddingLeft: 0; diff --git a/packages/demo-app-ts/src/Demos.ts b/packages/demo-app-ts/src/Demos.ts index 75a5b3df..453ebced 100644 --- a/packages/demo-app-ts/src/Demos.ts +++ b/packages/demo-app-ts/src/Demos.ts @@ -1,4 +1,6 @@ -import { TopologyPipelineDemo } from './demos/pipelinesDemo/TopologyPipelineDemo'; +import { PipelineTasksDemo } from './demos/pipelinesDemo/PipelineTasksDemo'; +import { PipelineLayoutDemo } from './demos/pipelinesDemo/PipelineLayoutDemo'; +import { PipelineGroupsDemo } from './demos/pipelineGroupsDemo/PipelineGroupsDemo'; import { Basics } from './demos/Basics'; import { StyleEdges, StyleGroups, StyleLabels, StyleNodes } from './demos/stylesDemo/Styles'; import { Selection } from './demos/Selection'; @@ -36,9 +38,25 @@ export const Demos: DemoInterface[] = [ isDefault: true, }, { - id: 'topology-pipelines-demo', - name: 'Topology Pipelines', - componentType: TopologyPipelineDemo + id: 'pipelines', + name: 'Pipelines', + demos: [ + { + id: 'pipelines-tasks-demo', + name: 'Task Nodes', + componentType: PipelineTasksDemo + }, + { + id: 'pipelines-layout-demo', + name: 'Pipeline Layout', + componentType: PipelineLayoutDemo + }, + { + id: 'pipelines-groups-layout-demo', + name: 'Pipeline Groups Layout', + componentType: PipelineGroupsDemo, + }, + ] }, { id: 'status-connectors', diff --git a/packages/demo-app-ts/src/demos/CollapsibleGroups.tsx b/packages/demo-app-ts/src/demos/CollapsibleGroups.tsx index e629eb74..a09141c9 100644 --- a/packages/demo-app-ts/src/demos/CollapsibleGroups.tsx +++ b/packages/demo-app-ts/src/demos/CollapsibleGroups.tsx @@ -2,9 +2,6 @@ import * as React from 'react'; import { action } from 'mobx'; import { TopologyView, - TopologyControlBar, - createTopologyControlButtons, - defaultControlButtonsOptions, EdgeModel, Model, ModelKind, @@ -28,6 +25,7 @@ import GroupHull from '../components/GroupHull'; import Group from '../components/DemoDefaultGroup'; import DemoDefaultNode from '../components/DemoDefaultNode'; import defaultComponentFactory from '../components/defaultComponentFactory'; +import DemoControlBar from './DemoControlBar'; const getModel = (collapseTypes: string[] = []): Model => { // create nodes from data @@ -209,30 +207,7 @@ const TopologyViewComponent: React.FunctionComponent }, [vis, collapseBlue, collapseLightBlue, collapseCyan, collapseOrange, collapsePink]); return ( - { - vis.getGraph().scaleBy(4 / 3); - }), - zoomOutCallback: action(() => { - vis.getGraph().scaleBy(0.75); - }), - fitToScreenCallback: action(() => { - vis.getGraph().fit(80); - }), - resetViewCallback: action(() => { - vis.getGraph().reset(); - vis.getGraph().layout(); - }), - legend: false - })} - /> - } - viewToolbar={viewToolbar} - > + } viewToolbar={viewToolbar}> ); diff --git a/packages/demo-app-ts/src/demos/DemoControlBar.tsx b/packages/demo-app-ts/src/demos/DemoControlBar.tsx new file mode 100644 index 00000000..0c4353d7 --- /dev/null +++ b/packages/demo-app-ts/src/demos/DemoControlBar.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + createTopologyControlButtons, + defaultControlButtonsOptions, + TopologyControlBar, + useVisualizationController, + action, +} from '@patternfly/react-topology'; + +const DemoControlBar: React.FC = () => { + const controller = useVisualizationController(); + + return ( + { + controller.getGraph().scaleBy(4 / 3); + }), + zoomOutCallback: action(() => { + controller.getGraph().scaleBy(0.75); + }), + fitToScreenCallback: action(() => { + controller.getGraph().fit(80); + }), + resetViewCallback: action(() => { + controller.getGraph().reset(); + controller.getGraph().layout(); + }), + legend: false + })} + /> + ); +}; + +export default DemoControlBar; \ No newline at end of file diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx new file mode 100644 index 00000000..09cc7430 --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskGroup.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { + AnchorEnd, + DagreLayoutOptions, + DefaultGroup, + GraphElement, + isNode, + LabelPosition, + Node, + TOP_TO_BOTTOM, + useAnchor, +} from '@patternfly/react-topology'; +import TaskGroupSourceAnchor from './TaskGroupSourceAnchor'; +import TaskGroupTargetAnchor from './TaskGroupTargetAnchor'; + +interface DemoTaskNodeProps { + element: GraphElement; +} + +const DemoTaskGroup: React.FunctionComponent = ({ element, ...rest }) => { + const verticalLayout = (element.getGraph().getLayoutOptions?.() as DagreLayoutOptions)?.rankdir === TOP_TO_BOTTOM; + + useAnchor( + React.useCallback((node: Node) =>new TaskGroupSourceAnchor(node, verticalLayout), [verticalLayout]), + AnchorEnd.source + ); + useAnchor( + React.useCallback((node: Node) => new TaskGroupTargetAnchor(node, verticalLayout),[verticalLayout]), + AnchorEnd.target + ); + if (!isNode(element)) { + return null; + } + return ( + + ); +}; + +export default observer(DemoTaskGroup); diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskNode.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskNode.tsx new file mode 100644 index 00000000..ef0571ac --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/DemoTaskNode.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { + DEFAULT_LAYER, + GraphElement, + Layer, + ScaleDetailsLevel, + TaskNode, + TOP_LAYER, + useHover, + WithContextMenuProps, + WithSelectionProps +} from '@patternfly/react-topology'; + +type DemoTaskNodeProps = { + element: GraphElement; +} & WithContextMenuProps & WithSelectionProps; + +const DemoTaskNode: React.FunctionComponent = ({ element, ...rest }) => { + const data = element.getData(); + const [hover, hoverRef] = useHover(); + const detailsLevel = element.getGraph().getDetailsLevel(); + + return ( + + + + + + ); +}; + +export default observer(DemoTaskNode); diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/OptionsBar.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/OptionsBar.tsx new file mode 100644 index 00000000..a15f614b --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/OptionsBar.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Radio, Text, ToolbarItem } from '@patternfly/react-core'; +import { observer } from '@patternfly/react-topology'; +import { PipelineGroupsDemoContext } from './PipelineGroupsDemoContext'; + +const OptionsBar: React.FC = observer(() => { + const pipelineOptions = React.useContext(PipelineGroupsDemoContext); + + return ( + <> + + Layout: + + + pipelineOptions.setVerticalLayout(false)} + label="Horizontal" + /> + + + pipelineOptions.setVerticalLayout(true)} + label="Vertical" + /> + + + ); +}); + +export default OptionsBar; diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/PipelineGroupsDemo.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/PipelineGroupsDemo.tsx new file mode 100644 index 00000000..65c13f96 --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/PipelineGroupsDemo.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { + Graph, + Layout, + PipelineDagreGroupsLayout, + Visualization, + VisualizationProvider, + useEventListener, + SelectionEventListener, + SELECTION_EVENT, + TopologyView, + VisualizationSurface, + getEdgesFromNodes, + DEFAULT_SPACER_NODE_TYPE, + observer, + NODE_SEPARATION_HORIZONTAL, + NODE_SEPARATION_VERTICAL, + LEFT_TO_RIGHT, + TOP_TO_BOTTOM, + PipelineNodeModel, + useVisualizationController, +} from '@patternfly/react-topology'; +import pipelineGroupsComponentFactory from './pipelineGroupsComponentFactory'; +import { createDemoPipelineGroupsNodes } from './createDemoPipelineGroupsNodes'; +import { PipelineGroupsDemoContext, PipelineGroupsDemoModel } from './PipelineGroupsDemoContext'; +import OptionsBar from './OptionsBar'; +import DemoControlBar from '../DemoControlBar'; + +const TopologyPipelineGroups: React.FC<{ nodes: PipelineNodeModel[] }> = observer(({ nodes }) => { + const controller = useVisualizationController(); + const options = React.useContext(PipelineGroupsDemoContext); + const [selectedIds, setSelectedIds] = React.useState(); + + useEventListener(SELECTION_EVENT, ids => { + setSelectedIds(ids); + }); + + React.useEffect(() => { + const edges = getEdgesFromNodes(nodes, DEFAULT_SPACER_NODE_TYPE, 'edge', 'edge'); + controller.fromModel( + { + graph: { + id: 'g1', + type: 'graph', + x: 25, + y: 25, + layout: options.verticalLayout ? TOP_TO_BOTTOM : LEFT_TO_RIGHT + }, + nodes, + edges, + }, + false + ); + }, [controller, nodes, options.verticalLayout]); + + return ( + } controlBar={}> + + + ); +}); + +TopologyPipelineGroups.displayName = 'TopologyPipelineLayout'; + +export const PipelineGroupsDemo = observer(() => { + const controller = new Visualization(); + controller.registerComponentFactory(pipelineGroupsComponentFactory); + controller.registerLayoutFactory( + (type: string, graph: Graph): Layout | undefined => + new PipelineDagreGroupsLayout(graph, { + nodesep: NODE_SEPARATION_HORIZONTAL, + ranksep: NODE_SEPARATION_VERTICAL + 40, + rankdir: type, + ignoreGroups: true, + }) + ); + const nodes = createDemoPipelineGroupsNodes(); + return ( +
+ + + + + +
+ ); +}); diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/PipelineGroupsDemoContext.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/PipelineGroupsDemoContext.tsx new file mode 100644 index 00000000..22a82cc7 --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/PipelineGroupsDemoContext.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { makeObservable, observable, action } from 'mobx'; + +export class PipelineGroupsDemoModel { + protected verticalLayoutP: boolean = false; + + constructor() { + makeObservable< + PipelineGroupsDemoModel, + | 'verticalLayoutP' + >(this, { + verticalLayoutP: observable, + setVerticalLayout: action, + }); + } + + public get verticalLayout(): boolean { + return this.verticalLayoutP; + } + public setVerticalLayout = (show: boolean): void => { + this.verticalLayoutP = show; + } +} + +export const PipelineGroupsDemoContext = React.createContext(new PipelineGroupsDemoModel()); \ No newline at end of file diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/TaskGroupSourceAnchor.ts b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/TaskGroupSourceAnchor.ts new file mode 100644 index 00000000..c082428b --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/TaskGroupSourceAnchor.ts @@ -0,0 +1,22 @@ +import { AbstractAnchor, Point, Node } from '@patternfly/react-topology'; + +export default class TaskGroupSourceAnchor extends AbstractAnchor { + private vertical = false; + + constructor(owner: E, vertical: boolean = true) { + super(owner); + this.vertical = vertical; + } + + getLocation(): Point { + return this.getReferencePoint(); + } + + getReferencePoint(): Point { + const bounds = this.owner.getBounds(); + if (this.vertical) { + return new Point(bounds.x + bounds.width / 2, bounds.bottom()); + } + return new Point(bounds.right(), bounds.y + bounds.height / 2); + } +} diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/TaskGroupTargetAnchor.ts b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/TaskGroupTargetAnchor.ts new file mode 100644 index 00000000..c349cf72 --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/TaskGroupTargetAnchor.ts @@ -0,0 +1,23 @@ +import { AbstractAnchor, Point, Node } from '@patternfly/react-topology'; + +export default class TaskGroupTargetAnchor extends AbstractAnchor { + private vertical = false; + + constructor(owner: E, vertical = false) { + super(owner); + this.vertical = vertical; + } + + getLocation(): Point { + return this.getReferencePoint(); + } + + getReferencePoint(): Point { + const bounds = this.owner.getBounds(); + + if (this.vertical) { + return new Point(bounds.x + bounds.width / 2, bounds.y); + } + return new Point(bounds.x, bounds.y + bounds.height / 2); + } +} diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts new file mode 100644 index 00000000..383c99cd --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/createDemoPipelineGroupsNodes.ts @@ -0,0 +1,469 @@ +/* eslint-disable camelcase */ +import { + PipelineNodeModel, + RunStatus, +} from '@patternfly/react-topology'; + +export const NODE_PADDING_VERTICAL = 15; +export const NODE_PADDING_HORIZONTAL = 15; + +export const GROUP_PADDING_VERTICAL = 15; +export const GROUP_PADDING_HORIZONTAL = 25; + +export const DEFAULT_TASK_WIDTH = 180; +export const DEFAULT_TASK_HEIGHT = 32; + +export const createExecution2 = (): [string, PipelineNodeModel[]] => { + const execution2: PipelineNodeModel = { + id: 'execution-2', + label: 'Execution 2', + type: 'Execution', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [GROUP_PADDING_VERTICAL, GROUP_PADDING_HORIZONTAL] + }, + group: true, + runAfterTasks: [], + data: { + status: RunStatus.Succeeded, + } + }; + + const task_2_1: PipelineNodeModel = { + id: 'task_2_1', + label: 'Task 2-1', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [], + data: { + status: RunStatus.Succeeded, + } + }; + + const task_2_2: PipelineNodeModel = { + id: 'task_2_2', + label: 'Task 2-2', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: ['task_2_1'], + data: { + status: RunStatus.Succeeded, + } + }; + + const task_2_3: PipelineNodeModel = { + id: 'task_2_3', + label: 'Task 2-3', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: ['task_2_1'], + data: { + status: RunStatus.Succeeded, + } + }; + + const task_2_4: PipelineNodeModel = { + id: 'task_2_4', + label: 'Task 2-4', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: ['task_2_1'], + data: { + status: RunStatus.Succeeded, + } + }; + execution2.children = [task_2_1.id, task_2_2.id, task_2_3.id, task_2_4.id]; + + const nodes:PipelineNodeModel[] = [execution2, task_2_1, task_2_2, task_2_3, task_2_4]; + + return ['execution-2', nodes] +} + +export const createExecution3 = (runAfter?: string): [string, PipelineNodeModel[]] => { + const execution3: PipelineNodeModel = { + id: 'execution-3', + label: 'Execution 3', + type: 'Execution', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [GROUP_PADDING_VERTICAL, GROUP_PADDING_HORIZONTAL] + }, + group: true, + runAfterTasks: runAfter ? [runAfter] : [], + data: { + status: RunStatus.Succeeded, + } + }; + + const task_3_1: PipelineNodeModel = { + id: 'task_3_1', + label: 'Task 3-1', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_2: PipelineNodeModel = { + id: 'task_3_2', + label: 'Task 3-2', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_3: PipelineNodeModel = { + id: 'task_3_3', + label: 'Task 3-3', + type: 'Execution', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + group: true, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_4: PipelineNodeModel = { + id: 'task_3_4', + label: 'Task 3-4', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_5: PipelineNodeModel = { + id: 'task_3_5', + label: 'Task 3-5', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_4.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_6: PipelineNodeModel = { + id: 'task_3_6', + label: 'Task 3-6', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_4.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_7: PipelineNodeModel = { + id: 'task_3_7', + label: 'Task 3-7', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_5.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_8: PipelineNodeModel = { + id: 'task_3_8', + label: 'Task 3-8', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_2.id, task_3_7.id], + data: { + status: RunStatus.Succeeded, + } + }; + + const task_3_3_1: PipelineNodeModel = { + id: 'task_3_3_1', + label: 'Task 3-3-1', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_3_2: PipelineNodeModel = { + id: 'task_3_3_2', + label: 'Task 3-3-2', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + runAfterTasks: [task_3_3_1.id], + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_3_3: PipelineNodeModel = { + id: 'task_3_3_3', + label: 'Task 3-3-3', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3_3_1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3_3_4: PipelineNodeModel = { + id: 'task_3_3_4', + label: 'Task 3-3-4', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + runAfterTasks: [task_3_3_3.id], + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + data: { + status: RunStatus.Succeeded, + } + }; + task_3_3.children = [task_3_3_1.id, task_3_3_2.id, task_3_3_3.id, task_3_3_4.id]; + + execution3.children = [task_3_1.id, task_3_2.id, task_3_3.id, task_3_4.id, task_3_5.id, task_3_6.id, task_3_7.id, task_3_8.id]; + + const nodes:PipelineNodeModel[] = [execution3, task_3_1, task_3_2, task_3_3, task_3_4, task_3_5, task_3_6, task_3_7, task_3_8, task_3_3_1, task_3_3_2, task_3_3_3, task_3_3_4]; + + return ['execution-3', nodes] +}; + +export const createDemoPipelineGroupsNodes = (): PipelineNodeModel[] => { + const nodes:PipelineNodeModel[] = []; + + const execution1: PipelineNodeModel = { + id: 'execution-1', + label: 'Execution 1', + type: 'Execution', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [GROUP_PADDING_VERTICAL, GROUP_PADDING_HORIZONTAL] + }, + group: true, + children: [], + runAfterTasks: [], + data: { + status: RunStatus.Succeeded, + } + }; + nodes.push(execution1); + + const [execution2Id, execution2Nodes] = createExecution2(); + execution1.children.push(execution2Id); + nodes.push(...execution2Nodes); + + const task_1_1: PipelineNodeModel = { + id: 'task_1_1', + label: 'Task 1-1', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [execution2Id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_1_2: PipelineNodeModel = { + id: 'task_1_2', + label: 'Task 1-2', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_1_1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_1_3: PipelineNodeModel = { + id: 'task_1_3', + label: 'Task 1-3', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_1_1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_1_4: PipelineNodeModel = { + id: 'task_1_4', + label: 'Task 1-4', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_1_3.id], + data: { + status: RunStatus.Succeeded, + } + }; + nodes.push(task_1_1, task_1_2, task_1_3, task_1_4); + execution1.children.push(task_1_1.id, task_1_2.id, task_1_3.id, task_1_4.id); + + const [execution3Id, execution3Nodes] = createExecution3(execution2Id); + execution1.children.push(execution3Id); + nodes.push(...execution3Nodes); + + const task_1: PipelineNodeModel = { + id: 'task_1', + label: 'Task 1', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [execution1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_2: PipelineNodeModel = { + id: 'task_2', + label: 'Task 2', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [execution1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_3: PipelineNodeModel = { + id: 'task_3', + label: 'Task 3', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [execution1.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_4: PipelineNodeModel = { + id: 'task_4', + label: 'Task 4', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_5: PipelineNodeModel = { + id: 'task_5', + label: 'Task 5', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_3.id], + data: { + status: RunStatus.Succeeded, + } + }; + const task_6: PipelineNodeModel = { + id: 'task_6', + label: 'Task 6', + type: 'Task', + width: DEFAULT_TASK_WIDTH, + height: DEFAULT_TASK_HEIGHT, + style: { + padding: [NODE_PADDING_VERTICAL, NODE_PADDING_HORIZONTAL] + }, + runAfterTasks: [task_4.id], + data: { + status: RunStatus.Succeeded, + } + }; + nodes.push(task_1, task_2, task_3, task_4, task_5, task_6); + + return nodes; +}; diff --git a/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx new file mode 100644 index 00000000..edc7b214 --- /dev/null +++ b/packages/demo-app-ts/src/demos/pipelineGroupsDemo/pipelineGroupsComponentFactory.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { + GraphElement, + ComponentFactory, + ModelKind, + SpacerNode, + DEFAULT_SPACER_NODE_TYPE, + withSelection, + withPanZoom, + GraphComponent, + TaskEdge +} from '@patternfly/react-topology'; +import DemoTaskNode from './DemoTaskNode'; +import DemoTaskGroup from './DemoTaskGroup'; + +const pipelineGroupsComponentFactory: ComponentFactory = ( + kind: ModelKind, + type: string +): React.ComponentType<{ element: GraphElement }> | undefined => { + if (kind === ModelKind.graph) { + return withPanZoom()(GraphComponent); + } + switch (type) { + case 'Execution': + return DemoTaskGroup; + case 'Task': + return withSelection()(DemoTaskNode); + case DEFAULT_SPACER_NODE_TYPE: + return SpacerNode; + case 'edge': + return TaskEdge; + default: + return undefined; + } +}; + +export default pipelineGroupsComponentFactory; diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineLayout.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineLayoutDemo.tsx similarity index 91% rename from packages/demo-app-ts/src/demos/pipelinesDemo/PipelineLayout.tsx rename to packages/demo-app-ts/src/demos/pipelinesDemo/PipelineLayoutDemo.tsx index 53209f8f..f27670c3 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineLayout.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineLayoutDemo.tsx @@ -92,7 +92,7 @@ const TopologyPipelineLayout: React.FC = observer(() => { TopologyPipelineLayout.displayName = 'TopologyPipelineLayout'; -export const PipelineLayout = React.memo(() => { +export const PipelineLayoutDemo = React.memo(() => { const controller = new Visualization(); controller.setFitToScreenOnLayout(true); controller.registerComponentFactory(pipelineComponentFactory); @@ -123,10 +123,12 @@ export const PipelineLayout = React.memo(() => { }); return ( - - - - - +
+ + + + + +
); }); diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineTasks.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineTasksDemo.tsx similarity index 83% rename from packages/demo-app-ts/src/demos/pipelinesDemo/PipelineTasks.tsx rename to packages/demo-app-ts/src/demos/pipelinesDemo/PipelineTasksDemo.tsx index 24bbe36a..549f3cc0 100644 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineTasks.tsx +++ b/packages/demo-app-ts/src/demos/pipelinesDemo/PipelineTasksDemo.tsx @@ -56,14 +56,16 @@ export const PipelineTasks: React.FC = observer(() => { PipelineTasks.displayName = 'PipelineTasks'; -export const TopologyPipelineTasks = React.memo(() => { +export const PipelineTasksDemo = React.memo(() => { const controller = new Visualization(); controller.registerComponentFactory(pipelineComponentFactory); return ( - - - - - +
+ + + + + +
); }); diff --git a/packages/demo-app-ts/src/demos/pipelinesDemo/TopologyPipelineDemo.tsx b/packages/demo-app-ts/src/demos/pipelinesDemo/TopologyPipelineDemo.tsx deleted file mode 100644 index 6ef4e524..00000000 --- a/packages/demo-app-ts/src/demos/pipelinesDemo/TopologyPipelineDemo.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { Tab, Tabs, TabTitleText } from '@patternfly/react-core'; -import { TASKS_TITLE, TopologyPipelineTasks } from './PipelineTasks'; -import { LAYOUT_TITLE, PipelineLayout } from './PipelineLayout'; - -const TASKS = 0; -const LAYOUT = 1; - -export const TopologyPipelineDemo: React.FunctionComponent = () => { - const [activeKey, setActiveKey] = React.useState(TASKS); - - const handleTabClick = (_event: React.MouseEvent, tabIndex: string | number) => { - setActiveKey(tabIndex); - }; - - return ( -
- - {TASKS_TITLE}}> - - - {LAYOUT_TITLE}}> - - - -
- ); -}; -TopologyPipelineDemo.displayName = 'TopologyPipelineDemo'; diff --git a/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx b/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx index b146f53c..e5d31950 100644 --- a/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx +++ b/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; -import { action } from 'mobx'; import { - createTopologyControlButtons, DagreLayout, - defaultControlButtonsOptions, EdgeModel, Graph, Layout, @@ -13,7 +10,6 @@ import { NodeModel, NodeShape, SELECTION_EVENT, - TopologyControlBar, TopologyView, useVisualizationController, Visualization, @@ -22,6 +18,7 @@ import { } from '@patternfly/react-topology'; import defaultComponentFactory from '../../components/defaultComponentFactory'; import statusConnectorsComponentFactory from './statusConnectorsComponentFactory'; +import DemoControlBar from '../DemoControlBar'; const DEFAULT_CHAR_WIDTH = 8; const DEFAULT_NODE_SIZE = 75; @@ -200,29 +197,7 @@ export const StatusConnectorsDemo: React.FunctionComponent= () => { }, [controller]); return ( - { - controller.getGraph().scaleBy(4 / 3); - }), - zoomOutCallback: action(() => { - controller.getGraph().scaleBy(0.75); - }), - fitToScreenCallback: action(() => { - controller.getGraph().fit(80); - }), - resetViewCallback: action(() => { - controller.getGraph().reset(); - controller.getGraph().layout(); - }), - legend: false - })} - /> - } - > + } > ); diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx index e5c223f1..96bb782d 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx @@ -1,14 +1,10 @@ import * as React from 'react'; -import { action } from 'mobx'; import { Tab, Tabs, TabTitleText } from '@patternfly/react-core'; import { - createTopologyControlButtons, - defaultControlButtonsOptions, GRAPH_POSITION_CHANGE_EVENT, GRAPH_LAYOUT_END_EVENT, SELECTION_EVENT, SelectionEventListener, - TopologyControlBar, TopologySideBar, TopologyView, useEventListener, @@ -25,6 +21,7 @@ import OptionsViewBar from './OptionsViewBar'; import { DemoContext } from './DemoContext'; import demoComponentFactory from './demoComponentFactory'; import { graphPositionChangeListener, layoutEndListener } from './listeners'; +import DemoControlBar from '../DemoControlBar'; interface TopologyViewComponentProps { useSidebar: boolean; @@ -89,27 +86,7 @@ const TopologyViewComponent: React.FunctionComponent return ( { - controller.getGraph().scaleBy(4 / 3); - }), - zoomOutCallback: action(() => { - controller.getGraph().scaleBy(0.75); - }), - fitToScreenCallback: action(() => { - controller.getGraph().fit(80); - }), - resetViewCallback: action(() => { - controller.getGraph().reset(); - controller.getGraph().layout(); - }), - legend: false - })} - /> - } + controlBar={} contextToolbar={} viewToolbar={ } sideBar={useSidebar && topologySideBar} diff --git a/packages/module/src/elements/BaseNode.ts b/packages/module/src/elements/BaseNode.ts index eb43b819..2c8fd079 100644 --- a/packages/module/src/elements/BaseNode.ts +++ b/packages/module/src/elements/BaseNode.ts @@ -159,7 +159,10 @@ export default class BaseNode extends getAllNodeChildren(): Node[] { return super.getChildren().reduce((total, nexChild) => { if (isNode(nexChild)) { - total.push(nexChild.isGroup() ? nexChild.getAllNodeChildren() : nexChild); + if (nexChild.isGroup()) { + return total.concat(nexChild.getAllNodeChildren()); + } + total.push(nexChild); } return total; }, []); diff --git a/packages/module/src/layouts/DagreGroupsLayout.ts b/packages/module/src/layouts/DagreGroupsLayout.ts new file mode 100644 index 00000000..afcb011e --- /dev/null +++ b/packages/module/src/layouts/DagreGroupsLayout.ts @@ -0,0 +1,172 @@ +import * as dagre from 'dagre'; +import { Edge, Graph, GRAPH_LAYOUT_END_EVENT, Layout, Node } from '../types'; +import { BaseLayout, LAYOUT_DEFAULTS } from './BaseLayout'; +import { LayoutLink } from './LayoutLink'; +import { LayoutNode } from './LayoutNode'; +import { DagreNode } from './DagreNode'; +import { DagreLink } from './DagreLink'; +import { DagreLayoutOptions, LEFT_TO_RIGHT } from './DagreLayout'; +import { LayoutGroup } from './LayoutGroup'; +import { Point } from '../geom'; +import { getClosestVisibleParent, getGroupChildrenDimensions } from '../utils'; + +export class DagreGroupsLayout extends BaseLayout implements Layout { + protected dagreOptions: DagreLayoutOptions; + + constructor(graph: Graph, options?: Partial) { + super(graph, options); + this.dagreOptions = { + ...this.options, + layoutOnDrag: false, + marginx: 0, + marginy: 0, + nodesep: this.options.nodeDistance, + edgesep: this.options.linkDistance, + rankdir: LEFT_TO_RIGHT, + ranker: 'tight-tree', + ...options + }; + } + + protected createLayoutNode(node: Node, nodeDistance: number, index: number) { + return new DagreNode(node, nodeDistance, index); + } + + protected createLayoutLink(edge: Edge, source: LayoutNode, target: LayoutNode, isFalse: boolean = false): LayoutLink { + return new DagreLink(edge, source, target, isFalse); + } + + protected updateEdgeBendpoints(edges: DagreLink[]): void { + edges.forEach(edge => { + const link = edge as DagreLink; + link.updateBendpoints(); + }); + } + + protected getFauxEdges(): LayoutLink[] { + return []; + } + + protected getAllLeaves(group: LayoutGroup): LayoutNode[] { + const leaves = [...group.leaves]; + group.groups?.forEach(subGroup => leaves.push(...this.getAllLeaves(subGroup))); + return leaves; + } + protected getAllSubGroups(group: LayoutGroup): LayoutGroup[] { + const groups = [...group.groups]; + group.groups?.forEach(subGroup => groups.push(...this.getAllSubGroups(subGroup))); + return groups; + } + + protected isNodeInGroups(node: LayoutNode, groups: LayoutGroup[]): boolean { + return !!groups.find(group => group.leaves.includes(node) || this.isNodeInGroups(node, group.groups)); + } + + protected getEdgeLayoutNode(nodes: LayoutNode[], groups: LayoutGroup[], node: Node | null): LayoutNode | undefined { + if (!node) { + return undefined; + } + + let layoutNode = nodes.find(n => n.id === node.getId()); + if (!layoutNode) { + const groupNode = groups.find(n => n.id === node.getId()); + if (groupNode) { + const dagreNode = new DagreNode(groupNode.element, groupNode.padding); + if (dagreNode) { + return dagreNode; + } + } + } + + if (!layoutNode && node.getNodes().length) { + const id = node.getChildren()[0].getId(); + layoutNode = nodes.find(n => n.id === id); + } + if (!layoutNode) { + layoutNode = this.getEdgeLayoutNode(nodes, groups, getClosestVisibleParent(node)); + } + + return layoutNode; + } + + protected getLinks(edges: Edge[]): LayoutLink[] { + const links: LayoutLink[] = []; + edges.forEach(e => { + const source = this.getEdgeLayoutNode(this.nodes, this.groups, e.getSource()); + const target = this.getEdgeLayoutNode(this.nodes, this.groups, e.getTarget()); + if (source && target) { + this.initializeEdgeBendpoints(e); + links.push(this.createLayoutLink(e, source, target)); + } + }); + + return links; + } + + protected startLayout(graph: Graph, initialRun: boolean, addingNodes: boolean): void { + if (initialRun || addingNodes) { + const doLayout = (parentGroup?: LayoutGroup) => { + const dagreGraph = new dagre.graphlib.Graph({compound: true}); + const options = {...this.dagreOptions}; + + Object.keys(LAYOUT_DEFAULTS).forEach(key => delete options[key]); + dagreGraph.setGraph(options); + + // Determine the groups, nodes, and edges that belong in this layout + const layerGroups = this.groups.filter((group) => group.parent?.id === parentGroup?.id || (!parentGroup && group.parent?.id === graph.getId())); + const layerNodes = this.nodes.filter((n) => n.element.getParent()?.getId() === parentGroup?.id || !parentGroup && n.element.getParent()?.getId() === graph.getId()); + const layerEdges = this.edges.filter((edge) => + (layerGroups.find((n) => n.id === edge.sourceNode.id) || layerNodes.find((n) => n.id === edge.sourceNode.id)) && + (layerGroups.find((n) => n.id === edge.targetNode.id) || layerNodes.find((n) => n.id === edge.targetNode.id)) + ); + + // Layout any child groups first + layerGroups.forEach((group) => { + doLayout(group); + + // Add the child group node (now with the correct dimensions) to the graph + const dagreNode = new DagreNode(group.element, group.padding); + const updateNode = dagreNode.getUpdatableNode(); + dagreGraph.setNode(group.id, updateNode); + }); + + layerNodes?.forEach(node => { + const updateNode = (node as DagreNode).getUpdatableNode(); + dagreGraph.setNode(node.id, updateNode); + }); + + layerEdges?.forEach(dagreEdge => { + dagreGraph.setEdge(dagreEdge.source.id, dagreEdge.target.id, dagreEdge); + }); + + dagre.layout(dagreGraph); + + // Update the node element positions + layerNodes.forEach(node => { + (node as DagreNode).updateToNode(dagreGraph.node(node.id)); + }); + + // Update the group element positions (setting the group's positions updates its children) + layerGroups.forEach(node => { + const dagreNode = dagreGraph.node(node.id); + node.element.setPosition(new Point(dagreNode.x, dagreNode.y)); + }); + + this.updateEdgeBendpoints(this.edges as DagreLink[]); + + // now that we've laid out the children, set the dimensions on the group (not on the graph) + if (parentGroup) { + parentGroup.element.setDimensions(getGroupChildrenDimensions(parentGroup.element)); + } + } + + doLayout(); + } + + if (this.dagreOptions.layoutOnDrag) { + this.forceSimulation.useForceSimulation(this.nodes, this.edges, this.getFixedNodeDistance); + } else { + this.graph.getController().fireEvent(GRAPH_LAYOUT_END_EVENT, {graph: this.graph}); + } + } +} diff --git a/packages/module/src/layouts/index.ts b/packages/module/src/layouts/index.ts index 4efa664c..bde35b29 100644 --- a/packages/module/src/layouts/index.ts +++ b/packages/module/src/layouts/index.ts @@ -4,6 +4,7 @@ export * from './ColaLayout'; export * from './ColaGroupsLayout'; export * from './ConcentricLayout'; export * from './DagreLayout'; +export * from './DagreGroupsLayout'; export * from './ForceLayout'; export * from './GridLayout'; export * from './LayoutNode'; diff --git a/packages/module/src/pipelines/layouts/PipelineDagreGroupsLayout.ts b/packages/module/src/pipelines/layouts/PipelineDagreGroupsLayout.ts new file mode 100644 index 00000000..669d10e8 --- /dev/null +++ b/packages/module/src/pipelines/layouts/PipelineDagreGroupsLayout.ts @@ -0,0 +1,33 @@ +import { Graph, Layout } from '../../types'; +import { NODE_SEPARATION_HORIZONTAL, NODE_SEPARATION_VERTICAL } from '../const'; +import { DagreLayoutOptions, LEFT_TO_RIGHT } from '../../layouts/DagreLayout'; +import { DagreGroupsLayout } from '../../layouts/DagreGroupsLayout'; + +export class PipelineDagreGroupsLayout extends DagreGroupsLayout implements Layout { + constructor(graph: Graph, options?: Partial) { + super(graph, { + linkDistance: 0, + nodeDistance: 0, + groupDistance: 0, + collideDistance: 0, + simulationSpeed: 0, + chargeStrength: 0, + allowDrag: false, + layoutOnDrag: false, + nodesep: NODE_SEPARATION_VERTICAL, + ranksep: NODE_SEPARATION_HORIZONTAL, + edgesep: 50, + ranker: 'tight-tree', + rankdir: LEFT_TO_RIGHT, + marginx: 20, + marginy: 20, + ...options + }); + } + set nodesep(nodesep: number) { + this.dagreOptions.nodesep = nodesep; + } + set ranksep(ranksep: number) { + this.dagreOptions.ranksep = ranksep; + } +} diff --git a/packages/module/src/pipelines/layouts/index.ts b/packages/module/src/pipelines/layouts/index.ts index 64865e28..4b76a570 100644 --- a/packages/module/src/pipelines/layouts/index.ts +++ b/packages/module/src/pipelines/layouts/index.ts @@ -1 +1,2 @@ export * from './PipelineDagreLayout'; +export * from './PipelineDagreGroupsLayout'; diff --git a/packages/module/src/utils/element-utils.ts b/packages/module/src/utils/element-utils.ts index 93d9502f..f3bda74b 100644 --- a/packages/module/src/utils/element-utils.ts +++ b/packages/module/src/utils/element-utils.ts @@ -1,4 +1,6 @@ import { GraphElement, Node, isNode, isGraph, NodeStyle } from '../types'; +import Rect from '../geom/Rect'; +import { Dimensions } from '../geom'; const groupNodeElements = (nodes: GraphElement[]): Node[] => { if (!nodes.length) { @@ -104,11 +106,47 @@ const getGroupPadding = (element: GraphElement, padding = 0): number => { return newPadding; }; +const getGroupChildrenDimensions = (group: Node): Dimensions => { + const children = group.getChildren() + .filter(isNode) + .filter(n => n.isVisible()); + if (!children.length) { + return new Dimensions(0, 0); + } + + let rect: Rect | undefined; + children.forEach(c => { + if (isNode(c)) { + const { padding } = c.getStyle(); + const b = c.getBounds(); + // Currently non-group nodes do not include their padding in the bounds + if (!c.isGroup() && padding) { + b.padding(c.getStyle().padding); + } + if (!rect) { + rect = b.clone(); + } else { + rect.union(b); + } + } + }); + + if (!rect) { + rect = new Rect(); + } + + const { padding } = group.getStyle(); + const paddedRect = rect.padding(padding); + + return new Dimensions(paddedRect.width, paddedRect.height); +}; + export { groupNodeElements, leafNodeElements, getTopCollapsedParent, getClosestVisibleParent, getElementPadding, - getGroupPadding + getGroupPadding, + getGroupChildrenDimensions };