🏡 index : cold-air-networks/mirror-jetstream.git

/*
 * Copyright (C) 2017 Apple Inc. All rights reserved.
 * Copyright (C) 2016-2017 Michael Saboff. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL APPLE INC. OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 */
"use strict";

let earthRadius = 3440; // In nautical miles.
let TwoPI = Math.PI * 2;
let degreeCharacter = "\u00b0";

let regExpOptionalUnicodeFlag;
var keywords;

if (this.useUnicode) {
    regExpOptionalUnicodeFlag = "u";
    keywords = UnicodeStrings;
} else {
    regExpOptionalUnicodeFlag = "";
    keywords = { get: function(str) { return str; } };
}

function status(text)
{
    console.debug("Status: " + text);
}

function error(text)
{
    console.error("Error: " + text);
}

if (typeof(Number.prototype.toRadians) === "undefined") {
    Number.prototype.toRadians = function() {
        return this * Math.PI / 180;
    }
}

if (typeof(Number.prototype.toDegrees) === "undefined") {
    Number.prototype.toDegrees = function() {
        return this * 180 / Math.PI;
    }
}

function distanceFromSpeedAndTime(speed, time)
{
    return speed * time.hours();
}

let LatRE = new RegExp("^([NS\\-])?(90|[0-8]?\\d)(?:( [0-5]?\\d\\.\\d{0,3})'?|(\\.\\d{0,6})|( ([0-5]?\\d)\" ?([0-5]?\\d)'?))?", "i" + regExpOptionalUnicodeFlag);

function decimalLatitudeFromString(latitudeString)
{
    if (typeof latitudeString != "string")
        return 0;

    let match = latitudeString.match(LatRE);

    if (!match)
        return 0;

    let result = 0;
    let sign = 1;

    if (match[1] && (match[1].toUpperCase() == "S" || match[1] == "-"))
        sign = -1;

    result = Number(match[2]);

    if (result != 90) {
        if (match[3]) {
            // e.g. N37 42.874
            let minutes = Number(match[3]);
            result = result + (minutes / 60);
        } else if (match[4]) {
            // e.g. N37.30697
            let decimalDegrees = Number(match[4]);
            result = result + decimalDegrees;
        } else if (match[5]) {
            // e.g. N37 18" 27'
            let degrees = Number(match[6]);
            let minutes = Number(match[7]);
            result = result + (degrees + minutes / 60) / 60;
        }
    }

    return result * sign;
}

let LongRE = new RegExp("^([EW\\-]?)(180|(?:1[0-7]|\\d)?\\d)(?:( [0-5]?\\d\\.\\d{0,3})|(\\.\\d{0,6})|( ([0-5]?\\d)\" ?([0-5]?\\d)'?)?)", "i" + regExpOptionalUnicodeFlag);

function decimalLongitudeFromString(longitudeString)
{
    if (typeof longitudeString != "string")
        return 0;

    let match = longitudeString.match(LongRE);

    if (!match)
        return 0;

    let result = 0;
    let sign = 1;

    if (match[1] && (match[1].toUpperCase() == "W" || match[1] == "-"))
        sign = -1;

    result = Number(match[2]);

    if (result != 180) {
        if (match[3]) {
            // e.g. W121 53.254
            let minutes = Number(match[3]);
            result = result + (minutes / 60);
        } else if (match[4]) {
            // e.g. W121.8876
            let decimalDegrees = Number(match[4]);
            result = result + decimalDegrees;
        } else if (match[5]) {
            // e.g. W121 53" 15'
            let degrees = Number(match[6]);
            let minutes = Number(match[7]);
            result = result + (degrees + minutes / 60) / 60;
        }
    }

    return result * sign;
}

let TimeRE = new RegExp("^([0-9][0-9]?)(?:\:([0-5][0-9]))?(?:\:([0-5][0-9]))?$");

class Time
{
    constructor(time)
    {
        if (time instanceof Date) {
            this._seconds = Math.Round(time.valueOf() / 1000);
            return;
        }

        if (typeof time == "string") {
            let match = time.match(TimeRE);

            if (!match) {
                this._seconds = 0;
                return;
            }

            if (match[3]) {
                let hours = parseInt(match[1].toString());
                let minutes = parseInt(match[2].toString());
                let seconds = parseInt(match[3].toString());

                this._seconds = (hours * 60 + minutes) * 60 + seconds;
            } else if (match[2]) {
                let minutes = parseInt(match[1].toString());
                let seconds = parseInt(match[2].toString());

                this._seconds = minutes * 60 + seconds;
            } else
                this._seconds = parseInt(match[1].toString());
            return;
        }

        if (typeof time == "number") {
            this._seconds = Math.round(time);
            return;
        }

        this._seconds = 0;
    }

    add(otherTime)
    {
        return new Time(this._seconds + otherTime._seconds);
    }

    addDate(otherDate)
    {
        return new Date(this._seconds * 1000 + otherDate.valueOf());
    }

    static differenceBetween(time2, time1)
    {
        let seconds1;
        let seconds2;
        if (time1 instanceof Time)
            seconds1 = time1.seconds();
        else
            seconds1 = Math.Round(time1.valueOf() / 1000);

        if (time2 instanceof Time)
            seconds2 = time2.seconds();
        else
            seconds2 = Math.Round(time2.valueOf() / 1000);

        return new Time(seconds2 - seconds1);
    }

    seconds()
    {
        return this._seconds;
    }

    minutes()
    {
        return this._seconds / 60;
    }

    hours()
    {
        return this._seconds / 3600;
    }

    toString()
    {
        let result = "";
        let seconds = this._seconds % 60;
        if (seconds < 0) {
            result = "-";
            seconds = -seconds;
        }
        let minutes = this._seconds / 60 | 0;
        let hours = minutes / 60 | 0;
        minutes = minutes % 60;

        if (hours)
            result = result + hours + ":";
        if (minutes < 10 && hours)
            result = result + "0";
        result = result + minutes + ":";
        if (seconds < 10)
            result = result + "0";
        result = result + seconds;

        return result;
    }
}

class GeoLocation
{
    constructor(latitude, longitude)
    {
        this.latitude = latitude;
        this.longitude = longitude;
    }

    latitudeString()
    {
        let latitude = this.latitude;
        let latitudePrefix = "N";
        if (latitude < 0) {
            latitude = -latitude;
            latitudePrefix = "S"
        }
        let latitudeDegrees = Math.floor(latitude);
        let latitudeMinutes = ((latitude - latitudeDegrees) * 60).toFixed(3);
        let latitudeMinutesFiller = latitudeMinutes < 10 ? " " : "";
        return latitudePrefix + latitudeDegrees + degreeCharacter + latitudeMinutesFiller + latitudeMinutes + "'";
    }

    longitudeString()
    {
        let longitude = this.longitude;
        let longitudePrefix = "E";
        if (longitude < 0) {
            longitude = -longitude;
            longitudePrefix = "W"
        }

        let longitudeDegrees = Math.floor(longitude);
        let longitudeMinutes = ((longitude - longitudeDegrees) * 60).toFixed(3);
        let longitudeMinutesFiller = longitudeMinutes < 10 ? " " : "";
        return longitudePrefix + longitudeDegrees + degreeCharacter + longitudeMinutesFiller + longitudeMinutes + "'";
    }

    distanceTo(otherLocation)
    {
        let dLat = (otherLocation.latitude - this.latitude).toRadians();
        let dLon = (otherLocation.longitude - this.longitude).toRadians();
        let a = Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(this.latitude.toRadians()) * Math.cos(otherLocation.latitude.toRadians()) *
            Math.sin(dLon/2) * Math.sin(dLon/2);
        let c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
        return earthRadius * c;
    }
    
    bearingFrom(otherLocation, magneticVariation)
    {
        if (magneticVariation == undefined)
            magneticVariation = 0;

        let dLon = (this.longitude - otherLocation.longitude).toRadians();
        let thisLatitudeRadians = this.latitude.toRadians();
        let otherLatitudeRadians = otherLocation.latitude.toRadians();
        let y = Math.sin(dLon) * Math.cos(this.latitude.toRadians());
        let x = Math.cos(otherLatitudeRadians) * Math.sin(thisLatitudeRadians) -
            Math.sin(otherLatitudeRadians) * Math.cos(thisLatitudeRadians) * Math.cos(dLon);
        return (Math.atan2(y, x).toDegrees() + 720 + magneticVariation) % 360;
    }

