'use strict';

// Solar Coaster - depict Sol and Luna rise, set and eclipses. Inspired by the Apple Watch face Solar (now Solar Graph).

const version = "26.3";

// MARK: - Sun constructor

function Sun( name, mass, radius, spectralClass, metallicity, temperature, luminosity, visualMagnitude ) { // Sun constructor

    const earthSunA = 149597870.7; // semi-major axis km, 1 AU
    const earthSunP = 365.25636; //  period dy, 1 sidereal yr
    const earthSunE = 0.0167086; // eccentricity
    const earthSunB = 449; // barycenter km

    this.name = name
    this.mass = mass // kg
    this.radius = radius // km
    this.spectralClass = spectralClass
    this.metallicity = metallicity
    this.temperature = temperature // K
    this.luminosity = luminosity // W (J/s)
    this.visualMagnitude = visualMagnitude
    this.sol = document.getElementById( 'sol' )
    this.solHalo = document.getElementById( 'solHalo' )
    this.horizon = document.getElementById( 'solarHorizon' )

    this.rightAscension = 0
    this.declination = 0
    this.maxAlt = 0
    
    this.previousSpeed = 0; // current - previous > 0 if trending faster (km/dy)
    this.speedTrend = '-';
    this.previousDistance = 0; // current - previous > 0 if trending larger (km)
    this.distanceTrend = '-';

    this.angularDiameterFromEarth = function ( now ) {
        
        var [d, trend] = this.earthDistance( now );
        var ad = 2 * Math.asin( ( 2 * this.radius ) / ( 2 * d ) );
        return ( ad * 180 ) / Math.PI;
        
    } // angularDiameterFromEarth

    this.animate = function ( minute ) {
        
        // Sun's right ascension, declination and max altitude. From https://aa.usno.navy.mil/faq/sun_approx

        let now = getNowDateObject ()
        let D = getJ2000Date(now)

        let g = 357.529 + 0.98560028 * D // mean anomaly degrees
        let q = 280.459 + 0.98564736 * D // mean longitude degrees
        g %= 360.0
        q %= 360.0
        let d2r = Math.PI / 180.0
        let r2d = 180.0 / Math.PI
        let L = q + (1.915 * Math.sin( g * d2r )) + (0.020 * Math.sin( 2 * g * d2r )) // geocentric apparent ecliptic longitude (adjusted for aberration)
        L %= 360.0
        let b = 0.0 // ecliptic latitude
        let R = 1.00014 - 0.01671 * Math.cos( g * d2r ) - 0.00014 * Math.cos( 2 * g * d2r ) // distance of the Sun from the Earth in AU
        let e = 23.439 - 0.00000036 * D // mean obliquity of the ecliptic
        //let tanRA1 = Math.atan( Math.cos( e * d2r ) * Math.sin( L * d2r ) / Math.cos( L * d2r ) )
        let tanRA2 = Math.atan2( Math.cos( e * d2r ) * Math.sin( L * d2r ), Math.cos( L * d2r ) )
        this.rightAscension = tanRA2 * r2d
        if ( this.rightAscension < 0.0 ) this.rightAscension += 360.0
        let sinDec = Math.asin( Math.sin( e * d2r ) * Math.sin( L * d2r  ) )
        this.declination = sinDec * r2d
        this.maxAlt = 90.0 - Math.abs( simEvent.locLat - this.declination )
        //console.log('tanRA', (this.rightAscension).toFixed(4), 'd', this.declination.toFixed(4), 'sunMaxAlt', this.maxAlt.toFixed(0))
 
        // Move the artificial horizon line to its proper "latitude" and adjust its length.
        
        let horizonCoord = getPointForMinute ( sunrise - solarSkew );
        this.horizon.setAttribute( 'y1', horizonCoord.y );
        this.horizon.setAttribute( 'y2', horizonCoord.y );
        let x1 = horizonCoord.x;
        horizonCoord = getPointForMinute( sunset - solarSkew );
        this.horizon.setAttribute( 'x1', x1 );
        this.horizon.setAttribute( 'x2', horizonCoord.x );
        
        // Translate the daylight more/less marker.
        
        let triangleName = ( trendingDayLength >= 0 ? 'triangleDn' : 'triangleUp' );
        let triangle = document.getElementById( triangleName );
        triangle.setAttribute( 'visibility', 'visible' );
        if ( triangleName == 'triangleDn' ) {
            document.getElementById( 'triangleUp' ).setAttribute( 'visibility', 'hidden' );
        } else {
            document.getElementById( 'triangleDn' ).setAttribute( 'visibility', 'hidden' );
        }
        let tx = 380;
        let ty = horizonCoord.y - 20;
        triangle.setAttribute( 'transform', 'translate(' + tx + ',' + ty + ')' );
        
        // Adjust details delta time so that horizon does not overlay it.
        
        if ( horizonCoord.y <= 760 ) {
            nowDelta.setAttribute( 'y', 820 )
        } else {
            nowDelta.setAttribute( 'y', 750 );
        }
        
        // Compute the path point corresponding to the current time of day and move Sol there, as well as
        // the halo.  Add filters and gradients as required, update title/time.
        
        let newCoord = getPointForMinute( minute - solarSkew )
        let trans = " translate(" + newCoord.x + "," + newCoord.y + ")"
        this.sol.setAttribute( "transform", trans )
        this.solHalo.setAttribute( "transform", trans )
        this.gradientFillSol( minute )
        updateTitle( minute )
        
        // Adjust various markers.
        
        let markers = {
            "dawnCircle"          : dawn,
            "duskCircle"          : dusk,
            "solarNoonCircle"     : solarNoon,
            "solarMidnightCircle" : solarMidnight,
            "solarMidnightCircleLeft" : solarMidnight + 1,
            "solarMidnightCircleRight" : solarMidnight - 1,
            "sunriseCircle"       : sunrise,
            "sunsetCircle"        : sunset,
            "nowCircle"           : getNowMinute(),
        };
        adjustMarkers( markers );
       
    } // animate
    
    this.earthAngularSpeed = function ( r ) { // Sun's angular speed relative to Earth at distance r km, in degrees/dy
        
        var omega = ( (360 / earthSunP) * (earthSunA / r) ) * Math.sqrt( (2 * (earthSunA / r)) - 1 );
        return omega;
        
    } // earthAngularSpeed
    
    this.earthOrbitalSpeed = function ( r ) { // Sun's linear speed relative to Earth at distance r km, in km/dy
        
        var s = ((2 * Math.PI * earthSunA) / earthSunP) * Math.sqrt( (2 * (earthSunA / r) - 1) );
        var deltaSpeed = s - this.previousSpeed;
        if ( deltaSpeed != 0 ) {
            this.speedTrend = ( deltaSpeed == 0 ? "-" : ( deltaSpeed > 0 ) ? unicodeUpTriangle : unicodeDownTriangle );
            this.previousSpeed = s;
        }
        return [s, this.speedTrend];
        
    } // earthOrbitalSpeed
    
    this.earthDistance = function ( now ) { // Earth - Sun distance r, in km
        
        // r = a * ( 1 - e*e ) / ( 1 + e * cos(θ) )
        //var fullYear = now.getFullYear()
        let fullYear = now.year
        //var perihelion = new Date( fullYear, 0, 3, 8, 0, 0, 0 );          // average 3 Jan 0800, the default
        let  perihelion = DateTime.fromObject( { year: fullYear, month: 1, day: 3, hour: 8, minute: 0, second: 0 } )   // average 3 Jan 0800, the default
        let periList = {                                                  // from the US Naval Observatory
            '2017'  :     'Jan   4 14 18',
            '2018'  :     'Jan   3 05 35',
            '2019'  :     'Jan   3 05 20',
            '2020'  :     'Jan   5 07 48',
            '2021'  :     'Jan   2 13 51',
            '2022'  :     'Jan   4 06 55',
            '2023'  :     'Jan   4 16 17',
            '2024'  :     'Jan   3 00 39',
            '2025'  :     'Jan   4 13 28',
            '2026'  :     'Jan   3 17 16',
            '2027'  :     'Jan   3 02 33',
            '2028'  :     'Jan   5 12 28',
            '2029'  :     'Jan   2 18 13',
            '2030'  :     'Jan   3 10 12',
            '2031'  :     'Jan   4 20 48',
            '2032'  :     'Jan   3 05 11',
            '2033'  :     'Jan   4 11 51',
            '2034'  :     'Jan   4 04 47',
            '2035'  :     'Jan   3 00 54',
            '2036'  :     'Jan   5 14 17',
            '2037'  :     'Jan   3 04 00',
            '2038'  :     'Jan   3 05 01',
            '2039'  :     'Jan   5 06 41',
            '2040'  :     'Jan   3 11 33',
            '2041'  :     'Jan   3 21 52',
            '2042'  :     'Jan   4 09 07',
            '2043'  :     'Jan   2 22 15',
            '2044'  :     'Jan   5 12 52',
            '2045'  :     'Jan   3 14 56',
            '2046'  :     'Jan   3 00 58',
            '2047'  :     'Jan   5 11 44',
            '2048'  :     'Jan   3 18 05',
            '2049'  :     'Jan   3 10 27',
            '2050'  :     'Jan   4 19 35',
            '2051'  :     'Jan   3 05 32',
            '2052'  :     'Jan   5 09 18',
            '2053'  :     'Jan   3 22 18',
            '2054'  :     'Jan   2 17 59',
            '2055'  :     'Jan   5 12 24',
            '2056'  :     'Jan   4 03 44',
            '2057'  :     'Jan   3 03 11',
            '2058'  :     'Jan   5 04 00',
            '2059'  :     'Jan   3 10 44',
            '2060'  :     'Jan   4 22 56',
            '2061'  :     'Jan   4 07 33',
            '2062'  :     'Jan   2 21 16',
            '2063'  :     'Jan   5 13 41',
            '2064'  :     'Jan   4 11 54',
            '2065'  :     'Jan   2 15 10',
            '2066'  :     'Jan   5 05 45',
            '2067'  :     'Jan   3 17 05',
            '2068'  :     'Jan   4 14 29',
            '2069'  :     'Jan   4 18 25',
            '2070'  :     'Jan   3 02 51',
            '2071'  :     'Jan   5 09 44',
            '2072'  :     'Jan   4 21 02',
            '2073'  :     'Jan   2 14 41',
            '2074'  :     'Jan   5 11 25',
            '2075'  :     'Jan   4 02 14',
            '2076'  :     'Jan   3 23 33',
            '2077'  :     'Jan   4 20 53',
            '2078'  :     'Jan   3 04 23',
            '2079'  :     'Jan   5 00 15',
            '2080'  :     'Jan   5 07 55',
            '2081'  :     'Jan   2 18 56',
            '2082'  :     'Jan   5 13 51',
            '2083'  :     'Jan   4 11 28',
            '2084'  :     'Jan   3 16 49',
            '2085'  :     'Jan   5 04 33',
            '2086'  :     'Jan   3 15 22',
            '2087'  :     'Jan   4 16 38',
            '2088'  :     'Jan   5 14 59',
            '2089'  :     'Jan   2 17 01',
            '2090'  :     'Jan   5 04 45',
            '2091'  :     'Jan   4 19 22',
            '2092'  :     'Jan   3 15 29',
            '2093'  :     'Jan   5 10 16',
            '2094'  :     'Jan   4 00 17',
            '2095'  :     'Jan   4 03 56',
            '2096'  :     'Jan   5 21 56',
            '2097'  :     'Jan   3 00 52',
            '2098'  :     'Jan   5 02 34',
            '2099'  :     'Jan   5 06 18',
            '2100'  :     'Jan   3 13 54',
        };
        for ( let key in periList ) {                                     // refine perihelion if possible
            if ( periList.hasOwnProperty( key ) ) {
                if ( key == fullYear ) {
                    var toks = periList[ key ].split( / +/ );
                    //perihelion = new Date( fullYear, 0, toks[1], toks[2], toks[3], 0, 0, 0 );
                    perihelion = DateTime.fromObject( { year: fullYear, month: 1, day: toks[ 1 ], hour: toks[ 2 ], minute: toks[ 3 ], second: 0 } )
                    break
                }
            }
        }
        //var olddays = ( now - perihelion ) / 1000.0 / 86400.0;               // milliseconds -> seconds -> days since perihelion
        let days = now.diff( perihelion , [ 'days' ] ).days
        //console.log('days='+days, 'olddays='+olddays)
        var degrees = days / ( earthSunP / 360.0 );                       // degrees along the orbital ellipse
        var radians = ( degrees * Math.PI ) / 180.0;                      // radians for the Math routines
        var unitsFactor = ( 1 ? 1.0 : 1.609344 );                         // TRUE for km, FALSE for miles
        //
        //var solarDist = ( ( a * ( 1 - e*e ) ) / ( 1 + e * Math.cos( radians ) ) - earthSunB ) / unitsFactor;    // distance
        //
        // I've had to split the above statement:  Xcode parser barfs, apparantly because the unitsFactor divide symbol is confused with a regexp.
        // The result is that the JS pulldown function list is truncated and it's not possible to navigate this source code.
        var earthDist = ( ( earthSunA * ( 1 - earthSunE * earthSunE ) ) / ( 1 + earthSunE * Math.cos( radians ) ) - earthSunB );
        earthDist /= unitsFactor;    // distance
        var deltaDistance = earthDist - this.previousDistance;
        if ( deltaDistance != 0 ) {
            this.distanceTrend = ( deltaDistance == 0 ? "-" : ( deltaDistance > 0 ) ? unicodeUpTriangle : unicodeDownTriangle );
            this.previousDistance = earthDist;
        }
        return [earthDist, this.distanceTrend];
        
    } // earthDistance

    this.gradientFillSol = function ( minute ) {
        
        if ( minute >= 0 && minute < dawn ) {
            this.isNight( minute );
        } else if ( ( minute > dawn ) && ( minute <= dawn + twiLen ) ) {
            this.isMorningTwilight( minute );
        } else if ( ( minute >= dawn + twiLen ) && ( minute < dusk - twiLen ) ) {
            this.isDay( minute );
        } else if ( ( minute >= dusk - twiLen ) && ( minute < dusk ) ) {
            this.isEveningTwilight( minute );
        } else if ( ( minute >= dusk ) ) {
            this.isNight( minute );
        }
        
    } // gradientfillSol

    this.isMorningTwilight = function ( minute ) {
        
        newTitle( "MORNING TWILIGHT", minute, dayMinute2HumanTime( minute ) )
        
        let m = minute - dawn // minutes 1, 2, 3, ... 60
        let x = twiLen / 2
        
        // Brighten Sol, and tighten and fadein the halo.
        //
        // The radial gradient controls the color of under-Sol. For the first x minutes of twilight the color varies
        // from, your pick, either black (0,0,0) or purple (128,0,128) to red (255,0,0). The last x minutes the color
        // varies from red (255,0,0) to yellow (255,255,0).  stop1 goes from 0% to 70%, stop2 is fixed at 100%.
        
        let stop1 =  document.getElementById("solarGradientStop1")
        let stop2 =  document.getElementById("solarGradientStop2")
        let p = ( ( twiLen - m ) / twiLen ) * 70 // stop p = 0 .. 70, percent - Note: also used for blur dispersion below
        stop1.setAttribute( 'offset', p + '%' )
        
        let r
        let g
        let b
        if ( m > x ) {        // last x minutes of twilight
            r = 255
            g = ( ( m - x ) / x) * 255
            g = Math.round( g )
            b = 0
        } else {            // first x minutes of twilight
            if ( nightSolColor == 'purple' ) {
                r = ( ( ( m - 0 ) / x ) * 128 ) + 128
                r = Math.round( r )
                g = 0
                b = ( 1 - ( ( m - 0 ) / x ) ) * 128
                b = Math.round( b )
            } else {        // from black
                r = ( ( m - 0 ) / x ) * 255
                r = Math.round( r )
                g = 0
                b = 0
            }
        }
        let color = rgbToHex( r, g, b )
        stop1.setAttribute( 'stop-color', color )
        stop2.setAttribute( 'offset', '100%' )
        stop2.setAttribute( 'stop-color', 'yellow' )
        
        // The blur filter stdDev controls the diffusness of the over-Sol halo.
        
        this.solHalo.setAttribute( "visibility", 'visible' )
        let blur = document.getElementById( 'solarBlur' )
        let stdDev
        stdDev = 72 + ( twiLen - m )
        blur.setAttribute( 'stdDeviation', stdDev)
        p = ( 100 - p ) / 100    // opacity p = 0.3 .. 1.0
        this.solHalo.setAttribute( 'opacity', p )
        
    } // isMorningTwilight

    this.isDay = function ( minute ) {
        
        newTitle( "DAY", minute, dayMinute2HumanTime( minute ) )
        
        // Sol is now bright, and the halo visible.
        
        let stop1 =  document.getElementById("solarGradientStop1")
        stop1.setAttribute( 'offset', '0%' )
        stop1.setAttribute( 'stop-color', 'yellow' )
        
        let stop2 =  document.getElementById("solarGradientStop2")
        stop2.setAttribute( 'offset', '100%' )
        stop2.setAttribute( 'stop-color', 'yellow' )
        
        this.solHalo.setAttribute( "visibility", 'visible' )
        let blur = document.getElementById( 'solarBlur' )
        blur.setAttribute( 'stdDeviation', 72 )
        this.solHalo.setAttribute( 'opacity', 1.0 )
        
    } // isDay

    this.isEveningTwilight = function ( minute ) {
        
        newTitle( "EVENING TWILIGHT", minute, dayMinute2HumanTime( minute ) )
        
        let m = dusk - minute // minutes 60, 59, 58 .... 1
        let x = twiLen / 2
        
        // Dim Sol, and dispserse and fadeout the halo.
        //
        // The radial gradient controls the color of under-Sol. For the first x minutes of twilight the color varies
        // from yellow (255,255,0) to red (255,0,0). The last x minutes the color varies from red (255,0,0) to, your
        // pick, either black (0,0,0) or purple (128,0,128). stop1 goes from 0% to 70%, stop2 is fixed at 100%.
        
        let stop1 =  document.getElementById("solarGradientStop1")
        let stop2 =  document.getElementById("solarGradientStop2")
        var p = ( ( twiLen - m ) / twiLen ) * 70 // stop p = 0 .. 70, percent - Note: also used for blur dispersion below
        stop1.setAttribute( 'offset', p + '%' )
        
        let r
        let g
        let b
        if ( m > x ) {        // first x minutes of twilight
            r = 255
            g = ( ( m - x ) / x) * 255
            g = Math.round( g )
            b = 0
        } else {            // last x minutes of twilight
            if ( nightSolColor == 'purple' ) {
                r = ( ( ( m - 0 ) / x ) * 128 ) + 128
                r = Math.round( r )
                g = 0
                b = ( 1 - ( ( m - 0 ) / x ) ) * 128
                b = Math.round( b )
            } else {        // to black
                r = ( ( m - 0 ) / x ) * 255
                r = Math.round( r )
                g = 0
                b = 0
            }
        }
        let color = rgbToHex( r, g, b )
        stop1.setAttribute( 'stop-color', color )
        stop2.setAttribute( 'offset', '100%' )
        stop2.setAttribute( 'stop-color', 'yellow' )
        
        // The blur filter stdDev controls the diffusness of the over-Sol halo.
        
        this.solHalo.setAttribute( "visibility", 'visible' )
        let blur = document.getElementById( 'solarBlur' )
        let stdDev
        stdDev = 72 + ( twiLen - m )
        blur.setAttribute( 'stdDeviation', stdDev)
        p = ( 100 - p ) / 100    // opacity p = 1.0 .. 0.3
        this.solHalo.setAttribute( 'opacity', p )
        
    } // isEveningTwilight

    this.isNight = function ( minute ) {
        
        newTitle( "NIGHT", minute, dayMinute2HumanTime( minute ) )
        
        // Sol is now dark, and the halo invisible.
        
        this.solHalo.setAttribute( "visibility", 'hidden' )
        
        let stop1 =  document.getElementById("solarGradientStop1")
        stop1.setAttribute( 'offset', '70%' )
        stop1.setAttribute( 'stop-color', nightSolColor )
        
        let stop2 =  document.getElementById("solarGradientStop2")
        stop2.setAttribute( 'offset', '100%' )
        stop2.setAttribute( 'stop-color', 'yellow' )
        
    } // isNight

} // Sun

// MARK: - Moon constructor

