import { sum } from 'd3' ; import { SankeyLink , SankeyLinkPlot } from './interfaces/link.interface' ; import { SankeyNodeLink } from './interfaces/node-link.interface' ; import { SankeyNode } from './interfaces/node.interface' ; export class SankeyChart < T > { /** Define the nodes in the chart */ private _nodes : ReadonlyArray < SankeyNode < T > > = [ ] ; /** Define the links in the chart */ private _links : ReadonlyArray < SankeyLink > = [ ] ; /** Store the node-links */ private _nodeLinks : ReadonlyArray < SankeyNodeLink < T > > = [ ] ; /** Define the minimum width of the nodes */ private _minWidth : number = 0 ; /** Define the maximum width of the nodes */ private _maxWidth : number = Infinity ; /** The minimum height of a node. */ private _minHeight : number ; /** Define the width of the chart */ private _width : number ; /** Define the height of the chart */ private _height : number ; /** Define the vertical padding between nodes */ private _spacing : number ; /** Define the minimum distance from the edge of the chart */ private _padding : number = 24 ; /** Define the spacing of the chart */ spacing ( spacing ) { this . _spacing = spacing ; return this ; } /** Define the width of the chart */ width ( width ) { this . _width = width ; return this ; } /** Define the height of the chart */ height ( height ) { this . _height = height ; return this ; } /** Define the nodes */ nodes ( nodes ) { this . _nodes = nodes ; return this ; } /** Define the links */ links ( links ) { this . _links = links ; return this ; } /** Define the minimum and maximum size of the nodes */ size ( minWidth , maxWidth , minHeight ) { this . _minWidth = minWidth ; this . _maxWidth = maxWidth ; this . _minHeight = minHeight ; return this ; } /** Get the sizes of each column */ columns ( ) { const columnCount = this . getColumnCount ( ) ; const padding = this . getColumnPadding ( ) ; const columnWidths = [ ] ; for ( let idx = 0 ; idx < columnCount ; idx ++ ) { columnWidths [ idx ] = this . getNodeWidth ( ) + ( padding * 2 ) ; if ( idx === 0 || idx === columnCount - 1 ) { columnWidths [ idx ] -= ( padding - this . _padding ) ; } } return columnWidths ; } /**
     * Perform the various stages of the layout
     * in the correct order as some steps are dependant
     * on the previous layout stages.
     */ layout ( ) { this . getNodeLinks ( ) ; this . getNodeValues ( ) ; this . getNodeColumns ( ) ; this . getNodeWidths ( ) ; this . getNodeHeights ( ) ; this . getNodePositions ( ) ; this . getLinkPlots ( ) ; return this . _nodeLinks ; } /** The curve equation for links */ link ( link ) { const { topLeft , topRight , bottomLeft , bottomRight } = link ; const dist = ( topRight [ 0 ] - topLeft [ 0 ] ) / 2 ; const topLeftCurve = [ topLeft [ 0 ] + dist , topLeft [ 1 ] ] ; const topRightCurve = [ topRight [ 0 ] - dist , topRight [ 1 ] ] ; const bottomLeftCurve = [ bottomLeft [ 0 ] + dist , bottomLeft [ 1 ] ] ; const bottomRightCurve = [ bottomRight [ 0 ] - dist , bottomRight [ 1 ] ] ; return 'M' + topLeft [ 0 ] + ',' + topLeft [ 1 ] + 'C' + topLeftCurve [ 0 ] + ',' + topLeftCurve [ 1 ] + ' ' + topRightCurve [ 0 ] + ',' + topRightCurve [ 1 ] + ' ' + topRight [ 0 ] + ',' + topRight [ 1 ] + 'L' + bottomRight [ 0 ] + ',' + bottomRight [ 1 ] + 'C' + bottomRightCurve [ 0 ] + ',' + bottomRightCurve [ 1 ] + ' ' + bottomLeftCurve [ 0 ] + ',' + bottomLeftCurve [ 1 ] + ' ' + bottomLeft [ 0 ] + ',' + bottomLeft [ 1 ] + 'L' + topLeft [ 0 ] + ',' + topLeft [ 1 ] ; } getFalloffPath ( nodeLink ) { const x = ( nodeLink . x + nodeLink . width ) ; const y = nodeLink . outputs . reduce ( ( bottom , output ) => Math . max ( bottom , output . bottomLeft [ 1 ] ) , 0 ) ; const width = 20 ; const radius = 6 ; const height = ( nodeLink . y + nodeLink . height - y ) + ( this . _spacing / 2 ) ; return 'M' + x + ',' + y + 'h ' + ( width - radius ) + 'a' + radius + ',' + radius + ' 0 0,1' + ( radius ) + ',' + ( radius ) + ' ' + 'v' + Math . max ( radius , height ) + 'h-' + width + 'Z' ; } /**
     * Get a `SankeyNodeLink` object from the id of a node
     */ getNodeLink ( id ) { return this . _nodeLinks . find ( nodeLink => nodeLink . node . id === id ) ; } /** Replace the node ids with actual references */ private getNodeLinks ( ) { this . _nodeLinks = this . _nodes . map ( node => { const inputs = this . _links . filter ( link => link . target === node . id ) ; const outputs = this . _links . filter ( link => link . source === node . id ) ; return { node , inputs , outputs , value : 0 , column : 0 , x : 0 , y : 0 , width : 0 , height : 0 , naturalHeight : 0 , falloff : 0 , active : false , focus : false } as SankeyNodeLink < T > ; } ) ; } /** Get the value for the node based on all its inputs and outputs */ private getNodeValues ( ) { for ( const node of this . _nodeLinks ) { node . value = Math . max ( sum ( node . inputs , input => input . value ) , sum ( node . outputs , output => output . value ) ) ; } } /**
     * We need to determine which column the node should
     * be placed in. This is determined by taking the input
     * and adding one.
     */ private getNodeColumns ( nodeLinks = this . _nodeLinks . filter ( node => node . inputs . length === 0 ) , column = 0 ) { for ( const nodeLink of nodeLinks ) { nodeLink . column = column ; this . getNodeColumns ( nodeLink . outputs . map ( output => this . getNodeLink ( output . target ) ) , column + 1 ) ; } } /** Get the width of each node */ private getNodeWidths ( ) { this . _nodeLinks . forEach ( node => node . width = this . getNodeWidth ( ) ) ; } /**
     * Scale the nodes height based on the value the represent
     */ private getNodeHeights ( ) { const groups = this . getColumnGroups ( ) ; const groupList = Object . keys ( groups ) . map < SankeyNodeLink < T > [ ] > ( group => groups [ group ] ) ; const total = groupList . reduce ( ( count , nodes ) => Math . max ( count , nodes . reduce ( ( accumulation , node ) => accumulation + node . value , 0 ) ) , 0 ) ; for ( const nodeLinks of groupList ) { for ( const nodeLink of nodeLinks ) { nodeLink . naturalHeight = ( ( nodeLink . value / total ) * this . _height ) - this . _spacing ; nodeLink . height = Math . max ( nodeLink . naturalHeight , this . _minHeight ) ; } } if ( this . _minHeight > 0 ) { try { this . adjustNodeHeightsToFit ( groupList ) ; } catch ( error ) { this . setNodesToNaturalHeight ( groupList ) ; } } } throws private adjustNodeHeightsToFit ( groupList ) { let largestColumn = this . getLargestColumn ( groupList ) ; while ( largestColumn . height > this . _height ) { const fixedNodes = largestColumn . nodes . filter ( nodeLink => nodeLink . height <= this . _minHeight ) ; const fixedHeight = fixedNodes . length * this . _minHeight + largestColumn . nodes . length * this . _spacing ; if ( fixedHeight > this . _height ) { throw new Error ( `Cannot fit data into chart with minHeight = ${ this . _minHeight }px (need ${ fixedHeight }px; ${ this . _height }px available)` ) ; } const flexibleHeight = largestColumn . height - fixedHeight ; const availableHeight = this . _height - fixedHeight ; const ratio = availableHeight / flexibleHeight ; for ( const group of groupList ) { for ( const nodeLink of group ) { if ( nodeLink . height > this . _minHeight ) { nodeLink . height *= ratio ; } if ( nodeLink . height < this . _minHeight ) { nodeLink . height = this . _minHeight ; } } } largestColumn = this . getLargestColumn ( groupList ) ; } } /** Set all nodes height to be the same as the naturalHeight. */ private setNodesToNaturalHeight ( groupList ) { for ( const group of groupList ) { for ( const nodeLink of group ) { nodeLink . height = nodeLink . naturalHeight ; } } } /**
     * Get all nodes grouped in their corresponding columns
     */ private getColumnGroups ( ) { return this . _nodeLinks . reduce < { [ key ] : SankeyNodeLink < T > [ ] ; } > ( ( collection , nodeLink ) => { collection [ nodeLink . column ] = collection [ nodeLink . column ] || [ ] ; collection [ nodeLink . column ] . push ( nodeLink ) ; return collection ; } , { } ) ; } /**
     * Get the number of columns
     */ private getColumnCount ( ) { return this . _nodeLinks . reduce ( ( column , nodeLink ) => Math . max ( nodeLink . column + 1 , column ) , 0 ) ; } /**
     * Position the nodes in their corresponding x and y positions
     */ private getNodePositions ( ) { const groups = this . getColumnGroups ( ) ; const padding = this . getColumnPadding ( ) ; for ( const nodeLink of this . _nodeLinks ) { nodeLink . x = this . getColumnPosition ( nodeLink . column ) + padding ; if ( nodeLink . column === 0 ) { nodeLink . x = this . _padding ; } nodeLink . y = groups [ nodeLink . column ] . slice ( 0 , groups [ nodeLink . column ] . indexOf ( nodeLink ) ) . reduce ( ( top , _node ) => top + _node . height , 0 ) + ( this . _spacing * groups [ nodeLink . column ] . indexOf ( nodeLink ) ) ; } } private getColumnPadding ( ) { const columnCount = this . getColumnCount ( ) ; const width = ( this . _width - ( columnCount * this . getNodeWidth ( ) ) ) - ( this . _padding * 2 ) ; const paddingCount = Math . max ( ( ( columnCount * 2 ) - 2 ) , 0 ) ; return width / paddingCount ; } private getLinkPlots ( ) { for ( const nodeLink of this . _nodeLinks ) { let inputY = nodeLink . y ; for ( const link of nodeLink . inputs ) { link . topRight = [ nodeLink . x , inputY ] ; inputY += ( ( link . value / nodeLink . value ) * nodeLink . height ) ; link . bottomRight = [ nodeLink . x , inputY ] ; } let outputValue = 0 ; let outputY = nodeLink . y ; for ( const link of nodeLink . outputs ) { link . topLeft = [ nodeLink . x + nodeLink . width , outputY ] ; outputY += ( ( link . value / nodeLink . value ) * nodeLink . height ) ; link . bottomLeft = [ nodeLink . x + nodeLink . width , outputY ] ; outputValue += link . value ; } nodeLink . falloff = nodeLink . value - outputValue ; } } /** Determine the position at which a column starts */ private getColumnPosition ( column ) { return this . columns ( ) . splice ( 0 , column ) . reduce ( ( total , width ) => total + width , 0 ) ; } /** Get the pixel width of a node */ private getNodeWidth ( ) { const width = ( this . _width - ( this . _padding * 2 ) ) / ( ( this . getColumnCount ( ) * 2 ) - 1 ) ; return Math . min ( this . _maxWidth , Math . max ( this . _minWidth , width ) ) ; } /** Get the column with the greatest height (along with its height) */ private getLargestColumn ( groupList ) { let largestColumn = null ; let largestColumnHeight = 0 ; for ( const group of groupList ) { const totalHeight = group . reduce ( ( acc , node ) => acc += node . height , 0 ) + group . length * this . _spacing ; if ( totalHeight > largestColumnHeight ) { largestColumnHeight = totalHeight ; largestColumn = group ; } } return { nodes : largestColumn , height : largestColumnHeight } ; } } interface ColumnWithHeight < T > { nodes : SankeyNodeLink < T > [ ] ; height : number ; }