    bearingTo(otherLocation, magneticVariation)
    {
        if (magneticVariation == undefined)
            magneticVariation = 0;

        let dLon = (otherLocation.longitude - this.longitude).toRadians();
        let thisLatitudeRadians = this.latitude.toRadians();
        let otherLatitudeRadians = otherLocation.latitude.toRadians();
        let y = Math.sin(dLon) * Math.cos(otherLocation.latitude.toRadians());
        let x = Math.cos(thisLatitudeRadians) * Math.sin(otherLatitudeRadians) -
            Math.sin(thisLatitudeRadians) * Math.cos(otherLatitudeRadians) * Math.cos(dLon);
        return (Math.atan2(y, x).toDegrees() + 720 + magneticVariation) % 360
    }

    locationFrom(bearing, distance, magneticVariation)
    {
        if (magneticVariation == undefined)
            magneticVariation = 0;

        let bearingRadians = (bearing - magneticVariation).toRadians();
        let thisLatitudeRadians = this.latitude.toRadians();
        let angularDistance = distance / earthRadius;
        let latitudeRadians = Math.asin(Math.sin(thisLatitudeRadians) * Math.cos(angularDistance) +
                                 Math.cos(thisLatitudeRadians) * Math.sin(angularDistance) * Math.cos(bearingRadians));
        let longitudeRadians = this.longitude.toRadians() +
            Math.atan2(Math.sin(bearingRadians) * Math.sin(angularDistance) * Math.cos(thisLatitudeRadians),
                       Math.cos(angularDistance) - Math.sin(thisLatitudeRadians) * Math.sin(latitudeRadians));

        return new GeoLocation(latitudeRadians.toDegrees(), longitudeRadians.toDegrees());
    }

    toString()
    {
        return "(" + this.latitudeString() + ", " + this.longitudeString() + ")";
    }
}

function findFaaWaypoint(waypoint)
{
    return faaWaypoints[waypoint];
}

class FaaWaypoints
{
    constructor()
    {
        if (!FaaWaypoints.instance) {
            FaaWaypoints.instance = this;
            this.waypoints = _faaWaypoints;
        }

        return FaaWaypoints.instance;
    }

    find(waypoint)
    {
        return this.waypoints[waypoint];
    }
}

FaaWaypoints.instance = undefined;

let faaWaypoints = new FaaWaypoints();

class FaaAirways
{
    constructor()
    {
        if (!FaaAirways.instance) {
            FaaAirways.instance = this;
            this.airways = _faaAirways;
        }

        return FaaAirways.instance;
    }

    isAirway(identifier)
    {
        return !!this.airways[identifier];
    }

    resolveAirway(airwayID, entryPoint, exitPoint)
    {
        let airway = this.airways[airwayID];

        if (!airway)
            return "";

        let entryIndex = airway.fixes.indexOf(entryPoint);
        let exitIndex = airway.fixes.indexOf(exitPoint);

        if (entryIndex == -1 || exitIndex == -1)
            return "";

        let stride = (entryIndex <= exitIndex) ? 1 : -1;

        let route = [];

        for (let idx = entryIndex; idx != exitIndex; idx = idx + stride)
            route.push(airway.fixes[idx]);

        route.push(airway.fixes[exitIndex]);

        return route;
    }
}

FaaAirways.instance = undefined;

let faaAirways = new FaaAirways();

class UserWaypoints
{
    constructor()
    {
        if (!UserWaypoints.instance) {
            UserWaypoints.instance = this;
            this.waypoints = {};
        }

        return UserWaypoints.instance;
    }

    clear()
    {
        this.waypoints = {};
    }

    find(waypoint)
    {
        return this.waypoints[waypoint];
    }

    update(name, description, latitude, longitude)
    {
        if (typeof latitude == "string")
            latitude = decimalLatitudeFromString(latitude);

        if (typeof longitude == "string")
            longitude = decimalLongitudeFromString(longitude);

        this.waypoints[name.toUpperCase()] = {
            "name": name,
            "description": description,
            "latitude": latitude,
            "longitude": longitude
        };
    }
}

UserWaypoints.instance = undefined;

let userWaypoints = new UserWaypoints();


class EngineConfig
{
    constructor(type, fuelFlow, trueAirspeed)
    {
        this.type = type;
        this._fuelFlow = fuelFlow;
        this._trueAirspeed = trueAirspeed;
    }

    trueAirspeed()
    {
        return this._trueAirspeed;
    }

    fuelFlow()
    {
        return this._fuelFlow;
    }

    static appendConfig(type, fuelFlow, trueAirspeed)
    {
        if (this.allConfigsByType[type]) {
            status("Duplicate Engine configuration: " + type);
            return;
        }

        var newConfig = new EngineConfig(type, fuelFlow, trueAirspeed);
        this.allConfigs.push(newConfig);
        this.allConfigsByType[type] = newConfig;
    }

    static getConfig(n)
    {
        if (n >= this.allConfigs.length)
            return undefined;

        return this.allConfigs[n];
    }
}

EngineConfig.allConfigs = [];
EngineConfig.allConfigsByType = {};
EngineConfig.Taxi = 0;
EngineConfig.Runup = 1;
EngineConfig.Takeoff = 2;
EngineConfig.Climb = 3;
EngineConfig.Cruise = 4;
EngineConfig.Pattern= 5;

class Waypoint
{
    constructor(name, type, description, latitude, longitude)
    {
        this.name = name;
        this.type = type;
        this.description = description;
        this.latitude = latitude;
        this.longitude = longitude;
    }
}

class Leg
{
    constructor(fix, location)
    {
        this.previous = undefined;
        this.next = undefined;
        this.fix = fix;
        this.location = location;
        this.course = 0;
        this.distance = 0;
        this.trueAirspeed = 0;
        this.windDirection = 0;
        this.windSpeed = 0;
        this.heading = 0;
        this.estGS = 0;
        this.startFlightTiming = false;
        this.stopFlightTiming = false;
        this.engineConfig = EngineConfig.Cruise;
        this.fuelFlow = 0;
        this.distanceRemaining = 0;
        this.estimatedTimeEnroute = undefined;
        this.estTimeRemaining = 0;
        this.estFuel = 0;
    }

    fixName()
    {
        return this.fix;
    }

    toString()
    {
        return this.fix;
    }

    setPrevious(leg)
    {
        this.previous = leg;
    }

    previousLeg()
    {
        return this.previous;
    }

    setNext(leg)
    {
        this.next = leg;
    }

    nextLeg()
    {
        return this.next;
    }

    setWind(windDirection, windSpeed)
    {
        this.windDirection = windDirection;
        this.windSpeed = windSpeed;
    }

    isSameWind(windDirection, windSpeed)
    {
        return this.windDirection == windDirection && this.windSpeed == windSpeed;
    }

    windToString()
    {
        if (!this.windSpeed)
            return "";

        return (this.windDirection ? this.windDirection : "360") + "@" + this.windSpeed;
    }

    setTrueAirspeed(trueAirspeed)
    {
        this.trueAirspeed = trueAirspeed;
    }

    isStandardTrueAirspeed()
    {

        let engineConfig = EngineConfig.getConfig(this.engineConfig);

        return (this.trueAirspeed == engineConfig.trueAirspeed());
    }

    trueAirspeedToString()
    {
        return this.trueAirspeed + "kts";
    }

    updateDistanceAndBearing(other)
    {
        this.distance = this.location.distanceTo(other);
        this.course = Math.round(this.location.bearingFrom(other));
        if (this.estimatedTimeEnroute == undefined && this.estGS != 0) {
            let estimatedTimeEnrouteInSeconds = Math.round(this.distance * 3600 / this.estGS);
            this.estimatedTimeEnroute = new Time(estimatedTimeEnrouteInSeconds);
        }

        if (this.estimatedTimeEnroute.seconds())
            this.estFuel = this.fuelFlow * this.estimatedTimeEnroute.hours();
    }

