import { GraphNode } from '../../graph/graph-node' ; import { Workflow } from '../../graph/workflow' ; import { SVGUtils } from '../../utils/svg-utils' ; import { GraphChange , SVGPlugin } from '../plugin' ; import { StepModel , WorkflowStepInputModel , WorkflowStepOutputModel } from "cwlts/models" ; export class SVGArrangePlugin implements SVGPlugin { private workflow : Workflow ; private svgRoot : SVGSVGElement ; private onBeforeChange : ( ) => void ; private onAfterChange : ( updates : NodePositionUpdates ) => void ; private triggerAfterRender : ( ) => void ; registerWorkflow ( workflow ) { this . workflow = workflow ; this . svgRoot = workflow . svgRoot ; } registerOnBeforeChange ( fn ) { this . onBeforeChange = ( ) => fn ( { type : "arrange" } ) ; } registerOnAfterChange ( fn ) { this . onAfterChange = ( ) => fn ( { type : "arrange" } ) ; } registerOnAfterRender ( fn ) { this . triggerAfterRender = ( ) => fn ( { type : "arrange" } ) ; } afterRender ( ) { const model = this . workflow . model ; const drawables = [ ] . concat ( model . steps || [ ] , model . inputs || [ ] , model . outputs || [ ] ) as Array < WorkflowStepInputModel | WorkflowStepOutputModel | StepModel > ; for ( let node of drawables ) { if ( node . isVisible ) { const missingCoordinate = isNaN ( parseInt ( node . customProps [ "sbg:x" ] ) ) ; if ( missingCoordinate ) { this . arrange ( ) ; return ; } } } } arrange ( ) { this . onBeforeChange ( ) ; this . workflow . resetTransform ( ) ; const { mainGraph , danglingNodes } = this . makeNodeGraphs ( ) ; const columns = this . distributeNodesIntoColumns ( mainGraph ) ; const { distributionArea , columnDimensions } = this . calculateColumnSizes ( columns ) ; let verticalBaseline = distributionArea . height / 2 ; let xOffset = 0 ; let maxYOffset = 0 ; const nodePositionUpdates = { } as NodePositionUpdates ; columns . forEach ( ( column , index ) => { const colSize = columnDimensions [ index ] ; let yOffset = verticalBaseline - ( colSize . height / 2 ) - column [ 0 ] . rect . height / 2 ; column . forEach ( node => { yOffset += node . rect . height / 2 ; const matrix = SVGUtils . createMatrix ( ) . translate ( xOffset , yOffset ) ; yOffset += node . rect . height / 2 ; if ( yOffset > maxYOffset ) { maxYOffset = yOffset ; } node . el . setAttribute ( "transform" , SVGUtils . matrixToTransformAttr ( matrix ) ) ; nodePositionUpdates [ node . connectionID ] = { x : matrix . e , y : matrix . f } ; } ) ; xOffset += colSize . width ; } ) ; let danglingNodeKeys = Object . keys ( danglingNodes ) . sort ( ( a , b ) => { const aIsInput = a . startsWith ( "out/" ) ; const aIsOutput = a . startsWith ( "in/" ) ; const bIsInput = b . startsWith ( "out/" ) ; const bIsOutput = b . startsWith ( "in/" ) ; const lowerA = a . toLowerCase ( ) ; const lowerB = b . toLowerCase ( ) ; if ( aIsOutput ) { if ( bIsOutput ) { return lowerB . localeCompare ( lowerA ) ; } else { return 1 ; } } else if ( aIsInput ) { if ( bIsOutput ) { return - 1 ; } if ( bIsInput ) { return lowerB . localeCompare ( lowerA ) ; } else { return 1 ; } } else { if ( ! bIsOutput && ! bIsInput ) { return lowerB . localeCompare ( lowerA ) ; } else { return - 1 ; } } } ) ; const danglingNodeMarginOffset = 30 ; const danglingNodeSideLength = GraphNode . radius * 5 ; let maxNodeHeightInRow = 0 ; let row = 0 ; let indexWidthMap = new Map < number , number > ( ) ; let rowMaxHeightMap = new Map < number , number > ( ) ; xOffset = 0 ; let danglingRowAreaWidth = Math . max ( distributionArea . width , danglingNodeSideLength * 3 ) ; danglingNodeKeys . forEach ( ( connectionID , index ) => { const el = danglingNodes [ connectionID ] as SVGGElement ; const rect = el . firstElementChild . getBoundingClientRect ( ) ; indexWidthMap . set ( index , rect . width ) ; if ( xOffset === 0 ) { xOffset -= rect . width / 2 ; } if ( rect . height > maxNodeHeightInRow ) { maxNodeHeightInRow = rect . height ; } xOffset += rect . width + danglingNodeMarginOffset + Math . max ( 150 - rect . width , 0 ) ; if ( xOffset >= danglingRowAreaWidth && index < danglingNodeKeys . length - 1 ) { rowMaxHeightMap . set ( row ++ , maxNodeHeightInRow ) ; maxNodeHeightInRow = 0 ; xOffset = 0 ; } } ) ; rowMaxHeightMap . set ( row , maxNodeHeightInRow ) ; let colYOffset = maxYOffset ; xOffset = 0 ; row = 0 ; danglingNodeKeys . forEach ( ( connectionID , index ) => { const el = danglingNodes [ connectionID ] as SVGGElement ; const width = indexWidthMap . get ( index ) ; const rowHeight = rowMaxHeightMap . get ( row ) ; let left = xOffset + width / 2 ; let top = colYOffset + danglingNodeMarginOffset + Math . ceil ( rowHeight / 2 ) + ( ( xOffset === 0 ? 0 : left ) / danglingRowAreaWidth ) * danglingNodeSideLength ; if ( xOffset === 0 ) { left -= width / 2 ; xOffset -= width / 2 ; } xOffset += width + danglingNodeMarginOffset + Math . max ( 150 - width , 0 ) ; const matrix = SVGUtils . createMatrix ( ) . translate ( left , top ) ; el . setAttribute ( "transform" , SVGUtils . matrixToTransformAttr ( matrix ) ) ; nodePositionUpdates [ connectionID ] = { x : matrix . e , y : matrix . f } ; if ( xOffset >= danglingRowAreaWidth ) { colYOffset += Math . ceil ( rowHeight ) + danglingNodeMarginOffset ; xOffset = 0 ; maxNodeHeightInRow = 0 ; row ++ ; } } ) ; this . workflow . redrawEdges ( ) ; this . workflow . fitToViewport ( ) ; this . onAfterChange ( nodePositionUpdates ) ; this . triggerAfterRender ( ) ; for ( const id in nodePositionUpdates ) { const pos = nodePositionUpdates [ id ] ; const nodeModel = this . workflow . model . findById ( id ) ; if ( ! nodeModel . customProps ) { nodeModel . customProps = { } ; } Object . assign ( nodeModel . customProps , { "sbg:x" : pos . x , "sbg:y" : pos . y } ) ; } return nodePositionUpdates ; } param { NodeIO [ ] [ ] } columns private calculateColumnSizes ( columns ) { const distributionArea = { width : 0 , height : 0 } ; const columnDimensions = [ ] ; for ( let i = 1 ; i < columns . length ; i ++ ) { let width = 0 ; let height = 0 ; for ( let j = 0 ; j < columns [ i ] . length ; j ++ ) { const entry = columns [ i ] [ j ] ; height += entry . rect . height ; if ( width < entry . rect . width ) { width = entry . rect . width ; } } columnDimensions [ i ] = { height , width } ; distributionArea . width += width ; if ( height > distributionArea . height ) { distributionArea . height = height ; } } return { columnDimensions , distributionArea } ; } /**
     * Maps node's connectionID to a 1-indexed column number
     */ private distributeNodesIntoColumns ( graph ) { const idToZoneMap = { } ; const sortedNodeIDs = Object . keys ( graph ) . sort ( ( a , b ) => b . localeCompare ( a ) ) ; const zones = [ ] ; for ( let i = 0 ; i < sortedNodeIDs . length ; i ++ ) { const nodeID = sortedNodeIDs [ i ] ; const node = graph [ nodeID ] ; if ( node . type !== "input" ) { idToZoneMap [ nodeID ] = this . traceLongestNodePathLength ( node , graph ) ; } else { let closestNodeZone = Infinity ; for ( let i = 0 ; i < node . outputs . length ; i ++ ) { const successorNodeZone = idToZoneMap [ node . outputs [ i ] ] ; if ( successorNodeZone < closestNodeZone ) { closestNodeZone = successorNodeZone ; } } if ( closestNodeZone === Infinity ) { idToZoneMap [ nodeID ] = 1 ; } else { idToZoneMap [ nodeID ] = closestNodeZone - 1 ; } } const zone = idToZoneMap [ nodeID ] ; zones [ zone ] || ( zones [ zone ] = [ ] ) ; zones [ zone ] . push ( graph [ nodeID ] ) ; } return zones ; } /**
     * Finds all nodes in the graph, and indexes them by their "data-connection-id" attribute
     */ private indexNodesByID ( ) { const indexed = { } ; const nodes = this . svgRoot . querySelectorAll ( ".node" ) ; for ( let i = 0 ; i < nodes . length ; i ++ ) { indexed [ nodes [ i ] . getAttribute ( "data-connection-id" ) ] = nodes [ i ] ; } return indexed ; } /**
     * Finds length of the longest possible path from the graph root to a node.
     * Lengths are 1-indexed. When a node has no predecessors, it will have length of 1.
     */ private traceLongestNodePathLength ( node , nodeGraph , visited = new Set < NodeIO > ( ) ) { visited . add ( node ) ; if ( node . inputs . length === 0 ) { return 1 ; } const inputPathLengths = [ ] ; for ( let i = 0 ; i < node . inputs . length ; i ++ ) { const el = nodeGraph [ node . inputs [ i ] ] ; if ( visited . has ( el ) ) { continue ; } inputPathLengths . push ( this . traceLongestNodePathLength ( el , nodeGraph , visited ) ) ; } return Math . max ( ... inputPathLengths ) + 1 ; } private makeNodeGraphs ( ) { const allNodes = this . indexNodesByID ( ) ; const nodeGraph = { } as NodeMap ; const edges = this . svgRoot . querySelectorAll ( ".edge" ) ; for ( let i = 0 ; i < edges . length ; i ++ ) { const edge = edges [ i ] ; const sourceConnectionID = edge . getAttribute ( "data-source-connection" ) ; const destinationConnectionID = edge . getAttribute ( "data-destination-connection" ) ; const [ sourceSide , sourceNodeID , sourcePortID ] = sourceConnectionID . split ( "/" ) ; const [ destinationSide , destinationNodeID , destinationPortID ] = destinationConnectionID . split ( "/" ) ; let sourceType = "step" ; let destinationType = "step" ; if ( sourceNodeID === sourcePortID ) { sourceType = sourceSide === "in" ? "output" : "input" ; } if ( destinationNodeID === destinationPortID ) { destinationType = destinationSide === "in" ? "output" : "input" ; } const sourceNode = this . svgRoot . querySelector ( `.node[data-id="${ sourceNodeID }"]` ) as SVGGElement ; const destinationNode = this . svgRoot . querySelector ( `.node[data-id="${ destinationNodeID }"]` ) as SVGGElement ; const sourceNodeConnectionID = sourceNode . getAttribute ( "data-connection-id" ) ; const destinationNodeConnectionID = destinationNode . getAttribute ( "data-connection-id" ) ; delete allNodes [ sourceNodeConnectionID ] ; delete allNodes [ destinationNodeConnectionID ] ; ( nodeGraph [ sourceNodeID ] || ( nodeGraph [ sourceNodeID ] = { inputs : [ ] , outputs : [ ] , type : sourceType , connectionID : sourceNodeConnectionID , el : sourceNode , rect : sourceNode . getBoundingClientRect ( ) } ) ) ; ( nodeGraph [ destinationNodeID ] || ( nodeGraph [ destinationNodeID ] = { inputs : [ ] , outputs : [ ] , type : destinationType , connectionID : destinationNodeConnectionID , el : destinationNode , rect : destinationNode . getBoundingClientRect ( ) } ) ) ; nodeGraph [ sourceNodeID ] . outputs . push ( destinationNodeID ) ; nodeGraph [ destinationNodeID ] . inputs . push ( sourceNodeID ) ; } return { mainGraph : nodeGraph , danglingNodes : allNodes } ; } } export type NodeIO = { inputs : string [ ] ; outputs : string [ ] ; connectionID : string ; el : SVGGElement ; rect : ClientRect ; type : "step" | "input" | "output" | string ; } ; export type NodeMap = { [ connectionID ] : NodeIO ; } ; export type NodePositionUpdates = { [ connectionID ] : { x : number ; y : number ; } ; } ;