import { animate , style , transition , trigger } from '@angular/animations' ; import { AfterViewInit , ChangeDetectionStrategy , ChangeDetectorRef , Component , ContentChild , ElementRef , Input , OnChanges , TemplateRef , ViewChild } from '@angular/core' ; import { ResizeDimensions } from '../../directives/resize/index' ; import { ColorService , ThemeColor } from '../../services/color/index' ; import { SankeyLink , SankeyLinkInteraction , SankeyLinkPlot } from './interfaces/link.interface' ; import { SankeyNodeLink } from './interfaces/node-link.interface' ; import { SankeyNode } from './interfaces/node.interface' ; import { SankeyChart } from './sankey-chart' ; import { SankeyFocusManager } from './sankey-focus-manager' ; @ Component ( { selector : 'ux-sankey-chart' , templateUrl : './sankey-chart.component.html' , changeDetection : ChangeDetectionStrategy . OnPush , viewProviders : [ SankeyFocusManager ] , animations : [ trigger ( 'tooltipAnimation' , [ transition ( ':enter' , [ style ( { opacity : 0 } ) , animate ( 160 , style ( { opacity : 1 } ) ) ] ) , transition ( ':leave' , [ animate ( 160 , style ( { opacity : 0 } ) ) ] ) ] ) ] , } ) export class SankeyChartComponent < T > implements OnChanges , AfterViewInit { /** Define the nodes to display */ @ Input ( ) nodes : ReadonlyArray < SankeyNode < T > > = [ ] ; /** Define the links to display */ @ Input ( ) links : ReadonlyArray < SankeyLink > = [ ] ; /** Define the headers of each column */ @ Input ( ) columns : string [ ] = [ ] ; /** Define the minimum width of a node */ @ Input ( ) minWidth : number = 0 ; /** Define the maximum width of a node */ @ Input ( ) maxWidth : number = Infinity ; /** The minimum height of a node. */ @ Input ( ) minHeight : number = 0 ; /** Define the function to get the contents of a link tooltip */ @ Input ( ) linkTooltip : ( link : SankeyLink ) => string = this . getLinkTooltip ; /** Define the function to get the contents of a falloff tooltip */ @ Input ( ) falloffTooltip : ( falloff : number ) => string = this . getFalloffTooltip ; /** Define the active color of a node */ @ Input ( ) color : string | ThemeColor ; /** Define the template of sankey chart nodes */ @ ContentChild ( 'sankeyNodeTemplate' , { static : false } ) nodeTemplate : TemplateRef < SankeyNodeContext < T > > ; /** Access the SVG element which will contain the links */ @ ViewChild ( 'linkContainer' , { static : true } ) linkContainer : ElementRef ; /** Access the element which will contain the nodes */ @ ViewChild ( 'nodeContainer' , { static : true } ) nodeContainer : ElementRef ; /** Store the width of the chart area */ _width : number ; /** Store the height of the chart area */ _height : number ; /** Define the nodes that should be rendered */ _nodes : ReadonlyArray < SankeyNodeLink < T > > = [ ] ; /** Define the columns to display */ _columns : ReadonlyArray < SankeyColumn > = [ ] ; /** Determine if the tooltip should be visible or not */ _isTooltipOpen : boolean = false ; /** Define the position of the tooltip */ _tooltipPosition : SankeyTooltipPosition = { x : 0 , y : 0 } ; /** Define the content to display in the tooltip */ _tooltipContent : string ; /** Determine if the component is initialised */ private _isInitialised : boolean = false ; /** Store the instance of the sankey layout */ private _sankey = new SankeyChart < T > ( ) ; constructor ( private _focusManager , private _changeDetector , private _colorService ) { } ngAfterViewInit ( ) { if ( ! this . nodeTemplate ) { throw new Error ( 'Sankey Chart - Node Template has not been defined.' ) ; } this . _width = this . nodeContainer . nativeElement . offsetWidth ; this . _height = this . nodeContainer . nativeElement . offsetHeight ; this . _render ( ) ; this . _isInitialised = true ; } /**
     * Detect any changes from Inputs. We can skip
     * the first function call as this happens before
     * the initial render so it has no effect.
     */ ngOnChanges ( ) { if ( this . _isInitialised ) { this . _render ( ) ; } } /** Re-render the chart */ _render ( ) { this . _nodes = this . _sankey . nodes ( this . nodes ) . links ( this . links ) . spacing ( 14 ) . size ( this . minWidth , this . maxWidth , this . minHeight ) . width ( this . _width || this . nodeContainer . nativeElement . offsetWidth ) . height ( this . _height || this . nodeContainer . nativeElement . offsetHeight ) . layout ( ) ; this . _focusManager . setNodes ( this . _nodes ) ; this . _columns = this . getColumns ( ) ; this . _changeDetector . detectChanges ( ) ; } /** Update the layout whenever the dimensions change changes */ _onResize ( dimensions ) { this . _width = dimensions . width ; this . _height = dimensions . height ; this . _render ( ) ; } /**
     * Column count should be based on the data, not the titles
     * as they may not specify titles but the nodes will still be
     * rendered.
     */ _getColumnCount ( ) { return this . _nodes . reduce ( ( column , node ) => Math . max ( column , node . column ) , 0 ) ; } /**
     * Get the SVG path that defines the shape of the link
     */ _getPath ( link ) { return this . _sankey . link ( link ) ; } /**
     * Set the active state of a node and the inputs and outputs
     * associated with this node.
     */ _setNodeActive ( nodeLink , active ) { nodeLink . active = active ; nodeLink . inputs . forEach ( link => link . active = active ) ; nodeLink . outputs . forEach ( link => link . active = active ) ; nodeLink . inputs . map ( link => this . _sankey . getNodeLink ( link . source ) ) . forEach ( _node => _node . active = active ) ; nodeLink . inputs . map ( link => this . _sankey . getNodeLink ( link . target ) ) . forEach ( _node => _node . active = active ) ; nodeLink . outputs . map ( link => this . _sankey . getNodeLink ( link . source ) ) . forEach ( _node => _node . active = active ) ; nodeLink . outputs . map ( link => this . _sankey . getNodeLink ( link . target ) ) . forEach ( _node => _node . active = active ) ; this . _changeDetector . detectChanges ( ) ; } /**
     * Set the focused state of a node and the inputs and outputs
     * associated with this node.
     */ _setNodeFocus ( nodeLink , focused , element ) { nodeLink . focus = focused ; nodeLink . inputs . forEach ( link => link . focus = focused ) ; nodeLink . outputs . forEach ( link => link . focus = focused ) ; nodeLink . inputs . map ( link => this . _sankey . getNodeLink ( link . source ) ) . forEach ( _node => _node . focus = focused ) ; nodeLink . inputs . map ( link => this . _sankey . getNodeLink ( link . target ) ) . forEach ( _node => _node . focus = focused ) ; nodeLink . outputs . map ( link => this . _sankey . getNodeLink ( link . source ) ) . forEach ( _node => _node . focus = focused ) ; nodeLink . outputs . map ( link => this . _sankey . getNodeLink ( link . target ) ) . forEach ( _node => _node . focus = focused ) ; if ( this . color ) { element . style . setProperty ( 'box-shadow' , this . _getFocusIndicator ( nodeLink ) , 'important' ) ; } this . _changeDetector . detectChanges ( ) ; } /**
     * Set the active state of a link and the source and target
     * nodes associated with the link
     */ _setLinkActive ( link , active ) { link . active = active ; if ( link . source !== undefined ) { this . _sankey . getNodeLink ( link . source ) . active = active ; } if ( link . target !== undefined ) { this . _sankey . getNodeLink ( link . target ) . active = active ; } this . _isTooltipOpen = active ; this . _tooltipContent = active ? this . linkTooltip ( link ) : '' ; this . _changeDetector . detectChanges ( ) ; } /**
     * This is required because we want to toggle a class based on the `active`
     * property on a link, however toggling classes using `NgClass` or the class
     * binding syntax `[class.xyz]` does not work in IE when applied to an SVG
     * element. (https://github.com/angular/angular/issues/6327)
     *
     * The alternatice is to bind directly to the `class` attribute and return a
     * string that will toggle the class based on the `active` property.
     */ _getLinkClass ( link ) { return `ux-sankey-chart-link ${ ( link . active || link . focus ) ? 'ux-sankey-chart-link-active' : '' }` ; } /**
     * Get the SVG path that defines the shape of the falloff indicator
     */ _getFalloffPath ( node ) { return this . _sankey . getFalloffPath ( node ) ; } /**
     * Falloff represents the amount of data that does not get passed on,
     * for example, if a node gets 1,000,000 items from inputs and only outputs
     * 500,000 then there is falloff of 500,000. However, items in the last column
     * never pass on any information, so tecnhically 100% of their input is falloff
     * so we shouldn't show it in the last column.
     */ _showFalloff ( nodeLink ) { return nodeLink . column < this . _columns . length - 1 ; } /** Update the visibility and content of the tooltip on falloff hover */ _setFalloffTooltip ( nodeLink , isVisible ) { this . _isTooltipOpen = isVisible ; this . _tooltipContent = isVisible ? this . falloffTooltip ( nodeLink . falloff ) : '' ; this . _changeDetector . detectChanges ( ) ; } /**
     * Update the position of the tooltip
     */ _setTooltipPosition ( event ) { const { left , top } = ( this . nodeContainer . nativeElement as HTMLElement ) . getBoundingClientRect ( ) ; const x = ( event . pageX - left ) - ( window . scrollX || document . documentElement . scrollLeft ) ; const y = ( event . pageY - top ) - ( window . scrollY || document . documentElement . scrollTop ) ; this . _tooltipPosition = { x , y } ; this . _changeDetector . detectChanges ( ) ; } /**
     * Correctly track the node changes in `*ngFor` based on
     * the unique node ids to prevent unnecessary re-rendering
     */ _trackNodeBy ( _index , nodeLink ) { return nodeLink . node . id ; } /**
     * Correctly track the link changes in `*ngFor` based on
     * the source and target to prevent unnecessary re-rendering
     */ _trackLinkBy ( _index , link ) { return `${ link . source }-${ link . target }` ; } /**
     * Get the color of node based on whether or not
     * the `color` input has been provided.
     */ _getColor ( item ) { if ( ! item . active && ! item . focus || ! this . color ) { return ; } return this . color instanceof ThemeColor ? this . color . toRgba ( ) : this . _colorService . resolve ( this . color ) ; } /**
     * We want the focus indicator color to match the active color,
     * which if programmatically defined need to be overriden
     */ _getFocusIndicator ( nodeLink ) { if ( ! nodeLink . focus || ! this . color ) { return '' ; } const color = this . color instanceof ThemeColor ? this . color : ThemeColor . parse ( this . _colorService . resolve ( this . color ) ) ; return `0 0 0 1px #fff, 0 0 0 3px ${ color . setAlpha ( 0.5 ) . toRgba ( ) }` ; } /**
     * Get columns mapped with their title if they have any
     */ private getColumns ( ) { return this . _sankey . columns ( ) . map ( ( width , index ) => ( { width , title : this . columns [ index ] || '' , position : this . getColumnPosition ( index ) } ) ) ; } /**
     * Get the start position of a column which can be determined
     * by finding a node that is in that column and using its
     * x position as all nodes start at the same position within a column.
     */ private getColumnPosition ( column ) { const node = this . _nodes . find ( _node => _node . column === column ) ; return node ? node . x : 0 ; } /**
     * Get the default content of a link tooltip
     */ private getLinkTooltip ( link ) { return link . value . toLocaleString ( 'en' ) + ' items' ; } /**
     * Get the default content of a falloff tooltip
     */ private getFalloffTooltip ( falloff ) { return falloff . toLocaleString ( 'en' ) + ' items' ; } } export interface SankeyColumn { width : number ; title : string ; position : number ; } export interface SankeyTooltipPosition { x : number ; y : number ; } export interface SankeyNodeContext < T > { node : SankeyNode < T > ; active : boolean ; focus : boolean ; }