    propagateWind()
    {
        let windDirection = this.windDirection;
        let windSpeed = this.windSpeed;

        windDirection = (windDirection + 360) % 360;
        if (!windDirection)
            windDirection = 360;

        for (let currLeg = this; currLeg; currLeg = currLeg.nextLeg()) {
            currLeg.windDirection = windDirection;
            currLeg.windSpeed = windSpeed;
            if (currLeg.stopFlightTiming)
                break;
        }
    }

    updateForWind()
    {
        if (!this.windSpeed || !this.trueAirspeed) {
            this.heading = this.course;
            this.estGS = this.trueAirspeed;
            return;
        }

        let windDirectionRadians = this.windDirection.toRadians();
        let courseRadians = this.course.toRadians();
        let swc = (this.windSpeed / this.trueAirspeed) * Math.sin(windDirectionRadians - courseRadians);
        if (Math.abs(swc) > 1) {
            status("Wind to strong to fly!");
            return;
        }

        let headingRadians = courseRadians + Math.asin(swc);
        if (headingRadians < 0)
            headingRadians += TwoPI;
        if (headingRadians > TwoPI)
            headingRadians -= TwoPI
        let groundSpeed = this.trueAirspeed * Math.sqrt(1 - swc * swc) -
            this.windSpeed * Math.cos(windDirectionRadians - courseRadians);
        if (groundSpeed < 0) {
            status("Wind to strong to fly!");
            return;
        }

        this.estGS = groundSpeed;
        this.heading = Math.round(headingRadians.toDegrees());
    }

    calculate()
    {
        let engineConfig = EngineConfig.getConfig(this.engineConfig);

        if (!this.trueAirspeed)
            this.trueAirspeed = engineConfig.trueAirspeed();
        this.fuelFlow = engineConfig.fuelFlow();

        this.updateForWind();
    }

    updateForward()
    {
        if (this.specialUpdateForward)
            this.specialUpdateForward();

        let previousLeg = this.previousLeg();
        let havePrevious = true;
        if (!previousLeg) {
            havePrevious = false;
            previousLeg = this;
            if (!this.estimatedTimeEnroute)
                this.estimatedTimeEnroute = new Time(0);
        }

        let thisLegType = this.type;
        if (thisLegType == "Climb" && havePrevious)
            this.location = previousLeg.location;
        else {
            this.updateDistanceAndBearing(previousLeg.location);
            this.updateForWind();
            let nextLeg = this.nextLeg();
            let previousLegType = previousLeg.type;
            if (havePrevious) {
                if (previousLegType == "Climb") {
                    let climbDistance = distanceFromSpeedAndTime(previousLeg.estGS, previousLeg.climbTime);
                    if (climbDistance < this.distance) {
                        let climbStartLocation = previousLeg.location;
                        let climbEndLocation = climbStartLocation.locationFrom(this.course, climbDistance);
                        previousLeg.location = climbEndLocation;
                        previousLeg.updateDistanceAndBearing(climbStartLocation);
                        this.estimatedTimeEnroute = undefined;
                        this.updateDistanceAndBearing(climbEndLocation);
                    } else {
                        status("Not enough distance to climb in leg #" + previousLeg.index);
                    }
                } else if ((thisLegType == "Left" || thisLegType == "Right") && nextLeg && nextLeg.location) {
                    let standardRateCircumference = this.trueAirspeed / 30;
                    let standardRateRadius = standardRateCircumference / TwoPI;
                    let offsetInboundBearing = 360 + previousLeg.course + (thisLegType == "Left" ? -90 : 90);
                    offsetInboundBearing = Math.round((offsetInboundBearing + 360) % 360);
                    // Save original location
                    if (!previousLeg.originalLocation)
                        previousLeg.originalLocation = previousLeg.location;
                    let previousLocation = previousLeg.originalLocation;
                    let inboundLocation = previousLocation.locationFrom(offsetInboundBearing, standardRateRadius);
                    let bearingToNext = Math.round(nextLeg.location.bearingFrom(previousLocation));
                    let offsetOutboundBearing = bearingToNext + (thisLegType == "Left" ? 90 : -90);
                    offsetOutboundBearing = (offsetOutboundBearing + 360) % 360;
                    let outboundLocation = previousLocation.locationFrom(offsetOutboundBearing, standardRateRadius);
                    let turnAngle = thisLegType == "Left" ? (360 + bearingToNext - previousLeg.course) : (360 + previousLeg.course - bearingToNext);
                    turnAngle = (turnAngle + 360) % 360;
                    let totalDegrees = turnAngle + 360 * this.extraTurns;
                    let secondsInTurn = Math.round(totalDegrees / 3);
                    this.estimatedTimeEnroute = new Time(Math.round((turnAngle + 360 * this.extraTurns) / 3));
                    this.estFuel = this.fuelFlow * this.estimatedTimeEnroute.hours();
                    this.location = outboundLocation;
                    this.distance = distanceFromSpeedAndTime(this.trueAirspeed, this.estimatedTimeEnroute);
                    previousLeg.location = inboundLocation;
                    let prevPrevLeg = previousLeg.previousLeg();
                    if (prevPrevLeg && prevPrevLeg.location) {
                        previousLeg.estimatedTimeEnroute = undefined;
                        previousLeg.updateDistanceAndBearing(prevPrevLeg.location);
                    }
                }
            }
        }
    }

    updateBackward()
    {
        let nextLeg = this.nextLeg();

        let distanceRemaining;
        let timeRemaining;

        if (nextLeg) {
            distanceRemaining = nextLeg.distanceRemaining;
            timeRemaining = nextLeg.estTimeRemaining;
        } else {
            distanceRemaining = 0;
            timeRemaining = new Time(0);
        }

        if (this.stopFlightTiming || timeRemaining.seconds()) {
            this.distanceRemaining = distanceRemaining + this.distance;;
            this.estTimeRemaining = timeRemaining.add(this.estimatedTimeEnroute);
        } else
            this.estTimeRemaining = new Time(0);
    }
}

let RallyLegWithFixRE = new RegExp("^([0-9a-z\.]{3,16})\\|(" + keywords.get("START") + "|" + keywords.get("TIMING") + ")", "i" + regExpOptionalUnicodeFlag);

class RallyLeg extends Leg
{
    constructor(type, fix, location, engineConfig)
    {
        super(fix, location);
        this.type = type;
        this.engineConfig = engineConfig;
    }

    fixName()
    {
        return this.type;
    }

    toString()
    {
        return this.fixName();
    }

    static reset()
    {
        RallyLeg.startLocation = undefined;
        RallyLeg.startFix = "";
        RallyLeg.totalTaxiTime = new Time(0);
        RallyLeg.taxiSegments = [];
    }

    static fixNeeded(fix)
    {
        let match = fix.match(RallyLegWithFixRE);

        if (!match)
            return "";

        return match[1].toString();
    }

    static getLegWithFix(waypointText, fix, location)
    {
        let match = waypointText.match(RallyLegWithFixRE);

        if (!match)
            return undefined;

        let legType = match[2].toString();

        if (legType == keywords.get("START")) {
            if (this.startLocation) {
                status("Trying to create second start leg");
                return undefined;
            }

            this.startLocation = location;
            this.startFix = fix;
            this.totalTaxiTime = new Time(0);
            this.taxiSegments = [];

            return new StartLeg(waypointText, fix, location);
        }

        if (legType == keywords.get("TIMING"))
            return new TimingLeg(waypointText, fix, location);


        error("Unhandled Rally Leg type " + legType);
        return undefined;
    }
}

RallyLeg.startLocation = undefined;
RallyLeg.startFix = "";
RallyLeg.totalTaxiTime = new Time(0);
RallyLeg.taxiSegments = [];

class StartLeg extends RallyLeg
{
    constructor(fixText, fix, location)
    {
        super("Start", fix, location, EngineConfig.Taxi);
    }

    fixName()
    {
        return this.fix + "|Start";
    }
}

class TimingLeg extends RallyLeg
{
    constructor(fixText, fix, location)
    {
        super("Timing", fix, location, EngineConfig.Cruise);
        this.stopFlightTiming = true;
    }

