Back to Index Page generated: May 8, 2024, 6:16:03 AM

Expansion Route Planner

Content

Warnings

  1. http://wiki.alioth.net/index.php/Route%20Planner -> 404 Not Found
  2. Low hanging fuit: Information URL exists...

Manifest

from Expansion Manager's OXP list from Expansion Manifest
Description Plot a route through multiple systems Plot a route through multiple systems
Identifier oolite.oxp.Alnivel.RoutePlanner oolite.oxp.Alnivel.RoutePlanner
Title Route Planner Route Planner
Category Mechanics Mechanics
Author Alnivel Alnivel
Version 0.3 0.3
Tags
Required Oolite Version
Maximum Oolite Version
Required Expansions
Optional Expansions
Conflict Expansions
Information URL https://wiki.alioth.net/index.php?title=Route_Planner_OXP n/a
Download URL https://www.dl.dropboxusercontent.com/s/sc0ncl2bil8fcfe/RoutePlanner.0.3.oxz n/a
License CC BY-NC-SA 4 CC BY-NC-SA 4
File Size n/a
Upload date n/a

Documentation

Equipment

This expansion declares no equipment. This may be related to warnings.

Ships

This expansion declares no ships. This may be related to warnings.

Models

This expansion declares no models. This may be related to warnings.

Scripts

Path
Scripts/route-planner-interface.js
"use strict";
this.name = "RoutePlanner_Interface";
this.author = "Alnivel";
this.copyright = "2022 Alnivel";
this.version = "0.2";
this.licence = "CC BY-NC-SA 4.0";

this.$ILib = null;

this.$screens = {};

this.$inverseMapTo = function (map) {
    return Object.keys(map).reduce(function (invMap, key) {
        return invMap[map[key]] = key;
    }.bind(this), {})
}

this.$findKeyByValue = function (dictionary, value) {
    var keys = Object.keys(dictionary);
    for (var i = 0; i < keys.length; i++) {
        var key = keys[i];
        if (dictionary[key] === value)
            return key;
    }
    return undefined;
}

this.$colorToColorSpecifierMap = {
    "Green": "greenColor",
    "Yellow": "yellowColor",
    "Red": "redColor",
    "Blue": "blueColor",
    "Cyan": "cyanColor",
}
this.$colorSpecifierToColorMap = this.$inverseMapTo(this.$colorToColorSpecifierMap)

this.$markerShapes = {
    "Cross": "MARKER_X",
    "Plus": "MARKER_PLUS",
    "Square": "MARKER_SQUARE",
    "Diamond": "MARKER_DIAMOND"
}

