import { MIN_LABEL_WIDTH } from '../constants' ; import { equalArrays , dragAble , dropAble , hasDnDType , IDropResult } from '../internal' ; import { categoryOf } from '../model' ; import { createNestedDesc , createReduceDesc , createStackDesc , IColumnDesc , isArrayColumn , isBoxPlotColumn , isCategoricalColumn , isMapColumn , isNumberColumn , isNumbersColumn , Column , ImpositionCompositeColumn , ImpositionCompositesColumn , createImpositionDesc , createImpositionsDesc , ImpositionBoxPlotColumn , createImpositionBoxPlotDesc , CompositeColumn , IMultiLevelColumn , isMultiLevelColumn , } from '../model' ; import { aria , cssClass , engineCssClass , RESIZE_ANIMATION_DURATION , RESIZE_SPACE } from '../styles' ; import MoreColumnOptionsDialog from './dialogs/MoreColumnOptionsDialog' ; import type { IRankingHeaderContext , IOnClickHandler } from './interfaces' ; import { getToolbar } from './toolbarResolvers' ; import { dialogContext } from './dialogs' ; import { addIconDOM , actionCSSClass , isActionMode , updateIconState } from './headerTooltip' ; export { createToolbarMenuItems , actionCSSClass } from './headerTooltip' ; function setTextOrEmpty ( node , condition , text ) { if ( condition ) { node . innerHTML = '&nbsp;' ; } else { node . textContent = text ; } return node ; } internal export interface IHeaderOptions { dragAble : boolean ; mergeDropAble : boolean ; rearrangeAble : boolean ; resizeable : boolean ; level : number ; extraPrefix : string ; } internal export function createHeader ( col , ctx , options = { } ) { options = Object . assign ( { dragAble : true , mergeDropAble : true , rearrangeAble : true , resizeable : true , level : 0 , extraPrefix : '' , } , options ) ; const node = ctx . document . createElement ( 'section' ) ; const extra = options . extraPrefix ? ( name ) => `${ cssClass ( name ) } ${ cssClass ( `${ options . extraPrefix }-${ name }` ) }` : cssClass ; const summary = col . getMetaData ( ) . summary ; node . innerHTML = `
    <div class="${ extra ( 'label' ) } ${ cssClass ( 'typed-icon' ) }"></div>
    <div class="${ extra ( 'sublabel' ) }"></div>
    <div class="${ extra ( 'toolbar' ) }"></div>
    <div class="${ extra ( 'spacing' ) }"></div>
    <div class="${ extra ( 'handle' ) } ${ cssClass ( 'feature-advanced' ) } ${ cssClass ( 'feature-ui' ) }"></div>
  ` ; setTextOrEmpty ( node . firstElementChild as HTMLElement , col . getWidth ( ) < MIN_LABEL_WIDTH , col . label ) ; setTextOrEmpty ( node . children [ 1 ] as HTMLElement , col . getWidth ( ) < MIN_LABEL_WIDTH || ! summary , summary ) ; createShortcutMenuItems ( node . getElementsByClassName ( cssClass ( 'toolbar' ) ) [ 0 ] ! as HTMLElement , options . level ! , col , ctx , 'header' ) ; toggleToolbarIcons ( node , col ) ; if ( options . dragAble ) { dragAbleColumn ( node , col , ctx ) ; } if ( options . mergeDropAble ) { mergeDropAble ( node , col , ctx ) ; } if ( options . rearrangeAble ) { rearrangeDropAble ( node . getElementsByClassName ( cssClass ( 'handle' ) ) [ 0 ] ! as HTMLElement , col , ctx ) ; } if ( options . resizeable ) { dragWidth ( col , node ) ; } return node ; } internal export function updateHeader ( node , col , minWidth = MIN_LABEL_WIDTH ) { const label = node . getElementsByClassName ( cssClass ( 'label' ) ) [ 0 ] ! as HTMLElement ; setTextOrEmpty ( label , col . getWidth ( ) < minWidth , col . label ) ; const summary = col . getMetaData ( ) . summary ; const subLabel = node . getElementsByClassName ( cssClass ( 'sublabel' ) ) [ 0 ] as HTMLElement ; if ( subLabel ) { setTextOrEmpty ( subLabel , col . getWidth ( ) < minWidth || ! summary , summary ) ; } let title = col . label ; if ( summary ) { title = `${ title }\n${ summary }` ; } if ( col . description ) { title = `${ title }\n${ col . description }` ; } node . title = title ; node . dataset . colId = col . id ; node . dataset . type = col . desc . type ; label . dataset . typeCat = categoryOf ( col ) . name ; updateIconState ( node , col ) ; updateMoreDialogIcons ( node , col ) ; } function updateMoreDialogIcons ( node , col ) { const root = node . closest ( `.${ cssClass ( ) }` ) ; if ( ! root ) { return ; } const dialog = root . querySelector < HTMLElement > ( `.${ cssClass ( 'more-options' ) }[data-col-id="${ col . id }"]` ) ; if ( ! dialog ) { return ; } updateIconState ( dialog , col ) ; } internal export interface IAddIcon { ( title , onClick ) : void ; } internal export function createShortcutMenuItems ( node , level , col , ctx , mode , willAutoHide = true ) { const addIcon = addIconDOM ( node , col , ctx , level , false , mode ) ; const toolbar = getToolbar ( col , ctx ) ; const shortcuts = toolbar . filter ( ( d ) => ! isActionMode ( col , d , mode , 'menu' ) ) ; const hybrids = shortcuts . reduce ( ( a , b ) => a + ( isActionMode ( col , b , mode , 'menu+shortcut' ) ? 1 : 0 ) , 0 ) ; shortcuts . forEach ( addIcon ) ; const moreEntries = toolbar . length - shortcuts . length + hybrids ; if ( shortcuts . length === toolbar . length || ( moreEntries === hybrids && ! willAutoHide ) ) { return ; } node . insertAdjacentHTML ( 'beforeend' , `<i data-a="m" data-m="${ moreEntries }" title="More …" class="${ actionCSSClass ( 'More' ) }">${ aria ( 'More …' ) }</i>` ) ; const i = node . lastElementChild as HTMLElement ; i . onclick = ( evt ) => { evt . stopPropagation ( ) ; ctx . dialogManager . setHighlightColumn ( col ) ; const dialog = new MoreColumnOptionsDialog ( col , dialogContext ( ctx , level , evt ) , mode , ctx ) ; dialog . open ( ) ; } ; } internal function toggleRotatedHeader ( node , col , defaultVisibleClientWidth ) { const label = node . getElementsByClassName ( cssClass ( 'label' ) ) [ 0 ] as HTMLElement ; if ( col . getWidth ( ) < MIN_LABEL_WIDTH ) { label . classList . remove ( `.${ cssClass ( 'rotated' ) }` ) ; return ; } const width = label . clientWidth ; const rotated = width <= 0 ? ( ( col . label . length * defaultVisibleClientWidth ) / 3 ) * 0.6 > col . getWidth ( ) : label . scrollWidth * 0.6 > label . clientWidth ; label . classList . toggle ( `.${ cssClass ( 'rotated' ) }` , rotated ) ; } internal function toggleToolbarIcons ( node , col , defaultVisibleClientWidth = 22.5 ) { toggleRotatedHeader ( node , col , defaultVisibleClientWidth ) ; const toolbar = node . getElementsByClassName ( cssClass ( 'toolbar' ) ) [ 0 ] as HTMLElement ; if ( toolbar . childElementCount === 0 ) { return ; } const availableWidth = col . getWidth ( ) ; const actions = Array . from ( toolbar . children ) . map ( ( d ) => ( { node : d as HTMLElement , width : d . clientWidth > 0 ? d . clientWidth : defaultVisibleClientWidth , } ) ) ; const shortCuts = actions . filter ( ( d ) => d . node . dataset . a === 'o' ) ; const hybrids = actions . filter ( ( d ) => d . node . dataset . a === 's' ) ; const moreIcon = actions . find ( ( d ) => d . node . dataset . a === 'm' ) ; const moreEntries = moreIcon ? Number . parseInt ( moreIcon . node . dataset . m ! , 10 ) : 0 ; const needMore = moreEntries > hybrids . length ; let total = actions . reduce ( ( a , b ) => a + b . width , 0 ) ; for ( const action of actions ) { action . node . classList . remove ( cssClass ( 'hidden' ) ) ; } if ( total < availableWidth ) { return ; } if ( moreIcon && ! needMore && total - moreIcon . width < availableWidth ) { moreIcon . node . classList . add ( cssClass ( 'hidden' ) ) ; return ; } for ( const action of hybrids . reverse ( ) . concat ( shortCuts . reverse ( ) ) ) { action . node . classList . add ( cssClass ( 'hidden' ) ) ; total -= action . width ; if ( total < availableWidth ) { return ; } } } internal export function dragWidth ( col , node ) { let ueberElement ; let sizeHelper ; let currentFooterTransformation = '' ; const handle = node . getElementsByClassName ( cssClass ( 'handle' ) ) [ 0 ] as HTMLElement ; let start = 0 ; let originalWidth = 0 ; const mouseMove = ( evt ) => { evt . stopPropagation ( ) ; evt . preventDefault ( ) ; const end = evt . clientX ; const delta = end - start ; if ( Math . abs ( start - end ) < 2 ) { return ; } start = end ; const width = Math . max ( 0 , col . getWidth ( ) + delta ) ; sizeHelper . classList . toggle ( cssClass ( 'resize-animated' ) , width < originalWidth ) ; sizeHelper . style . transform = `${ currentFooterTransformation } translate(${ width - originalWidth - RESIZE_SPACE }px, 0px)` ; node . style . width = `${ width }px` ; col . setWidth ( width ) ; toggleToolbarIcons ( node , col ) ; } ; const mouseUp = ( evt ) => { evt . stopPropagation ( ) ; evt . preventDefault ( ) ; const end = evt . clientX ; node . classList . remove ( cssClass ( 'change-width' ) ) ; ueberElement . removeEventListener ( 'mousemove' , mouseMove ) ; ueberElement . removeEventListener ( 'mouseup' , mouseUp ) ; ueberElement . removeEventListener ( 'mouseleave' , mouseUp ) ; ueberElement . classList . remove ( cssClass ( 'resizing' ) ) ; node . style . width = null ; setTimeout ( ( ) => { sizeHelper . classList . remove ( cssClass ( 'resizing' ) , cssClass ( 'resize-animated' ) ) ; } , RESIZE_ANIMATION_DURATION * 1.2 ) ; if ( Math . abs ( start - end ) < 2 ) { return ; } const delta = end - start ; const width = Math . max ( 0 , col . getWidth ( ) + delta ) ; col . setWidth ( width ) ; toggleToolbarIcons ( node , col ) ; } ; handle . onmousedown = ( evt ) => { evt . stopPropagation ( ) ; evt . preventDefault ( ) ; node . classList . add ( cssClass ( 'change-width' ) ) ; originalWidth = col . getWidth ( ) ; start = evt . clientX ; ueberElement = node . closest < HTMLElement > ( 'body' ) || node . closest < HTMLElement > ( `.${ cssClass ( ) }` ) ! ; ueberElement . addEventListener ( 'mousemove' , mouseMove ) ; ueberElement . addEventListener ( 'mouseup' , mouseUp ) ; ueberElement . addEventListener ( 'mouseleave' , mouseUp ) ; ueberElement . classList . add ( cssClass ( 'resizing' ) ) ; sizeHelper = node . closest < HTMLElement > ( `.${ engineCssClass ( ) }` ) ! . querySelector < HTMLElement > ( `.${ cssClass ( 'resize-helper' ) }` ) ; currentFooterTransformation = ( sizeHelper . previousElementSibling ! as HTMLElement ) . style . transform ! ; sizeHelper . style . transform = `${ currentFooterTransformation } translate(${ - RESIZE_SPACE }px, 0px)` ; sizeHelper . classList . add ( cssClass ( 'resizing' ) ) ; } ; handle . onclick = ( evt ) => { evt . stopPropagation ( ) ; evt . preventDefault ( ) ; } ; } internal export const MIMETYPE_PREFIX = 'text/x-caleydo-lineup-column' ; internal export function dragAbleColumn ( node , column , ctx ) { dragAble ( node , ( ) => { const ref = JSON . stringify ( ctx . provider . toDescRef ( column . desc ) ) ; const data = { 'text/plain' : column . label , [ `${ MIMETYPE_PREFIX }-ref` ] : column . id , [ MIMETYPE_PREFIX ] : ref , } ; if ( isNumberColumn ( column ) ) { data [ `${ MIMETYPE_PREFIX }-number` ] = ref ; data [ `${ MIMETYPE_PREFIX }-number-ref` ] = column . id ; } if ( isCategoricalColumn ( column ) ) { data [ `${ MIMETYPE_PREFIX }-categorical` ] = ref ; data [ `${ MIMETYPE_PREFIX }-categorical-ref` ] = column . id ; } if ( isBoxPlotColumn ( column ) ) { data [ `${ MIMETYPE_PREFIX }-boxplot` ] = ref ; data [ `${ MIMETYPE_PREFIX }-boxplot-ref` ] = column . id ; } if ( isMapColumn ( column ) ) { data [ `${ MIMETYPE_PREFIX }-map` ] = ref ; data [ `${ MIMETYPE_PREFIX }-map-ref` ] = column . id ; } if ( isArrayColumn ( column ) ) { data [ `${ MIMETYPE_PREFIX }-array` ] = ref ; data [ `${ MIMETYPE_PREFIX }-array-ref` ] = column . id ; } if ( isNumbersColumn ( column ) ) { data [ `${ MIMETYPE_PREFIX }-numbers` ] = ref ; data [ `${ MIMETYPE_PREFIX }-numbers-ref` ] = column . id ; } return { effectAllowed : 'copyMove' , data , } ; } , true ) ; } internal export function rearrangeDropAble ( node , column , ctx ) { dropAble ( node , [ `${ MIMETYPE_PREFIX }-ref` , MIMETYPE_PREFIX ] , ( result ) => { let col = null ; const data = result . data ; if ( ! ( `${ MIMETYPE_PREFIX }-ref` in data ) ) { const desc = JSON . parse ( data [ MIMETYPE_PREFIX ] ) ; col = ctx . provider . create ( ctx . provider . fromDescRef ( desc ) ) ; return col != null && column . insertAfterMe ( col ) != null ; } const id = data [ `${ MIMETYPE_PREFIX }-ref` ] ; col = ctx . provider . find ( id ) ; if ( ! col || ( col === column && ! result . effect . startsWith ( 'copy' ) ) ) { return false ; } if ( result . effect . startsWith ( 'copy' ) ) { col = ctx . provider . clone ( col ! ) ; return col != null && column . insertAfterMe ( col ) != null ; } const toInsertParent = col . parent ; if ( ! toInsertParent ) { return column . insertAfterMe ( col ) != null ; } if ( toInsertParent === column . parent ) { return toInsertParent . moveAfter ( col , column ) != null ; } col . removeMe ( ) ; return column . insertAfterMe ( col ) != null ; } , null , true ) ; } internal export function resortDropAble ( node , column , ctx , where , autoGroup ) { dropAble ( node , [ `${ MIMETYPE_PREFIX }-ref` , MIMETYPE_PREFIX ] , ( result ) => { let col = null ; const data = result . data ; if ( `${ MIMETYPE_PREFIX }-ref` in data ) { const id = data [ `${ MIMETYPE_PREFIX }-ref` ] ; col = ctx . provider . find ( id ) ; if ( ! col || col === column ) { return false ; } } else { const desc = JSON . parse ( data [ MIMETYPE_PREFIX ] ) ; col = ctx . provider . create ( ctx . provider . fromDescRef ( desc ) ) ; if ( col ) { column . findMyRanker ( ) ! . push ( col ) ; } } const ranking = column . findMyRanker ( ) ! ; if ( ! col || col === column || ! ranking ) { return false ; } const criteria = ranking . getSortCriteria ( ) ; const groups = ranking . getGroupCriteria ( ) ; const removeFromSort = ( col ) => { const existing = criteria . findIndex ( ( d ) => d . col === col ) ; if ( existing >= 0 ) { return criteria . splice ( existing , 1 ) [ 0 ] . asc ; } return false ; } ; const asc = removeFromSort ( col ) ; const groupIndex = groups . indexOf ( column ) ; const index = criteria . findIndex ( ( d ) => d . col === column ) ; if ( autoGroup && groupIndex >= 0 ) { removeFromSort ( column ) ; if ( isCategoricalColumn ( col ) ) { groups . splice ( groupIndex + ( where === 'after' ? 1 : 0 ) , 0 , col ) ; } else { const removed = groups . splice ( 0 , groups . length - groupIndex ) ; criteria . unshift ( ... removed . reverse ( ) . map ( ( d ) => ( { asc : false , col : d } ) ) ) ; criteria . unshift ( { asc , col } ) ; } } else if ( index < 0 ) { criteria . push ( { asc , col } ) ; } else if ( index === 0 && autoGroup && isCategoricalColumn ( col ) ) { groups . push ( col ) ; } else { criteria . splice ( index + ( where === 'after' ? 1 : 0 ) , 0 , { asc , col } ) ; } if ( ! equalArrays ( groups , ranking . getGroupCriteria ( ) ) ) { ranking . setGroupCriteria ( groups ) ; } ranking . setSortCriteria ( criteria ) ; return true ; } , null , true ) ; } internal export function mergeDropAble ( node , column , ctx ) { const resolveDrop = ( result ) => { const data = result . data ; const copy = result . effect === 'copy' ; const prefix = MIMETYPE_PREFIX ; const key = Object . keys ( data ) . find ( ( d ) => d . startsWith ( prefix ) && d . endsWith ( '-ref' ) ) ; if ( key ) { const id = data [ key ] ; let col = ctx . provider . find ( id ) ! ; if ( copy ) { col = ctx . provider . clone ( col ) ; } else if ( col === column ) { return null ; } else { col . removeMe ( ) ; } return col ; } const alternative = Object . keys ( data ) . find ( ( d ) => d . startsWith ( prefix ) ) ; if ( ! alternative ) { return null ; } const desc = JSON . parse ( alternative ) ; return ctx . provider . create ( ctx . provider . fromDescRef ( desc ) ) ! ; } ; const pushChild = ( result ) => { const col = resolveDrop ( result ) ; return col != null && ( column as CompositeColumn ) . push ( col ) != null ; } ; const mergeImpl = ( col , desc ) => { if ( col == null ) { return false ; } const ranking = column . findMyRanker ( ) ! ; const index = ranking . indexOf ( column ) ; const parent = ctx . provider . create ( desc ) as CompositeColumn ; column . removeMe ( ) ; parent . push ( column ) ; parent . push ( col ) ; return ranking . insert ( parent , index ) != null ; } ; const mergeWith = ( desc ) => ( result ) => { const col = resolveDrop ( result ) ; return mergeImpl ( col , desc ) ; } ; const all = [ `${ MIMETYPE_PREFIX }-ref` , MIMETYPE_PREFIX ] ; const numberish = [ `${ MIMETYPE_PREFIX }-number-ref` , `${ MIMETYPE_PREFIX }-number` ] ; const categorical = [ `${ MIMETYPE_PREFIX }-categorical-ref` , `${ MIMETYPE_PREFIX }-categorical` ] ; const boxplot = [ `${ MIMETYPE_PREFIX }-boxplot-ref` , `${ MIMETYPE_PREFIX }-boxplot` ] ; const numbers = [ `${ MIMETYPE_PREFIX }-numbers-ref` , `${ MIMETYPE_PREFIX }-numbers` ] ; node . dataset . draginfo = '+' ; if ( column instanceof ImpositionCompositeColumn ) { return dropAble ( node , categorical . concat ( numberish ) , pushChild , ( e ) => { if ( hasDnDType ( e , ... categorical ) ) { node . dataset . draginfo = 'Color by' ; return ; } if ( hasDnDType ( e , ... numberish ) ) { node . dataset . draginfo = 'Wrap' ; } } , false , ( ) => column . children . length < 2 ) ; } if ( column instanceof ImpositionBoxPlotColumn ) { return dropAble ( node , categorical . concat ( boxplot ) , pushChild , ( e ) => { if ( hasDnDType ( e , ... categorical ) ) { node . dataset . draginfo = 'Color by' ; return ; } if ( hasDnDType ( e , ... boxplot ) ) { node . dataset . draginfo = 'Wrap' ; } } , false , ( ) => column . children . length < 2 ) ; } if ( column instanceof ImpositionCompositesColumn ) { return dropAble ( node , categorical . concat ( numbers ) , pushChild , ( e ) => { if ( hasDnDType ( e , ... categorical ) ) { node . dataset . draginfo = 'Color by' ; return ; } if ( hasDnDType ( e , ... numbers ) ) { node . dataset . draginfo = 'Wrap' ; } } , false , ( ) => column . children . length < 2 ) ; } if ( isMultiLevelColumn ( column ) ) { return dropAble ( node , ( column as IMultiLevelColumn ) . canJustAddNumbers ? numberish : all , pushChild ) ; } if ( column instanceof CompositeColumn ) { return dropAble ( node , ( column as CompositeColumn ) . canJustAddNumbers ? numberish : all , pushChild ) ; } if ( isNumbersColumn ( column ) ) { node . dataset . draginfo = 'Color by' ; return dropAble ( node , categorical , mergeWith ( createImpositionsDesc ( ) ) ) ; } if ( isBoxPlotColumn ( column ) ) { node . dataset . draginfo = 'Color by' ; return dropAble ( node , categorical , mergeWith ( createImpositionBoxPlotDesc ( ) ) ) ; } if ( isNumberColumn ( column ) ) { node . dataset . draginfo = 'Merge' ; return dropAble ( node , categorical . concat ( numberish ) , ( result , evt ) => { const col = resolveDrop ( result ) ; if ( col == null ) { return false ; } if ( isCategoricalColumn ( col ) ) { return mergeImpl ( col , createImpositionDesc ( ) ) ; } if ( isNumberColumn ( col ) ) { return mergeImpl ( col , evt . shiftKey ? createReduceDesc ( ) : createStackDesc ( ) ) ; } return false ; } , ( e ) => { if ( hasDnDType ( e , ... categorical ) ) { node . dataset . draginfo = 'Color by' ; return ; } if ( hasDnDType ( e , ... numberish ) ) { node . dataset . draginfo = e . shiftKey ? 'Min/Max' : 'Sum' ; } } ) ; } node . dataset . draginfo = 'Group' ; return dropAble ( node , all , mergeWith ( createNestedDesc ( ) ) ) ; }