import { LiveAnnouncer } from '@angular/cdk/a11y' ; import { ChangeDetectionStrategy , ChangeDetectorRef , Component , ContentChild , ElementRef , EventEmitter , Input , NgZone , OnDestroy , OnInit , Output , TemplateRef } from '@angular/core' ; import { hierarchy , HierarchyRectangularNode , partition , scaleLinear , select , Selection , transition } from 'd3' ; import { Subject } from 'rxjs' ; import { takeUntil } from 'rxjs/operators' ; import { ContrastService , FocusIndicatorOriginService } from '../../directives/accessibility/index' ; import { ResizeService } from '../../directives/resize/index' ; import { ColorService , ThemeColor } from '../../services/color/index' ; @ Component ( { selector : 'ux-partition-map' , templateUrl : './partition-map.component.html' , changeDetection : ChangeDetectionStrategy . OnPush } ) export class PartitionMapComponent implements OnInit , OnDestroy { /** Define the colors to be used for each row and the order they should appear. */ @ Input ( ) set colors ( colors ) { this . _colors = colors ; this . _segmentColors . clear ( ) ; } /** Determine the pixel height of collapsed segments. */ @ Input ( ) collapsedHeight : number = 40 ; /** Define a minimum desired pixel width for a segment. */ @ Input ( ) minSegmentWidth : number = 5 ; /** Define the dataset to display in the chart. */ @ Input ( ) set dataset ( dataset ) { this . _dataset = dataset ; this . _segmentColors . clear ( ) ; this . setDataset ( dataset ) ; } get dataset ( ) : Readonly < PartitionMapSegment > { return this . _dataset ; } /** Define the currently selected item. */ @ Input ( ) set selected ( selected ) { if ( this . _segments . length === 0 ) { this . _awaitingSelection = selected ; return ; } this . select ( this . getHierarchyNodeFromSegment ( selected ) ) ; } /** Define the function that will return the aria announcement for a given segment. */ @ Input ( ) segmentAnnouncement : ( info : PartitionMapSegmentAnnouncementInfo ) => string = this . defaultSegmentAnnouncement ; /** Emits whenever a segment is selected. */ @ Output ( ) selectedChange = new EventEmitter < PartitionMapSegment > ( ) ; /** Access a if provided custom template */ @ ContentChild ( 'partitionMapSegment' , { static : false } ) segmentTemplate : TemplateRef < PartitionMapCustomSegmentContext > ; /** Store the processed segments */ _segments : HierarchyRectangularNode < PartitionMapSegment > [ ] = [ ] ; /** Store the current dataset */ private _dataset : Readonly < PartitionMapSegment > ; /** Store the specified color sequences */ private _colors : ( string | ThemeColor ) [ ] [ ] = [ [ ] ] ; /** Store the currently selected segment */ private _selected : HierarchyRectangularNode < PartitionMapSegment > ; /** Store the assigned colors for each segment */ private _segmentColors = new Map < string , string > ( ) ; /** Store the visible x scale */ private _x = scaleLinear ( ) . range ( [ 0 , 100 ] ) ; /** Store the visible y scale */ private _y = scaleLinear ( ) . range ( [ 0 , 100 ] ) ; /** Store the visible d3 segments */ private _segmentsSelection : Selection < HTMLDivElement , HierarchyRectangularNode < PartitionMapSegment > , HTMLElement , { } > ; /** Store the current focusable segment */ private _focusableSegment : HierarchyRectangularNode < PartitionMapSegment > ; /** Store an item awaiting selection */ private _awaitingSelection : PartitionMapSegment ; /** Store the width of the chart on resize to avoid any reflow */ private _width : number = this . _elementRef . nativeElement . offsetWidth ; /** Store the height of the chart on resize to avoid any reflow */ private _height : number = this . _elementRef . nativeElement . offsetHeight ; /** Flag to determine when the inputs have all been bound */ private _initialized = false ; /** Unsubscribe from any observables on destroy */ private _onDestroy = new Subject < void > ( ) ; constructor ( private _colorService , private _elementRef , private _changeDetector , private _ngZone , private _focusOrigin , private _contrastRatio , private _liveAnnouncer , private _resizeService ) { } ngOnInit ( ) { this . _resizeService . addResizeListener ( this . _elementRef . nativeElement ) . pipe ( takeUntil ( this . _onDestroy ) ) . subscribe ( dimensions => { this . _width = dimensions . width ; this . _height = dimensions . height ; this . _changeDetector . detectChanges ( ) ; if ( this . _selected ) { this . _x . domain ( [ this . getSegmentX ( this . _selected ) , this . getSegmentX ( this . _selected ) + this . getSegmentWidth ( this . _selected ) ] ) ; this . _y . domain ( [ this . _selected . y0 , 1 ] ) . range ( [ this . getTotalCollapsedHeight ( ) , 100 ] ) ; } this . updateSegments ( ) ; } ) ; this . _initialized = true ; this . _changeDetector . detectChanges ( ) ; } ngOnDestroy ( ) { this . _resizeService . removeResizeListener ( this . _elementRef . nativeElement ) ; this . _onDestroy . next ( ) ; this . _onDestroy . complete ( ) ; } /** Handle segment clicks */ _onSegmentSelect ( segment ) { this . select ( this . _isSelected ( segment ) && segment . parent ? segment . parent : segment ) ; } /** Get the background color for a given segment */ _getBackgroundColor ( segment ) { if ( ! this . _initialized ) { return '#fff' ; } const key = `${ segment . data . name } - ${ segment . depth }` ; if ( this . _segmentColors . has ( key ) ) { return this . _segmentColors . get ( key ) ; } const sequence = this . getColorSequence ( segment . depth ) ; if ( ! sequence || sequence . length === 0 ) { return '#fff' ; } const siblings = this . getAllSiblings ( segment ) ; const sibling = siblings [ siblings . indexOf ( segment ) - 1 ] ; if ( sibling ) { const index = sequence . indexOf ( this . _getBackgroundColor ( sibling ) ) ; const color = sequence [ ( index + 1 ) % sequence . length ] ; this . _segmentColors . set ( key , color ) ; return color ; } this . _segmentColors . set ( key , sequence [ 0 ] ) ; return sequence [ 0 ] ; } /** Get the tab index of a segment */ _getTabIndex ( segment ) { return segment === this . _focusableSegment ? 0 : - 1 ; } /** Shift focus to the parent segment */ _focusParent ( segment ) { if ( ! segment . parent ) { return ; } this . focusSegment ( segment . parent ) ; } /** Shift focus to the child segment */ _focusChild ( segment ) { if ( ! segment . children ) { return ; } const child = segment . children . find ( _segment => this . isVisible ( _segment ) ) ; if ( child ) { this . focusSegment ( child ) ; } } /** Shift focus to the sibling segment */ _focusSibling ( segment , delta ) { if ( ! segment . parent ) { return ; } const siblings = this . getAllSiblings ( segment ) ; const index = siblings . indexOf ( segment ) ; const sibling = siblings [ index + delta ] ; if ( ! sibling || ! this . isVisible ( sibling ) ) { return ; } this . focusSegment ( sibling ) ; } _focusFirstSibling ( segment ) { if ( ! segment . parent ) { return ; } const siblings = this . getAllSiblings ( segment ) ; const sibling = siblings . find ( _sibling => this . isVisible ( _sibling ) ) ; if ( ! sibling ) { return ; } this . focusSegment ( sibling ) ; } _focusLastSibling ( segment ) { if ( ! segment . parent ) { return ; } const siblings = this . getAllSiblings ( segment ) ; const sibling = siblings . reverse ( ) . find ( _sibling => this . isVisible ( _sibling ) ) ; if ( ! sibling ) { return ; } this . focusSegment ( sibling ) ; } /** Determine if a given segment is currently collapsed */ _isCollapsed ( segment ) { return this . _selected && segment . depth < this . _selected . depth ; } /** Determine if a given segment is currently selected */ _isSelected ( segment ) { return this . _selected === segment ; } /** Get the contast color class for the segment */ _getContrastColor ( segment ) { const backgroundColor = this . _getBackgroundColor ( segment ) ; const lightColor = ThemeColor . parse ( '#fff' ) ; const darkColor = ThemeColor . parse ( '#000' ) ; const color = this . _contrastRatio . getContrastColor ( ThemeColor . parse ( backgroundColor ) , lightColor , darkColor ) ; return color === lightColor ? 'partition-map-segment-light' : 'partition-map-segment-dark' ; } /** Provide an aria announcement when the node is focused */ _onFocus ( segment ) { const ancestors = segment . ancestors ( ) . map ( ancestor => ancestor . data ) ; const [ item , ... parents ] = ancestors ; const hierarchichalItem = this . getHierarchyNodeFromSegment ( item ) ; const announcement = this . segmentAnnouncement ( { item , parents , value : this . _getSegmentValue ( segment . data ) , collapsed : this . _isCollapsed ( hierarchichalItem ) , selected : this . _isSelected ( hierarchichalItem ) } ) ; this . _liveAnnouncer . announce ( announcement ) ; } /** Determine if the content is smaller than the width of an ellipsis */ _getSegmentContentHidden ( segment ) { const width = ( this . _width / 100 ) * this . getNormalizedSegmentWidth ( segment ) ; return width < 50 ; } /** Get the value of a segment based on the accumulation of all child values */ _getSegmentValue ( segment ) { if ( segment . hasOwnProperty ( 'value' ) ) { return ( segment as PartitionMapSegmentWithValue ) . value ; } return ( segment as PartitionMapSegmentWithChildren ) . children . reduce ( ( value , child ) => value + this . _getSegmentValue ( child ) , 0 ) ; } _getContext ( segment ) { const context = { segment : segment . data , value : this . _getSegmentValue ( segment . data ) , color : this . _getBackgroundColor ( segment ) , expanded : ! this . _isCollapsed ( segment ) , children : [ ] } ; if ( segment . children ) { context . children = segment . children . map ( this . _getContext . bind ( this ) ) ; } return context ; } trackByIndex ( index ) { return index ; } /** Convert the public facing data structure into the layout format we require */ private setDataset ( dataset ) { const segmentHierarchy = hierarchy ( dataset ) . sum ( this . getSegmentValue ) ; const root = partition ( ) ( segmentHierarchy ) as HierarchyRectangularNode < PartitionMapSegment > ; this . _segments = root . descendants ( ) ; this . _focusableSegment = root ; this . _changeDetector . detectChanges ( ) ; this . _segmentsSelection = select ( this . _elementRef . nativeElement ) . selectAll ( '.partition-map-segment' ) . data ( this . _segments ) as Selection < HTMLDivElement , HierarchyRectangularNode < PartitionMapSegment > , HTMLElement , { } > ; this . updateSegments ( ) ; if ( this . _awaitingSelection ) { this . select ( this . getHierarchyNodeFromSegment ( this . _awaitingSelection ) ) ; this . _awaitingSelection = null ; } } /** Update the size and position of the segments */ private updateSegments ( ) { if ( ! this . _segmentsSelection ) { return ; } this . _segmentsSelection . style ( 'left' , data => this . getNormalizedSegmentX ( data ) + '%' ) . style ( 'top' , data => this . getNormalizedSegmentY ( data ) + '%' ) . style ( 'width' , data => ( this . getNormalizedSegmentWidth ( data ) + 0.01 ) + '%' ) . style ( 'height' , data => this . getNormalizedSegmentHeight ( data ) + '%' ) . style ( 'padding-right' , data => this . getSegmentPaddingRight ( data ) + '%' ) . style ( 'padding-left' , data => this . getSegmentPaddingLeft ( data ) + '%' ) ; } /**
     * Get the X position of a given segment. The X position can be determined
     * by calculating the width of every sibling segment to the left of it
     */ private getSegmentX ( segment ) { if ( ! segment . parent ) { return segment . x0 ; } let accumulation = this . getSegmentX ( segment . parent ) ; for ( const sibling of segment . parent . children ) { if ( sibling === segment ) { return accumulation ; } accumulation += this . getSegmentWidth ( sibling ) ; } } /** Calculate width based of each segment */ private getSegmentWidth ( segment ) { if ( ! segment . parent ) { return 1 ; } const parentOffset = this . getSegmentWidth ( segment . parent ) / ( segment . parent . x1 - segment . parent . x0 ) ; const width = ( segment . x1 - segment . x0 ) ; if ( this . isDescendantOfSelected ( segment ) ) { const modifier = this . getDistributionModifier ( segment ) ; return ( width * modifier ) * parentOffset ; } return width * parentOffset ; } /** Return the X position of the segment in a normalized form based on the specifiec domain */ private getNormalizedSegmentX ( segment ) { return this . _x ( this . getSegmentX ( segment ) ) ; } /** Return the Y position of the segment in a normalized form based on the specifiec domain */ private getNormalizedSegmentY ( segment ) { if ( this . _isCollapsed ( segment ) ) { return segment . depth * this . getCollapsedHeight ( ) ; } return this . _y ( segment . y0 ) ; } /** Return the width of the segment in a normalized form based on the specifiec domain */ private getNormalizedSegmentWidth ( segment ) { return this . _x ( this . getSegmentX ( segment ) + this . getSegmentWidth ( segment ) ) - this . _x ( this . getSegmentX ( segment ) ) ; } /** Return the height of the segment in a normalized form based on the specifiec domain */ private getNormalizedSegmentHeight ( segment ) { if ( this . _isCollapsed ( segment ) ) { return this . getCollapsedHeight ( ) ; } return this . _y ( segment . y0 + ( segment . y1 - segment . y0 ) ) - this . _y ( segment . y0 ) ; } /**
     * As parent segments collapse they increase in size, as the content is centered this can
     * cause the content to appear either mis-aligned or off screen. We can calculate the padding
     * required to always ensure the content appears visibly centered within the node.
     */ private getSegmentPaddingRight ( segment ) { if ( ! this . _isCollapsed ( segment ) ) { return 0 ; } return ( this . getNormalizedSegmentWidth ( segment ) - this . getSegmentPaddingLeft ( segment ) ) - this . getNormalizedSegmentWidth ( this . _selected ) ; } private getSegmentPaddingLeft ( segment ) { if ( ! this . _isCollapsed ( segment ) ) { return 0 ; } return Math . abs ( this . getNormalizedSegmentX ( segment ) ) ; } /**
     * This function returns the value for each segment. Leaf segments will have a value property which we can simply return, however
     * non-leaf segments should get their values based on the leaf segments that are children, in which case we can return 0
     */ private getSegmentValue ( segment ) { if ( segment . hasOwnProperty ( 'value' ) ) { const value = ( segment as PartitionMapSegmentWithValue ) . value ; return Math . max ( value , 1 ) ; } return 0 ; } /** Get the total height of all the collapse rows */ private getTotalCollapsedHeight ( ) { return this . _selected ? this . _selected . depth * this . getCollapsedHeight ( ) : 0 ; } /** Get the collapsed height in percentage format */ private getCollapsedHeight ( ) { return parseFloat ( ( ( this . collapsedHeight / this . _height ) * 100 ) . toPrecision ( 3 ) ) ; } /** Determine if a given segment is currently visible based on the selected segment */ private isVisible ( segment ) { if ( ! this . _selected ) { return true ; } return ! ! [ ... this . _selected . ancestors ( ) , ... this . _selected . descendants ( ) ] . find ( _segment => _segment === segment ) ; } /** Update the focusable item and perform a focus */ private focusSegment ( segment ) { const element = this . _segmentsSelection . nodes ( ) . find ( node => select ( node ) . data ( ) [ 0 ] === segment ) ; if ( ! element ) { return ; } this . _focusableSegment = segment ; this . _focusOrigin . setOrigin ( 'keyboard' ) ; element . focus ( ) ; ( this . _elementRef . nativeElement as HTMLElement ) . scrollLeft = 0 ; ( this . _elementRef . nativeElement as HTMLElement ) . scrollTop = 0 ; } /** Get all the segments at a given depth */ private getAllSiblings ( segment ) { return this . _segments . filter ( _segment => _segment . depth === segment . depth ) ; } private getHierarchyNodeFromSegment ( segment ) { return this . _segments . find ( _segment => _segment . data === segment ) ; } /** Select a specified segment */ private select ( segment ) { if ( ! segment || this . _isSelected ( segment ) ) { return ; } this . selectedChange . emit ( segment . data ) ; this . _selected = segment ; this . _focusableSegment = segment ; this . _x . domain ( [ this . getSegmentX ( segment ) , this . getSegmentX ( segment ) + this . getSegmentWidth ( segment ) ] ) ; this . _y . domain ( [ segment . y0 , 1 ] ) . range ( [ this . getTotalCollapsedHeight ( ) , 100 ] ) ; const segmentTransition = transition ( ) . duration ( 500 ) ; this . _ngZone . runOutsideAngular ( ( ) => { this . _segmentsSelection . transition ( segmentTransition ) . style ( 'left' , data => this . getNormalizedSegmentX ( data ) + '%' ) . style ( 'top' , data => this . getNormalizedSegmentY ( data ) + '%' ) . style ( 'width' , data => ( this . getNormalizedSegmentWidth ( data ) + 0.01 ) + '%' ) . style ( 'height' , data => this . getNormalizedSegmentHeight ( data ) + '%' ) . style ( 'padding-right' , data => this . getSegmentPaddingRight ( data ) + '%' ) . style ( 'padding-left' , data => this . getSegmentPaddingLeft ( data ) + '%' ) ; } ) ; } /** Normalize the available colors to a string[][] from portentially a ThemeColor[][] */ private getColorSequence ( depth ) { const colorSet = this . _colors [ depth ] ; if ( ! colorSet ) { throw new Error ( 'Partition Map: Please provide a color sequence for items with a depth of ' + depth ) ; } return colorSet . map ( color => ThemeColor . isInstanceOf ( color ) ? ( color as ThemeColor ) . toRgba ( ) : this . _colorService . resolve ( color as string ) ) ; } /** Determine if a segment is a descendant of the currently selected item */ private isDescendantOfSelected ( segment ) { if ( ! this . _selected ) { return true ; } if ( this . _selected === segment ) { return false ; } return ! ! this . _selected . descendants ( ) . find ( _segment => _segment === segment ) ; } /**
     * We have an option to allow a minimum desired width for items. This will
     * allow us to attempt to determine the size a segment would be accounting for very
     * small segments that have their widths artifically increased to make them more visible
     */ private getDistributionModifier ( segment ) { const minSegmentWidth = ( this . minSegmentWidth / this . _width ) * 100 ; const siblings = segment . parent . children . map ( _segment => { return { segment : _segment , width : this . _x ( _segment . x1 - _segment . x0 ) } ; } ) ; const isAcceptable = ( segments ) => ! segments . find ( _segment => _segment . width < minSegmentWidth ) || segments . filter ( _segment => _segment . width < minSegmentWidth ) . length === siblings . length ; if ( isAcceptable ( siblings ) ) { return 1 ; } let amountToReclaim = siblings . reduce ( ( accumulation , _segment ) => accumulation + ( _segment . width < minSegmentWidth ? minSegmentWidth - _segment . width : 0 ) , 0 ) ; while ( ! isAcceptable ( siblings ) && amountToReclaim !== 0 ) { const shrinkableSiblings = siblings . filter ( sibling => sibling . width > minSegmentWidth ) ; const growableSiblings = siblings . filter ( sibling => sibling . width < minSegmentWidth ) ; if ( shrinkableSiblings . length === 0 || growableSiblings . length === 0 ) { break ; } const shrinkTarget = amountToReclaim / shrinkableSiblings . length ; let reclaimed = 0 ; for ( const sibling of shrinkableSiblings ) { const subtractAmount = sibling . width - shrinkTarget > minSegmentWidth ? shrinkTarget : sibling . width - minSegmentWidth ; reclaimed += subtractAmount ; sibling . width -= subtractAmount ; } amountToReclaim -= reclaimed ; const growTarget = reclaimed / growableSiblings . length ; for ( const sibling of growableSiblings ) { const addAmount = sibling . width + growTarget < minSegmentWidth ? growTarget : minSegmentWidth - sibling . width ; sibling . width += addAmount ; } } const matchingSegment = siblings . find ( sibling => sibling . segment === segment ) ; const isLast = siblings . findIndex ( sibling => sibling . segment === segment ) === siblings . length - 1 ; if ( isLast ) { const parentWidth = this . _x ( segment . parent . x1 - segment . parent . x0 ) ; const width = siblings . reduce ( ( total , sibling ) => total + sibling . width , 0 ) ; if ( parentWidth !== width ) { return ( matchingSegment . width + ( parentWidth - width ) ) / this . _x ( matchingSegment . segment . x1 - matchingSegment . segment . x0 ) ; } } return matchingSegment . width / this . _x ( matchingSegment . segment . x1 - matchingSegment . segment . x0 ) ; } /** Get the default announcement when a segment is focused */ private defaultSegmentAnnouncement ( info ) { if ( info . parents . length === 0 ) { return `This is the root segment. It has a value of ${ info . value }.` ; } return `${ info . item . name } has a value of ${ info . value } and is a ${ info . parents . map ( parent => `descendant of ${ parent . name }` ) . join ( ' and a ' ) }` ; } } export interface PartitionMapSegmentBase { name : string ; data ? : { [ key ] : any ; } ; } export interface PartitionMapSegmentWithChildren extends PartitionMapSegmentBase { children : ReadonlyArray < PartitionMapSegment > ; } export interface PartitionMapSegmentWithValue extends PartitionMapSegmentBase { value : number ; } /** This union type allows us to ensure that a partition map segment has either children or a value */ export type PartitionMapSegment = PartitionMapSegmentWithChildren | PartitionMapSegmentWithValue ; /** Partition map custom segment template context */ export interface PartitionMapCustomSegmentContext { segment : PartitionMapSegment ; color : string ; value : number ; expanded : boolean ; children : PartitionMapCustomSegmentContext [ ] ; } /** An object of this interface is passed to the announcer function */ export interface PartitionMapSegmentAnnouncementInfo { item : PartitionMapSegment ; value : number ; parents : PartitionMapSegment [ ] ; collapsed : boolean ; selected : boolean ; }