Internet Roadtrip Pathfinder

A somewhat reliable pathfinder for neal.fun's Internet Roadtrip.

// ==UserScript==
// @name        Internet Roadtrip Pathfinder
// @namespace   internet-roadtrip-pathfinder
// @match       https://neal.fun/*
// @version     1.7.0
// @author      mat
// @description A somewhat reliable pathfinder for neal.fun's Internet Roadtrip.
// @license     0BSD
// @run-at      document-start
// @resource    flagCheckerboardPng data:image/png;base64,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
// @resource    flagSvg data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gVXBsb2FkZWQgdG86IFNWRyBSZXBvLCB3d3cuc3ZncmVwby5jb20sIEdlbmVyYXRvcjogU1ZHIFJlcG8gTWl4ZXIgVG9vbHMgLS0+Cgo8c3ZnCiAgIHdpZHRoPSI4MDBweCIKICAgaGVpZ2h0PSI4MDBweCIKICAgdmlld0JveD0iMCAwIDI0IDI0IgogICBmaWxsPSJub25lIgogICB2ZXJzaW9uPSIxLjEiCiAgIGlkPSJzdmcxIgogICBzb2RpcG9kaTpkb2NuYW1lPSJmbGFnLnN2ZyIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS40ICg4NmE4YWQ3LCAyMDI0LTEwLTExKSIKICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiCiAgIHhtbG5zOnNvZGlwb2RpPSJodHRwOi8vc29kaXBvZGkuc291cmNlZm9yZ2UubmV0L0RURC9zb2RpcG9kaS0wLmR0ZCIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzMSIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9Im5hbWVkdmlldzEiCiAgICAgcGFnZWNvbG9yPSIjNTA1MDUwIgogICAgIGJvcmRlcmNvbG9yPSIjZmZmZmZmIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgaW5rc2NhcGU6c2hvd3BhZ2VzaGFkb3c9IjAiCiAgICAgaW5rc2NhcGU6cGFnZW9wYWNpdHk9IjAiCiAgICAgaW5rc2NhcGU6cGFnZWNoZWNrZXJib2FyZD0iMSIKICAgICBpbmtzY2FwZTpkZXNrY29sb3I9IiNkMWQxZDEiCiAgICAgaW5rc2NhcGU6em9vbT0iMSIKICAgICBpbmtzY2FwZTpjeD0iMzYyIgogICAgIGlua3NjYXBlOmN5PSI0NjAuNSIKICAgICBpbmtzY2FwZTp3aW5kb3ctd2lkdGg9IjI1NjAiCiAgICAgaW5rc2NhcGU6d2luZG93LWhlaWdodD0iMTM2OSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LXk9Ii04IgogICAgIGlua3NjYXBlOndpbmRvdy1tYXhpbWl6ZWQ9IjEiCiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnMSIgLz4KICA8cGF0aAogICAgIGQ9Im0gNC43LDQuMzA5OTM5MSBjIC0wLjE1MjYyMTYsMC4wNTI0MzMgLTAuMjc3NjYsMC4xNDU2NiAtMC4zNjkxNCwwLjI3MzAxIC0wLjA5MTQ4LDAuMTI3MzYgLTAuMTQwNzQsMC4yODAxOSAtMC4xNDA4NiwwLjQzNjk5IFYgMTkuOTk5ODk5IGMgMCwwLjE5OSAwLjA3OTAyLDAuMzg5NzAxIDAuMjE5NjcsMC41MzA0MDEgMC4xNDA2NSwwLjE0MDYgMC4zMzE0MiwwLjIxOTU5OSAwLjUzMDMzLDAuMjE5NTk5IDAuMTk4OTEsMCAwLjM4OTY4LC0wLjA3OSAwLjUzMDMzLC0wLjIxOTYgMC4xNDA2NSwtMC4xNDA3IDAuMjE5NjcwMiwtMC4zMzE0IDAuMjE5NjcsLTAuNTMwNCBsIC01LjNlLTYsLTUuODYwMDE3IFYgMy45OTU5OTg1IGMgMCwwIC0wLjgzNzM3MzEsMC4yNjE1MDggLTAuOTg5OTk0NywwLjMxMzk0MDYgeiIKICAgICBmaWxsPSIjMDAwMDAwIgogICAgIGlkPSJwYXRoMS0yIgogICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuODU2ODc0MTIsMCwwLDAuODU2ODc0MTIsMS43MDkwMzA4LDEuNzEzOTMwNCkiCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2Utd2lkdGg6MS4xNjcwMztzdHJva2U6bm9uZSIKICAgICBzb2RpcG9kaTpub2RldHlwZXM9InpjY3Njc2NzY2N6IgogICAgIGlua3NjYXBlOmxhYmVsPSJtYXN0IgogICAgIGlua3NjYXBlOmV4cG9ydC1maWxlbmFtZT0iZmxhZy1jaGVja2VyYm9hcmQucG5nIgogICAgIGlua3NjYXBlOmV4cG9ydC14ZHBpPSI0OS4yMjAwMDEiCiAgICAgaW5rc2NhcGU6ZXhwb3J0LXlkcGk9IjQ5LjIyMDAwMSIgLz4KICA8ZwogICAgIGlkPSJnMTAiCiAgICAgaW5rc2NhcGU6bGFiZWw9ImZsYWciPgogICAgPHBhdGgKICAgICAgIGQ9Ik0gMTkuNDIsNC40NDk5NCBDIDE5LjMyMDMsNC4zODExNiAxOS4yMDUzLDQuMzM3OSAxOS4wODUsNC4zMjM5NSAxOC45NjQ3LDQuMzEgMTguODQyOCw0LjMyNTc5IDE4LjczLDQuMzY5OTQgMTcuNTkyNTcsNC44NjI4OTk4IDE1Ljg0ODI1Myw1LjMwOTExNjEgMTUuMTQ3MTIxLDUuMzY1NTU4MyBMIDE1LDUuMzY5OTQgYyAtMC44NzUxMzYsLTAuMTEyODg0NCAtMS41ODczLC0wLjU1NDI1IC0yLjMsLTEgLTAuOTE5OCwtMC41Njg1MSAtMi4yMjM5NDksLTEuMDk4OTI5MSAtMywtMS4xNiAtMC43NzYwNTEyLC0wLjA2MTA3MSAtNS4zOTc4ODUzLDAuOTgxNzE4NyAtNS42MzEzODAyLDEuMTUxOTM0NyAtMC4yMzM0OTQ4LDAuMTcwMjE2IDAuMDQ5ODc3LDkuOTYyMzkyMyAwLjA0OTg3Nyw5Ljk2MjM5MjMgMCwwIDQuMTE0NjUzNSwtMC45MTE1NjcgNS40MzE1MDM1LC0xLjAzNDM2NyAwLjg0NjcwMDcsMC4yMDc5IDEuNjU2MjAwNywwLjU0NTIgMi40MDAwMDA3LDEgMC44NzAxLDAuNTMxOSAxLjgyMzQsMC45MTM5IDMuMTYwNCwwLjkyODMgMS4zMzY5OTksMC4wMTQ0IDIuNzUyODk5LC0wLjM2NTYgNC4wOTk1OTksLTAuOTI4MyAwLjE0MDYsLTAuMDU1NyAwLjI2MTMsLTAuMTUyIDAuMzQ2OCwtMC4yNzY3IDAuMDg1NSwtMC4xMjQ3IDAuMTMxOSwtMC4yNzIxIDAuMTMzMiwtMC40MjMzIFYgNS4wNjk5NCBDIDE5LjY5NzUsNC45NTI1OCAxOS42NzY5LDQuODM1MTIgMTkuNjMsNC43MjczIDE5LjU4Myw0LjYxOTQ3IDE5LjUxMSw0LjUyNDQgMTkuNDIsNC40NDk5NCBaIgogICAgICAgZmlsbD0iIzAwMDAwMCIKICAgICAgIGlkPSJwYXRoNCIKICAgICAgIHRyYW5zZm9ybT0ibWF0cml4KDAuNzMyNzQzMDUsMCwwLDAuNzMwODM2MzUsMi45NTI0NjUzLDIuNzcwNzA2OCkiCiAgICAgICBzdHlsZT0iZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO2ZpbGwtcnVsZTpub256ZXJvO3N0cm9rZTojMDAwMDAwO3N0cm9rZS13aWR0aDoxLjYzOTgyO3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2Utb3BhY2l0eToxIgogICAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjc2NjY2N6Y2NjY2Njc2NjY2MiIC8+CiAgPC9nPgo8L3N2Zz4K
// @resource    flagWithCrossSvg data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gVXBsb2FkZWQgdG86IFNWRyBSZXBvLCB3d3cuc3ZncmVwby5jb20sIEdlbmVyYXRvcjogU1ZHIFJlcG8gTWl4ZXIgVG9vbHMgLS0+Cgo8c3ZnCiAgIHdpZHRoPSI4MDBweCIKICAgaGVpZ2h0PSI4MDBweCIKICAgdmlld0JveD0iMCAwIDI0IDI0IgogICBmaWxsPSJub25lIgogICB2ZXJzaW9uPSIxLjEiCiAgIGlkPSJzdmcxIgogICBzb2RpcG9kaTpkb2NuYW1lPSJmbGFnLXdpdGgtY3Jvc3Muc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjQgKDg2YThhZDcsIDIwMjQtMTAtMTEpIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxkZWZzCiAgICAgaWQ9ImRlZnMxIiAvPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0ibmFtZWR2aWV3MSIKICAgICBwYWdlY29sb3I9IiM1MDUwNTAiCiAgICAgYm9yZGVyY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyb3BhY2l0eT0iMSIKICAgICBpbmtzY2FwZTpzaG93cGFnZXNoYWRvdz0iMCIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMCIKICAgICBpbmtzY2FwZTpwYWdlY2hlY2tlcmJvYXJkPSIxIgogICAgIGlua3NjYXBlOmRlc2tjb2xvcj0iI2QxZDFkMSIKICAgICBpbmtzY2FwZTp6b29tPSIxLjQxNDIxMzYiCiAgICAgaW5rc2NhcGU6Y3g9IjM2Mi4wMzg2NiIKICAgICBpbmtzY2FwZTpjeT0iNDY5Ljg3MjQ0IgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMjU2MCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMzY5IgogICAgIGlua3NjYXBlOndpbmRvdy14PSItOCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJzdmcxIiAvPgogIDxwYXRoCiAgICAgZD0ibSA0LjcsNC4zMDk5MzkxIGMgLTAuMTUyNjIxNiwwLjA1MjQzMyAtMC4yNzc2NiwwLjE0NTY2IC0wLjM2OTE0LDAuMjczMDEgLTAuMDkxNDgsMC4xMjczNiAtMC4xNDA3NCwwLjI4MDE5IC0wLjE0MDg2LDAuNDM2OTkgViAxOS45OTk4OTkgYyAwLDAuMTk5IDAuMDc5MDIsMC4zODk3MDEgMC4yMTk2NywwLjUzMDQwMSAwLjE0MDY1LDAuMTQwNiAwLjMzMTQyLDAuMjE5NTk5IDAuNTMwMzMsMC4yMTk1OTkgMC4xOTg5MSwwIDAuMzg5NjgsLTAuMDc5IDAuNTMwMzMsLTAuMjE5NiAwLjE0MDY1LC0wLjE0MDcgMC4yMTk2NzAyLC0wLjMzMTQgMC4yMTk2NywtMC41MzA0IGwgLTUuM2UtNiwtNS44NjAwMTcgViAzLjk5NTk5ODUgYyAwLDAgLTAuODM3MzczMSwwLjI2MTUwOCAtMC45ODk5OTQ3LDAuMzEzOTQwNiB6IgogICAgIGZpbGw9IiMwMDAwMDAiCiAgICAgaWQ9InBhdGgxLTIiCiAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMC44NTY4NzQxMiwwLDAsMC44NTY4NzQxMiwxLjcwOTAzMDgsMS43MTM5MzA0KSIKICAgICBzdHlsZT0iZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eToxO3N0cm9rZS13aWR0aDoxLjE2NzAzO3N0cm9rZTpub25lIgogICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iemNjc2NzY3NjY3oiCiAgICAgaW5rc2NhcGU6bGFiZWw9Im1hc3QiIC8+CiAgPGcKICAgICBpZD0iZzEwIgogICAgIGlua3NjYXBlOmxhYmVsPSJmbGFnIj4KICAgIDxwYXRoCiAgICAgICBkPSJNIDE5LjQyLDQuNDQ5OTQgQyAxOS4zMjAzLDQuMzgxMTYgMTkuMjA1Myw0LjMzNzkgMTkuMDg1LDQuMzIzOTUgMTguOTY0Nyw0LjMxIDE4Ljg0MjgsNC4zMjU3OSAxOC43Myw0LjM2OTk0IDE3LjU5MjU3LDQuODYyODk5OCAxNS44NDgyNTMsNS4zMDkxMTYxIDE1LjE0NzEyMSw1LjM2NTU1ODMgTCAxNSw1LjM2OTk0IGMgLTAuODc1MTM2LC0wLjExMjg4NDQgLTEuNTg3MywtMC41NTQyNSAtMi4zLC0xIC0wLjkxOTgsLTAuNTY4NTEgLTIuMjIzOTQ5LC0xLjA5ODkyOTEgLTMsLTEuMTYgLTAuNzc2MDUxMiwtMC4wNjEwNzEgLTUuMzk3ODg1MywwLjk4MTcxODcgLTUuNjMxMzgwMiwxLjE1MTkzNDcgLTAuMjMzNDk0OCwwLjE3MDIxNiAwLjA0OTg3Nyw5Ljk2MjM5MjMgMC4wNDk4NzcsOS45NjIzOTIzIDAsMCA0LjExNDY1MzUsLTAuOTExNTY3IDUuNDMxNTAzNSwtMS4wMzQzNjcgMC44NDY3MDA3LDAuMjA3OSAxLjY1NjIwMDcsMC41NDUyIDIuNDAwMDAwNywxIDAuODcwMSwwLjUzMTkgMS44MjM0LDAuOTEzOSAzLjE2MDQsMC45MjgzIDEuMzM2OTk5LDAuMDE0NCAyLjc1Mjg5OSwtMC4zNjU2IDQuMDk5NTk5LC0wLjkyODMgMC4xNDA2LC0wLjA1NTcgMC4yNjEzLC0wLjE1MiAwLjM0NjgsLTAuMjc2NyAwLjA4NTUsLTAuMTI0NyAwLjEzMTksLTAuMjcyMSAwLjEzMzIsLTAuNDIzMyBWIDUuMDY5OTQgQyAxOS42OTc1LDQuOTUyNTggMTkuNjc2OSw0LjgzNTEyIDE5LjYzLDQuNzI3MyAxOS41ODMsNC42MTk0NyAxOS41MTEsNC41MjQ0IDE5LjQyLDQuNDQ5OTQgWiIKICAgICAgIGZpbGw9IiMwMDAwMDAiCiAgICAgICBpZD0icGF0aDQiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjczMjc0MzA1LDAsMCwwLjczMDgzNjM1LDIuOTUyNDY1MywyLjc3MDcwNjgpIgogICAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtmaWxsLXJ1bGU6bm9uemVybztzdHJva2U6IzAwMDAwMDtzdHJva2Utd2lkdGg6MS42Mzk4MjtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlLW9wYWNpdHk6MSIKICAgICAgIHNvZGlwb2RpOm5vZGV0eXBlcz0iY3NjY2NjemNjY2NjY3NjY2NjIiAvPgogIDwvZz4KICA8ZwogICAgIGlkPSJnOSIKICAgICBpbmtzY2FwZTpsYWJlbD0iY3Jvc3MiCiAgICAgdHJhbnNmb3JtPSJtYXRyaXgoMC43NjAwMDAwMSwwLDAsMC43NjAwMDAwMSwzLjA4Mjc4NTQsMTIuMjM1NTMyKSIKICAgICBzdHlsZT0ic3Ryb2tlLXdpZHRoOjEuMzE1NzkiPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOiMwMDAwMDA7c3Ryb2tlLXdpZHRoOjUyLjYzMTY7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2Utb3BhY2l0eToxIgogICAgICAgZD0ibSA0OTguNTEwMTEsMTE5LjUwMTI2IDE1MCwxNTAiCiAgICAgICBpZD0icGF0aDgiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLjAzLDAsMCwwLjAzLC0wLjAyMTIxMzIsMC43NDI0NjIxMikiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNjIiAvPgogICAgPHBhdGgKICAgICAgIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOiMwMDAwMDA7c3Ryb2tlLXdpZHRoOjUyLjYzMTY7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOm1pdGVyO3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2Utb3BhY2l0eToxIgogICAgICAgZD0ibSA0OTguNTEwMTEsMTE5LjUwMTI2IDE1MCwxNTAiCiAgICAgICBpZD0icGF0aDkiCiAgICAgICB0cmFuc2Zvcm09Im1hdHJpeCgwLDAuMDMsLTAuMDMsMCwyMy4wMTkxMjgsLTEwLjYyNzgwMykiCiAgICAgICBzb2RpcG9kaTpub2RldHlwZXM9ImNjIiAvPgogIDwvZz4KPC9zdmc+Cg==
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// @grant       GM_addStyle
// @grant       GM_getResourceURL
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       unsafeWindow
// ==/UserScript==