function Moon( name, mass, radius ) { // Moon constructor

    const earthMoonA = 384399; // semi-major axis km
    const earthMoonP = 27.321661; // period dy, 1 sidereal month
    const earthMoonE = 0.0549; // eccentricity
    const earthMoonB = 1700; // barycenter km

    this.name = name;
    this.mass = mass; // kg
    this.radius = radius; // km
    
    this.previousSpeed = 0; // current - previous > 0 if trending faster (km/dy)
    this.speedTrend = '-';
    this.previousDistance = 0; // current - previous > 0 if trending larger (km)
    this.distanceTrend = '-';
    this.lunaPath = document.getElementById( 'lunaPath')       // Luna's foreground (arc) path
    this.lunaBack = document.getElementById( 'lunaBack')       // Luna's background path
    this.lunaOutline = document.getElementById( 'lunaOutline') // Luna's stroke
    this.visible = 0
    this.rightAscension = 0
    this.declination = 0
    this.maxAlt = 0
    this.moonSunRelativePositionsIndex = 0 // if moon west of sun, else 1 if moon east of sun
    this.moonSunRelativePositions = [ // X-coordinates of the elements 'eclipseControls', 'discAndSlider', 'luna', 'comparePositionTracks', respectively
                                        [  0,    0, 1100,    0], // when moon west of sun
                                        [860, 1040,   90, -190], // when moon east of sun
                                   ]
    this.moonSunRelativePositionsMoonX = -1;
    this.moonSunRelativePositionsMoonHasSwapped = false
    
    this.angularDiameterFromEarth = function ( now ) {
        
        let [d, trend] = this.earthDistance( now )
        let ad = 2 * Math.asin( ( 2 * this.radius ) / ( 2 * d ) )
        return ( ad * 180 ) / Math.PI
        
   } // angularDiameterFromEarth

    this.canvasCoordsForProperIllumination = function () {
        
        return this.moonSunRelativePositions[ this.moonSunRelativePositionsIndex ]
        
    } // canvasCoordsForProperIllumination
    
    this.moonSunRelativePositionsMoonX = this.canvasCoordsForProperIllumination()[2]

    this.earthAngularSpeed = function ( r ) { // Moon's angular speed relative to Earth at distance r km, in degrees/dy
        
        let omega = ( (360 / earthMoonP) * (earthMoonA / r) ) * Math.sqrt( (2 * (earthMoonA / r)) - 1 )
        return omega
        
    } // earthAngularSpeed

    this.animate = function ( minute ) {
        
        // Update Luna's phase, visibility and altitude.
        
        this.createPhasedMoon ( minute  ) // draw Luna with altitude and phase
        this.setLunaOpacity( minute) // vary opacity around moonrise and moonset times
        adjustMarkers( { "moonriseCircle" : moonrise, "moonsetCircle" : moonset } );
        
    } // animate

    this.createPhasedMoon = function ( minute ) {
        
        // Simulate the moon's phase angle and altitude above the horizon.
        //
        // Update the SVG path with the proper arc corresponding to today's phase.  Compute the minute-by-minute
        // phase angle that varies continuously as Luna crosses the sky.  We do this according to SunCalc's note:
        //
        // By subtracting the parallacticAngle from the angle one can get the zenith angle of the moons bright
        // limb (anticlockwise). The zenith angle can be used do draw the moon shape from the observers perspective
        // (e.g. moon lying on its back).
        
        let hhh = Math.floor (minute / 60.0) // hour of day
        let mmm = minute - (hhh*60)      // minute of day
        let now = getNowDateObject()
       // now = new Date( now.getFullYear(), now.getMonth(), now.getDate(), hhh, mmm, 0, 0 )
       // now = new Date( now.getFullYear(), now.getMonth(), now.getDate(), 0, minute, 0, 0 )
        now = DateTime.fromObject( { year: now.year, month: now.month, day: now.day, hour: hhh, minute: mmm } )

        let lunaIllumination = SunCalc.getMoonIllumination( now )
        let phase = lunaIllumination.phase
        let moonPos = SunCalc.getMoonPosition( now, simEvent.locLat, simEvent.locLon)

        // Phase.
        
        let radians = lunaIllumination.angle - moonPos.parallacticAngle
        let crescentRotationAngle = radians * ( 180 / Math.PI ) // cresent rotation angle, degrees
        //console.log("illumAngle="+lunaIllumination.angle+", parallacticAngle="+moonPos.parallacticAngle+", radians="+radians+", crescentRotationAngle="+crescentRotationAngle);
        crescentRotationAngle = - crescentRotationAngle+270
        if ( lunaIllumination.angle > 0 ) {
            crescentRotationAngle = crescentRotationAngle + 180
        }
        let bbox = this.lunaBack.getBBox();
        let x = bbox.x + bbox.width / 2;
        let y = bbox.y + bbox.height / 2;
        let rotate = "  rotate(" +  crescentRotationAngle + "," + x + "," + y +")";
        this.lunaPath.setAttribute( "transform", rotate );

        let sweep = []
        let mag
        // the "sweep-flag" and the direction of movement change every quarter moon
        // zero and one are both new moon; 0.50 is full moon
        if (phase <= 0.25) {
            sweep = [ 1, 0 ]
            mag = 20 - 20 * phase * 4
        } else if (phase <= 0.50) {
            sweep = [ 0, 0 ]
            mag = 20 * (phase - 0.25) * 4
        } else if (phase <= 0.75) {
            sweep = [ 1, 1 ]
            mag = 20 - 20 * (phase - 0.50) * 4
        } else if (phase <= 1) {
            sweep = [ 0, 1 ]
            mag = 20 * (phase - 0.75) * 4
        } else {
            console.log("ABRT! minute="+minute)
            exit
        }
        
        mag /= 2
        let data = this.lunaPath.getAttribute( 'd' )
        let d = "m0,0 "
        d = d + "a" + mag + ",10 0 1," + sweep[0] + " 0,75 "
        d = d + "a10,10 0 1," + sweep[1] + " 0,-75"
        this.lunaPath.setAttribute( 'd', d )

        // Altitude above the horizon line: determine the height in pixels between solar noon and the horizon line,
        // then compute the fraction of that height that the moon is at which is used to compute the pixel distance
        // above the horizon.  The 75 is the pixel diameter of the SVG moon.
        
        // To get maxMoonAlt requires the moon's declination, reasonably approximated by the following formula.
        // But ... to get the right ascension required 800 lines of Meeus code - ugh - but as a byproduct we
        // get a more accurate declination... both are eventually computed in calculateMoonTopocentric().
        //
        // From https://astronomy.stackexchange.com/questions/29932/how-to-calculate-declination-of-moon
        //let t = getJ2000Date( now )
        //let u = t - 0.48 * Math.sin( ( 2 * Math.PI * ( t - 3.45 ) ) / 27.55455 )
        //let moonDecl1 = 23.45 * Math.sin( ( 2 * Math.PI * ( u - 10.75 ) ) / 27.32158 )
        //let moonDecl2 =  5.1  * Math.sin( ( 2 * Math.PI * ( u - 20.15 ) ) / 27.21222 )
        //moonDec = moonDecl1 + moonDecl2

        let lunarCoordinates = getMoonTopocentricCoords( now.toJSDate(), simEvent );
        let t = Math.trunc( lunarCoordinates[ 'ra' ] )
        let f = lunarCoordinates[ 'ra' ] - t
        this.rightAscension = 15.0 * t + f
        this.declination = lunarCoordinates[ 'dec' ]
        this.maxAlt = 90.0 - Math.abs( simEvent.locLat - this.declination )
        
        let lunarAltitudeDegrees = moonPos.altitude * ( 180 / Math.PI );
        this.maxAlt = Math.max( this.maxAlt, lunarAltitudeDegrees )
        let hY = document.getElementById( 'solarHorizon' ).getAttribute( 'y1' )
        let heightHorizon2SolarNoon = hY - getPointForMinute ( solarNoon - solarSkew ).y
        let fractionOfHeight = lunarAltitudeDegrees / 90.0
        let newY = hY - ( fractionOfHeight * heightHorizon2SolarNoon )
        newY -= ( 75.0 / 2.0 )
        
        // Moon is allowed to swap left/right X canvas position exactly once, but continuously change in Y.
        
        this.moonSunRelativePositionsIndex = ( phase > 0.5 ) ? 0 : 1
        let xPositions = this.canvasCoordsForProperIllumination()
        let newX = xPositions[2]
        if ( this.moonSunRelativePositionsMoonHasSwapped == false && newX != this.moonSunRelativePositionsMoonX ) {
            this.moonSunRelativePositionsMoonX = newX;
            this.moonSunRelativePositionsMoonHasSwapped = true
            if (typeof swapAssociatedElements === "function") { swapAssociatedElements( xPositions ) }
        }
        document.getElementById( 'luna' ).setAttribute( 'transform', 'translate( ' + this.moonSunRelativePositionsMoonX + ',' + newY + ')' );
        
        function swapAssociatedElements ( xPositions ) { // ensure eclipse controls and slider are opposite of the moon
            document.getElementById( 'eclipseControls' ).setAttribute( 'transform', 'translate( ' + xPositions[ 0 ] + ', 0)' );
            document.getElementById( 'discAndSlider' ).setAttribute(  'transform', 'translate( ' + xPositions[ 1 ] + ', 0)' );
            document.getElementById( 'comparePositionTracks' ).setAttribute(  'transform', 'translate( ' + xPositions[ 3 ] + ', 0)' );
        } // swapAssociatedElements

    } // createPhasedMoon

    this.earthOrbitalSpeed = function ( r ) { // Moon's linear speed relative to Earth at distance r km, in km/dy
        
        let s = ((2 * Math.PI * earthMoonA) / earthMoonP) * Math.sqrt( (2 * (earthMoonA / r) - 1) )
        let deltaSpeed = s - this.previousSpeed
        if ( deltaSpeed != 0 ) {
            this.speedTrend = ( deltaSpeed == 0 ? "-" : ( deltaSpeed > 0 ) ? unicodeUpTriangle : unicodeDownTriangle )
            this.previousSpeed = s
        }
        return [s, this.speedTrend]
        
    } // earthOrbitalSpeed
    
    this.earthDistance = function ( now ) { // Earth - Moon distance r, in km
        
        let moonPos = SunCalc.getMoonPosition( now.toJSDate(), simEvent.locLat, simEvent.locLon)
        let deltaDistance = moonPos.distance - this.previousDistance
        if ( deltaDistance != 0 ) {
            this.distanceTrend = ( deltaDistance == 0 ? "-" : ( deltaDistance > 0 ) ? unicodeUpTriangle : unicodeDownTriangle )
            this.previousDistance = moonPos.distance
        }
        return [moonPos.distance, this.distanceTrend]
        
    } // earthDistance
    
    this.phase = function ( now ) { // current moon phase
        
        let lunaIllumination = SunCalc.getMoonIllumination( now )
        let phase = lunaIllumination.phase
        phase = parseFloat( phase ).toFixed( 2 )
        var phaseHuman = "UnknownPhase"
        if ( phase == '0.00' ) {
            phaseHuman = 'New'
        } else if ( phase == '0.25' ) {
            phaseHuman = 'First Quarter'
        } else if ( phase == '0.50' ) {
            phaseHuman = 'Full'
        } else if ( phase == '0.75' ) {
            phaseHuman = 'Last Quarter'
        } else {
            phase = lunaIllumination.phase
            if ( phase > 0.00 && phase < 0.25 ) {
                phaseHuman = 'Waxing Crescent'
            } else if ( phase > 0.25 && phase < 0.50 ) {
                phaseHuman = 'Waxing Gibbous'
            } else if ( phase > 0.50 && phase < 0.75 ) {
                phaseHuman = 'Waning Gibbous'
            } else if ( phase > 0.75 ) {
                phaseHuman = 'Waning Crescent'
            } else {
                phaseHuman = 'unknown phase'
            }
        }
        let illumination = parseFloat( lunaIllumination.fraction * 100.0 ) // %
        return phaseHuman + " " + illumination.toFixed( illumination >= 100 ? 0 : 1 ) + '%'
        
    } // phase

    this.setLunaOpacity = function ( minute ) {
        
        let opacity = 1.0

        let lunaTimeInterval = moonset - moonrise // in minutes
        let flag = lunaTimeInterval  // > 0 if moonset > moonrise, < 0 if moonset < moonrise
        if ( lunaTimeInterval <= 0 ) {
            lunaTimeInterval = kLastMinute + lunaTimeInterval
        }
        let mins = minute - moonrise;
        if ( flag < 0 && mins < 0 ) {
            mins = kLastMinute - moonrise + minute
        }
        
        // Increase opacity from 0 -> 1 for 12 minutes after moonrise.
        // Decrease opacity from 1 -> 0 for 12 minutes before moonset.
        
        let fadeInStop =   ( 12 / lunaTimeInterval )
        let fadeOutStart = ( ( lunaTimeInterval -  12 ) / lunaTimeInterval )
        let now = (mins / lunaTimeInterval )
        if ( now <= fadeInStop ) {
            opacity = now / fadeInStop
        }
        if ( now >= fadeOutStart ) {
            opacity = ( 1 -  now ) / ( 1 - fadeOutStart )
        }
        this.lunaPath.setAttribute( 'opacity', opacity )
        this.lunaBack.setAttribute( 'opacity', opacity )
        this.lunaOutline.setAttribute( 'opacity', opacity )
        this.visible = opacity > 0

    } // end setLunaOpacity

} // end Moon

// MARK: - Globals

// MARK: ◦ Constants

const DateTime                 = luxon.DateTime
const DateTimeDuration         = luxon.Duration
const DateTimeInfo             = luxon.Info
const DateTimeInterval         = luxon.Interval
const DateTimeSettings         = luxon.Settings

const kHTMLWidth               = 521.0
const kHTMLHeight              = 960.0

const maxAltitude              = 26000
const maxLatitude              = 60.0
const minLatitude              = -61.0
const unicodeUpTriangle        = '\u21e1';
const unicodeDownTriangle      = '\u21e3';
const kGreenwichLatitude       = 51.477815
const kGreenwichLongitude      = -0.001502
const kGreenwichAltitude       = 47.244
const kGreenwichZone           = 'Europe/London'
const eType                    = {
    'P' : 'Partial',
    'T' : 'Total',
    'N' : 'Penumbral',
};
const eTypeSolar               = {
    'P' : 'Partial',
    'A' : 'Annular',
    'T' : 'Total',
};
const fullMonth1               = {
    'Jan' : 'January',
    'Feb' : 'February',
    'Mar' : 'March',
    'Apr' : 'April',
    'May' : 'May',
    'Jun' : 'June',
    'Jul' : 'July',
    'Aug' : 'August',
    'Sep' : 'September',
    'Oct' : 'October',
    'Nov' : 'November',
    'Dec' : 'December',
};

const month2Ord                = {
    'Jan' : '01',
    'Feb' : '02',
    'Mar' : '03',
    'Apr' : '04',
    'May' : '05',
    'Jun' : '06',
    'Jul' : '07',
    'Aug' : '08',
    'Sep' : '09',
    'Oct' : '10',
    'Nov' : '11',
    'Dec' : '12',
};

// User defaults keys.

const kSolarCoasterPrefix      = "com.bigcatos.SolarCoaster"

const kSolarCoasterSunTrack    = kSolarCoasterPrefix + ".SunTrack"
const kSolarCoasterLatitude    = kSolarCoasterPrefix + ".Latitude"
const kSolarCoasterLongitude   = kSolarCoasterPrefix + ".Longitude"
const kSolarCoasterAltitude    = kSolarCoasterPrefix + ".Altitude"
const kSolarCoasterTheme       = kSolarCoasterPrefix + ".Theme"
const kSolarCoaster24HourTime  = kSolarCoasterPrefix + ".24HourTime"
const kSolarCoasterSimDate     = kSolarCoasterPrefix + ".SimDate"
const kSolarCoasterCustPltDict = kSolarCoasterPrefix + ".CustomPaletteDictionaryString"
const kSolarCoasterSSATI       = kSolarCoasterPrefix + ".ScreenSaverAnimationTimeInterval"
const kSolarCoasterSSTZ        = kSolarCoasterPrefix + ".ScreenSaverTimeZone"
const kSolarCoasterSSEclipses  = kSolarCoasterPrefix + ".ScreenSaverEclipseSummary"

const kThemeCustom             = "Custom"

const kSimModeAutomatic        = 0
const kSimModeManual           = 2
const kLastMinute              = 24 * 60        // palette minute modulus
const kHexLenRGB               = 6
const kHexLenRGBA              = 8
const kHexLen                  = kHexLenRGBA    // count of hex digits in a color
const kHexColorRegEx           = /[a-f\d]{2}/ig // decompose hex color into components
const kBGPKey                  = 0
const kBGPMinute               = 1
const kBGPColor                = 2

// Misc

const kImminentEclipseSep      = "~~ImminentEclipseCircumstances~~"

// MARK: ◦ DOM Variables

// WTF!  I spent an hour wondering how a variable that *I did not define*, not
// only was defined, but had the value I wanted. Eventually I learned this behavior
// of implicitly defining globals was for real - mind appropriately boggled!
//
// Here then, is the list of potential implicit globals - which I shall not use. I'll
// define the globals I want to use explicitly, and then annotate that fact with a #.

/*
 
# about
  altitude
  backgroundThemeAuto
  backgroundThemeCustom
  backgroundThemeCustomImage
  bodyid
  boomerang
  boomerangDisclosure
  closePlotly
  comparePositionTracks
  comparePositionTracksBorder
  comparePositionTracksImage
# customize
# dawnCircle
  debugText
# detailedInfo1
# detailedInfo2
# detailedInfo3
# detailedInfo4
# detailedInfo5
# detailedInfo6
# detailedInfo7
# detailedInfo8
# detailedInfo9
# detailedInfo10
# detailedInfo11
# detailedInfo12
  diffPopup
  discAndSlider
  doAbout * 2
  doCustomize * 2
  doInfo
# duskCircle
  eclipse1
  eclipse2
  eclipse3
  eclipse4
  eclipseControls
  ellipsis-menu
  eclprb1
  eclprb2
  eclprb3
  hud
  latitude
  longitude
  luna
  lunaBack
  lunaClipMask
  lunaImage
  lunaOutline
  lunaPath
  lunarEclipseIcon
  modal-content-here
# moonriseCircle
# moonsetCircle
  myModalWindow
  nav-button
# nowCircle
# nowDelta
  offScreenMenu
  plotlyContent
  plotlyDiv
  settingsTable
  simDateNOW
  simDatePicker
  simModeManualFastMinutesBorder
  simModeManualFastMinutes
  slider
  sliderTrack
  sol
  solarBlur
  solarBlur2
  solarBlurFilter
  solarBlurFilter2
  solarcoaster
# solarDate
  solarEclipseIcon
  solarGradient
  solarGradientStop1
  solarGradientStop2
  solarHorizon
# solarMidnightCircle
# solarMidnightCircleLeft
# solarMidnightCircleRight
# solarNoonCircle
  solHalo
  suncalcTZ
# subtitle
# sunriseCircle
# sunriseTime
# sunsetCircle
# sunsetTime
  sunTrackElliptical
  sunTrackEllipticalButton
  sunTrackSinusoidal
  sunTrackSinusoidalButton
  svgElement
# timeFormat
# title
  triangleDn
  triangleUp
  version
  webVersion
 
 */

// MARK: ◦ JS Variables

var Sol                     // Sun singleton
var Luna                    // Moon singleton

var about                   // the About view
var activePaletteOrdinal    // index into paletteList[]
var backgroundPalette       // active background palette
var currentMinute           // minute currently being displayed
var customize               // the Customize view
var darkMode                // "" for light, "dark" for dark, *** set by Objective-C wrapper ***
var dawn                    // minute of day
var dawnCircle              // special marker
// Sun and Moon detailed info
var detailedInfo1
var detailedInfo2
var detailedInfo3
var detailedInfo4
var detailedInfo5
var detailedInfo6
var detailedInfo7
var detailedInfo8
var detailedInfo9
var detailedInfo10
var detailedInfo11
var detailedInfo12
// Sun and Moon detailed info
var dusk                    // minute of day
var duskCircle              // special marker
var eclipses                // list of eclipses for this year and next
var eclipsesVisible         // list of visible eclipses for this year and next
var ellipsisMenu            // the menu
var imminentEclipse         // circumstances of soonest eclipse, solar or lunar, for Screen Saver
var isWebkit                // false if Web App, else true (iOS, macOS, Screen Saver)
var isWebStyle              // if Web App or macOS or iPad
var lastCheckedPalette      // last palette selected by the user
var lastKnownMinute         // replay lost minutes counter (tween)
var macOS                   // 1 IFF Obj-C is running on macOS
var minutesInterval         // minutes between simulation updates
var minuteSlider            // draggable SVG element
var minuteToBackgroundColor // array of colors, indexed by simulated minute of the day
var moonrise                // minute of the day
var moonriseCircle          // special marker
var moonset                 // minute of the day
var moonsetCircle           // special marker
var myAlertActive           // TRUE IFF myAlert() has a modal active
var nightSolColor           // black or purple
var nowCircle               // special marker
var nowDelta                // +/ minute offset relative to Now
var offScreenMenu           // the ellipsis menu's view
var pathIsElliptical        // YES, or NO sinusoidal
var peTestInProgress        // set by Palette Editor when user has requested Test
// These times are for the backgroundPalette only, they are not cardinal times.
var postDusk
var postSunrise
var preDawn
var preSunset
// These times are for the backgroundPalette only, they are not cardinal times.
//var previousDayLength       // current - previous > 0 if trending longer (seconds)
var screenSaver             // 1 IFF running as a macOS ScreenSaver
var showDetails             // true to show the gore
var simEvent                // as in physics, a place and time dictionary
var solarCoasterBlue        // default daylight blue
var solarCoasterEmbed       // special configuration if an outside App has embedded us
var solarCoasterEmbedBgC    // optional static background color
var solarDate               // simulation date
var solarMidnight           // minute of day
var solarMidnightCircle     // special marker for elliptical track
var solarMidnightCircleLeft // special markers ...
var solarMidnightCircleRight// ... for sinusoidal track
var solarNoon               // minute of day
var solarNoonCircle         // special marker
var solarSkew               // basically the differnece between solar noon and real noon (allows for a horizontal horizon line)
var ssMinute                // ScreenSaver minute
var subtitle                // show details subtitle element
var sunrise                 // minute of day
var sunriseCircle           // special marker
var sunriseTime             // SVG Text element
var sunset                  // minute of day
var sunsetCircle            // special marker
var sunsetTime              // SVG Text element
var sunTrack                // Sol's SVG tracking path
var sunTrackLen             // length of path
var timeFormat              // 12/24 hour setting
var timerHandle             // so we can cancel timer
var title                   // show details title element
var trendingDayLength       // > 0 if trending longer (seconds)
var tweenTime = -1          // -1 when not tweening, 0 otherwise (the tween start time)
var tweenStart              // start minute of the simulated day
var tweenEnd                // end minute of the simulated day
var tweenTimeEnd            // either 1 or 2 seconds (the tween end time)
var tweenDT                 // milliseconds between tweens
var twiLen                  // twilight length in minutes
var tzHourDelta             // hours between here and user selected timezone
var urlParams               // parameters (GPS coords) from the URL
var use24HourClock          // 12 or 24 hour clock format
var userDefaultsDict        // user defaults from Obj-C

// MARK: - Initialization