this.$configureInterface = function () {
    let interfaceScript = this;
    let mainScript = worldScripts["RoutePlanner"];
    let ILib = this.$ILib = worldScripts["RoutePlanner_InterfaceLib"];

    mainScreen: {
        const mainScreen = this.$screens.main = new ILib.PagedScreenController({
            title: "Route Planning Interface",
            screenID: "RoutePlanner_Main",
            allowInterrupt: true,
            exitScreen: "GUI_SCREEN_INTERFACES"
        });

        mainScreen.addToContent(new ILib.LabelLine("General"));
        mainScreen.addToContent(
            new ILib.CycleLine(
                expandDescription("[RoutePlanner_interface_main_wp-order-mode]"),
                mainScript.$waypointOrderModes,
                mainScript.$currentWaypointOrderMode)
                .onSelected(function (selectedIndex, selectedValue) {
                    mainScript.$currentWaypointOrderMode = selectedValue;
                })
        );

        mainScreen.addToContent(
            new ILib.ButtonLine(expandDescription("[RoutePlanner_interface_main_edit-route]"))
                .onUpdate(function () {
                    return {
                        unselectable: mainScript.$currentWaypointOrderMode === "None"
                    };
                })
                .onSelected(function () {
                    this.$screens.editRouteInstructions.runScreen(mainScript.$currentWaypointOrderMode)
                    return true; // returning true prevent current screen redrawing
                }.bind(this))
        );

        mainScreen.addToContent(
            new ILib.ButtonLine(expandDescription("[RoutePlanner_interface_main_route-statistics]"))
                .onUpdate(function () {
                    return {
                        unselectable: mainScript.$currentWaypointOrderMode === "None"
                    };
                })
                .onSelected(function () {
                    this.$screens.routeStatisticsScreen.runScreen()
                    return true;
                }.bind(this))
        );

        const wpTypes = mainScript.$wpTypes;
        const wpTypesList = Object.keys(wpTypes);
        for (let i = 0; i < wpTypesList.length; i++)
        {
            let typeKey = wpTypesList[i];
            let type = wpTypes[typeKey];

            let labelText = expandDescription(
                "[RoutePlanner_interface_main_route-through-toggler]",
                {waypointType: expandDescription(type.nameKey)});
            mainScreen.addToContent(
                new ILib.ToggleLine(labelText, type.isUsed)
                    .onSelected(function (newState) {
                        type.isUsed = newState;
                    })
            )   
        }        

        mainScreen.addToContent(
            new ILib.ButtonLine(expandDescription("[RoutePlanner_interface_main_appearance-settings]"))
                .onSelected(function () {
                    this.$screens.appearanceSettings.runScreen()
                    return true; // returning true prevent current screen redrawing
                }.bind(this))
        );
    }

    const returnToMain = this.$screens.main.returnToScreen.bind(this.$screens.main);

    appearanceSettingsScreen: {
        const appearanceSettingsScreen = this.$screens.appearanceSettings = new ILib.PagedScreenController({
            title: "Route Planning Interface: Appearance Settings",
            screenID: "RoutePlanner_Main_AppearanceSettings",
            allowInterrupt: true,
            exitScreen: "GUI_SCREEN_INTERFACES"
        }, returnToMain);

        const colors = this.$colorToColorSpecifierMap;
        const appearance = mainScript.$appearance;

        appearanceSettingsScreen.addToContent(
            new ILib.CycleLine(
                expandDescription("[RoutePlanner_interface_appearance_user-wp-marker-shape]"),
                this.$markerShapes,
                this.$findKeyByValue(this.$markerShapes, appearance.userWaypointShape))
                .onSelected(function (selectedShapeIndex, selectedShape) {
                    appearance.userWaypointShape = selectedShape;
                })
        );

        appearanceSettingsScreen.addToContent(
            new ILib.CycleLine(
                expandDescription("[RoutePlanner_interface_appearance_user-wp-marker-color]"),
                colors,
                this.$findKeyByValue(colors, appearance.userWaypointColor))
                .onSelected(function (selectedColorIndex, selectedColor) {
                    appearance.userWaypointColor = selectedColor;
                })
        );

        appearanceSettingsScreen.addToContent(
            new ILib.CycleLine(
                expandDescription("[RoutePlanner_interface_appearance_user-route-color-jumps]"),
                colors,
                this.$findKeyByValue(colors, appearance.routeLineColorByJumps))
                .onSelected(function (selectedColorIndex, selectedColor) {
                    appearance.routeLineColorByJumps = selectedColor;
                })
        );

        appearanceSettingsScreen.addToContent(
            new ILib.CycleLine(
                expandDescription("[RoutePlanner_interface_appearance_user-route-color-time]"),
                colors,
                this.$findKeyByValue(colors, appearance.routeLineColorByTime))
                .onSelected(function (selectedColorIndex, selectedColor) {
                    appearance.routeLineColorByTime = selectedColor;
                })
        );
    }

    function formatWaypoints(waypoints, highlightedSystemId, waypointOrderMode) {
        function getSystemSubstitution(id) {
            if (id < 10) return "%J00" + id;
            if (id < 100) return "%J0" + id;
            else return "%J" + id;
        }

        const wpSubtitutions = waypoints.map(function (wp) {
            if (highlightedSystemId === wp)
                return " \[" + getSystemSubstitution(wp) + "\] ";
            else
                return getSystemSubstitution(wp);
        });

        const wpSeparator = waypointOrderMode === "Auto" ? ", " : " → ";

        return wpSubtitutions.length? expandDescription(wpSubtitutions.join(wpSeparator)) : "None";
    }

    routeEditingInstruction: {
        const instructionSP = {
            title: "Route Planning Interface: Route editing instruction",
            screenID: "RoutePlanner_EditRoute_Instructions",
            allowInterrupt: false,
            exitScreen: "GUI_SCREEN_SHORT_RANGE_CHART",
        }

        instructionSP.choices = {
            "00_SPACER" : { text: "" },
            "01_RETURN": {
                text: ILib.$padTextOnSide(
                    expandDescription("[RoutePlanner_interface_route-editing-instructions_return]"), 
                    "right", 32),
                color: "cyanColor"
            },
            "02_ADD_START": {
                text: ILib.$padTextOnSide(
                    expandDescription("[RoutePlanner_interface_route-editing-instructions_add-start]"), 
                    "right", 32),
                color: "cyanColor"
            },
            "03_CONTINUE": {
                text: ILib.$padTextOnSide(
                    expandDescription("[RoutePlanner_interface_route-editing-instructions_start-edit-on-chart]"), 
                    "right", 32),
                color: "cyanColor"
            },
            "04_SPACER": { text: "" },
            "05_SPACER": { text: "" },
        }

        this.$screens.editRouteInstructions = {
            screenParameters: instructionSP,
            returnToMain: returnToMain,
            userWaypoints: mainScript.$userWaypoints,

            runScreen: function (waypointOrderMode) {
                let targetSystem = player.ship.targetSystem;
                let firstUserWaypoint = this.userWaypoints.length === 0? -1 : this.userWaypoints[0];

                const addToStartChoise = this.screenParameters.choices["02_ADD_START"];
                if(system.ID === targetSystem || firstUserWaypoint === targetSystem)
                {
                    addToStartChoise.unselectable = true;
                    addToStartChoise.color = "darkGrayColor";
                }
                else
                {
                    addToStartChoise.unselectable = false;
                    addToStartChoise.color = "cyanColor";
                }

                let callback = function (choice) {
                    if (choice === "01_RETURN") this.returnToMain();
                    else if (choice === "02_ADD_START") 
                    {                                            
                        this.userWaypoints.unshift(targetSystem);
                        interfaceScript.$screens.editRouteInstructions.runScreen(waypointOrderMode);
                    }
                    else mainScript.$routeEditingEnabled = true;
                };
                
                

                instructionSP.message = expandMissionText("RoutePlanner_interface_route-editing-instructions", {
                    waypointOrderMode: waypointOrderMode,
                    userWaypoints: formatWaypoints(this.userWaypoints, targetSystem, waypointOrderMode)
                });

                mission.runScreen(this.screenParameters, callback, this);
            },


        }
    }

    routeEditingActions: {
        const routeEditActionSP = {
            title: "Route Planning Interface: <systemname>",
            screenID: "RoutePlanner_EditRoute_SystemAction",
            allowInterrupt: false,
            exitScreen: "GUI_SCREEN_SHORT_RANGE_CHART",
            choices: {}
        }

        this.$screens.editRouteSystemAction = {
            screenParameters: routeEditActionSP,
            returnToMain: returnToMain,
            userWaypoints: mainScript.$userWaypoints,

            selectedSystemId: null,
            selectedSystemIndex: null,

            systemId: null,
            systemIndex: null,

            runScreen: function (systemId, waypointOrderMode) {
                const systemIndex = this.userWaypoints.indexOf(systemId);
                const selectedSystemId = this.selectedSystemId;

                this.systemId = systemId;
                this.systemIndex = systemIndex;


                const systemInWaypoints = systemIndex !== -1;
                const systemName = System.systemNameForID(systemId);
                let selectedSystemName = undefined;
                let systemIsSelected = false;

                if (selectedSystemId === null)
                    this.selectedSystemIndex = null;
                else {
                    this.selectedSystemIndex = this.userWaypoints.indexOf(selectedSystemId);
                    selectedSystemName = System.systemNameForID(selectedSystemId);
                    systemIsSelected = systemId === selectedSystemId;
                }

                this.screenParameters.message = this.getText(systemId, waypointOrderMode);
                this.screenParameters.choices = this.getChoices(systemInWaypoints, systemIsSelected, selectedSystemName);

                this.screenParameters.title = "Route Planning Interface: " + systemName;

                mission.runScreen(this.screenParameters, this.screenCallback, this);
            },

            getText: function (highlightedSystemId, waypointOrderMode) {
                

                return expandMissionText("RoutePlanner_interface_route-editing-actions", {
                    waypointOrderMode: waypointOrderMode,
                    userWaypoints: formatWaypoints(this.userWaypoints, highlightedSystemId, waypointOrderMode)
                });
            },

            getChoices: function (systemInWaypoints, systemIsSelected, selectedSystemName) {
                const choices = {};

                if (systemInWaypoints) {
                    if (systemIsSelected)
                        choices["01_DESELECT"] = "Deselect";
                    else
                        choices["01_SELECT"] = "Select";

                    choices["02_REMOVE"] = "Remove";
                    if (selectedSystemName) {
                        choices["03_SWAP"] = "Swap with " + selectedSystemName;
                    }
                    // choices["03_SPACER"] = "";
                }
                else {
                    if (selectedSystemName) {
                        choices["01_ADD_BEFORE"] = "Add before " + selectedSystemName;
                        choices["02_ADD_AFTER"] = "Add after " + selectedSystemName;
                    }
                    else {
                        choices["01_SPACER"] = "";
                        choices["02_SPACER"] = "";
                    }

                    choices["03_ADD_END"] = "Add to the end";
                }

                choices["04_SPACER"] = ""
                choices["05_BACK_TO_CHART"] = "Back to chart";
                choices["06_STOP_EDIT"] = "Stop route editing";

                return choices;
            },

            screenCallback: function (choice) {
                if (choice === "05_BACK_TO_CHART") return;
                else if (choice === "06_STOP_EDIT") {
                    mainScript.$routeEditingEnabled = false;
                    this.returnToMain();
                    return;
                }

                if (this.systemIndex !== -1) {
                    if (choice === "01_SELECT")
                        this.selectedSystemId = this.systemId;
                    else if (choice === "01_DESELECT")
                        this.selectedSystemId = null;
                    else if (choice === "02_REMOVE") {
                        this.userWaypoints.splice(this.systemIndex, 1);
                        if (this.selectedSystemIndex === this.systemIndex)
                            this.selectedSystemId = null
                    }

                    else if (choice === "03_SWAP") {
                        let temp = this.userWaypoints[this.systemIndex];
                        this.userWaypoints[this.systemIndex] = this.userWaypoints[this.selectedSystemIndex];
                        this.userWaypoints[this.selectedSystemIndex] = temp;

                        this.selectedSystemIndex = this.systemIndex;
                    }
                }
                else {
                    if (choice == "01_ADD_BEFORE")
                        this.userWaypoints.splice(this.selectedSystemIndex, 0, this.systemId)
                    else if (choice == "02_ADD_AFTER")
                        this.userWaypoints.splice(this.selectedSystemIndex + 1, 0, this.systemId)
                    else if (choice == "03_ADD_END")
                        this.userWaypoints.push(this.systemId)
                }

            }
        }
    }

    routeStatistics: {
        const routeStatisticsScreen = this.$screens.routeStatisticsScreen = new ILib.PagedScreenController({
            title: "Route Planning Interface: Statistics",
            screenID: "RoutePlanner_Main_RouteStatistics",
            allowInterrupt: true,
            exitScreen: "GUI_SCREEN_INTERFACES"
        }, returnToMain);

        let formatToColumns = function (name, type, remainTime, travelTime, distance){
            return "" +
                ILib.$padTextOnSide(name, "right", 6) + 
                ILib.$padTextAround(type, 8) +
                ILib.$padTextOnSide(remainTime, "left", 6) +
                ILib.$padTextOnSide(travelTime, "left", 6) +
                ILib.$padTextOnSide(distance, "left", 6);
        } 
        
        routeStatisticsScreen.addToHeader(
            new ILib.LabelLine(formatToColumns("Name", "Type", "Remain time", "Travel time", "Distance, LY"))
        );

        routeStatisticsScreen.addToHeader(new ILib.LabelLine(ILib.$repeatPattern("-", 32)));        
        
        let rsScreenRun = routeStatisticsScreen.runScreen.bind(routeStatisticsScreen);
        let updateTable = function (){
            const playerShip = player.ship;
            const targetSystem = playerShip.targetSystem;

            const memoizedRoutes = mainScript.$memoizedRoutes;
            const routeMode = playerShip.routeMode;
            const distanceMeasurement = routeMode === "OPTIMIZED_BY_JUMPS" ? "distance" : "distanceWhenTime";
            const timeMeasurement     = routeMode === "OPTIMIZED_BY_JUMPS" ? "timeWhenDistance" : "time";

            const waypointsOnCurrentRoute = mainScript.$waypointsOnCurrentRoute;
            const waypointsOnCurrentRouteCount = waypointsOnCurrentRoute.length;

            const storedRoutes = mainScript.$storedRoutes;
            const storedRoutesCount = storedRoutes.length;

            
            routeStatisticsScreen.clearContent();

            let totalTime = 0;
            let totalDistance = 0;
            let previousWaypointId = system.ID;   

            for(let i = waypointsOnCurrentRouteCount - 1; i >= 0; i--)
            {
                let waypointInfo = waypointsOnCurrentRoute[i];
                if(waypointInfo === undefined) // if something went wrong then just ignore it and hope for better
                    continue;
                let waypointId = waypointInfo.ID;

                let name = System.systemNameForID(waypointId);
                let costs = memoizedRoutes.routeCost(previousWaypointId, waypointId);
                totalDistance += costs[distanceMeasurement];
                totalTime += costs[timeMeasurement];

                let type = "", remainTime = "";
                if(waypointInfo.type)
                    type = expandDescription(waypointInfo.type.nameKey);
                if(waypointId === targetSystem)
                    type += ", " + expandDescription("[RoutePlanner_wpType_targetSystem]");
                if(waypointInfo.eta)
                    remainTime = ((waypointInfo.eta - clock.seconds) / 3600).toFixed(1);

                routeStatisticsScreen.addToContent(
                    new ILib.LabelLine(formatToColumns(name, type, remainTime, totalTime.toFixed(1), totalDistance.toFixed(1)))
                );

                previousWaypointId = waypointId;
            }   

            if(targetSystem !== previousWaypointId) {
                let name = System.systemNameForID(targetSystem);
                let costs = memoizedRoutes.routeCost(system.ID, targetSystem);
                totalDistance = costs[distanceMeasurement];
                totalTime = costs[timeMeasurement];

                routeStatisticsScreen.addToContent(
                    new ILib.LabelLine(formatToColumns(
                        name, expandDescription("[RoutePlanner_wpType_targetSystem]"), 
                        "", totalTime.toFixed(1), totalDistance.toFixed(1)))
                );

                previousWaypointId = targetSystem;
            }

            for(let i = 0; i < storedRoutesCount; i++)
            {
                let waypointInfo = storedRoutes[i].endWaypointInfo;
                if(waypointInfo === undefined) // if something went wrong then just ignore it and hope for better 2
                    continue;
                let waypointId = waypointInfo.ID;

                let name = System.systemNameForID(waypointId);
                let costs = memoizedRoutes.routeCost(previousWaypointId, waypointId);
                totalDistance += costs[distanceMeasurement];
                totalTime += costs[timeMeasurement];

                let type = "", remainTime = "";
                if(waypointInfo.type)
                    type = expandDescription(waypointInfo.type.nameKey);
                if(waypointInfo.eta)
                    remainTime = ((waypointInfo.eta - clock.seconds) / 3600).toFixed(1);

                routeStatisticsScreen.addToContent(
                    new ILib.LabelLine(formatToColumns(name, type, remainTime, totalTime.toFixed(1), totalDistance.toFixed(1)))
                );

                previousWaypointId = waypointId;
            }
            
            routeStatisticsScreen.clearFooter();

            routeStatisticsScreen.addToFooter(new ILib.LabelLine(ILib.$repeatPattern("-", 32)));  

            routeStatisticsScreen.addToFooter(
                new ILib.LabelLine(formatToColumns("Total", "", "", totalTime.toFixed(1), totalDistance.toFixed(1)))
            );

        }

        
        routeStatisticsScreen.runScreen = function() { updateTable(); rsScreenRun(); }

    }
}
Scripts/route-planner-interfaceLib.js
"use strict";
this.name = "RoutePlanner_InterfaceLib";
this.author = "Alnivel";
this.copyright = "2022 Alnivel";
this.version = "0.2";
this.licence = "CC BY-NC-SA 4.0";

