您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.8.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,iVBORw0KGgoAAAANSUhEUgAAADYAAABACAYAAABRPoQBAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAB5AAAAeQAUaqovYAAAAYdEVYdFNvZnR3YXJlAFBhaW50Lk5FVCA1LjEuOBtp6qgAAAC2ZVhJZklJKgAIAAAABQAaAQUAAQAAAEoAAAAbAQUAAQAAAFIAAAAoAQMAAQAAAAIAAAAxAQIAEAAAAFoAAABphwQAAQAAAGoAAAAAAAAAL8AAAOgDAAAvwAAA6AMAAFBhaW50Lk5FVCA1LjEuOAADAACQBwAEAAAAMDIzMAGgAwABAAAAAQAAAAWgBAABAAAAlAAAAAAAAAACAAEAAgAEAAAAUjk4AAIABwAEAAAAMDEwMAAAAADN/HAgpPPtnwAACDlJREFUaEPtmltoFd0Vx38z58Qkpmo+xQtVa6M5tbEaQzwRi0igDyq14EsopUEfoqZqQ1WwoHipUJ9EULSlVKF9KaJS73xeWlpFEEuN8FnFS2MuSszJ5VPRxJhxzpnVl32mZ/bMHJM0n+0p+cN6yNr/vdess9Zes2bvGAwdBjANmA3MA+YDMSXTAQGeAXeB68Bf1N+fFIauCMBnwLeASiCuHCkFJurEELwF/gr8HrgKfNAJnwozgB8Av1AP0g44KhL/qXwB1AH5utGRRjpi04AfA99XkflM44007qkf7oI+MERUAMuAYuCfwJ+A15mD7QG/7pAkLy/PpxuE/FGl+VDwDaBe7V9bW68VWIGK2HlgtT5bh2maOI7j/r18+XIqKiqYN28ebW1t7Nu3LysfwDAMRMSjU3vwCPAboEMfBAqBuUA1sBL4LjBBJ2WgF1jKUKJ1+PBhuXXrlrS1tUlvb6+kUikREbl8+bKPC0hdXZ1cv35dGhoaXJ1hGD6ekh7gd8CP1B7/OXASeBIQmY/JHwDeBAwESkdHhwTh0qVLPi4gW7ZsERER27blzp07Ul9f746Zpunjj6B0mcAYPZZh0FPrY3AcB8dxiEajxONxjhw5wtWrV1m8eDGO42AYg3nbDAuTTCCaqclmLGwsYN8A8P79e0zTdP/Oz89nxYoVXLhwgSVLloTO05G5Rhi0ZzMNIAW4M6PRKGfOnGHKlCmkUqlMMs3Nzbx7986ziOM4jB8/njlz5ngiapomvb29NDc360YBmDlzJsXFxdy9e5ejR4/y9OlTdywSifhsl5eXs3XrVkpLS4lEIogIpmnS09NDbW0tfX19mXRBf/kWFRVJd3e3vo1ERGTXrl16Lgsgp06d0qkiItLZ2enjpqWtrc3ldXd3y40bN+To0aOyevVqHxeQzZs3u8UqEz09PTJp0iSd7/hiLCIkk0ldDUBeXp6uyopUKkVBQYGuBvDYmDx5MtXV1TQ0NLBmzRoPLxN6FNHWyYTPsaEWiJHGYPbTYGCq0LkI+lVyEb6fx3EcIpGIroZhOB2JRBgYGNDVoMaGgmQySTTqKeCQZR0DSALuqGEYXLlyhalTp7qOpKva7du36ezs9CyWTCaJxWIsXLgQx3HcEm6aJq9fv+bmzZsuN3POsmXLPDZQD3n//n2ePHnicUJEKCoqorq6mry8PNdGJBKhu7ubmpoaent7XX46Cz9kVpQsLY88e/ZMRERSqZQ4jiOO44iIyPnz531cQNavXy+2bYvjOJJKpdyq9v79e1m7dq2PD8i5c+dERNw5aRuPHj3ycTNFe27HVOV+UEhHyjRNDMNwIxmWDoWFhS7XNE23MJimGVot05FKz0nb+FhR0V/2gY4FvVAJmDxcZFsnbCxMHwafY0Nd4H8VPsdCvpkgSyTDICKBc4K+1dII4pNFHwZT9YouRITy8nKqqqqIx+PE43EqKytZsGABlmVh2zYDAwOuJJNJUqkUhmG4/Hg8zuzZs8nPz6e/v9/DtyyL/v5+CgoKiMVinjmoiplMJj1zbNvGsixisRiLFi1y+VVVVZSXlwc6bQBfApMylffu3WPWrFm+99bx48e5ceMGY8eOdXVv3ryhrq6OVatWefimaZJIJNizZ4/vE8WyLPbv309JSYmv3F+7do1jx44xYcK/P5Ity2L+/Pls27aNMWPGeF4piUSCpUuX8vq1e9SBqox06aWzpaXFbTIzsXfvXg8vLWFNcEdHh4/7MRtnz571cQHZuHGjJJNJnS5dXV2hTbCvnbAsS1fBIEquDhEJLetheywMYfsySIfaY7722LZtXZVzCIxY2KfAcDBSFXaoCHQs7GHCUjSMbxhG6Jwwx8LWsiwrsMMJ2x6GOt7yHFru2LGD0tJST/6KCDNnzqSwsNBj3DRN2tvb6evr8xgRESKRCCUlJZim6c4xDAPbtmlpafE9lOM4jBs3junTp/uOGV6+fElXV5enOTZVo7179279BxSUY25FiUajeoVxJez4Lexcsb6+XqeKqCY6rAm+dOmSThcRkaamJh83U4KaYA+Gsr9s2+bt27ckEgl9CFQTHATbtkOrZVgq6u9UHfo8n2PZkEgkePjwIZ9//jmHDh1i06ZNLF++nHXr1gXumRcvXtDe3q6rPxk8qRgm0Wg0a5rq33Hpk96JEyfKwYMHpampyU0ry7I8p8KZcvHiRU8KpvH48WMfN4v4UzEM6R4uDHoqpNuoV69esX37dmKxGIcOHSKRSBCJREJfrEGRJ+SbL4xLWFUcaWR+MZSVlbFz505aW1uZMGECkUjEbaRt26asrIyKigpfD/n8+XMaGxvJy8ujo6OD06dP09zcnGHFgz4Gm4pZ5IO6/vmbupL6RwBH0C4iVq5cKSdPnpTW1lYZGBhwUy7smKG2tlYaGxvlxIkTsmHDBikuLvZxMuTPQ43YgHLikbqV/ELNb1c3iaIuORapG9IfAlP0RfS+r6amhsrKSmbMmMGDBw84cOCAhx+GzEzQsIosEfsAtACXgV+qy8G5QJG+ShZMU1eyXwasHyrDvB1Ny8608SDHrgDfBr7mfc5h45vqUi8ZYGskpB+4CHwv02iQY7/OJIwglgBnVErrNocqH4C/qwjN1Q0R4thvddII4zvAPvVgfQH2g8RSW+Mc8DNgQbYGI6x4HAN+oum+CpgqTcvUf/Z8XV2cR1VE3gAJ5VCL+g8fz0VYNvw3IvaVIzSUuY5Rx3INo47lGkYdyzWMOpZrGHUs1zDqWK5h1LFcw/+1Y+HHqTkMU50l6AjS5RRMdWyl46quyEUUAr9SZ3NdwE91Qi7iXyloL5S5DKdnAAAAAElFTkSuQmCC // @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, non_sharp_turn_penalty: 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(); }); addSlider("Non-sharp turn penalty (in seconds)", "non_sharp_turn_penalty", 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, use_option_cache: SETTINGS.use_option_cache, forward_penalty_on_intersections: SETTINGS.forward_penalty_on_intersections, non_sharp_turn_penalty: SETTINGS.non_sharp_turn_penalty, }); } /** * 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; })({});