function luxonTest() {
    
   // console.log('##### in luxonTest')

   // console.log('##### out luxonTest')

    /*
    let s1 = new Set( ['mousedown', 'touchstart'] )
    let s2 = new Set( ['mousemove', 'touchmove'] )
    let s3 = new Set( ['mouseend', 'touchend', 'touchcancel'] )
    console.log('s1',s1)
    let eventType = 'mousedown'
    console.log('has=', s1.has( eventType ) )
    let m1 = new Map( [ [s1, function(){console.log('func1')}], [s2, function(){console.log('func2')}], [s3, function(){console.log('func3')}] ] )
    //console.log('m1', m1)
    // define handlers
    
    
    
    // handle events
    for (const [key, value] of m1) {
      console.log(`${key}: ${value}`);
        if ( key.has( eventType ) ) {
            value()
            break
        }
    }
*/
    /*
    let zone = 'Asia/Kolkata'
    DateTimeSettings.defaultZone = zone
    let start = DateTime.now()
    let dur = DateTimeDuration.fromObject( { minutes: 1 } )

    for ( let m = 58; m < 63; m++ ) {
        start = start.plus( dur )
        console.log('m='+m+'  '+ start.toString())
    }
 
    let now = DateTime.now()
    let moonTimes = SunCalc.getMoonTimes( now.toJSDate(), simEvent.locLat, simEvent.locLon );
 //   console.log(moonTimes)

    
    //return
     */
    
    /*
    let now = new Date()                                                 // JS Date
    console.log('now=',now)
    let times = SunCalc.getTimes( now, 41.28, -96.2, 357 );
    console.log('1) sunrise here', times['sunrise'])
    
    now.setHours( now.getHours() + 10.5 )                                // manually add TWO TZ offsets (-5 -> +5.5)
    console.log('manually add 10.5 hours, new now=', now )
    times = SunCalc.getTimes( now, 28.63243, 77.21879, 216 )             // SunCalc
    console.log('2) OLD method,  sunrise there', times['sunrise'])
    */
    
    /*let zone = 'Asia/Kolkata'
    DateTimeSettings.defaultZone = zone;
    console.log('--- default TZ='+zone)
    
    let l = DateTime.fromJSDate( new Date() )                            // Lux Date
    console.log('luxNow date fromJSDate=', l.toString(), ' offset='+l.offset)
    console.log('luxNow date from Now()=', DateTime.now().toString(), ' offset='+DateTime.now().offset)
    let jsDate = new Date( l.year, l.month-1, l.day, l.hour, l.minute, l.second)  // create JS date, handle TZ automatically
    console.log('luxNow JSDate for SunCalc=',jsDate)
    let times = SunCalc.getTimes( jsDate, 28.63243, 77.21879, 216 )          // SunCalc
    //console.log('3) NEW method, sunrise there', times['sunrise'])
    
    console.log('####### GET SUNRISE IN LUX TIME')
    let l2 = DateTime.fromJSDate( times['sunrise'] )
    console.log('NEW sunrise lux', l2.toString())
    
    //let cow = DateTime.local(l.year, l.month, l.day, l.hour, l.minute, l.second)
    //console.log('cow='+cow.toString())
    
    //console.log('try #2 sunrise from JS date='+DateTime.fromJSDate( times['sunrise'] ).toString() )
*/

    /*
    console.log(luxon.Info.months())
    
    const dt = DateTime.local(2017, 5, 15, 8, 30)
    console.log(dt.toString(), dt)
    let tz ='America/Chicago'
    let dtTZ = DateTime.fromObject( {year:2017, month:5, day: 15, hour: 8, minute:30 }, { zone: tz})
    console.log(dtTZ.toString(), dtTZ)
    
    DateTimeSettings.defaultZone = "Asia/Tokyo";
    console.log('--- default TZ Asia/Tokyo')
    let dt2 = DateTime.local(2017, 5, 15, 8, 30)
    console.log(dt2.toString(), dt2)
     */

    //let nowOld = new Date( '2030-06-01T00:00:00')
    //let nowNew = DateTime.local( 2030, 6, 1, 0, 0, 0 )
    //let sunTimes =      SunCalc.getTimes( now, 28.63243, 77.21879, 0);
    //let moonTimes = SunCalc.getMoonTimes( now, 41.43,-96.39, true);

    //console.log(nowOld)
    //console.log(nowNew)
   // console.log(sunTimes)
    //console.log(moonTimes.rise, moonTimes.set)
    // Define the base class
    
    
    /*
    class Animal {
      constructor(name) {
        this.name = name;
      }
      speak() {
        console.log(`${this.name} makes a sound.`);
      }
    }

    // Define a subclass that extends the base class
    class Dog extends Animal {
      constructor(name, breed) {
        // Call the parent's constructor using the 'super' keyword
        super(name);
        this.breed = breed;
      }
      speak() {
        console.log(`${this.name} barks.`);
      }
    }

    // Instantiate the subclass multiple times to get unique instances
    const dog1 = new Dog('Fido', 'Golden Retriever');
    const dog2 = new Dog('Buddy', 'Labrador');
    const dog3 = new Dog('Lucy', 'Poodle');

    // Each instance is unique with its own properties
    console.log(dog1.name); // Output: Fido
    console.log(dog2.name); // Output: Buddy
    console.log(dog3.name); // Output: Lucy

    // Proving that the instances are distinct objects
    console.log(dog1 === dog2); // Output: false

    dog1.speak(); // Output: Fido barks.
    dog2.speak(); // Output: Buddy barks.
     
     */

} // luxonTest

window.onload = function () {
    
    DateTimeSettings.throwOnInvalid = true

    // There are two main phases of initialization:
    //
    // 1) fetchUserDefaults() creates userDefaultsDict{} with default or persistent values.
    //    This requires a second call to fetchUserDefaults() - which onload() does not make -
    //    and when this second call completes, fetchUserDefaults() initiates phase 2.
    //
    // 2) initializeSolarCoaster(), called by fetchUserDefaults(), then proceeds to complete
    //    initialization.
    
    fetchUserDefaults( 0 )        // get user defaults, first step

} // onload

function fetchUserDefaults( uDD ) {
    
    // Called *twice*, *before* real initialization: do not assume global variables are set.
    // Our job is simply to create a dictionary of user defaults for others to use.
    //
    // First call (uDD == 0), we message Obj-C to get a dictionary of user defaults,
    // or for the Web App create the default userDefaultsDict{}.
    //
    // Second call (uDD = userDefaultsDict) is the string represenation of the dict,
    // given to us from Obj-C which we eval, or, for the Web App, it's a real dictionary.
    // In either case, invoke initializeSolarCoaster() to move to the next initialization
    // phase.

    timeFormat = document.getElementById( 'timeFormat' )
    
    if ( uDD == 0 ) { // first call
        try {
            window.webkit.messageHandlers.fetchUserDefaults.postMessage( 'JS requesting userDefaults from Obj-C.' )
        }
        catch { // Web App, no user default settings, setup default options
            userDefaultsDict = {
                [kSolarCoasterSunTrack]    : "sinusoidal",
                [kSolarCoasterTheme]       : "Earth",
                [kSolarCoaster24HourTime]  : "yes",
                [kSolarCoasterSimDate]     : "",
                [kSolarCoasterLatitude]    : "",
                [kSolarCoasterLongitude]   : "",
                [kSolarCoasterAltitude]    : "",
                [kSolarCoasterCustPltDict] : "",
                [kSolarCoasterSSATI]       : "5",
                [kSolarCoasterSSTZ]        : "",
                [kSolarCoasterSSEclipses]  : "",
            }
            fetchUserDefaults ( userDefaultsDict ) // proceed to second step
        }
    } else { // second call, either from ourself or from Obj-C
        const keyCount = Object.keys( uDD ).length
        if ( keyCount != 11 ) { console.log( keyCount, "### User defaults dict must be 11=",uDD) }

        eval ( uDD ); // update userDefaultsDict{}
        initializeSolarCoaster() // dependancy satisfied, goto next phase
    }
    
} // fetchUserDefaults

function initializeSolarCoaster() {
    
    // initSolarCoaster() performs the bulk of initialization, and is a dependancy before
    // the Screen Saver can be initiialized. Afterwards, start the main animation loop.

    initSolarCoaster()
    initScreenSaver( 0 )
    resetAnimations()
    
} // initializeSolarCoaster

function initSolarCoaster() {

    Sol  = new Sun( 'Sol', 1.9885e+30, 6.95700e+5,  'G2 V', 0.0122, 5778, 3.828e+26, -26.74)
    Luna = new Moon( 'Luna', 7.347e+22, 1.7374e+3)

    simEvent = {      // instantiate the reference, prevents App crash during very early initialization
        date: userDefaultsDict[ kSolarCoasterSimDate ],
        dateOrig: '',
        ianaZone: kGreenwichZone,
        locAlt: kGreenwichAltitude,
        locLat: kGreenwichLatitude,
        locLon: kGreenwichLongitude,
        locOvr: '',
        mode: kSimModeAutomatic, // 0 = automatic, > 0 manual
    }

    document.getElementById( 'version' ).textContent = 'Solar Coaster ' // +  version;
    document.getElementById( 'webVersion' ).innerHTML = version

    isWebkit = ( window.webkit && window.webkit.messageHandlers ) ? true : false
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) { backgroundButtonChanged(); })

    document.addEventListener("visibilitychange", function() {
        /*
        if (document.visibilityState === 'visible') {
            resetAnimations()
        } else {
            dismissModal()
        }
         */
        dismissModal()
        resetAnimations()
    })

    // Often used DOM elements.
    
    dawnCircle               = document.getElementById( 'dawnCircle' )
    detailedInfo1            = document.getElementById( 'detailedInfo1' )
    detailedInfo2            = document.getElementById( 'detailedInfo2' )
    detailedInfo3            = document.getElementById( 'detailedInfo3' )
    detailedInfo4            = document.getElementById( 'detailedInfo4' )
    detailedInfo5            = document.getElementById( 'detailedInfo5' )
    detailedInfo6            = document.getElementById( 'detailedInfo6' )
    detailedInfo7            = document.getElementById( 'detailedInfo7' )
    detailedInfo8            = document.getElementById( 'detailedInfo8' )
    detailedInfo9            = document.getElementById( 'detailedInfo9' )
    detailedInfo10           = document.getElementById( 'detailedInfo10' )
    detailedInfo11           = document.getElementById( 'detailedInfo11' )
    detailedInfo12           = document.getElementById( 'detailedInfo12' )
    duskCircle               = document.getElementById( 'duskCircle' )
    moonriseCircle           = document.getElementById( 'moonriseCircle' )
    moonsetCircle            = document.getElementById( 'moonsetCircle' )
    nowCircle                = document.getElementById( 'nowCircle' )
    nowDelta                 = document.getElementById( 'nowDelta')
    solarDate                = document.getElementById( 'solarDate' )
    solarMidnightCircle      = document.getElementById( 'solarMidnightCircle' )
    solarMidnightCircleLeft  = document.getElementById( 'solarMidnightCircleLeft' )
    solarMidnightCircleRight = document.getElementById( 'solarMidnightCircleRight' )
    solarNoonCircle          = document.getElementById( 'solarNoonCircle' )
    subtitle                 = document.getElementById( 'subtitle')
    sunriseCircle            = document.getElementById( 'sunriseCircle' )
    sunriseTime              = document.getElementById( 'sunriseTime' )
    sunsetCircle             = document.getElementById( 'sunsetCircle' )
    sunsetTime               = document.getElementById( 'sunsetTime' )
    timeFormat               = document.getElementById( 'timeFormat' )
    title                    = document.getElementById( 'title')

    // Ellipsis menu and associated view elements.
    
    about = document.querySelector(".about");
    customize = document.querySelector(".customize");
    ellipsisMenu = document.querySelector(".ellipsis-menu");
    offScreenMenu = document.querySelector(".off-screen-menu");
    ellipsisMenu.addEventListener("click", () => {
        ellipsisClicked()
    });
    
    // Make Settings' dynamic palette radio buttons.
    
    for ( let n = paletteList.length - 1; n >= 1; n--) {
        initDynamicPaletteRadioButton( paletteList[ n ] );
    }
    
    // Set custom Settings / Options based on userDefaultsDict{}.
    
    activePaletteOrdinal = 1
    
    // --- Track and 24 hour time
    
    setSunTrack()
    use24HourClock = setTimeFormat( userDefaultsDict[ kSolarCoaster24HourTime ] == 'yes' ? 1 : 0 )
    timeFormat.checked = use24HourClock

    // --- Background

    if ( userDefaultsDict[ kSolarCoasterCustPltDict ] != "" ) {
        let paletteDictionaryString = userDefaultsDict[ kSolarCoasterCustPltDict ];
        eval( paletteDictionaryString ) // creates paletteDictionary{}
        document.getElementById( "backgroundThemeCustomImage" ).setAttribute( 'src', "data:image/png;base64, " + paletteDictionary[ "base64PNG-160x18" ] )
    }
    let defaultPaletteName = userDefaultsDict[ kSolarCoasterTheme ]
    if ( defaultPaletteName == kThemeCustom ) {
        let custom = document.getElementById( "backgroundThemeCustom" )
        custom.checked = true
    } else {
        if ( defaultPaletteName == 'Auto' ) {
            defaultPaletteName = ( darkMode == '' ? 'Day' : 'Night' );
        }
        for ( let p = 1; p < paletteList.length; p++ ) {
            let pd = paletteList[ p ]           // paletteDictionary
            let pn = pd[ "paletteName" ]        // palette name
            if ( defaultPaletteName != pn ) { continue }
            document.getElementById( 'backgroundTheme' + userDefaultsDict[ kSolarCoasterTheme ] ).checked = true;
            lastCheckedPalette = userDefaultsDict[ kSolarCoasterTheme ]
            activePaletteOrdinal = p;
            break
        }
    } // ifend custom

    // --- Location

    if( userDefaultsDict[ kSolarCoasterLatitude ] != '' && userDefaultsDict[ kSolarCoasterLongitude ] != '' && userDefaultsDict[ kSolarCoasterAltitude ] != '' ) {
        document.getElementById( 'latitude' ).value = userDefaultsDict[ kSolarCoasterLatitude ]
        document.getElementById( 'longitude' ).value = userDefaultsDict[ kSolarCoasterLongitude ]
        document.getElementById( 'altitude' ).value  = userDefaultsDict[ kSolarCoasterAltitude ]
        simEvent.locLat = parseFloat( document.getElementById( 'latitude' ).value )
        simEvent.locLon = parseFloat( document.getElementById( 'longitude' ).value )
        simEvent.locAlt = parseFloat( document.getElementById( 'altitude' ).value )
        validateLocation();
        simEvent.ianaZone = setTimezone();
    }
    
    // Override simEvent with special test dates and/or locations/time zones.
    
    let simDate = userDefaultsDict[ kSolarCoasterSimDate ]
    //if ( simDate != '' ) setNewSimDate( simDate )
    
    let simEventOverrides = {
         0: [ ''          , ''                      ], // NOW, here
         1: [ '2025-05-22', ''                      ], // documentation date, here
         2: [ '2017-08-21', '40.96,-98.602,448'     ], // TSE NE
         3: [ '2017-08-21', '37,-87.7,0'            ], // TSE maximum
         4: [ '2024-04-08', '25.3,-104.1,0'         ], // TSE Mexico maximum
         5: [ '2030-06-01', '56.5,80.1,0'           ], // ASE Ukraine
         6: [ '2045-08-12', '36.851,-98.602,448'    ], // TSE NE mimics 2017
         7: [ '',           '28.63243,77.21879,216' ], // NOW, New Dehli
         8: [ '',           '51.51,-0.13,21'        ], // NOW, Greenwich
         9: [ '',           '41.28,-96.204,356.379' ], // NOW, Elkhorn
        10: [ '2046-02-05', '36.851,-98.602,448'    ], // PSE NE
        11: [ '2022-11-08', ''                      ], // TLE
        12: [ '1957-10-04', ''                      ], // Sputnik
        13: [ '2026-03-20', ''                      ], // equinox northern hemisphere
        14: [ '2025-06-02', ''                      ], // summer solstice northern hemisphere
        15: [ '2026-04-20', ''                      ], // while documenting v 26 dot 2
        16: [ '2026-03-03', '41.28,-96.204,354.9'   ], // while documenting v 26 dot 2
        17: [ '2026-03-26', '41.28,-96.204,354.9'   ], // while documenting v 26 dot 2
    }
    let simEventOverride = -1   // no override, use simEvent as it exists
    if ( simEventOverride in simEventOverrides ) {
        simEvent.date   = simEventOverrides[ simEventOverride ][ 0 ]
        simEvent.locOvr = simEventOverrides[ simEventOverride ][ 1 ]
        alert(' Using overridden date="'+simEvent.date+'"')
    }
    simEvent.dateOrig = simEvent.date    // keep after simDate is set

    // Misc

    darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches
    eclipses = [];
    eclipsesVisible = [];
    imminentEclipse = [];
    lastCheckedPalette = 'Earth'
    lastKnownMinute = getNowMinute() - 1
    macOS = false
    minutesInterval = 1;
    minuteSlider = false                        // see lubricateDraggables()
    myAlertActive = 0
    nightSolColor = 'purple'
    peTestInProgress = 0
    screenSaver = 0
    setDetailedInfoVisibility( 'hidden' )
    showDetails = 0
    solarCoasterBlue = '#4090f5ff'
    solarCoasterEmbed = 0
    solarCoasterEmbedBgC = ''
    ssMinute = getNowMinute()
    tweenDT = 10
    tweenTime = -1

    urlParams = {}                              // lat/lon/alt may be specified here
    if (location.search) {
        var parts = location.search.substring(1).split('&');
        for (var i = 0; i < parts.length; i++) {
            var nv = parts[i].split('=');
            if (!nv[0]) continue;
            urlParams[nv[0]] = nv[1] || true;
        }
    }

    if ( ! isWebkit || macOS  ) initMacStyles() // Obj-C may set macOS = true later
    if ( ! isWebkit ) initWebLocation()         // Obj-C may override later ????????????????????????????

    initTrendingData()                          // set initial "previous" values so we can determine trends later
    lubricateDraggables()                       // setup mouse event callbacks for all draggable SVG elements
    let minute = getNowMinute()
    updateRiseAndSetTimes ( minute )            // update Sol and Luna rise and set times, minute of the day
    minuteSlider.slide( minute );
    activateExistingPalette( activePaletteOrdinal )

    console.log("### Solar Coaster version " + version + " Web App, " + getNowDateObject().toString() + ", lat " + simEvent.locLat + ", lon " + simEvent.locLon + ", alt " + simEvent.locAlt );

    if ( urlParams.SolarCoasterEmbed ) {
        
        solarCoasterEmbed = 1

        // The embedder can make customizations here. In this example, some decorations
        // are hidden, we're re-sized and the sun track is set to elliptical.
        // For a static background color, assign it to solarCoasterEmbedBgC.
        
        solarCoasterEmbedBgC = solarCoasterBlue
        let element = document.getElementById( 'solarcoaster' );
        let aspectRatio = kHTMLWidth / kHTMLHeight
        let h = 258
        element.style['height'] = h + 'px'
        element.style['width'] = ( h * aspectRatio ) + 'px'
        document.getElementById( 'version' ).textContent = 'Solar Coaster ' +  version
        document.getElementById( 'boomerang' ).style.display = 'none'
        document.getElementById( 'solarcoaster' ).classList.toggle( 'active')
        document.getElementById( 'ellipsis-menu' ).style.display = 'none'
        userDefaultsDict[ kSolarCoasterSunTrack ] = 'elliptical'
        setSunTrack();
    }
    luxonTest()

} // initSolarCoaster

function initScreenSaver ( step ) {
    
    // Similar to fetchUserDefaults(), this is a two-step process because we first
    // have to ask Obj-C are we a Screen Saver?
    //
    // step:  0 ask Obj-C if we are a Screen Saver
    //        1 only happens if we are a Screen Saver, else Obj-C is mute
    
    if ( step == 0 ) {
        try {
            window.webkit.messageHandlers.okToInitScreenSaver.postMessage( 'JS asking Obj-C are we Screen Saver?' )
        }
        catch {
            //ssLog('notSS')
        }
    } else if ( step == 1 ) {
        screenSaver = 1
        let svg = document.getElementById( 'solarcoaster' )
        svg.classList.remove( 'solarcoaster' )
        svg.classList.add( 'solarcoasterSS' )
        showOnlyMarkers()
        showDetails = 1
        setDetailedInfoVisibility( 'visible' )
        simEvent.mode = kSimModeManual
        ssMinute = getNowMinute()
        about.style.display = 'none'
        customize.style.display = 'none'
        document.getElementById( 'boomerangDisclosure' ).setAttribute( 'visibility', 'hidden' )
        document.getElementById( 'ellipsis-menu' ).style.display = 'none'
        document.getElementById( 'boomerang' ).style.display = 'none'
        animateOneFrame( 99 ) // fake one realtime update to get an accurate initial screen
    }

} // initScreenSaver

function initDynamicPaletteRadioButton ( paletteDictionary ) {
    
    let pn = paletteDictionary[ "paletteName" ]
    let tbodyRef = document.getElementById('settingsTable').getElementsByTagName('tbody')[0]
    let newRow = tbodyRef.insertRow( 3 )
    let col0 = newRow.insertCell( )
    let col1 = newRow.insertCell( )
    let col2 = newRow.insertCell( )
    col2.setAttribute('style', 'padding-bottom: 5px; float:left;')
    if ( pn == 'Earth' ) {
        col0.setAttribute( 'style','float:right; padding-right:20px; position: relative; top: 0px;' )
        let b = document.createTextNode( 'Background:' )
        col0.appendChild( b )
        col0.setAttribute( 'class', 'customizeTableFontSize' )
    }
    let targetCell = newRow.cells[ 1 ];
    let inp = document.createElement( 'input' )
    inp.setAttribute( 'class', 'radio-input' )
    inp.setAttribute( 'type', 'radio' )
    inp.setAttribute( 'style', 'transform: scale(1.5);' )
    inp.setAttribute( 'id', 'backgroundTheme' + pn )
    inp.setAttribute( 'name', 'backgroundTheme' )
    inp.setAttribute( 'value', pn )
    inp.setAttribute( 'onchange', 'backgroundButtonChanged( )' )
    if ( pn == 'Earth' ) inp.setAttribute( 'checked', 1 )
    targetCell.appendChild(inp)
    let lab = document.createElement( 'label' )
    targetCell.appendChild( lab );
    lab.setAttribute( 'for', pn )
    lab.setAttribute( 'class', 'customizeTableFontSize' )
    lab.innerHTML = '&nbsp' + pn
    
    targetCell = newRow.cells[ 2 ];
    targetCell.setAttribute( 'align', 'left' )
    let img = document.createElement( 'img' )
    targetCell.appendChild( img )
    img.setAttribute( 'class', 'paletteIcon' )
    img.setAttribute( 'src', "data:image/png;base64, " + paletteDictionary[ "base64PNG-160x18" ] )
    img.setAttribute( 'width', 160 )
    img.setAttribute( 'float', 'left' )

} // initDynamicPaletteRadioButton

function initMacStyles () {
    
    // Elements for Mac, iPad and Web App need to be smaller, or iPhone bigger.
    
    isWebStyle = false
    if ( ! isWebkit || macOS ) { // web App style
        isWebStyle = true
    }
    if ( ! isWebkit && ! macOS ) { // web App
        document.getElementById( 'doCustomize' ).innerHTML = 'Options'
    }
    
    if ( (! ( window.webkit && window.webkit.messageHandlers )) || macOS ) {
        let x = document.getElementsByClassName('ellipsis-menu')
        x[0].classList.add('ellipsis-menuWeb')
        x = document.getElementsByClassName( 'off-screen-menu' )
        x[0].classList.add('off-screen-menuWeb')
        
        x = document.getElementsByClassName('ellipsis-close-button')
        for ( let ele of x) { ele.classList.add('ellipsis-close-buttonWeb') }
        
        x = document.getElementsByClassName('about')
        x[0].classList.add('aboutWeb')
        
        x = document.getElementsByClassName('customize')
        x[0].classList.add('customizeWeb')
        
        x = document.getElementsByClassName('customizeTableFontSize')
        for ( let ele of x) { ele.classList.add('customizeTableFontSizeWeb') }

        x = document.getElementsByClassName('infoDetailLeft')
        for ( let ele of x) { ele.classList.add('infoDetailLeftWeb') }
        x = document.getElementsByClassName('infoDetailRight')
        for ( let ele of x) { ele.classList.add('infoDetailRightWeb') }
        
        x = document.getElementsByClassName('disclosureTriangle')
        for ( let ele of x) { ele.classList.add('disclosureTriangleWeb') }
        
        x = document.getElementsByClassName('boomerangMenu')
        for ( let ele of x) { ele.classList.add('boomerangMenuWeb') }
    }

} // initMacStyles

