import { BooleanInput , coerceBooleanProperty } from '@angular/cdk/coercion' ; import { DOWN_ARROW , ENTER , LEFT_ARROW , RIGHT_ARROW , UP_ARROW } from '@angular/cdk/keycodes' ; import { DomPortalOutlet , TemplatePortal } from '@angular/cdk/portal' ; import { AfterViewInit , ApplicationRef , ChangeDetectionStrategy , Component , ComponentFactoryResolver , ContentChild , ElementRef , EventEmitter , Injector , Input , NgZone , OnChanges , OnDestroy , Output , Renderer2 , SimpleChanges , TemplateRef , ViewChild , ViewContainerRef } from '@angular/core' ; import { event , hierarchy , HierarchyPointLink , HierarchyPointNode , interpolate , linkVertical , select , Selection , transition , tree , zoom , ZoomBehavior , ZoomTransform , zoomTransform } from 'd3' ; import { Subject } from 'rxjs' ; import { debounceTime , takeUntil } from 'rxjs/operators' ; import { FocusIndicator , FocusIndicatorService } from '../../directives/accessibility/index' ; import { ResizeDimensions , ResizeService } from '../../directives/resize/index' ; @ Component ( { selector : 'ux-organization-chart' , templateUrl : './organization-chart.component.html' , changeDetection : ChangeDetectionStrategy . OnPush , } ) export class OrganizationChartComponent < T > implements AfterViewInit , OnChanges , OnDestroy { /** Define the root node of the chart */ @ Input ( ) dataset : OrganizationChartNode < T > ; /** Define the presentation of the connectors */ @ Input ( ) connector : OrganizationChartConnector = 'elbow' ; /** Define the width of a node */ @ Input ( ) nodeWidth : number ; /** Define the height of a node */ @ Input ( ) nodeHeight : number ; /** Define the duration of the transition animations */ @ Input ( ) duration : number = 750 ; /** Define the vertical space between nodes */ @ Input ( ) verticalSpacing : number ; /** Define whether or not we can reveal additional parents */ @ Input ( ) showReveal : boolean = false ; /** Define the aria label for the reveal button */ @ Input ( ) revealAriaLabel : string = 'Reveal More' ; /** Defines whether nodes can be toggled or not */ @ Input ( ) set toggleNodesOnClick ( toggleNodesOnClick ) { this . _toggleNodesOnClick = coerceBooleanProperty ( toggleNodesOnClick ) ; } get toggleNodesOnClick ( ) : boolean { return this . _toggleNodesOnClick ; } /** Programmatically select an item */ @ Input ( ) set selected ( selected ) { if ( this . selected === selected || ! selected ) { return ; } if ( this . _isInitialised ) { this . select ( selected ) ; this . centerNode ( selected ) ; } else { this . _pendingSelection = selected ; } } /** Emit whenever a node is selected */ @ Output ( ) selectedChange = new EventEmitter < OrganizationChartNode < T > > ( true ) ; /** Emit whenever the reveal button is pressed */ @ Output ( ) reveal = new EventEmitter < void > ( ) ; /** Emit when the transition ends */ @ Output ( ) transitionEnd = new EventEmitter < void > ( ) ; /** Get the template for the node content */ @ ContentChild ( 'revealTemplate' , { static : false } ) revealTemplate : TemplateRef < any > ; /** Get the template for the node content */ @ ContentChild ( 'nodeTemplate' , { static : false } ) nodeTemplate : TemplateRef < OrganizationChartNodeContext < T > > ; /** Access the reveal button element */ @ ViewChild ( 'revealElement' , { static : true } ) revealElement : ElementRef ; /** Access the container element for the links */ @ ViewChild ( 'links' , { static : true } ) linksContainer : ElementRef ; /** Access the container element for the nodes */ @ ViewChild ( 'nodes' , { static : true } ) nodesContainer : ElementRef ; private _toggleNodesOnClick : boolean = true ; /** Store the internal selected node */ private _selected : OrganizationChartNode < T > ; /** Store a flattened array of nodes */ private _nodeLayout : HierarchyPointNode < OrganizationChartNode < T > > [ ] = [ ] ; /** Store a flattened array of links */ private _linkLayout : HierarchyPointLink < OrganizationChartNode < T > > [ ] = [ ] ; /** Store the links selection */ private _linksContainer : Selection < SVGElement , { } , null , undefined > ; /** Store the links selection */ private _nodesContainer : Selection < HTMLDivElement , { } , null , undefined > ; /** Store all the node elements */ private _nodes : Selection < HTMLDivElement , HierarchyPointNode < OrganizationChartNode < T > > , HTMLDivElement , HierarchyPointNode < OrganizationChartNode < T > > > ; /** Store all the link paths */ private _links : Selection < SVGElement , HierarchyPointLink < OrganizationChartNode < T > > , SVGElement , HierarchyPointLink < OrganizationChartNode < T > > > ; /** Store the zoom behavior */ private _zoom : ZoomBehavior < Element , { } > ; /** Store the current layout */ private _layout : HierarchyPointNode < OrganizationChartNode < T > > ; /** Store the current width of the chart */ private _width : number ; /** Store the current height of the chart */ private _height : number ; /** Store the portal/outlets associated with some data */ private _portals = new Map < OrganizationChartNode < T > , OrganizationChartPortalRef > ( ) ; /** Store the focus indicators associated with nodes */ private _indicators = new Map < OrganizationChartNode < T > , FocusIndicator > ( ) ; /** Store whether or not a transition is in progress */ private _isTransitioning : boolean = false ; /** Store whether or not a camera pan is in progress */ private _isPanning : boolean = false ; /** Determine if the component is initialised */ private _isInitialised : boolean = false ; /** Determine if the connector type has changed since the last render */ private _hasConnectorChanged : boolean = false ; /** Store the currently focused node if there is one */ private _focused : OrganizationChartNode < T > ; /** Store any selection made before the chart is initialised */ private _pendingSelection : OrganizationChartNode < T > ; /** Automatically unsubscribe from all subscriptions on destroy */ private _onDestroy = new Subject < void > ( ) ; constructor ( private _elementRef , private _resizeService , private _componentFactoryResolver , private _injector , private _appRef , private _viewContainerRef , private _renderer , private _focusIndicator , private _ngZone ) { } ngAfterViewInit ( ) { if ( ! this . nodeTemplate ) { throw new Error ( 'Organization Chart - You must provide a node template!' ) ; } if ( ! this . nodeWidth || ! this . nodeHeight ) { throw new Error ( 'Organization Chart - You must specify a nodeWidth and nodeHeight' ) ; } this . _zoom = zoom ( ) . scaleExtent ( [ 1 , 1 ] ) . interpolate ( interpolate ) . on ( 'zoom' , this . applyCameraPosition . bind ( this ) ) . on ( 'end' , ( ) => { if ( ! this . _isPanning ) { this . ensureNodesAreVisible ( ) ; } } ) ; this . _linksContainer = select ( this . linksContainer . nativeElement ) ; this . _nodesContainer = select ( this . nodesContainer . nativeElement ) ; this . _ngZone . runOutsideAngular ( ( ) => this . _nodesContainer . call ( this . _zoom ) ) ; this . render ( ) ; this . _width = this . _elementRef . nativeElement . offsetWidth ; this . _height = this . _elementRef . nativeElement . offsetHeight ; const resize$ = this . _resizeService . addResizeListener ( this . _elementRef . nativeElement ) ; resize$ . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( this . onResize . bind ( this ) ) ; resize$ . pipe ( takeUntil ( this . _onDestroy ) , debounceTime ( this . duration ) ) . subscribe ( this . ensureNodesAreVisible . bind ( this ) ) ; this . centerNode ( this . dataset , OrganizationChartAxis . Horizontal , false ) ; this . moveCamera ( 0 , 150 , false ) ; this . _isInitialised = true ; } ngOnChanges ( changes ) { if ( changes . connector && ! changes . connector . firstChange ) { this . _hasConnectorChanged = true ; } if ( Object . keys ( changes ) . length === 1 && changes . selected ) { return ; } if ( this . _isInitialised ) { this . render ( ) ; } } ngOnDestroy ( ) { this . _onDestroy . next ( ) ; this . _onDestroy . complete ( ) ; this . _portals . forEach ( node => { node . portal . detach ( ) ; node . outlet . dispose ( ) ; } ) ; } /** Perform the actual rendering of the chart */ render ( ) { this . updateLayout ( ) ; this . updateSelections ( ) ; const defaultTransition = transition ( ) . duration ( this . duration ) . on ( 'start' , ( ) => this . _isTransitioning = true ) . on ( 'end' , ( ) => { this . _isTransitioning = false ; this . transitionEnd . emit ( ) ; } ) ; this . _links . enter ( ) . insert ( 'path' ) . attr ( 'class' , 'ux-organization-chart-link' ) . attr ( 'd' , link => this . getLinkPath ( link ) ) . attr ( 'opacity' , - 2 ) . transition ( defaultTransition ) . attr ( 'd' , link => this . getLinkPath ( link ) ) . attr ( 'opacity' , 1 ) ; this . _links . transition ( ) . duration ( this . _hasConnectorChanged ? 0 : this . duration ) . attr ( 'd' , link => this . getLinkPath ( link ) ) ; this . _links . exit ( ) . transition ( defaultTransition ) . attr ( 'd' , ( link ) => this . getCollapsedLinkPath ( link ) ) . attr ( 'opacity' , 0 ) . remove ( ) ; this . _nodes . enter ( ) . append ( 'div' ) . attr ( 'class' , 'ux-organization-chart-node' ) . style ( 'width' , this . nodeWidth + 'px' ) . style ( 'height' , this . nodeHeight + 'px' ) . style ( 'left' , node => ( node . parent ? node . parent . x : node . x ) + 'px' ) . style ( 'top' , node => ( node . parent ? node . parent . y : node . y ) + 'px' ) . style ( 'opacity' , 0 ) . on ( 'keydown' , this . onKeydown . bind ( this ) ) . on ( 'focus' , this . onFocus . bind ( this ) ) . on ( 'mousedown' , ( ) => event . stopPropagation ( ) ) . on ( 'click' , this . onClick . bind ( this ) ) . each ( this . renderNodeTemplate . bind ( this ) ) . each ( ( node , index , group ) => this . monitorFocus ( group [ index ] , node ) ) . transition ( defaultTransition ) . style ( 'left' , node => node . x + 'px' ) . style ( 'top' , node => node . y + 'px' ) . style ( 'opacity' , 1 ) ; this . _nodes . transition ( defaultTransition ) . style ( 'left' , node => node . x + 'px' ) . style ( 'top' , node => node . y + 'px' ) ; this . _nodes . exit ( ) . transition ( defaultTransition ) . style ( 'left' , ( node ) => ( node . parent ? node . parent . x : node . x ) + 'px' ) . style ( 'opacity' , 0 ) . remove ( ) . on ( 'end' , ( node ) => this . destroyNode ( node ) ) ; select ( this . revealElement . nativeElement ) . style ( 'left' , ( ( this . nodeWidth / 2 ) - ( this . revealElement . nativeElement . offsetWidth / 2 ) ) + 'px' ) . style ( 'top' , - ( ( this . nodeHeight / 2 ) + ( this . revealElement . nativeElement . offsetHeight / 2 ) ) + 'px' ) ; this . updateSelections ( ) ; if ( ! this . _selected ) { this . select ( this . _pendingSelection || this . dataset ) ; this . _pendingSelection = null ; } this . setNodeAttributes ( ) ; this . applyCameraPosition ( ) ; this . _hasConnectorChanged = false ; } /** Select a specified node */ select ( node ) { node = this . coerceDataNode ( node ) as OrganizationChartNode < T > ; if ( this . _selected === node ) { return ; } this . expandParents ( node ) ; this . deselect ( false ) ; this . _selected = node ; this . selectedChange . next ( this . _selected ) ; if ( this . _isInitialised ) { this . render ( ) ; } this . _renderer . addClass ( this . getNodeElement ( this . _selected ) , 'ux-organization-chart-node-selected' ) ; this . setNodeAttributes ( ) ; } /** Deselect the currently selected node */ private deselect ( emit = true ) { if ( this . _nodes ) { this . _nodes . nodes ( ) . forEach ( element => this . _renderer . removeClass ( element , 'ux-organization-chart-node-selected' ) ) ; } if ( emit && ! ! this . _selected ) { this . _selected = null ; this . selectedChange . next ( null ) ; this . setNodeAttributes ( ) ; } } /** Toggle the collapsed state of a node */ toggle ( node ) { if ( this . _isTransitioning ) { return ; } node = this . coercePointNode ( node ) as HierarchyPointNode < OrganizationChartNode < T > > ; this . select ( node ) ; this . isExpanded ( node ) ? this . collapse ( node ) : this . expand ( node ) ; } /** Expand a node */ expand ( node ) { if ( this . _isTransitioning || ! this . toggleNodesOnClick ) { return ; } node = this . coercePointNode ( node ) as HierarchyPointNode < OrganizationChartNode < T > > ; node . ancestors ( ) . forEach ( _node => _node . data . expanded = true ) ; this . render ( ) ; if ( Array . isArray ( node . data . children ) && node . data . children . length > 0 ) { this . centerNode ( node . data . children [ Math . floor ( node . data . children . length / 2 ) ] ) ; } else { this . centerNode ( node ) ; } } /** Collapse a node */ collapse ( node ) { if ( this . _isTransitioning ) { return ; } node = this . coercePointNode ( node ) as HierarchyPointNode < OrganizationChartNode < T > > ; node . descendants ( ) . forEach ( _node => _node . data . expanded = false ) ; this . render ( ) ; this . centerNode ( node ) ; } /** Move a specific node to the center of the screen */ centerNode ( node , axis = OrganizationChartAxis . Both , animate = true ) { node = this . coercePointNode ( node ) as HierarchyPointNode < OrganizationChartNode < T > > ; const camera = this . getCameraPosition ( ) ; const x = axis === OrganizationChartAxis . Vertical ? camera . x : ( this . _width / 2 ) - ( node . x + ( this . nodeWidth / 2 ) ) ; const y = axis === OrganizationChartAxis . Horizontal ? camera . y : ( this . _height / 2 ) - ( node . y + ( this . nodeHeight / 2 ) ) ; this . setCameraPosition ( x , y , animate ) ; } /** Explicity set the position of the camera */ setCameraPosition ( x , y , animate = true ) { let camera = zoomTransform ( this . _nodesContainer . node ( ) ) ; if ( camera . x === x && camera . y === y ) { return ; } camera = camera . translate ( x - camera . x , y - camera . y ) ; this . _isPanning = true ; if ( animate ) { this . _nodesContainer . transition ( ) . duration ( this . duration ) . call ( this . _zoom . transform , camera ) . on ( 'end interrupt cancel' , ( ) => this . _isPanning = false ) ; } else { this . _nodesContainer . call ( this . _zoom . transform , camera ) ; this . _isPanning = false ; } } /** Move the camera an amount from its current position */ moveCamera ( x , y , animate = true ) { const camera = this . getCameraPosition ( ) ; this . setCameraPosition ( camera . x + x , camera . y + y , animate ) ; } /** Focus a given node */ focus ( node ) { this . focusNode ( this . coercePointNode ( node ) ) ; } /** Focus the root node */ _focusRootNode ( ) { this . focusNode ( this . coercePointNode ( this . dataset ) ) ; } /** Destroy the outlet and portal associated with a node */ private destroyNode ( node ) { node = this . coercePointNode ( node ) ; if ( this . _indicators . has ( node . data ) ) { this . _indicators . get ( node . data ) . destroy ( ) ; this . _indicators . delete ( node . data ) ; } if ( ! this . _portals . has ( node . data ) ) { return ; } const portalRef = this . _portals . get ( node . data ) ; portalRef . portal . detach ( ) ; portalRef . outlet . dispose ( ) ; this . _portals . delete ( node . data ) ; } private updateLayout ( ) { this . _layout = this . getLayout ( ) ; this . _nodeLayout = this . _layout . descendants ( ) ; this . _linkLayout = this . _layout . links ( ) ; } /** Ensure the selections stay in sync with the view */ private updateSelections ( ) { this . _nodes = this . _nodesContainer . selectAll ( '.ux-organization-chart-node' ) . data ( this . _nodeLayout , ( node ) => node . data . id . toString ( ) ) as Selection < HTMLDivElement , HierarchyPointNode < OrganizationChartNode < T > > , HTMLDivElement , HierarchyPointNode < OrganizationChartNode < T > > > ; this . _links = this . _linksContainer . selectAll ( '.ux-organization-chart-link' ) . data ( this . _linkLayout , ( link ) => { return `${ link . source . data . id }-${ link . target . data . id }` ; } ) as Selection < SVGElement , HierarchyPointLink < OrganizationChartNode < T > > , SVGElement , HierarchyPointLink < OrganizationChartNode < T > > > ; } /** Render the content of the node based on the template provided */ private renderNodeTemplate ( node , index , group ) { const context = { data : node . data . data , node : node . data , focused : false } ; Object . defineProperty ( context , 'focused' , { get : ( ) => this . _focused === node . data } ) ; const outlet = this . createPortalOutlet ( group [ index ] ) ; const portal = new TemplatePortal < OrganizationChartNodeContext < T > > ( this . nodeTemplate , this . _viewContainerRef , context ) ; portal . attach ( outlet ) ; this . _portals . set ( node . data , { portal , outlet } ) ; } /** Handle any zoom events (we use zoom for panning behaviour) */ private applyCameraPosition ( ) { let { x , y } = zoomTransform ( this . _nodesContainer . node ( ) ) ; x = Math . round ( x ) ; y = Math . round ( y ) ; this . _renderer . setStyle ( this . revealElement . nativeElement , 'transform' , `translate(${ x }px, ${ y }px)` ) ; this . _nodesContainer . selectAll ( '.ux-organization-chart-node' ) . style ( 'transform' , `translate(${ x }px, ${ y }px)` ) ; this . _linksContainer . selectAll ( '.ux-organization-chart-link' ) . attr ( 'transform' , `translate(${ x } ${ y })` ) ; } /** Get the data in with the required layout information */ private getLayout ( ) { const treeHierarchy = hierarchy ( this . dataset , node => Array . isArray ( node . children ) && node . expanded ? node . children : [ ] ) ; const layout = tree < OrganizationChartNode < T > > ( ) . nodeSize ( [ this . nodeWidth , this . nodeHeight ] ) . separation ( this . getNodeSpacing . bind ( this ) ) ; const treeLayout = layout ( treeHierarchy ) ; const verticalSpacing = this . verticalSpacing === undefined ? this . nodeHeight : this . verticalSpacing ; treeLayout . each ( data => data . y = data . depth * ( this . nodeHeight + verticalSpacing ) ) ; return treeLayout ; } /** Determine how much horizontal spacing should be between nodes */ private getNodeSpacing ( nodeOne , nodeTwo ) { if ( nodeOne . parent !== nodeTwo . parent ) { return 2 ; } return 1.5 ; } /** Ensure we consistently use the HierarchyPoint data structure */ private coercePointNode ( node ) { if ( node . hasOwnProperty ( 'depth' ) && node . hasOwnProperty ( 'x' ) && node . hasOwnProperty ( 'y' ) ) { return node as HierarchyPointNode < OrganizationChartNode < T > > ; } const match = this . _nodeLayout . find ( _node => _node . data === node ) ; if ( ! match ) { throw new Error ( 'The node does not exist in the hierarchy' ) ; } return match ; } private coerceDataNode ( node ) { if ( node . hasOwnProperty ( 'depth' ) && node . hasOwnProperty ( 'x' ) && node . hasOwnProperty ( 'y' ) ) { return ( node as HierarchyPointNode < OrganizationChartNode < T > > ) . data ; } return node as OrganizationChartNode < T > ; } /** Handle chart resize events */ private onResize ( { width , height } ) { this . _width = width ; this . _height = height ; } /** Deteremine if a node is expanded or collapsed */ private isExpanded ( node ) { return ! ! node . data . expanded ; } /** Get the current position of the camera */ private getCameraPosition ( ) { return zoomTransform ( this . _nodesContainer . node ( ) ) ; } /** Get the SVG line definition for each link */ private getLinkPath ( pointLink ) { if ( this . connector === 'elbow' ) { const source = { x : pointLink . source . x + ( this . nodeWidth / 2 ) , y : pointLink . source . y + this . nodeHeight } ; const target = { x : pointLink . target . x + ( this . nodeWidth / 2 ) , y : pointLink . target . y } ; return 'M' + source . x + ',' + ( source . y ) + 'v' + ( ( target . y - source . y ) / 2 ) + 'h' + ( target . x - source . x ) + 'v' + ( ( target . y - source . y ) / 2 ) ; } else { const source = { x : pointLink . source . x + ( this . nodeWidth / 2 ) , y : pointLink . source . y + ( this . nodeHeight / 2 ) } ; const target = { x : pointLink . target . x + ( this . nodeWidth / 2 ) , y : pointLink . target . y + ( this . nodeHeight / 2 ) } ; return linkVertical ( ) ( { source : [ source . x , source . y ] , target : [ target . x , target . y ] } ) ; } } /** Get the link path line defintion when the link is collapsing */ private getCollapsedLinkPath ( pointLink ) { return this . getLinkPath ( { source : pointLink . source , target : pointLink . source } ) ; } /** Create a dynamic region that Angular can insert into */ private createPortalOutlet ( element ) { return new DomPortalOutlet ( element , this . _componentFactoryResolver , this . _appRef , this . _injector ) ; } /** Make the appropriate node tabbable and update aria attributes */ private setNodeAttributes ( ) { for ( const element of this . _nodes . nodes ( ) ) { this . _renderer . setAttribute ( element , 'tabindex' , '-1' ) ; this . _renderer . setAttribute ( element , 'aria-expanded' , ! ! this . getNodeData ( element ) . data . expanded ? 'true' : 'false' ) ; } if ( this . _selected ) { this . _renderer . setAttribute ( this . getNodeElement ( this . _selected ) , 'tabindex' , '0' ) ; } } /** Get the element that represents a given node */ private getNodeElement ( node ) { node = this . coercePointNode ( node ) ; const index = this . _nodes . data ( ) . indexOf ( node ) ; return this . _nodes . nodes ( ) [ index ] ; } /** Get the element that represents a given node */ private getNodeData ( node ) { const index = this . _nodes . nodes ( ) . indexOf ( node ) ; return this . _nodes . data ( ) [ index ] ; } /** Handle click events */ private onClick ( node ) { if ( ! this . toggleNodesOnClick ) { return ; } this . toggle ( node ) ; } /** Handle keyboard events */ private onKeydown ( node ) { if ( ! this . toggleNodesOnClick ) { return ; } switch ( event . keyCode ) { case DOWN_ARROW : event . preventDefault ( ) ; if ( ! node . data . expanded && Array . isArray ( node . data . children ) && node . data . children . length > 0 ) { return this . expand ( node ) ; } return this . focusChild ( node ) ; case RIGHT_ARROW : event . preventDefault ( ) ; return this . focusNextSibling ( node ) ; case UP_ARROW : event . preventDefault ( ) ; return this . focusParent ( node ) ; case LEFT_ARROW : event . preventDefault ( ) ; return this . focusPreviousSibling ( node ) ; case ENTER : return this . toggle ( node ) ; } } /** When a node receives focus */ private onFocus ( node ) { if ( ! this . isNodeInViewport ( node , this . _width * 0.1 , this . _height * 0.1 ) ) { this . centerNode ( node ) ; } } /** Move focus to the parent node */ private focusParent ( node ) { if ( node . parent ) { this . focusNode ( node . parent ) ; } else if ( this . revealElement ) { this . revealElement . nativeElement . focus ( ) ; this . centerNode ( this . dataset ) ; } } /** Move focus to the child node */ private focusChild ( node ) { if ( Array . isArray ( node . children ) && node . children . length > 0 ) { this . focusNode ( node . children [ Math . floor ( node . children . length / 2 ) ] ) ; } } /** Move focus to the sibling on the left */ private focusPreviousSibling ( node ) { if ( node . parent ) { this . focusNode ( node . parent . children [ node . parent . children . indexOf ( node ) - 1 ] ) ; } } /** Move focus to the sibling on the right */ private focusNextSibling ( node ) { if ( node . parent ) { this . focusNode ( node . parent . children [ node . parent . children . indexOf ( node ) + 1 ] ) ; } } /** Focus a given node */ private focusNode ( node ) { if ( node ) { this . getNodeElement ( node ) . focus ( { preventScroll : true } ) ; this . nodesContainer . nativeElement . scrollTop = 0 ; this . nodesContainer . nativeElement . scrollLeft = 0 ; } } /** Determine if a node is fully visible within the viewport */ private isNodeInViewport ( node , insetX = 0 , insetY = 0 ) { const { x , y } = this . getCameraPosition ( ) ; const left = node . x + x ; const top = node . y + y ; const right = node . x + x + this . nodeWidth ; const bottom = node . y + y + this . nodeHeight ; return left >= insetX && top >= insetY && right <= ( this . _width - insetX ) && bottom <= ( this . _height - insetY ) ; } /** Determine if a node is fully outside of the viewport */ private isNodeOutsideViewport ( node , insetX = 0 , insetY = 0 ) { const { x , y } = this . getCameraPosition ( ) ; const left = node . x + x + this . nodeWidth ; const top = node . y + y + this . nodeHeight ; const right = node . x + x ; const bottom = node . y + y ; return left < insetX || top < insetY || right > ( this . _width - insetX ) || bottom > ( this . _height - insetY ) ; } /** Determine how far a node is from being within the viewport */ private getDistanceFromViewport ( node , insetX = 0 , insetY = 0 ) { if ( ! this . isNodeOutsideViewport ( node , insetX , insetY ) ) { return [ 0 , 0 ] ; } const { x , y } = this . getCameraPosition ( ) ; const left = insetX - ( node . x + x + this . nodeWidth ) ; const top = insetY - ( node . y + y + this . nodeHeight ) ; const right = ( node . x + x ) - ( this . _width - insetX ) ; const bottom = ( node . y + y ) - ( this . _height - insetY ) ; let horizontal = 0 ; let vertical = 0 ; if ( left > 0 && left > right ) { horizontal = left ; } if ( right > 0 && left < right ) { horizontal = - right ; } if ( top > 0 && top > bottom ) { vertical = top ; } if ( bottom > 0 && top < bottom ) { vertical = - bottom ; } return [ horizontal , vertical ] ; } /** Begin monitoring the element focus so we only show styling when navigated by keyboard */ private monitorFocus ( element , node ) { const indicator = this . _focusIndicator . monitor ( element , { checkChildren : false , programmaticFocusIndicator : true } ) ; indicator . isFocused$ . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( isFocused => { this . _ngZone . run ( ( ) => { if ( isFocused ) { this . _focused = node . data ; } else if ( node . data === this . _focused ) { this . _focused = null ; } } ) ; } ) ; this . _indicators . set ( node . data , indicator ) ; } private ensureNodesAreVisible ( ) { const visibleCount = this . _nodes . filter ( node => ! this . isNodeOutsideViewport ( node ) ) . size ( ) ; if ( visibleCount > 0 ) { return ; } const distances = this . _nodes . data ( ) . map ( node => this . getDistanceFromViewport ( node , this . nodeWidth * 1.25 , this . nodeHeight * 1.5 ) ) ; const [ x , y ] = distances . reduce ( ( previous , current ) => { const [ previousX , previousY ] = previous ; const [ currentX , currentY ] = current ; return ( Math . abs ( previousX ) + Math . abs ( previousY ) ) < ( Math . abs ( currentX ) + Math . abs ( currentY ) ) ? previous : current ; } ) ; this . moveCamera ( x , y ) ; } /** Expand all parent nodes */ private expandParents ( node ) { let parent = this . getParent ( node ) ; while ( parent ) { parent . expanded = true ; parent = this . getParent ( parent ) ; } } /** Get the parent of a given node */ private getParent ( node ) { return [ this . coerceDataNode ( this . dataset ) , ... this . getAllChildren ( this . dataset ) ] . find ( _node => { if ( ! Array . isArray ( _node . children ) ) { return false ; } return _node . children . find ( ( child ) => child . id === node . id ) ; } ) ; } /** Get a flat array of all the nodes childrent */ private getAllChildren ( node ) { const children = node . children || [ ] ; return [ ... children , ... children . reduce ( ( accumulation , child ) => [ ... accumulation , ... this . getAllChildren ( child ) ] , [ ] ) ] . map ( child => this . coerceDataNode ( child ) ) ; } static ngAcceptInputType_toggleNodesOnClick : BooleanInput ; } export interface OrganizationChartNode < T > { id : number | string ; data ? : T ; children ? : ReadonlyArray < OrganizationChartNode < T > > ; expanded ? : boolean ; } export interface OrganizationChartNodeContext < T > { data : T ; node : OrganizationChartNode < T > ; focused : boolean ; } export interface OrganizationChartPortalRef { outlet : DomPortalOutlet ; portal : TemplatePortal ; } export type OrganizationChartConnector = 'elbow' | 'curved' ; export enum OrganizationChartAxis { Horizontal , Vertical , Both }