    fixName()
    {
        return this.fix + "|Timing";
    }
}

let RallyLegNoFixRE = new RegExp(keywords.get("TAXI") + "|" + keywords.get("RUNUP") + "|" + keywords.get("TAKEOFF") + "|" + keywords.get("CLIMB") + "|" + keywords.get("PATTERN") + "|" + keywords.get("LEFT") + "|" + keywords.get("RIGHT"), "i" + regExpOptionalUnicodeFlag);

class RallyLegWithoutFix extends RallyLeg
{
    constructor(type, CommentsAsFix, location, engineConfig)
    {
        super(type, CommentsAsFix, location, engineConfig);
    }

    setPrevious(previous)
    {
        if (this.setLocationFromPrevious() && previous)
            this.location = previous.location;

        super.setPrevious(previous);
    }

    setLocationFromPrevious()
    {
        return false;
    }

    static isRallyLegWithoutFix(fix)
    {
        let barPosition = fix.indexOf("|");
        let firstPart = barPosition < 0 ? fix : fix.substring(0, barPosition);

        return RallyLegNoFixRE.test(firstPart);
    }

    static getLegNoFix(waypointText)
    {
        let barPosition = waypointText.indexOf("|");
        let firstPart = barPosition < 0 ? waypointText : waypointText.substring(0, barPosition);
        firstPart = firstPart.toUpperCase();

        let match = firstPart.match(RallyLegNoFixRE);

        if (!match)
            return undefined;

        let legType = match[0].toString();

        if (legType == keywords.get("TAXI"))
            return new TaxiLeg(waypointText);

        if (legType == keywords.get("RUNUP"))
            return new RunupLeg(waypointText);

        if (legType == keywords.get("TAKEOFF")) {
            if (!this.startLocation) {
                status("Trying to create a Takeoff leg without start leg");
                return undefined;
            }

            return new TakeoffLeg(waypointText);
        }

        if (legType == keywords.get("CLIMB"))
            return new ClimbLeg(waypointText);

        if (legType == keywords.get("PATTERN"))
            return new PatternLeg(waypointText);

        if (legType == keywords.get("LEFT") || legType == keywords.get("RIGHT"))
            return new TurnLeg(waypointText, legType == keywords.get("RIGHT"));

        error("Unhandled Rally Leg type " + legType);
        return undefined;
    }
}

// TAXI[|<time>]  e.g. TAXI|2:30
let TaxiLegRE = new RegExp("^" + keywords.get("TAXI") + "(?:\\|([0-9][0-9]?(?:\:[0-5][0-9])?))?$", "i" + regExpOptionalUnicodeFlag);

class TaxiLeg extends RallyLegWithoutFix
{
    constructor(fixText)
    {
        let match = fixText.match(TaxiLegRE);

        super("Taxi", "", new GeoLocation(-1, -1), EngineConfig.Taxi);

        let taxiTimeString = "5:00";
        if (match[1])
            taxiTimeString = match[1].toString();

        this.estimatedTimeEnroute = new Time(taxiTimeString);
    }

    setLocationFromPrevious()
    {
        return true;
    }    

    fixName()
    {
        return "Taxi|" + this.estimatedTimeEnroute.toString();
    }
}

// RUNUP[|<time>]  e.g. RUNUP|0:30
let RunupLegRE = new RegExp("^" + keywords.get("RUNUP") + "(?:\\|([0-9][0-9]?(?:\:[0-5][0-9])?))?$", "i" + regExpOptionalUnicodeFlag);

class RunupLeg extends RallyLegWithoutFix
{
    constructor(fixText)
    {
        let match = fixText.match(RunupLegRE);

        super("Runup", "", new GeoLocation(-1, -1), EngineConfig.Runup);

        let runupTimeString = "30";
        if (match[1])
            runupTimeString = match[1].toString();

        this.estimatedTimeEnroute = new Time(runupTimeString);
    }

    setLocationFromPrevious()
    {
        return true;
    }    

    fixName()
    {
        return "Runup|" + this.estimatedTimeEnroute.toString();
    }
}

// TAKEOFF[|<time>][|<bearing>|<distance>]  e.g. TAKEOFF|2:00|270@3.5
let TakeoffLegRE = new RegExp("^" + keywords.get("TAKEOFF") + "(?:\\|([0-9][0-9]?(?:\:[0-5][0-9])?))?(?:\\|([0-9]{1,2}|[0-2][0-9][0-9]|3[0-5][0-9]|360)(?:@)(\\d{1,2}(?:\\.\\d{1,4})?))?$", "i" + regExpOptionalUnicodeFlag);

class TakeoffLeg extends RallyLegWithoutFix
{
    constructor(fixText)
    {
        let match = fixText.match(TakeoffLegRE);

        let bearingFromStart = 0;
        let distanceFromStart = 0;
        let takeoffEndLocation = RallyLeg.startLocation;
        if (match && match[2] && match[3]) {
            bearingFromStart = parseInt(match[2].toString()) % 360;
            distanceFromStart = parseFloat(match[3].toString());
            takeoffEndLocation = RallyLeg.startLocation.locationFrom(bearingFromStart, distanceFromStart);
        }

        super("Takeoff", "", takeoffEndLocation, EngineConfig.Takeoff);

        this.bearingFromStart = bearingFromStart;
        this.distanceFromStart = distanceFromStart;

        let takeoffTimeString = "2:00";
        if (match[1])
            takeoffTimeString = match[1].toString();

        this.estimatedTimeEnroute = new Time(takeoffTimeString);
        this.startFlightTiming = true;
    }

    fixName()
    {
        let result = "Takeoff";

        if (this.estimatedTimeEnroute.seconds() != 120)
            result += "|" + this.estimatedTimeEnroute.toString();
        if (this.distanceFromStart)
            result += "|" + this.bearingFromStart + "@" + this.distanceFromStart;

        return result;
    }
}

// CLIMB|<alt>|<time>  e.g. CLIMB|5000|7:00
let ClimbLegRE = new RegExp("^" + keywords.get("CLIMB") + "(?:\\|)(\\d{3,5})(?:\\|([0-9][0-9]?(?:\:[0-5][0-9])?))$", "i" + regExpOptionalUnicodeFlag);

class ClimbLeg extends RallyLegWithoutFix
{
    constructor(fixText)
    {
        let match = fixText.match(ClimbLegRE);

        let altitude = 5500;
        if (match && match[1])
            altitude = match[1].toString();

        super("Climb", altitude + "\"", undefined, EngineConfig.Climb);

        let timeToClimb = "8:00";
        if (match && match[2])
            timeToClimb = match[2].toString();

        this.altitude = altitude;
        this.climbTime = this.estimatedTimeEnroute = new Time(timeToClimb);
    }

    setLocationFromPrevious()
    {
        return true;
    }    

    fixName()
    {
        return "Climb|" + this.altitude + "|" + this.estimatedTimeEnroute.toString();
    }
}

// PATTERN|<time>  e.g. PATTERN|0:30
let PatternLegRE = new RegExp("^" + keywords.get("PATTERN") + "(?:\\|([0-9][0-9]?(?:\:[0-5][0-9])?))$", "i" + regExpOptionalUnicodeFlag);

class PatternLeg extends RallyLegWithoutFix
{
    constructor(fixText)
    {
        super("Pattern", "", undefined, EngineConfig.Pattern);

        let match = fixText.match(PatternLegRE);
        let patternTimeString = match[1].toString();
        this.estimatedTimeEnroute = new Time(patternTimeString);
    }

    setLocationFromPrevious()
    {
        return true;
    }    

    fixName()
    {
        return "Pattern|" + this.estimatedTimeEnroute.toString();
    }
}

// {LEFT,RIGHT}[|+<extra_turns>]  e.g. LEFT|2
let TurnLegRE = new RegExp("^(" + keywords.get("LEFT") + "|" + keywords.get("RIGHT") + ")(?:\\|\\+(\\d))?$", "i");