(function() {
    let ILib = this;

    this.__BasePagedController = function __BasePagedController(screenParameters) {
        __BasePagedController_init(screenParameters);
    }

    this.__BasePagedController.prototype.__BasePagedController_init = function (screenParameters)  {
        this.screenParameters = screenParameters;
        this.headerLines = [];
        this.contentLines = [];
        this.footerLines = [];

        this.headerPrefix = "00HEADER";
        this.contentPrefix = "20CONTENT";
        this.footerPrefix = "40FOOTER";

        this.currentPage = 0;
        this.pages = 0;

        this.selectedChoicesKey = undefined;
    }

    this.__BasePagedController.prototype.addToHeader = function addToHeader(line) {
        const headerLinesCount = this.headerLines.length;
        const key = (headerLinesCount < 10 ? ("0" + headerLinesCount) : headerLinesCount) + "_NO_KEY";
        this.headerLines.push({ key: key, line: line });
    }

    this.__BasePagedController.prototype.addToContent = function addToContent(line) {
        const contentLinesCount = this.contentLines.length;
        const key = (contentLinesCount < 10 ? ("0" + contentLinesCount) : contentLinesCount) + "_NO_KEY";
        this.contentLines.push({ key: key, line: line });
    }

    this.__BasePagedController.prototype.addToFooter = function addToFooter(line) {
        const footerLinesCount = this.footerLines.length;
        const key = (footerLinesCount < 10 ? ("0" + footerLinesCount) : footerLinesCount) + "_NO_KEY";
        this.footerLines.push({ key: key, line: line });
    }

    /*
        Remove line from ...
        this.__BasePagedScreenController.prototype.removeLineFromContent = function removeFromContent(line) {
        let removePos = -1;
        let i = this.contentLines.length;
        while (i--) {
            if (this.contentLines[i].line === line) {
                removePos = i;
                break;
            }
        }
        if (removePos === -1)
            return false;
        else {
            this.contentLines.splice(removePos, 1);
            return true;
        }
    }
    */

    this.__BasePagedController.prototype.clearHeader = function clearHeader() {
        this.headerLines.length = 0;
    }

    this.__BasePagedController.prototype.clearContent = function clearHeader() {
        this.contentLines.length = 0;
    }

    this.__BasePagedController.prototype.clearFooter = function clearHeader() {
        this.footerLines.length = 0;
    }

    /***
     * Update every line in header and fill "choises" parameter with them  
     * @param {object} choises
     * @returns {number} Header height in lines
     */
    this.__BasePagedController.prototype.updateHeader = function updateHeader(choises) {
        const linesForHeader = this.headerLines.length;

        for (let i = 0; i < linesForHeader; i++) {
            let pair = this.headerLines[i];
            let key = this.headerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            choises[key] = pair.line.update();
        }

        return linesForHeader;
    }

    /**
     * Update content lines on specified page and fill "choises" parameter with them
     * @param {object} choises
     * @param {number} linesPerPage Maximum number of lines per page
     * @param {number} page Page number
     * @returns {number} Content height on specified page in lines
     */
    this.__BasePagedController.prototype.updateContent = function updateContent(choises, linesPerPage, page) {
        let linesStartIndex = linesPerPage * page;
        let linesEndIndex = linesStartIndex + linesPerPage;
        let contentLinesCount = this.contentLines.length;

        let linesEndIndexLimited = contentLinesCount < linesEndIndex ? contentLinesCount : linesEndIndex;

        for (let i = linesStartIndex; i < linesEndIndexLimited; i++) {
            let pair = this.contentLines[i];
            let key = this.contentPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            choises[key] = pair.line.update();
        }

        return linesEndIndexLimited - linesStartIndex;
    }

    /***
     * Update every line in footer and fill "choises" parameter with them  
     * @param {object} choises
     * @returns {number} Footer height in lines
     */
    this.__BasePagedController.prototype.updateFooter = function updateFooter(choises) {
        const linesForFooter = this.footerLines.length;
        
        for (let i = 0; i < linesForFooter; i++) {
            let pair = this.footerLines[i];
            let key = this.footerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            choises[key] = pair.line.update();
        }

        return linesForFooter;
    }

    this.__BasePagedController.prototype.proccessChoises = function proccessChoises(choise) {
        for (let i = 0; i < this.headerLines.length; i++) {
            let pair = this.headerLines[i];
            let key = this.headerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            let line = pair.line;
            if (choise === key) {
                return line.select && line.select(); // if true returned then don`t redraw this screen
            }
        }
        
        for (let i = 0; i < this.contentLines.length; i++) {
            let pair = this.contentLines[i];
            let key = this.contentPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            let line = pair.line;
            if (choise === key) {
                return line.select && line.select();
            }
        }

        for (let i = 0; i < this.footerLines.length; i++) {
            let pair = this.footerLines[i];
            let key = this.footerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            let line = pair.line;
            if (choise === key) {
                return line.select && line.select();
            }
        }

        // Restarting the screen, if necessary, is performed by the child 
        /* 
        this.screenParameters.choices = this.getPage(this.currentPage);
        this.screenParameters.initialChoicesKey = initialChoicesKey;
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
        */
    }

    this.__BasePagedController.prototype.runScreen = function runScreen() {
        log("ROUTEPLANER_INTERFACELIB_DEBUG", "Started " + this.screenParameters.title + " mission screen");
        this.currentPage = 0;
        this.screenParameters.choices = this.getPage(0);
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    this.__BasePagedController.prototype.returnToScreen = function returnToScreen() {
        if(this.currentPage === undefined || this.currentPage > this.pages)
            this.currentPage = 0;

        log("ROUTEPLANER_INTERFACELIB_DEBUG", "Returned to " + this.screenParameters.title + " mission screen on page " + this.currentPage);
        this.screenParameters.choices = this.getPage(this.currentPage);
        this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    /**
     * Create paged screen that can be filled with line objects.
     * Each time the line is selected, it will re-render, 
     * unless the Return option is selected (then will be called callbackOnReturn)
     * or the onSelect callback of the selected line returns true
     * @param {*} screenParameters 
     * @param {Function} [callbackOnReturn]
     */
    this.PagedScreenController = function PagedScreenController(screenParameters, callbackOnReturn) {
        this.__BasePagedController_init(screenParameters); // Don`t know how to get base constructor properly
        this.callbackOnReturn = callbackOnReturn ?  callbackOnReturn : null;
    }

    this.PagedScreenController.prototype = Object.create(__BasePagedController.prototype);

    this.PagedScreenController.prototype.onReturn = function onReturn(callback) {
        this.callbackOnReturn = callback;
    }

    this.PagedScreenController.prototype.getPage = function getPage(page) {
        const screenHeight = (player.ship.hudAllowsBigGui ? 27 : 21);

        const choises = {};

        const headerHeight = this.updateHeader(choises);
        const footerHeight = this.updateFooter(choises);
        const controlsHeight = 5;

        const maxContentHeight = screenHeight - headerHeight - footerHeight - controlsHeight;
        const pages = this.pages = Math.ceil(this.contentLines.length / maxContentHeight);

        const contentHeight = this.updateContent(choises, maxContentHeight, page);
        const remainHeight = maxContentHeight - contentHeight;
        log("ROUTEPLANER_INTERFACELIB_DEBUG", "Remain lines after content " + remainHeight + " from " + maxContentHeight)
        /*** Fill empty ***/
        const spacer = { text: "" };
        for (let i = 0; i < remainHeight; i++) {
            let key = "21EMPTY_" + (i < 10 ? ("0" + i) : i) + "_EMPTY";
            choises[key] = spacer;
        }

        /*** Controlls ***/
        if (pages > 1) {
            choises["70_SPACER"] = spacer;
            choises["71_PAGE_NUM"] = {
                text: "Page " + (1 + page) + " of " + pages,
                unselectable: true
            }
            choises["73_NEXT_PAGE"] = {
                text: "Next page",
                unselectable: page + 1 === pages
            };
            choises["74_PREV_PAGE"] = {
                text: "Previous page",
                unselectable: page === 0
            };

        } else {
            choises["70_SPACER"] = spacer;
            choises["71_SPACER"] = spacer;
            choises["73_SPACER"] = spacer;
            choises["74_SPACER"] = spacer;
        }

        choises["80_RETURN"] = { text: "Return" };

        return choises;
    }

    this.PagedScreenController.prototype.onChoiceSelected = function onChoiceSelected(choise) {
        if (choise === "80_RETURN") {
            if (this.callbackOnReturn) this.callbackOnReturn();
            return;
        }
        this.selectedChoicesKey = choise;
        if (choise === "74_PREV_PAGE") {
            this.currentPage--;
            if (this.currentPage === 0)
                this.selectedChoicesKey = "73_NEXT_PAGE";
        }
        else if (choise === "73_NEXT_PAGE") {
            this.currentPage++;
            if (this.currentPage + 1 === this.pages)
                this.selectedChoicesKey = "74_PREV_PAGE";
        }
        else {
            const stopRestartingScreen = this.proccessChoises(choise);
            
            if(stopRestartingScreen)
            {
                log("ROUTEPLANER_INTERFACELIB_DEBUG", "Screen \"" + this.screenParameters.title + "\" restarting has been stopped by key " + choise)
                return;
            }
                
        }

        this.screenParameters.choices = this.getPage(this.currentPage);
        this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    

    this.$limitText = function $limitText(text, limitWidth) {
        const ellipsis = "…";

        let tmp = text;
        while (defaultFont.measureString(tmp) > limitWidth) {
            tmp = tmp.substring(0, tmp.length - 2) + ellipsis;
        }

        return tmp;
    }

    /**
     * 
     * @param {string} text 
     * @param {"left"|"right"} padSide 
     * @param {number} desiredWidth 
     * @returns Padded text 
     */
    this.$padTextOnSide = function $padTextOnSide(text, padSide, desiredWidth) {
        const hairSpace = String.fromCharCode(31);
        const hairSpaceWidth = defaultFont.measureString(hairSpace);


        const textWidth = defaultFont.measureString(text);

        const padsNeeded = Math.floor((desiredWidth - textWidth) / hairSpaceWidth);

        let resultText;
        if (padsNeeded > 1)
            if (padSide == "left")
                resultText = new Array(padsNeeded).join(hairSpace) + text;
            else
                resultText = text + new Array(padsNeeded).join(hairSpace);
        else
            resultText = this.$limitText(text, desiredWidth);

        return resultText;
    }

    this.$padTextAround = function $padTextAround(text, desiredWidth) {
        const hairSpace = String.fromCharCode(31);
        const hairSpaceWidth = defaultFont.measureString(hairSpace);

        const textWidth = defaultFont.measureString(text);

        const padsNeeded = Math.floor((desiredWidth - textWidth) / (2 * hairSpaceWidth));

        let resultText;
        if (padsNeeded > 1) {
            let sidePadding = new Array(padsNeeded).join(hairSpace);
            resultText = sidePadding + text + sidePadding;
        }
        else
            resultText = this.$limitText(text, desiredWidth);

        return resultText;
    }

    this.$repeatPattern = function $repeatPattern(pattern, desiredWidth) {
        const patternWidth = defaultFont.measureString(pattern);
        const repeatsNeeded = Math.ceil(desiredWidth / patternWidth);

        let tmp = new Array(repeatsNeeded).join(pattern);
        while (defaultFont.measureString(tmp) > desiredWidth) {
            tmp = tmp.substring(0, tmp.length - 1);
        }

        return tmp
    }

    this.__LineWithCallbacksPrototype = {
        /**
         * Set callback that will be called when line is redrawing. 
         * The arguments for the function are same as for select callback, 
         * but it can return dictionary whose 
         * key-value pairs will be setted as choice parameters
         * @param {Function} updateCallback 
         * @returns Line object
         */
        onUpdate: function onUpdate(updateCallback) {
            this.updateCallback = updateCallback;
            return this;
        },
        /**
         * Set callback that will be called when line is selected. 
         * The arguments for the function depends on line type.
         * Is callback return true than screen will not redrawed. 
         * It can be used to change to another mission screen.
         * @param {*} selectCallback 
         * @returns Line object
         */
        onSelected: function onSelected(selectCallback) {
            this.selectCallback = selectCallback;
            return this;
        }
    }

    // TODO: Add lines hiding?

    /**
     * Create empty line object
     */
    this.EmptyLine = function () {}

    this.EmptyLine.prototype.update = function update() {
        return { text: "" };
    }

    /**
     * Create line object that can`t be selected
     * @param {string} text 
     */
    this.LabelLine = function (text, alignment) {
        this.choiceObject = {
            text: ILib.$padTextAround(text, 32),
            unselectable: true,
            color: "whiteColor",
            alignment: alignment? alignment : "CENTER"
        }
    }

    this.LabelLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.LabelLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback();

            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined &&
                    key !== "unselectable") {

                    if (key === "text")
                        this.choiceObject[key] = ILib.$padTextOnSide(value, "right", 32);
                    else
                        this.choiceObject[key] = value;
                }
            }

        }
        return this.choiceObject;
    };

    /**
     * Create line object that can be selected.
     * When selected onSelected callback will be called without any arguments
     * @param {string} text 
     */
    this.ButtonLine = function (text) {
        this.choiceObject = {
            text: ILib.$padTextOnSide(text, "right", 32),
            color: "cyanColor",
            alignment: "LEFT"
        };
        this.selectableColor = "cyanColor";
        this.unselectableColor = "darkGrayColor";
    }

    this.ButtonLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.ButtonLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback();
            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined) {
                    if (key === "color")
                        this.selectableColor = value;
                    else if (key === "text")
                        this.choiceObject[key] = ILib.$padTextOnSide(value, "right", 32);
                    else
                        this.choiceObject[key] = value;
                }
            }
            if (this.choiceObject.unselectable)
                this.choiceObject.color = this.unselectableColor;
            else
                this.choiceObject.color = this.selectableColor;
        }

        return this.choiceObject;
    }
    this.ButtonLine.prototype.select = function select() {
        if (this.selectCallback)
            return this.selectCallback();
    }


    /**
     * Create line object that toggles between true and false when selected.
     * When selected onSelected callback will be called with new state as argument
     * @param {string} text 
     * @param {Boolean} initialState 
     */
    this.ToggleLine = function (text, initialState) {
        this.choiceObject = {
            color: "cyanColor"
        };
        this.selectableColor = "cyanColor";
        this.unselectableColor = "darkGrayColor";
        this.text = text;
        this.state = !!initialState;
    }

    this.ToggleLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.ToggleLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback(this.state);

            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined) {
                    if (key === "color")
                        this.selectableColor = value;
                    else if (key === "text")
                        this.text = value;
                    else if (key === "state")
                        this.state = !!value;
                    else
                        this.choiceObject[key] = value;
                }
            }
            if (this.choiceObject.unselectable)
                this.choiceObject.color = this.unselectableColor;
            else
                this.choiceObject.color = this.selectableColor;
        }

        this.choiceObject.text =
            ILib.$padTextOnSide(this.text, "right", 24) +
            ILib.$padTextAround(" < " + (this.state ? "On" : "Off") + " > ", 8);
        return this.choiceObject;
    }
    this.ToggleLine.prototype.select = function select() {
        this.state = !this.state;
        if (this.selectCallback)
            return this.selectCallback(this.state);
    }


    /**
     * Create line object that cycles between the specified values when selected.
     * When selected onSelected callback will be called with new index and value as arguments
     * @param {string} text
     * @param {Array|Object.<string, any>} items If it is an array the values are 
     * the same as the labels, otherwise it is treated as a dictionary of labels to values
     * @param {any} [initialItem] 
     */
    this.CycleLine = function (text, items, initialItem) {
        const itemsIsArray = Array.isArray(items);
        const itemKeys = itemsIsArray ? items : Object.keys(items);

        let itemValues;
        if (itemsIsArray)
            itemValues = items
        else { // Object.values(items);
            itemValues = Array(itemKeys.length)
            for (let i = 0; i < itemKeys.length; i++)
                itemValues[i] = items[itemKeys[i]]
        }

        let initialIndex = itemKeys.indexOf(initialItem);
        if (initialIndex === -1)
            initialIndex = 0;

        this.choiceObject = {
            color: "cyanColor",
        };
        this.items = itemKeys;
        this.values = itemValues;
        this.currentIndex = initialIndex;
        this.text = text;
        this.selectableColor = "cyanColor";
        this.unselectableColor = "darkGrayColor";
    }

    this.CycleLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.CycleLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback(this.currentIndex, this.values[this.currentIndex]);

            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined) {
                    if (key === "text")
                        this.text = value;
                    else if (key === "currentIndex") {
                        this.currentIndex = value;
                    }
                    else
                        this.choiceObject[key] = value;
                }
            }
            if (this.choiceObject.unselectable)
                this.choiceObject.color = this.unselectableColor;
            else
                this.choiceObject.color = this.selectableColor;
        }

        const currentItem = this.items[this.currentIndex];
        this.choiceObject.text =
            ILib.$padTextOnSide(this.text, "right", 24) +
            ILib.$padTextAround(" < " + currentItem + " > ", 8);

        return this.choiceObject;
    }
    this.CycleLine.prototype.select = function select() {
        this.currentIndex = (this.currentIndex + 1) % this.values.length;
        if (this.selectCallback)
            return this.selectCallback(this.currentIndex, this.values[this.currentIndex]);
    }

}).call(this);
Scripts/route-planner-main.js
"use strict";
this.name = "RoutePlanner";
this.author = "Alnivel";
this.copyright = "2022 Alnivel";
this.version = "0.2";
this.licence = "CC BY-NC-SA 4.0";

