import * as d3 from "d3" ; import { Axis , DataView , DataViewHierarchyNode , DataViewRow , Mod , ModProperty , Size } from "spotfire-api" ; import { generalErrorHandler } from "./generalErrorHandler" ; import { interactionLock } from "./interactionLock" ; import { resources } from "./resources" ; import { render , SunBurstHierarchyNode , SunBurstSettings } from "./sunburst" ; const hierarchyAxisName = "Hierarchy" ; const colorAxisName = "Color" ; const sizeAxisName = "Size" ; enum InteractionMode { drilldown = "drilldown" , mark = "mark" } window . Spotfire . initialize ( async ( mod ) => { const context = mod . getRenderContext ( ) ; const reader = mod . createReader ( mod . visualization . data ( ) , mod . windowSize ( ) , mod . visualization . axis ( hierarchyAxisName ) , mod . visualization . axis ( colorAxisName ) , mod . visualization . axis ( sizeAxisName ) , mod . property < string > ( "labels" ) , mod . property < boolean > ( "showNullValues" ) , mod . property < string > ( "interactionMode" ) , mod . property < string > ( "rootNode" ) , mod . property < boolean > ( "sortByValue" ) ) ; let interaction = interactionLock ( ) ; reader . subscribe ( generalErrorHandler ( mod , 10000 , interaction ) ( onChange ) ) ; let totalSize = 0 ; function findNode ( jsonPath , rootNode ) { if ( jsonPath == null || rootNode == null ) { return undefined ; } let path = jsonToPath ( jsonPath ) ; let node = rootNode ; if ( path . length == 0 ) { return undefined ; } path . forEach ( ( key ) => { node = node ?. children ?. find ( ( c ) => c . key == key ) ; } ) ; return node ; } async function onChange ( dataView , windowSize , hierarchyAxis , colorAxis , sizeAxis , labels , showNullValues , interactionMode , rootNodePath , sortByValue ) { const hasSizeExpression = ! ! sizeAxis . parts . length ; const hasHierarchyExpression = ! ! hierarchyAxis . parts . length ; const hasColorHierarchy = ! ! colorAxis . parts . length && colorAxis . isCategorical ; let hierarchyRoot = await ( await dataView . hierarchy ( hierarchyAxisName ) ) ! . root ( ) ; let rootNode = findNode ( rootNodePath . value ( ) , hierarchyRoot ) ; if ( ! rootNode ) { if ( hasHierarchyExpression && hierarchyRoot ) { rootNode = hierarchyRoot ; } else { rootNode = ( await dataView . allRows ( ) ) ?. [ 0 ] ?. leafNode ( hierarchyAxisName ) as DataViewHierarchyNode ; } } const plotWarnings = validateDataView ( rootNode , ! showNullValues . value ( ) && hasHierarchyExpression , hasSizeExpression ) ; const coloringFromLevel = getColoringStartLevel ( rootNode , colorAxis , hierarchyAxis , plotWarnings ) ; const getAbsSize = ( r ) => hasSizeExpression ? Math . abs ( r . continuous ( sizeAxisName ) . value < number > ( ) || 0 ) : 1 ; const getRealSize = ( r ) => hasSizeExpression ? r . continuous ( sizeAxisName ) . value < number > ( ) || 0 : 1 ; let hierarchy = d3 . hierarchy < SunBurstHierarchyNode > ( rootNode , flattenColorsIfColorSplits ) . sum ( ( d ) => ! d . children && ! d . hasVirtualChildren ? d ! . rows ( ) . reduce ( ( p , c ) => p + getAbsSize ( c ) , 0 ) : 0 ) ; if ( sortByValue . value ( ) ) { hierarchy . sort ( ( a , b ) => ( b . value || 0 ) - ( a . value || 0 ) ) ; } hierarchy . eachAfter ( ( n ) => { var d = n . data ; d . actualValue = d ! . rows ( ) . reduce ( ( p , c ) => p + getRealSize ( c ) , 0 ) ; n . data . fill = getFillColor ( n ) ; } ) ; totalSize = hierarchy . value || 0 ; const breadcrumbs = jsonToPath ( rootNodePath . value ( ) || "{}" ) ; if ( breadcrumbs . length ) { breadcrumbs . unshift ( resources . breadCrumbRoot ) ; } const settings = { animationSpeed : context . interactive ? 250 : 0 , breadcrumbs : breadcrumbs , breadCrumbClick ( i ) { const p = breadcrumbs . slice ( 0 , i + 1 ) ; p . shift ( ) ; rootNodePath . set ( JSON . stringify ( { path : p } ) ) ; } , containerSelector : "#mod-container" , size : windowSize , clearMarking : ( ) => { if ( currentInteractionMode ( ) == InteractionMode . drilldown ) { var currentPath = rootNodePath . value ( ) ; if ( currentPath ) { let json = JSON . parse ( currentPath ) as { path : ( string | null ) [ ] ; } ; json . path . pop ( ) ; rootNodePath . set ( JSON . stringify ( json ) ) ; } } else dataView . clearMarking ( ) ; } , mark ( node ) { if ( d3 . event . ctrlKey ) { node . mark ( "ToggleOrAdd" ) ; } else { node . mark ( ) ; } } , click ( node ) { if ( currentInteractionMode ( ) == InteractionMode . drilldown ) { if ( node . children && node . children . length ) { rootNodePath . set ( JSON . stringify ( { path : getPathToNode ( node ) } ) ) ; } } else { if ( d3 . event . ctrlKey ) { node . mark ( "ToggleOrAdd" ) ; } else { node . mark ( ) ; } } } , getId ( node ) { function hash ( s ) { return s . split ( "" ) . reduce ( function ( a , b ) { a = ( a << 5 ) - a + b . charCodeAt ( 0 ) ; return a & a ; } , 0 ) ; } return hash ( pathToJson ( getPathToNode ( node ) ) ) . toString ( ) ; } , getLabel ( node , availablePixels ) { if ( labels . value ( ) == "off" ) { return "" ; } if ( labels . value ( ) == "marked" && node . markedRowCount ( ) == 0 ) { return "" ; } const fontSize = context . styling . general . font . fontSize ; const fontWidth = fontSize * 0.7 ; let label = node . formattedValue ( ) ; if ( label . length > availablePixels / fontWidth ) { return label . slice ( 0 , Math . max ( 1 , availablePixels / fontWidth - 2 ) ) + "…" ; } return label ; } , style : { marking : { color : context . styling . scales . font . color } , background : { color : context . styling . general . backgroundColor } , label : { fontFamily : context . styling . general . font . fontFamily , color : context . styling . general . font . color , size : parseInt ( "" + context . styling . general . font . fontSize ) , style : context . styling . general . font . fontStyle , weight : context . styling . general . font . fontWeight } } , getCenterText ( node ) { let percentage = 0 ; try { percentage = ( 100 * node . rows ( ) . reduce ( ( p , r ) => p + getAbsSize ( r ) , 0 ) ) / totalSize ; } catch ( _ ) { } let percentageString = percentage . toPrecision ( 3 ) + "%" ; if ( percentage < 0.1 ) { percentageString = "< 0.1%" ; } return { value : percentageString , text : node . formattedPath ( ) } ; } , onMouseover ( node ) { mod . controls . tooltip . show ( node . formattedPath ( ) ) ; } , onMouseLeave : mod . controls . tooltip . hide } ; render ( hierarchy , settings ) ; renderSettingsButton ( mod , labels , showNullValues , interactionMode , sortByValue ) ; renderWarningsIcon ( mod , plotWarnings ) ; context . signalRenderComplete ( ) ; function currentInteractionMode ( ) { if ( interactionMode . value ( ) == InteractionMode . drilldown ) { return d3 . event . altKey ? InteractionMode . mark : InteractionMode . drilldown ; } return d3 . event . altKey ? InteractionMode . drilldown : InteractionMode . mark ; } function flattenColorsIfColorSplits ( node ) { if ( ! hasHierarchyExpression && hasColorHierarchy && ! node . virtualLeaf ) { const fakeNodes = node . rows ( ) . map ( ( r ) => rowToSunBurstHierarchyLeaf ( r , node ) ) ; node . hasVirtualChildren = true ; return fakeNodes ; } return node . level == hierarchyAxis . parts . length - 2 && hasColorHierarchy ? node . children ?. flatMap ( ( child ) => child . rows ( ) . map ( ( r ) => rowToSunBurstHierarchyLeaf ( r , child ) ) ) : node . children ; function rowToSunBurstHierarchyLeaf ( r , child ) { return { formattedPath : ( ) => child . formattedPath ( ) , formattedValue : ( ) => child . formattedValue ( ) , key : r . elementId ( ) , leafCount : ( ) => 1 , level : node . level + 1 , mark : ( operation ? ) => r . mark ( operation ) , markedRowCount : ( ) => ( r . isMarked ( ) ? 1 : 0 ) , parent : node , rows : ( ) => [ r ] , virtualLeaf : true } as SunBurstHierarchyNode ; } } function getFillColor ( d3Node ) { let node = d3Node . data ; let rows = node . rows ( ) ; if ( ! rows . length ) { return "transparent" ; } if ( ! hasHierarchyExpression ) { return node . rows ( ) [ 0 ] . color ( ) . hexCode ; } if ( ! showNullValues . value ( ) && rows [ 0 ] . categorical ( hierarchyAxisName ) . value ( ) . slice ( 0 , node . level + 1 ) . find ( ( pathElement ) => pathElement . value ( ) == null ) ) { return "transparent" ; } const hasMarking = rootNode ! . rows ( ) . findIndex ( ( r ) => r . isMarked ( ) ) >= 0 ; if ( node . level + 1 <= coloringFromLevel ) { if ( node . rows ( ) . findIndex ( ( r ) => r . isMarked ( ) ) >= 0 || ! hasMarking ) { return "rgb(208,208,208)" ; } return "rgba(208,208,208, 0.2)" ; } if ( hasMarking ) { let firstMarkedInTree = findFirstRow ( d3Node , ( r ) => r . isMarked ( ) ) ; if ( firstMarkedInTree ) { return firstMarkedInTree . color ( ) . hexCode ; } } let colorAsNode = d3Node ; while ( colorAsNode && colorAsNode . children ) { colorAsNode = colorAsNode . children . find ( ( n ) => n . data . rows ( ) . length > 0 ) ; } if ( ! colorAsNode ) { colorAsNode = d3Node ; } let firstMarkedRow = colorAsNode . data . rows ( ) . findIndex ( ( r ) => r . isMarked ( ) ) ; if ( firstMarkedRow == - 1 ) { firstMarkedRow = 0 ; } return colorAsNode . data . rows ( ) [ firstMarkedRow ] . color ( ) . hexCode ; } } } ) ; function findFirstRow ( node , predicate ) { if ( ! node . children ) { return node . data . rows ( ) . find ( predicate ) ; } let found ; node . children . every ( ( c ) => { found = findFirstRow ( c , predicate ) ; return ! found ; } ) ; return found ; } function jsonToPath ( str ) { let json = JSON . parse ( str ) as { path : ( string | null ) [ ] ; } ; return json . path || [ ] ; } function pathToJson ( path ) { return JSON . stringify ( { path } ) ; } function getPathToNode ( d ) { let path = [ d . key ] ; let node = d . parent as SunBurstHierarchyNode | undefined ; while ( node && node . level >= 0 ) { path . push ( node . key ) ; node = node . parent ; } return path . reverse ( ) ; } param rootNode param warnings function validateDataView ( rootNode , validateHierarchy , validataSize ) { let warnings = [ ] ; let issues = 0 ; let rows = rootNode . rows ( ) ; if ( validateHierarchy ) { rowLoop : for ( let row of rows ) { let path = row . categorical ( hierarchyAxisName ) . value ( ) . slice ( ) ; let length = path . length ; let currentIndex = length - 1 ; while ( currentIndex >= 1 ) { if ( path [ currentIndex ] . value ( ) && ! path [ currentIndex - 1 ] . value ( ) ) { issues ++ ; warnings . push ( resources . warningEmptyPath ( row . categorical ( hierarchyAxisName ) . formattedValue ( ) ) ) ; if ( issues == 3 ) { break rowLoop ; } break ; } currentIndex -- ; } } } if ( validataSize ) { rowLoop : for ( let row of rows ) { const size = row . continuous ( sizeAxisName ) . value ( ) ; if ( typeof size == "number" && size < 0 ) { warnings . push ( resources . warningNegativeValues ) ; break rowLoop ; } } } return warnings ; } param colorAxis param hierarchyAxis returns function getColoringStartLevel ( rootNode , colorAxis , hierarchyAxis , warnings ) { let coloringStartLevel = 0 ; if ( ! colorAxis . parts . length || ! colorAxis . isCategorical ) { return 0 ; } const uniqueCount = ( arr ? ) => arr ?. filter ( ( item , i , a ) => a . indexOf ( item ) === i ) . length || 0 ; const firstLevelWithUniqueColoring = ( node ) : number => { if ( ! node . children ) { return node . level + 1 ; } if ( Math . max ( ... node . children . map ( ( child ) => child . rows ( ) . map ( ( r ) => r . categorical ( colorAxisName ) . leafIndex ) ) . map ( uniqueCount ) ) <= 1 ) { return node . level + 1 ; } return Math . max ( ... node . children . map ( firstLevelWithUniqueColoring ) ) ; } ; coloringStartLevel = firstLevelWithUniqueColoring ( rootNode ) ; if ( coloringStartLevel == hierarchyAxis . parts . length ) { coloringStartLevel = hierarchyAxis . parts . length - 1 ; warnings . push ( resources . warningSplittingColorAxis ) ; } return coloringStartLevel ; } function renderSettingsButton ( mod , labels , showNullValues , interactionMode , sortByValue ) { let settingsButton = document . querySelector < HTMLElement > ( ".settings" ) ; settingsButton ?. classList . toggle ( "visible" , mod . getRenderContext ( ) . isEditing ) ; let pos = settingsButton ! . getBoundingClientRect ( ) ; settingsButton ! . onclick = ( ) => { mod . controls . popout . show ( { x : pos . left + pos . width / 2 , y : pos . top + pos . height , autoClose : true , alignment : "Top" , onChange ( event ) { if ( event . name == "labels" ) { labels . set ( event . value ) ; } if ( event . name == "showNullValues" ) { showNullValues . set ( event . value ) ; } if ( event . name == "interactionMode" ) { interactionMode . set ( event . value ) ; } if ( event . name == "sortByValue" ) { sortByValue . set ( event . value ) ; } } } , ( ) => [ mod . controls . popout . section ( { heading : resources . popoutLabelsHeading , children : [ mod . controls . popout . components . radioButton ( { enabled : true , name : "labels" , value : "all" , checked : labels . value ( ) == "all" , text : resources . popoutLabelsAll } ) , mod . controls . popout . components . radioButton ( { enabled : true , name : "labels" , value : "marked" , checked : labels . value ( ) == "marked" , text : resources . popoutLabelsMarked } ) , mod . controls . popout . components . radioButton ( { enabled : true , value : "off" , name : "labels" , checked : labels . value ( ) == "off" , text : resources . popoutLabelsNone } ) ] } ) , mod . controls . popout . section ( { heading : resources . popoutInteractionModeHeading , children : [ mod . controls . popout . components . radioButton ( { enabled : true , name : "interactionMode" , value : InteractionMode . mark , checked : interactionMode . value ( ) != InteractionMode . drilldown , tooltip : resources . popoutInteractionModeMarkTooltip , text : resources . popoutInteractionModeMark } ) , mod . controls . popout . components . radioButton ( { enabled : true , name : "interactionMode" , value : InteractionMode . drilldown , checked : interactionMode . value ( ) == InteractionMode . drilldown , tooltip : resources . popoutInteractionModeDrillDownTooltip , text : resources . popoutInteractionModeDrillDown } ) ] } ) , mod . controls . popout . section ( { children : [ mod . controls . popout . components . checkbox ( { enabled : true , name : "showNullValues" , checked : showNullValues . value ( ) || false , text : resources . popoutShowNullValues } ) ] } ) , mod . controls . popout . section ( { children : [ mod . controls . popout . components . checkbox ( { enabled : true , name : "sortByValue" , checked : sortByValue . value ( ) || false , text : resources . popoutSortByValue } ) ] } ) ] ) ; } ; } function renderWarningsIcon ( mod , warnings ) { let warningButton = document . querySelector < HTMLElement > ( ".warnings" ) ; warningButton ?. classList . toggle ( "visible" , mod . getRenderContext ( ) . interactive && ! ! warnings . length ) ; warningButton ! . onmouseenter = ( ) => { mod . controls . tooltip . show ( resources . warningsTooltip ( warnings ) ) ; } ; warningButton ! . onmouseleave = ( ) => { mod . controls . tooltip . hide ( ) ; } ; document . body . onclick = ( ) => mod . controls . tooltip . hide ( ) ; }