function initTrendingData () {
    
    let dur = DateTimeDuration.fromObject( { minutes: 10 } )
    let older = getNowDateObject().minus( dur )
    
    var distance, trend;
    [distance, trend] = Luna.earthDistance( older );
    Luna.earthOrbitalSpeed( distance );
    [distance, trend] = Sol.earthDistance( older );
    Sol.earthOrbitalSpeed( distance );

} // initTrendingData

function storeUserDefaults () {
    
    try {
        var copy={};
        Object.assign(copy, userDefaultsDict);
        delete copy[ kSolarCoasterCustPltDict ] // must remain a string, therefore delete it so it cannot becomes an NSDictionary
        window.webkit.messageHandlers.storeUserDefaults.postMessage( copy )
    }
    catch {
        //console.log("cannot send user defaults="+userDefaultsDict);
    }

} // storeUserDefaults

// MARK: - Location Services
 
// The state of location services in the Apple ecosystem:
//
// A Web App must depend on navigator.geolocation.
//
// An iOS or macOS App must depend on location service APIs provided my the OS.
//
// A Mac Screen Saver has absolutely no access to location services, coordinates are hardcoded.
//
// To initialize Solar Coaster's position all methods end up calling setLatLonAlt(). Position
// is assumed to be Greenwich, England, if all else fails.

function initWebLocation () {
    
    // For this Web App latitude, longitude and altitude are set variously:
    //
    // 1) hardcoded to Greenwich, England, because there are no persistent user defaults
    // 2) if they are specified as a query string as below, likely only from being embedded by Outside
    // 3) or navigator.geolocation

    simEvent.locLat  = kGreenwichLatitude
    simEvent.locLon = kGreenwichLongitude
    simEvent.locAlt  = kGreenwichAltitude
    
    if ( urlParams.latitude && urlParams.longitude && urlParams.altitude ) {
        simEvent.locLat =  urlParams.latitude
        simEvent.locLon = urlParams.longitude
        simEvent.locAlt = urlParams.altitude
    } else  if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(
            // success
            function(location) {
                simEvent.locLat = location.coords.latitude.toFixed(3)
                simEvent.locLon = location.coords.longitude.toFixed(3)
                simEvent.locAlt = 0.0
                if ( location.coords.altitude ) { simEvent.locAlt = location.coords.altitude.toFixed( 3 ) }
                setLatLonAlt( simEvent.locLat, simEvent.locLon, simEvent.locAlt )
            },
            // failure
            function(error) {
                console.log('web gps error getting location=', error)
            },
            //options
            {
                timeout: 5000 // ms
            }
        )
    } else {
        console.log("GPS not supported by browser")
    }
    validateLocation();
    simEvent.ianaZone = setTimezone();

} // initWebLocation

/*
function setGeoLocation ( position ) {
    console.log("what's up here?")
    
    simEvent.locLat = position.coords.latitude.toFixed(3);
    simEvent.locLon = position.coords.longitude.toFixed(3);
    simEvent.locAlt = 0.0;
    //ssLog('position='+simEvent.locLat+', '+simEvent.locLon)
    if ( position.coords.altitude ) {
        simEvent.locAlt = position.coords.altitude.toFixed( 3 );
    }
    
    // Reload page with hardcoded location.
    let url = window.location.href;
    url = url.replace('#','');
    if (url.indexOf('?') > -1){
        console.log("extra params: ", url);
    } else {
        url += '?';
        url += 'latitude=' + simEvent.locLat;
        url += '&';
        url += 'longitude=' + simEvent.locLon;
        url += '&';
        url += 'altitude=' + simEvent.locAlt;
    }
    window.location.href = url;
    
} // setGeoLocation
 */

// MARK: - Mainloop

function adjustMarkers ( markers ) {
    
    var newCoord, elem, kind, trans;
    for ( var key in markers ) {
        if ( markers.hasOwnProperty( key ) ) {
            newCoord = getPointForMinute( markers[ key ] - solarSkew );
            elem = document.getElementById( key );
            kind = elem.constructor.name;
            if ( kind == 'SVGCircleElement' ) {
                trans = " translate(" + newCoord.x + "," + newCoord.y + ")";
            } else if ( kind == 'SVGRectElement' ) {
                var newx = newCoord.x - 14;
                var newy = newCoord.y - 14;
                trans = " translate(" + newx + "," + newy + ")";
            } else if ( kind == 'SVGPolygonElement' ) {
                var newx = newCoord.x - 14 -8;
                var newy = newCoord.y - 14 -8;
                trans = " translate(" + newx + "," + newy + ") scale( 0.35 )";
            }
            elem.setAttribute( "transform", trans );
        }
    }
    
} // end adjustMarkers

/*function fetchTZForSS () {
    
    let suncalcTZ = simEvent.ianaZone
    //console.log("  fetchTZForSS="+suncalcTZ+'.')
    return suncalcTZ;
    
}*/

function animateOneFrame ( ati ) { // Screen Saver function

    if ( ati > 60.0 ) {
        ssMinute = getNowMinute()
    }
    
    if ( ssMinute == solarMidnight ) {
        ssMinute = solarMidnight + 1
        newDay( ssMinute );
        return;
    }
    let nextMin = ssMinute + 1;
    let lastMin = kLastMinute;  // day minutes range from 0 -> 1439
    if ( nextMin > lastMin ) {
        ssMinute = 0;
        return;
    }
    updateRiseAndSetTimes ( ssMinute ); // update Sol and Luna rise and set times, minute of the day
    Sol.animate ( ssMinute );
    Luna.animate ( ssMinute  );
    animateBackGroundColor ( true, ssMinute );
    if ( ati < 60.0 ) {
        ssMinute++
    }
    
} // animateOneFrame

function animateBodies ( minuteOfDay ) {

    if ( screenSaver || ( showDetails && simEvent.mode == kSimModeAutomatic )  ) return;
    
    clearTimeout( timerHandle );
    let interval = ( simEvent.mode == kSimModeAutomatic ? 1000 * 60 * minutesInterval : 10 );

    if ( minuteOfDay == solarMidnight ) {
        newDay( solarMidnight + 1 );
        return;
    }
    
    if ( simEvent.mode == kSimModeManual ) {
        
        let minute, nextMin, lastMin;
        minute = minuteOfDay;
        if ( macOS ) updatePaletteEditor( minute );
        nextMin = minute + 1;
        lastMin = kLastMinute;  // day minutes range from 0 -> 1439
        if ( nextMin > lastMin ) {
            animateBodies( 0 ); // new day
            return;
        }
        updateRiseAndSetTimes ( minute ); // update Sol and Luna rise and set times, minute of the day
        Sol.animate ( minute );
        Luna.animate ( minute  );
        animateBackGroundColor ( true, minute );
        minuteSlider.slide ( minute );
        timerHandle = setTimeout(  function() { animateBodies( nextMin ) },  interval );
        
    } else if ( simEvent.mode == kSimModeAutomatic ) {
        
        let minute = getNowMinute();
        updateRiseAndSetTimes ( minute ); // update Sol and Luna rise and set times, minute of the day
        if ( minute == ( lastKnownMinute + minutesInterval ) || minute == lastKnownMinute  ) {
            tweenTime = -1;
            lastKnownMinute = minute;
        } else {		// if not now
            minute = playMissedMinutes( minute );
            interval = tweenDT;
        } // ifend tween or not
        if ( minute < 1440 ) {
            Sol.animate ( minute );
            Luna.animate ( minute );
            animateBackGroundColor ( true, minute );
        } else {
            tweenTime = -1
        }

        timerHandle = setTimeout(  function() { animateBodies( solarMidnight + 1 ) }, interval );
    }

} // end animateBodies

function animateBackGroundColor ( skipLettersUpdate, minute ) {

    let bgColor = minuteToBackgroundColor[ minute ];
    setBackground( bgColor );
 
    // Paint letters and paths with a complementary color if BizarroWorld.
    
    paletteDictionary = paletteList[ activePaletteOrdinal ];
    let foregroundColor = paletteDictionary[ "foregroundColor" ];
    if (  foregroundColor != "opposite" && skipLettersUpdate  ) return;

    if ( foregroundColor == "opposite" ) {
        let bgColorNoAlpha = bgColor.slice(0, -2)
        foregroundColor = getComplementaryColor( bgColorNoAlpha);
    }
       
    let svgElement = document.getElementById("svgElement");
    let elms = svgElement.getElementsByClassName('letters')
    for (var i = 0; i < elms.length; i++) {
        changeFill( elms[ i ], foregroundColor )
    }
    elms = svgElement.getElementsByClassName('paths')
    for (var i = 0; i < elms.length; i++) {
        elms[ i ].setAttribute( "stroke", foregroundColor );
    }

    // ellipsis menu et.al.
    
    let ellipsisMenuElement = document.getElementById("ellipsis-menu");
    elms = ellipsisMenuElement.querySelectorAll('.letters');
    for (var i = 0; i < elms.length; i++) {
        elms[ i ].style.outline = '1px solid ' + foregroundColor;
    }

} // animateBackGroundColor

function changeFill(element, newFill) {
    
    const currentStyle = element.getAttribute('style') || ''
    const styleArray = currentStyle.split(';')
    const styleMap = {}

    styleArray.forEach(style => {
        const parts = style.trim().split(':')
        if (parts.length === 2) {
            styleMap[parts[0].trim()] = parts[1].trim()
        }
    });
  
    styleMap['fill'] = newFill

    let updatedStyle = ''
    for (const key in styleMap) {
        updatedStyle += `${key}: ${styleMap[key]};`
    }
  
    element.setAttribute('style', updatedStyle)
    
} // changeFill

function getComplementaryColor(color) {
    
    // From SO: JavaScript Complementary Colors, Nina Scholz
    
    let c = color.slice(1),
        i = parseInt(c, 16),
        v = ((1 << 4 * c.length) - 1 - i).toString(16);

    while (v.length < c.length) v = '0' + v;
    return '#' + v;
    
} // getComplementaryColor

function getPointForMinute ( minute ) {
    
    if ( isNaN( minute ) ) {
        minute = 0;
    } else if ( minute < 0 ) {
        minute = minute + kLastMinute;
    } else if ( minute > kLastMinute ) {
        minute = minute - kLastMinute;
    }
    if ( minute > kLastMinute ) {
        console.log("minute="+minute+" > " + kLastMinute);
        minute = 0;
        tweenTime = -1;
    }
    var lengthAlongPath = sunTrackLen * ( minute / kLastMinute )
    
    
    if ( pathIsElliptical ) {
        lengthAlongPath += sunTrackLen / 4.0
        lengthAlongPath = lengthAlongPath % sunTrackLen
    }
    
    
    if ( ! isFinite( lengthAlongPath ) || isNaN( lengthAlongPath )) {
        console.log( "getPointForMinute: lengthAlongPath is not finite or not a number" );
        lengthAlongPath = 0;
    }
    
    if ( simEvent.mode == kSimModeManual ) {
        if ( lengthAlongPath   > sunTrackLen ) {
            lengthAlongPath = sunTrackLen;
            //console.log("length > pathLen: minute="+minute+", len="+lengthAlongPath+", sunTrack="+sunTrackLen);
        }
        if ( lengthAlongPath   < 0 ) {
            lengthAlongPath = 0;
            //console.log("length < 0: minute="+minute+", len="+lengthAlongPath+", sunTrack="+sunTrackLen);
        }
    }
    
    return sunTrack.getPointAtLength ( lengthAlongPath );
    
} // end getPointForMinute

function resetAnimations () {
    
    tweenTime = -1; // in case tween never completed
    //console.log("solar Coaster onVisible "+new DateTime() + ", LNM=" + lastKnownMinute + ", minute=" + getNowMinute() + ", tweenTime="+tweenTime);
    clearTimeout( timerHandle );
    animateBodies( getNowMinute() );
    
} // end resetAnimations

function lux2Min ( sod, lux ) { // lux >= sod
    
    let min = Math.floor( lux.diff( sod, 'minutes' ).minutes ) // minute of the day
    return min < 0 ? min + 1440 : min
    
} // lux2Min

function updateRiseAndSetTimes( minute ) {

    if ( minute == null ) {
        alert( "updateRiseAndSetTimes: minute undefined" )
    }
    currentMinute = minute // currentMinute used by: sunElement(), moonElement()
    let sod = getNowDateObject().startOf('day')
    let now = sod.plus( { minutes: currentMinute } )
    solarDate.innerHTML = now.year + '-' + addZero( now.month ) + '-' + addZero( now.day ) +
    ' (' + simEvent.locLat + ', ' + simEvent.locLon + ', ' + simEvent.locAlt + ')'
    
    // Set minute of the day sunrise and sunset times, as well as human format display strings.
    
    let times = SunCalc.getTimes( now.toJSDate(), simEvent.locLat, simEvent.locLon, simEvent.locAlt )
    let luxSunrise = DateTime.fromJSDate( times.sunrise )
    let luxSunset = DateTime.fromJSDate( times.sunset )
    sunrise = lux2Min( sod, luxSunrise )
    sunset = lux2Min( sod, luxSunset )
    sunriseTime.textContent = addZero( luxSunrise.hour ) + ':' + addZero( luxSunrise.minute )
    sunsetTime.textContent = addZero( luxSunset.hour ) + ':' + addZero( luxSunset.minute )
    //console.log(minute,sod.toString(),now.toString(),luxSunrise.toString(),sunrise.toString())
    
    // Now that we're past the code that is predicated on 24 hour clock format, see if we're displaying 12 hour time.
    
    if ( ! use24HourClock ) {
        sunriseTime.textContent = clock24To12( sunriseTime.innerHTML );
        sunsetTime.textContent = clock24To12( sunsetTime.innerHTML );
    }
    
    // Mark solar noon, nadir, dawn, dusk, and length of twilight.
    
    let luxDawn, luxDusk
    if ( ( ! isNaN( times.nauticalDawn ) && ( ! isNaN( times.nauticalDusk) ) ) ) {
        luxDawn = DateTime.fromJSDate( times.nauticalDawn )
        luxDusk = DateTime.fromJSDate( times.nauticalDusk )
    } else {
        luxDawn = DateTime.fromJSDate( times.dawn )
        luxDusk = DateTime.fromJSDate( times.dusk )
    }
    dawn = lux2Min( sod, luxDawn )
    dusk = lux2Min( sod, luxDusk )
    twiLen = sunrise - dawn;
    
    let luxSolarNoon = DateTime.fromJSDate( times.solarNoon )
    let luxSolarMidnight = DateTime.fromJSDate( times.nadir )
    solarNoon = lux2Min( sod, luxSolarNoon )
    solarMidnight = lux2Min( sod, luxSolarMidnight )
    
    let currentDayLength = times.sunset - times.sunrise
    let dur = DateTimeDuration.fromObject( { days: 1 } )
    let yesterday = now.minus( dur )
    let times0 = SunCalc.getTimes( yesterday.toJSDate(), simEvent.locLat, simEvent.locLon, simEvent.locAlt );
    let pdl = times0.sunset - times0.sunrise
    trendingDayLength = currentDayLength - pdl // milliseconds
    
    // Special subtle background color change times.
    
    preDawn = dawn - ( ( dawn - solarMidnight ) / 2.0 );
    postSunrise = sunrise + ( ( solarNoon - sunrise ) / 2.0 );
    preSunset = sunset - ( ( sunset - solarNoon ) / 2.0 );
    if ( solarMidnight - dusk > 0 ) {
        postDusk = dusk + ( ( solarMidnight - dusk ) / 2.0);
    } else {
        postDusk =  kLastMinute - ( dusk + (dusk - solarMidnight)/2 );
        postDusk =  ( dusk + (solarMidnight + kLastMinute - dusk)/2 );
        if ( postDusk > kLastMinute ) postDusk -= kLastMinute;
        if ( postDusk < dusk ) postDusk = kLastMinute;
    }

    // Luna.
    
    let moonTimes = SunCalc.getMoonTimes( now.toJSDate(), simEvent.locLat, simEvent.locLon );
    if ( moonTimes.alwaysUp ) {
        moonrise = 0;
        moonset = kLastMinute;
    } else if ( moonTimes.alwaysDown ) {
        moonrise = -1;
        moonset = -1;
    } else if ( moonTimes.rise && moonTimes.set ) {
        let luxMoonrise = DateTime.fromJSDate( moonTimes.rise )
        let luxMoonset  = DateTime.fromJSDate( moonTimes.set )
        moonrise = lux2Min( sod, luxMoonrise ) // ( moonTimes.rise.getHours() * 60 ) +  ( moonTimes.rise.getMinutes() * 1 ) ;
        moonset = lux2Min( sod, luxMoonset ) // ( moonTimes.set.getHours() * 60 ) +  ( moonTimes.set.getMinutes() * 1 ) ;
    } else if ( ! moonTimes.rise ) {
        let luxMoonset  = DateTime.fromJSDate( moonTimes.set )
        moonset = lux2Min( sod, luxMoonset ) // ( moonTimes.set.getHours() * 60 ) +  ( moonTimes.set.getMinutes() * 1 ) ;
        moonrise = -1;
    } else if ( ! moonTimes.set ) {
        let luxMoonrise = DateTime.fromJSDate( moonTimes.rise )
        moonrise = lux2Min( sod, luxMoonrise ) // ( moonTimes.rise.getHours() * 60 ) +  ( moonTimes.rise.getMinutes() * 1 ) ;
        moonset = -1;
    } else {
        console.log("hmm, what to do now??");
    }
    let trend;
    
    // Detailed info, Sol.

    let toDeg = 180 / Math.PI
    let extraSpaces = use24HourClock ? "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0" : "";
    detailedInfo2.textContent = "\u00A0Dawn: " + sqzSpc( dayMinute2HumanTime( dawn ) ) + extraSpaces + "\u00A0\u00A0" + 'Dusk: ' + sqzSpc( dayMinute2HumanTime( dusk ) );
    detailedInfo3.textContent = "\u00A0Noon: " + sqzSpc( dayMinute2HumanTime( solarNoon ) ) + extraSpaces + "\u00A0\u00A0" + 'Midn: ' + sqzSpc( dayMinute2HumanTime( solarMidnight ) );
    let solarDist;
    [solarDist, trend] = Sol.earthDistance( now );
    let currentSolarDist = parseFloat( solarDist ).toFixed( 0 );
    let sunPos = SunCalc.getPosition( now.toJSDate(), simEvent.locLat, simEvent.locLon);

    detailedInfo4.textContent = "\u00A0Alti: " + fixDegrees( parseFloat( sunPos.altitude * toDeg ).toFixed( 0 ) ) + '\xB0/' + fixDegrees( parseFloat( Sol.maxAlt ).toFixed( 0 ) ) + "\xB0" + "\u00A0\u00A0\u00A0\u00A0\u00A0" + 'Azim:\u00A0' + fixDegrees( parseFloat( (sunPos.azimuth + Math.PI) * toDeg ).toFixed( 0 ) ) + "\xB0";
    detailedInfo5.textContent = "\u00A0Dist: " + commify( currentSolarDist ) + " km" + trend;
    let v;
    [v, trend] = Sol.earthOrbitalSpeed( solarDist ); // km/dy
    v /= 24;
    
    detailedInfo6.textContent = "\u00A0Velo: " + "\u00A0\u00A0\u00A0\u00A0" + commify( parseFloat( v ).toFixed(0) ) + " km/h" + trend;

    // Detailed info, Luna.

    let moonPos = SunCalc.getMoonPosition( now, simEvent.locLat, simEvent.locLon);
    detailedInfo8.textContent = "\u00A0Rise: " + sqzSpc( dayMinute2HumanTime( moonrise ) ) + extraSpaces + "\u00A0" + ' Set : ' + sqzSpc( dayMinute2HumanTime( moonset ) );

    detailedInfo9.textContent = "\u00A0Alti: " + fixDegrees( parseFloat( moonPos.altitude * toDeg ).toFixed( 0 ) ) + "\xB0/" + fixDegrees( parseFloat( Luna.maxAlt ).toFixed( 0 ) ) + "\xB0" + "\u00A0\u00A0\u00A0\u00A0\u00A0" + 'Azim:\u00A0' + fixDegrees( parseFloat( (moonPos.azimuth + Math.PI) * toDeg ).toFixed( 0 ) ) + "\xB0";
    let lunarDist;
    [lunarDist, trend] = Luna.earthDistance( now );
    let currentLunarDist = parseFloat( lunarDist ).toFixed( 0 );
    extraSpaces = "\u00A0\u00A0\u00A0\u00A0";
    detailedInfo10.textContent = "\u00A0Dist: " + extraSpaces + commify( currentLunarDist ) + " km" + trend;

    [v, trend] = Luna.earthOrbitalSpeed( lunarDist );
    v /= 24;
    detailedInfo11.textContent = "\u00A0Velo: "  + "\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0" + commify( parseFloat( v ).toFixed(0) ) + " km/h" + trend;
    detailedInfo12.textContent = "\u00A0Phas: " + Luna.phase( now );

    // Introducing a horizontal artificial horizon causes a skew of ( sunrise - halfNightMinutes ) minutes
    // we must adjust for, so that sunrise and sunset on the solar sine path intersect the horizon
    // horizontally.
    //
    // Solar noon is the moment that divides the time interval from sunrise to sunset exactly in half.
    // var nnn = solarNoon - (12*60); // Hmmmm, nnn is the same as solarSkew ?
    
    let dayMinutes = sunset - sunrise;
    let halfNightMinutes = ( kLastMinute - dayMinutes ) / 2;
    solarSkew = sunrise - halfNightMinutes;
    solarSkew = solarSkew.toFixed( 0 );

} // end updateRiseAndSetTimes

// MARK: - New Day / Background Palette

function newDay ( minute ) {

    // Start new day animation.  In version 5 no longer animated ... would sometimes hang the App.
    
    updateRiseAndSetTimes( minute );
    setupMinuteToBackgroundColor();
    animateBodies( minute );

} // end newDay