/*************** Loading and saving variables ***************/
this.startUp = function () {
    this.$storedRoutes = [];

    const storedRoutes = JSON.parse(missionVariables.routePlanner_storedRoutes);
    if (storedRoutes)
        this.$storedRoutes = storedRoutes;

    const storedUserWaypoints = JSON.parse(missionVariables.routePlanner_storedUserWaypoints);
    if (storedUserWaypoints)
        this.$userWaypoints = storedUserWaypoints;

    const storedWaypointOrderMode = JSON.parse(missionVariables.routePlanner_waypointOrderMode);
    if (storedWaypointOrderMode)
        this.$currentWaypointOrderMode = storedWaypointOrderMode;

    const savedAppearanceParams = JSON.parse(missionVariables.routePlanner_appearanceParams);
    if (savedAppearanceParams)
        this.$appearance = savedAppearanceParams;

    const wpTypesList = Object.keys(this.$wpTypes)
    for(let i = 0; i < wpTypesList.length; i++)
    {
        const typeKey = wpTypesList[i];
        const savedIsUsed = missionVariables["routePlanner_wpType_isUsed" + typeKey];
        this.$wpTypes[typeKey].isUsed = savedIsUsed === null ? true : Boolean(Number(savedIsUsed));
    }    

    // log("RoutePlannerDebug", 
    //     "Loaded:".concat(
    //     "\nStored routes", JSON.stringify(this.$storedRoutes),
    //     "\nUser waypoints: ", JSON.stringify(this.$userWaypoints),
    //     "\nOrder mode: ", this.$currentWaypointOrderMode,
    //     "\nAppearence: ", JSON.stringify(this.$appearance),
    //     "\nEnd"
    // ));

    this.$waypointsOnCurrentRoute = [];
}