class TurnLeg extends RallyLegWithoutFix
{
    constructor(fixText, isRightTurn)
    {
        let match = fixText.match(TurnLegRE);

        let direction = "Left";
        if (match && match[1])
            direction = match[1].toString().toUpperCase() == keywords.get("LEFT") ? "Left" : "Right";

        let engineConfig = EngineConfig.Cruise;

        super(direction, "", new GeoLocation(-1, -1), engineConfig);

        this.extraTurns = (match && match[2]) ? parseInt(match[2]) : 0;
    }

    fixName()
    {
        let result = this.type;
        if (this.extraTurns)
            result += ("|+" + this.extraTurns);

        return result;
    }
}

let LegModifier = new RegExp("(360|3[0-5][0-9]|[0-2][0-9]{2}|[0-9]{1,2})@([0-9]{1,3})|([1-9][0-9]{1,2}|0)kts", "i");

class FlightPlan
{
    constructor(name, route)
    {
        this._name = name;
        this._route = route;
        this._firstLeg = undefined;
        this._lastLeg = undefined;
        this._legCount = 0;
        this._defaultWindDirection = 0;
        this._defaultWindSpeed = 0;
        this._trueAirspeedOverride = 0;
        this._timeToGate = undefined;
        this._estimatedTimeEnroute = undefined;
        this._totalFuel = 0;
        this._totalTime = new Time();
        this._gateTime = new Time();

        RallyLeg.reset();   //  Refactor to make this more OO
    }

    clear()
    {
    }

    appendLeg(leg)
    {
        if (!this._firstLeg)
            this._firstLeg = leg;
        if (this._lastLeg)
            this._lastLeg.setNext(leg);
        leg.setPrevious(this._lastLeg);
        leg.setNext(undefined);

        if (this._trueAirspeedOverride) {
            leg.setTrueAirspeed(this._trueAirspeedOverride);
            this.clearTrueAirspeedOverride(0);
        }

        if (this._defaultWindSpeed)
            leg.setWind(this._defaultWindDirection, this._defaultWindSpeed);

        this._lastLeg = leg;
        this._legCount++;
    }

    setDefaultWind(windDirection, windSpeed)
    {
        this._defaultWindDirection = windDirection;
        this._defaultWindSpeed = windSpeed;
    }

    clearTrueAirspeedOverride()
    {
        this._trueAirspeedOverride = 0;
    }

    setTrueAirspeedOverride(trueAirspeed)
    {
        this._trueAirspeedOverride = trueAirspeed;
    }

    isLegModifier(fix)
    {
        return LegModifier.test(fix);
    }

    processLegModifier(fix)
    {
        let match = fix.match(LegModifier);

        if (match) {
            if (match[1] && match[2]) {
                let windDirection = parseInt(match[1].toString()) % 360;
                let windSpeed = parseInt(match[2].toString());

                this.setDefaultWind(windDirection, windSpeed);
            } else if (match[3]) {
                let trueAirspeed = parseInt(match[3].toString());
                this.setTrueAirspeedOverride(trueAirspeed);
            }
        }
    }

    resolveWaypoint(waypointText)
    {
        if (this.isLegModifier(waypointText))
            this.processLegModifier(waypointText);
        else if (RallyLegWithoutFix.isRallyLegWithoutFix(waypointText)) {
            let rallyLeg = RallyLegWithoutFix.getLegNoFix(waypointText);
            if (rallyLeg)
                this.appendLeg(rallyLeg);
        } else {
            let fixName = RallyLeg.fixNeeded(waypointText);
            let isRallyWaypoint = false;

            if (fixName)
                isRallyWaypoint = true;
            else
                fixName = waypointText;

            let waypoint = userWaypoints.find(fixName);
            if (!waypoint)
                waypoint = faaWaypoints.find(fixName);
            if (!waypoint) {
                error("Couldn't find waypoint \"" + waypointText + "\"");
                return;
            }

            let location = new GeoLocation(waypoint.latitude, waypoint.longitude);

            if (isRallyWaypoint) {
                let rallyLeg = RallyLeg.getLegWithFix(waypointText, fixName, location);
                this.appendLeg(rallyLeg);
            } else
                this.appendLeg(new Leg(waypoint.name, location));
        }
    }

    parseRoute()
    {
        let waypointsToLookup = this._route.split(/ +/);
        let priorWaypoint = "";

        for (let waypointIndex = 0; waypointIndex < waypointsToLookup.length; waypointIndex++) {
            let currentWaypoint = waypointsToLookup[waypointIndex].toUpperCase();
            if (faaAirways.isAirway(currentWaypoint) && (waypointIndex + 1) < waypointsToLookup.length) {
                let exitWaypointFix = waypointsToLookup[waypointIndex + 1];
                let airwayFixes = faaAirways.resolveAirway(currentWaypoint, priorWaypoint, exitWaypointFix);

                // We skip the entry and exit fixes, because they are handled in the prior / next
                // iterations of the outer loop.
                for (let airwayFixIndex = 1; airwayFixIndex < airwayFixes.length - 1; airwayFixIndex++)
                    this.resolveWaypoint(airwayFixes[airwayFixIndex]);
            } else
                this.resolveWaypoint(currentWaypoint);
            
            priorWaypoint = currentWaypoint;
        }
    }

    calculate()
    {
        if (!this._firstLeg)
            return;

        let haveStartTiming = false;
        let haveStopTiming = false;
        for (let thisLeg = this._firstLeg; thisLeg; thisLeg = thisLeg.nextLeg()) {
            thisLeg.calculate();
            if (thisLeg.startFlightTiming) {
                if (haveStartTiming)
                    status("Have duplicate Start timing leg in row " + thisLeg.toString());
                haveStartTiming = true;
            }
            if (thisLeg.stopFlightTiming) {
                if (haveStopTiming)
                    status("Have duplicate Timing leg in row " + thisLeg.toString());
                haveStopTiming = true;
            }
        }

        if (!haveStartTiming)
            this._firstLeg.startFlightTiming = true;
        if (!haveStopTiming)
            this._lastLeg.stopFlightTiming = true;

        for (let thisLeg = this._firstLeg; thisLeg; thisLeg = thisLeg.nextLeg())
            thisLeg.updateForward();

        for (let thisLeg = this._lastLeg; thisLeg; thisLeg = thisLeg.previousLeg())
            thisLeg.updateBackward();

        for (let thisLeg = this._firstLeg; thisLeg; thisLeg = thisLeg.nextLeg()) {
            if (thisLeg.startFlightTiming)
                this._gateTime = thisLeg.estTimeRemaining;
        }

        this._totalTime = this._firstLeg.estTimeRemaining;
    }

    resolvedRoute()
    {
        let result = "";
        let lastWindDirection = 0;
        let lastWindSpeed = 0;

        let legIndex = 0;
        let currentLeg = this._firstLeg;
        
        for (; currentLeg; currentLeg = currentLeg.nextLeg(), legIndex++) {

            if (legIndex)
                result = result + " ";

            if (!currentLeg.isSameWind(lastWindDirection, lastWindSpeed)) {
                result = result + currentLeg.windToString() + " ";
                lastWindDirection = currentLeg.windDirection;
                lastWindSpeed = currentLeg.windSpeed;
            }

            if (!currentLeg.isStandardTrueAirspeed())
                result = result + currentLeg.trueAirspeedToString() + " ";

            result = result + currentLeg.toString();
        }

        return result;
    }

    name()
    {
        return this._name;
    }

    totalTime()
    {
        return this._totalTime;
    }
    
    gateTime()
    {
        return this._gateTime;
    }
    
    toString()
    {
        let result = "";
        let lastWindDirection = 0;
        let lastWindSpeed = 0;

        let legIndex = 0;
        let currentLeg = this._firstLeg;
        
        for (; currentLeg; currentLeg = currentLeg.nextLeg(), legIndex++) {

            if (legIndex)
                result = result + " ";

            if (!currentLeg.isSameWind(lastWindDirection, lastWindSpeed)) {
                result = result + currentLeg.windToString() + " ";
                lastWindDirection = currentLeg.windDirection;
                lastWindSpeed = currentLeg.windSpeed;
            }

            if (!currentLeg.isStandardTrueAirspeed())
                result = result + currentLeg.trueAirspeedToString() + " ";

            result = result + currentLeg.toString();
            result = result + " " + currentLeg.location + " " + currentLeg.distance.toFixed(2) + "nm " + currentLeg.estGS.toFixed(2) + "kts " + currentLeg.estimatedTimeEnroute + " ";
        }

        if (this._gateTime)
            result = result + " gate time " + this._gateTime;

        result = result + " total time " + this._firstLeg.estTimeRemaining;
        return result;
    }
}