function activateExistingPalette ( paletteOrdinal ) {
    
    activePaletteOrdinal = paletteOrdinal                   // existing active palette
    paletteDictionary = paletteList[ activePaletteOrdinal ] // paletteDictionary{}
    eval ( paletteDictionary[ "backgroundPalette" ] )       // backgroundPalette[]
    setupMinuteToBackgroundColor( )                         // minuteToBackgroundColor[]
    animateBackGroundColor( false, getNowMinute() )         // update GUI

    
} // activateExistingPalette

function activateCustomPalette ( paletteDictionaryString ) {

    activePaletteOrdinal = 0                                // custom active palette
    eval( paletteDictionaryString )                         // create paletteDictionary{}
    paletteList[ activePaletteOrdinal ] = paletteDictionary // store in paletteList[]
    eval( paletteDictionary[ "backgroundPalette" ] )        // create backgroundPalette[]
    document.getElementById( "backgroundThemeCustomImage" ).setAttribute( 'src', "data:image/png;base64, " + paletteDictionary[ "base64PNG-160x18" ] )
    document.getElementById( 'backgroundThemeCustom' ).checked = 1;
    setupMinuteToBackgroundColor( )                         // create minuteToBackgroundColor[]
    animateBackGroundColor ( false, getNowMinute() )        // update GUI

} // activateCustomPalette

function byMinuteOfDay( a, b ) {
    
    // a and b are backgroundPalette arrays of 3: [ key, minute, color ]
    
    return a[ kBGPMinute ] - b[ kBGPMinute ];
    
} // byMinuteOfDay

function fetchBackgroundPalette () {
    
    paletteDictionary = paletteList[ activePaletteOrdinal ]
    paletteDictionary[ "backgroundPalette" ] = backgroundPalette
    let date = getNowDateObject()
    paletteDictionary[ "when" ] = date.toFormat('yyyy-MM-dd')
    paletteDictionary[ "where" ] =
        "(" + simEvent.locLat + ", " + simEvent.locLon + ", " + simEvent.locAlt + ")"
    return paletteDictionary

} // fetchBackgroundPalette

function interpolateColors( begMin, begCol, endMin, endCol, cra ) {

    let minuteDistance = endMin - begMin
    if ( minuteDistance % 2 == 0 ) {
        let begRGB = begCol.match( kHexColorRegEx )
        let endRGB = endCol.match( kHexColorRegEx )
        let midCol = '#'
        for( let i = 0; i < kHexLen / 2; i++ ) {
            let c = parseInt( begRGB[ i ], 16 ) + parseInt( endRGB[ i ], 16 )
            midCol += Math.floor( c / 2 ).toString( 16 ).padStart( 2, '0' )
        }
        let midMin = begMin + ( minuteDistance / 2 )
        cra[ midMin ] = midCol
        interpolateColors( begMin, begCol, midMin, midCol, cra ) // left
        interpolateColors( midMin, midCol, endMin, endCol, cra ) // right
    } else {
        cra[ begMin ] = begCol
        cra[ endMin ] = endCol
        if ( minuteDistance <= 1 ) return;
        interpolateColors( begMin + 1, begCol, endMin, endCol, cra )
    }
    
} // interpolateColors

function initCustomPalette ( paletteDictString ) {
    
    activateCustomPalette( paletteDictString )
    return paletteDictionary // now Obj-C gets its own copy of the palette dictionary
    
} // initCustomPalette

function setupMinuteToBackgroundColor() {
    
    // Fluidly change Screen Saver background every minute.
    //
    // It's easy to find one color midway between two others colors - not so easy to find a range of smoothly transitioning colors.
    // The first attempt found the distance between the two colors and then divided each distance RGB component by the count of
    // transition steps.  Works OK for a few transitions over a small distance, but when the color delta between steps becomes <= 1
    // everything goes black, or repeats.
    //
    // Attempt two is this recursive function, which finds one color midway between two other colors, then recurses the left interval
    // midpoint and the right interval midpoint. Here intervals are ranges of minutes of the day, 0 - 1439. This method handles small
    // deltas by distributing duplicate colors throughout the range.
    
    minuteToBackgroundColor = []
  //  backgroundPalette.sort( byMinuteOfDay() )
    let lrre = backgroundPalette.length - 1 // last rectangle's right edge

    for ( let i = 0; i < lrre; i++ ) {
        let [ key0, begMin, begCol ] = backgroundPalette[ i + 0 ]
        let [ key1, endMin, endCol ] = backgroundPalette[ i + 1 ]
        interpolateColors(
            Math.round( begMin ), begCol, // rectangle's left edge
            Math.round( endMin ), endCol, // rectangle's right edge
            minuteToBackgroundColor       // colors result array
        )
    }

    // Minutes before the first palette section, left edge, and
    // after the last palette section, right edge, where the daily
    // cycle wraps / repeats are dual in color.

    let [ key0, begMin, begCol ] = backgroundPalette[ 0 ]
    let [ key1, endMin, endCol ] = backgroundPalette[ lrre ]
    for ( let i = 0; i < kLastMinute; i++ ) {
        if ( i < begMin ) minuteToBackgroundColor[ i ] = begCol
        if ( i > endMin ) minuteToBackgroundColor[ i ] = endCol
    }
    
} // setupMinuteToBackgroundColor

function testBackgroundPalette ( testInProgress, newBackgroundPaletteDictionary ) { // used by palette editor
    
    peTestInProgress = testInProgress
    if ( peTestInProgress ) {
        activateCustomPalette( newBackgroundPaletteDictionary )
        showDetails = 0
        doBoomerang( peTestInProgress )
        simEvent.mode = kSimModeAutomatic
        simModeManualFastMinutes(  )
    } else {
        doBoomerang( peTestInProgress )
        simEvent.mode = kSimModeAutomatic
    }
    
} // testBackgroundPalette

function updatePaletteEditor( minute ) {
    
    try {
        window.webkit.messageHandlers.showTestPalette.postMessage( minute + '=' + minuteToBackgroundColor[ minute ] )
    }
    catch {
        console.log( 'why is showTestPalette failing' )
    }
    
} // updatePaletteEditor

// MARK: - onclick / onchange

function aboutFunc( ) {
    
    if ( customize.classList.contains( 'active') ) {
        customize.classList.toggle("active")
    }
    about.classList.toggle("active")

} // aboutFunc

function backgroundButtonChanged(  ) {

    if ( ! getCheckedValue( "backgroundTheme" ) ) alert( 'Logic error in background-color choice' );
    
} // backgroundButtonChanged

function comparePositionTracks ( days ) {

    // Dismiss the diff menu, clear plotly's previous artwork and show the empty canvas, then generate a new plot.
    
    document.getElementById('diffPopup').classList.toggle('closed')
    let downloadPlot = false
    showHud( 'Generating plot ...' )
    let plotlyDiv = document.getElementById('plotlyDiv')
    plotlyDiv.innerHTML = ''
    plotlyDiv.style.display = 'block'
    setTimeout( plotPositions, 100, days )

    return
        
    function plotPositions( positionPlotDays ) {

        // Generate plotly traces of sun and moon minute-by-minute positions, and if there is a solar eclipse, add
        // special markers highlighting partial, total and maximum eclipse minutes.

        // |positionPlotDays| must be odd, of the form: N + 1 + N where N >= 0, thus there are the same number of days
        // before and after the actual sim day.

        let pmdBias = ( positionPlotDays - 1 ) / 2  // plot midpoint day bias
        let minBias = pmdBias * kLastMinute         // minute value bias
        let toDeg   = 180 / Math.PI                 // radians to degrees

        // Compute all the minute-by-minute position data, starting from minute 0, which is |pmdBias| days before the
        // actual sim day and continuing for |positionPlotDays| consecutive days. This scheme uses the fact that JS
        // Date functions "carry over" when a parameter - here minutes - overflows its defined bounds. Also set the
        // X-axis tick labels to show a value only on the first minute of each day.

        let positionTracks = {
            minsX: [],  // minute of the day
            minsH: [],  // human minute
            sunY:  [],  // azimuth
            sunZ:  [],  // altitude
            moonY: [],  // azimuth
            moonZ: [],  // altitude
        }

        let now = getNowDateObject().startOf( 'day' )              // sim day
        now = now.minus( {days: pmdBias} )
        let yr = now.year
        let mn = now.month
        let dy = now.day
        let xVals = []
        let xText = []
        let plotDate = ''
        let dur = DateTimeDuration.fromObject( { minutes: 1 } )

        for ( let m = 0; m < kLastMinute * positionPlotDays; m++ ) {
            now = now.plus( dur )
            if ( m % kLastMinute == 0 )  {
                let dt  = now.year + '-' + ('0' + now.month).slice(-2) + '-' + ( '0' + (now.day  ) ).slice(-2)
                xVals.push( m )
                xText.push( m == minBias ? '0/' + dt : dt )
                if ( m == minBias ) plotDate = dt
            } else if ( m % 720 == 0  && positionPlotDays < 7)  {
                xVals.push( m )
                xText.push( m - minBias )
            }
            positionTracks.minsX[ m ] = m
            positionTracks.minsH[ m ] = minute2Human( positionTracks.minsX[ m ] )

            let sunPos = SunCalc.getPosition( now.toJSDate(), simEvent.locLat, simEvent.locLon)
            positionTracks.sunY[ m ]  = (sunPos.azimuth + Math.PI)  * toDeg
            positionTracks.sunZ[ m ]  =  sunPos.altitude            * toDeg
            
            let moonPos = SunCalc.getMoonPosition( now.toJSDate(), simEvent.locLat, simEvent.locLon)
            positionTracks.moonY[ m ] = (moonPos.azimuth + Math.PI) * toDeg
            positionTracks.moonZ[ m ] =  moonPos.altitude           * toDeg
        } // forend all minutes in all plot days
        
        // It's plotly time: there are 2 - 5 traces.  The first two traces are the sun and moon position tracks
        // sunTrack{} and moonTrack{}. A solar eclipse for Today produces as many as three additional sub-traces
        // that indicate the duration of partial, total and maximum eclipse: partialEclipseTrace{},
        // totalEclipseTrace{} and maximumEclipseTrace{}.
        
        let sunColor = '#FF8844' //'rgba(255, 136, 68, 0.9)'
        let moonColor = '#00bbFF'
        let legendGroup = 'positionTracks'
        let legendGroupTitle = {
            text: "Position Traces",
            font: {
                color: 'black',
                textcase: 'upper',
            },
        }

        var customHoverTemplate = '%{customdata} (%{z:.0f}&deg;, %{y:.0f}&deg;)'
        
        let sunTrack = {
            type: 'scatter3d',
            name: '<span style="color:' + sunColor + '">Sun</span>',
            visible: true,
            mode: 'markers',
            legendgroup: legendGroup,
            legendgrouptitle: legendGroupTitle,
            hovertemplate: customHoverTemplate,
            customdata: positionTracks.minsH,
            x: positionTracks.minsX,
            y: positionTracks.sunY,
            z: positionTracks.sunZ,
            marker: {
                color: sunColor,
                size: 3,
                symbol: 'circle',
            },
        }

        let moonTrack = {
            type: 'scatter3d',
            name: '<span style="color:' + moonColor + '">Moon</span>',
            visible: true,
            mode: 'markers',
            legendgroup: legendGroup,
            legendgrouptitle: legendGroupTitle,
            hovertemplate: customHoverTemplate,
            customdata: positionTracks.minsH,
            x: positionTracks.minsX,
            y: positionTracks.moonY,
            z: positionTracks.moonZ,
            marker: {
                color: moonColor,
                size: 3,
                symbol: 'circle',
            },
        }

        let [ solarEclipseType, partialEclipseTrace, totalEclipseTrace, maximumEclipseTrace ] = solarEclipseTraces ()

        // Define the plot layout: set the title, label the axes, and override the X-axis tick labels
        // to show a value only on the first minute of each day.
        
        let layout = {
            title: {text: plotDate + (solarEclipseType == 'None' ? '' : ' ' + solarEclipseType + ' Solar Eclipse') + ' Position Traces<br>( ' + simEvent.locLat + ', ' + simEvent.locLon +', ' + simEvent.locAlt + ' )'},
            scene: {
                xaxis: {
                    title: { text: 'X Minute' },
                    tickvals: xVals,
                    ticktext: xText,
                },
                yaxis: { title: { text: 'Y Azimuth' } },
                zaxis: { title: { text: 'Z Altitude' } },
                bgcolor: 'white',
            },
            autosize: true,
        }
        
        // Define the plot configuration: add a close button with an SVG icon, remove unwanted toolbar buttons.
        
        var closePlotIcon = {
            'width': 250,
            'height': 250,
            svg: [
                '<svg width="100%" height="100%" viewBox="0 0 509 505" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;">',
                '<g transform="matrix(33.3333,0,0,33.3333,-145,-149)">',
                '<path d="M6,6L18,18" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:2px;"/>',
                '</g>',
                '<g transform="matrix(33.3333,0,0,33.3333,-145,-149)">',
                '<path d="M18,6L6,18" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:2px;"/>',
                '</g>',
                '</svg>',
            ].join('')
        }
        let config = {
            responsive: true,
            displayModeBar: true,
            displaylogo: false,
            modeBarButtonsToRemove:[ 'resetCameraDefault3d', 'resetCameraLastSave3d', 'orbitRotation', 'resetCameraDefault3d', 'resetCameraLastSave3d', 'toImage'], /* 'zoom3d', 'pan3d', 'tableRotation', 'handleDrag3d', 'hoverClosest3d', */
            modeBarButtonsToAdd: [
                {
                    name: 'Close Plot',
                    icon: closePlotIcon,
                    click: () => plotlyDiv.style.display = 'none'
                },
            ]
        };
        
        // Finally, invoke plotly and display its result, and (for me) optionally download the HTML for documentation purposes.

        
        Plotly.newPlot( plotlyDiv, [ sunTrack, moonTrack, partialEclipseTrace, totalEclipseTrace, maximumEclipseTrace ], layout, config )

        document.getElementById('hud').style.display = 'none'
        if ( downloadPlot) { downloadPlotAsHTML( eval( 'delete config.modeBarButtonsToAdd; config' ), plotlyDiv ) } // plotly barfs with a close button, don't want it anyway, luckily
        
        return

        function solarEclipseTraces () {
                        
            // The explicitly specified trace type stops plotly from scribbling spurious axes values if no
            // solar eclipse traces and/or the traces are empty.

            let solarEclipseType = 'None'
            let partialEclipseTrace = { type: 'scatter3d' }
            let totalEclipseTrace   = { type: 'scatter3d' }
            let maximumEclipseTrace = { type: 'scatter3d' }
            
            let solarEclipses = setSolartimeperiod( "SE2001", simEvent )
            if ( solarEclipses.length > 0 ) {
                let ed = solarEclipses[ 0 ][ 0 ]  // e.g. eclipse day 2045-Aug-12
             //   ed = Date.parse( ed.substr( 0, 5 ) + month2Ord[ ed.substr( 5, 3 ) ] + ed.substr( 8, 3 ) + ' 00:00:00'  ) // e.g. 2045-08-12 => epoch timestamp
                ed = DateTime.fromFormat( ed, 'yyyy-MMM-dd')
                //console.log(ed.toString(), ' time=', ed.toMillis())
                let now = getNowDateObject()
                now = now.startOf( 'day' )
                //console.log('now='+now.toString(), now.toMillis())
                if ( ed.toMillis() != now.toMillis() ) { solarEclipses = [] }
            }
            if ( solarEclipses.length <= 0 ) { return [ solarEclipseType, partialEclipseTrace, totalEclipseTrace, maximumEclipseTrace ] }
            
            // Because eclipse sub-traces are relative to the actual sim day, their minute of the day must be biased
            // forward |minBias| minutes to properly overlay the main sun trace.
            //
            // Sample total and partial solar eclipse arrays:
            //
            // 2045-Aug-12,T,10:34:44,43,106,11:47:15,11:50:03,57,125,11:52:51,13:08:43,67,161,1.075,1.000,5m35s
            // 2046-Feb-05,P,17:52:24,01,249,-,18:01(s),0(s),251,-,18:01(s),0(s),251,0.116(s),0.046(s),-
            // 2017-Aug-21,T,11:33:52,50,128,12:58:00,12:59:17,60,161,13:00:35,14:26:02,59,204,1.03,1.000,2m35s
            // 2030-Jun-01,A,23:56:06,51,141,01:25:16,01:27:57,56,176,01:30:37,03:00:42,52,213,0.944,0.892,5m20s

            let solarEclipse = solarEclipses[ 0 ]
            solarEclipseType = solarEclipse[ 1 ] == 'T' ? 'Total' : solarEclipse[ 1 ] == 'A' ? 'Annular' : 'Partial'
            //console.log('solarEclipse='+solarEclipse)
            let partialSolarEclipseColor = '#2CFF05'
            let totalSolarEclipseColor = '#00ffff'
            let maximumSolarEclipseColor = '#cc00f4'
            
            partialSolarEclipseColor = '#2CFF05'
            totalSolarEclipseColor   = '#cc00f4'
            maximumSolarEclipseColor = '#00ffff'

            let timeRegex = /^(\d+):(\d+)/i

            // Partial trace: both eclipse types have a period of partial eclipse.

            let sX = []
            let sY = []
            let sZ = []
            let customdata = []
            let matches = solarEclipse[ 2 ].match( timeRegex )
            let pBeg = parseInt ( matches[ 1 ] ) * 60 + parseInt( matches[ 2 ] )
            matches = solarEclipse[ 10 ].match( timeRegex )
            let pEnd = parseInt ( matches[ 1 ] ) * 60 + parseInt( matches[ 2 ] )
            
            for ( let i = 0; i <= pEnd - pBeg; i++ ) {
                let m = i + pBeg + minBias
                sX[ i ] = m
                sY[ i ] = positionTracks.sunY[ m ]
                sZ[ i ] = positionTracks.sunZ[ m ]
            }
            customdata = positionTracks.minsH.slice( minBias + pBeg, minBias + pEnd + 1)
            
            partialEclipseTrace = {
                x: sX,
                y: sY,
                z: sZ,
                mode: 'line',
                type: 'scatter3d',
                hovertemplate: customHoverTemplate,
                customdata: customdata,
                marker: {
                    size: 7,
                    color: partialSolarEclipseColor,
                    symbol: 'circle',
                    line: {
                    width: 0,
                        color: 'black'
                    }
                },
                name: '<span style="color:' + partialSolarEclipseColor + '">' + 'Partial Eclipse' + '</span>'
            };
            
            // Total trace: a partial eclipse has a single moment of maximality, a total eclipse has a duration of totality.

            sX = []
            sY = []
            sZ = []
            customdata = []
            matches = solarEclipse[ 6 ].match( timeRegex )
            let maxEclipseTime = parseInt ( matches[ 1 ] ) * 60 + parseInt( matches[ 2 ] )
            //console.log('maxEclipseTime='+maxEclipseTime)
            let duration = 0
            if ( solarEclipseType != 'Partial' ) {
                matches = solarEclipse[ 15 ].match( /(\d+)m(\d+)s/i )
                duration = Math.round(  parseInt ( matches[ 1 ] ) + ( parseInt( matches[ 2 ] ) / 60 ) )
            }
            
   //         console.log('dur='+duration)
        
            /*
            let start = duration % 2
            console.log(start)
            for ( let i = start; i <= duration; i++ ) {
                let m = i + ( maxEclipseTime - Math.round( duration / 2 ) ) + minBias
                sX[ i ] = m
                sY[ i ] = positionTracks.sunY[ m ]
                sZ[ i ] = positionTracks.sunZ[ m ]
                customdata[ i ] = minute2Human( sX[ i ] )
                console.log(i,sX[ i ],m)
            }
            console.log('CD='+customdata.length, customdata)
            console.log('POS='+positionTracks.minsH.length)
            //customdata = positionTracks.minsH.slice( minBias + start + maxEclipseTime, minBias + start + maxEclipseTime + duration + 1)
           // console.log('frog='+customdata.length, customdata)
             console.log('---')
             */
            
            let half = duration / 2
            let tBeg = Math.round( maxEclipseTime - half )
            let tEnd = Math.round( maxEclipseTime + half )
     //       console.log(half, tBeg, tEnd)
            for ( let i = 0; i <= tEnd - tBeg; i++ ) {
                let m = Math.round( i + tBeg + minBias )
      //          console.log(i, m )
                sX[ i ] = m
                sY[ i ] = positionTracks.sunY[ m ]
                sZ[ i ] = positionTracks.sunZ[ m ]
            }
            customdata = positionTracks.minsH.slice( minBias + tBeg, minBias + tEnd + 1 /*( duration % 2 ? 0 : 1 )*/ )
            //console.log('eclipse span '+positionTracks.minsH.slice( minBias + tBeg, minBias + tEnd + 1/*( duration % 2 ? 0 : 1 )*/ )   )

            totalEclipseTrace = {
                x: sX,
                y: sY,
                z: sZ,
                mode: 'line',
                type: 'scatter3d',
                visible: true,
                hovertemplate: customHoverTemplate,
                customdata: customdata,
                marker: {
                    size: 10,
                    color: totalSolarEclipseColor,
                    symbol: 'circle',
                    line: {
                    width: 0,
                        color: 'black'
                    }
                },
                name: '<span style="color:' + totalSolarEclipseColor + '">' + solarEclipseType + ' Eclipse' + '</span>'
            }
            if ( totalEclipseTrace[ 'x' ].length == 1 && totalEclipseTrace[ 'x' ][0] == maxEclipseTime ) {  // maximumEclipseTrace makes up for this
                console.log('INVISIBLE')
                totalEclipseTrace[ 'visible' ] = false
            }

            // Maximum trace: both eclipse types have a single point called maximum eclipse.

            let t = maxEclipseTime
            let m = t + minBias
            maximumEclipseTrace = {
                x: [m],
                y: [positionTracks.sunY[ m ]],
                z: [positionTracks.sunZ[ m ]],
                mode: 'line',
                type: 'scatter3d',
                customdata: [ minute2Human( t ) ],
                hovertemplate: customHoverTemplate,
                marker: {
                    size: 14,
                    color: maximumSolarEclipseColor,
                    symbol: 'circle',
                    line: {
                    width: 1,
                        color: 'black'
                    }
                },
                name: '<span style="color:' + maximumSolarEclipseColor + '">' + 'Max ' + solarEclipseType + ' Eclipse' + '</span>'
            }
        
            return [ solarEclipseType, partialEclipseTrace, totalEclipseTrace, maximumEclipseTrace ]

        } // solarEclipseTraces

        function minute2Human( minute ) {
            
            if ( minute < 0 ) { minute += kLastMinute }
            minute %= kLastMinute
            let h = Math. floor ( minute / 60.0 )
            let m = minute - ( h * 60 )
            return h.toFixed(0).padStart( 2, '0' ) + ':' + m.toFixed(0).padStart( 2, '0' )
            
        } // minute2Human

        function downloadPlotAsHTML ( plConfig, plotlyDiv ) {

            async function getPlotlyScript() {
              // fetch
              const plotlyRes = await fetch('https://cdn.plot.ly/plotly-3.3.1.min.js')
              // get response as text
              return await plotlyRes.text()
            }
            
            function getChartState ( plConfig ) {
              return {
                data: plotlyDiv.data, // current data
                layout: plotlyDiv.layout, // current layout
                config: plConfig,
              }
            }

            async function getHtml( plConfig ) {
              const plotlyModuleText = await getPlotlyScript()
              const state = getChartState( plConfig )

              return `
                  <head>
                    <meta charset="utf-8" />
                  </head>

                  <script type="text/javascript">
                    ${plotlyModuleText}
                  <\/script>
                
                  <div id="plotly-output"></div>
                  
                  <script type="text/javascript">
                    Plotly.newPlot(
                      'plotly-output', 
                      ${JSON.stringify(state.data)},
                      ${JSON.stringify(state.layout)},
                      ${JSON.stringify(state.config)},
                    )
                <\/script>
              `
            }
            
            async function exportToHtml ( plConfig ) {
              // Create URL
              const blob = new Blob( [await getHtml( plConfig )], { type: 'text/html' } )
              const url = URL.createObjectURL(blob)

              // Create downloader
              const downloader = document.createElement('a')
              downloader.href = url
              downloader.download = 'positionTracks.html'

              // Trigger click
              downloader.click()

              // Clean up
              URL.revokeObjectURL(url)
            }

            exportToHtml( plConfig )

        } // downloadPlotAsHTML

    } // plotPositions
    
} // comparePositionTracks