this.playerWillSaveGame = function (message) {
    const equipmentStatus = player.ship.equipmentStatus("EQ_ADVANCED_NAVIGATIONAL_ARRAY");
    if (equipmentStatus === "EQUIPMENT_OK" || equipmentStatus === "EQUIPMENT_DAMAGED") {
        missionVariables.routePlanner_storedRoutes = JSON.stringify(this.$storedRoutes);
        missionVariables.routePlanner_storedUserWaypoints = JSON.stringify(this.$userWaypoints);
        missionVariables.routePlanner_waypointOrderMode = JSON.stringify(this.$currentWaypointOrderMode)
        missionVariables.routePlanner_appearanceParams = JSON.stringify(this.$appearance);

        const wpTypesList = Object.keys(this.$wpTypes)
        for(let i = 0; i < wpTypesList.length; i++)
        {
            const typeKey = wpTypesList[i];
            missionVariables["routePlanner_wpType_isUsed" + typeKey] = Number(this.$wpTypes[typeKey].isUsed);
        }        
    }
    else {
        missionVariables.anlivel_routePlanner_storedRoutes = null; 
    }
}

/*************** Parameters ***************/

this.$waypointOrderModes = ["Manual", "Auto", "None"];
this.$currentWaypointOrderMode = "Auto";
this.$routeEditingEnabled = false;

this.$appearance = {
    userWaypointColor: "blueColor",
    userWaypointShape: "MARKER_X",
    routeLineColorByJumps: "cyanColor",
    routeLineColorByTime: "redColor"
}

/*************** Waypoint types ***************/
/**
 * @typedef {Object} WaypointType
 * @property {Boolean} isUsed
 * @property {Boolean} requiresMainStation
 * @property {string} nameKey
 * @property {Boolean} isTargetSystem
 */

/** @type {Object.<string, WaypointType>}*/
this.$wpTypes = {
    cargoContract:     { isUsed: true, requiresMainStation: true,  nameKey: "[RoutePlanner_wpType_cargo]" },
    parcelContract:    { isUsed: true, requiresMainStation: true,  nameKey: "[RoutePlanner_wpType_parcel]" },
    passengerContract: { isUsed: true, requiresMainStation: true,  nameKey: "[RoutePlanner_wpType_passenger]" }, 
    userSpecified:     { isUsed: true, requiresMainStation: false, nameKey: "[RoutePlanner_wpType_userSpecified]" }
}

/** @type {WaypointType}*/
this.$wpTypeTargetSystem = { 
    isUsed: true, 
    requiresMainStation: false, 
    nameKey: "[RoutePlanner_wpType_targetSystem]", 
    isTargetSystem: true
}

/*************** Interface ***************/
this.$startUpCompleted = false;

this.shipDockedWithStation = this.startUpComplete = function () {
    this.$startUpCompleted = true;

    const equipmentStatus = player.ship.equipmentStatus("EQ_ADVANCED_NAVIGATIONAL_ARRAY");
    if (equipmentStatus === "EQUIPMENT_OK" || equipmentStatus === "EQUIPMENT_DAMAGED") {
        this.$setInterfaceToStation();
    }
}