EngineConfig.appendConfig("Taxi", 2, 0);
EngineConfig.appendConfig("Runup", 8, 0);
EngineConfig.appendConfig("Takeoff", 27, 105);
EngineConfig.appendConfig("Climb", 22, 125);
EngineConfig.appendConfig("Cruise", 15, 142);
EngineConfig.appendConfig("Pattern", 11, 95);

class ExpectedFlightPlan
{
    constructor(name, route, expectedRoute, expectedTotalTime, expectedGateTime)
    {
        this._name = name;
        this._route = route;
        this._expectedRoute = expectedRoute;
        this._expectedTotalTime = expectedTotalTime;
        this._expectedGateTime = expectedGateTime;
    }

    reset()
    {
        this._flightPlan = new FlightPlan(this._name, this._route);
    }

    resolveRoute()
    {
        this._flightPlan.parseRoute();
    }

    calculate()
    {
        this._flightPlan.calculate();
    }

    checkExpectations()
    {
        if (this._expectedRoute) {
            let computedRoute = this._flightPlan.resolvedRoute();
            if (this._expectedRoute != computedRoute)
                error("Flight plan " + this._flightPlan.name() + " route different than expected (\"" +
                      this._expectedRoute + "\"), got (\"" + computedRoute + "\")");
        }

        if (this._expectedTotalTime) {
            let computedTotalTime = this._flightPlan.totalTime();
            let deltaTime = Math.abs(Time.differenceBetween(this._expectedTotalTime, computedTotalTime).seconds());
            if (deltaTime > 5)
                error("Flight plan " + this._flightPlan.name() + " total time different than expected (" +
                      this._expectedTotalTime + "), got (" + computedTotalTime + ")");
        }

        if (this._expectedGateTime) {
            let computedGateTime = this._flightPlan.gateTime();
            let deltaTime = Math.abs(Time.differenceBetween(this._expectedGateTime, computedGateTime).seconds());
            if (deltaTime > 5)
                error("Flight plan " + this._flightPlan.name() + " gate time different than expected (" +
                      this._expectedGateTime + "), got (" + computedGateTime + ")");
        }
    }
}

function setupUserWaypoints()
{
    userWaypoints.clear();   
    userWaypoints.update("Oilcamp", "Oil storage in the middle of no where", "36.68471", "-120.50277");
    userWaypoints.update("I5.Westshields", "I5 & West Shields", "36.77774", "-120.72426");
    userWaypoints.update("I5.165", "Intersection of I5 and CA165", "36.93022", "-120.84068");
    userWaypoints.update("I5.VOLTA", "I5 & Volta Road", "37.01419", "-120.92878");
    userWaypoints.update("PT.ALPHA", "Intersection of I5 and CA152", "37.05665", "-120.96990");
    userWaypoints.update("Jellysferry", "Jelly's Ferry bridge across Sacramento River", "N40 19.037", "W122 11.359");
    userWaypoints.update("Howie", "RDD Timing Point", "N40 21.893", "W122 13.042");
    userWaypoints.update("Hale", "2014 Leg 1 Timing", "N39 33.621", "W119 14.438");
    userWaypoints.update("Winnie", "2014 Leg 2 Timing", "N40 50.499", "W114 12.595");
    userWaypoints.update("WindRiver", "2014 Leg 3 Timing", "N42 43.733", "W108 38.800");
    userWaypoints.update("Buff", "2014 Leg 4 Timing", "N43 59.455", "W103 16.171");
    userWaypoints.update("Omega", "2014 Leg 5 Timing", "N44 53.388", "W95 38.935");
    userWaypoints.update("Paul", "2014 Leg 6 Timing", "N43 22.027", "W89 37.111");
    userWaypoints.update("MicrowaveSt", "2014 Microwave Station", "N40 24.89", "W117 12.37");
    userWaypoints.update("RanchTower", "2014 Ranch/Tower", "N41 6.16", "W115 5.43");
    userWaypoints.update("FremontIsland", "2014 Fremont Island", "N41 10.49", "W112 20.64");
    userWaypoints.update("Tremonton", "2014 Tremonton", "N41 42.86", "W112 11.05");
    userWaypoints.update("RandomPoint", "2014 Random Point", "N42", "W111 03");
    userWaypoints.update("Farson", "2014 Farson", "N42 6.40", "W109 26.95");
    userWaypoints.update("Midwest", "2014 Midwest", "N43 24.49", "W109 16.68");
    userWaypoints.update("Bill", "2014 Bill", "N43 13.96", "W105 15.60");
    userWaypoints.update("MMNHS", "2014 MMNHS", "N43 52.67", "W101 57.65");
    userWaypoints.update("Tracks", "2014 Tracks", "N44 21", "W100 22");
    userWaypoints.update("Towers", "2014 Towers", "N43 34.25", "W92 25.64");
    userWaypoints.update("IsletonBridge", "Isleton Bridge", "N38 10.32", "W121 35.62");
    userWaypoints.update("Mystery15", "2015 Mystery", "N38 46.22", "W122 34.25");
    userWaypoints.update("Paskenta", "Paskenta Town", "N39 53.13", "W122 32.36");
    userWaypoints.update("Bonanza", "Bonanza Town", "N42 12.15", "W121 24.53");
    userWaypoints.update("Silverlake", "Silverlake", "N43 07.41", "W121 03.74");
    userWaypoints.update("Millican", "Bend Timing Start", "N43 52.75", "W120 55.13");
    userWaypoints.update("Goering", "Bend Timing", "N44 05.751", "W120 56.834");
    userWaypoints.update("Constantia2", "Our Constantia Wpt", "N39 56.068", "W120 0.831");
    userWaypoints.update("Hallelujah2", "Reno Timing", "N39 46.509", "W120 2.336");
    userWaypoints.update("Redding.Pond", "Pond 6nm North of KRDD", "N40 36", "W122 17");
    userWaypoints.update("Thunderhill", "Thunder Hill Race Track", "N39 32.36", "W122 19.83");
    userWaypoints.update("CascadeHighway", "Cascade Wonderland Highway", "N40 46.63", "W122 19.12");
    userWaypoints.update("Eagleville", "Eagleville closed airport", "N41 18.73", "W120 3.00");
    userWaypoints.update("DuckLakePass", "Saddle near Duck Lake", "N41 3.00", "W120 3.00");
}