function customizeFunc( ) {
    
    if ( macOS ) {
        let custom = document.getElementById( "backgroundThemeCustom" )
        custom.disabled = 0
    }
    if ( about.classList.contains( 'active') ) {
        about.classList.toggle("active")
    }
    customize.classList.toggle("active")

} // customizeFunc

function dismissModal() { // close modal and plotly
    
    document.getElementById('myModalWindow').style.display = "none"
    document.getElementById('modal-content-here').style['background-color'] = '#cacaca'
    myAlertActive = 0
    document.getElementById('plotlyDiv').style.display = 'none'

} // dismissModal

function doBoomerang ( paletteTest ) {
    
    if ( paletteTest == -1 && peTestInProgress == 1 ) { showHud( 'Palette Editor test in progress...' ); return; }
    
    showDetails = 1 - showDetails
    
    let vis = showDetails ? 'visible' : 'hidden'
    setMarkersVisibility( vis )
    setDetailedInfoVisibility( vis )
    title.setAttribute( 'visibility', vis )
    subtitle.setAttribute( 'visibility', vis )
    nowDelta.setAttribute( 'visibility', vis )
 
    for ( let item of [ 'slider', 'sliderTrack', 'simModeManualFastMinutes', 'simModeManualFastMinutesBorder', 'comparePositionTracksImage', 'comparePositionTracksBorder', 'eclipse1', 'eclipse2', 'eclipse3', 'eclipse4', 'version', 'eclprb1', 'eclprb2', 'eclprb3' ] ) {
        let ele = document.getElementById( item )
        ele.setAttribute( 'visibility', vis )
        if ( item == 'simModeManualFastMinutes' ) {
            
            // Well, this is horseshit :)  Otherwise, the 0.3s "transition" delays the visibility change. A
            // 'reflow' is sorta' like emptying cache or setNeedsDisplay(): took too many hours to discover!
            
            ele.classList.add('notransition'); // disable transitions
            if ( ! showDetails ) {
                if ( ele.classList.contains( 'active') ) {
                    ele.classList.toggle("active");
                }
            }
            ele.getBBox(); // trigger a reflow, flushing the CSS changes
            ele.classList.remove('notransition'); // re-enable transitions
        }
    }

    simEvent.mode = kSimModeAutomatic
    let boomer = document.getElementById( 'boomerangDisclosure' )
    let coastr = document.getElementById( 'solarcoaster' )
    
    if ( showDetails ) {
        if ( ! boomer.classList.contains( 'active') ) boomer.classList.toggle( 'active')
        if ( ! coastr.classList.contains( 'active') ) coastr.classList.toggle( 'active')
        minuteSlider.slide( getNowMinute() )
    } else {
        if (   boomer.classList.contains( 'active') ) boomer.classList.toggle( 'active')
        if (   coastr.classList.contains( 'active') ) coastr.classList.toggle( 'active')
        initTrendingData()
        simEvent.date = simEvent.dateOrig
        animateBodies( getNowMinute() )
    }

} // doBoomerang

function ellipsisClicked () {
    
    ellipsisMenu.classList.toggle("active")
    offScreenMenu.classList.toggle("active")
    if ( about.classList.contains( 'active') ) {
        about.classList.toggle("active")
    }
    if ( customize.classList.contains( 'active') ) {
        customize.classList.toggle("active")
    }

} // ellipsisClicked

function getCheckedValue( groupName ) {

    if ( groupName == 'sunTrack' ) {
        let track = document.querySelector('input[name=sunTrack]:checked').value
        userDefaultsDict[ kSolarCoasterSunTrack ] = track
        storeUserDefaults()
        setSunTrack()
        let now = getNowMinute()
        Sol.animate( now )
        Luna.animate( now )
        return track
    } else if ( groupName == 'backgroundTheme' ) {
        let pn = document.querySelector('input[name=backgroundTheme]:checked').value
        userDefaultsDict[ kSolarCoasterTheme ] = pn
        storeUserDefaults()
        if ( pn == "Custom" ) {
            try { // Obj-C
                window.webkit.messageHandlers.customOpen.postMessage( 'customOpen' )
                if ( userDefaultsDict[ kSolarCoasterCustPltDict ] == "" ) {
                    document.querySelector('input[type="radio"]:checked').checked = 0;
                    document.getElementById('backgroundTheme' + lastCheckedPalette).checked = 1;
                    userDefaultsDict[ kSolarCoasterTheme ] = lastCheckedPalette
                    storeUserDefaults();
                    return lastCheckedPalette;
                }
            }
            catch { // web
                pn = lastCheckedPalette
                document.getElementById('backgroundTheme' + pn).checked = 1;
                alert ('Custom background palettes are only available for iOS, macOS and Screen Saver.')
                return pn
            }
            
        }
        if ( pn == "Auto" ) {
            pn = "Day"
            if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
                pn = "Night"
            }
        }
        for ( let p = 1; p < paletteList.length; p++ ) {
            let pd = paletteList[ p ]
            if ( pd[ "paletteName" ] != pn ) { continue }
            lastCheckedPalette = userDefaultsDict[ kSolarCoasterTheme ]
            activateExistingPalette( p )
            break
        }
        return pn
    }
    
} // getCheckedValue

function locationTextChanged () {
    
    userDefaultsDict[ kSolarCoasterLatitude ]  = document.getElementById( 'latitude' ).value
    userDefaultsDict[ kSolarCoasterLongitude ] = document.getElementById( 'longitude' ).value
    userDefaultsDict[ kSolarCoasterAltitude ]  = document.getElementById( 'altitude' ).value
    storeUserDefaults()

    if( userDefaultsDict[ kSolarCoasterLatitude ] != '' && userDefaultsDict[ kSolarCoasterLongitude ] != '' && userDefaultsDict[ kSolarCoasterAltitude ] != '' ) {
        simEvent.locLat = parseFloat( document.getElementById( 'latitude' ).value )
        simEvent.locLon = parseFloat( document.getElementById( 'longitude' ).value )
        simEvent.locAlt = parseFloat( document.getElementById( 'altitude' ).value )
    } else {
        if ( isWebkit ) {
            try { // Obj-C
                window.webkit.messageHandlers.whatIsYour20.postMessage( 'JS needs new coords' )
            }
            catch { // web
                //
            }
        } else {
            initWebLocation()
        }
    }
    validateLocation()
    simEvent.ianaZone = setTimezone()
    setLatLonAlt( simEvent.locLat, simEvent.locLon, simEvent.locAlt )

} // locationTextChanged

function onclickFunction( evt ) {

    evt.stopPropagation();
    
    var id = evt.target.id;
    if ( event.target == document.getElementById('modal-content-here') || event.target == document.getElementById('myModalWindow') || event.target == document.getElementById('myModalOK') ) {
        dismissModal()
        return;
    }

    if ( id == 'boomerangDisclosure' ) {
        doBoomerang( -1 )
    } else if ( id == 'simModeManualFastMinutes' ) {
        simModeManualFastMinutes( id )
    } else if ( id == 'comparePositionTracksImage' || id == 'nav-button' ) {
        document.getElementById('diffPopup').classList.toggle('closed')
    } else if ( id == 'sol' || id == 'solHalo' ) {
        sunElement( id )
    } else if ( id == 'solarEclipseIcon' || id == 'eclipse1' || id == 'eclipse2' ) {
        solarEclipseElement( id )
    } else if ( id == 'lunarEclipseIcon' || id == 'eclipse3' || id == 'eclipse4' || id == 'eclipse5' ) {
        lunarEclipseElement( id )
    } else if ( Luna.visible && ( id == 'lunaPath' || id == 'lunaBack' || id == 'lunaOutline' )  ) {
        moonElement( id )
    } else if ( id == 'triangleUp' || id == 'triangleDn' || id == 'solarHorizon' ) {
        triangleElement( id )
    } else if ( id == "doAbout" ) {
        aboutFunc()
    } else if ( id == "doCustomize" ) {
        customizeFunc();
    } else if ( id == "simDateNOW" ) {
        let picker = document.getElementById( 'simDatePicker' )
        if ( document.getElementById( 'simDateNOW' ).checked ) {
            picker.disabled = 1
            setNewSimDate( '' )
            let ndo = getNowDateObject( )
            picker.value = ndo.getFullYear() + '-' + addZero( ndo.getMonth() + 1 ) + '-' + addZero( ndo.getDate() )
        } else {
            picker.disabled = 0
        }
    } else if ( id == "simDatePicker" ) {
        setNewSimDate( document.getElementById( 'simDatePicker' ).value )
    } else if ( id == "doInfo" ) {
        try { // Obj-C
            window.webkit.messageHandlers.doInfo.postMessage( 'Please display Solar Coaster Help' )
        }
        catch { // web
            window.open('https://www.bigcatos.com/BigCatOs/SolarCoasterHelp/index.html', '_blank')
        }
    } else  {
        var markers = {
            "dawnCircle:Nautical Dawn"           : dawn,
            "duskCircle:Nautical Dusk"           : dusk,
            "solarNoonCircle:Solar Noon"         : solarNoon,
            "solarMidnightCircle:Solar Midnight" : solarMidnight,
            "solarMidnightCircleLeft:Solar Midnight"  : solarMidnight,
            "solarMidnightCircleRight:Solar Midnight" : solarMidnight,
            "sunriseCircle:Sunrise"              : sunrise,
            "sunsetCircle:Sunset"                : sunset,
            "moonriseCircle:Moonrise"            : moonrise,
            "moonsetCircle:Moonset"              : moonset,
            "nowCircle:Now"                      : getNowMinute(),
        };
        var circle;
        for ( var key in markers ) {
            if ( markers.hasOwnProperty( key ) ) {
                var toks = key.split( ':' );
                if ( id == toks[0] && markers[ key ] != -1 ) {
                    alert( toks[1] + ': ' + dayMinute2HumanTime( markers[ key ] ) );
                    return;
                }
            }
        }
    }
    
} // end onclickFunction

function setNewSimDate ( newDate ) {
    
    userDefaultsDict[ kSolarCoasterSimDate ] = newDate
    storeUserDefaults( userDefaultsDict )
    simEvent.date = simEvent.dateOrig = newDate
    
    let nowCheck = document.getElementById('simDateNOW')
    nowCheck.checked = newDate == '' ? true : false
    newDay( getNowMinute() )
    let picker = document.getElementById('simDatePicker')
    picker.value = newDate
    picker.disabled = nowCheck.checked ? true : false
    
} // setNewSimDate

function setSunTrack () {
    
    // Wow, JS with evaluateJavaScript is touchy - even referencing an undefined thing
    // causes the evaluation to fail.  Took me 4 hourds to figure this out :)
    
    if (typeof userDefaultsDict === 'undefined') { return }

    pathIsElliptical = ( userDefaultsDict[kSolarCoasterSunTrack] == 'elliptical' ) ? true : false
    let pathID = pathIsElliptical ? 'sunTrackElliptical' : 'sunTrackSinusoidal'
    sunTrack = document.getElementById( pathID )
    sunTrackLen = sunTrack.getTotalLength()
    sunTrack.setAttribute( 'visibility', 'visible' )
    document.getElementById( ( ! pathIsElliptical ? 'sunTrackElliptical' : 'sunTrackSinusoidal') ).setAttribute( 'visibility', 'hidden' )
    document.getElementById( pathID + 'Button' ).checked = true
    
} // setSunTrack

function showHud( msg ) {
    
    let hudElement = document.getElementById('hud')
    hudElement.style.display = 'block'
    hudElement.innerHTML = msg
    setTimeout(() => {
        hudElement.style.display = 'none'
    }, 1500)
    
} // showHud

function showOnlyMarkers () {
    
    setMarkersVisibility( 'visible' )
    title.setAttribute( 'visibility', 'visible' )
    subtitle.setAttribute( 'visibility', 'visible' )
    nowDelta.setAttribute( 'visibility', 'hidden' )
    
} // showOnlyMarkers

function simModeManualFastMinutes( id ) {
    
    simEvent.mode = ( simEvent.mode != kSimModeAutomatic ? kSimModeAutomatic : kSimModeManual )
    clearTimeout( timerHandle )
    let ele = document.getElementById( 'simModeManualFastMinutes' )
    ele.classList.toggle("active")
    if ( simEvent.mode == kSimModeManual ) {
        animateBodies( getNowMinute() )
    } else if ( simEvent.mode == kSimModeAutomatic ) {
        let minute = getNowMinute()
        minuteSlider.slide ( minute )
        animateBodies( minute )
    }

} // simModeManualFastMinutes

function sunTrackButtonChanged () {

    if ( ! getCheckedValue( "sunTrack" ) ) alert( 'Logic error in sunTrack choice' )
    
} // sunTrackButtonChanged

function timeFormatButtonChanged () {
    
    use24HourClock = setTimeFormat ( timeFormat.checked ? 1 : 0 )
    userDefaultsDict[ kSolarCoaster24HourTime ] = use24HourClock ? "yes" : 'no'
    storeUserDefaults()
    updateRiseAndSetTimes( getNowMinute() )

} // timeFormatButtonChanged

function validateLocation() {
    
    if ( simEvent.locLat < minLatitude ) simEvent.locLat = minLatitude
    if ( simEvent.locLat > maxLatitude ) simEvent.locLat = maxLatitude
    if ( simEvent.locLon < -180.0 ) simEvent.locLon = -180.0
    if ( simEvent.locLon > +180.0 ) simEvent.locLon = +180.0
    if ( simEvent.locAlt < 0.0 ) simEvent.locAlt = 0.0
    if ( simEvent.locAlt > maxAltitude ) simEvent.locAlt = maxAltitude

} // validateLocation

// MARK: - Class Draggable and Subclassed SVG Draggables

class Draggable {
    
