import * as cola from 'webcola' ; import { DiagramModel } from '../diagram/model' ; import { boundsOf , Vector , computeGrouping , Size } from '../diagram/geometry' ; import { Element } from '../diagram/elements' ; import { EventObserver } from './events' ; import { getContentFittingBox } from '../diagram/paperArea' ; export interface LayoutNode { id ? : string ; x : number ; y : number ; width : number ; height : number ; bounds ? : any ; fixed ? : number ; innerBounds ? : any ; } export interface LayoutLink { source : LayoutNode ; target : LayoutNode ; } export function groupForceLayout ( params ) { const layout = new cola . Layout ( ) . nodes ( params . nodes ) . links ( params . links ) . avoidOverlaps ( params . avoidOvelaps ) . convergenceThreshold ( 1e-9 ) . jaccardLinkLengths ( params . preferredLinkLength ) . handleDisconnected ( true ) ; layout . start ( 30 , 0 , 10 , undefined , false ) ; } export function groupRemoveOverlaps ( nodes ) { const nodeRectangles = [ ] ; for ( const node of nodes ) { nodeRectangles . push ( new cola . Rectangle ( node . x , node . x + node . width , node . y , node . y + node . height ) ) ; } cola . removeOverlaps ( nodeRectangles ) ; for ( let i = 0 ; i < nodeRectangles . length ; i ++ ) { const node = nodes [ i ] ; const rectangle = nodeRectangles [ i ] ; node . x = rectangle . x ; node . y = rectangle . y ; } } export function translateToPositiveQuadrant ( positions , offset ) { let minX = Infinity , minY = Infinity ; positions . forEach ( ( position ) => { minX = Math . min ( minX , position . x ) ; minY = Math . min ( minY , position . y ) ; } ) ; const { x , y } = offset ; positions . forEach ( ( position , key ) => { positions . set ( key , { x : position . x - minX + x , y : position . y - minY + y , } ) ; } ) ; } export function uniformGrid ( params ) { return ( cellIndex ) => { const row = Math . floor ( cellIndex / params . rows ) ; const column = cellIndex - row * params . rows ; return { x : column * params . cellSize . x , y : row * params . cellSize . y , width : params . cellSize . x , height : params . cellSize . y , } ; } ; } export function padded ( nodes , padding , transform ) { if ( padding ) { for ( const node of nodes ) { node . x -= padding . x ; node . y -= padding . y ; node . width += 2 * padding . x ; node . height += 2 * padding . y ; } } transform ( ) ; if ( padding ) { for ( const node of nodes ) { node . x += padding . x ; node . y += padding . y ; node . width -= 2 * padding . x ; node . height -= 2 * padding . y ; } } } export function biasFreePadded ( nodes , padding , transform ) { const nodeSizeMap = new Map < string , Size > ( ) ; const possibleCompression = { x : Infinity , y : Infinity } ; for ( const node of nodes ) { nodeSizeMap . set ( node . id , { width : node . width , height : node . height } ) ; const maxSide = Math . max ( node . width , node . height ) ; const compressionX = node . width ? maxSide / node . width : 1 ; const compressionY = node . height ? maxSide / node . height : 1 ; possibleCompression . x = Math . min ( 1 + ( compressionX - 1 ) , possibleCompression . x ) ; possibleCompression . y = Math . min ( 1 + ( compressionY - 1 ) , possibleCompression . y ) ; node . height = maxSide ; node . width = maxSide ; } padded ( nodes , padding , ( ) => transform ( ) ) ; const fittingBox = getContentFittingBoxForLayout ( nodes ) ; for ( const node of nodes ) { const size = nodeSizeMap . get ( node . id ) ; node . x = ( node . x - fittingBox . x ) / possibleCompression . x + fittingBox . x ; node . y = ( node . y - fittingBox . y ) / possibleCompression . y + fittingBox . y ; node . height = size . height ; node . width = size . width ; } } export type CalculatedLayout = object & { readonly layoutBrand : void ; } ; export interface UnzippedCalculatedLayout extends CalculatedLayout { group ? : string ; keepAveragePosition : boolean ; positions : Map < string , Vector > ; nestedLayouts : UnzippedCalculatedLayout [ ] ; } export function calculateLayout ( params ) { const grouping = computeGrouping ( params . model . elements ) ; const { layoutFunction , model , fixedElements , selectedElements } = params ; if ( selectedElements && selectedElements . size <= 1 ) { return { positions : new Map ( ) , nestedLayouts : [ ] , keepAveragePosition : false , } as UnzippedCalculatedLayout ; } return internalRecursion ( params . group ) ; function internalRecursion ( group ) { const elementsToProcess = group ? grouping . get ( group ) : model . elements . filter ( ( el ) => el . group === undefined ) ; const elements = selectedElements ? elementsToProcess . filter ( ( el ) => selectedElements . has ( el ) ) : elementsToProcess ; const nestedLayouts = [ ] ; for ( const element of elements ) { if ( grouping . has ( element . id ) ) { nestedLayouts . push ( internalRecursion ( element . id ) ) ; } } const nodes = [ ] ; const nodeById = { } ; for ( const element of elements ) { const { x , y , width , height } = boundsOf ( element ) ; const node = { id : element . id , x , y , width , height , fixed : fixedElements && fixedElements . has ( element ) ? 1 : 0 , } ; nodeById [ element . id ] = node ; nodes . push ( node ) ; } const links = [ ] ; for ( const link of model . links ) { if ( ! model . isSourceAndTargetVisible ( link ) ) { continue ; } const source = model . sourceOf ( link ) ; const target = model . targetOf ( link ) ; const sourceNode = nodeById [ source . id ] ; const targetNode = nodeById [ target . id ] ; if ( sourceNode && targetNode ) { links . push ( { source : sourceNode , target : targetNode } ) ; } } layoutFunction ( nodes , links , group ) ; const positions = new Map ( ) ; for ( const node of nodes ) { positions . set ( node . id , { x : node . x , y : node . y } ) ; } return { positions , group , nestedLayouts , keepAveragePosition : Boolean ( selectedElements ) , } as UnzippedCalculatedLayout ; } } export function applyLayout ( model , layout ) { const { positions , group , nestedLayouts , keepAveragePosition , } = layout as UnzippedCalculatedLayout ; const elements = model . elements . filter ( ( { id } ) => positions . has ( id ) ) ; for ( const nestedLayout of nestedLayouts ) { applyLayout ( model , nestedLayout ) ; } if ( group ) { const offset = getContentFittingBox ( elements , [ ] ) ; translateToPositiveQuadrant ( positions , offset ) ; } const averagePosition = keepAveragePosition ? calculateAveragePosition ( elements ) : undefined ; for ( const element of elements ) { element . setPosition ( positions . get ( element . id ) ) ; } if ( keepAveragePosition ) { const newAveragePosition = calculateAveragePosition ( elements ) ; const averageDiff = { x : averagePosition . x - newAveragePosition . x , y : averagePosition . y - newAveragePosition . y , } ; positions . forEach ( ( position , elementId ) => { const element = model . getElement ( elementId ) ; element . setPosition ( { x : position . x + averageDiff . x , y : position . y + averageDiff . y , } ) ; } ) ; } } export function calculateAveragePosition ( position ) { let xSum = 0 ; let ySum = 0 ; for ( const element of position ) { xSum += element . position . x + element . size . width / 2 ; ySum += element . position . y + element . size . height / 2 ; } return { x : xSum / position . length , y : ySum / position . length , } ; } export function placeElementsAround ( params ) { const { model , elements , targetElement , prefferedLinksLength } = params ; const targetElementBounds = boundsOf ( targetElement ) ; const targetPosition = { x : targetElementBounds . x + targetElementBounds . width / 2 , y : targetElementBounds . y + targetElementBounds . height / 2 , } ; let outgoingAngle = 0 ; if ( targetElement . links . length > 0 ) { const averageSourcePosition = calculateAveragePosition ( targetElement . links . map ( ( link ) => { const linkSource = model . sourceOf ( link ) ; return linkSource !== targetElement ? linkSource : model . targetOf ( link ) ; } ) ) ; const vectorDiff = { x : targetPosition . x - averageSourcePosition . x , y : targetPosition . y - averageSourcePosition . y , } ; if ( vectorDiff . x !== 0 || vectorDiff . y !== 0 ) { outgoingAngle = Math . atan2 ( vectorDiff . y , vectorDiff . x ) ; } } const step = Math . min ( Math . PI / elements . length , Math . PI / 6 ) ; const elementsSteck = [ ] . concat ( elements ) ; const placeElementFromSteck = ( curAngle , element ) => { if ( element ) { const size = element . size ; element . setPosition ( { x : targetPosition . x + prefferedLinksLength * Math . cos ( curAngle ) - size . width / 2 , y : targetPosition . y + prefferedLinksLength * Math . sin ( curAngle ) - size . height / 2 , } ) ; } } ; const isOddLength = elementsSteck . length % 2 === 0 ; if ( isOddLength ) { for ( let angle = step / 2 ; elementsSteck . length > 0 ; angle += step ) { placeElementFromSteck ( outgoingAngle - angle , elementsSteck . pop ( ) ) ; placeElementFromSteck ( outgoingAngle + angle , elementsSteck . pop ( ) ) ; } } else { placeElementFromSteck ( outgoingAngle , elementsSteck . pop ( ) ) ; for ( let angle = step ; elementsSteck . length > 0 ; angle += step ) { placeElementFromSteck ( outgoingAngle - angle , elementsSteck . pop ( ) ) ; placeElementFromSteck ( outgoingAngle + angle , elementsSteck . pop ( ) ) ; } } return new Promise ( ( resolve ) => { const listener = new EventObserver ( ) ; listener . listen ( model . events , 'changeCells' , ( ) => { listener . stopListening ( ) ; removeOverlaps ( { model , padding : { x : 15 , y : 15 } , } ) ; resolve ( ) ; } ) ; } ) ; } export function removeOverlaps ( params ) { const { padding , model , group , fixedElements , selectedElements } = params ; return calculateLayout ( { model , group , fixedElements , selectedElements , layoutFunction : ( nodes ) => { padded ( nodes , padding , ( ) => groupRemoveOverlaps ( nodes ) ) ; } , } ) ; } export function forceLayout ( params ) { const { model , group , fixedElements , selectedElements } = params ; return calculateLayout ( { model , group , fixedElements , selectedElements , layoutFunction : ( nodes , links ) => { if ( fixedElements && fixedElements . size > 0 ) { biasFreePadded ( nodes , { x : 50 , y : 50 } , ( ) => groupForceLayout ( { nodes , links , preferredLinkLength : 200 , avoidOvelaps : true , } ) ) ; } else { groupForceLayout ( { nodes , links , preferredLinkLength : 200 } ) ; biasFreePadded ( nodes , { x : 50 , y : 50 } , ( ) => groupRemoveOverlaps ( nodes ) ) ; } } , } ) ; } export function getContentFittingBoxForLayout ( nodes ) { let minX = Infinity , minY = Infinity ; let maxX = - Infinity , maxY = - Infinity ; for ( const node of nodes ) { const { x , y , width , height } = node ; minX = Math . min ( minX , x ) ; minY = Math . min ( minY , y ) ; maxX = Math . max ( maxX , x + width ) ; maxY = Math . max ( maxY , y + height ) ; } return { x : Number . isFinite ( minX ) ? minX : 0 , y : Number . isFinite ( minY ) ? minY : 0 , width : Number . isFinite ( minX ) && Number . isFinite ( maxX ) ? maxX - minX : 0 , height : Number . isFinite ( minY ) && Number . isFinite ( maxY ) ? maxY - minY : 0 , } ; }