var Pathfinder = (function (exports) {
    'use strict';

    // geojson uses [lng,lat] and we mostly do the same, so we handle all of that here to avoid mistakes
    function getLat(pos) {
        return pos[1];
    }
    function getLng(pos) {
        return pos[0];
    }
    function newPosition(lat, lng) {
        return [lng, lat];
    }
    /**
     * @param coords formatted like "lat,lng"
     * @returns [lng, lat]
     */
    function parseCoordinatesString(coords) {
        if (!coords.includes(","))
            return null;
        const [lat, lng] = coords.split(",").map(Number);
        if (lat === undefined || lng === undefined)
            return null;
        return newPosition(lat, lng);
    }

    const LOG_PREFIX = "[Pathfinder]";

    let map;
    async function initMap() {
        map = await IRF.vdom.map.then((mapVDOM) => mapVDOM.state.map);
    }

    /**
     * in meters, from Google Maps
     */
    const EARTH_RADIUS = 6_378_137;
    /**
     * @param origin lat, lng
     * @param dest lat, lng
     * @returns in meters
     */
    function calculateDistance(origin, dest) {
        const aLat = getLat(origin);
        const bLat = getLat(dest);
        const aLng = getLng(origin);
        const bLng = getLng(dest);
        const theta1 = toRadians(aLat);
        const theta2 = toRadians(bLat);
        const deltaTheta = toRadians(bLat - aLat);
        const deltaLambda = toRadians(bLng - aLng);
        const a = Math.pow(Math.sin(deltaTheta / 2), 2) +
            Math.cos(theta1) *
                Math.cos(theta2) *
                Math.pow(Math.sin(deltaLambda / 2), 2);
        const c = 2 * Math.asin(Math.sqrt(a));
        return EARTH_RADIUS * c;
    }
    /**
     * @param origin lat, lng
     * @param dest lat, lng
     * @returns in degrees
     */
    function calculateHeading(origin, dest) {
        const [aLng, aLat] = [toRadians(getLng(origin)), toRadians(getLat(origin))];
        const [bLng, bLat] = [toRadians(getLng(dest)), toRadians(getLat(dest))];
        const deltaLng = bLng - aLng;
        const [aLatSin, aLatCos] = [Math.sin(aLat), Math.cos(aLat)];
        const [bLatSin, bLatCos] = [Math.sin(bLat), Math.cos(bLat)];
        const [deltaLngSin, deltaLngCos] = [Math.sin(deltaLng), Math.cos(deltaLng)];
        const s = deltaLngSin * bLatCos;
        const c = aLatCos * bLatSin - aLatSin * bLatCos * deltaLngCos;
        return (toDegrees(Math.atan2(s, c)) + 360) % 360;
    }
    /**
     *
     * @param a in degrees
     * @param in degrees
     * @returns in degrees, between 0 and 360
     */
    function calculateHeadingDiff(a, b) {
        a = (a + 360) % 360;
        b = (b + 360) % 360;
        let diff = Math.abs(a - b);
        if (diff > 180) {
            diff = 360 - diff;
        }
        return diff;
    }
    function toRadians(degrees) {
        return degrees * (Math.PI / 180);
    }
    function toDegrees(radians) {
        return radians * (180 / Math.PI);
    }

    /**
     * Code related to picking the best option and finding panoramas in the pathfinder's path.
     */
    function showBestOption() {
        if (!exports.currentData) {
            throw Error("called showBestOption when currentData was still null");
        }
        const currentPos = newPosition(exports.currentData.lat, exports.currentData.lng);
        const firstPath = getFirstPath();
        if (firstPath.length < panosAdvancedInFirstPath + 2)
            return;
        const bestNextPos = firstPath[panosAdvancedInFirstPath + 1];
        const bestHeading = calculateHeading(currentPos, bestNextPos);
        console.debug(LOG_PREFIX, "option bestHeading", bestHeading);
        let bestOptionIndex = -1;
        let bestOptionHeadingDiff = Infinity;
        const options = exports.currentData.options;
        // first, check only the option that have lat+lng present (since those are more reliable)
        for (let optionIndex = 0; optionIndex < options.length; optionIndex++) {
            const option = options[optionIndex];
            if (!option.lat || !option.lng)
                continue;
            const optionPos = newPosition(option.lat, option.lng);
            const firstPathSliced = firstPath.slice(panosAdvancedInFirstPath, panosAdvancedInFirstPath + 2);
            const [matchingPanoInPathIndex, matchingPanoInPathDistance] = findClosestPanoInPath(optionPos, firstPathSliced);
            console.debug(LOG_PREFIX, "option with lat+lng", firstPathSliced[matchingPanoInPathIndex], matchingPanoInPathDistance);
            // heading diff and distance in meters aren't really comparable, but if a pano had a distance
            // of less than 1m then it's almost guaranteed to be the one we want anyways.
            if (matchingPanoInPathDistance < 1 &&
                matchingPanoInPathDistance < bestOptionHeadingDiff) {
                bestOptionIndex = optionIndex;
                bestOptionHeadingDiff = matchingPanoInPathDistance;
            }
        }
        // if nothing was found from the lat+lng check, do the less reliable heading check instead
        if (bestOptionIndex < 0) {
            for (let optionIndex = 0; optionIndex < options.length; optionIndex++) {
                const option = options[optionIndex];
                const optionHeading = option.heading;
                const optionHeadingDiff = calculateHeadingDiff(optionHeading, bestHeading);
                if (optionHeadingDiff < bestOptionHeadingDiff) {
                    bestOptionIndex = optionIndex;
                    bestOptionHeadingDiff = optionHeadingDiff;
                }
            }
        }
        if (bestOptionHeadingDiff > 100) {
            console.warn(LOG_PREFIX, "all of the options are bad!");
        }
        else {
            console.debug(LOG_PREFIX, "best option is", options[bestOptionIndex], `(diff: ${bestOptionHeadingDiff})`);
            highlightOptionIndex(bestOptionIndex);
        }
    }
    function highlightOptionIndex(optionIndex) {
        const optionArrowEls = Array.from(document.querySelectorAll(".option"));
        for (let i = 0; i < optionArrowEls.length; i++) {
            const optionArrowEl = optionArrowEls[i];
            if (i === optionIndex)
                optionArrowEl.classList.add("pathfinder-chosen-option");
            else
                optionArrowEl.classList.remove("pathfinder-chosen-option");
        }
    }
    function clearOptionHighlights() {
        for (const optionArrowEl of document.querySelectorAll(".option")) {
            optionArrowEl.classList.remove("pathfinder-chosen-option");
        }
    }
    /**
     * @returns The index of the closest pano in the path, and its distance.
     * Also, panos after the first one with a heading difference greater than 100 degrees are ignored.
     */
    function findClosestPanoInPath(targetPos, path) {
        let closestPanoInFirstPathIndex = -1;
        let closestPanoInFirstPathDistance = Infinity;
        for (let i = 0; i < path.length; i++) {
            const candidatePos = path[i];
            const distanceToCur = calculateDistance(candidatePos, targetPos);
            if (i > 0 && exports.currentData !== null) {
                // heading check
                const prevPos = path[i - 1];
                const candidateHeading = calculateHeading(prevPos, candidatePos);
                const headingDiff = calculateHeadingDiff(exports.currentData.heading, candidateHeading);
                if (headingDiff > 100) {
                    console.debug(LOG_PREFIX, "skipping due to heading diff:", headingDiff);
                    continue;
                }
            }
            if (distanceToCur < closestPanoInFirstPathDistance) {
                closestPanoInFirstPathIndex = i;
                closestPanoInFirstPathDistance = distanceToCur;
            }
        }
        return [closestPanoInFirstPathIndex, closestPanoInFirstPathDistance];
    }

    const DEFAULT_SETTINGS = {
        current_searching_path: true,
        allow_long_jumps: true,
        remove_reached_stops: false,
        // advanced settings
        use_option_cache: true,
        backend_url: "https://ir.matdoes.dev",
        heuristic_factor: 3.3,
        forward_penalty_on_intersections: 0,
    };
    const SETTINGS = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
    function loadSettingsFromSave() {
        const savedSettings = GM_getValue("settings") ?? "{}";
        for (const [k, v] of Object.entries(JSON.parse(savedSettings))) {
            // @ts-ignore assume that settings has the correct types
            SETTINGS[k] = v;
        }
    }
    function saveSettings() {
        GM_setValue("settings", JSON.stringify(SETTINGS));
    }
    function initSettingsTab() {
        loadSettingsFromSave();
        console.log(LOG_PREFIX, "loaded settings:", SETTINGS);
        const settingsTab = IRF.ui.panel.createTabFor(GM.info, {
            tabName: "Pathfinder",
            style: `
        .pathfinder-settings-tab-content {
            *, *::before, *::after {
                box-sizing: border-box;
            }

            .field-group {
                margin-block: 1rem;
            }
            .field-group-right-aligned {
                float: right;
                display: flex;
            }
            button {
                margin-left: 0.5rem;
            }
            i {
                margin-top: 0;
            }
            h2 {
                margin-bottom: 0;
            }
        }
        `,
            className: "pathfinder-settings-tab-content",
        });
        function addSetting(opts) {
            const id = `pathfinder-${opts.key}`;
            opts.inputEl.id = id;
            function setDisplayedValue(v) {
                if (typeof v === "boolean")
                    opts.inputEl.checked = v;
                else
                    opts.inputEl.value = v.toString();
            }
            setDisplayedValue(SETTINGS[opts.key]);
            opts.inputEl.addEventListener("change", (e) => {
                let newValue;
                const settingType = typeof DEFAULT_SETTINGS[opts.key];
                if (settingType === "boolean")
                    newValue = opts.inputEl.checked;
                else if (settingType === "number")
                    newValue = Number(opts.inputEl.value);
                else
                    newValue = opts.inputEl.value;
                SETTINGS[opts.key] = newValue;
                updateLabel();
                saveSettings();
                opts.cb?.(newValue);
            });
            opts.inputEl.addEventListener("input", (e) => {
                updateLabel(opts.inputEl.value);
            });
            const labelEl = document.createElement("label");
            labelEl.htmlFor = id;
            function updateLabel(shownValue) {
                const newLabelText = typeof opts.label === "string"
                    ? opts.label
                    : opts.label(shownValue ?? SETTINGS[opts.key]);
                labelEl.textContent = newLabelText;
            }
            updateLabel();
            const fieldGroupEl = document.createElement("div");
            fieldGroupEl.classList.add("field-group");
            fieldGroupEl.append(labelEl);
            const rightAlignedContentEl = document.createElement("div");
            rightAlignedContentEl.classList.add("field-group-right-aligned");
            if (!opts.isInputSeparate)
                rightAlignedContentEl.append(opts.inputEl);
            if (opts.hasResetBtn) {
                const resetBtnEl = document.createElement("button");
                resetBtnEl.textContent = "Reset";
                rightAlignedContentEl.append(resetBtnEl);
                resetBtnEl.addEventListener("click", () => {
                    const newValue = DEFAULT_SETTINGS[opts.key];
                    SETTINGS[opts.key] = newValue;
                    setDisplayedValue(newValue);
                    updateLabel();
                    saveSettings();
                    opts.cb?.(newValue);
                });
            }
            fieldGroupEl.append(rightAlignedContentEl);
            if (opts.isInputSeparate)
                fieldGroupEl.append(opts.inputEl);
            settingsTab.container.appendChild(fieldGroupEl);
        }
        function addToggle(label, key, cb) {
            const inputEl = document.createElement("input");
            inputEl.type = "checkbox";
            inputEl.classList.add(IRF.ui.panel.styles.toggle);
            addSetting({ inputEl, label, key, hasResetBtn: true, cb });
        }
        function addTextInput(label, key, cb) {
            const inputEl = document.createElement("input");
            addSetting({ inputEl, label, key, hasResetBtn: true, cb });
        }
        function addSlider(label, key, min, max, cb) {
            const inputEl = document.createElement("input");
            inputEl.type = "range";
            inputEl.classList.add(IRF.ui.panel.styles.slider);
            inputEl.min = min.toString();
            inputEl.max = max.toString();
            if (max - min < 10)
                inputEl.step = "0.01";
            function getLabel(label, value) {
                return `${label}: ${value}`;
            }
            addSetting({
                inputEl,
                label: (value) => getLabel(label, value),
                key,
                hasResetBtn: true,
                isInputSeparate: true,
                cb: (value) => {
                    cb?.(value);
                },
            });
        }
        addToggle("Show currently searching path", "current_searching_path", () => {
            rerenderPath("current_searching_path");
        });
        addToggle("Allow long jumps", "allow_long_jumps", () => {
            clearCachedPaths();
            refreshPath();
        });
        addToggle("Remove stops as they are reached", "remove_reached_stops");
        const advancedSettingsHeaderEl = document.createElement("h2");
        advancedSettingsHeaderEl.textContent = "Advanced settings";
        const advancedSettingsDescEl = document.createElement("i");
        advancedSettingsDescEl.textContent =
            "NOTE: These settings can make the pathfinder stop working, mess up your ETAs, and significantly hurt performance. You should reset them if something breaks.";
        settingsTab.container.append(advancedSettingsHeaderEl, advancedSettingsDescEl);
        addTextInput("Custom backend URL", "backend_url", () => {
            pfWs.close();
            clearCachedPaths();
            refreshPath();
        });
        addToggle("Use option cache", "use_option_cache", () => {
            clearCachedPaths();
            refreshPath();
        });
        addSlider("Heuristic factor", "heuristic_factor", 1, 4, () => {
            clearCachedPaths();
            refreshPath();
        });
        addSlider("Forward penalty on intersections (in seconds)", "forward_penalty_on_intersections", 0, 600, () => {
            clearCachedPaths();
            refreshPath();
        });
    }

    let tricksControl = undefined;
    /**
     * Tries to initialize the Minimap Tricks integration, if possible.
     */
    function tryInitMmt() {
        Promise.all([waitForMmtControl, waitForMmtAddContextFn]).then(([newTricksControl, addContext]) => {
            tricksControl = newTricksControl;
            onMmtFound(addContext);
        });
    }
    /**
     * Called when the Minimap Tricks userscript is found.
     */
    async function onMmtFound(addContext) {
        if (!tricksControl) {
            throw Error("tricksControl must be set");
        }
        document.body.classList.add("pathfinder-found-minimap-tricks");
        function setAndSaveDestination(pos) {
            updateDestinationFromString(`${getLat(pos)},${getLng(pos)}`);
        }
        function clearAndSaveDestination() {
            updateDestinationFromString("");
        }
        // Map button
        tricksControl.addButton(GM_getResourceURL("flagSvg"), "Find path to location", (control) => setAndSaveDestination(newPosition(control.lat, control.lng)), 
        // contexts
        ["Map"]);
        // Add stop button
        const addStopBtn = tricksControl.addButton(GM_getResourceURL("flagSvg"), "Add stop to path", (control) => {
            addStopToPath(newPosition(control.lat, control.lng));
        }, 
        // contexts
        ["Map", "Marker", "Pathfinder"]);
        addStopBtn.context_button.classList.add("pathfinder-add-stop-mmt-context-menu-button");
        // Marker button
        tricksControl.addButton(GM_getResourceURL("flagSvg"), "Set as pathfinder destination", (control) => setAndSaveDestination(newPosition(control.lat, control.lng)), 
        // contexts
        ["Marker"]);
        // Remove buttons
        const removePathBtn = tricksControl.addButton(GM_getResourceURL("flagWithCrossSvg"), "Clear found path", () => clearAndSaveDestination(), 
        // contexts
        ["Side", "Map", "Car", "Pathfinder", "Pathfinder destination"]);
        removePathBtn.side_button.classList.add("pathfinder-clear-path-mmt-side-button");
        removePathBtn.context_button.classList.add("pathfinder-clear-path-mmt-context-menu-button");
        tricksControl.addButton(GM_getResourceURL("flagWithCrossSvg"), "Remove stop", (control) => {
            removeStop(newPosition(control.lat, control.lng));
        }, 
        // contexts
        ["Pathfinder stop"]);
        addContext("Pathfinder", [
            // New buttons
            "Find path to location",
            "Add stop to path",
            "Clear found path",
            // Grandfathered buttons from Minimap Tricks
            "Copy coordinates",
            "Add marker",
        ]);
        addContext("Pathfinder destination", [
            "Clear found path",
            "Copy coordinates",
            "Add marker",
        ]);
        addContext("Pathfinder stop", [
            "Remove stop",
            "Copy coordinates",
            "Add marker",
        ]);
        map.on("contextmenu", "best_path", (event) => {
            event.preventDefault();
            openContextMenu(event.originalEvent, event.lngLat, "Pathfinder");
        });
        map.on("contextmenu", "best_path_segments", (event) => {
            event.preventDefault();
            openContextMenu(event.originalEvent, event.lngLat, "Pathfinder");
        });
        setNewInfoDisplay();
    }
    function setNewInfoDisplay() {
        const pathfinderInfoControl = new (class {
            _map;
            _container;
            onAdd(map) {
                this._map = map;
                this._container = this.insertDom();
                return this._container;
            }
            insertDom() {
                const containerEl = document.createElement("div");
                containerEl.id = "minimap-controls";
                containerEl.className =
                    "maplibregl-ctrl maplibregl-ctrl-scale pathfinder-info";
                containerEl.style.marginRight = "36px";
                return containerEl;
            }
            onRemove() {
                if (this._container) {
                    this._container.parentNode?.removeChild(this._container);
                }
                this._map = undefined;
            }
        })();
        map.addControl(pathfinderInfoControl, "bottom-right");
        replacePathfinderInfoEl(pathfinderInfoControl._container);
    }
    const waitForMmtControl = new Promise((resolve) => {
        if (unsafeWindow._MMT_control) {
            resolve(unsafeWindow._MMT_control);
            return;
        }
        let _tricksControl;
        Object.defineProperty(unsafeWindow, "_MMT_control", {
            get() {
                return _tricksControl;
            },
            set(tricksControl) {
                _tricksControl = tricksControl;
                resolve(tricksControl);
            },
            configurable: true,
            enumerable: true,
        });
    });
    const waitForMmtAddContextFn = new Promise((resolve) => {
        if (unsafeWindow._MMT_addContext) {
            resolve(unsafeWindow._MMT_addContext);
            return;
        }
        let _contexts;
        Object.defineProperty(unsafeWindow, "_MMT_addContext", {
            get() {
                return _contexts;
            },
            set(contexts) {
                _contexts = contexts;
                resolve(contexts);
            },
            configurable: true,
            enumerable: true,
        });
    });
    function addMarkerContextMenuListener(marker, contextName) {
        marker.getElement().addEventListener("contextmenu", (event) => {
            openContextMenu(event, marker.getLngLat(), contextName);
        });
    }
    function openContextMenu(event, pos, contextName) {
        if (!tricksControl)
            return;
        event.stopPropagation();
        event.preventDefault();
        const data = {};
        tricksControl.openMenu(contextName, pos.lat, pos.lng, event.clientX, event.clientY, data);
    }

    let destinationMarker;
    /**
     * should be the same length as the number of stops
     */
    let stopMarkers = [];
    async function initMarkers() {
        const maplibre = await IRF.modules.maplibre;
        destinationMarker = new maplibre.Marker({
            element: (() => {
                const imgEl = document.createElement("img");
                imgEl.className = "pathfinder-destination-marker";
                imgEl.src = GM_getResourceURL("flagCheckerboardPng");
                return imgEl;
            })(),
            anchor: "bottom-left",
        });
        addMarkerContextMenuListener(destinationMarker, "Pathfinder destination");
        rerenderStopMarkers();
    }
    function updateDestinationMarker(position) {
        if (!position) {
            // if no coords are passed then the point is removed
            destinationMarker.remove();
            return;
        }
        destinationMarker
            .setLngLat([getLng(position), getLat(position)])
            .addTo(map);
    }
    async function newStopMarker() {
        const maplibre = await IRF.modules.maplibre;
        const marker = new maplibre.Marker({
            element: (() => {
                const imgEl = document.createElement("img");
                imgEl.className = "pathfinder-stop-marker";
                imgEl.src = GM_getResourceURL("flagCheckerboardPng");
                return imgEl;
            })(),
            anchor: "bottom-left",
        });
        addMarkerContextMenuListener(marker, "Pathfinder stop");
        return marker;
    }
    async function rerenderStopMarkers() {
        const unorderedStops = getUnorderedStops();
        while (stopMarkers.length > unorderedStops.length) {
            stopMarkers.pop().remove();
        }
        while (unorderedStops.length > stopMarkers.length) {
            const stopMarker = await newStopMarker();
            // coordinates are required, but they'll get updated in a moment
            stopMarker.setLngLat([0, 0]).addTo(map);
            stopMarkers.push(stopMarker);
        }
        // stopMarkers and unorderedStops are now the same length, now update the lat/lng for all of them
        for (let i = 0; i < stopMarkers.length; i++) {
            const marker = stopMarkers[i];
            const stopPos = unorderedStops[i];
            marker.setLngLat([getLng(stopPos), getLat(stopPos)]);
        }
    }

    /**
     * Returns the list of destinations for the path we're following. Usually this will just be the one
     * destination, but can include more if the path has stops in it.
     */
    function getPathDestinations() {
        const remainingStops = getUnorderedStops();
        const lastDestination = parseCoordinatesString(getDestinationString());
        if (!exports.currentData || !lastDestination)
            return [];
        const stops = [];
        // find the closest stop in remainingStops
        let currentPosition = newPosition(exports.currentData.lat, exports.currentData.lng);
        while (remainingStops.length > 0) {
            let closestIndex = -1;
            let closestDistance = Number.POSITIVE_INFINITY;
            for (const [candidateIndex, candidate] of remainingStops.entries()) {
                const candidateDistance = calculateDistance(currentPosition, candidate);
                if (candidateDistance < closestDistance) {
                    closestIndex = candidateIndex;
                    closestDistance = candidateDistance;
                }
            }
            const closestStop = remainingStops[closestIndex];
            if (!closestStop) {
                throw Error(`closestStop should\'ve been set. closestIndex: ${closestIndex}, remainingStops: ${remainingStops}`);
            }
            stops.push(closestStop);
            remainingStops.splice(closestIndex, 1);
        }
        stops.push(lastDestination);
        return stops;
    }
    function addStopToPath(pos) {
        const currentStops = getUnorderedStops();
        if (currentStops.find((s) => s[0] === pos[0] && s[1] === pos[1])) {
            // stop is already present
            return;
        }
        currentStops.push(pos);
        setStops(currentStops);
        rerenderCompletePathSegments();
        rerenderStopMarkers();
        refreshPath();
    }
    function removeStop(pos) {
        const oldStops = getUnorderedStops();
        const newStops = oldStops.filter((s) => s[0] !== pos[0] || s[1] !== pos[1]);
        if (newStops.length === oldStops.length) {
            console.warn(LOG_PREFIX, "failed to remove stop at", pos, "currentStops:", oldStops);
            return false;
        }
        setStops(newStops);
        rerenderCompletePathSegments();
        rerenderStopMarkers();
        refreshPath();
        return true;
    }
    /**
     * @returns {GeoJSON.Position[]} array of [lng, lat]
     */
    function getUnorderedStops() {
        return JSON.parse(GM_getValue("stops") || "[]");
    }
    function setStops(stops) {
        GM_setValue("stops", JSON.stringify(stops));
    }

    const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
    function prettyTime(seconds) {
        if (seconds < 0)
            return "now";
        const hours = Math.floor(seconds / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const secondsLeft = Math.floor(seconds % 60);
        const msLeft = Math.floor((seconds * 1000) % 1000);
        if (hours > 0) {
            return `${hours}h ${minutes}m ${secondsLeft}s`;
        }
        else if (minutes > 0) {
            return `${minutes}m ${secondsLeft}s`;
        }
        else if (secondsLeft > 0) {
            return `${secondsLeft}s`;
        }
        else {
            return `${msLeft}ms`;
        }
    }

    /**
     * A map of path IDs to the best_paths that we calculated to completion.
     */
    const completePathSegments = new Map();
    /**
     * Path ID to its cost (which is the duration in seconds).
     */
    const pathSegmentCosts = new Map();
    const pathIdsToPathSegmentMetadata = new Map();
    /**
     * `lat,lng` to the path id, this includes destinations that aren't currently being used
     */
    const destinationToPathIdMap = new Map();
    /**
     * A map of path IDs to their expected destinations. It'll likely be a few meters from the actual destination.
     */
    const pathIdToDestination = new Map();
    const calculatingPaths = new Map();
    async function setupPathSources() {
        console.debug(LOG_PREFIX, "waiting for old-route to render");
        let waitedCount = 0;
        // alternatively just wait 2 seconds, in case internet-roadtrip.neal.fun/route is broken
        while (map.getSource("old-route") === undefined && waitedCount < 20) {
            await sleep(100);
            waitedCount += 1;
        }
        console.debug(LOG_PREFIX, "setting up path sources");
        setupPathSource("current_searching_path", "#00f");
        setupPathSource("best_path", "#f0f");
        setupPathSource("best_path_segments", "#f0f");
    }
    function setupPathSource(pathSourceId, color) {
        map.addSource(pathSourceId, {
            type: "geojson",
            data: {
                type: "Feature",
                properties: {},
                geometry: {
                    type: "LineString",
                    coordinates: [],
                },
            },
        });
        map.addLayer({
            id: pathSourceId,
            type: "line",
            source: pathSourceId,
            layout: {
                "line-join": "round",
                "line-cap": "round",
            },
            paint: {
                "line-color": color,
                "line-width": 4,
            },
        });
    }
    async function updatePathSource(pathSourceId, keepPrefixLength, append) {
        let curPath = calculatingPaths.get(pathSourceId) ?? [];
        curPath = curPath.slice(0, keepPrefixLength);
        curPath.push(...append);
        calculatingPaths.set(pathSourceId, curPath);
        rerenderPath(pathSourceId);
    }
    function updateCurrentLocation(curPos) {
        // check if the new location is near the front of our pathfinder's best path
        const firstPath = getFirstPath().slice(0, 10);
        const [closestPanoInBestPathIndex, closestPanoInBestPathDistance] = findClosestPanoInPath(curPos, firstPath);
        if (closestPanoInBestPathIndex === -1) {
            // this is usually fine, but can sometimes happen if we were stuck and got teleported out
            return false;
        }
        if (closestPanoInBestPathDistance > 20) {
            return false;
        }
        const i = closestPanoInBestPathIndex;
        panosAdvancedInFirstPath += i;
        updateLastCostRecalculationTimestamp();
        console.debug(LOG_PREFIX, "close enough, updating panosAdvancedInBestPath to", panosAdvancedInFirstPath);
        rerenderPath("best_path");
        rerenderPath("current_searching_path");
        rerenderCompletePathSegments();
        return true;
    }
    function getFirstPath() {
        const firstPathDest = getPathDestinations()[0];
        const firstPathId = convertDestinationToPathId(firstPathDest);
        if (firstPathId === undefined) {
            console.debug(LOG_PREFIX, "called updateCurrentLocation before the current path was requested");
            return [];
        }
        const unskipped = calculatingPathId === firstPathId
            ? calculatingPaths.get("best_path")
            : completePathSegments.get(firstPathId);
        return unskipped?.slice(panosAdvancedInFirstPath) ?? [];
    }
    let panosAdvancedInFirstPath = 0;
    function rerenderPath(pathSourceId) {
        let skip = 0;
        if (calculatingPathId !== undefined) {
            const pathDestination = pathIdsToPathSegmentMetadata.get(calculatingPathId).destination;
            const paths = getPathDestinations();
            const firstDestInPath = paths[0];
            if (firstDestInPath[0] === pathDestination[0] &&
                firstDestInPath[1] === pathDestination[1]) {
                // we've confirmed that this is the first path, so skip some panos
                skip = panosAdvancedInFirstPath;
            }
        }
        console.debug(LOG_PREFIX, "rendering", pathSourceId, "and skipping", skip);
        let path = calculatingPaths.get(pathSourceId)?.slice(skip) ?? [];
        // hide the current_path if the setting is checked
        // hide the current_searching_path if the setting is checked
        if (pathSourceId === "current_searching_path" &&
            !SETTINGS.current_searching_path) {
            path = [];
        }
        const pathSource = map.getSource(pathSourceId);
        pathSource?.setData({
            type: "Feature",
            properties: {},
            geometry: {
                type: "LineString",
                coordinates: path,
            },
        });
    }
    function rerenderCompletePathSegments() {
        const multiLines = [];
        for (const [index, stopDest] of getPathDestinations().entries()) {
            const pathId = convertDestinationToPathId(stopDest);
            if (pathId === undefined) {
                console.debug(LOG_PREFIX, "failed rendering path segment because it hasn't been requested yet:", destinationToPathIdMap, stopDest);
                continue;
            }
            let skip = 0;
            if (index === 0) {
                skip = panosAdvancedInFirstPath;
            }
            console.debug(LOG_PREFIX, "rendering best_path_segments and skipping", skip);
            const lines = completePathSegments.get(pathId)?.slice(skip);
            if (lines) {
                multiLines.push(lines);
            }
            else {
                console.warn(LOG_PREFIX, "stop destination", stopDest, "not present in completePathSegments. this probably just means that we haven't finished calculating it");
                break;
            }
        }
        const pathSource = map.getSource("best_path_segments");
        pathSource.setData({
            type: "Feature",
            properties: {},
            geometry: {
                type: "MultiLineString",
                coordinates: multiLines,
            },
        });
    }
    /**
     * Remove the pathfinder's lines from the map and forget their current state. You should probably
     * use `clearAllPaths` instead.
     */
    function resetRenderedPath() {
        panosAdvancedInFirstPath = 0;
        clearCalculatingPaths();
        rerenderCompletePathSegments();
        rerenderPath("best_path");
        rerenderPath("current_searching_path");
    }
    function updateCompletePathSegment(pathId, path, cost) {
        completePathSegments.set(pathId, path);
        pathSegmentCosts.set(pathId, cost);
        // no path is being calculated at this point anymore
        clearCalculatingPathId();
        // the path is already in completePathSegments, so remove it from
        // calculatingPaths to make it so we don't render the same path twice
        calculatingPaths.clear();
    }
    function convertDestinationToPathId(pos) {
        return destinationToPathIdMap.get(`${getLat(pos)},${getLng(pos)}`);
    }
    function clearCalculatingPaths() {
        if (getUnorderedStops().length === 0) {
            // persist these so we can avoid recalculating paths with many stops
            completePathSegments.clear();
            pathSegmentCosts.clear();
            pathIdsToPathSegmentMetadata.clear();
            destinationToPathIdMap.clear();
            pathIdToDestination.clear();
        }
        calculatingPaths.clear();
        clearCalculatingPathId();
    }
    function clearCachedPaths() {
        completePathSegments.clear();
        pathSegmentCosts.clear();
        pathIdsToPathSegmentMetadata.clear();
        destinationToPathIdMap.clear();
        pathIdToDestination.clear();
        calculatingPaths.clear();
        clearCalculatingPathId();
    }

    let pfWs;
    let queuedWebSocketMessages = [];
    let calculatingPathId = undefined;
    let nextPathId = 0;
    /**
     *
     * @param heading in degrees
     * @param start lng, lat
     * @param end lng, lat
     * @param startPano The ID of the current pano. If not passed, the start coords will get
     * snapped to the nearest pano instead.
     */
    function requestNewPath(heading, start, end, startPano) {
        const pathMetadata = {
            source: {
                pos: start,
                heading,
            },
            destination: end,
        };
        let alreadyKnownPathNodes = undefined;
        let alreadyKnownPathCost = undefined;
        const previousPathIdToSameDest = convertDestinationToPathId(end);
        if (previousPathIdToSameDest !== undefined) {
            console.debug(LOG_PREFIX, "we previously calculated a path to", end, "that we might be able to reuse");
            // save the data in a variable for a bit just in case
            const oldPathMetadata = pathIdsToPathSegmentMetadata.get(previousPathIdToSameDest);
            const oldPathCost = pathSegmentCosts.get(previousPathIdToSameDest);
            const oldPathNodes = completePathSegments.get(previousPathIdToSameDest);
            // we're calculating a new path to the same destination, so forget everything we have
            // stored about the old path to avoid a memory leak
            pathIdsToPathSegmentMetadata.delete(previousPathIdToSameDest);
            pathSegmentCosts.delete(previousPathIdToSameDest);
            completePathSegments.delete(previousPathIdToSameDest);
            pathIdToDestination.delete(previousPathIdToSameDest);
            // we might be able to copy that old path (to avoid recalculating) if it had the same source too
            if (oldPathNodes !== undefined &&
                JSON.stringify(oldPathMetadata) === JSON.stringify(pathMetadata)) {
                alreadyKnownPathNodes = oldPathNodes;
                alreadyKnownPathCost = oldPathCost;
                console.debug(LOG_PREFIX, "reusing old path", previousPathIdToSameDest, "to", end);
            }
        }
        console.debug(LOG_PREFIX, "destinationToPathIdMap", destinationToPathIdMap, end);
        // request a new path
        const pathId = nextPathId;
        calculatingPathId = pathId;
        nextPathId++;
        pathIdsToPathSegmentMetadata.set(pathId, pathMetadata);
        destinationToPathIdMap.set(`${getLat(end)},${getLng(end)}`, pathId);
        pathIdToDestination.set(pathId, end);
        if (alreadyKnownPathNodes !== undefined) {
            onProgress({
                id: calculatingPathId,
                percent_done: 1,
                best_path_cost: alreadyKnownPathCost,
                best_path_keep_prefix_length: 0,
                best_path_append: alreadyKnownPathNodes,
                current_path_keep_prefix_length: 0,
                current_path_append: [],
            });
            return;
        }
        sendWebSocketMessage({
            kind: "path",
            start: [getLat(start), getLng(start)],
            end: [getLat(end), getLng(end)],
            heading,
            start_pano: startPano,
            id: pathId,
            no_long_jumps: !SETTINGS.allow_long_jumps,
            heuristic_factor: SETTINGS.heuristic_factor,
            forward_penalty_on_intersections: SETTINGS.forward_penalty_on_intersections,
            use_option_cache: SETTINGS.use_option_cache,
        });
    }
    /**
     * Send a message to the pathfinder's WebSocket, queuing it for later if the WebSocket is currently
     * closed.
     *
     * @param msg The object that will get converted into JSON and sent to
     * the server.
     */
    function sendWebSocketMessage(msg) {
        console.debug(LOG_PREFIX, "sending", msg, "to pathfinder websocket");
        if (pfWs.readyState !== 1) {
            console.debug(LOG_PREFIX, "websocket is closed, adding message to queue");
            queuedWebSocketMessages.push(JSON.stringify(msg));
        }
        else {
            pfWs.send(JSON.stringify(msg));
        }
    }
    async function waitAndReconnect() {
        console.debug(LOG_PREFIX, "reconnecting to WebSocket");
        // this timeout is 10 seconds because of a firefox quirk that makes it delay creating websockets if you do it too fast
        await new Promise((r) => setTimeout(r, 10000));
        console.debug(LOG_PREFIX, "reconnecting...");
        connect();
    }
    function connect() {
        console.debug(LOG_PREFIX, "connecting to websocket");
        pfWs = new WebSocket(SETTINGS.backend_url.replace("http", "ws").replace(/\/$/, "") + "/path");
        console.debug(LOG_PREFIX, "websocket created:", pfWs);
        pfWs.addEventListener("close", async () => {
            console.debug(LOG_PREFIX, "Pathfinder WebSocket closed.");
            waitAndReconnect();
        });
        pfWs.addEventListener("error", (e) => {
            console.error(LOG_PREFIX, "Pathfinder WebSocket error:", e);
            pfWs.close();
        });
        pfWs.addEventListener("open", () => {
            console.debug(LOG_PREFIX, "Pathfinder WebSocket connected.");
            for (const msg of queuedWebSocketMessages) {
                pfWs.send(msg);
            }
            queuedWebSocketMessages = [];
        });
        pfWs.addEventListener("message", (e) => {
            const data = JSON.parse(e.data);
            if (data.type === "progress") {
                onProgress(data);
            }
            else if (data.type === "error") {
                alert(data.message);
            }
        });
    }
    async function waitUntilConnected() {
        if (pfWs.readyState !== 1) {
            await new Promise((res) => pfWs.addEventListener("open", res));
        }
    }
    async function clearCalculatingPathId() {
        calculatingPathId = undefined;
    }

    exports.pathfinderInfoEl = void 0;
    let pathfinderRefreshBtnEl;
    let destinationInputEl;
    exports.lastCostRecalculationTimestamp = Date.now();
    async function init() {
        const vdomContainer = await IRF.vdom.container;
        await initMap();
        injectStylesheet();
        const pathfinderContainerEl = document.createElement("div");
        pathfinderContainerEl.id = "pathfinder-container";
        destinationInputEl = document.createElement("input");
        destinationInputEl.classList.add("pathfinder-destination-input");
        destinationInputEl.placeholder = "lat, lng";
        destinationInputEl.value = getDestinationString();
        pathfinderRefreshBtnEl = document.createElement("button");
        pathfinderRefreshBtnEl.textContent = "🗘";
        pathfinderRefreshBtnEl.classList.add("pathfinder-refresh-btn");
        pathfinderRefreshBtnEl.disabled = true;
        exports.pathfinderInfoEl = document.createElement("span");
        exports.pathfinderInfoEl.classList.add("pathfinder-info");
        setInterval(() => {
            let totalCost = 0;
            let totalPanos = 0;
            for (const dest of getPathDestinations()) {
                const pathSegmentId = convertDestinationToPathId(dest);
                if (pathSegmentId === undefined) {
                    // means we haven't calculated this path yet, so we can't set an eta!
                    return;
                }
                const pathSegmentCost = pathSegmentCosts.get(pathSegmentId);
                if (pathSegmentCost === undefined) {
                    // means the path hasn't finished being calculated
                    return;
                }
                totalCost += pathSegmentCost;
                const actualPath = completePathSegments.get(pathSegmentId);
                totalPanos += actualPath.length;
            }
            if (totalCost) {
                const secondsSinceLastCostRecalculation = (Date.now() - exports.lastCostRecalculationTimestamp) / 1000;
                totalCost -= secondsSinceLastCostRecalculation;
                const advancedPercentage = panosAdvancedInFirstPath / totalPanos;
                const adjustedCost = totalCost * (1 - advancedPercentage);
                const prettyEta = prettyTime(adjustedCost);
                exports.pathfinderInfoEl.textContent = `ETA: ${prettyEta}`;
            }
        }, 1000);
        pathfinderContainerEl.appendChild(destinationInputEl);
        pathfinderContainerEl.appendChild(pathfinderRefreshBtnEl);
        pathfinderContainerEl.appendChild(exports.pathfinderInfoEl);
        vdomContainer.state.updateData = new Proxy(vdomContainer.state.updateData, {
            apply(oldUpdateData, thisArg, args) {
                // onUpdateData is a promise so errors won't propagate to here
                onUpdateData(args[0]);
                return oldUpdateData.apply(thisArg, args);
            },
        });
        const mapContainerEl = map.getContainer().parentElement;
        mapContainerEl.appendChild(pathfinderContainerEl);
        await initMarkers();
        destinationInputEl.addEventListener("change", () => {
            console.debug(LOG_PREFIX, "destination input changed");
            refreshPath();
        });
        pathfinderRefreshBtnEl.addEventListener("click", () => {
            console.debug(LOG_PREFIX, "refresh button clicked");
            refreshPath();
        });
        tryInitMmt();
        initSettingsTab();
        // this has to be done after settings are loaded so the backend url is correct
        connect();
        await setupPathSources();
        // wait until the websocket is open
        await waitUntilConnected();
        // now wait until we've received data from the internet roadtrip ws
        while (!exports.currentData) {
            await sleep(100);
        }
        console.debug(LOG_PREFIX, "start called");
        refreshPath();
    }
    function updateLastCostRecalculationTimestamp() {
        exports.lastCostRecalculationTimestamp = Date.now();
    }
    /** called when we receive a progress update from the pathfinder server */
    function onProgress(data) {
        pathfinderRefreshBtnEl.disabled = false;
        if (data.percent_done < 0) {
            // means the path was cleared
            clearAllPaths();
            rerenderCompletePathSegments();
            return;
        }
        updatePathSource("best_path", data.best_path_keep_prefix_length, data.best_path_append);
        updatePathSource("current_searching_path", data.current_path_keep_prefix_length, data.current_path_append);
        if (data.percent_done < 1) {
            // round to 5 decimal places but truncate to 1
            const percentDoneString = (data.percent_done * 100)
                .toFixed(5)
                .match(/^-?\d+(?:\.\d{0,1})?/)[0];
            exports.pathfinderInfoEl.textContent = `${percentDoneString}%`;
            return;
        }
        // path is done
        const pathId = data.id;
        updateCompletePathSegment(pathId, calculatingPaths.get("best_path"), data.best_path_cost);
        console.debug(LOG_PREFIX, `finished path ${pathId}, updated in completePathSegments`);
        rerenderCompletePathSegments();
        updateLastCostRecalculationTimestamp();
        // find the next segment if possible!
        const lastPosition = completePathSegments.get(pathId).at(-1);
        const secondLastPosition = completePathSegments.get(pathId).at(-2);
        const lastHeading = calculateHeading(secondLastPosition, lastPosition);
        const expectedDestination = pathIdToDestination.get(pathId);
        const allDestinations = getPathDestinations();
        const destinationIndex = allDestinations.findIndex((d) => d[0] === expectedDestination[0] && d[1] === expectedDestination[1]);
        console.debug(LOG_PREFIX, "allDestinations:", allDestinations, "expectedDestination:", expectedDestination, "destinationIndex", destinationIndex);
        if (destinationIndex === -1) {
            console.warn(LOG_PREFIX, "the path we just found wasn't in getPathDestinations():", allDestinations, "expectedDestination:", expectedDestination);
            return;
        }
        if (destinationIndex === allDestinations.length - 1) {
            console.debug(LOG_PREFIX, "found last segment in path");
            return;
        }
        const nextDestination = allDestinations[destinationIndex + 1];
        requestNewPath(lastHeading, lastPosition, nextDestination, 
        // pathfinder server doesn't send us pano ids
        undefined);
        rerenderCompletePathSegments();
    }
    /**
     * Send a message to stop calculating a path, and remove the lines from the map.
     */
    function abortPathfinding() {
        // note that this will get set again when the server sends us a progress update with percent_done being -1
        if (exports.pathfinderInfoEl)
            exports.pathfinderInfoEl.textContent = "";
        if (calculatingPathId !== undefined) {
            sendWebSocketMessage({
                kind: "abort",
                id: calculatingPathId,
            });
        }
        clearAllPaths();
    }
    /**
     * Remove the pathfinder's lines from the map, without aborting the current path.
     */
    function clearAllPaths() {
        exports.pathfinderInfoEl.textContent = "";
        resetRenderedPath();
    }
    function updateDestinationFromString(destString) {
        setDestinationString(destString);
        const dest = parseCoordinatesString(destString);
        updateDestinationMarker(dest);
        if (!dest) {
            document.body.classList.remove("pathfinder-has-destination");
            setStops([]);
            // abortPathfinding has to happen before clearAllPaths so the calculatingPathId is still set
            abortPathfinding();
            clearCalculatingPaths();
            rerenderCompletePathSegments();
            rerenderStopMarkers();
            clearAllPaths();
            return;
        }
        clearAllPaths();
        document.body.classList.add("pathfinder-has-destination");
        if (!exports.currentData) {
            // if we haven't received any data from the game yet then we can't know our current location
            return;
        }
        const curPos = newPosition(exports.currentData.lat, exports.currentData.lng);
        // at least one destination must be present because we just set the destination string and
        // checked that it was valid
        const firstDestination = getPathDestinations()[0];
        requestNewPath(exports.currentData.heading, curPos, firstDestination, exports.currentData.pano);
    }
    exports.previousData = null;
    exports.currentData = null;
    /**
     * The number of times in a row that the current position wasn't found in the best path.
     * This exists so we can recalculate the path if this value gets too high.
     */
    let lostPathCount = 0;
    /**
     * Called whenever we receive a message from the game WebSocket.
     */
    async function onUpdateData(msg) {
        [exports.previousData, exports.currentData] = [exports.currentData, msg];
        const curPos = newPosition(exports.currentData.lat, exports.currentData.lng);
        const locationChanged = exports.previousData?.lat !== getLat(curPos) ||
            exports.previousData?.lng !== getLng(curPos) ||
            exports.previousData?.heading !== exports.currentData.heading;
        if (locationChanged)
            clearOptionHighlights();
        if (SETTINGS.remove_reached_stops) {
            const remainingStops = getUnorderedStops();
            for (const stop of [...remainingStops]) {
                if (calculateDistance(stop, curPos) < 15 /* meters */) {
                    removeStop(stop);
                    break;
                }
            }
        }
        if (locationChanged && getPathDestinations().length > 0) {
            const isPathFound = updateCurrentLocation(curPos);
            if (isPathFound) {
                lostPathCount = 0;
                // wait a bit to make sure that any new elements are created
                await sleep(1100);
                showBestOption();
            }
            else {
                console.warn(LOG_PREFIX, `lost path? (#${lostPathCount})`);
                lostPathCount += 1;
                if (lostPathCount >= 3) {
                    lostPathCount = 0;
                    refreshPath();
                }
            }
        }
    }
    async function refreshPath() {
        pathfinderRefreshBtnEl.disabled = true;
        const destinationValue = getDestinationString();
        const hasDestination = destinationValue.trim() !== "";
        document.body.classList.toggle("pathfinder-has-destination", hasDestination);
        if (!hasDestination) {
            updateDestinationMarker(null);
            abortPathfinding();
            return;
        }
        updateDestinationFromString(destinationValue);
        // makes it so we don't wait until the next location change to highlight the new best option
        await sleep(2000);
        showBestOption();
    }
    /**
     * @returns A string that should be formatted as `lat,lng`, but might not be.
     */
    function getDestinationString() {
        return GM_getValue("destination") ?? "";
    }
    /**
     * @param value A string that should be formatted as `lat,lng`, but might not be.
     */
    function setDestinationString(value) {
        GM_setValue("destination", value.trim());
    }
    function injectStylesheet() {
        GM_addStyle(`
    body:not(.pathfinder-found-minimap-tricks) {
      & .map-container .info-button {
        /* overlaps with our ui */
        display: none;
      }
      & .pathfinder-refresh-btn {
        line-height: 1;
        padding: 0.2em;
      }
      & .pathfinder-info {
        background-color: #fff;
        padding: 0.1em 0.3em;
      }
    }

    body.pathfinder-found-minimap-tricks {
      & #pathfinder-container {
        display: none;
      }

      & .pathfinder-info {
        margin-right: 36px;

        &:empty {
          display: none;
        }
      }
    }

    .pathfinder-destination-marker {
      width: 25px;
      cursor: default;
    }
    .pathfinder-stop-marker {
      width: 15px;
      cursor: default;
    }
    .pathfinder-chosen-option path {
      fill: #f0f !important;
    }

    body:not(.pathfinder-has-destination) {
      & .pathfinder-clear-path-mmt-side-button,
      .pathfinder-clear-path-mmt-context-menu-button,
      .pathfinder-add-stop-mmt-context-menu-button {
        display: none !important;
      }
    }
  `);
    }
    function replacePathfinderInfoEl(newEl) {
        exports.pathfinderInfoEl.remove();
        exports.pathfinderInfoEl = newEl;
    }
    init();

    exports.clearAllPaths = clearAllPaths;
    exports.getDestinationString = getDestinationString;
    exports.onProgress = onProgress;
    exports.refreshPath = refreshPath;
    exports.replacePathfinderInfoEl = replacePathfinderInfoEl;
    exports.updateDestinationFromString = updateDestinationFromString;
    exports.updateLastCostRecalculationTimestamp = updateLastCostRecalculationTimestamp;

    return exports;

})({});