/* jshint esnext: true */

( function() { // nb: put the entire file into its own local namespace, global stuff gets added to window.

// --------------------------------------------------------------------

var DEFAULT_PLAYER_COLORS = [
    "#00ff00", // nb: this is for the "expected results" line graph
    "#479dd6", "#c48718", "#cf75c9", "#5fd760"
] ;

var DR_VALS = {
    "DR": [ 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ],
    "dr": [ 1, 2, 3, 4, 5, 6 ],
} ;

var DR_CLASS_IDS = { DR: ".d6x2", dr: ".d6x1" } ;

var ROLL_TYPES = {
    "IFT": "IFT",
    "MC": "Morale Check",
    "Rally": "Rally",
    "TH": "To Hit",
    "TK": "To Kill",
    "CC": "Close Combat",
    "SA": "Sniper Activation",
    "TC": "Task Check",
    "RS": "Random Selection",
    "Other": "Other",
} ;

var MOVING_AVERAGE_WINDOW_SIZES = [ 5, 10, 20, 50, 100 ] ;
var PREFERRED_WINDOW_SIZE = 20 ;
var MAX_TIME_PLOT_SPACING = 40 ;

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

// NOTE: We store this information in globals so that ChartJS callbacks use the latest data (even if
// it has been re-generated after the user has changed the roll type filter), not what was active
// when the charts were first created (because it was captured by an enclosure).

// data extracted from a log file
var gRawResponseData, gLogFileAnalysis, gLfaStats, gTimePlotEvents ;
var gPlayerColorIndex={} ;

// these map ChartJS dataset index's to player ID's
var gDistribDatasetPlayerIndex={}, gPieDatasetPlayerIndex={}, gTimePlotDatasetPlayerIndex, gHotnessPlayerIndex ;

// ChartJS chart objects
var gDistribCharts={}, gPieCharts={}, gTimePlotChart, gHotnessChart ;

var $gDialog ;
var $gBanner, $gHotness, $gHotnessPopup, $gSelectFilePopup, $gOptions, $gRollTypeDropList, $gDistribBarGraphsCheckBox, $gStackBarGraphsCheckBox ;
var $gPlayerColorsButton, $gPlayerColorsPopup ;
var $gTimePlot, $gTimePlotChartWrapper ;
var $gTimePlotOptions, $gMovingAverageDropList, $gTimePlotZoomInButton, $gTimePlotZoomOutButton ;

var gEventHandlers, gIsInitialLoad ;
var gHotnessBorderColor, gTimePlotValOffset, gTimePlotZoom ;

var gShowTabularData ; // nb: for testing porpoises

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

// NOTE: Adding keyboard shortcuts was ridiculously complicated >:-/ The problem was that selectmenu's
// were processing key-presses, even if the Alt key was down, and selecting any option that started
// with that letter e.g. pressing Alr-R to activate the Roll Types droplist would also select "Rally".
// Adding a keydown handler to the selectmenu to ignore these events worked, unless the droplist
// already had focus. The only way I could get things to work properly was to change the selectmenu's
// prototype (!) and ignore events there :wtf:

SHORTCUT_HANDLERS = {
    82: function () { // "R"
        $gOptions.css( "opacity", 1.0 ) ;
        $gRollTypeDropList.selectmenu("instance").button.focus() ;
    },
    71: function () { // "G"
        $gMovingAverageDropList.selectmenu("instance").button.focus() ;
    },
    88: function () { // "X"
        var $elem = $gBanner.find( ".select-file" ) ;
        if ( $elem.css( "display" ) != "none" )
            $gBanner.find( ".select-file" ).click() ;
    },
    50: function() { // "2"
        $( "#lfa .hotness img.dice" ).click() ;
    },
} ;

gPrevSelectMenuKeyDownHandler = $.ui.selectmenu.prototype._buttonEvents.keydown ;
$.ui.selectmenu.prototype._buttonEvents.keydown = function( evt ) {
    if ( evt.altKey && SHORTCUT_HANDLERS[evt.keyCode] )
        return ;
    gPrevSelectMenuKeyDownHandler.call( this, evt ) ;
} ;

const gOptionsNormalOpacity = 0.5 ;
function restoreOptionsOpacity() { $gOptions.css( "opacity", gOptionsNormalOpacity ) ; }

// --------------------------------------------------------------------

window.show_lfa_dialog = function( resp )
{
    // save a copy of the raw response data
    gRawResponseData = resp ;

    function closeDialog() { $gDialog.dialog( "close" ) ; }

    function onKeyDown( evt ) {
        if ( evt.keyCode == $.ui.keyCode.ESCAPE ) {
            if ( evt.shiftKey ) {
                // NOTE: Since analyzing a log file is a lengthy process, we make it harder
                // to accidentally close the dialog (need to Shift-Escape).
                closeDialog() ;
            } else {
                // NOTE: However, we allow ESCAPE to close popups.
                closeAllPopupsAndDropLists() ;
                restoreOptionsOpacity() ;
            }
        }
    }
    function fixupDropListOptions( $dropList ) {
        // fixup styling for droplist items
        var id = $dropList.attr( "id" ) ;
        $( "#" + id + "-menu" ).find( ".ui-menu-item" ).each( function() {
            $(this).css( { "font-size": "80%" } ) ;
        } ) ;
    }

    // initialize user settings
    if ( ! gUserSettings.lfa )
        gUserSettings.lfa = {} ;
    if ( ! gUserSettings.lfa[ "player-colors" ] )
        gUserSettings.lfa[ "player-colors" ] = DEFAULT_PLAYER_COLORS.slice() ;

    // show the main window (implemented as a dialog)
    gEventHandlers = new jQueryHandlers() ;
    $( "#lfa" ).dialog( {
        dialogClass: "lfa",
        modal: true,
        resizable: false,
        // NOTE: We handle ESCAPE ourself, handle_escape() has an exception for this dialog.
        closeOnEscape: false,
        create: function() {
            // initialize the splitter
            Split( [ "#lfa .top-pane", "#lfa .bottom-pane" ], {
                direction: "vertical",
                sizes: [ 60, 40 ],
                minSize: [ 350, 200 ], /* nb: this needs to be set in the CSS as well */
                gutterSize: 3,
                onDrag: updateLayout,
            } ) ;
            var $gripper = $( "<img src='" + gImagesBaseUrl + "/gripper-horz.png'>" ) ;
            $( "#lfa .gutter.gutter-vertical" ).append( $gripper ) ;
            // initialize other controls
            $(this).find( "select[name='roll-type']" ).selectmenu( {
                width: 70,
                open: function() { fixupDropListOptions( $(this) ) ; },
            } ) ;
            $(this).find( "select[name='moving-average']" ).selectmenu( {
                width: 55,
                open: function() { fixupDropListOptions( $(this) ) ; },
            } ) ;
            $(this).find( "button.download" ).button() ;
            $(this).find( "button.player-colors" ).button() ;
            $(this).find( "button.zoom-in" ).button() ;
            $(this).find( "button.zoom-out" ).button() ;
        },
        open: function() {
            $gDialog = $(this) ;
            gEventHandlers.addHandler( $("#lfa .ui-dialog-titlebar-close"), "click", closeDialog ) ;
            gEventHandlers.addHandler( $(document), "keydown", onKeyDown ) ;
            loadDialog() ;
        },
        close: function() {
            // NOTE: We explicitly close everything so that they aren't visible next time we open.
            closeAllPopupsAndDropLists() ;
            // clean up handlers
            gEventHandlers.cleanUp() ;
            // clean up charts
            for ( var key in gDistribCharts )
                gDistribCharts[key].destroy() ;
            for ( key in gPieCharts )
                gPieCharts[key].destroy() ;
            gTimePlotChart.destroy() ;
            gHotnessChart.destroy() ;
        },
    } ) ;
} ;

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function loadDialog()
{
    // initialize
    gShowTabularData = getUrlParam( "lfa_tables" ) ;
    gIsInitialLoad = true ;
    var i ;

    // initialize
    $gBanner = $( "#lfa .banner" ) ;
    $gHotness = $( "#lfa .hotness" ).hide() ;
    $gHotnessPopup = $( "#lfa .hotness-popup" ) ;
    $gSelectFilePopup = $( "#lfa .select-file-popup" ) ;
    $gOptions = $( "#lfa .options" ) ;
    $gRollTypeDropList = $( "#lfa select[name='roll-type']" ) ;
    $gDistribBarGraphsCheckBox = $( "#lfa input[name='distrib-bar-graphs']" ).prop(
        "checked", gUserSettings.lfa["distrib-bar-graphs"]
    ) ;
    $gStackBarGraphsCheckBox = $( "#lfa input[name='stack-bar-graphs']" ).prop(
        "checked", gUserSettings.lfa["stack-bar-graphs"]
    ) ;
    $gPlayerColorsButton = $( "#lfa .options .player-colors" ) ;
    $gPlayerColorsPopup = $( "#lfa .player-colors-popup" ) ;
    $gDisableAnimationsCheckBox = $( "#lfa input[name='disable-animations']" ).prop(
        "checked", gUserSettings["disable-animations"]
    ) ;
    $gTimePlot =  $( "#lfa .time-plot" ) ;
    $gTimePlotChartWrapper = $( "#lfa .time-plot .wrapper" ) ;
    $gTimePlotOptions = $( "#lfa .time-plot-options" ) ;
    $gMovingAverageDropList = $( "#lfa select[name='moving-average']" ) ;
    $gTimePlotZoomInButton = $( "#lfa .time-plot-options .zoom-in" ) ;
    $gTimePlotZoomOutButton = $( "#lfa .time-plot-options .zoom-out" ) ;

    // analyze the log files
    gLogFileAnalysis = new LogFileAnalysis( gRawResponseData, -1 ) ;
    var rollTypes = gLogFileAnalysis.getRollTypes() ;

    // initialize the hotness popup
    initHotnessPopup() ;

    // initialize the player colors
    var prevColorsLen = gUserSettings.lfa[ "player-colors" ].length ; // nb: this includes the "expected results" color
    gLogFileAnalysis.forEachPlayer( function( playerId, playerNo ) {
        if ( playerNo >= prevColorsLen-1 ) {
            // NOTE: We have more players than colors - create a new player color based on what we have
            gUserSettings.lfa["player-colors"].push(
                gUserSettings.lfa["player-colors"][ 1 + (playerNo % (prevColorsLen-1)) ]
            ) ;
        }
        gPlayerColorIndex[ playerId ] = 1 + playerNo ;
    } ) ;
    if ( gUserSettings.lfa["player-colors"].length > prevColorsLen )
        save_user_settings() ;

    // initialize the file selection popup
    initSelectFilePopup() ;
    if ( gLogFileAnalysis.logFiles.length > 1 )
        $gBanner.find( ".select-file" ).show() ;
    else
        $gBanner.find( ".select-file" ).hide() ;

    // create the charts
    for ( var key in DR_CLASS_IDS ) {
        gDistribCharts[ key ] = createDistribChart( key, DR_CLASS_IDS[key] ) ;
        gPieCharts[ key ] = createPieChart( key, DR_CLASS_IDS[key] ) ;
    }
    gTimePlotChart = createTimePlotChart() ;
    gHotnessChart = createHotnessChart() ;
    gHotnessBorderColor = $gHotness.css( "border-top-color" ) ;

    // load the roll types
    var buf = [ "<option value=''>", "All", "</option>" ] ;
    function addRollType( rollType ) {
        buf.push( "<option value='"+rollType+"'>", rollType, "</option>" ) ;
    }
    for ( key in ROLL_TYPES )
        addRollType( key ) ;
    rollTypes.forEach( function( rollType ) {
        if ( ! ROLL_TYPES[ rollType ] )
            addRollType( rollType ) ;
    } ) ;
    $gRollTypeDropList.html( buf.join("") ).selectmenu( "refresh" ) ;
    gEventHandlers.addHandler( $gRollTypeDropList, "selectmenuchange", reloadAll ) ;

    // add a click handler for distrib line graphs
    gEventHandlers.addHandler( $gDistribBarGraphsCheckBox, "click", function() {
        // update the UI
        var isChecked = $(this).is( ":checked" ) ;
        for ( var key in DR_VALS ) {
            for ( var i=0 ; i < gDistribCharts[key].data.datasets.length-1 ; ++i )
                gDistribCharts[key].data.datasets[i].type = isChecked ? "bar" : "line" ;
            setChartAnimation( gDistribCharts[key].options ) ;
            gDistribCharts[key].update() ;
        }
        updateUi() ;
        // save the new setting
        gUserSettings.lfa["distrib-bar-graphs"] = isChecked ;
        save_user_settings() ;
    } ) ;

    // add a click handler for stacked bar graphs
    gEventHandlers.addHandler( $gStackBarGraphsCheckBox, "click", function() {
        // update the UI
        var isChecked = $(this).is( ":checked" ) ;
        for ( var key in DR_VALS ) {
            gDistribCharts[key].options.scales.xAxes[0].stacked = isChecked ;
            setChartAnimation( gDistribCharts[key].options ) ;
            gDistribCharts[key].update() ;
        }
        // save the new setting
        gUserSettings.lfa["stack-bar-graphs"] = isChecked ;
        save_user_settings() ;
    } ) ;

    // add a click handler for enabling/disabling animations
    gEventHandlers.addHandler( $gDisableAnimationsCheckBox, "click", function() {
        // save the new setting
        gUserSettings["disable-animations"] = $(this).is( ":checked" ) ;
        save_user_settings() ;
    } );

    // add a handler for the moving average window size
    gEventHandlers.addHandler( $gMovingAverageDropList, "selectmenuchange", function() {
        updateTimePlotChart( null ) ;
    } ) ;

    // add handlers to zoom the time-plot chart in and out
    gEventHandlers.addHandler( $gTimePlotZoomInButton, "click", function( evt ) {
        zoomTimePlotChart( evt.ctrlKey ? null : +1 ) ;
    } ) ;
    gEventHandlers.addHandler( $gTimePlotZoomOutButton, "click", function( evt ) {
        zoomTimePlotChart( evt.ctrlKey ? null : -1 ) ;
    } ) ;

    // add a click handler to download the data
    gEventHandlers.addHandler( $("#lfa .options button.download"), "click", onDownloadData ) ;

    // initialize the color pickers
    initPlayerColorsConfig() ;

    // preload the die images
    [ "yellow", "white" ].forEach( function( color ) {
        for ( i=1 ; i <= 6 ; ++i )
            $.get( makeDieImageUrl( i, color ) ) ;
    } ) ;

    // handle window resizing
    gEventHandlers.addHandler( $(window), "resize", function() {
        closeAllPopupsAndDropLists() ;
        updateLayout() ;
    } ) ;

    // add keyboard shortcut handlers
    gEventHandlers.addHandler( $(document), "keydown", function( evt ) {
        var handler = SHORTCUT_HANDLERS[ evt.keyCode ] ;
        if ( evt.altKey && handler ) {
            closeAllPopupsAndDropLists() ;
            handler( evt ) ;
        }
    } ) ;

    // NOTE: We used to get the options panel to fade in and out as the mouse moves over it via opacity CSS.
    // However, when we added keyboard shortcuts, we temporarily set the opacity to 1.0 if the Roll Type droplist
    // is requested, but we can't set it back since we can't set CSS pseudo-selectors (:hover) in Javascript :-/
    // So, we dropped the :hover CSS and always do it via mouse enter/leave events.
    gEventHandlers.addHandler( $gOptions, "mouseenter", function() { $(this).css("opacity",1.0) ; } ) ;
    gEventHandlers.addHandler( $gOptions, "mouseleave", restoreOptionsOpacity ) ;
    // NOTE: We also need to restore opacity after the Roll Types droplist has been used/dismissed.
    gEventHandlers.addHandler( $gRollTypeDropList, "selectmenuclose", restoreOptionsOpacity ) ;
    gEventHandlers.addHandler( $gRollTypeDropList.selectmenu("instance").button, "focusout", restoreOptionsOpacity ) ;
    restoreOptionsOpacity() ;

    // load the charts with data
    Chart.defaults.global.defaultFontColor = "#444" ;
    $gMovingAverageDropList.val( PREFERRED_WINDOW_SIZE ) ;
    reloadAll() ;

    // set initial focus
    $gRollTypeDropList.focus() ;

    // update the UI
    updateUi() ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function initHotnessPopup()
{
    function makeReport() {

        // initialize
        var rolls={}, snipers={} ;
        gLogFileAnalysis.forEachPlayer( function( playerId, playerNo ) {
            rolls[ playerId ] = {} ;
            for ( var rollType in ROLL_TYPES )
                rolls[ playerId ][ rollType ] = { 2: 0, 12: 0 } ;
            snipers[ playerId ] = { 1: 0, 2: 0 } ;
        } ) ;

        // count how many 2's and 12's were rolled, and Sniper Activations
        gLogFileAnalysis.extractEvents( 1, {
            onRollEvent: function( evt ) {
                var rollTotal = LogFileAnalysis.rollTotal( evt.rollValue ) ;
                if ( evt.rollType == "SA" && ( rollTotal == 1 || rollTotal == 2 ) )
                    ++ snipers[ evt.playerId ][ rollTotal ] ;
                else if ( ! LogFileAnalysis.isSingleDie( evt.rollValue ) && ( rollTotal == 2 || rollTotal == 12 ) )
                    ++ rolls[ evt.playerId ][ evt.rollType ][ rollTotal ] ;
            }
        } ) ;

        // figure out which roll types had at least one 2 or 12
        var rollTypesToShow = {} ;
        gLogFileAnalysis.forEachPlayer( function( playerId, playerNo ) {
            for ( var rollType in ROLL_TYPES ) {
                if ( rolls[playerId][rollType][2] > 0 || rolls[playerId][rollType][12] > 0 )
                    rollTypesToShow[ rollType ] = true ;
            }
        } ) ;

        // add the 2's and 12's to the report
        var buf = [] ;
        function addRollReport( tableClass, die1, die2 ) {
            // add the header
            buf.push( "<table class='" + tableClass + "'>" ) ;
            buf.push( "<tr>", "<td class='icon'>",
                "<img src='" + makeDieImageUrl( die1, "yellow" ) + "' class='die'>",
                "<img src='" + makeDieImageUrl( die2, "white" ) + "' class='die'>"
            ) ;
            for ( var rollType in ROLL_TYPES ) {
                if ( rollTypesToShow[ rollType ] )
                    buf.push( "<th>", rollType ) ;
            }
            gLogFileAnalysis.forEachPlayer( function( playerId, playerNo ) {
                buf.push( "<tr>", "<td class='player'>", makePlayerNameHTML(playerId) ) ;
                for ( var rollType in ROLL_TYPES ) {
                    if ( ! rollTypesToShow[ rollType ] )
                        continue ;
                    var nRollTypes = rolls[ playerId ][ rollType ][ die1+die2 ] ;
                    buf.push( "<td class='val'>", nRollTypes === 0 ? "-" : nRollTypes ) ;
                }
            } ) ;
            buf.push( "</table>" ) ;
        }
        addRollReport( "2s", 1, 1 ) ;
        addRollReport( "12s", 6, 6 ) ;

        // add a divider
        buf.push(
            "<div style='height:0.25em;border-bottom:1px dotted #aaa;'>&nbsp;</div>",
            "<div style='height:0.75em;'>&nbsp;</div>"
        ) ;

        // add the Sniper Activations to the report
        buf.push( "<table class='snipers'>" ) ;
        buf.push( "<tr>", "<td class='icon'>",
            "<img src='" + gImagesBaseUrl+"/sniper.png" + "' class='sniper'>",
            "<th>", "dr 1", "<th>", "dr 2"
        ) ;
        gLogFileAnalysis.forEachPlayer( function( playerId, playerNo ) {
            buf.push( "<tr>", "<td class='player'>", makePlayerNameHTML(playerId) ) ;
            [ 1, 2 ].forEach( function( val ) {
                var nActivations = snipers[ playerId ][ val ] ;
                buf.push( "<td class='val'>", nActivations === 0 ? "-" : nActivations ) ;
            } ) ;
        } ) ;
        buf.push( "</table>" ) ;

        // generate the report
        return buf.join( "" ) ;
    }

    function makePlayerNameHTML( playerId ) {
        return escapeHTML( gLogFileAnalysis.playerName( playerId )  ) ;
    }

    // add a click handler for the hotness popup
    var $elem = $( "#lfa .hotness img.dice" ) ;
    gEventHandlers.addHandler( $elem, "click", function( evt ) {
        closeAllPopupsAndDropLists() ;
        // NOTE: We have to re-generate the report each time it's shown, since the user
        // may have chosen a different set of log files.
        $gHotnessPopup.html( makeReport() ).fadeIn( 50 ) ;
        var maxWidth = 0 ;
        $gHotnessPopup.find( "table" ).each( function() {
            maxWidth = Math.max( $(this).outerWidth() , maxWidth ) ;
        } ) ;
        $gHotnessPopup.css( { width: maxWidth } ) ;
        $gHotnessPopup.position( {
            my: "right top", at: "left-5 top+2", of: $elem, collision: "fit"
        } ) ;
        stopEvent( evt ) ;
    } ) ;

    // handle clicks outside the popup (to dismiss it)
    // NOTE: We do this by adding a click handler to the main dialog window, and a click handler
    // to the popup that prevents the event from bubbling up i.e. if the main dialog window receives
    // a click event, it must've been outside the popup window.
    gEventHandlers.addHandler( $gHotnessPopup, "click", function() {
        return false ;
    } ) ;
    gEventHandlers.addHandler( $("#lfa"), "click", function() {
        $gHotnessPopup.fadeOut( 20 ) ;
    } ) ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function initSelectFilePopup()
{
    // initialize the file selection popup
    var buf = [] ;
    function addRadioButton( val, caption ) {
        buf.push( "<div class='row'>",
            "<input type='radio' value='" + val + "' name='select-file-radio-group'>",
            "<label>", caption, "</label>",
            "</div>"
        ) ;
    }
    addRadioButton( -1, "All files" )  ;
    for ( i=0 ; i < gLogFileAnalysis.logFiles.length ; ++i )
        addRadioButton( i, gLogFileAnalysis.logFiles[i] ) ;
    $gSelectFilePopup.html( buf.join("") ).hide() ;
    var $currSel = $gSelectFilePopup.find( "input[type='radio']" ).first() ;

    // handle a new log file being selected
    // FUDGE! Normally we would call this handler when the "change" event fires on a radio button,
    // but we have to hack around some problems with the "click outside the popup to dismiss it" feature,
    // and so we call it manually ourself elsewhere.
    function selectNewLogFile( $radio ) {
        $currSel = $radio ;
        gLogFileAnalysis = new LogFileAnalysis( gRawResponseData, $radio.val() ) ;
        $gSelectFilePopup.hide() ;
        reloadAll() ;
    }
    function onFileSelected( $radio, evt ) {
        if ( evt.clientX === 0 && evt.clientY === 0 )
            return ; // nb: this selection was done via the keyboard
        selectNewLogFile( $radio ) ;
    }
    function selectFocusRadioButton() {
        // FUDGE! We can't rely on the timing of event processing, so we give the UI a bit of time
        // to catch up before checking which radio button has focus.
        setTimeout( function() {
            var $radio = $gSelectFilePopup.find( "input[type='radio']:focus" ) ;
            $radio.prop( "checked", true ) ;
        }, 10 ) ;
    }
    gEventHandlers.addHandler( $gSelectFilePopup, "keydown", function( evt ) {
        if ( evt.keyCode == 13 )
            selectNewLogFile( $gSelectFilePopup.find( "input[type='radio']:focus" ) ) ;
        else if ( evt.keyCode == $.ui.keyCode.HOME ) {
            $gSelectFilePopup.find( "input[type='radio']" ).first().focus() ;
            selectFocusRadioButton() ;
        }
        else if ( evt.keyCode == $.ui.keyCode.END ) {
            $gSelectFilePopup.find( "input[type='radio']" ).last().focus() ;
            selectFocusRadioButton() ;
        }
        else if ( evt.keyCode == $.ui.keyCode.UP || evt.keyCode == $.ui.keyCode.DOWN || evt.keyCode == $.ui.keyCode.LEFT || evt.keyCode == $.ui.keyCode.RIGHT ) {
            // FUDGE! The browser will move the focus rectangle, but we want
            // the radio button to become actually selected.
            selectFocusRadioButton() ;
        }
    } ) ;

    // FUDGE! Make the labels clickable as well.
    $gSelectFilePopup.find( ".row" ).each( function( evt ) {
        gEventHandlers.addHandler( $(this).children( "label" ), "click", function( evt ) {
            onFileSelected( $(this).parent().children( "input[type='radio']" ), evt ) ;
        } ) ;
    } ) ;

    // add a click handler to open the file selection popup
    gEventHandlers.addHandler( $gBanner.find( ".select-file" ), "click", function( evt ) {
        // show the popup
        closeAllPopupsAndDropLists() ;
        $gSelectFilePopup.show().position( {
            my: "right top", at: "right+3 bottom+5", of: $(this), collision: "fit"
        } ) ;
        $currSel.prop( "checked", true ).focus() ;
        stopEvent( evt ) ;
    } ) ;

    // handle clicks outside the popup (to dismiss it)
    // NOTE: We do this by adding a click handler to the main dialog window, and a click handler
    // to the popup that prevents the event from bubbling up i.e. if the main dialog window receives
    // a click event, it must've been outside the popup window.
    // Unfortunately, this messes up the operation of radio buttons, so we have to manage them ourself :-/
    gEventHandlers.addHandler( $gSelectFilePopup, "click", function() {
        return false ;
    } ) ;
    gEventHandlers.addHandler( $("#lfa"), "click", function() {
        $gSelectFilePopup.hide() ;
    } ) ;
    gEventHandlers.addHandler( $gSelectFilePopup.find( "input[type='radio']" ), "click", function( evt ) {
        // FUDGE! We have to handle clicks on the radio button ourself :-/
        onFileSelected( $(this), evt ) ;
        return false ;
    } ) ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function initPlayerColorsConfig()
{
    // NOTE: This works around a gnarly problem with how we interact with the Spectrum color pickers.
    // We want to auto-close the popup dialog when a color picker is closed (whether the changes
    // were accepted or cancelled), *unless* the user is opening another color picker. In this case,
    // Spectrum automatically closes the color picker and opens the new one, but we don't want to close
    // our popup.
    // In the normal case (user opens a color picker, does stuff, then closes it), we get these events:
    //   BEFORE SHOW ; SHOW ; ... ; HIDE
    // If the user opens a color picker while another one is already on-screen, we get:
    //   BEFORE SHOW ; HIDE ; SHOW ; ...
    var isBeforeShowActive = false ;

    // NOTE: People want to use nationality colors in their graphs, so we provide them as a pre-defined palette.
    var natColors=[], natNames=[] ;
    var sortedNats = get_sorted_nats() ;
    sortedNats.forEach( function( nat ) {
        // NOTE: As always, two-tone counters cause us grief :-/ We probably want to use the background
        // color here (index 0), but in general, they're just too weak :-(
        natColors.push( gTemplatePack.nationalities[ nat ].ob_colors[2] ) ;
        natNames.push( gTemplatePack.nationalities[ nat ].display_name ) ;
    } ) ;
    function fixupPaletteTooltips() {
        // NOTE: It's possible to add custom color names (e.g. "german" = #d3edfc), and these custom names
        // will appear as palette tooltips (if we set the preferred format to "name"):
        //   https://github.com/bgrins/spectrum/pull/356#issuecomment-149417882
        // Unfortunately, quite a few nationalities have the same color, so if the user selects e.g. "ANZAC",
        // it might appear in the UI as "British" :-/ Instead, we provide this function that fixes up
        // the tooltips. Sigh...
        $( ".sp-palette-container .sp-thumb-el:visible" ).each( function( n ) {
            $(this).attr( "title", natNames[n] ) ;
        } ) ;
    }

    // initialize the color pickers
    function initColorPicker( colorPickerNo, playerId, caption, style ) {
        var buf = [ "<div class='row'>",
            "<input class='color-picker player" + colorPickerNo + "'></input>",
            "<span class='caption'>", caption, "</span>",
            "</div>"
        ] ;
        var $elem = $( buf.join("") ).attr( "data-playerId", playerId ) ;
        if ( style )
            $elem.css( style ) ;
        $gPlayerColorsPopup.append( $elem ) ;
        $elem = $gPlayerColorsPopup.find( ".color-picker.player" + colorPickerNo ) ;
        $elem.spectrum( {
            color: playerId === ":expected:" ? gUserSettings.lfa["player-colors"][0] : gUserSettings.lfa["player-colors"][gPlayerColorIndex[playerId]],
            showPalette: true, palette: natColors,
            showInput: true, preferredFormat: "hex",
            chooseText: "OK",
            cancelText: "Cancel",
            clickoutFiresChange: false,
            move: function( color ) {
                updateChartColors( playerId, color.toHexString() ) ;
                fixupPaletteTooltips() ;
            },
            beforeShow: function() { isBeforeShowActive=true ; },
            show: function() {
                $(this).attr( "data-prev-color", $(this).spectrum("get").toHexString() ) ;
                fixupPaletteTooltips() ;
                isBeforeShowActive = false ;
                // FUDGE! The spectrum control sizes itself too narrow if the window is zoomed (I think the problem
                // is in getOffset() in spectrum.js), and since the colorpicker panel is floating, it ends up below
                // the palette panel (instead of to the right). We hack around this by re-positioning the container.
                const lfaWidth = $( "#lfa" )[0].clientWidth ;
                var $popup = $( ".sp-container:visible" ) ;
                const popupWidth = $popup.outerWidth() ;
                $popup.css( { left: Math.round( lfaWidth - popupWidth ) + "px" } ) ;
            },
            hide: function( color ) {
                // NOTE: We get this event when the color-picker is closed, regardless of whether a new color
                // was picked or not. If it was accepted, we get the new color, if it was cancelled, we get the old one,
                // so either way, we want to update the chart colors and save the color.
                color = color.toHexString() ;
                updateChartColors( playerId, color ) ;
                var colorIndex = playerId === ":expected:" ? 0 : gPlayerColorIndex[playerId] ;
                gUserSettings.lfa["player-colors"][ colorIndex ] = color ;
                save_user_settings() ;
                if ( ! isBeforeShowActive )
                    $gPlayerColorsPopup.hide() ;
                $(this).attr( "data-prev-color", null ) ;
            },
        } ) ;
    }
    $gPlayerColorsPopup.hide().empty() ;
    gLogFileAnalysis.forEachPlayer( function( playerId, playerNo ) {
        initColorPicker( 1+playerNo, playerId, gLogFileAnalysis.playerName(playerId)) ;
    } ) ;
    initColorPicker( 0, ":expected:", "expected results", {"border-top":"1px dotted #aaa","padding-top":"0.75em"} ) ;

    // add a click handler to open the player colors popup
    gEventHandlers.addHandler( $gPlayerColorsButton, "click", function( evt ) {
        // show the popup
        closeAllPopupsAndDropLists() ;
        $gPlayerColorsPopup.find( ".row" ).each( function() {
            // NOTE: Because the set of players can change over time (e.g. if multiple log files are analyzed
            // that have different players in them, and the user switches between them), we want to only show
            // color pickers for the players currently on-screen. If we re-create the color pickers each time
            // the popup opens, this could result in the color assigned to each player changing, so instead,
            // we create color pickers for *all* players once at startup, then show/hide them as necessary.
            var playerId = $(this).attr( "data-playerId" ) ;
            if ( gLogFileAnalysis.playerIds().indexOf( playerId ) !== -1 || playerId === ":expected:" )
                $(this).show() ;
            else
                $(this).hide() ;
        } ) ;
        var leftPos = $gPlayerColorsButton.offset().left - 6 ;
        $gPlayerColorsPopup.css( {
            position: "absolute",
            left: Math.min( leftPos, $gDialog.innerWidth() - $gPlayerColorsPopup.outerWidth() ),
            top: $gPlayerColorsButton.offset().top + $gPlayerColorsButton.height() + 5,
        } ).show() ;
        stopEvent( evt ) ;
    } ) ;

    // handle clicks outside the popup (to dismiss it)
    // NOTE: We do this by adding a click handler to the main dialog window, and a click handler
    // to the popup that prevents the event from bubbling up i.e. if the main dialog window receives
    // a click event, it must've been outside the popup window.
    gEventHandlers.addHandler( $gPlayerColorsPopup, "click", function() {
        return false ;
    } ) ;
    gEventHandlers.addHandler( $("#lfa"), "click", function() {
        $gPlayerColorsPopup.hide() ;
    } ) ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function onInitialLoadCompleted()
{
    // show the hotness chart
    if ( gAppConfig.DISABLE_LFA_HOTNESS_FADEIN )
        $gHotness.show() ;
    else
        setTimeout( function() { $gHotness.fadeIn(1000) ; }, 1000 ) ;

    // update the UI after the initial load has completed
    $gOptions.show() ;
    $gTimePlotOptions.show() ;
    gIsInitialLoad = false ;
}

// --------------------------------------------------------------------

function reloadAll()
{
    // initialize
    var rollType = $gRollTypeDropList.val() ;

    // update the banner
    $gBanner.find( ".title" ).text( gLogFileAnalysis.title ) ;
    $gBanner.find( ".title2" ).text( gLogFileAnalysis.title2 ? "("+gLogFileAnalysis.title2+")" : "" ) ;
    var buf = [] ;
    if ( gLogFileAnalysis.logFileNo >= 0 && gRawResponseData.logFiles.length > 1 ) {
        buf.push( "<span class='caption'>",
            (1+parseInt(gLogFileAnalysis.logFileNo)) + "/" + gRawResponseData.logFiles.length,
            "</span>"
        ) ;
    }
    buf.push( "<span class='ui-selectmenu-icon ui-icon ui-icon-triangle-1-s'>", "</span>" ) ; // nb: jQuery down arrow
    $gBanner.find( ".select-file" ).html( buf.join("") ) ;
    var val ;
    if ( ROLL_TYPES[ rollType ] )
        val = ROLL_TYPES[ rollType ] ;
    else if ( rollType === "" )
        val = "all" ;
    else
        val = "\"" + rollType + "\"" ;
    $gBanner.find( ".roll-type" ).text( "Showing " + val + " rolls." ) ;

    // update the distribution charts
    gLfaStats = gLogFileAnalysis.extractStats( function( evt ) {
        return rollType === "" || checkRollType( evt, rollType ) ;
    } ) ;
    var key, data ;
    for ( key in DR_VALS ) {
        data = getDistribData( key) ;
        var $parentElem = $( "#lfa .distrib" + DR_CLASS_IDS[key] ) ;
        if ( gShowTabularData ) {
            // show the data in tables (for testing porpoises)
            $parentElem.html(
                makeTabularDataHtml( DR_VALS[key], data.datasets )
            ) ;
        } else {
            // load the charts
            gDistribCharts[key].data.datasets = data.datasets ;
            setChartAnimation( gDistribCharts[key].options ) ;
            updateChartForNoData( data.datasets.length === 0, gDistribCharts[key], $parentElem ) ;
            gDistribCharts[key].update() ;
        }
    }

    // update the pie charts
    for ( key in DR_VALS ) {
        data = getPieData( key ) ;
        if ( gShowTabularData ) {
            // show the data in tables (for testing porpoises)
            $( "#lfa .pie" + DR_CLASS_IDS[key] ).html(
                makeTabularDataHtml( data.labels, data.datasets )
            ) ;
        } else {
            // load the charts
            gPieCharts[key].data.datasets = data.datasets ;
            gPieCharts[key].data.labels = data.labels ;
            setChartAnimation( gPieCharts[key].options ) ;
            updatePieChartForNoData( data.datasets.length === 0, gPieCharts[key] ) ;
            gPieCharts[key].update() ;
        }
    }

    // FUDGE! We try to show moving averages in the time-plot chart, since raw rolls are usually too spiky
    // to derive any insights. However, if the user switches to a roll type that has no (or very few) points,
    // the droplist of available window sizes only shows "1" (i.e. for the raw rolls), which persists if
    // the user then switches to a roll type that has lots of points (e.g. "All"), and so they have to
    // manually change the window size. This is annoying, so we attempt to remedy that with this.
    var windowSize = Math.max( $gMovingAverageDropList.val(), PREFERRED_WINDOW_SIZE ) ;

    // update the time-plot chart
    gTimePlotZoom = null ; // nb: force the chart to auto-fit
    updateTimePlotChart( windowSize ) ;

    // update the dice hotness chart
    data = getHotnessData() ;
    if ( gShowTabularData ) {
        // show the data in tables (for testing porpoises)
        $( "#lfa .hotness" ).html(
            makeTabularDataHtml( data.labels, data.datasets, 3 )
        ).css( { border: "none" } ).show() ;
    } else {
        // load the chart
        var dataVals = data.datasets[0].data ;
        var maxVal=0, hasVal=false ;
        for ( var i=0 ; i < dataVals.length ; ++i ) {
            if ( dataVals[i] === null )
                continue ;
            if ( Math.abs( dataVals[i] ) > maxVal )
                maxVal = Math.abs( dataVals[i] ) ;
            hasVal = true ;
        }
        maxVal = Math.ceil( maxVal ) ;
        gHotnessChart.data.datasets = data.datasets ;
        gHotnessChart.data.labels = data.labels ;
        gHotnessChart.options.scales.xAxes[0].ticks.suggestedMin = - maxVal ;
        gHotnessChart.options.scales.xAxes[0].ticks.suggestedMax = maxVal ;
        setChartAnimation( gHotnessChart.options ) ;
        updateChartForNoData( !hasVal, gHotnessChart, $gHotness ) ;
        $gHotness.css( "border-color", hasVal ? gHotnessBorderColor : "#ccc" ) ;
        $gHotness.find( "img.dice" ).css( "display", hasVal ? "block" : "none" ) ;
        $gHotness.find( "canvas" ).css( "display", hasVal ? "block" : "none" ) ;
        gHotnessChart.update() ;
    }

    // update the layout
    updateLayout() ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function updateTimePlotChart( currWindowSize )
{
    // initialize
    var rollType = $gRollTypeDropList.val() ;
    if ( currWindowSize === null )
        currWindowSize = $gMovingAverageDropList.val() || PREFERRED_WINDOW_SIZE ;

    // extract events from the log file analysis
    var isSingleDie, labels, nextLabel, logFileIndexes ;
    function extractEvents( windowSize ) {
        labels = [] ;
        isSingleDie = nextLabel = null ;
        logFileIndexes = [] ;
        return gLogFileAnalysis.extractEvents( windowSize, {
            onTurnTrackEvent: function( evt ) {
                // we have a Turn Track event - use the phase description as the next label
                nextLabel = evt.side + " " + evt.turnNo + " " + evt.phase ;
            },
            onCustomLabelEvent: function( evt ) {
                // we have a custom label event - use the caption as the next label
                nextLabel = evt.caption ;
            },
            onRollEvent: function( evt ) {
                // we have a DR/dr roll - check if we want to include it
                if ( rollType === "" ) {
                    // NOTE: If we are showing "all" rolls, we only show DR's.
                    if ( LogFileAnalysis.isSingleDie( evt.rollValue ) )
                        return false ;
                } else if ( rollType === "Other" ) {
                    if ( ! checkRollType( evt, rollType ) )
                        return false ;
                    // NOTE: If we are showing "other" rolls, we only show DR's.
                    if ( LogFileAnalysis.isSingleDie( evt.rollValue ) )
                        return false ;
                } else if ( ! checkRollType( evt, rollType ) ) {
                    // nope - we're not interested in this one
                    return false ;
                }
                return true ;
            },
            onLogFileEvent: function( evt ) {
                // we're starting a new log file
                logFileIndexes.push( labels.length ) ;
            },
            _onAddEvent: function( evt ) {
                // remember if we are extracting DR's or dr's
                if ( isSingleDie === null )
                    isSingleDie = LogFileAnalysis.isSingleDie( evt.rollValue ) ;
                // add a label for the event
                if ( nextLabel ) {
                    labels.push( nextLabel ) ;
                    nextLabel = null ;
                } else
                    labels.push( "" ) ;
            },
        } ) ;
    }

    // figure out what moving average window sizes we should show
    var windowSizes = [ 1 ] ;
    var events = extractEvents( 1 ) ; // FIXME! We should really cache this.
    var maxNRolls = 0 ;
    for ( var playerId in events.nRolls ) {
        if ( events.nRolls[playerId] > maxNRolls )
            maxNRolls = events.nRolls[ playerId ] ;
    }
    function isUseableWindowSize( windowSize ) {
        return maxNRolls >= windowSize+20 || maxNRolls >= 2*windowSize ;
    }
    MOVING_AVERAGE_WINDOW_SIZES.forEach( function( windowSize ) {
        if ( isUseableWindowSize( windowSize ) )
            windowSizes.push( windowSize ) ;
    } ) ;

    // check if the current window size is too big
    if ( currWindowSize > windowSizes[ windowSizes.length-1 ] ) {
        // yup - set it to the largest value available
        currWindowSize = windowSizes[ windowSizes.length-1 ] ;
    }

    // update the droplist with the available window sizes
    var buf = [ "<option value='1'> - </option>" ] ;
    MOVING_AVERAGE_WINDOW_SIZES.forEach( function( windowSize ) {
        if ( isUseableWindowSize( windowSize ) ) {
            buf.push( "<option value='" + windowSize + "'" ) ;
            if ( windowSize == currWindowSize )
                buf.push( " selected='selected'" ) ;
            buf.push( ">" + windowSize + "</option>" ) ;
        }
    } ) ;
    $gMovingAverageDropList.html( buf.join("") ).selectmenu( "refresh" ) ;

    // generate the chart data
    gTimePlotEvents = extractEvents( currWindowSize ) ;
    // NOTE: It would be nice to offset the single die values so that the X-axis appears
    // half-way up the Y-axis, but since it would appear at y=3.5, this causes the tick labels
    // to be non-integral :-/
    var data = getTimePlotData( isSingleDie ? 0 : 7 ) ;

    // check if we want to show the data in a table (for testing porpoises)
    if ( gShowTabularData ) {
        // yup - make it so
        $gTimePlotChartWrapper.html( makeTabularDataHtml( labels, data.datasets ) ) ;
        return ;
    }

    // configure extra vertical lines to delineate each log file
    logFileIndexes.shift() ; // nb: ignore the first log file
    gTimePlotChart.data.verticalLines = logFileIndexes ;

    // update the controls
    // NOTE: We do this before updating the chart, since we may disable zoom buttons depending on the data.
    var hasData = data.datasets.length > 0 ;
    $gMovingAverageDropList.selectmenu( hasData ? "enable" : "disable" ) ;
    $gTimePlotOptions.find( "label" ).attr( "disabled", !hasData ) ;
    $gTimePlotZoomInButton.button( hasData ? "enable" : "disable" ) ;
    $gTimePlotZoomOutButton.button( hasData ? "enable" : "disable" ) ;

    // update the chart
    gTimePlotChart.data.datasets = data.datasets ;
    gTimePlotChart.data.labels = labels ;
    updateChartForNoData( data.datasets.length === 0, gTimePlotChart, $gTimePlotChartWrapper ) ;
    if ( currWindowSize == 1 ) {
        // we're showing raw rolls - show all possible values in the Y axis
        gTimePlotChart.options.scales.yAxes[0].ticks.suggestedMin = isSingleDie ? 1 : 2-7 ;
        gTimePlotChart.options.scales.yAxes[0].ticks.suggestedMax = isSingleDie ? 6 : 12-7 ;
    } else {
        // we're showing moving averages - let ChartJS zoom in as appropriate
        gTimePlotChart.options.scales.yAxes[0].ticks.suggestedMin = null ;
        gTimePlotChart.options.scales.yAxes[0].ticks.suggestedMax = null ;
    }
    setChartAnimation( gTimePlotChart.options ) ;
    gTimePlotChart.update() ;
    zoomTimePlotChart( 0 ) ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function zoomTimePlotChart( zoom ) {

    // initialize
    var datasets = gTimePlotChart.data.datasets ;
    var labels = gTimePlotChart.data.labels ;
    var availableWidth = $gTimePlot.width() ;

    var chartWidth ;
    if ( gTimePlotZoom === null || zoom === null ) {
        // set the initial zoom
        gTimePlotZoom = 1.0 ;
        $gTimePlotZoomInButton.button( "enable" ) ;
        $gTimePlotZoomOutButton.button( "enable" ) ;
    } else {
        const zoomDelta = 0.25 ;
        if ( zoom < 0 ) {
            // the user is zooming out
            if ( gTimePlotZoom <= zoomDelta )
                return ;
            gTimePlotZoom -= zoomDelta ;
            $gTimePlotZoomInButton.button( "enable" ) ;
        } else if ( zoom > 0 ) {
            // the user is zooming in
            gTimePlotZoom += zoomDelta ;
            $gTimePlotZoomOutButton.button( "enable" ) ;
            // remove any blank points we may have added earlier
            removeTrailingBlankVals( labels, datasets ) ;
            gTimePlotChart.update() ;
        }
    }

    // figure out how much horizontal space there should be between points
    var nXVals = labels.length ;
    var spacing = availableWidth / nXVals ; // nb: things still work if we have 0 values :-)
    spacing *= gTimePlotZoom ;
    if ( spacing <= 2 ) {
        spacing = 2 ;
    } else if ( spacing >= MAX_TIME_PLOT_SPACING ) {
        spacing = MAX_TIME_PLOT_SPACING ;
        // we've hit the max spacing - zooming in any further won't have any effect
        $gTimePlotZoomInButton.button( "disable" ) ;
    }
    chartWidth = spacing * nXVals ;
    if ( chartWidth < availableWidth ) {
        // NOTE: We have an upper limit on how much space there can be between points, so if there aren't
        // many points, the graph will be narrower than the available width, and we will have unused space
        // on the right. This is ugly, so we add enough dummy points to make the grid lines go all the way
        // to the right edge.
        var nBlankVals = ( availableWidth - chartWidth - 1 ) / spacing ;
        for ( var i=0 ; i < nBlankVals ; ++i ) {
            labels.push( "" ) ;
            for ( var j=0 ; j < datasets.length ; ++j )
                datasets[j].data.push( null ) ;
        }
        gTimePlotChart.update() ;
        chartWidth = availableWidth ;
    }
    if ( chartWidth <= availableWidth ) {
        // the user can see all the values - there's no point zooming out any further
        $gTimePlotZoomOutButton.button( "disable" ) ;
    }

    // NOTE: ChartJS charts don't really like being resized, so we place it inside a wrapper
    // and resize that, with the chart configured to be responsive.
    $gTimePlotChartWrapper.css( { width: chartWidth } ) ;
    gTimePlotChart.resize() ;
}

function removeTrailingBlankVals( labels, datasets ) {
    // count the number of trailing blank values in each dataset
    var minTrailingBlankVals=null, i, j ;
    for ( i=0 ; i < datasets.length ; ++i ) {
        var nTrailingBlankVals = 0 ;
        for ( j=datasets[i].data.length-1 ; j >= 0 ; --j ) {
            if ( datasets[i].data[j] === null )
                ++ nTrailingBlankVals ;
            else
                break ;
        }
        if ( minTrailingBlankVals === null || nTrailingBlankVals < minTrailingBlankVals )
            minTrailingBlankVals = nTrailingBlankVals ;
    }
    // remove trailing blank values from each dataset
    if ( minTrailingBlankVals > 0 ) {
        for ( i=0 ; i < minTrailingBlankVals ; ++i ) {
            labels.pop() ;
            for ( j=0 ; j < datasets.length ; ++j )
                datasets[j].data.pop() ;
        }
    }
}

// --------------------------------------------------------------------

function createDistribChart( key, classId )
{
    // initialize
    var playerId ;

    // chart callbacks
    function legend_label_filter( label ) { return label.text != "(expected)" ; }
    function yAxis_tick( val, index, vals ) { return val+"%" ; }
    function tooltip_title( tooltipItem, data ) { return key + " " + tooltipItem[0].label ; }
    function tooltip_label( tooltipItem, data ) {
        playerId = gDistribDatasetPlayerIndex[key][ tooltipItem.datasetIndex ] ;
        if ( ! playerId )
            return " Expected: " + tooltipItem.value + "%" ;
        return " " + gLogFileAnalysis.playerName( playerId ) ;
    }
    function tooltip_footer( tooltipItem, data ) {
        playerId = gDistribDatasetPlayerIndex[key][ tooltipItem[0].datasetIndex ] ;
        if ( playerId ) {
            playerId = gDistribDatasetPlayerIndex[key][ tooltipItem[0].datasetIndex ] ;
            var nRolls = gLfaStats[playerId][key].nRolls ;
            var nInstances = gLfaStats[playerId][key].distrib[ tooltipItem[0].label ] || 0 ;
            var msg = nInstances + " of " + nRolls ;
            msg += " " + pluralString( nRolls, key, key+"'s" ) ;
            msg += " (" + tooltipItem[0].value + "%)" ;
            return "     " + msg ;
        }
    }

    // create the chart
    var $canvas = $( "#lfa .distrib" + classId + " canvas" ) ;
    var chart = new Chart( $canvas, {
        // FUDGE! The charts seem to be sensitive to the initial type, and don't redraw themselves properly
        // when switching types later. If they start as line graphs, the points appear on top of the vertical
        // column dividers, but if the user switches to bar graphs, the left- and right-most bars are not visible.
        // If they start as bar graphs, and the user switches to line graphs, the points appear in between
        // the vertical column dividers, which is techincally correct, but doesn't look as good.
        // So, it looks like the chart control is getting confused about where to draw the various elements
        // when switching between graph types, so we opt for the 2nd case.
        type: "bar",
        data: {
            labels: DR_VALS[key],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            legend: {
                labels: { filter: legend_label_filter },
            },
            tooltips: {
                footerFontStyle: "normal",
                callbacks: { title: tooltip_title, label: tooltip_label, footer: tooltip_footer },
            },
            scales: {
                xAxes: [ {
                    stacked: $gStackBarGraphsCheckBox.is( ":checked" ),
                    scaleLabel: {
                        display: true,
                        labelString: key === "dr" ? " ".repeat(8)+"dr distribution" : "DR distribution"+" ".repeat(70),
                        fontSize: 16,
                    },
                } ],
                yAxes: [ {
                    ticks: {
                        display: false,
                        beginAtZero: true,
                        callback: yAxis_tick,
                        // NOTE: Label auto-skip doesn't really work in the Y-axis,
                        // so we ensure label spread by setting the padding.
                        autoSkipPadding: 10,
                    },
                    gridLines: { drawOnChartArea: false },
                } ],
            },
            plugins: { labels: false },
        }
    } ) ;

    return chart ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function getDistribData( key )
{
    // initialize
    gDistribDatasetPlayerIndex[ key ] = {} ;
    var datasets = [] ;

    // generate the data for each player
    // NOTE: We don't need to generate labels since they will always be the same (1-6 or 2-12).
    gLogFileAnalysis.forEachPlayer( function( playerId ) {

        // check if the next player has any rolls
        if ( gLfaStats[playerId][key].nRolls === 0 )
            return ;

        // get the player's rolls
        var dataVals = [] ;
        DR_VALS[key].forEach( function( drVal ) {
            if ( gLfaStats[playerId][key].distrib[ drVal ] === undefined ) {
                dataVals.push( 0 ) ;
                return ;
            }
            var val = 100 * gLfaStats[playerId][key].distrib[ drVal ] / gLfaStats[playerId][key].nRolls ;
            dataVals.push( Math.round( 10 * val ) / 10 ) ;
        } ) ;
        gDistribDatasetPlayerIndex[key][ datasets.length ] = playerId ;
        var label = gLogFileAnalysis.playerName( playerId ) ;
        if ( gLfaStats[playerId][key].rollAverage )
            label += " (" + fpFmt( gLfaStats[playerId][key].rollAverage, 1 ) + ")" ;

        // add a dataset for the player's rolls
        datasets.push( {
            label: label,
            data: dataVals,
            type: $gDistribBarGraphsCheckBox.is(":checked") ? "bar" : "line",
            // nb: the following are used for both bar and line graphs
            borderWidth: 1,
            borderColor: getPlayerColor( playerId ),
            backgroundColor: getPlayerColor2( playerId ),
            // nb: the following are needed for line graphs
            fill: false,
            lineTension: 0,
            pointBackgroundColor: getPlayerColor( playerId ),
            spanGaps: true,
        } ) ;

    } ) ;

    // add a dataset to show the expected distribution
    if ( datasets.length > 0 ) {
        datasets.push( {
            type: "line",
            label: "(expected)",
            data: Object.values( LogFileAnalysis.EXPECTED_DISTRIB[ key ] ),
            borderColor: gUserSettings.lfa["player-colors"][0],
            backgroundColor: gUserSettings.lfa["player-colors"][0],
            borderDash: [5,5],
            borderWidth: 1,
            lineTension: 0,
            fill: false,
            order: 1,
        } ) ;
    }

    return { datasets: datasets } ;
}

// --------------------------------------------------------------------

function createPieChart( key, classId )
{
    // chart callbacks
    function tooltip_label( tooltipItem, data ) { return " " + data.labels[ tooltipItem.index ] ; }
    function tooltip_footer( tooltipItem, data ) {
        var playerId = gPieDatasetPlayerIndex[ key ][ tooltipItem[0].index ] ;
        if ( ! playerId )
            return null ;
        var nRolls = gLfaStats[playerId][key].nRolls ;
        var totalRolls = gLfaStats.totalRolls[ key ] ;
        if ( totalRolls === 0 )
            return null ;
        var msg = gLfaStats[playerId][key].nRolls  + " of " + totalRolls ;
        msg += " " + pluralString( totalRolls, key, key+"'s" ) ;
        return "     " + msg ;
    }

    // create the chart
    var $canvas = $( "#lfa .pie" + classId + " canvas" ) ;
    var chart = new Chart( $canvas, {
        type: "pie",
        options: {
            responsive: true,
            maintainAspectRatio: false,
            legend: { display: false },
            tooltips: {
                bodyFontSize: 12,
                footerFontStyle: "normal",
                footerFontSize: 12,
                callbacks: { label: tooltip_label, footer: tooltip_footer },
            },
            plugins: {
                labels: { fontSize: 12 },
            },
        }
    } ) ;

    return chart ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function getPieData( key )
{
    // initialize
    var datasets = [] ;
    gPieDatasetPlayerIndex[ key ] = {} ;
    var dataVals=[], labels=[], borderColors=[], bgdColors=[] ;

    // generate the data for each player
    // NOTE: We load the players in reverse order to get them to render
    // in the same left-right order as the bar graph labels.
    var playerIds = gLogFileAnalysis.playerIds() ;
    for ( var i=playerIds.length-1 ; i >= 0 ; --i ) {

        // check if the next player has any rolls
        var playerId = playerIds[ i ] ;
        var nRolls = gLfaStats[ playerId ][ key ].nRolls  ;
        if ( nRolls === 0 )
            continue ;

        // add an entry for the player's rolls
        gPieDatasetPlayerIndex[ key ][ dataVals.length ] = playerId ;
        dataVals.push( nRolls ) ;
        labels.push( gLogFileAnalysis.playerName( playerId ) ) ;
        borderColors.push( getPlayerColor(playerId) ) ;
        bgdColors.push( getPlayerColor2(playerId) ) ;

    }

    if ( dataVals.length > 0 ) {
        datasets.push( {
            data: dataVals,
            borderColor: borderColors,
            borderWidth: 1,
            backgroundColor: bgdColors,
        } ) ;
    }
    return { datasets: datasets, labels: labels } ;
}

// --------------------------------------------------------------------

function createHotnessChart()
{
    // chart callbacks
    function tooltip_title( tooltipItem, data ) {
        // NOTE: We disable full tooltips because we don't have a lot of on-screen space
        // and if we're shorter than 90px, tooltips sometimes get cropped :-/
        return null ;
    }
    function tooltip_label( tooltipItem, data ) {
        var playerId = gHotnessPlayerIndex[ tooltipItem.index ] ;
        var playerName = gLogFileAnalysis.playerName( playerId ) ;
        return " " + playerName + ": " + signedVal(tooltipItem.value) ;
    }

    function signedVal( val ) {
        // return the value as a signed value
        val = parseFloat( val ) ;
        if ( val === 0 )
            return "0" ;
        var val2 = gHotnessChart && gHotnessChart.options.scales.xAxes[0].ticks.suggestedMax < 10 || Math.abs(val) < 10 ? fpFmt(val,1) : fpFmt(val,0) ;
        return val < 0 ? val2 : "+"+val2 ;
    }

    // create the chart
    var $canvas = $gHotness.find( "canvas" ) ;
    var chart = new Chart( $canvas, {
        type: "horizontalBar",
        options: {
            responsive: true,
            maintainAspectRatio: false,
            legend: { display: false },
            tooltips: {
                callbacks: { title: tooltip_title, label: tooltip_label },
            },
            scales: {
                xAxes: [ {
                    position: "top",
                    ticks: {
                        display: true,
                        callback: function( val ) { return signedVal( val ) ; },
                        fontSize: 10, fontStyle: "italic",
                    },
                    gridLines: { drawTicks: false },
                } ],
                yAxes: [ {
                    gridLines: { drawTicks: false },
                } ],
            },
        }
    } ) ;

    return chart ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function getHotnessData()
{
    // initialize
    gHotnessPlayerIndex = {} ;
    var dataVals=[], bgdColors=[], borderColors=[], labels=[] ;

    // generate the data
    var hotness = gLogFileAnalysis.calcHotness( gLfaStats ) ;
    gLogFileAnalysis.forEachPlayer( function( playerId ) {

        // add the next player
        gHotnessPlayerIndex[ dataVals.length ] = playerId ;
        var playerName = gLogFileAnalysis.playerName( playerId ) ;
        if ( playerName.length > 20 )
            playerName = playerName.substring(0,20).trim() + "..." ;
        labels.push( playerName + "  " ) ;

        // add the hotness score
        dataVals.push( hotness[ playerId ][0] ) ;
        var rollRatio = hotness[playerId][1] ;
        var alpha = fpFmt( Math.max( rollRatio, 0.5 ), 1 ) ;
        bgdColors.push( rollRatio >= 1 ? getPlayerColor2(playerId) : "rgba(224,224,224,"+alpha+")" ) ;
        borderColors.push( rollRatio >= 1 ? getPlayerColor(playerId) : "rgba(176,176,176,"+alpha+")" ) ;

    } ) ;

    return {
        datasets: [ {
            data: dataVals,
            borderColor: borderColors,
            backgroundColor: bgdColors,
            borderWidth: 1
        } ],
        labels: labels,
    } ;
}

// --------------------------------------------------------------------

function createTimePlotChart()
{
    // chart callbacks
    function tooltip_title( tooltipItem, data ) {
        var evt = gTimePlotEvents.events[ tooltipItem[0].index ] ;
        var rollNo ;
        if ( gTimePlotEvents.windowSize == 1 )
            rollNo = evt.rollNo ;
        else
            rollNo = (evt.rollNo - gTimePlotEvents.windowSize + 1) + "-" + evt.rollNo ;
        var msg = LogFileAnalysis.isSingleDie( evt.rollValue ) ? "dr": "DR" ;
        msg += " " + rollNo + " of " + gTimePlotEvents.nRolls[evt.playerId] ;
        return "<nobr>" + msg + "<nobr>" ;
    }
    function tooltip_label( tooltipItem, data ) {
        var playerId = gTimePlotDatasetPlayerIndex[ tooltipItem.datasetIndex ] ;
        return gLogFileAnalysis.playerName( playerId ) ;
    }
    function tooltip_footer( tooltipItem, data ) {
        var evt = gTimePlotEvents.events[ tooltipItem[0].index ] ;
        var msg ;
        if ( gTimePlotEvents.windowSize != 1 ) {
            var nFpDigits = $gMovingAverageDropList.val() < 50 ? 1 : 2 ;
            msg = fpFmt( evt.movingAverage, nFpDigits ) ;
        } else {
            // NOTE: We should return a string here, but since we implemented a custom tooltip function,
            // we return the actual values, so that we can select the appropriate die image.
            msg = [ evt.rollValue, evt.rollType ] ;
        }
        return msg ;
    }

    // create the chart
    var $canvas = $( "#lfa .time-plot canvas" ) ;
    var chart = new Chart( $canvas, {
        type: "line",
        options: {
            responsive: true,
            maintainAspectRatio: false,
            legend: {
                position: "top", align: "start",
            },
            tooltips: {
                footerFontStyle: "normal",
                callbacks: { title: tooltip_title, label: tooltip_label, footer: tooltip_footer },
                enabled: false, custom: customTimePlotTooltip,
            },
            scales: {
                xAxes: [ {
                    ticks: {
                        minRotation: 0, maxRotation: 30,
                    },
                    gridLines: { drawOnChartArea: false },
                } ],
                yAxes: [ {
                    ticks: {
                        display: false,
                        suggestedMin: 2, suggestedMax: 12, stepSize: 1,
                        callback: function( val, index, vals ) { return gTimePlotValOffset+val ; },
                    },
                } ],
            },
            animation: {
                onComplete: function() {
                    if ( gIsInitialLoad )
                        onInitialLoadCompleted() ;
                },
            },
        }
    } ) ;

    return chart ;
}

function customTimePlotTooltip( tooltipModel )
{
    // NOTE: This creates a tooltip completely from scratch (so we can include the die images).
    // Adapted from:
    //   https://www.chartjs.org/docs/latest/configuration/tooltip.html?h=html#external-custom-tooltips

    // locate the tooltip element
    var tooltipElem = $( "#lfa .timePlot-tooltip" )[ 0 ] ;
    if ( tooltipModel.opacity === 0 ) {
        tooltipElem.style.opacity = 0 ;
        return ;
    }

    // set the caret position
    tooltipElem.classList.remove( "above", "below", "no-transform" ) ;
    if ( tooltipModel.yAlign )
        tooltipElem.classList.add( tooltipModel.yAlign ) ;
    else
        tooltipElem.classList.add( "no-transform" ) ;

    function addDieImage( buf, dieVal, color ) {
        buf.push(
            "<img src='" + makeDieImageUrl(dieVal,color) + "' style='height:1.5em;float:left;margin:0.25em 0.25em 0 0;'>"
        ) ;
    }

    // generate the tooltip HTML
    if ( tooltipModel.body ) {
        // insert the title
        var buf = [ "<thead>" ] ;
        var titleLines = tooltipModel.title || [] ;
        titleLines.forEach( function( title ) {
            buf.push( "<tr> <th style='text-align:left;'>", title ) ;
        } ) ;
        buf.push( "</thead>" ) ;
        // insert the body
        buf.push( "<tbody>", "<tr>", "<td>" ) ;
        tooltipModel.body.forEach( function( bodyItem ) {
            bodyItem.lines.forEach( function( bodyItemLine ) {
                buf.push( bodyItemLine, "<br>" ) ;
            } ) ;
        } ) ;
        // insert the footer
        if ( gTimePlotEvents.windowSize != 1 ) {
            // we are showing moving averages
            buf.push( "<span style='font-style:italic;font-size:120%;'>", tooltipModel.footer[0], "</span>" ) ;
        } else {
            // we are showing the raw DR/dr's
            var dieVals = tooltipModel.footer[ 0 ] ;
            if ( LogFileAnalysis.isSingleDie( dieVals ) )
                addDieImage( buf, dieVals, "yellow" ) ;
            else {
                addDieImage( buf, dieVals[0], "yellow" ) ;
                for ( var i=1 ; i < dieVals.length ; ++i )
                    addDieImage( buf, dieVals[i], "white" ) ;
            }
            buf.push(
                "<div style='display:inline-block;height:1.8em;line-height:1.8em;margin-left:0.25em;font-style:italic;'>",
                tooltipModel.footer[1],
                "</div>"
            ) ;
        }
        buf.push( "</tbody>" ) ;
        // update the tooltip
        var tableRoot = tooltipElem.querySelector( "table" ) ;
        tableRoot.innerHTML = buf.join( "" ) ;
        tableRoot.style.color = tooltipModel.bodyFontColor ;
    }

    // configure the tooltip
    var marginX=8, marginY=4 ;
    var position = this._chart.canvas.getBoundingClientRect() ;
    tooltipElem.style.opacity = 1 ;
    tooltipElem.style.position = "absolute" ;
    var newLeft = position.left + window.pageXOffset + tooltipModel.caretX + marginX ;
    if ( newLeft >= position.width - tooltipElem.offsetWidth - 20 )
        newLeft = tooltipModel.caretX - tooltipElem.offsetWidth ;
    tooltipElem.style["z-index"] = 150 ; // nb: put this on top of the hotness popup
    tooltipElem.style.left = newLeft + "px" ;
    tooltipElem.style.top = position.top + window.pageYOffset + tooltipModel.caretY - tooltipElem.offsetHeight - marginY + "px" ;
    tooltipElem.style.background = tooltipModel.backgroundColor ;
    tooltipElem.style.fontFamily = tooltipModel._bodyFontFamily ;
    tooltipElem.style.fontSize = tooltipModel.bodyFontSize + "px" ;
    tooltipElem.style.fontStyle = tooltipModel._bodyFontStyle ;
    tooltipElem.style.padding = tooltipModel.yPadding + "px " + tooltipModel.xPadding + "px" ;
    tooltipElem.style.pointerEvents = "none" ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function getTimePlotData( valOffset )
{
    // initialize
    gTimePlotDatasetPlayerIndex = {} ;
    gTimePlotValOffset = gShowTabularData ? 0 : valOffset ;
    var datasets = [] ;

    // generate the data for each player
    gLogFileAnalysis.forEachPlayer( function( playerId ) {

        // get the rolls for the next player
        var dataVals=[], nRealVals=0 ;
        gTimePlotEvents.events.forEach( function( evt ) {
            if ( evt === null ) {
                dataVals.push( null ) ;
                return ;
            }
            if ( evt.playerId == playerId ) {
                dataVals.push( evt.movingAverage - gTimePlotValOffset ) ;
                ++ nRealVals ;
            } else {
                dataVals.push( null ) ;
            }
        } ) ;
        if ( nRealVals === 0 )
            return ;
        nRealVals += gTimePlotEvents.windowSize - 1 ;

        // add a dataset for the player's rolls
        gTimePlotDatasetPlayerIndex[ datasets.length ] = playerId ;
        datasets.push( {
            type: "line",
            label: gLogFileAnalysis.playerName( playerId ) + " (" + nRealVals + ")",
            data: dataVals,
            borderColor: getPlayerColor(playerId),
            borderWidth: 1,
            backgroundColor: getPlayerColor2(playerId),
            pointBackgroundColor: getPlayerColor(playerId),
            fill: false,
            lineTension: gTimePlotEvents.windowSize == 1 ? 0 : 0.4,
            spanGaps: true,
        } ) ;

    } ) ;

    return { datasets: datasets } ;
}

// --------------------------------------------------------------------

function updateLayout()
{
    // FUDGE! Charts really don't like being resized, and even the technique of making them responsive
    // and putting them inside a wrapper div, and resizing that, isn't completely reliable :-(
    // I eventually gave up and lay things out using Javascript :-/

    // initialize
    const availableWidth = $( ".ui-dialog.lfa" ).width() - 25 ;
    const optionsWidth = $gOptions.outerWidth() ;
    const pieChartHeight = 90 ;

    function setDistribWidth() {
        // set the width of the DR distribution chart
        var newWidth = availableWidth * 0.7 ;
        var maxWidth = availableWidth - $distrib2.position().left ;
        newWidth = Math.min( newWidth, maxWidth ) ;
        newWidth = Math.max( newWidth, 1.5*optionsWidth ) ;
        $distrib2.width( newWidth ) ;
    }

    // resize the DR distribution chart
    var $distrib2 = $( "#lfa .distrib.d6x2" ) ;
    var availableHeight ;
    if ( ! gShowTabularData ) {
        setDistribWidth() ;
        availableHeight = $("#lfa .top-pane").height() - pieChartHeight ;
        $distrib2.height( availableHeight ) ;
        gDistribCharts.DR.resize() ;
    }

    // resize the dr distribution chart
    $distrib1 = $( "#lfa .distrib.d6x1" ) ;
    if ( ! gShowTabularData ) {
        newWidth = availableWidth * 0.3 ;
        $distrib1.width( Math.max( newWidth, optionsWidth ) ) ;
        var newBottom = $distrib2.position().top + $distrib2.height() + 6 ;
        $distrib1.height( newBottom - $distrib1.position().top ) ;
        gDistribCharts.dr.resize() ;
    }

    // FUDGE! If we restore a maximized window, the DR distribution chart is resized too narrow.
    // It seems to have something to do with adjusting the width of the dr distribution chart,
    // so we do it again here :-/
    setDistribWidth() ;

    // resize the time-plot chart
    // NOTE: Resizing horizontally is more complicated (since it depends on whether or not we're showing
    //  a scrollbar, which depends on how many points there are), which is done in zoomTimePlotChart().
    if ( ! gShowTabularData ) {
        var $timePlot = $( "#lfa .time-plot .wrapper" ) ;
        availableHeight = $("#lfa .bottom-pane").height() - 20 ; // nb: leave some room for the h-scrollbar.
        $timePlot.height( availableHeight ) ;
        gTimePlotChart.resize() ;
    }

    // position the hotness chart
    var nPlayers = gLogFileAnalysis.playerIds().length ;
    var barHeight = Math.max( 12 - 2*nPlayers, 6 ) ;
    var newHeight = 50 + barHeight * nPlayers ;
    $gHotness.css( { height: newHeight } ) ;
    var newImageHeight = 48 - 3 * nPlayers ;
    $gHotness.find( "img.dice" ).css( { height: newImageHeight } ) ;

    // re-position any "no data" signs
    $gDialog.find( ".no-data" ).each( function() {
        positionNoData( $(this) ) ;
    } ) ;
}

function updateUi()
{
    // update the UI
    if ( $gDistribBarGraphsCheckBox.is( ":checked" ) )
        $gStackBarGraphsCheckBox.removeAttr( "disabled" ) ;
    else
        $gStackBarGraphsCheckBox.attr( "disabled", "disabled" ) ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

var gPrevChartColors = {} ;

function updateChartForNoData( hasNoData, chart, $parentElem )
{
    // initialize
    var $noDataElem = $parentElem.children( ".no-data" ) ;

    if ( hasNoData ) {

        // flag that the chart has no data
        if ( $noDataElem.length === 0 ) {
            var $sign = $( "<div class='no-data'> No data </div>" ) ;
            $parentElem.append( $sign ) ;
            positionNoData( $sign ) ;
        }
        // grey out chart elements
        var color = "#ccc" ;
        chart.options.scales.xAxes[0].scaleLabel.fontColor = color ;
        chart.options.scales.xAxes[0].ticks.fontColor = color ;
        chart.options.scales.xAxes[0].gridLines.zeroLineColor = color ;
        chart.options.scales.yAxes[0].gridLines.zeroLineColor = color ;

    } else {

        // remove the "no data" marker
        $noDataElem.remove() ;
        // restore color to chart elements
        if ( ! gPrevChartColors[ chart ] ) {
            gPrevChartColors[ chart ] = {
                scaleLabel: chart.options.scales.xAxes[0].scaleLabel.fontColor,
                ticks: chart.options.scales.xAxes[0].ticks.fontColor,
                xZeroLineColor: chart.options.scales.xAxes[0].gridLines.zeroLineColor,
                yZeroLineColor: chart.options.scales.yAxes[0].gridLines.zeroLineColor,
            } ;
        } else {
            chart.options.scales.xAxes[0].scaleLabel.fontColor = gPrevChartColors[ chart ].scaleLabel ;
            chart.options.scales.xAxes[0].ticks.fontColor = gPrevChartColors[ chart ].ticks ;
            chart.options.scales.xAxes[0].gridLines.zeroLineColor = gPrevChartColors[ chart ].xZeroLineColor ;
            chart.options.scales.yAxes[0].gridLines.zeroLineColor = gPrevChartColors[ chart ].yZeroLineColor ;
        }

    }

    // hide axis ticks if the chart has no data
    chart.options.scales.yAxes[0].ticks.display = ! hasNoData ;
}

function positionNoData( $noData )
{
    // position the "no data" sign
    var $parent = $noData.parent() ;
    $noData.css( {
        position: "absolute",
        left: ($parent.width() - $noData.width()) / 2,
        bottom: ($parent.height() - $noData.height()) / 2,
    } ) ;
}

function updatePieChartForNoData( noData, chart )
{
    // FUDGE! Loading a pie chart with no data makes it disappear :-/ We show a disabled pie chart
    // by loading it with a single dummy data point and changing the color to grey.
    if ( noData ) {
        // flag that the chart has no data
        chart.data.datasets = [ { data: [-1], backgroundColor: "#f0f0f0" } ] ;
        chart.options.tooltips.enabled = false ;
        chart.options.hover.mode = null ;
        chart.options.plugins.labels = false ;
    } else {
        // flag that the chart has data
        chart.options.tooltips.enabled = true ;
        chart.options.hover.mode = "single" ;
        chart.options.plugins.labels = true ;
    }
}

// --------------------------------------------------------------------

function updateChartColors( playerId, newColor )
{
    if ( ! playerId )
        return ;

    // initialize
    var key, i ;

    // update the DR/dr distribution charts
    function isDatasetMatch( datasetNo ) {
        if ( playerId === ":expected:" )
            return datasetNo == gDistribCharts[key].data.datasets.length - 1 ; // nb: "expected results" is the last dataset
        else
            return  gDistribDatasetPlayerIndex[key][ datasetNo ] == playerId ;
    }
    for ( key in DR_CLASS_IDS ) {
        for ( i=0 ; i < gDistribCharts[key].data.datasets.length ; ++i ) {
            if ( isDatasetMatch( i ) ) {
                gDistribCharts[key].data.datasets[ i ].borderColor = newColor ;
                gDistribCharts[key].data.datasets[ i ].backgroundColor = playerId === ":expected:" ? newColor : makePlayerColor2( newColor ) ;
                gDistribCharts[key].data.datasets[ i ].pointBackgroundColor = newColor ;
                gDistribCharts[key].update() ;
                break ;
            }
        }
    }

    // update the DR/dr pie charts
    for ( key in DR_CLASS_IDS ) {
        if ( gPieCharts[key].data.datasets.length === 0 )
            continue ;
        for ( i=0 ; i < gPieCharts[key].data.datasets[0].data.length ; ++i ) {
            if ( gPieDatasetPlayerIndex[ key ][i] == playerId ) {
                gPieCharts[key].data.datasets[0].borderColor[ i ] = newColor ;
                gPieCharts[key].data.datasets[0].backgroundColor[ i ] = makePlayerColor2( newColor ) ;
                gPieCharts[key].update() ;
                break ;
            }
        }
    }

    // update the time-plot chart
    for ( i=0 ; i < gTimePlotChart.data.datasets.length ; ++i ) {
        if ( gTimePlotDatasetPlayerIndex[ i ] == playerId ) {
            gTimePlotChart.data.datasets[ i ].borderColor = newColor ;
            gTimePlotChart.data.datasets[ i ].backgroundColor = makePlayerColor2( newColor ) ;
            gTimePlotChart.data.datasets[ i ].pointBackgroundColor = newColor ;
            gTimePlotChart.update() ;
            break ;
        }
    }

    // update the hotness chart
    var dataset = gHotnessChart.data.datasets[ 0 ] ;
    for ( i=0 ; i < dataset.data.length ; ++i ) {
        if ( gHotnessPlayerIndex[i] == playerId ) {
            dataset.borderColor[i] = newColor ;
            dataset.backgroundColor[i] = makePlayerColor2( newColor ) ;
            gHotnessChart.update() ;
            break ;
        }
    }
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function getPlayerColor( playerId ) { return gUserSettings.lfa["player-colors"][ gPlayerColorIndex[playerId] ] ; }
function getPlayerColor2( playerId ) { return makePlayerColor2( gUserSettings.lfa["player-colors"][ gPlayerColorIndex[playerId] ] ) ; }

function makePlayerColor2( color ) {
    color = tinycolor( color ) ;
    color.setAlpha( 0.2 ) ;
    return color.toRgbString() ;
}

// --------------------------------------------------------------------

function onDownloadData()
{
    // NOTE: Everything is handled as UTF-8, but as usual, dealing with Excel is problematic :-/
    // We could insert a BOM, but there are known issues with Excel barfing on these. Without one,
    // the user may have to import the file via the Data menu (configuring the encoding and delimiter),
    // but at least it will work (and only needs to be done if there is non-ASCII content).
    // We could also set the charset in the Content-Type header, but that doesn't help
    // when the user saves the download in a file, then tries to open it in another application.

    // initialize
    var buf = [ '"Log file","Phase","Player","Type","Die 1","Die 2"\n' ] ;
    function safeVal( val ) {
        return '"' + strReplaceAll(val,'"','""') + '"' ;
    }

    // process each event
    var nextLogFilename=null, nextLabel=null ;
    gLogFileAnalysis.extractEvents( 1, {
        onLogFileEvent: function( evt ) {
            // save the log filename (it will be included in the next row of data)
            nextLogFilename = evt.filename ;
        },
        onTurnTrackEvent: function( evt ) {
            // save the phase (it will be included in the next row of data)
            nextLabel = evt.side + " " + evt.turnNo + " " + evt.phase ;
        },
        onCustomLabelEvent: function( evt ) {
            // save the custom label (it will be included in the next row of data)
            nextLabel = evt.caption ;
        },
        onRollEvent: function( evt ) {
            // generate the next row of data
            if ( nextLogFilename ) {
                buf.push( safeVal( nextLogFilename ) ) ;
                nextLogFilename = null ;
            }
            buf.push( "," ) ;
            if ( nextLabel ) {
                buf.push( safeVal( nextLabel ) ) ;
                nextLabel = null ;
            }
            buf.push( "," ) ;
            buf.push( safeVal( gLogFileAnalysis.playerName( evt.playerId ) ) ) ;
            buf.push( "," ) ;
            buf.push( safeVal( evt.rollType ) ) ;
            buf.push( "," ) ;
            if ( LogFileAnalysis.isSingleDie( evt.rollValue ) )
                buf.push( evt.rollValue, "," ) ;
            else
                buf.push( evt.rollValue[0], ",", evt.rollValue[1] ) ;
            buf.push( "\n" ) ;
        },
    } ) ;

    // return the data to the user
    var data = buf.join( "" ).trim() ;
    if ( getUrlParam( "lfa_persistence" ) )
        $( "#_lfa-download_"). val( data ) ;
    else {
        closeAllPopupsAndDropLists() ;
        if ( gWebChannelHandler )
            gWebChannelHandler.save_log_file_analysis( data ) ;
        else
            download( toUTF8(data), "analysis.csv", "application/text" ) ;
    }
}

// --------------------------------------------------------------------

gTabularDataSeqNo = 0 ;

function makeTabularDataHtml( labels, datasets, nFpDigits )
{
    // NOTE: This is for testing porpoises only. It shows the chart data
    // in tables, so that the test suite can easily extract it.

    // figure out how many rows we have
    var nRows = labels.length ;
    var i, j ;
    datasets.forEach( function( dataset ) {
        if ( dataset.data.length > nRows )
            nRows = dataset.data.length ;
    } ) ;

    var buf = [] ;
    function pushVal( vals, valNo ) {
        if ( valNo < vals.length ) {
            if ( typeof vals[valNo] === "number" && nFpDigits !== undefined )
                buf.push( fpFmt( vals[valNo], nFpDigits ) ) ;
            else
                buf.push( vals[valNo] ) ;
        } else {
            buf.push( "???" ) ; // nb: should never get here!
        }
    }

    // generate the table HTML
    buf.push( "<table class='chart-data' data-seqno='" + (++gTabularDataSeqNo) + "'>" ) ;
    if ( datasets.length > 0 ) {
        buf.push( "<tr>", "<th>" ) ;
        datasets.forEach( function( dataset ) {
            buf.push( "<th>", dataset.label ) ;
        } ) ;
        for ( i=0 ; i < nRows ; ++i ) {
            buf.push( "<tr>", "<td class='label'>" ) ;
            pushVal( labels, i ) ;
            for ( j=0 ; j < datasets.length ; ++j ) {
                buf.push( "<td>" ) ;
                pushVal( datasets[j].data, i ) ;
            }
        }
    }
    buf.push( "</table>" ) ;

    return buf.join( "" ) ;
}

// --------------------------------------------------------------------

var gOriginalLineDraw = Chart.controllers.line.prototype.draw ;

Chart.helpers.extend( Chart.controllers.line.prototype, { draw: function() {

    // NOTE: We install this function into the chart prototype, to draw extra vertical lines
    // in the time-plot chart that indicate the start of each new log file.

    // initialize
    gOriginalLineDraw.apply( this, arguments ) ;
    var chart = this.chart ;

    // figure out where we need to draw vertical lines
    var verticalLines = chart.config.data.verticalLines ;
    if ( ! verticalLines )
        return ;

    // draw each vertical line
    var ctx = chart.chart.ctx ;
    verticalLines.forEach( function( index ) {
        var xAxis = chart.scales[ "x-axis-0" ] ;
        var yAxis = chart.scales[ "y-axis-0" ] ;
        ctx.save() ;
        ctx.beginPath() ;
        var xPos = xAxis.getPixelForValue( undefined, index ) ;
        ctx.moveTo( xPos, yAxis.top ) ;
        ctx.lineWidth = 1 ;
        ctx.setLineDash( [ 5, 3 ] ) ;
        ctx.strokeStyle = "#d0d0d0" ;
        ctx.lineTo( xPos, yAxis.bottom ) ;
        ctx.stroke() ;
        ctx.restore() ;
    } ) ;

} } ) ;

// --------------------------------------------------------------------

function closeAllPopupsAndDropLists()
{
    // close all popups
    $gPlayerColorsPopup.hide() ;
    $gPlayerColorsPopup.find( ".color-picker" ).each( function() {
        var prevColor = $(this).attr( "data-prev-color" ) ;
        if ( prevColor )
            $(this).spectrum( "set", prevColor ) ;
        $(this).spectrum( "hide") ;
    } ) ;
    $gSelectFilePopup.hide() ;
    $gHotnessPopup.hide() ;

    // close all droplists
    $( "#lfa select" ).each( function() {
        $(this).selectmenu( "close" ) ;
    } ) ;
}

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

function setChartAnimation( options ) {
    // The ChartJS docs claim that you can disable animations globally via Chart.defaults.global.animation,
    // but it doesn't seem to work :-/ We set it on each individual chart.
    if ( ! options.animation )
        options.animation = {} ;
    options.animation.duration = $gDisableAnimationsCheckBox.is( ":checked" ) ? 0 : 1000 ;
}

function makeArray( val, count ) {
    // create an array consisting of a repeated value
    var arr = [] ;
    for ( var i=0 ; i < count ; ++i )
        arr.push( val ) ;
    return arr ;
}

function checkRollType( evt, rollType ) { return evt.rollType === rollType ; }
function makeDieImageUrl( dieVal, color ) { return gImagesBaseUrl + "/lfa/die/" + color + "/" + dieVal + ".png" ; }

// --------------------------------------------------------------------

} )() ; // end local namespace
