import './style.scss' ; import { Participant , Match , ParticipantResult , Stage , Status } from 'brackets-model' ; import { splitBy , getRanking , getOriginAbbreviation , findRoot , completeWithBlankMatches } from './helpers' ; import * as dom from './dom' ; import * as lang from './lang' ; import { Locale } from './lang' ; import { Config , Connection , FinalType , BracketType , OriginHint , ParticipantContainers , RankingItem , RoundName , ViewerData , ParticipantImage , Side , } from './types' ; export class BracketsViewer { readonly participantRefs : Record < number , HTMLElement [ ] > = { } ; private participants : Participant [ ] = [ ] ; private participantImages : ParticipantImage [ ] = [ ] ; private stage ! : Stage ; private config ! : Config ; private skipFirstRound = false ; private alwaysConnectFirstRound = false ; public onMatchClicked = ( _ ) : void => { } ; param data param config public async render ( data , config ? ) { const root = document . createDocumentFragment ( ) ; this . config = { participantOriginPlacement : config ?. participantOriginPlacement || 'before' , separatedChildCountLabel : config ?. separatedChildCountLabel !== undefined ? config . separatedChildCountLabel : false , showSlotsOrigin : config ?. showSlotsOrigin !== undefined ? config . showSlotsOrigin : true , showLowerBracketSlotsOrigin : config ?. showLowerBracketSlotsOrigin !== undefined ? config . showLowerBracketSlotsOrigin : true , highlightParticipantOnHover : config ?. highlightParticipantOnHover !== undefined ? config . highlightParticipantOnHover : true , } ; this . participants = data . participants ; data . participants . forEach ( participant => this . participantRefs [ participant . id ] = [ ] ) ; data . stages . forEach ( stage => this . renderStage ( root , { ... data , stages : [ stage ] , matches : data . matches . filter ( match => match . stage_id === stage . id ) , } ) ) ; findRoot ( config ?. selector ) . append ( root ) ; } param match public updateMatch ( match ) { const matchContainer = document . querySelector ( `[data-match-id='${ match . id }']` ) ; if ( ! matchContainer ) throw Error ( 'Match not found.' ) ; matchContainer . setAttribute ( 'data-match-status' , match . status . toString ( ) ) ; const result1 = matchContainer . querySelector ( '.participant:nth-of-type(1) .result' ) ; if ( result1 && match . opponent1 ?. score ) result1 . innerHTML = match . opponent1 ?. score ?. toString ( ) ; const result2 = matchContainer . querySelector ( '.participant:nth-of-type(2) .result' ) ; if ( result2 && match . opponent2 ?. score ) result2 . innerHTML = match . opponent2 ?. score ?. toString ( ) ; } param images public setParticipantImages ( images ) { this . participantImages = images ; } param name param locale public addLocale ( name , locale ) { lang . addLocale ( name , locale ) ; } param root param data private renderStage ( root , data ) { const stage = data . stages [ 0 ] ; const matchesByGroup = splitBy ( data . matches , 'group_id' ) ; this . stage = stage ; this . skipFirstRound = stage . settings . skipFirstRound || false ; switch ( stage . type ) { case 'round_robin' : this . renderRoundRobin ( root , stage , matchesByGroup ) ; break ; case 'single_elimination' : case 'double_elimination' : this . renderElimination ( root , stage , matchesByGroup ) ; break ; default : throw Error ( `Unknown bracket type: ${ stage . type }` ) ; } } param root param stage param matchesByGroup private renderRoundRobin ( root , stage , matchesByGroup ) { const container = dom . createRoundRobinContainer ( stage . id ) ; container . append ( dom . createTitle ( stage . name ) ) ; let groupNumber = 1 ; for ( const groupMatches of matchesByGroup ) { const groupId = groupMatches [ 0 ] . group_id ; const groupContainer = dom . createGroupContainer ( groupId , lang . getGroupName ( groupNumber ++ ) ) ; const matchesByRound = splitBy ( groupMatches , 'round_id' ) ; let roundNumber = 1 ; for ( const roundMatches of matchesByRound ) { const roundId = roundMatches [ 0 ] . round_id ; const roundContainer = dom . createRoundContainer ( roundId , lang . getRoundName ( roundNumber ++ , 0 ) ) ; for ( const match of roundMatches ) roundContainer . append ( this . createMatch ( match ) ) ; groupContainer . append ( roundContainer ) ; } groupContainer . append ( this . createRanking ( groupMatches ) ) ; container . append ( groupContainer ) ; } root . append ( container ) ; } param root param stage param matchesByGroup private renderElimination ( root , stage , matchesByGroup ) { const container = dom . createEliminationContainer ( stage . id ) ; container . append ( dom . createTitle ( stage . name ) ) ; if ( stage . type === 'single_elimination' ) this . renderSingleElimination ( container , matchesByGroup ) ; else this . renderDoubleElimination ( container , matchesByGroup ) ; root . append ( container ) ; } param container param matchesByGroup private renderSingleElimination ( container , matchesByGroup ) { const hasFinal = matchesByGroup [ 1 ] !== undefined ; this . renderBracket ( container , splitBy ( matchesByGroup [ 0 ] , 'round_id' ) , lang . getRoundName , 'single-bracket' ) ; if ( hasFinal ) this . renderFinal ( container , 'consolation_final' , matchesByGroup [ 1 ] ) ; } param container param matchesByGroup private renderDoubleElimination ( container , matchesByGroup ) { const hasLoserBracket = matchesByGroup [ 1 ] !== undefined ; const hasFinal = matchesByGroup [ 2 ] !== undefined ; this . renderBracket ( container , splitBy ( matchesByGroup [ 0 ] , 'round_id' ) , lang . getWinnerBracketRoundName , 'winner-bracket' , hasFinal ) ; if ( hasLoserBracket ) this . renderBracket ( container , splitBy ( matchesByGroup [ 1 ] , 'round_id' ) , lang . getLoserBracketRoundName , 'loser-bracket' ) ; if ( hasFinal ) this . renderFinal ( container , 'grand_final' , matchesByGroup [ 2 ] ) ; } param container param matchesByRound param roundName param bracketType param connectFinal private renderBracket ( container , matchesByRound , roundName , bracketType , connectFinal ? ) { const groupId = matchesByRound [ 0 ] [ 0 ] . group_id ; const roundCount = matchesByRound . length ; const bracketContainer = dom . createBracketContainer ( groupId , lang . getBracketName ( this . stage , bracketType ) ) ; const roundsContainer = dom . createRoundsContainer ( ) ; const { matches : completedMatches , fromToornament } = completeWithBlankMatches ( bracketType , matchesByRound [ 0 ] , matchesByRound [ 1 ] ) ; this . alwaysConnectFirstRound = ! fromToornament ; for ( let roundIndex = 0 ; roundIndex < matchesByRound . length ; roundIndex ++ ) { const roundId = matchesByRound [ roundIndex ] [ 0 ] . round_id ; const roundNumber = roundIndex + 1 ; const roundContainer = dom . createRoundContainer ( roundId , roundName ( roundNumber , roundCount ) ) ; const roundMatches = fromToornament && roundNumber === 1 ? completedMatches : matchesByRound [ roundIndex ] ; for ( const match of roundMatches ) roundContainer . append ( match && this . createBracketMatch ( roundNumber , roundCount , match , bracketType , connectFinal ) || this . skipBracketMatch ( ) ) ; roundsContainer . append ( roundContainer ) ; } bracketContainer . append ( roundsContainer ) ; container . append ( bracketContainer ) ; } param container param finalType param matches private renderFinal ( container , finalType , matches ) { const upperBracket = container . querySelector ( '.bracket .rounds' ) ; if ( ! upperBracket ) throw Error ( 'Upper bracket not found.' ) ; const winnerWb = matches [ 0 ] . opponent1 ; const displayCount = winnerWb ?. id === null || winnerWb ?. result === 'win' ? 1 : 2 ; const finalMatches = matches . slice ( 0 , displayCount ) ; const roundCount = matches . length ; for ( let roundIndex = 0 ; roundIndex < finalMatches . length ; roundIndex ++ ) { const roundNumber = roundIndex + 1 ; const roundContainer = dom . createRoundContainer ( finalMatches [ roundIndex ] . round_id , lang . getFinalMatchLabel ( finalType , roundNumber , roundCount ) ) ; roundContainer . append ( this . createFinalMatch ( finalType , finalMatches , roundNumber , roundCount ) ) ; upperBracket . append ( roundContainer ) ; } } param matches private createRanking ( matches ) { const table = dom . createTable ( ) ; const ranking = getRanking ( matches ) ; table . append ( dom . createRankingHeaders ( ranking ) ) ; for ( const item of ranking ) table . append ( this . createRankingRow ( item ) ) ; return table ; } param item private createRankingRow ( item ) { const row = dom . createRow ( ) ; const notRanked = item . played === 0 ; for ( const key in item ) { const prop = key as keyof RankingItem ; const data = item [ prop ] ; if ( prop === 'id' ) { const participant = this . participants . find ( participant => participant . id === data ) ; if ( participant !== undefined ) { const cell = dom . createCell ( participant . name ) ; this . setupMouseHover ( participant . id , cell ) ; row . append ( cell ) ; continue ; } } if ( notRanked && ( prop === 'rank' || prop === 'points' ) ) { row . append ( dom . createCell ( '-' ) ) ; continue ; } row . append ( dom . createCell ( data ) ) ; } return row ; } param roundNumber param roundCount param match param matchLocation param connectFinal private createBracketMatch ( roundNumber , roundCount , match , matchLocation , connectFinal ? ) { const connection = dom . getBracketConnection ( this . alwaysConnectFirstRound , roundNumber , roundCount , match , matchLocation , connectFinal ) ; const matchLabel = lang . getMatchLabel ( match . number , roundNumber , roundCount , matchLocation ) ; const originHint = lang . getOriginHint ( roundNumber , roundCount , this . skipFirstRound , matchLocation ) ; return this . createMatch ( match , matchLocation , connection , matchLabel , originHint , roundNumber ) ; } param type param matches param roundNumber param roundCount private createFinalMatch ( type , matches , roundNumber , roundCount ) { const roundIndex = roundNumber - 1 ; const connection = dom . getFinalConnection ( type , roundNumber , matches . length ) ; const matchLabel = lang . getFinalMatchLabel ( type , roundNumber , roundCount ) ; const originHint = lang . getFinalOriginHint ( type , roundNumber ) ; return this . createMatch ( matches [ roundIndex ] , 'final-group' , connection , matchLabel , originHint ) ; } /**
     * Creates a hidden empty match to act as a placeholder.
     */ private skipBracketMatch ( ) { const matchContainer = dom . createMatchContainer ( ) ; const opponents = dom . createOpponentsContainer ( ) ; const participant1 = this . createParticipant ( null ) ; const participant2 = this . createParticipant ( null ) ; opponents . append ( participant1 , participant2 ) ; matchContainer . append ( opponents ) ; matchContainer . style . visibility = 'hidden' ; return matchContainer ; } param match param matchLocation param connection param label param originHint param roundNumber private createMatch ( match , matchLocation ? , connection ? , label ? , originHint ? , roundNumber ? ) { const matchContainer = dom . createMatchContainer ( match . id , match . status ) ; const opponents = dom . createOpponentsContainer ( ( ) => this . onMatchClicked ( match ) ) ; if ( match . status >= Status . Completed ) originHint = undefined ; const participant1 = this . createParticipant ( match . opponent1 , 'opponent1' , originHint , matchLocation , roundNumber ) ; const participant2 = this . createParticipant ( match . opponent2 , 'opponent2' , originHint , matchLocation , roundNumber ) ; this . renderMatchLabel ( opponents , match , label ) ; opponents . append ( participant1 , participant2 ) ; matchContainer . append ( opponents ) ; if ( ! connection ) return matchContainer ; dom . setupConnection ( opponents , matchContainer , connection ) ; return matchContainer ; } param participant param side param originHint param matchLocation param roundNumber private createParticipant ( participant , side ? , originHint ? , matchLocation ? , roundNumber ? ) { const containers = { participant : dom . createParticipantContainer ( participant && participant . id ) , name : dom . createNameContainer ( ) , result : dom . createResultContainer ( ) , } ; if ( participant === null || participant === undefined ) dom . setupBye ( containers . name ) ; else this . renderParticipant ( containers , participant , side , originHint , matchLocation , roundNumber ) ; containers . participant . append ( containers . name , containers . result ) ; if ( participant && participant . id !== null ) this . setupMouseHover ( participant . id , containers . participant ) ; return containers . participant ; } param containers param participant param side param originHint param matchLocation param roundNumber private renderParticipant ( containers , participant , side ? , originHint ? , matchLocation ? , roundNumber ? ) { const found = this . participants . find ( item => item . id === participant . id ) ; if ( found ) { containers . name . innerText = found . name ; containers . participant . setAttribute ( 'title' , found . name ) ; this . renderParticipantImage ( containers . name , found . id ) ; this . renderParticipantOrigin ( containers . name , participant , side , matchLocation , roundNumber ) ; } else this . renderHint ( containers . name , participant , originHint , matchLocation ) ; containers . result . innerText = `${ participant . score === undefined ? '-' : participant . score }` ; dom . setupWin ( containers . participant , containers . result , participant ) ; dom . setupLoss ( containers . participant , containers . result , participant ) ; } param nameContainer param participantId private renderParticipantImage ( nameContainer , participantId ) { const found = this . participantImages . find ( item => item . participantId === participantId ) ; if ( found ) dom . addParticipantImage ( nameContainer , found . imageUrl ) ; } param opponents param match param label private renderMatchLabel ( opponents , match , label ? ) { const childCountLabel = `Bo${ match . child_count }` ; label = label || '' ; if ( this . config . separatedChildCountLabel ) { opponents . append ( dom . createMatchLabel ( label , lang . getMatchStatus ( match . status ) ) ) ; if ( match . child_count > 0 ) opponents . append ( dom . createChildCountLabel ( lang . t ( 'common.best-of-x' , { x : match . child_count } ) ) ) ; return ; } if ( match . child_count > 0 ) { const joined = label ? `${ label }, ${ childCountLabel }` : childCountLabel ; opponents . append ( dom . createMatchLabel ( joined , lang . getMatchStatus ( match . status ) ) ) ; } } param nameContainer param participant param originHint param matchLocation private renderHint ( nameContainer , participant , originHint ? , matchLocation ? ) { if ( originHint === undefined || participant . position === undefined ) return ; if ( ! this . config . showSlotsOrigin ) return ; if ( ! this . config . showLowerBracketSlotsOrigin && matchLocation === 'loser-bracket' ) return ; dom . setupHint ( nameContainer , originHint ( participant . position ) ) ; } param nameContainer param participant param side param matchLocation param roundNumber private renderParticipantOrigin ( nameContainer , participant , side ? , matchLocation ? , roundNumber ? ) { if ( participant . position === undefined || matchLocation === undefined ) return ; if ( ! this . config . participantOriginPlacement || this . config . participantOriginPlacement === 'none' ) return ; if ( ! this . config . showSlotsOrigin ) return ; if ( ! this . config . showLowerBracketSlotsOrigin && matchLocation === 'loser-bracket' ) return ; const abbreviation = getOriginAbbreviation ( matchLocation , this . skipFirstRound , roundNumber , side ) ; if ( ! abbreviation ) return ; const origin = abbreviation + participant . position ; dom . addParticipantOrigin ( nameContainer , origin , this . config . participantOriginPlacement ) ; } param participantId param element private setupMouseHover ( participantId , element ) { if ( ! this . config . highlightParticipantOnHover ) return ; const refs = this . participantRefs [ participantId ] ; if ( ! refs ) throw Error ( `The participant (id: ${ participantId }) does not exist in the participants table.` ) ; refs . push ( element ) ; element . addEventListener ( 'mouseover' , ( ) => { refs . forEach ( el => el . classList . add ( 'hover' ) ) ; } ) ; element . addEventListener ( 'mouseleave' , ( ) => { refs . forEach ( el => el . classList . remove ( 'hover' ) ) ; } ) ; } }