function createTestRoutes()
{
    let flightPlans = [
        { name: "Rally Practice 1", route: "C83|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|5:00 CA67 0CN1 28CA 126kts I5.165 PT.ALPHA KLSN|Timing Pattern|0:45 Taxi|2:00" },
        { name: "Rally Practice 2", route: "C83|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|5:00 ECA 343@4 5CL3 67CA 314@12 126kts I5.165 126kts PT.ALPHA|Timing  126kts KLSN Pattern|0:45 Taxi|2:00" },
        { name: "Rally Practice 3", route: "KTCY|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|3500|5:00 O27 67CA OILCAMP I5.WESTSHIELDS I5.165 I5.VOLTA PT.ALPHA|Timing KLSN Pattern|0:45 Taxi|2:00" },
        { name: "Rally Practice 4", route: "C83|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|5:00 62@3 9CL0 342@9 CL84 28CA 315@10 126kts I5.165 126kts PT.ALPHA|Timing  126kts KLSN Pattern|0:45 Taxi|2:00" },
        { name: "Rally Practice 5", route: "C83|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|7:00 O27 9CL0 CL01 I5.165 PT.ALPHA KLSN Pattern|0:45 Taxi|2:00" },
        { name: "2014 HWD Rally Leg 1", route: "KHWD|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|4:35 VPDUB Climb|8500|6:25 2CL9 Left|+1 09CL Left|+1 O02 Left|+1 77NV Left 126kts HALE|Timing KSPZ Pattern|0:45 Taxi|2:00"},
        { name: "2014 HWD Rally Leg 2", route: "KSPZ|Start Taxi|4:00 Runup|0:30 Taxi|1:00 Takeoff Climb|7500|9:00 NV30 Left MicrowaveSt Left DOBYS Left RanchTower Left 126kts Winnie|Timing KENV Pattern|0:45 Taxi|2:00"},
        { name: "2014 HWD Rally Leg 3", route: "KENV|Start Taxi|5:00 Runup|0:30 Taxi|1:00 Takeoff Climb|7500|8:30 FremontIsland Left|+1 Tremonton Left|+1 RandomPoint Left|+1 Farson Left 126kts WindRiver|Timing KLND Pattern|0:45 Taxi|2:00"},
        { name: "2014 HWD Rally Leg 4", route: "KLND|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|7500|10:00 Midwest Left Bill Right|+1 WY09 Left KCUT Left 126kts BUFF|Timing KRAP Pattern|0:45 Taxi|2:00"},
        { name: "2014 HWD Rally Leg 5", route: "KRAP|Start Taxi|5:00 Runup|0:30 Taxi|3:00 Takeoff Climb|7500|9:00 MMNHS Left|+1 Tracks Left|+1 8D7 Left 5H3 Left 126kts Omega KMVE Pattern|0:45 Taxi|2:00"},
        { name: "2014 HWD Rally Leg 6", route: "KMVE|Start Taxi|6:00 Runup|0:30 Taxi|2:00 Takeoff Climb|5500|6:00 1D6 Left 68Y Right|+1 Towers Left KCHU Left 126kts Paul|Timing KMSN Pattern|0:45 Taxi|2:00"},
        { name: "2015 HWD Rally Leg 1", route: "KHWD|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|4:35 VPDUB Climb|5500|3:25 IsletonBridge Mystery15 Left|+1 71CL Left|+1 Paskenta Left|+1 O37 Left|+1 JELLYSFERRY HOWIE|Timing KRDD Pattern|0:45 Taxi|2:00"},
        { name: "2015 HWD Rally Leg 2", route: "KRDD|Start Taxi|6:00 Runup|0:30 Taxi|2:00 Takeoff Climb|8500|1:20 REDDING.POND Climb|8500|7:40 A26 O81 Left 340@6 Bonanza Left|+1 Silverlake Left|+1 Millican 126kts Goering|Timing KBDN pattern|30 taxi|2:00"},
        { name: "2016 HWD Rally Leg 1", route: "KHWD|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|5500|4:35 VPDUB Climb|5500|3:25 65CN 3CN7 57CN Left|+1 2CL1 Left|+1 O37 Left|+1 JELLYSFERRY HOWIE|Timing KRDD Pattern|0:45 Taxi|2:00"},
        { name: "2016 HWD Rally Leg 2", route: "KRDD|Start Taxi|6:00 Runup|0:30 Taxi|2:00 Takeoff Climb|8500|1:20 REDDING.POND Climb|8500|7:40 O89 KAAT Left 1Q2 KSVE Left H37 Left CONSTANTIA2 HALLELUJAH2|Timing KRTS pattern|30 taxi|2:00"},
        { name: "2017 HWD Rally Leg 1", route: "KHWD|Start Taxi|6:00 Runup|0:30 Taxi|1:00 Takeoff Climb|4500|8:00 10@6 9CL9 63CL THUNDERHILL 60@10 90CL 0O4 350@10 126kts JELLYSFERRY 126kts HOWIE|Timing KRDD Pattern|0:45 Taxi|4:00" },
        { name: "2017 HWD Rally Leg 2", route: "KRDD|Start Taxi|6:00 Runup|0:30 Taxi|2:00 Takeoff Climb|8500|6:40 CASCADEHIGHWAY Climb|8500|8:20 KLKV 210@2 EAGLEVILLE DUCKLAKEPASS 210@5 O39 209@8 AHC 148@6 126kts CONSTANTIA2 126kts Hallelujah2|Timing KRTS Pattern|0:30 Taxi|2:00" },
        { name: "San Jose to San Diego", route: "KSJC LICKE SNS V25 REDIN KMYF" },
        { name: "San Diego to San Jose", route: "KMYF JOPDO CARIF V23 LAX V299 VTU V25 PRB SARDO RANCK V485 LICKE KSJC" },
        { name: "San Jose to Renton", route: "KSJC SUNOL RBL V23 SEA KRNT" },
        { name: "Renton to Willows", route: "KRNT SEA V495 BTG V23 RBL KWLW" },
        { name: "SJC to SEA #1", route: "ksjc sunol 135kts 300@12 rdd 0kts pdx ksea" },
        { name: "SJC to SEA #2", route: "KSJC SJC V334 SAC V23 BTG V495 SEA KSEA" },
        { name: "SJC to SEA #3", route: "KSJC SJC V334 SAC V23 FJS V495 SEA KSEA" },
        { name: "Roseburg to San Jose", route: "KRBG RBG KOLER V495 FJS V23 RBL SUNOL KSJC" },
        { name: "SAN to DEN", route: "KSAN HAILE V514 LYNSY V8 JNC V134 BINBE KDEN" },
        { name: "Denver to Minneapolis", route: "KDEN DVV V8 AKO V80 FSD V148 RWF V412 FCM KMSP" },
        { name: "Reno to Chicago", route: "KRNO FMG V6 LLC V32 CEVAR V200 STACO V32 FBR V6 MBW V100 RFD V171 SIMMN V172 DPA KORD" },
        { name: "Denver to Oklahoma City", route: "KDEN AVNEW V366 HGO V263 LAA V304 LBL V507 MMB V17 IRW KOKC" },
        { name: "Stockton to Hawthorne 1", route: "KSCK MOD V113 PXN V107 AVE V137 GMN V23 LAX KHHR" },
        { name: "Stockton to Hawthorne 2", route: "ksck patyy v113 rom v485  exert v25 lax khhr" },
        { name: "Long View to Corpus Christi", route: "KGGG PIPES V289 LFK V13 WORRY KCRP" },
        { name: "Austin Exec to Beaumont 1", route: "KEDC HOOKK V306 TNV V574 IAH V222 SHINA KBPT" },
        { name: "Austin Exec to Beaumont 2", route: "KEDC HOOKK V306 DAS KBPT" },
        { name: "Austin Exec to Beaumont 3", route: "KEDC HOOKK V306 TNV DAS KBPT" },
        { name: "Savannah to Daytona Beach", route: "KSAV KELER V437 COKES KDAB" },
        { name: "Philly to Hartford 1", route: "KPNE ARD V276 DIXIE V16 JFK V229 SNIVL KHFD" },
        { name: "Philly to Hartford 2", route: "KPNE ARD V276 MANTA V139 RICED MAD KHFD" },
        { name: "Philly to Hartford 3", route: "KPNE DITCH V312 DRIFT V139 SARDI V308 ORW KHFD" },
        { name: "Philly to Hartford 4", route: "KPNE ZIDET V479 ARD V433 LGA V99 YALER KHFD" },
        { name: "West Georgie Regional to Frankfort, KY 1", route: "kctj noone nello v5 gqo kfft" },
        { name: "West Georgie Regional to Frankfort, KY 2", route: "kctj felto v243 gqo v333 hyk v512 clegg kfft" },
        { name: "West Georgie Regional to Frankfort, KY 3", route: "kctj rmg v333 hyk v512 clegg kfft" },
        { name: "West Georgie Regional to Frankfort, KY 4", route: "kctj rmg v333 hch v51 lvt v493 hyk kfft" },
        { name: "Raleigh / Durham to Baltimore Martin State 1", route: "KRDU aimhi  KMTN" },
        { name: "Raleigh / Durham to Baltimore Martin State 2", route: "KRDU rdu v155 lvl ric ott KMTN" },
        { name: "Raleigh / Durham to Baltimore Martin State 3", route: "KRDU rdu v155 mange v157 colin v33 ott v433 paleo KMTN" },
        { name: "Raleigh / Durham to Baltimore Martin State 4", route: "KRDU rdu v155 LVL V157 RIC V16 PXT V93 GRACO KMTN" },
        { name: "Roswell, NM to Longview, TX", route: "KROW Climb|9500|6:00 070@14 HOB V68 PIZON 050@16 V16 ABI V62 JEN V94 OTTIF KGGG" },
        { name: "Lubbock to Longview 1", route: "KLBB JEN CQY KGGG" },
        { name: "Lubbock to Longview 2", route: "KLBB ralls v102 gth v278 byp v114 awlar KGGG" },
        { name: "Lubbock to Longview 3", route: "KLBB hydro v62 jen v94 ottif KGGG" },
        { name: "Stockton, CA to North Las Vegas 1", route: "KSCK ECA V244 OAL V105 LUCKY KVGT" },
        { name: "Stockton, CA to North Las Vegas 2", route: "KSCK ehf v197 pmd v12 basal v394 oasys KVGT" },
        { name: "Bakersfield to Santa Rosa 1", route: "KBFL EHF V248 AVE OAK SAU KSTS" },
        { name: "Bakersfield to Santa Rosa 2", route: "KBFL EHF V248 AVE PXN V301 SUNOL KSTS" },
        { name: "Bakersfield to Santa Rosa 3", route: "KBFL SCRAP V248 AVE V107 OAK V195 CROIT V108 STS KSTS" },
        { name: "Bakersfield to Santa Rosa 4", route: "KBFL EHF V23 SAC V494 SNUPY KSTS" },
        { name: "Bakersfield to Santa Rosa 5", route: "KBFL EHF V248 AVE V137 SNS V230 SHOEY V27 PYE KSTS" },
        { name: "Bakersfield to Santa Rosa 6", route: "KBFL EHF V23 CZQ MOD OAKEY KSTS" },
        { name: "Bakersfield to Santa Rosa 7", route: "KBFL EHF V23 LIN CCR CROIT SGD KSTS" },
        { name: "Bakersfield to Santa Rosa 8", route: "KBFL EHF V248 AVE V107 PXN SGD KSTS" },
        { name: "Great Falls to Boeing King Field 1", route: "KGTF GTF V120 MLP V2 GEG V120 NORMY KBFI" },
        { name: "Great Falls to Boeing King Field 2", route: "KGTF GTF V120 MLP J70 SEA KBFI" },
        { name: "Great Falls to Boeing King Field 3", route: "KGTF GTF V187 ELN V2 SEA KBFI" },
        { name: "Great Falls to Boeing King Field 4", route: "KGTF KEETA Q144 ZIRAN KBFI" },
        { name: "Boise to Centenial 1", route: "KBOI BOI V4 LAR RAMMS NIWOT KAPA" },
        { name: "Boise to Centenial 2", route: "KBOI ROARR PIH J20 OCS J154 AVVVS KAPA" },
        { name: "Boise to Centenial 3", route: "KBOI CANEK V4 OCS V328 DOBEE V356 ELORE KAPA" },
        { name: "Boise to Centenial 4", route: "KBOI BOI J54 PIH J20 OCS J52 FQF KAPA" },
        { name: "St. Louis to Birmingham 1", route: "KSTL SPUDZ V125 DUEAS V540 CNG V67 SYI V321 BOAZE V115 COLIG KBHM" },
        { name: "St. Louis to Birmingham 2", route: "KSTL ODUJY FAM CGI JKS MSL VUZ KBHM" },
        { name: "Chattanoga to Augusta 1", route: "KCHA ODF AHN V417 MSTRS KAGS" },
        { name: "Chattanoga to Augusta 2", route: "KCHA GQO NELLO CCATT T292 JACET KAGS" },
        { name: "Chattanoga to Augusta 3", route: "KCHA GQO ATL ANNAN KAGS" },
        { name: "Chattanoga to Augusta 4", route: "KCHA HOCHE V5 AHN V417 IRQ KAGS" },
        { name: "Concord, NC to Richmond, VA 1", route: "KJQF SBV V20 RIC KRIC" },
        { name: "Concord, NC to Richmond, VA 2", route: "KJQF GIZMO V143 GSO V266 SBV V20 RIC KRIC" },
        { name: "Concord, NC to Richmond, VA 3", route: "KJQF GSO SBV V20 RIC KRIC" },
        { name: "Concord, NC to Richmond, VA 4", route: "KJQF GIZMO V454 LVL V157 RIC KRIC" },
        { name: "Buffalo, NY to Portland, ME 1", route: "KBUF BUF V2 UCA V496 NEETS V39 LIMER KPWM" },
        { name: "Buffalo, NY to Portland, ME 2", route: "KBUF HANKK AUDIL PUPPY GFL KPWM" },
        { name: "Buffalo, NY to Portland, ME 3", route: "KBUF BUF V14 GGT V428 UCA V496 ENE KPWM" },
        { name: "Buffalo, NY to Portland, ME 4", route: "KBUF JOSSY Q935 PONCT ARIME CDOGG ENE KPWM" },
        { name: "Moline, IL to Battle Creek, MI 1", route: "KMLI GENSO V8 NOMES V156 AZO KBTL" },
        { name: "Moline, IL to Battle Creek, MI 2", route: "KMLI OBK J547 PMM KBTL" },
        { name: "Moline, IL to Battle Creek, MI 3", route: "KMLI PLL OBK ELX KBTL" },
        { name: "Moline, IL to Battle Creek, MI 4", route: "KMLI PLL RFD V100 ELX AZO KBTL" },
        { name: "Green Bay, WI to Indianapolis, IN 1", route: "KGRB OKK V305 WELDO KIND" },
        { name: "Green Bay, WI to Indianapolis, IN 2", route: "KGRB GRB J101 BAE J89 OBK EON V24 VHP KIND" },
        { name: "Green Bay, WI to Indianapolis, IN 3", route: "KGRB WAFLE V7 BVT V399 ADVAY KIND" },
        { name: "Green Bay, WI to Indianapolis, IN 4", route: "KGRB WAFLE V7 BVT VHP KIND" },
        { name: "Anoka County, MN to Springfield, IL 1", route: "KANE KANAC V97 ODI V129 GROWL KSPI" },
        { name: "Anoka County, MN to Springfield, IL 2", route: "KANE GEP PRIOR FGT V411 RST V67 ULAXY KSPI" },
        { name: "Anoka County, MN to Springfield, IL 3", route: "KANE GEP PRIOR FGT V411 RST V503 CID V67 ULAXY KSPI" },
        { name: "Anoka County, MN to Springfield, IL 4", route: "KANE WAGNR V510 ODI V129 SPI KSPI" },
        { name: "Little Rock to Souix City 1", route: "KLIT MCI J41 OVR KSUX" },
        { name: "Little Rock to Souix City 2", route: "KLIT ROLAN V534 HAAWK V71 SGF V159 SUX KSUX" },
        { name: "Little Rock to Souix City 3 ", route: "KLIT ROLAN V534 SCRAN V527 RZC V13 BUM V71 PANNY KSUX" },
        { name: "Little Rock to Souix City 4", route: "KLIT ROLAN V534 SCRAN V527 RZC V13 EOS V307 CNU V131 TOP PWE SUX KSUX" }
    ];

    setupUserWaypoints();

    print("let expectedFlightPlans = [");

    for (let i = 0; i < flightPlans.length; ++i) {
        let flightPlanName = flightPlans[i].name;
        let flightPlanRoute = flightPlans[i].route;
        let flightPlan = new FlightPlan(flightPlanName, flightPlanRoute);
        flightPlan.parseRoute();
        flightPlan.calculate();
        let totalTime = flightPlan.totalTime();
        let gateTime = flightPlan.gateTime();
        let expectedGateTimeString;
        if (Math.abs(Time.differenceBetween(totalTime, gateTime).seconds()) == 0)
            expectedGateTimeString = "undefined";
        else
            expectedGateTimeString = "new Time(\"" + gateTime + "\")";

            print("    new ExpectedFlightPlan(\"" + flightPlanName + "\", \"" + flightPlanRoute + "\", \"" +
                  flightPlan.resolvedRoute() + "\", new Time(\"" + totalTime + "\"), " + expectedGateTimeString + "),");
    }

    print("];");

    print("# Created " + flightPlans.length + " flight plans");
}

// createTestRoutes();