    #requiredClass = 'Draggable'
    #eventMap = new Map( [ // used to both define listeners and dispatch events
        [new Set( ['mousedown', 'touchstart'] ),            this.startDrag],
        [new Set( ['mousemove', 'touchmove']  ),            this.drag],
        [new Set( ['mouseup', 'touchend', 'touchcancel'] ), this.endDrag],
    ] )

    constructor( svgID, draggableID ) {
        this.draggable = document.getElementById( draggableID )
        if ( ! this.draggable.classList.contains( this.#requiredClass ) ) {
            console.log( 'Draggable: SVG element \'' + draggableID +
                '\' is not of class ' + '\'' + this.#requiredClass + '\'.')
            return false
        }
        this.selectedElement = false
        this.button = 0
        this.clicked = false
        this.delta = 1
        this.fixedY = 0
        this.offset = 0
        this.origin = 0
        this.startX = 0
        this.startY = 0
        this.svg = document.getElementById( svgID )

        for ( let [ s, f ] of this.#eventMap ) {
            for ( let eventType of s ) {
                this.draggable.addEventListener( eventType, this)
            }
        }
    } // constructor

    handleEvent( e ) {
        for ( let [ s, f ] of this.#eventMap ) {
            if ( s.has( e.type ) ) {
                this[ f.name ]( e )
                break
            }
        }
    } // handleEvent
    
    startDrag( e ) {
        this.selectedElement = false
        if ( e.target.classList.contains( this.#requiredClass ) ) {
            e.preventDefault()
            this.selectedElement = e.target
            this.button = e.button
            this.clicked = false
            this.origin = {x: this.selectedElement.getAttribute( 'x' ),
                           y: this.selectedElement.getAttribute( 'y' ) }
            this.offset = this.#getMousePosition( e )
            this.offset.x -= this.origin.x
            this.offset.y -= this.origin.y
            this.startX = e.pageX
            this.startY = e.pageY
        }
    } // startDrag
    
    drag( e ) {
        return this.selectedElement ? this.#getMousePosition( e ) : false
    }  // drag
    
    endDrag( e ) {
        this.selectedElement = false
        let diffX = Math.abs( e.pageX - this.startX )
        let diffY = Math.abs( e.pageY - this.startY )
        if ( diffX < this.delta && diffY < this.delta ) { this.clicked = true }
        return this.clicked
    } // endDrag
    
    #getMousePosition( e ) { // get mouse coordinates in SVG frame
        let pt = this.svg.createSVGPoint()
        if ( e.touches ) { e = e.touches[ 0 ] }
        pt.x = e.clientX
        pt.y = e.clientY
        let svgP = pt.matrixTransform( this.svg.getScreenCTM().inverse() )
        return { x: svgP.x, y: svgP.y }
    } // #getMousePosition

} // Draggable

class MinuteSlider extends Draggable {
    
    constructor ( svg, id ) {
        super( svg, id )
        let y = document.getElementById( 'sliderTrack' ).getAttribute( 'y' )
        let h = document.getElementById( 'sliderTrack' ).getAttribute( 'height' )
        this.sliderTop = parseFloat( y )
        this.sliderLen = parseFloat( h )
        this.sliderBot = this.sliderTop + this.sliderLen
        this.timeSyncAngle = 0
        this.mPP = kLastMinute / this.sliderLen // minutes per track pixel
        let styles = {
            transformOrigin: 'center',
               transformBox: 'fill-box',
                 transition: 'transform 0.5s ease-in-out'
        }
        Object.assign( this.draggable.style, styles );
    } // constructor

    startDrag( e ) {
        super.startDrag( e )
    } // startDrag
    
    drag( e ) {
        let coords = super.drag( e )
        if ( coords ) {
            if ( coords.y < this.sliderTop ) { coords.y = this.sliderTop }
            if ( coords.y > this.sliderBot ) { coords.y = this.sliderBot }
            this.selectedElement.setAttribute( 'x', this.origin.x )
            this.selectedElement.setAttribute( 'y', coords.y - this.offset.y )
            this.animate( this.#y2Min( coords.y ) )
        }
    } // drag
    
    endDrag( e ) {
        if ( super.endDrag( e ) ) { this.timeSync( getNowMinute() ) }
    } // endDrag
    
    animate( minute ) { // update Sol and Luna rise and set times, update GUI
        updateRiseAndSetTimes( minute )
        Sol.animate ( minute )
        Luna.animate ( minute  )
        animateBackGroundColor ( true, minute )
    } // animate

    slide( minute ) { // take a minute of the day and move the slider accordingly
        this.draggable.setAttribute( 'y', this.#min2Y( minute ) )
    } // slide
    
    timeSync( minute ) { // rotate timeSync symbol and slide to current minute
        this.timeSyncAngle += 180
        this.draggable.style.transform = 'rotate(' + this.timeSyncAngle + 'deg)'
        this.slide( minute )
        this.animate( minute )
    } //timeSync
    
    // Invertible private functions Minute-to-SliderY and SliderY-to-Minute.
    
    #min2Y = function( m ) { return this.sliderTop + ( m / this.mPP ) }
    #y2Min = function( y ) { return Math.floor(  ( y - this.sliderTop ) * this.mPP ) }

} // MinuteSlider

function lubricateDraggables( ) { // activate SVG draggables
    
    // Initialize minuteSlider, ...
    
    let draggables = document.getElementsByClassName( 'Draggable' );
    for ( let i = 0; i < draggables.length; i++ ) {
        let id = draggables[ i ].id
        if ( id == 'slider' ) {
            minuteSlider = new MinuteSlider( 'svgElement', id )
        } // slider
    } // draggables
    
} // lubricateDraggables

// MARK: - Mostly called from Objective-C wrapper

function deadWebviewCheck () { // always return ALIVE indicating we are alive
    
    // Obj-C wrapper calls us to ensure Solar Coaster JS is still running.  On failure
    // the wrapper code will make a new WkWebView instance and reload us.  A special
    // case of the White Screen Of Death (WSOD).
    
    return 'ALIVE';
    
} // end deadWebviewCheck

function imminentEclipseCircumstances () {
    
    var eclipseHTML = ' '
    if ( imminentEclipse ) {
        if ( imminentEclipse.length == 16 ) { // solar eclipse
            eclipseHTML = showSolarEclipseData( 0, [ imminentEclipse ] )
        } else if ( imminentEclipse.length == 18 ) { // lunar eclipse
            eclipseHTML = showLunarEclipseData( 0, [ imminentEclipse ] ) // need array of array
        }
    }
    return eclipseHTML;
    
} // end imminentEclipseCircumstances

function onceDailyEclipseCheck () { // Apps only
    
    // Collect all eclipses, sort by time, and return summary for Apps. Remember imminent eclipse for the screen saver.
        
    let eclipseSummary = [];
    let allEclipses = [];
    imminentEclipse = [];
    
    let solarEclipses = setSolartimeperiod( "SE2001", simEvent )
    let numSolarEclipses = solarEclipses.length;
    if ( numSolarEclipses > 0 ) {
        for ( let i = 0; i < numSolarEclipses; i++ ) {
            let eventDetails = solarEclipses[ i ]; // array of 16 : 2017-Aug-21, P, 11:38:05, 52, 133, -, 13:03:49, 60, 168, -, 14:29:57, 58, 209, 0.985, 0.987, -
            allEclipses.push(eventDetails);
        }
    }
    
    let lunarEclipses = setLunartimeperiod( "LE2001", simEvent )
    let numLunarEclipses = lunarEclipses.length;
    if ( numLunarEclipses > 0 ) {
        for ( let i = 0; i < numLunarEclipses; i++ ) {
            let eventDetails = lunarEclipses[ i ]; // array of 18 : 2018-Jan-31, T, 2.294,  1.315, 10:51, +29, 11:48, +19, 12:52, +07, 13:30, +01, italic-14:08, italic--06, italic-15:11, italic--15, italic-16:08, italic--23
            console.log(eventDetails);
            allEclipses.push( eventDetails );
        }
    }
    
    allEclipses.sort( function(a,b) {
        let aDate = a[0];
        let y = aDate.substring(0, 4);
        let m = aDate.substring(5, 8);
        let d = aDate.substring(9, 11);
        m = month2Ord[ m ];
        aDate = y + '-' + m + '-' + d;
        let aEpoch = new Date( aDate ).getTime() / 1000;
            
        let bDate = b[0];
        y = bDate.substring(0, 4);
        m = bDate.substring(5, 8);
        d = bDate.substring(9, 11);
        m = month2Ord[ m ];
        bDate = y + '-' + m + '-' + d;
        let bEpoch = new Date( bDate ).getTime() / 1000;
            
        return aEpoch - bEpoch;
    });
           
    imminentEclipse = allEclipses[0];
    
    let numEclipses = allEclipses.length;
    if ( numEclipses > 0 ) {
        let event;
        for ( let i = 0; i < numEclipses; i++ ) {
            let eventDetails = allEclipses[ i ]; // array of 16 or 18
            event =  eTypeSolar[ eventDetails[1] ] + ' solar eclipse '; // assume solar eclipse
            if ( eventDetails.length == 18 ) {
                event = eType[ eventDetails[1] ] + ' lunar eclipse ';
            }
            eclipseSummary.push( event + lunarHuman2solarHumanDate( eventDetails[0] ) );
        }
    }
    eclipseSummary.push( kImminentEclipseSep )
    eclipseSummary.push( imminentEclipseCircumstances() )
    userDefaultsDict[ kSolarCoasterSSEclipses ] = eclipseSummary.join('^');
    storeUserDefaults() // make summary available for Screen Saver
    return eclipseSummary;

} // end onceDailyEclipseCheck

function setBackground ( color ) {
        
    let backgroundColor = color;
    
    if ( solarCoasterEmbedBgC != '' ) backgroundColor = solarCoasterEmbedBgC

    try {
        window.webkit.messageHandlers.setiOSViewBackgroundColor.postMessage( backgroundColor )
    }
    catch {
        // not needed for web app
    }
    
    let element = document.getElementById( 'solarcoaster' )
    element.style['background'] = backgroundColor
    element = document.getElementById( 'bodyid' )
    element.style['background'] = backgroundColor
    element = document.getElementById( 'version' )
    if ( backgroundColor != 'purple' ) {
        element.setAttribute( 'fill', 'purple' )
    } else {
        element.setAttribute( 'fill', solarCoasterBlue )
    }
    return 0
    
} // end setBackground

function setLatLonAlt ( lat, lon, alt ) { // called by obj-c and ourself

    if( userDefaultsDict[ kSolarCoasterLatitude ] != '' && userDefaultsDict[ kSolarCoasterLongitude ] != '' && userDefaultsDict[ kSolarCoasterAltitude ] != '' ) return
    
    if ( simEvent.locOvr ) { [ lat, lon, alt ] = simEvent.locOvr.match( /[+-]?([0-9]*[.])?[0-9]+/ig ) }
    
    simEvent.locLat = lat
    simEvent.locLon = lon
    simEvent.locAlt = alt
    simEvent.ianaZone = setTimezone()
    updateRiseAndSetTimes( getNowMinute() )
    activateExistingPalette( activePaletteOrdinal )
    clearTimeout( timerHandle )
    animateBodies( getNowMinute() )

} // end setLatLonAlt

function setSSPalette( apo ) {
    
    activateExistingPalette( apo )

} // setSSPalette

function exit( status ) {
    // http://kevin.vanzonneveld.net
    // +   original by: Brett Zamir (http://brettz9.blogspot.com)
    // +      input by: Paul
    // +   bugfixed by: Hyam Singer (http://www.impact-computing.com/)
    // +   improved by: Philip Peterson
    // +   bugfixed by: Brett Zamir (http://brettz9.blogspot.com)
    // %        note 1: Should be considered expirimental. Please comment on this function.
    // *     example 1: exit();
    // *     returns 1: null

    var i;

    if (typeof status === 'string') {
        alert(status);
    }

    window.addEventListener('error', function (e) {e.preventDefault();e.stopPropagation();}, false);

    var handlers = [
        'copy', 'cut', 'paste',
        'beforeunload', 'blur', 'change', 'click', 'contextmenu', 'dblclick', 'focus', 'keydown', 'keypress', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'resize', 'scroll',
        'DOMNodeInserted', 'DOMNodeRemoved', 'DOMNodeRemovedFromDocument', 'DOMNodeInsertedIntoDocument', 'DOMAttrModified', 'DOMCharacterDataModified', 'DOMElementNameChanged', 'DOMAttributeNameChanged', 'DOMActivate', 'DOMFocusIn', 'DOMFocusOut', 'online', 'offline', 'textInput',
        'abort', 'close', 'dragdrop', 'load', 'paint', 'reset', 'select', 'submit', 'unload'
    ];

    function stopPropagation (e) {
        e.stopPropagation();
        // e.preventDefault(); // Stop for the form controls, etc., too?
    }
    for ( i=0; i < handlers.length; i++ ) {
        window.addEventListener(handlers[i], function (e) {stopPropagation(e);}, true);
    }

    if (window.stop) {
        window.stop();
    }

    throw '';
}

function stopScreenSaver (  ) {
    
    clearTimeout( timerHandle );
    
}

function setTimeFormat ( tf ) {    // 0 = 12 hour, 1 = 24 hour
    
    use24HourClock = tf;
    updateTitle( getNowMinute() );
    return use24HourClock;
    
} // end setTimeFormat

function setVersion ( v ) {
    
    version = v;
    document.getElementById( 'version' ).textContent = 'Solar Coaster'; // make updating docs easier without version ' +  version;
    return 0;
    
} // end setVersion

// MARK: - Sun, Moon, Eclipses

function computeLunarEclipseDuration ( s, e ) {
    
    if ( s =='-' || e == '-' ) {
        return '';
    }
    
    var end = e.replace( 'italic-', '' );
    var start = s.replace( 'italic-', '' );
    end = end.split(':');
    start = start.split(':');
    var endSecs = end[0]*3600 + end[1]*60 + (+end[2] || 0);
    var startSecs = start[0]*3600 + start[1]*60 + (+start[2] || 0);
    var secs = endSecs - startSecs;
    if ( secs < 0 ) {
        secs += 86400;
    }
    return (secs/3600 |0) + 'h ' + ((secs%3600) / 60 |0) + 'm '; //  + (secs%60) + 's';

} // computeLunarEclipseDuration

function getLunarAzimuthDegrees ( hhmm, penumbralEclipseBegins ) {

    var toks = simEvent.date.split( '-' );
    hhmm = hhmm.replace( 'italic-', '' );
    var dt = new Date( toks[0], toks[1]-1, toks[2], hhmm.substr(0,2), hhmm.substr(3,2), 0, 0 );
    var penumbralDate = new Date( toks[0], toks[1]-1, toks[2], penumbralEclipseBegins.substr(0,2), penumbralEclipseBegins.substr(3,2), 0, 0 );
    if ( dt.getTime() < penumbralDate.getTime() ) { // if current time is < penumbral begins, then we are in the next day
        var nextDay = new Date(dt);
        nextDay.setDate(dt.getDate()+1);
        dt = nextDay;
    }
    var moonPos = SunCalc.getMoonPosition( dt, simEvent.locLat, simEvent.locLon);
    return (moonPos.azimuth + Math.PI) * ( 180 / Math.PI )

} // end getLunarAzimuthDegrees

function lunarEclipseElement( id ) {
    
    let lunarEclipses = setLunartimeperiod( "LE2001", simEvent )
    
    let numLunarEclipses = lunarEclipses.length
    let msg = '<svg width=100 height=100 viewbox="0,0 155,150"><use href="#lunarEclipseIcon" x=-10 y=-20 /></svg>';
    msg += '<div>Between <b>Today</b> and the end of next year '
    if ( numLunarEclipses == 0 ) {
        msg = msg + 'no lunar eclipses'
    } else {
        msg = msg + numLunarEclipses + ' lunar ' + (numLunarEclipses == 1 ? 'eclipse' : 'eclipses' )
    }
    //console.log(solarDate.innerHTML)
    msg = msg + ' will be visible from your current location. </div>'
    if ( numLunarEclipses > 0 ) {
        msg = msg + "<div><ul style='list-style-type: disc;'>"
        for( var e in lunarEclipses ) {
            var eventDetails = lunarEclipses[e] // array of 18 : 2018-Jan-31,T, 2.294, 1.315,10:51,+29,11:48,+19,12:52,+07,13:30,+01,italic-14:08,italic--06,italic-15:11,italic--15,italic-16:08,italic--23
            var event = eType[ eventDetails[1] ] + ' Eclipse ' + lunarHuman2solarHumanDate( eventDetails[0] )
            msg = msg + '<li>' + event
        }
        msg += '</ul></div>'
    }
    
    if ( numLunarEclipses > 0 ) {
        // Must recalulate everything now that we have a real date so that the timezone is accurate.
        let le = lunarEclipses[0];
        simEvent.date = le[0].substring(0,4) + '-' + month2Ord[ le[0].substring(5,8) ] + '-' + le[0].substring(9,11);
        lunarEclipses = setLunartimeperiod( "LE2001", simEvent )
    }
        
    if ( screenSaver == 0 ) {
        msg = msg + "</ul><p>" + showLunarEclipseData( 1, lunarEclipses );
        var help = document.getElementById( 'modal-content-here' );
        help.style['color'] = 'black';
        myAlert( '', msg );
    }

} // lunarEclipseElement

function moonElement ( id ) {
    
    let toDeg = 180 / Math.PI
    let now = getNowDateObject().startOf('day').plus( { minutes: currentMinute } );
    var trend, distance, v;

    var moonPos = SunCalc.getMoonPosition( now, simEvent.locLat, simEvent.locLon);
    var parallacticAngle = moonPos.parallacticAngle;
    var parallacticAngleDegrees = parallacticAngle * ( 180 / Math.PI );
    [distance, trend] = Luna.earthDistance( now );
    var distanceMiles = distance * 0.62137119;
    var dString = "Distance: " + commify( parseFloat( distance ).toFixed( 0 ) ) + " km" + trend + "\n";
    var altitudeString = "Altitude: " + parseFloat( moonPos.altitude * toDeg ).toFixed( 0 ) + "\xB0\n";
    var azimuthString = "Azimuth: " + parseFloat( (moonPos.azimuth + Math.PI) * toDeg ).toFixed( 0 ) + "\xB0\n";
    [v, trend] = Luna.earthOrbitalSpeed( distance ); // km/dy
    v /= 24;
    v = v.toFixed( 0 );
    var vString = "Velocity: " + commify(v) + " km/h"  + trend + " \n";
    var pString = "Phase: " + Luna.phase( now ) + "\n"
    
    let hms = DegreesToRA_PARALLAX( Luna.rightAscension )
    let raStr = 'Right ascension: ' + hms[0] + 'h ' + hms[1] + 'm ' + hms[2] + 's' + ' (' + Luna.rightAscension.toFixed(4) + '\xB0)\n'
    hms = decimalToDMS( Luna.declination )
    let sign = Luna.declination < 0.0 ? '-' : ''
    let decStr = 'Declination: ' + sign + hms['degrees'] + '\xB0 ' + hms['minutes'] + '\' ' + hms['seconds'].toFixed(0) + '"' + ' (' + Luna.declination.toFixed(4) + "\xB0)\n"

    alert( "Luna Information\n\n" + altitudeString +  azimuthString + dString + vString + pString + raStr + decStr );
        
} // end moonElement

function showLunarEclipseData( canResetSimDate, lunarEclipses ) {
    
    let msg = ''
    let numLunarEclipses = lunarEclipses.length
    if ( numLunarEclipses <= 0 ) {
        return msg
    }
    let now = getNowDateObject()
    let ldate
    for ( i = 0; i < numLunarEclipses; i++ ) {
        let le = lunarEclipses[ i ]
        if ( canResetSimDate ) {
            simEvent.date = le[0].substring(0,4) + '-' + month2Ord[ le[0].substring(5,8) ] + '-' + le[0].substring(9,11)
        }
        let lm = le[0].substring(5,8)
        ldate = le[0].substring(0,4) + '-' + lm + '-' + le[0].substring(9,11)
        if ( screenSaver == 0 ) {
            msg = msg + "<p>The simulator date has been temporarily set to the eclipse date '" + simEvent.date + "'."
            let nowMin = getNowMinute()
            updateRiseAndSetTimes( nowMin )
            Sol.animate ( nowMin )
            Luna.animate ( nowMin  )
            animateBackGroundColor ( true, nowMin )
        }
            msg = msg + '<center><h1>Lunar Eclipse of ' + lunarHuman2solarHumanDate( ldate ) + "</h1>"
            msg = msg + '<h1>Moon in ' + eType[ le[1] ] + " Eclipse at this Location</h1></center>\n"
            msg = msg + "<center><table class=eclipseTable border=0>\n"
            var duration = computeLunarEclipseDuration( le[ 8], le[12] )
            if ( duration != '' ) {
                msg += formatTD ( 'Total duration',  duration, '' )
            }
            duration = computeLunarEclipseDuration( le[ 6], le[14], '' )
            if ( duration != '' ) {
                msg += formatTD ( 'Partial duration',  duration, '' )
            }
            duration = computeLunarEclipseDuration( le[ 4], le[16], '' )
            if ( duration != '' ) {
                msg += formatTD ( 'Penumbral duration',  duration, '' )
            }
            msg += formatTD( '-', '-' )
            msg = msg + formatTD ( 'Penumbral magnitude',             le[ 2], '' )
            msg = msg + formatTD ( 'Umbral magnitude',                le[ 3], '' )
            msg += formatTD( '-', '-' );
            if ( le[ 4] != '-' ) {
                msg = msg + formatTD ( 'Penumbral begins',        le[ 4], joinAltAzi( le[ 4], le[ 5], le[ 4] ) )
            }
            if ( le[ 6] != '-' ) {
                msg = msg + formatTD ( 'Partial begins',          le[ 6], joinAltAzi( le[ 6], le[ 7], le[ 4] ) )
            }
            if ( le[ 8] != '-' ) {
                msg = msg + formatTD ( 'Total begins',            le[ 8], joinAltAzi( le[ 8], le[ 9], le[ 4] ) )
            }
            if ( le[10] != '-' ) {
                msg = msg + formatTD ( 'Middle',                  le[10], joinAltAzi( le[10], le[11], le[ 4] ) )
            }
            if ( le[12] != '-' ) {
                msg = msg + formatTD ( 'Total ends',              le[12], joinAltAzi( le[12], le[13], le[ 4] ) )
            }
            if ( le[14] != '-' ) {
                msg = msg + formatTD ( 'Partial ends',            le[14], joinAltAzi( le[14], le[15], le[ 4] ) )
            }
            if ( le[16] != '-' ) {
                msg = msg + formatTD ( 'Penumbral ends',          le[16], joinAltAzi( le[16], le[17], le[ 4] ) )
            }
            break;
    }
    msg = msg + '</table></center>';
    return msg;
    
} // end showLunarEclipseData

function showSolarEclipseData( canResetSimDate, solarEclipses ) {
    
    let msg = ''
    let numSolarEclipses = solarEclipses.length
    if ( numSolarEclipses <= 0 ) {
        return msg
    }
    let now = getNowDateObject()
    let ldate
    for ( let i = 0; i < numSolarEclipses; i++ ) {
        var le = solarEclipses[ i ];
        if ( canResetSimDate ) {
            simEvent.date = le[0].substring(0,4) + '-' + month2Ord[ le[0].substring(5,8) ] + '-' + le[0].substring(9,11);
        }
        let lm = le[0].substring(5,8);
        ldate = le[0].substring(0,4) + '-' + lm + '-' + le[0].substring(9,11);
        if ( screenSaver == 0 ) {
            msg = msg + "<p>The simulator date has been temporarily set to the eclipse date '" + simEvent.date + "'.";
            var nowMin = getNowMinute();
            updateRiseAndSetTimes( nowMin );
            Sol.animate ( nowMin );
            Luna.animate ( nowMin  );
        }
            msg = msg + '<center><h1>Solar Eclipse of ' + lunarHuman2solarHumanDate( ldate ) + "</h1>";
            msg = msg + '<h1>Sun in ' + eTypeSolar[ le[1] ] + " Eclipse at this Location</h1></center>\n";
            msg = msg + "<center><table class=eclipseTable border=0>\n";
            let duration = "";
            let end = le[10].replace( '(s)', '' );
            end = end.split(':');
            let start = le[2].replace( '(r)', '' );
            start = start.split(':');
            let endSecs = end[0]*3600 + end[1]*60 + (+end[2] || 0);
            let startSecs = start[0]*3600 + start[1]*60 + (+start[2] || 0);
            let secs = endSecs - startSecs;
            duration = (secs/3600 |0) + 'h ' + ((secs%3600) / 60 |0) + 'm ' + (secs%60) + 's';
            msg = msg + formatTD ( 'Duration',                              duration, '' );
            msg = msg + formatTD ( 'Magnitude',                             le[13], '' );
            let rs = '';
            let obscuration = le[14];
            if ( obscuration.endsWith( '(s)' ) || obscuration.endsWith( '(r)' ) ) {
                rs = obscuration.substr( obscuration.length-3, 3 );
                obscuration = obscuration.substr( 0, obscuration.length-3 );
            }
            obscuration = ( obscuration * 100.0 ) + '%' + rs;
            msg = msg + formatTD ( 'Obscuration',                           obscuration, '' );
            msg += formatTD( '-', '-' );
            msg = msg + formatTD( "Solar angular diameter", parseFloat( Sol.angularDiameterFromEarth( now ) ).toFixed( 3 ) + "\xB0", '' );
            msg = msg + formatTD( "Lunar angular diameter", parseFloat( Luna.angularDiameterFromEarth( now ) ).toFixed( 3 ) + "\xB0", '' );
            msg += formatTD( '-', '-' );
            msg = msg + formatTD ( 'Partial begins',                le[ 2], joinSolarAltAzi( le[ 3], le[4] ) );
            if ( le[ 5] != '-' ) msg = msg + formatTD ( 'A or T eclipse begins',                 le[ 5], '' );
            msg = msg + formatTD ( 'Maximum',                       le[ 6], joinSolarAltAzi( le[ 7], le[ 8] ) );
            if ( le[ 9] != '-' ) msg = msg + formatTD ( 'A or T eclipse ends',                   le[ 9], '' );
            msg = msg + formatTD ( 'Partial ends',                  le[10], joinSolarAltAzi( le[11], le[12] ) );
            if ( le[15] != '-' ) msg = msg + formatTD ( 'A or T eclipse duration',               le[15], '' );
            msg = msg + "</table>\n";
            msg = msg + "<p>Abbreviations: A = Annular, T = Total, (r) = in progress at sun<u>r</u>ise, (s) = in progress at sun<u>s</u>et.";
            break;
    }
    msg = msg + '</center>';
    return msg;
    
} // showSolarEclipseData

function solarEclipseElement( id ) {

    let solarEclipses = setSolartimeperiod( "SE2001", simEvent )
    
    var numSolarEclipses = solarEclipses.length;
    let msg = '<svg width=100 height=100 viewbox="0,0 155,150"><use href="#solarEclipseIcon" x=-10 y=-20 /></svg>';
    msg += '<p>Between <b>Today</b> and the end of next year ';
    if ( numSolarEclipses == 0 ) {
        msg = msg + 'no solar eclipses'; 
    } else {
        msg = msg + numSolarEclipses + ' solar ' + (numSolarEclipses == 1 ? 'eclipse' : 'eclipses' );
    }
    msg = msg + ' will be visible from your current location.';
    if ( numSolarEclipses > 0 ) {
        msg = msg + "<p><ul>";
        for( var e in solarEclipses ) {
            var eventDetails = solarEclipses[e]; // array of 16 : 2017-Aug-21,P,11:38:05,52,133,-,13:03:49,60,168,-,14:29:57,58,209,0.985,0.987,-
            var event = eTypeSolar[ eventDetails[1] ] + ' Eclipse ' + lunarHuman2solarHumanDate( eventDetails[0] );
            msg = msg + '<li>' + event;
        }
    }
    
    if ( numSolarEclipses > 0 ) {
        // Must recalulate everything now that we have a real date so that the timezone is accurate.
        let le = solarEclipses[0];
        simEvent.date = le[0].substring(0,4) + '-' + month2Ord[ le[0].substring(5,8) ] + '-' + le[0].substring(9,11);
        solarEclipses = setSolartimeperiod( "SE2001", simEvent )
    }

    if ( screenSaver == 0 ) {
        msg = msg + "</ul><p>" + showSolarEclipseData( 1, solarEclipses );
        var help = document.getElementById( 'modal-content-here' );
        help.style['color'] = 'black';
        myAlert( '', msg );
    }
    
} // end solarEclipseElement

function sunElement ( id ) {
    
    let toDeg = 180 / Math.PI
    let now = getNowDateObject().startOf('day').plus( { minutes: currentMinute } );
    let trend, distance, v

    let sunPos = SunCalc.getPosition( now.toJSDate(), simEvent.locLat, simEvent.locLon);
    [distance, trend] = Sol.earthDistance( now )
    let altitudeString = "Altitude: " + parseFloat( sunPos.altitude * toDeg ).toFixed( 0 ) + "\xB0\n"
    let azimuthString = "Azimuth: " + parseFloat( (sunPos.azimuth + Math.PI) * toDeg ).toFixed( 0 ) + "\xB0\n"

    let hms = DegreesToRA_PARALLAX( Sol.rightAscension )
    let raStr = 'Right ascension: ' + hms[0] + 'h ' + hms[1] + 'm ' + hms[2] + 's' + ' (' + Sol.rightAscension.toFixed(4) + '\xB0)\n'
    
    hms = decimalToDMS( Sol.declination )
    let sign = Sol.declination < 0.0 ? '-' : ''
    let decStr = 'Declination: ' + sign + hms['degrees'] + '\xB0 ' + hms['minutes'] + '\' ' + hms['seconds'].toFixed(0) + '"' + ' (' + Sol.declination.toFixed(4) + "\xB0)\n"
    
    let dString = "Distance: " + commify( parseFloat( distance ).toFixed( 0 ) ) + " km" + trend + "\n";
    
    [v, trend] = Sol.earthOrbitalSpeed( distance ) // km/dy
    v /= 24;
    v = v.toFixed( 0 );
    let vString = "Velocity: " + commify(v) + " km/h"  + trend + "\n";
    
    alert( "Sol Information\n\n" +  altitudeString + azimuthString + dString + vString + raStr + decStr );
    
} // end sunElement

// MARK: - Time, Date, TZ

function changeTimezone(date, ianatz) {
   // console.log('SHOULD NEVER BE HERE #########################################################')

   // suppose the date is 12:00 UTC
   var invdate = new Date(date.toLocaleString('en-US', {
       timeZone: ianatz
   }));
   // then invdate will be 07:00 in Toronto  and the diff is 5 hours
   var diff = date.getTime() - invdate.getTime();
   // so 12:00 in Toronto is 17:00 UTC
    let frog= new Date(date.getTime() - diff); // needs to substract
    //return new Date(date.getTime() - diff); // needs to substract
    //console.log(invdate, frog)
    return frog

} // changeTimezone

function clock24To12 (time) {
   
   time = time.toString ().match ( /^([01]\d|2[0-3])(:)([0-5]\d)(:[0-5]\d)?$/ ) || [time];
   
   if (time.length > 1) {    // if time format correct (seconds optional)
       time = time.slice (1);  // remove full string match value
       time[5] = +time[0] < 12 ? ' am' : ' pm';
       time[0] = +time[0] % 12 || 12; // adjust hours
   }
   return time.join ('');  // return adjusted time or original string
    
} // clock24To12

Date.prototype.dst = function() {
    
    return this.getTimezoneOffset() < this.stdTimezoneOffset()
    
} // Date.dst

Date.prototype.stdTimezoneOffset = function() {
    
    var jan = new Date(this.getFullYear(), 0, 1)
    var jul = new Date(this.getFullYear(), 6, 1)
    return Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset())
    
} // Date.stdTimezoneOffset

function daysIntoYear(date) {
    
    return (Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - Date.UTC(date.getFullYear(), 0, 0)) / 24 / 60 / 60 / 1000;
    
} // daysIntoYear

function dayMinute2HumanTime ( minute ) {
    
    if ( minute == -1 ) {
        var spc = "\u00A0\u00A0\u00A0\u00A0\u00A0";
        if ( ! use24HourClock ) {
            spc = spc + "\u00A0\u00A0";
        }
        return spc;
    }
    
    var m = Math.floor( minute );
    var h = Math.floor( m / 60.0 );
    m = m - h * 60.0;
    if ( h >= 24 ) {
        h -= 24;
    }
    var ht = addZero( h ) + ':' + addZero( m );
    if ( ! use24HourClock ) {
        ht = clock24To12( ht );
    }
    return ht;
    
} // dayMinute2HumanTime

function getJ2000Date( date ) {
    
    return date.diff( DateTime.fromISO( '2000-01-01T12:00:00.000Z' ), [ 'days' ] ).days
    
} // getJ2000Date

function getNowDateObject () { // returns a normalized Date object, either now, or a fake now corresponding to the simDate
    
    /*let now = new Date();
    
    if ( simEvent.date ) {
        console.log('not here')
        let toks = simEvent.date.split( '-' );
        now = new Date( toks[0], toks[1]-1, toks[2], now.getHours(), now.getMinutes(), 0, 0 );
    }

    if ( simEvent.ianaZone != "" ) {
        let here = now;
        let there = changeTimezone(here, simEvent.ianaZone);
        let nowHour = now.getHours();
        let thereHour = there.getHours()
        tzHourDelta = nowHour - thereHour;
        now.setHours( now.getHours() - tzHourDelta );
    }*/

    let now = DateTime.now()
    if ( simEvent.date ) {
        let toks = simEvent.date.split( '-' );
        now = DateTime.fromObject( { year: toks[ 0 ], month: toks[ 1 ], day: toks[ 2 ], hour: now.hour, minute: now.minute, second: 0 } )
    }

    return now;
    
} // end getNowDateObject

function getNowMinute () {
    
    // return 873 // documention time on 2025.05.22
    // return 779 // 2017.08.21 full solar eclipse
    // return 299 // 2022.11.08 full lunar eclipse
    let now = getNowDateObject();
    return now.hour * 60 + now.minute
    
} // end getNowMinute

function lunarHuman2solarHumanDate (lunarStyle) {
    
    // 2018-Jan-31
    // 2
    // 31 January 2018
    
    var y = lunarStyle.substring(0, 4);
    var m = lunarStyle.substring(5, 8);
    var d = lunarStyle.substring(9, 11);
    m = fullMonth1[ m ];
    if ( d.substring(0, 1) == '0' ) {
        d = d.substring(1, 2);
    }
    
    return d + ' ' + m + ' ' + y;

} // end lunarHuman2solarHumanDate

function minute2DeltaTime ( minute ) {
    
    if ( minute < 0 ) { minute = -minute }
    let h = Math.floor( minute / 60.0 )
    let m = minute - ( h * 60 )
    return h + ":" + addZero( m )
    
} // end minute2DeltaTime

function setTimezone () { // get IANA zone
    
    try { simEvent.locLat = simEvent.locLat.replace( '-rewrite', '' ) } catch {}
    try { simEvent.locLon = simEvent.locLon.replace( '-rewrite', '' ) } catch {}
    try { simEvent.locAlt = simEvent.locAlt.replace( '-rewrite', '' ) } catch {}

    let suncalcTZ = SunCalc.getTZ( simEvent.locLat, simEvent.locLon )
    DateTimeSettings.defaultZone = suncalcTZ
    document.getElementById( "suncalcTZ" ).innerHTML = suncalcTZ
    simEvent.ianaZone = suncalcTZ
    
    return suncalcTZ

} // setTimezone

function fetchTimezone () { // return TZ for SS
    
    userDefaultsDict[ kSolarCoasterSSTZ ] = simEvent.ianaZone
    storeUserDefaults()
    return simEvent.ianaZone;

} // fetchTimezone

// MARK: - Tween

function playMissedMinutes ( minute ) {
    
    // Tween missed minutes: forward and reverse ease-in and ease-out the simulation.
    // I learned all this from http://upshots.org/actionscript/jsas-understanding-easing.
        
        //console.log("Start Tween minute=" + minute + ", LNM=" + lastKnownMinute + ", tweenTime=" + tweenTime);
        //if ( minute < 0 || minute >= kLastMinute ) minute = 0;
        //if ( lastKnownMinute < 0 || lastKnownMinute >= kLastMinute ) lastKnownMinute = 0;

    if ( lastKnownMinute < ( minute - minutesInterval ) ) { // if before now
        if ( tweenTime == -1 ) {
            tweenTime = 0;
            tweenStart = lastKnownMinute;
            tweenEnd = minute - lastKnownMinute;
            tweenTimeEnd = ( tweenEnd < kLastMinute / 2 ? 1 : 2 );
            //console.log( "start forward tween, LNM="+lastKnownMinute+", minute="+minute+", tweenStart="+tweenStart+", tweenEnd="+tweenEnd+", tweenTineEnd="+tweenTimeEnd );
        }
        minute = tween( tweenTime, tweenStart, tweenEnd, tweenTimeEnd );
        minute = Math.round( minute );
        tweenTime += tweenDT / ( 1000 * minutesInterval );
        lastKnownMinute = minute + minutesInterval;
    } else     if ( lastKnownMinute > ( minute + minutesInterval ) ) { // if after now
        if ( tweenTime == -1 ) {
            tweenTime = 0;
            tweenStart =  minute;
            //tweenEnd = lastKnownMinute - minute;
            tweenEnd = ( ( lastKnownMinute - minute ) % kLastMinute ) + kLastMinute % kLastMinute;
            tweenTimeEnd = ( tweenEnd < kLastMinute / 2 ? 1 : 2 );
            //console.log( "start reverse tween, LNM="+lastKnownMinute+", minute="+minute+", tweenStart="+tweenStart+", tweenEnd="+tweenEnd+", tweenTineEnd="+tweenTimeEnd );
        }
        minute = tween( tweenTime, tweenStart, tweenEnd, tweenTimeEnd );
        minute = ( tweenStart + tweenEnd ) - ( minute - tweenStart );
        minute = Math.round( minute );
        tweenTime += tweenDT / ( 1000 * minutesInterval );
        lastKnownMinute = minute - minutesInterval;
    } else {
        var d = new Date();
        //console.log("Tween bug at "+d+", LNM="+lastKnownMinute+", minute="+minute);
        lastKnownMinute = getNowMinute();
        tweenTime = -1;
    }
    
    if ( minute < 0 ) minute = lastKnownMinute = getNowMinute();
    return Math.round( minute );
    
} // end playMissedMinutes

function tween ( t, b, c, d ) {
    
    //    return c * t / d + b;         // linear
    //    return c*((t=t/d-1)*t*t+1)+b; // ease out cubic
    //
    // Ease-in and ease-out quadratic.
    
    if ( (t /= d / 2 ) < 1 ) {
        return c / 2 * t * t + b;
    }
    return -c / 2 * ( (--t) * ( t - 2 ) -1 ) + b;
    
} // end tween

// MARK: - Miscellaneous

function addZero ( i ) {
    
    let j = '' + i
    if ( j < 10  && j.length < 2 ) {
        j = "0" + j
    }
    return j
    
} // addZero

function commify(x) {
    
    var parts = x.toString().split(".")
    parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",")
    return parts.join(".")
    
} // commify

function componentToHex( c ) {
    
    var d = c
    if ( d > 255 ) {
        d = 255
    }
    var hex = d.toString(16)
    return hex.length == 1 ? "0" + hex : hex
    
} // componentToHex

function decimalToDMS(decimal) {
    
    let absolute = Math.abs(decimal)
    let degrees = Math.floor(absolute)
    let minutes = Math.floor((absolute - degrees) * 60)
    let seconds = ((absolute - degrees - minutes / 60) * 3600)

    return {
        degrees: degrees,
        minutes: minutes,
        seconds: seconds,
    }
    
} // decimalToDMS

function fixDegrees( num ) {

    if ( num == -0 ) {
        num = 0
    }
    let sign = num >= 0 ? 1 : 0
    if ( ! sign ) {
        num = -num
    }
    if ( num < 10 ) {
        if ( sign ) {
            num = "\u00A0\u00A0" + num
        } else {
            num = "\u00A0-" + num
        }
    } else {
        if ( sign ) {
            num = "\u00A0" + num
        } else {
            num = '-' + num
        }
    }
    return num

} // fixDegrees

function formatTD ( desc, val, val2 ) {
    
    if ( desc == '-' && val =='-' ) {
        return "<tr> <td align=right> &nbsp</td>  <td align=right> </td> </tr>\n"
    }
    
    let newVal
    let italicize = 0;
    if ( val.search( 'italic-') == -1 ) {
        newVal = val
    } else {
        newVal = val.replace( 'italic-', '' )
        val2 = val2.replace( 'italic-', '' )
        italicize = 1
    }
    
    // Covert to AM / PM if that preference is set:
    //      Lunar time format is hh:mm
    //      Solar time format is hh:mm:ss and hh:mm:ss.fraction
    // There may be an (r) or (s)
    
    let rs = ''
    if ( ! use24HourClock ) {
        if ( newVal.substr( newVal.length - 3, 3 ) == '(r)' ) {
            rs = '(r)'
            newVal = newVal.replace( '(r)', '' )
            if ( newVal.length == 5 ) {
                newVal += ':00'
            }
        }
        if ( newVal.substr( newVal.length - 3, 3 ) == '(s)' ) {
            rs = '(s)'
            newVal = newVal.replace( '(s)', '' )
            if ( newVal.length == 5 ) {
                newVal += ':00'
            }
        }
        if ( newVal.length == 5 && newVal.substr(2,1) == ':' ) {
            let h = newVal.substr(0,2)
            let pm = ' am'
            if ( h > 12 ) {
                h -= 12
                pm = ' pm'
            }
            newVal = h + ':' + newVal.substr(3,2) + pm
        } else if ( newVal.length == 10 && newVal.substr(2,1) == ':' && newVal.substr(5,1) == ':' && newVal.substr(8,1) == '.' ) { // 11:38:07.2
            let h = newVal.substr(0,2)
            let pm = ' am'
            if ( h > 12 ) {
                h -= 12
                pm = ' pm'
            }
            newVal = h + ':' + newVal.substr(3,5) + pm
        } else if ( newVal.length == 8 && newVal.substr(2,1) == ':' && newVal.substr(5,1) == ':' ) { // 13:03:49
            let h = newVal.substr(0,2)
            let pm = ' am'
            if ( h > 12 ) {
                h -= 12;
                pm = ' pm';
            }
            newVal = h + ':' + newVal.substr(3,5) + pm
        }

    }
    
    newVal += rs;

    if ( italicize ) {
        newVal = '<font color=#787878><i>' + newVal + '</i></font>'
        val2 = '<font color=#787878><i>' + val2 + '</i></font>'
    }
    
    let tr = '<tr class="eclipseTable' + (isWebStyle ? ' eclipseTableWeb' :'') + '"> <td align=right> ' + desc + ':&nbsp</td>  <td align=right> ' + newVal + '</td> <td align=right> &nbsp&nbsp&nbsp' + val2 + "</td> </tr>\n"
    return tr

} // formatTD

function joinAltAzi ( hhmm, altitude, penumbralEclipseBegins ) {
    
    let alt = tidyDegrees( altitude )
    let azi = '-'
    if ( hhmm != '-') {
        azi = parseFloat( getLunarAzimuthDegrees( hhmm, penumbralEclipseBegins ) ).toFixed(  ) + "\xB0"
    }
    return '(' + alt + ', ' + azi + ')'
    
} // joinAltAzi

function joinSolarAltAzi ( altitude, azimuth ) {
    
    let alt = tidyDegrees( altitude )
    let azi = tidyDegrees( azimuth )
    return '(' + alt + ',' + azi + ')'
    
} // end joinAltAzi

function myAlert( img, msg ) {
    
    if ( myAlertActive ) {
        alert("myAlert() is already active, this should not happen")
        return
    }
    myAlertActive = 1
    let leftPadding = 2
    let newMsg = '<button id="myModalOK" onclick="dismissModal();" style="position: fixed; background:rgba(0,22,61,.1); text-align:center; width:36px; height:36px; border-radius:100%; border:1px red; display: inline-flex;flex-direction: column; justify-content:center; align-items:center; font-size:28px; color: white; padding: 0px 0px 0px ' + leftPadding + 'px; text-decoration:none; " >X</button><p>'
    if ( img != '' ) {
        newMsg = newMsg + '<img src="imgs/' + img + '" width=100% height=100%> <br>'
    }
    let modalWindow = document.getElementById('myModalWindow')
    let modalContent = document.getElementById('modal-content-here')
    modalWindow.style.display = "block"
    modalContent.innerHTML = newMsg + msg

} // myAlert

function newTitle ( msg, minute, submsg ) {
    
    title.textContent = msg
    let now = getNowMinute()
    let delta =  Math.round( minute - now )
    let sign = delta >= 0 ? '+' : '-'
    nowDelta.textContent = sign + minute2DeltaTime( delta )
    subtitle.textContent = submsg
    
} //end newTitle

/*
function pdl () { // previousDayLength
    
    let now = getNowDateObject()
    let dur = DateTimeDuration.fromObject( { days: 1 } )
    console.log('dur='+dur)
    //
    let frog = yesterday.minus( dur )
    let d = yesterday.diff( frog , [ 'days' ] )
    console.log('d='+d.days)
    let cow = yesterday - frog;
    console.log('cow='+cow)
    //
    yesterday = yesterday.minus( dur )
    console.log('new yesterday='+yesterday.toString())
    let solarTimes = SunCalc.getTimes( yesterday.toJSDate(), simEvent.locLat, simEvent.locLon, simEvent.locAlt );
    return ( solarTimes.sunset.getHours()  * 3600 + solarTimes.sunset.getMinutes()  * 60 + solarTimes.sunset.getSeconds()  ) -
    ( solarTimes.sunrise.getHours() * 3600 + solarTimes.sunrise.getMinutes() * 60 + solarTimes.sunrise.getSeconds() )

} // pdl
 */

function rgbToHex( r, g, b ) {
    
    return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b)
    
} // rgbToHex