this.equipmentAdded = function (equipmentKey) {
    if (player.ship.docked && equipmentKey === "EQ_ADVANCED_NAVIGATIONAL_ARRAY") {
        this.$setInterfaceToStation();
    }
}

this.equipmentRemoved  = function (equipmentKey) {
    const station = player.ship.dockedStation;
    if (station && equipmentKey === "EQ_ADVANCED_NAVIGATIONAL_ARRAY") {
        station.setInterface("RoutePlanner_Interface", null);

    }
}

this.$setInterfaceToStation = function $setInterfaceToStation(){
    if (!this.$startUpCompleted) 
        return;

    if (!this.$interface) {
        let interfaceScript = worldScripts["RoutePlanner_Interface"];
        interfaceScript.$configureInterface();
        this.$interface = interfaceScript.$screens

        // log("RoutePlannerDebug", 
        //     "Interface initiated with:".concat(
        //     "\nUser waypoints: ", JSON.stringify(this.$userWaypoints),
        //     "\nOrder mode: ", this.$currentWaypointOrderMode,
        //     "\nAppearence: ", JSON.stringify(this.$appearance),
        //     "\nEnd"
        // )); 
    }

    const interfaceMain = this.$interface.main;
    const station = player.ship.dockedStation;
    station.setInterface("RoutePlanner_Interface", {
        title: "Route planning",
        category: "Ship Systems", // String.fromCharCode("0x1D") + "Ship Systems", // \u200b - zws
        summary: "Edit the current route, change the planning mode or other planner options",
        callback: interfaceMain.runScreen.bind(interfaceMain)
    });
}

/*************** Updating route in flight ***************/
// Route for previous galaxy makes no sense in new
this.playerEnteredNewGalaxy = function () {
    this.$storedRoutes = [];
    this.$userWaypoints = [];
    this.$storedUserWaypoints = [];
}

// When player launched without target system and have planed routes select first route automatically
this.shipWillLaunchFromStation = function () {
    if (player.ship.targetSystem === system.ID && this.$storedRoutes.length !== 0) {
        const autoRouteInfo = this.$removeFirstStoredRoute()

        const autoRoute = autoRouteInfo.route;
        const newTargetSystem = autoRoute[autoRoute.length - 1];
        player.ship.targetSystem = newTargetSystem;
        this.$waypointsOnCurrentRoute = [autoRouteInfo.endWaypointInfo];

        player.consoleMessage(
            expandMissionText("RoutePlanner_message_automatically-setted-destination", {
                nextWaypointName: System.systemNameForID(newTargetSystem)
            }), 5);
    }
}

/// TODO
this.shipExitedWitchspace = function () {

    if (this.$currentWaypointOrderMode !== "None" ) {
        const targetSystem = player.ship.targetSystem;
        if(targetSystem === system.ID) 
        {
            // log("RP TEST", JSON.stringify(this.$waypointsOnCurrentRoute))
            if (this.$storedRoutes.length !== 0) {
                const nextRouteInfo = this.$storedRoutes[0];
    
                const currentWpInfo = nextRouteInfo.startWaypointInfo;
                const wpRequiresMainStation = currentWpInfo.type.requiresMainStation;
                const wpType = currentWpInfo.type.nameKey;
                
                const nextWpInfo = nextRouteInfo.endWaypointInfo;
    
                let message = wpRequiresMainStation? 
                    "RoutePlanner_message_reached-waypoint_requires-main-station" : 
                    "RoutePlanner_message_reached-waypoint";
    
                player.consoleMessage(
                    expandMissionText(message, {
                        nextWaypointName: System.systemNameForID(nextWpInfo.ID),
                        waypointType: expandDescription(wpType)
                    }), 8);
            }
            else if(this.$waypointsOnCurrentRoute.length == 1)
            {
                const lastWp = this.$waypointsOnCurrentRoute[0];

                let message = lastWp.type.requiresMainStation? 
                    "RoutePlanner_message_reached-last-waypoint_requires-main-station" : 
                    "RoutePlanner_message_reached-last-waypoint";
                player.consoleMessage(
                    expandMissionText(message, {
                        waypointType: expandDescription(lastWp.type.nameKey)
                    }), 8);
            }
        }
        else if(this.$waypointsOnCurrentRoute.length > 1){
            const wp = this.$waypointsOnCurrentRoute[0];
            
            if(wp.ID === targetSystem) {
                let message = wp.type.requiresMainStation? 
                    "RoutePlanner_message_reached-midroute-waypoint_requires-main-station" : 
                    "RoutePlanner_message_reached-midroute-waypoint";
                player.consoleMessage(
                    expandMissionText(message, {
                        waypointType: expandDescription(wp.type.nameKey)
                    }), 8);
                
                    this.$waypointsOnCurrentRoute.shift();
            }
        }        

    }

    const userWpIndex = this.$userWaypoints.indexOf(system.ID);
    if (userWpIndex !== -1) {
        this.$userWaypoints.splice(userWpIndex, 1);

        this.$redrawUserWaypoints(
            this.$userWaypoints, 
            this.$appearance.userWaypointColor, 
            this.$appearance.userWaypointShape
        );
    }
}

/*************** Operation with waypoints ***************/
/**
 * @typedef {Object} WaypointInfo
 * @property {Number} ID
 * @property {WaypointType} type
 * @property {Number|null} eta
 */

/** @type {WaypointInfo[]} */
this.$userWaypoints = [];

this.$collectWaypoints = function $collectWaypoints() {
    const playerShip = player.ship;
    const targetSystem = playerShip.targetSystem;
    const seenSystems = {/* wpDestination0: outIndex0, ... */}; 
    const currentRouteSystems = {/* wpDestination0: routeWpIndex, ... */}; 

    const plannedWps = [];
    let plannedWpsIndex = 0;

    const currentRouteWps = []; // waypoints that already on current route
    let currentRouteWpsIndex = 0;

    let targetSystemWp = { // waypoints that on current targetSystem
        ID: targetSystem,
        type: this.$wpTypeTargetSystem,
        eta: undefined
    };

    // do this to remove waypoints that are already on the current route
    const currentRouteInfo = system.info.routeToSystem(
        System.infoForSystem(galaxyNumber, targetSystem),
        playerShip.routeMode);

    if (currentRouteInfo) {
        let i = currentRouteInfo.route.length;
        while (i--) {
            currentRouteSystems[currentRouteInfo.route[i]] = i;
        }
    }
    
    const wpCollections = [
        {collection: playerShip.contracts,  type: this.$wpTypes.cargoContract},
        {collection: playerShip.parcels,    type: this.$wpTypes.parcelContract},
        {collection: playerShip.passengers, type: this.$wpTypes.passengerContract}
    ];

    const userWp = this.$userWaypoints.map(function (systemId) {
        return { destination: systemId }
    });
    wpCollections.push({collection: userWp, type: this.$wpTypes.userSpecified});


    

    let wpCollectionIndex = wpCollections.length;
    while (wpCollectionIndex--) {
        const wpCollection = wpCollections[wpCollectionIndex].collection;
        const wpType = wpCollections[wpCollectionIndex].type;
        if(!wpType.isUsed)
            continue;

        let i = wpCollection.length;
        while (i--) {

            let wp = wpCollection[i];
            let wpEta = wp.eta + clock.seconds; // Not sure if it can be called now eta, but whatever
            let wpDestination = wp.destination;

            let indexInCurrentRoute = currentRouteSystems[wpDestination];
            let wpLiesOnCurrentRoute = indexInCurrentRoute !== undefined;

            if (seenSystems[wpDestination] === undefined) {
                let storedWaypoint = {
                    ID: wpDestination,
                    type: wpType,
                    eta: wpEta
                };

                if(wpLiesOnCurrentRoute)
                {
                    if(targetSystemWp.ID === wpDestination)
                    {
                        if(targetSystemWp.type.isTargetSystem) // it target system type then just replace
                        {
                            targetSystemWp = storedWaypoint;
                        }
                        else if(targetSystemWp.type !== wpType)
                        {
                            seenWp.type = {
                                isUsed: targetSystemWp.isUsed || wpType.isUsed,
                                requiresMainStation: targetSystemWp.requiresMainStation || wpType.requiresMainStation,
                                nameKey: "[RoutePlanner_wpType_several]"
                            }
                        }
                    }

                    storedWaypoint.indexInCurrentRoute = indexInCurrentRoute;
                    seenSystems[wpDestination] = currentRouteWpsIndex;
                    currentRouteWps[currentRouteWpsIndex] = storedWaypoint;
                    currentRouteWpsIndex++;
                }
                else
                {
                    seenSystems[wpDestination] = plannedWpsIndex;
                    plannedWps[plannedWpsIndex] = storedWaypoint;

                    plannedWpsIndex++;
                }                
            }
            else 
            {   
                let wpIndex = seenSystems[wpDestination];
                
                let seenWp = wpLiesOnCurrentRoute? currentRouteWps[wpIndex] : plannedWps[wpIndex];
                let seenWpType = seenWp.type;

                if(seenWpType !== wpType)
                {
                    seenWp.type = {
                        isUsed: seenWpType.isUsed || wpType.isUsed,
                        requiresMainStation: seenWpType.requiresMainStation || wpType.requiresMainStation,
                        nameKey: "[RoutePlanner_wpType_several]"
                    }
                }
                
                seenWp.eta = seenWp.eta < wpEta? seenWp.eta : wpEta;
            }
        }  
    }

    currentRouteWps.sort(function (a, b) {
        return a.indexInCurrentRoute < b.indexInCurrentRoute;
    });
    return {onRoute: currentRouteWps, notOnRoute: plannedWps, targetSystem: targetSystemWp};
}

