diff --git a/packages/demo-app-ts/src/demos/Layouts.tsx b/packages/demo-app-ts/src/demos/Layouts.tsx index ce60cec5..07830e12 100644 --- a/packages/demo-app-ts/src/demos/Layouts.tsx +++ b/packages/demo-app-ts/src/demos/Layouts.tsx @@ -10,7 +10,7 @@ import { useModel, ComponentFactory } from '@patternfly/react-topology'; -import defaultLayoutFactory from '../layouts/defaultLayoutFactory'; +import defaultLayoutFactory, { LayoutType } from '../layouts/defaultLayoutFactory'; import defaultComponentFactory from '../components/defaultComponentFactory'; import GroupHull from '../components/GroupHull'; import Group from '../components/DemoDefaultGroup'; @@ -61,9 +61,9 @@ const layoutStory = return null; }; -export const Force = withTopologySetup(layoutStory(getModel('Force'))); -export const Dagre = withTopologySetup(layoutStory(getModel('Dagre'))); -export const Cola = withTopologySetup(layoutStory(getModel('Cola'))); +export const Force = withTopologySetup(layoutStory(getModel(LayoutType.Force))); +export const Dagre = withTopologySetup(layoutStory(getModel(LayoutType.Dagre))); +export const Cola = withTopologySetup(layoutStory(getModel(LayoutType.Cola))); export const Layouts: React.FunctionComponent = () => { const [activeKey, setActiveKey] = useState(0); diff --git a/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx b/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx index 07b3f471..1fca54d8 100644 --- a/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx +++ b/packages/demo-app-ts/src/demos/statusConnectorsDemo/StatusConnectors.tsx @@ -19,6 +19,7 @@ import { import defaultComponentFactory from '../../components/defaultComponentFactory'; import statusConnectorsComponentFactory from './statusConnectorsComponentFactory'; import DemoControlBar from '../DemoControlBar'; +import { LayoutType } from '../../layouts/defaultLayoutFactory'; const DEFAULT_CHAR_WIDTH = 8; const DEFAULT_NODE_SIZE = 75; @@ -184,7 +185,7 @@ export const StatusConnectorsDemo: React.FunctionComponent = () => { const graph = { id: 'g1', type: 'graph', - layout: 'Dagre' + layout: LayoutType.Dagre }; const model = { graph, nodes, edges }; diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoContext.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoContext.tsx index bd2b0853..f92fe388 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoContext.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/DemoContext.tsx @@ -2,6 +2,7 @@ import { createContext } from 'react'; import { action, makeObservable, observable } from 'mobx'; import { LabelPosition } from '@patternfly/react-topology'; import { GeneratorEdgeOptions, GeneratorNodeOptions } from './generator'; +import { LayoutType } from '../../layouts/defaultLayoutFactory'; export class DemoModel { protected nodeOptionsP: GeneratorNodeOptions = { @@ -29,10 +30,12 @@ export class DemoModel { numGroups: 1, nestedLevel: 0 }; - protected layoutP: string = 'ColaNoForce'; + protected layoutP: string = LayoutType.ColaNoForce; protected medScaleP: number = 0.5; protected lowScaleP: number = 0.3; + protected logEventsP: boolean = false; + constructor() { makeObservable< DemoModel, @@ -42,12 +45,14 @@ export class DemoModel { | 'layoutP' | 'medScaleP' | 'lowScaleP' + | 'logEventsP' | 'setNodeOptions' | 'setEdgeOptions' | 'setCreationCounts' | 'setLayout' | 'setMedScale' | 'setLowScale' + | 'setLogEvents' >(this, { nodeOptionsP: observable.ref, edgeOptionsP: observable.shallow, @@ -55,12 +60,14 @@ export class DemoModel { layoutP: observable, medScaleP: observable, lowScaleP: observable, + logEventsP: observable, setNodeOptions: action, setEdgeOptions: action, setCreationCounts: action, setLayout: action, setMedScale: action, - setLowScale: action + setLowScale: action, + setLogEvents: action }); } @@ -111,6 +118,12 @@ export class DemoModel { public setLowScale = (scale: number): void => { this.lowScaleP = scale; }; + public get logEvents(): boolean { + return this.logEventsP; + } + public setLogEvents = (log: boolean): void => { + this.logEventsP = log; + }; } export const DemoContext = createContext(new DemoModel()); diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsViewBar.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsViewBar.tsx index bbadd781..a068e4db 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsViewBar.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/OptionsViewBar.tsx @@ -1,6 +1,7 @@ import { useContext, useState, useRef } from 'react'; import { Button, + Checkbox, Dropdown, DropdownItem, DropdownList, @@ -14,6 +15,19 @@ import { } from '@patternfly/react-core'; import { Controller, Model, observer } from '@patternfly/react-topology'; import { DemoContext } from './DemoContext'; +import { LayoutType } from '../../layouts/defaultLayoutFactory'; + +const LayoutTitles: Record = { + [LayoutType.BreadthFirst]: 'Breadth First', + [LayoutType.Cola]: 'Cola', + [LayoutType.ColaGroups]: 'Cola Groups', + [LayoutType.ColaNoForce]: 'Cola No Force', + [LayoutType.Concentric]: 'Concentric', + [LayoutType.Dagre]: 'Dagre', + [LayoutType.DagreHorizontal]: 'Dagre Horizontal', + [LayoutType.Force]: 'Force', + [LayoutType.Grid]: 'Grid' +}; const OptionsContextBar: React.FC<{ controller: Controller }> = observer(({ controller }) => { const options = useContext(DemoContext); @@ -36,36 +50,39 @@ const OptionsContextBar: React.FC<{ controller: Controller }> = observer(({ cont ) => ( setLayoutDropdownOpen(!layoutDropdownOpen)}> - {options.layout} + {LayoutTitles[options.layout]} )} isOpen={layoutDropdownOpen} onOpenChange={(isOpen) => setLayoutDropdownOpen(isOpen)} > - updateLayout('Force')}> - Force + updateLayout(LayoutType.Force)}> + {LayoutTitles[LayoutType.Force]} + + updateLayout(LayoutType.Dagre)}> + {LayoutTitles[LayoutType.Dagre]} - updateLayout('Dagre')}> - Dagre + updateLayout(LayoutType.DagreHorizontal)}> + {LayoutTitles[LayoutType.DagreHorizontal]} - updateLayout('Cola')}> - Cola + updateLayout(LayoutType.Cola)}> + {LayoutTitles[LayoutType.Cola]} - updateLayout('ColaGroups')}> - ColaGroups + updateLayout(LayoutType.ColaGroups)}> + {LayoutTitles[LayoutType.ColaGroups]} - updateLayout('ColaNoForce')}> - ColaNoForce + updateLayout(LayoutType.ColaNoForce)}> + {LayoutTitles[LayoutType.ColaNoForce]} - updateLayout('Grid')}> - Grid + updateLayout(LayoutType.Grid)}> + {LayoutTitles[LayoutType.Grid]} - updateLayout('Concentric')}> - Concentric + updateLayout(LayoutType.Concentric)}> + {LayoutTitles[LayoutType.Concentric]} - updateLayout('BreadthFirst')}> - BreadthFirst + updateLayout(LayoutType.BreadthFirst)}> + {LayoutTitles[LayoutType.BreadthFirst]} @@ -193,6 +210,14 @@ const OptionsContextBar: React.FC<{ controller: Controller }> = observer(({ cont + + options.setLogEvents(checked)} + label="Log events" + /> + ); }); diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx b/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx index a083681c..ce9e5628 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/TopologyPackage.tsx @@ -47,7 +47,8 @@ const TopologyViewComponent: FunctionComponent = obs options.creationCounts.numNodes, options.creationCounts.numGroups, options.creationCounts.numEdges, - options.creationCounts.nestedLevel + options.creationCounts.nestedLevel, + options.layout ); const model = { @@ -60,6 +61,7 @@ const TopologyViewComponent: FunctionComponent = obs }; controller.fromModel(model, true); + controller.getGraph().layout(); }, [controller, options.creationCounts, options.layout]); // Once we have the graph, run the layout. This ensures the graph size is set (by the initial size observation in VisualizationSurface) @@ -120,14 +122,14 @@ const TopologyViewComponent: FunctionComponent = obs }, [selectedIds, controller]); useEffect(() => { - controller.addEventListener(GRAPH_POSITION_CHANGE_EVENT, graphPositionChangeListener); - controller.addEventListener(GRAPH_LAYOUT_END_EVENT, layoutEndListener); - - return () => { + if (options.logEvents) { + controller.addEventListener(GRAPH_POSITION_CHANGE_EVENT, graphPositionChangeListener); + controller.addEventListener(GRAPH_LAYOUT_END_EVENT, layoutEndListener); + } else { controller.removeEventListener(GRAPH_POSITION_CHANGE_EVENT, graphPositionChangeListener); controller.removeEventListener(GRAPH_LAYOUT_END_EVENT, layoutEndListener); - }; - }, [controller]); + } + }, [controller, options.logEvents]); useEffect(() => { controller.getGraph().setDetailsLevelThresholds({ diff --git a/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts b/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts index e8d7695d..2eed6fb5 100644 --- a/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts +++ b/packages/demo-app-ts/src/demos/topologyPackageDemo/generator.ts @@ -9,6 +9,7 @@ import { NodeShape, NodeStatus } from '@patternfly/react-topology'; +import { LayoutType } from '../../layouts/defaultLayoutFactory'; export const DEFAULT_NODE_SIZE = 75; @@ -120,7 +121,8 @@ export const generateDataModel = ( numNodes: number, numGroups: number, numEdges: number, - groupDepth: number = 0 + groupDepth: number = 0, + layout: string = '' ): Model => { const groups: NodeModel[] = []; const nodes: NodeModel[] = []; @@ -166,25 +168,137 @@ export const generateDataModel = ( nodes.push(createNode(i)); } - const nodesPerGroup = Math.floor((numNodes - 2) / numGroups); - for (let i = 0; i < numGroups; i++) { - createGroup(nodes.slice(i * nodesPerGroup, (i + 1) * nodesPerGroup), 'Group', i + 1); - } + // For Dagre layouts we need special edge creation to make it sensible + if (layout === LayoutType.Dagre || layout === LayoutType.DagreHorizontal) { + // Compute numLevels such that every parent has at least 2 children + // For a binary tree: total nodes for L levels = 2^L - 1, so L = floor(log2(n + 1)) + const maxLevels = layout === LayoutType.Dagre ? 5 : 12; + const numLevels = Math.min(maxLevels, Math.max(1, Math.floor(Math.log2(numNodes + 1)))); - for (let i = 0; i < numEdges; i++) { - const sourceNum = getRandomNode(numNodes); - const targetNum = getRandomNode(numNodes, sourceNum); - const edge = { - id: `edge-${nodes[sourceNum].id}-${nodes[targetNum].id}`, - type: 'edge', - source: nodes[sourceNum].id, - target: nodes[targetNum].id, - data: { - index: i, - tag: '250kbs' + // Distribute nodes across levels ensuring all levels are populated + // First, calculate nodes per level to ensure we reach all levels + const levels: number[][] = []; + let nodeIndex = 0; + + // Start with 1 node at top, then grow each level but cap growth to ensure all levels get nodes + // const baseNodesPerLevel = Math.max(1, Math.floor(numNodes / numLevels)); + let remainingNodes = numNodes; + + for (let level = 0; level < numLevels; level++) { + const remainingLevels = numLevels - level; + // Ensure we leave at least 2 nodes for each remaining level (except first level) + const minNodesForRemainingLevels = (remainingLevels - 1) * 2; + const maxForThisLevel = Math.max(1, remainingNodes - minNodesForRemainingLevels); + // First level gets 1 node, subsequent levels get at least 2 nodes + const minNodes = level === 0 ? 1 : 2; + const targetNodes = + level === 0 + ? 1 + : Math.min(Math.max(minNodes, Math.floor(remainingNodes / remainingLevels) + 1), maxForThisLevel); + const nodesAtLevel = Math.max(minNodes, Math.min(targetNodes, remainingNodes)); + + levels.push([]); + for (let j = 0; j < nodesAtLevel && nodeIndex < numNodes; j++) { + levels[level].push(nodeIndex++); + remainingNodes--; } - }; - edges.push(edge); + } + + const lowestLevel = levels.length - 1; + + // Handle any remaining dangling nodes by adding them to the lowest level + if (nodeIndex < numNodes) { + while (nodeIndex < numNodes) { + levels[lowestLevel].push(nodeIndex++); + } + } + + // Create edges connecting each parent to its children + // Ensure every parent that has children has at least 2 children + // Track children by parent for grouping siblings + const childrenByParent: Map = new Map(); + const nodesWithChildren: Set = new Set(); + + let i = 0; + for (let level = 0; level < lowestLevel; level++) { + const parents = levels[level]; + const children = levels[level + 1]; + + if (children.length < 2) { + // Not enough children to give any parent at least 2 + break; + } + + // Limit parents to those who can have at least 2 children each + const maxParentsWithChildren = Math.floor(children.length / 2); + const parentsWithChildren = parents.slice(0, maxParentsWithChildren); + + if (parentsWithChildren.length === 0) { + break; + } + + // Distribute children evenly, ensuring at least 2 per parent + const childrenPerParent = Math.max(2, Math.ceil(children.length / parentsWithChildren.length)); + let childIdx = 0; + + for (const sourceNum of parentsWithChildren) { + if (!childrenByParent.has(sourceNum)) { + childrenByParent.set(sourceNum, []); + } + + for (let c = 0; c < childrenPerParent && childIdx < children.length; c++) { + const targetNum = children[childIdx++]; + childrenByParent.get(sourceNum)?.push(targetNum); + if (!nodesWithChildren.has(sourceNum)) { + nodesWithChildren.add(sourceNum); + } + + const edge = { + id: `edge-${nodes[sourceNum].id}-${nodes[targetNum].id}`, + type: 'edge', + source: nodes[sourceNum].id, + target: nodes[targetNum].id, + data: { + index: i++, + tag: '250kbs' + } + }; + edges.push(edge); + } + } + } + + // Create groups for leaf nodes + let groupIndex = 1; + childrenByParent.forEach((childIndices) => { + // Filter to only include leaf nodes (nodes with no children) + const leafChildren = childIndices.filter((idx) => !nodesWithChildren.has(idx)); + if (leafChildren.length >= 1) { + const siblingNodes = leafChildren.map((idx) => nodes[idx]); + createGroup(siblingNodes, 'Group', groupIndex++); + } + }); + } else { + const nodesPerGroup = Math.floor((numNodes - 2) / numGroups); + for (let i = 0; i < numGroups; i++) { + createGroup(nodes.slice(i * nodesPerGroup, (i + 1) * nodesPerGroup), 'Group', i + 1); + } + + for (let i = 0; i < numEdges; i++) { + const sourceNum = getRandomNode(numNodes); + const targetNum = getRandomNode(numNodes, sourceNum); + const edge = { + id: `edge-${nodes[sourceNum].id}-${nodes[targetNum].id}`, + type: 'edge', + source: nodes[sourceNum].id, + target: nodes[targetNum].id, + data: { + index: i, + tag: '250kbs' + } + }; + edges.push(edge); + } } nodes.push(...groups); diff --git a/packages/demo-app-ts/src/layouts/defaultLayoutFactory.ts b/packages/demo-app-ts/src/layouts/defaultLayoutFactory.ts index 56fc671b..ed0b8d43 100644 --- a/packages/demo-app-ts/src/layouts/defaultLayoutFactory.ts +++ b/packages/demo-app-ts/src/layouts/defaultLayoutFactory.ts @@ -8,26 +8,41 @@ import { DagreLayout, GridLayout, BreadthFirstLayout, - ColaGroupsLayout + ColaGroupsLayout, + LEFT_TO_RIGHT } from '@patternfly/react-topology'; +export enum LayoutType { + BreadthFirst = 'BreadthFirst', + ColaGroups = 'ColaGroupsLayout', + Cola = 'ColaLayout', + ColaNoForce = 'ColaNoForceLayout', + Concentric = 'ConcentricLayout', + Dagre = 'DagreLayout', + DagreHorizontal = 'DagreHorizontalLayout', + Force = 'ForceLayout', + Grid = 'GridLayout' +} + const defaultLayoutFactory: LayoutFactory = (type: string, graph: Graph): Layout | undefined => { switch (type) { - case 'BreadthFirst': + case LayoutType.BreadthFirst: return new BreadthFirstLayout(graph); - case 'Cola': + case LayoutType.Cola: return new ColaLayout(graph); - case 'ColaNoForce': + case LayoutType.ColaNoForce: return new ColaLayout(graph, { layoutOnDrag: false }); - case 'Concentric': + case LayoutType.Concentric: return new ConcentricLayout(graph); - case 'Dagre': + case LayoutType.Dagre: return new DagreLayout(graph); - case 'Force': + case LayoutType.DagreHorizontal: + return new DagreLayout(graph, { rankdir: LEFT_TO_RIGHT }); + case LayoutType.Force: return new ForceLayout(graph); - case 'Grid': + case LayoutType.Grid: return new GridLayout(graph); - case 'ColaGroups': + case LayoutType.ColaGroups: return new ColaGroupsLayout(graph, { layoutOnDrag: false }); default: return new ColaLayout(graph, { layoutOnDrag: false });