function setDetailedInfoVisibility( vis )  {

    solarDate.setAttribute( 'visibility', vis )
    detailedInfo1.setAttribute( 'visibility', vis )
    detailedInfo2.setAttribute( 'visibility', vis )
    detailedInfo3.setAttribute( 'visibility', vis )
    detailedInfo4.setAttribute( 'visibility', vis )
    detailedInfo5.setAttribute( 'visibility', vis )
    detailedInfo6.setAttribute( 'visibility', vis )
    detailedInfo7.setAttribute( 'visibility', vis )
    detailedInfo8.setAttribute( 'visibility', vis )
    detailedInfo9.setAttribute( 'visibility', vis )
    detailedInfo10.setAttribute( 'visibility', vis )
    detailedInfo11.setAttribute( 'visibility', vis )
    detailedInfo12.setAttribute( 'visibility', vis )

} // setDetailedInfoVisibility

function setMarkersVisibility( vis ) {
    
    dawnCircle.setAttribute( "visibility", vis )
    duskCircle.setAttribute( "visibility", vis )
    solarNoonCircle.setAttribute( "visibility", vis )
    sunriseCircle.setAttribute( "visibility", vis )
    sunsetCircle.setAttribute( "visibility", vis )
    moonriseCircle.setAttribute( "visibility", ( moonrise == -1 ) ? 'hidden' : vis )
    moonsetCircle.setAttribute( "visibility", ( moonset == -1 ) ? 'hidden' : vis )
    nowCircle.setAttribute( 'visibility', vis )
    solarMidnightCircle.setAttribute( "visibility", vis )
    solarMidnightCircleLeft.setAttribute( "visibility", vis )
    solarMidnightCircleRight.setAttribute( "visibility", vis )
    nowDelta.setAttribute( 'visibility', vis )
    
} // end setMarkersVisibility

function sqzSpc ( str ) {
    
    if ( str.length > 7 ) {
        str = str.replace( ' ', '' )
    }
    return str;
    
} // sqzSpc

function ssLog ( msg ) {
    
    console.log( msg )
    let debug = document.getElementById( 'debugText' )
    if ( ! debug ) return
    debug.setAttribute( 'visibility', 'visible' )
    let t = debug.innerHTML
    //debug.innerHTML = msg + ' ' + t // right to left
    debug.innerHTML = t + ' ' + msg // left to right

} // ssLog

function tidyDegrees ( le ) { // tidy degrees
    
    let d = le
    if ( d == '-' ) {
        return d
    }
    d = d.replace( '+', '');
    if ( d == '0(r)' ) {
        return '0' + "\xb0" + '(r)'
    }
    if ( d == '0(s)' ) {
        return '0' + "\xb0" + '(s)'
    }
    if ( d.length > 1 && d.substr(0, 1) == '0' ) {
        d = d.substr( 1, d.length - 1 )
    }
    if ( d.length > 2 && d.substr(0, 2) == '-0' ) {
        d = '-' + d.substr( 2, d.length - 2 )
    }
    // italic-05
    if ( d.length > 1+7 && d.substr(0, 1+7) == 'italic-0' ) {
        d = 'italic-' + d.substr( 1+7, d.length - 1+7 )
    }
    // italic--05
    if ( d.length > 1+7+1 && d.substr(0, 1+7+1) == 'italic--0' ) {
        d = 'italic--' + d.substr( 1+7+1, d.length - 1+7+1 )
    }

    d += "\xB0"
    return d
    
} // tidyDegrees

function triangleElement( id ) {

    var daylight = sunset - sunrise
    var h = Math.floor( daylight / 60.0 )
    var m = daylight - h * 60.0
    
    alert( "Daylight: " + h + " hours, " + m + " minutes"  )
    
} // triangleElement

function updateTitle ( minute ) {
    
    let now = getNowMinute()
    if ( minute >= dawn-1 && minute <= dawn + ( minutesInterval - 1 )  ) {
        newTitle( "DAWN", minute, dayMinute2HumanTime( dawn ) )
    } else if ( minute >= sunrise-1 && minute <= sunrise + ( minutesInterval - 1 ) ) {
        newTitle( 'SUNRISE', minute, dayMinute2HumanTime( sunrise ) )
    } else if ( minute >= solarNoon-1 && minute <= solarNoon + ( minutesInterval - 1 ) ) {
        newTitle( 'SOLAR NOON', minute, dayMinute2HumanTime( solarNoon ) )
    } else if ( minute >= sunset-1 && minute <= sunset + ( minutesInterval - 1 ) ) {
        newTitle( 'SUNSET', minute, dayMinute2HumanTime( sunset ) )
    } else if ( minute >= dusk-1 && minute <= dusk + ( minutesInterval - 1 )  ) {
        newTitle( "DUSK", minute, dayMinute2HumanTime( dusk ) )
    } else if ( (minute >= solarMidnight-1 && minute <= solarMidnight    + ( minutesInterval - 1 )) || (minute >= solarMidnight+0 && minute <= solarMidnight+1  + ( minutesInterval - 1 )) ) {
        newTitle( "SOLAR MIDNIGHT", minute, dayMinute2HumanTime( solarMidnight ) )
    } else if ( minute >= now-1 && minute <= now + ( minutesInterval - 1 ) ) {
        newTitle( 'NOW', minute, dayMinute2HumanTime( now ) )
    } else if ( minute >= (moonrise >= solarMidnight ? moonrise : moonrise + kLastMinute)-1 && minute <= (moonrise >= solarMidnight ? moonrise : moonrise + kLastMinute) + ( minutesInterval - 1 ) && moonrise != -1 ) {
        newTitle( 'MOONRISE', minute, dayMinute2HumanTime( moonrise ) )
    } else if ( minute >= (moonset >= solarMidnight ? moonset : moonset + kLastMinute)-1 && minute <= (moonset >= solarMidnight ? moonset : moonset + kLastMinute) + ( minutesInterval - 1 ) &&  moonset != -1 ) {
        newTitle( 'MOONSET', minute, dayMinute2HumanTime( moonset ) )
    }

} // updateTitle