this.$getUserWaypoints = function $getUserWaypoints() {
    const that = this;
    const targetSystem = player.ship.targetSystem;   
    const userWaypoints = this.$userWaypoints;

    let startI;
    let targetSystemWp; // waypoints that on current targetSystem
    if(targetSystem && userWaypoints[0] === targetSystem) {
        startI = 1;
        targetSystemWp = { 
            ID:  targetSystem,
            type: this.$wpTypeTargetSystem,
            eta: undefined
        };
    }
    else {
        startI = 0;
        targetSystemWp = {
            ID: userWaypoints[0], 
            type: this.$wpTypes.userSpecified
        }
    }     

    const plannedWps = [];
    for(let i = startI; i < userWaypoints.length; i++) {
        plannedWps.push({
            ID: userWaypoints[i], 
            type: that.$wpTypes.userSpecified
        });
    }

    return {onRoute: [targetSystemWp], notOnRoute: plannedWps, targetSystem: targetSystemWp};
}

this.$recalculateRoutes = function $recalculateRoutes() {
    let newRoutesInfo = undefined;
    const storedRoutes = this.$storedRoutes;
    const currentWaypointOrderMode = this.$currentWaypointOrderMode;

    const targetSystem = player.ship.targetSystem;
    const routeMode = player.ship.routeMode;

    if (currentWaypointOrderMode === "None" || routeMode === "OPTIMIZED_BY_NONE") {
        this.$redrawRoutes([], lineColor);
        this.$redrawUserWaypoints([], this.$appearance.userWaypointColor, this.$appearance.userWaypointShape);
        return 
    }
        

    let waypoints;
    // There was a check to see if it was possible not to recalculate the path, 
    // but it became too complicated. Maybe should return it later
    if (true) {

        this.$prevRouteMode = routeMode;

        const autoOrderMode = currentWaypointOrderMode === "Auto";

        
        if(autoOrderMode)
        {
            waypoints = this.$collectWaypoints();
            newRoutesInfo = this.$routeWithCheapestInsertion(waypoints.targetSystem, waypoints.notOnRoute, routeMode);
        }
        else
        {
            waypoints = this.$getUserWaypoints();
            newRoutesInfo = this.$routeKeepingOrder(waypoints.targetSystem, waypoints.notOnRoute, routeMode);
        }
    }

    this.$waypointsOnCurrentRoute = waypoints.onRoute;

    const lineColor = routeMode === "OPTIMIZED_BY_JUMPS" ?
        this.$appearance.routeLineColorByJumps :
        this.$appearance.routeLineColorByTime;

    this.$redrawRoutes(newRoutesInfo, lineColor);
    this.$redrawUserWaypoints(this.$userWaypoints, this.$appearance.userWaypointColor, this.$appearance.userWaypointShape);
}

this.playerEnteredContract = function () { this.$recalculateRoutes() };

this.guiScreenChanged = function (to, from) {
    if (this.$routeEditingEnabled) {
        const fromChartScreen = from === "GUI_SCREEN_SHORT_RANGE_CHART" || from === "GUI_SCREEN_LONG_RANGE_CHART";
        const toChartScreen = to === "GUI_SCREEN_SHORT_RANGE_CHART" || to === "GUI_SCREEN_LONG_RANGE_CHART";
        const toSystemDataScreen = to === "GUI_SCREEN_SYSTEM_DATA";

        if (fromChartScreen && toSystemDataScreen) {
            const editScreen = this.$interface.editRouteSystemAction;
            editScreen.runScreen.call(editScreen, player.ship.infoSystem, this.$currentWaypointOrderMode);
        }
        else if ((fromChartScreen && toChartScreen) || mission.screenID) { }
        else {
            this.$routeEditingEnabled = false;
        }

    }

    // Start periodicaly check if route mode or cursor position changed if user on chart screen
    if (guiScreen === "GUI_SCREEN_SHORT_RANGE_CHART" ||
        guiScreen === "GUI_SCREEN_LONG_RANGE_CHART") {

        this.$prevRouteMode = player.ship.routeMode;
        this.$prevCursorCoordinates = player.ship.cursorCoordinates;
        this.$cursorMoving = false;
        this.$routeUndrawed = true;

        this.$checkChartInterfaceChangesTimer.start();
    }
    else {
        this.$checkChartInterfaceChangesTimer.stop();
        if ((from === "GUI_SCREEN_SHORT_RANGE_CHART" ||
            from === "GUI_SCREEN_LONG_RANGE_CHART") &&
            this.$routeUndrawed === false) {

            this.$eraseStoredRoutes();
        }

    }
}

this.$checkChartInterfaceChangesTimer = new Timer(this, function $checkChartInterfaceChangesTimer() {
    const cursorCoordinates = player.ship.cursorCoordinates;

    if (cursorCoordinates.x != this.$prevCursorCoordinates.x ||
        cursorCoordinates.y != this.$prevCursorCoordinates.y)
        this.$cursorMoving = true;
    else
        this.$cursorMoving = false;


    if (player.ship.routeMode != this.$prevRouteMode) {
        this.$recalculateRoutes();
    }
    else if (this.$cursorMoving && this.$routeUndrawed === false) {
        this.$eraseStoredRoutes();
        this.$routeUndrawed = true;
    } else if (this.$cursorMoving == false && this.$routeUndrawed) {
        this.$routeUndrawed = false;
        this.$recalculateRoutes();
    }

    this.$prevCursorCoordinates = cursorCoordinates;
}, -1, 0.25);

/**
 * Builds hopefully approximate optimal route from starting system thought waypoints
 * @param {WaypointInfo} startSystem
 * @param {WaypointInfo[]} waypoints 
 * @param {"OPTIMIZED_BY_NONE"|"OPTIMIZED_BY_JUMPS"|"OPTIMIZED_BY_TIME"} routeMode 
 * @returns {RouteInfo[]}
 */
