diff --git a/packages/demo-app-ts/src/Demos.ts b/packages/demo-app-ts/src/Demos.ts index b4d82c79..6fa33191 100644 --- a/packages/demo-app-ts/src/Demos.ts +++ b/packages/demo-app-ts/src/Demos.ts @@ -11,6 +11,7 @@ import { ContextMenus } from './demos/ContextMenus'; import { TopologyPackage } from './demos/TopologyPackage'; import { ComplexGroup } from './demos/Groups'; import { CollapsibleGroups } from './demos/CollapsibleGroups'; +import { AggregateEdges } from './demos/AggregateEdges'; import './Demo.css'; @@ -70,6 +71,11 @@ export const Demos: DemoInterface[] = [ }, ] }, + { + id: 'aggregate-edges', + name: 'Aggregate Edges', + componentType: AggregateEdges, + }, { id: 'selection', name: 'Selection', diff --git a/packages/demo-app-ts/src/demos/AggregateEdges.tsx b/packages/demo-app-ts/src/demos/AggregateEdges.tsx new file mode 100644 index 00000000..490466d9 --- /dev/null +++ b/packages/demo-app-ts/src/demos/AggregateEdges.tsx @@ -0,0 +1,283 @@ +import * as React from 'react'; +import { action } from 'mobx'; +import * as _ from 'lodash'; +import { + ColaLayout, + createAggregateEdges, + createTopologyControlButtons, + defaultControlButtonsOptions, + Graph, + Layout, + LayoutFactory, + Model, + NodeShape, + SELECTION_EVENT, + TopologyControlBar, + TopologySideBar, + TopologyView, useVisualizationController, + Visualization, VisualizationProvider, + VisualizationSurface +} from '@patternfly/react-topology'; +import defaultComponentFactory from '../components/defaultComponentFactory'; +import stylesComponentFactory from '../components/stylesComponentFactory'; +import { + createEdge, + createNode, +} from '../utils/styleUtils'; +import { ToolbarGroup, ToolbarItem, Checkbox } from '@patternfly/react-core'; + +const defaultLayoutFactory: LayoutFactory = (type: string, graph: Graph): Layout | undefined => { + return new ColaLayout(graph, { layoutOnDrag: false, nodeDistance: 100 }); +}; + +const getModel = (peerAggregate: boolean, collapsibleAggregate: boolean): Model => { + const node1 = createNode({ + id: '1', + shape: NodeShape.ellipse, + label: 'One', + setLocation: false, + }); + const node2 = createNode({ + id: '2', + shape: NodeShape.ellipse, + label: 'Two', + setLocation: false, + }); + const group1Nodes = [ + createNode({ + id: '11', + shape: NodeShape.ellipse, + label: 'One-One', + setLocation: false, + }), + createNode({ + id: '12', + shape: NodeShape.ellipse, + label: 'One-Two', + setLocation: false, + }), + createNode({ + id: '13', + shape: NodeShape.ellipse, + label: 'One-Three', + setLocation: false, + }), + ]; + const group2Nodes = [ + createNode({ + id: '21', + shape: NodeShape.ellipse, + label: 'Two-One', + setLocation: false, + }), + createNode({ + id: '22', + shape: NodeShape.ellipse, + label: 'Two-Two', + setLocation: false, + }), + createNode({ + id: '23', + shape: NodeShape.ellipse, + label: 'Two-Three', + setLocation: false, + }), + createNode({ + id: '24', + shape: NodeShape.ellipse, + label: 'Two-Four', + setLocation: false, + }), + createNode({ + id: '25', + shape: NodeShape.ellipse, + label: 'Two-Five', + setLocation: false, + }), + ]; + const group3Nodes = [ + createNode({ + id: '31', + shape: NodeShape.ellipse, + label: 'Three-One', + setLocation: false, + }), + createNode({ + id: '32', + shape: NodeShape.ellipse, + label: 'Three-Two', + setLocation: false, + }), + createNode({ + id: '33', + shape: NodeShape.ellipse, + label: 'Three-Three', + setLocation: false, + }), + ]; + + const groupOne = { + id: 'Group 1', + type: 'group', + label: 'Group 1', + children: group1Nodes.map(n => n.id), + group: true, + style: { padding: 17 }, + data: { + collapsedWidth: 75, + collapsedHeight: 75, + collapsible: true + } + }; + + const groupTwo = { + id: 'Group 2', + type: 'group', + label: 'Group 2', + children: group2Nodes.map(n => n.id), + group: true, + collapsed: collapsibleAggregate, + style: { padding: 17 }, + data: { + collapsedWidth: 75, + collapsedHeight: 75, + collapsible: true + } + }; + + const groupThree = { + id: 'Group 3', + type: 'group', + label: 'Group 3', + children: group3Nodes.map(n => n.id), + group: true, + style: { padding: 17 }, + data: { + collapsedWidth: 75, + collapsedHeight: 75, + collapsible: true + } + }; + + const nodes = [node1, node2, ...group1Nodes, ...group2Nodes, ...group3Nodes, groupOne, groupTwo, groupThree]; + + const edges = [ + createEdge('11', '21', {}), + createEdge('12', '21', {}), + createEdge('13', '21', {}), + createEdge('1', '31', {}), + createEdge('1', '32', {}), + createEdge('2', '31', {}), + createEdge('21', '31', {}), + createEdge('32', '21', {}), + createEdge('21', '32', {}), + createEdge('22', '31', {}), + createEdge('22', '32', {}), + // createEdge('23', '25', {}), TODO: crash view + ]; + + const graph = { + id: 'g1', + type: 'graph', + layout: 'ColaNoForce' + }; + + return { + graph, + nodes, + edges: createAggregateEdges('aggregate-edge', edges, nodes, { group: peerAggregate, visibility: collapsibleAggregate }) + }; +}; + +export const AggregateEdgesDemo: React.FunctionComponent = () => { + const controller = useVisualizationController(); + const [selectedIds, setSelectedIds] = React.useState([]); + const [peerAggregate, setGroupAggregate] = React.useState(true); + const [collapsibleAggregate, setCollapsibleAggregate] = React.useState(false); + + const viewToolbar = ( + <> + + + setGroupAggregate(checked)} + /> + + + setCollapsibleAggregate(checked)} + /> + + + + ); + + React.useEffect(() => { + const model = getModel(peerAggregate, collapsibleAggregate); + // eslint-disable-next-line no-console + console.log("model", model); + controller.addEventListener(SELECTION_EVENT, ids => { + setSelectedIds(ids); + }); + + controller.fromModel(model, false); + }, [collapsibleAggregate, controller, peerAggregate]); + + const topologySideBar = ( + 0} onClose={() => setSelectedIds([])}> +
{_.head(selectedIds)}
+
+ ); + + 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 + })} + /> + } + sideBar={topologySideBar} + sideBarOpen={_.size(selectedIds) > 0} + viewToolbar={viewToolbar} + > + + + ); +}; + + + +export const AggregateEdges = React.memo(() => { + const controller = new Visualization(); + controller.registerLayoutFactory(defaultLayoutFactory); + controller.registerComponentFactory(defaultComponentFactory); + controller.registerComponentFactory(stylesComponentFactory); + + return ( + + + + ); +}); diff --git a/packages/module/src/elements/BaseEdge.ts b/packages/module/src/elements/BaseEdge.ts index e75e6bd1..019cf442 100644 --- a/packages/module/src/elements/BaseEdge.ts +++ b/packages/module/src/elements/BaseEdge.ts @@ -27,6 +27,8 @@ export default class BaseEdge extends @observable.ref private endPoint?: Point; + private aggregatedIds?: string[]; + @computed private get sourceAnchor(): Anchor { return this.getSourceAnchorNode().getAnchor(AnchorEnd.source, this.getType()); @@ -118,6 +120,24 @@ export default class BaseEdge extends if (this.startPoint) { return this.startPoint; } + + if ( + this.getId().startsWith("aggregate_") && + this.getSource().isGroup() && this.getSource().getChildren().includes(this.getTarget()) + ) { + const relatedEdge = this.getGraph() + .getEdges().find(e => e !== this && + (e.getSource() === this.source || e.getTarget() === this.source) && + this.getAggregatedIds().every(c => e.getAggregatedIds().includes(c))); + if (relatedEdge) { + if (relatedEdge.getSource() === this.source) { + return relatedEdge.getStartPoint(); + } else { + return relatedEdge.getEndPoint(); + } + } + } + const bendpoints = this.getBendpoints(); let referencePoint: Point; if (bendpoints && bendpoints.length > 0) { @@ -142,6 +162,24 @@ export default class BaseEdge extends if (this.endPoint) { return this.endPoint; } + + if ( + this.getId().startsWith("aggregate_") && + this.getTarget().isGroup() && this.getTarget().getChildren().includes(this.getSource()) + ) { + const relatedEdge = this.getGraph() + .getEdges().find(e => e !== this && + (e.getSource() === this.target || e.getTarget() === this.target) && + this.getAggregatedIds().every(c => e.getAggregatedIds().includes(c))); + if (relatedEdge) { + if (relatedEdge.getSource() === this.target) { + return relatedEdge.getStartPoint(); + } else { + return relatedEdge.getEndPoint(); + } + } + } + const bendpoints = this.getBendpoints(); let referencePoint: Point; if (bendpoints && bendpoints.length > 0) { @@ -162,6 +200,14 @@ export default class BaseEdge extends } } + getAggregatedIds = () => { + return this.aggregatedIds || []; + } + + setAggregatedIds = (aggregatedIds: string[]) => { + this.aggregatedIds = aggregatedIds; + } + setModel(model: E): void { super.setModel(model); if (model.source) { @@ -187,6 +233,9 @@ export default class BaseEdge extends if ('bendpoints' in model) { this.bendpoints = model.bendpoints ? model.bendpoints.map(b => new Point(b[0], b[1])) : []; } + if('aggregatedIds' in model) { + this.aggregatedIds = model.aggregatedIds; + } } toModel(): EdgeModel { diff --git a/packages/module/src/types.ts b/packages/module/src/types.ts index 7bf4e1bf..b17b4927 100644 --- a/packages/module/src/types.ts +++ b/packages/module/src/types.ts @@ -126,6 +126,7 @@ export interface EdgeModel extends ElementModel { target?: string; edgeStyle?: EdgeStyle; animationSpeed?: EdgeAnimationSpeed; + aggregatedIds?: string[]; bendpoints?: PointTuple[]; } @@ -248,6 +249,8 @@ export interface Edge extends GraphEle setStartPoint(x?: number, y?: number): void; getEndPoint(): Point; setEndPoint(x?: number, y?: number): void; + getAggregatedIds(): string[]; + setAggregatedIds(aggregatedIds: string[]): void; getBendpoints(): Point[]; setBendpoints(points: Point[]): void; removeBendpoint(point: Point | number): void; diff --git a/packages/module/src/utils/createAggregateEdges.ts b/packages/module/src/utils/createAggregateEdges.ts index 35e7155c..1cddf893 100644 --- a/packages/module/src/utils/createAggregateEdges.ts +++ b/packages/module/src/utils/createAggregateEdges.ts @@ -1,6 +1,11 @@ import * as lodash from 'lodash'; import { EdgeModel, NodeModel } from '../types'; +export interface AggregateOptions { + visibility?: boolean; + group?: boolean; +}; + const getNodeParent = (nodeId: string, nodes: NodeModel[]): NodeModel | undefined => nodes.find(n => (n.children ? n.children.includes(nodeId) : null)); @@ -20,75 +25,156 @@ const getDisplayedNodeForNode = (nodeId: string | undefined, nodes: NodeModel[] return displayedNode ? displayedNode.id : ''; }; +const manageEdgeMerge = (source: string, target: string, aggregateEdgeType: string, newEdges: EdgeModel[], + edge: EdgeModel, aggregateEdges: EdgeModel[], forceAdd: boolean) => { + + // Make sure visible is defined so that changes override what could already be in the element + edge.visible = 'visible' in edge ? edge.visible : true; + + if (source !== target) { + const existing = aggregateEdges.find( + e => (e.source === source || e.source === target) && (e.target === target || e.target === source) && e.type === aggregateEdgeType + ); + + if (existing) { + // At least one other edge, add this edge and add the aggregate edge to the edges + + // Add this edge to the aggregate and set it not visible + existing.aggregatedIds && existing.aggregatedIds.push(edge.id); + edge.visible = false; + + // Hide edges that are depicted by this aggregate edge + lodash.forEach(existing.aggregatedIds, existingChild => { + const updateEdge = newEdges.find(newEdge => newEdge.id === existingChild); + if (updateEdge) { + updateEdge.visible = false; + } + }); + + // Update the aggregate edges bidirectional flag + existing.data.bidirectional = existing.data.bidirectional || existing.source !== edge.source; + + // Check if this edge has already been added + if ( + !newEdges.find( + e => (e.source === source || e.source === target) && (e.target === target || e.target === source) && e.type === aggregateEdgeType + ) + ) { + newEdges.push(existing); + } + } else { + const newEdge: EdgeModel = { + data: { bidirectional: false }, + aggregatedIds: [edge.id], + source, + target, + id: `aggregate_${source}_${target}`, + type: aggregateEdgeType + }; + aggregateEdges.push(newEdge); + if (forceAdd) { + newEdges.push(newEdge); + edge.visible = false; + } + } + } else { + // Hide edges that connect to a non-visible node to its ancestor + edge.visible = false; + } +}; + +const manageEdgeByCollapsible = (aggregateEdgeType: string, newEdges: EdgeModel[], edge: EdgeModel, aggregateEdges: EdgeModel[], nodes: NodeModel[]) => { + const source = getDisplayedNodeForNode(edge.source, nodes); + const target = getDisplayedNodeForNode(edge.target, nodes); + + if (source !== edge.source || target !== edge.target) { + manageEdgeMerge(source, target, aggregateEdgeType, newEdges, edge, aggregateEdges, false) + } +} + +const getParentAtDepth = (node: string, depth: number, nodes: NodeModel[]) => { + let curr: string | null = node; + while (curr && depth > 0) { + curr = getNodeParent(curr, nodes)?.id; + depth--; + } + return curr; +}; + +const manageEdgeByPeerGroup = (aggregateEdgeType: string, newEdges: EdgeModel[], edge: EdgeModel, aggregateEdges: EdgeModel[], nodes: NodeModel[]) => { + let topReached = false; + let nestingDepth = 1; + + const srcParent = getNodeParent(edge.source, nodes)?.id || edge.source; + const dstParent = getNodeParent(edge.target, nodes)?.id || edge.target; + + const ref: string = `${aggregateEdgeType}-${[srcParent, dstParent].sort((a, b) => a.localeCompare(b)).join('-')}` + let prev: string = edge.source; + let curr: string | null = null; + + function getEdgePeer(e: EdgeModel): string { + return topReached ? e.target : e.source; + } + + function moveNext() { + if (curr) { + prev = curr; + } + + // check if prev & curr share the same parent + if (!curr || getNodeParent(prev, nodes) !== getNodeParent(curr, nodes)) { + const peer = getEdgePeer(edge); + curr = getParentAtDepth(peer, nestingDepth, nodes); + } else { + curr = null; + } + } + + for (let i = 0; i < 2; i++) { + moveNext(); + while (curr) { + manageEdgeMerge(prev, curr, ref, newEdges, edge, aggregateEdges, prev === edge.source); + nestingDepth++; + moveNext(); + } + + nestingDepth = 1; + if (!topReached) { + // top common parent has been reached, moving now from tgt to src + topReached = true; + } else { + // add last edge + manageEdgeMerge(prev, edge.target, ref, newEdges, edge, aggregateEdges, true); + } + } +} + const createAggregateEdges = ( aggregateEdgeType: string, edges: EdgeModel[] | undefined, - nodes: NodeModel[] | undefined + nodes: NodeModel[] | undefined, + options: AggregateOptions = { visibility: true, group: false } ): EdgeModel[] => { const aggregateEdges: EdgeModel[] = []; - return lodash.reduce( + const result = lodash.reduce( edges, (newEdges: EdgeModel[], edge: EdgeModel) => { - const source = getDisplayedNodeForNode(edge.source, nodes); - const target = getDisplayedNodeForNode(edge.target, nodes); - - // Make sure visible is defined so that changes override what could already be in the element - edge.visible = 'visible' in edge ? edge.visible : true; - - if (source !== edge.source || target !== edge.target) { - if (source !== target) { - const existing = aggregateEdges.find( - e => (e.source === source || e.source === target) && (e.target === target || e.target === source) - ); - - if (existing) { - // At least one other edge, add this edge and add the aggregate edge to the edges - - // Add this edge to the aggregate and set it not visible - existing.children && existing.children.push(edge.id); - edge.visible = false; - - // Hide edges that are depicted by this aggregate edge - lodash.forEach(existing.children, existingChild => { - const updateEdge = newEdges.find(newEdge => newEdge.id === existingChild); - if (updateEdge) { - updateEdge.visible = false; - } - }); - - // Update the aggregate edges bidirectional flag - existing.data.bidirectional = existing.data.bidirectional || existing.source !== edge.source; - - // Check if this edge has already been added - if ( - !newEdges.find( - e => (e.source === source || e.source === target) && (e.target === target || e.target === source) - ) - ) { - newEdges.push(existing); - } - } else { - const newEdge: EdgeModel = { - data: { bidirectional: false }, - children: [edge.id], - source, - target, - id: `aggregate_${source}_${target}`, - type: aggregateEdgeType - }; - aggregateEdges.push(newEdge); - } - } else { - // Hide edges that connect to a non-visible node to its ancestor - edge.visible = false; - } + if (options.visibility) { + manageEdgeByCollapsible(aggregateEdgeType, newEdges, edge, aggregateEdges, nodes); + } + if (options.group) { + manageEdgeByPeerGroup(aggregateEdgeType, newEdges, edge, aggregateEdges, nodes); } newEdges.push(edge); return newEdges; }, [] as EdgeModel[] ); + + // eslint-disable-next-line no-console + console.log("result", result); + return result; }; export { createAggregateEdges };