this.$routeWithCheapestInsertion = function $routeWithCheapestInsertion(startSystem, waypoints, routeMode) {
    if (routeMode === "OPTIMIZED_BY_NONE" || waypoints.length === 0) return [];

    const measurement = routeMode === "OPTIMIZED_BY_JUMPS" ? "distance" : "time";

    waypoints.sort((function (a, b) {
        return this.$memoizedRoutes.routeCost(startSystem.ID, b.ID)[measurement] -
            this.$memoizedRoutes.routeCost(startSystem.ID, a.ID)[measurement];
    }).bind(this));

    const waypointsCount = waypoints.length;
    const bestRoute = [startSystem];

    const noRoute = { route: { length: Infinity }, distance: Infinity, time: Infinity };

    //bestRoute[0] = { ID: startSystem.ID };

    for (let i = 0; i < waypointsCount; i++) {
        const newWp = waypoints[i];
        if (!newWp.ID) break;

        const endPos = bestRoute.length - 1;

        let bestInsertPos, bestCostDifference, bestNewCostStart, bestNewCostEnd = null;

        if (true) { // check cost of inserting in end
            const endRouteWp = bestRoute[endPos];
            const cost = this.$memoizedRoutes.routeCost(endRouteWp.ID, newWp.ID);

            let costDifference = cost[measurement];// * 2*(1 + i/waypointsCount);//* 2;
            bestInsertPos = endPos + 1;
            bestCostDifference = costDifference;
            bestNewCostStart = cost;
        }
        for (let j = 0; j < endPos; j++) {
            const startWp = bestRoute[j];
            const endWp = bestRoute[j + 1];

            const oldCost = this.$memoizedRoutes.routeCost(startWp.ID, endWp.ID);
            const newCostStart = this.$memoizedRoutes.routeCost(startWp.ID, newWp.ID);
            const newCostEnd = this.$memoizedRoutes.routeCost(newWp.ID, endWp.ID);

            let costDifference = (newCostStart[measurement] + newCostEnd[measurement]) - oldCost[measurement];
            if (costDifference < bestCostDifference) {
                bestInsertPos = j + 1;
                bestCostDifference = costDifference;
                bestNewCostStart = newCostStart;
                bestNewCostEnd = newCostEnd;
            }
        }

        // Had plans to posibillity to add transitions between systems that cann`t be done with usual witchjump
        // Maybe someday I`ll do it. Maybe
        //
        // if(bestNewCostStart.through)
        //     if(bestEndCostStart && bestEndCostStart.through)
        //         bestRoute.splice(bestInsertPos, 0, bestNewCostStart.through, newWp, bestEndCostStart.through);
        //     else
        //         bestRoute.splice(bestInsertPos, 0, bestNewCostStart.through, newWp);
        // else
        //     if(bestEndCostStart && bestEndCostStart.through)
        //         bestRoute.splice(bestInsertPos, 0, newWp, bestEndCostStart.through);
        //     else
        //         bestRoute.splice(bestInsertPos, 0, newWp);    

        bestRoute.splice(bestInsertPos, 0, newWp);
    }

    const partialRoutesCount = bestRoute.length - 1;
    const partialRoutes = new Array(partialRoutesCount);

    let startWaypointInfo = bestRoute[0];
    let aInfo, bInfo = System.infoForSystem(galaxyNumber, bestRoute[0].ID);
    for (let i = 1; i < bestRoute.length; i++) {
        aInfo = bInfo;
        bInfo = System.infoForSystem(galaxyNumber, bestRoute[i].ID);

        let routeInfo = aInfo.routeToSystem(bInfo, routeMode);
        routeInfo.startWaypointInfo = bestRoute[i-1];
        routeInfo.endWaypointInfo = bestRoute[i];
        partialRoutes[i - 1] = routeInfo;
    }

    return partialRoutes;
}

/**
 * Builds a route from the starting system through waypoints in their exact order
 * @param {WaypointInfo} startSystem
 * @param {WaypointInfo[]} waypoints 
 * @param {"OPTIMIZED_BY_NONE"|"OPTIMIZED_BY_JUMPS"|"OPTIMIZED_BY_TIME"} routeMode 
 * @returns {RouteInfo[]}
 */
this.$routeKeepingOrder = function $routeKeepingOrder(startSystem, waypoints, routeMode) {
    if (routeMode === "OPTIMIZED_BY_NONE" || waypoints.length === 0) return [];

    const partialRoutesCount = waypoints.length;
    const partialRoutes = new Array(partialRoutesCount);

    let previusWaypoint, currentWaypoint = startSystem;
    let aInfo, bInfo = System.infoForSystem(galaxyNumber, startSystem.ID);
    for (let i = 0; i < partialRoutesCount; i++) {
        previusWaypoint = currentWaypoint;
        currentWaypoint = waypoints[i];
        aInfo = bInfo;
        bInfo = System.infoForSystem(galaxyNumber, currentWaypoint.ID);

        let routeInfo = aInfo.routeToSystem(bInfo, routeMode);
        routeInfo.startWaypointInfo = previusWaypoint;
        routeInfo.endWaypointInfo = currentWaypoint;
        partialRoutes[i] = aInfo.routeToSystem(bInfo, routeMode);
    }

    return partialRoutes;
}

this.$memoizedRoutes = {
    routeCost: function (from, to) {
        if (to === null || to === undefined || from === to)
            return { distance: 0, time: 0, through: null };

        // routes are symmetric, right?
        if (from <= to) {
            a = from;
            b = to
        } else {
            a = to;
            b = from;
        }

        const a = from < to ? from : to;
        const b = a === from ? to : from;

        let aCosts = this.$costsMatrix[a];
        if (aCosts === undefined) {
            aCosts = this.$costsMatrix[a] = {};
        }

        let routeCost = aCosts[b];
        if (routeCost === undefined) {
            const aSystemInfo = System.infoForSystem(galaxyNumber, a);
            const bSystemInfo = System.infoForSystem(galaxyNumber, b);

            const routeInfoByJumps = aSystemInfo.routeToSystem(bSystemInfo, "OPTIMIZED_BY_JUMPS");
            const routeInfoByTime = aSystemInfo.routeToSystem(bSystemInfo, "OPTIMIZED_BY_TIME");

            if (routeInfoByJumps == null)
                routeCost = aCosts[b] = null;
            else {
                routeCost = aCosts[b] = {
                    distance: routeInfoByJumps.distance,
                    timeWhenDistance: routeInfoByJumps.time,
                    time: routeInfoByTime.time,
                    distanceWhenTime: routeInfoByTime.distance,
                    through: null
                };
            }
        }

        return routeCost;
    },

    $costsMatrix: {
        /*
        systemNumberFrom: {
            systemNumberTo: {
                distance: 0
                timeWhenDistance: 0, // time when optimized by distance
                time: 0
                distanceWhenTime: 0, // distance when optimized by time
                through: null
            }
        }
        */
    }
}

/*************** Chart drawing operations ***************/

/**
 * Remove stored routes from chart but keep them stored
 */
this.$eraseStoredRoutes = function () {
    for (let i = 0; i < this.$storedRoutes.length; i++) {
        this.$eraseRoute(this.$storedRoutes[i]);
    }
}

/**
 * Remove stored routes from chart, draw and store new routes.
 * If need remove current routes and do not draw new pass [] as newRoutes
 * @param {RouteInfo[]} newRoutes 
 */
this.$redrawRoutes = function (newRoutes, lineColor) {
    if (newRoutes) {
        for (let i = 0; i < this.$storedRoutes.length; i++) {
            this.$eraseRoute(this.$storedRoutes[i]);
        }
    } else {
        newRoutes = this.$storedRoutes;
    }

    for (let i = 0; i < newRoutes.length; i++) {
        /*let a = i;
        new Timer(this, function() {
            this.$drawRoute(newRoutes[a]);
        }, i);
        */
        this.$drawRoute(newRoutes[i], lineColor);
    }
    this.$storedRoutes = newRoutes;
}

/**
 * Remove first stored route, erase it from chart and return
 * @returns {RouteInfo} Removed route
 */
this.$removeFirstStoredRoute = function () {
    const route = this.$storedRoutes.shift();
    this.$eraseRoute(route);

    return route;
}

this.$drawRoute = function (routeInfo, lineColor) {
    for (var i = 0; i < routeInfo.route.length - 1; i++) {
        if (routeInfo.route[i] < routeInfo.route[i + 1]) {
            SystemInfo.setInterstellarProperty(galaxyNumber, routeInfo.route[i], routeInfo.route[i + 1], 2, "link_color", lineColor);
        } else {
            SystemInfo.setInterstellarProperty(galaxyNumber, routeInfo.route[i + 1], routeInfo.route[i], 2, "link_color", lineColor);
        }
    }
}

this.$eraseRoute = function (routeInfo) {
    mission.unmarkSystem({
        system: routeInfo.route[0],
        name: this.name
    });

    for (var i = 0; i < routeInfo.route.length - 1; i++) {
        if (routeInfo.route[i] < routeInfo.route[i + 1]) {
            SystemInfo.setInterstellarProperty(galaxyNumber, routeInfo.route[i], routeInfo.route[i + 1], 2, "link_color", null);
        } else {
            SystemInfo.setInterstellarProperty(galaxyNumber, routeInfo.route[i + 1], routeInfo.route[i], 2, "link_color", null);
        }
    }
}

this.$storedUserWaypoints = [];

/**
 * Erase stored user waypoints from chart, draw and store new.
 * @param {WaypointInfo[]} newUserWaypoints 
 * @param {string} userWaypointColor 
 * @param {string} userWaypointShape 
 */
this.$redrawUserWaypoints = function $redrawUserWaypoints (newUserWaypoints, userWaypointColor, userWaypointShape) {
    const storedUserWaypoints = this.$storedUserWaypoints;
    for(let i = 0; i < storedUserWaypoints.length; i++) {
        mission.unmarkSystem({
            system: storedUserWaypoints[i],
            name: this.name
        });
    }

    this.$storedUserWaypoints = (newUserWaypoints || []).slice();

    for(let i = 0; i < storedUserWaypoints.length; i++) {
        mission.markSystem({
            system: newUserWaypoints[i],
            name: this.name,
            markerColor: userWaypointColor,
            markerShape: userWaypointShape
        });
    }
}