// ==UserScript==
// @name Internet Roadtrip - Minimap Teleport Markers
// @description Replace teleport-like jumps on the neal.fun/internet-roadtrip minimap with portal markers
// @namespace me.netux.site/user-scripts/internet-roadtrip/minimap-teleport-markers
// @version 0.1.3
// @author netux
// @license MIT
// @match https://neal.fun/internet-roadtrip/
// @icon https://neal.fun/favicons/internet-roadtrip.png
// @grant GM_addStyle
// @grant GM.setValue
// @grant GM.getValue
// @run-at document-start
// @require https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==
/* globals IRF */
(async () => {
const MOD_NAME = GM.info.script.name.replace('Internet Roadtrip -', '').trim();
const MOD_PREFIX = 'mtpm-';
const PORTAL_ICONS_SOURCE_NAME = `${MOD_PREFIX}portal-icons`;
const PORTAL_ICONS_LAYER_NAME = `${MOD_PREFIX}portal-icons`;
const TELEPORT_LINES_SOURCE_NAME = `${MOD_PREFIX}teleport-lines`;
const TELEPORT_LINES_LAYER_NAME = `${MOD_PREFIX}teleport-lines`;
const TELEPORT_DISTANCE_THRESHOLD_METERS = 5_000;
//const PORTAL_ENTRANCE_IMAGE_URL = 'https://cloudy.netux.site/neal_internet_roadtrip/teleport-markers/portal-entrance.svg';
const PORTAL_ENTRANCE_IMAGE_URL = 'https://cloudy.netux.site/neal_internet_roadtrip/teleport-markers/portal-entrance.png';
const PORTAL_ENTRANCE_IMAGE_NAME = 'portal-entrance';
//const PORTAL_EXIT_IMAGE_URL = 'https://cloudy.netux.site/neal_internet_roadtrip/teleport-markers/portal-exit.svg';
const PORTAL_EXIT_IMAGE_URL = 'https://cloudy.netux.site/neal_internet_roadtrip/teleport-markers/portal-exit.png';
const PORTAL_EXIT_IMAGE_NAME = 'portal-exit';
const cssClass = (... names) => names.map((name) => MOD_PREFIX + name).join(' ');
const cssProp = (name) => `--${MOD_PREFIX}${name}`;
const RAD_TO_DEG = 180 / Math.PI;
const DEG_TO_RAD = 1 / RAD_TO_DEG;
const util = {
// Yoinked from Chris.
// Thanks Chris!
haversineDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3; // Earth radius in meters
const phi1 = lat1 * DEG_TO_RAD;
const phi2 = lat2 * DEG_TO_RAD;
const deltaPhi = (lat2 - lat1) * DEG_TO_RAD;
const deltaLambda = (lon2 - lon1) * DEG_TO_RAD;
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
},
// Yoinked from Jakub. Thanks Jakub!
// Helper functions for working with tiles
// Based on https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
gcsToTileCoordinates(x, y, z) {
const n = 2**z
const lon_deg = x / n * 360.0 - 180.0
const lat_rad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)))
const lat_deg = lat_rad * 180.0 / Math.PI
return [lat_deg, lon_deg]
},
tileToGcsCoordinates([lat, lng], z) {
const n = 2 ** z;
const x = n * ((lng + 180) / 360);
const latRad = lat / 180 * Math.PI;
const y = n * (1 - (Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI)) / 2;
return [Math.floor(x), Math.floor(y), Math.floor(z)];
}
}
const settings = {
portalIcons: {
layer: {
show: true,
opacity: 1,
},
},
teleportLines: {
layer: {
show: true,
opacity: 0.5
},
}
};
for (const key in settings) {
settings[key] = await GM.getValue(key, settings[key]);
}
async function saveSettings() {
for (const key in settings) {
await GM.setValue(key, settings[key]);
}
}
const mapVDOM = await IRF.vdom.map;
const map = mapVDOM.data.map;
let isMapLoaded = map.loaded();
const waitForMapToLoad = new Promise((resolve) => {
map.once('load', () => {
isMapLoaded = true;
resolve();
});
});
function updateUiFromSettings() {
map.setLayoutProperty(PORTAL_ICONS_LAYER_NAME, 'visibility', settings.portalIcons.layer.show ? 'visible' : 'none');
map.setPaintProperty(PORTAL_ICONS_LAYER_NAME, 'icon-opacity', settings.portalIcons.layer.opacity);
map.setLayoutProperty(TELEPORT_LINES_LAYER_NAME, 'visibility', settings.teleportLines.layer.show ? 'visible' : 'none');
map.setPaintProperty(TELEPORT_LINES_LAYER_NAME, 'line-opacity', settings.teleportLines.layer.opacity);
}
const portalIconsSourceGeojsonData = {
type: 'FeatureCollection',
features: []
};
const teleportLinesSourceGeojsonData = {
type: 'Feature',
geometry: {
type: 'MultiLineString',
coordinates: []
}
};
waitForMapToLoad.then(() => {
map.loadImage(PORTAL_ENTRANCE_IMAGE_URL)
.then((image) => map.addImage(PORTAL_ENTRANCE_IMAGE_NAME, image.data))
.catch((error) => {
console.error(`[${MOD_NAME}] Could not load portal entrance image:`, error)
});
map.loadImage(PORTAL_EXIT_IMAGE_URL)
.then((image) => map.addImage(PORTAL_EXIT_IMAGE_NAME, image.data))
.catch((error) => {
console.error(`[${MOD_NAME}] Could not load portal exit image:`, error)
});
map.addSource(PORTAL_ICONS_SOURCE_NAME, {
type: 'geojson',
data: portalIconsSourceGeojsonData
});
map.addLayer({
id: PORTAL_ICONS_LAYER_NAME,
source: PORTAL_ICONS_SOURCE_NAME,
type: 'symbol',
minzoom: 5,
layout: {
'icon-image': ['get', 'icon'],
'icon-rotate': ['get', 'angle'],
'icon-size': [
'interpolate',
['linear'],
['zoom'],
/* map zoom, icon-size */
5, 0.02,
18, 0.1,
],
'icon-allow-overlap': true
}
});
map.addSource(TELEPORT_LINES_SOURCE_NAME, {
type: 'geojson',
data: teleportLinesSourceGeojsonData
});
map.addLayer({
id: TELEPORT_LINES_LAYER_NAME,
source: TELEPORT_LINES_SOURCE_NAME,
type: 'line',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': 'orange',
'line-width': 3
}
});
updateUiFromSettings();
});
//map.on('zoom', () => console.debug(map.getZoom()));
const isTeleport = ([prevLat, prevLng], [thisLat, thisLng]) =>
util.haversineDistance(prevLat, prevLng, thisLat, thisLng) > TELEPORT_DISTANCE_THRESHOLD_METERS;
function addTeleportToMap([prevLat, prevLng], [thisLat, thisLng]) {
// Add teleport line to its own layer
teleportLinesSourceGeojsonData.geometry.coordinates.push([
[prevLng, prevLat],
[thisLng, thisLat]
]);
// Create portal icons
const angle = Math.atan2(thisLat - prevLat, thisLng - prevLng) * RAD_TO_DEG;
portalIconsSourceGeojsonData.features.push({
type: 'Feature',
properties: {
'icon': PORTAL_ENTRANCE_IMAGE_NAME,
'angle': angle,
'portal-type': 'entrance'
},
geometry: {
type: 'Point',
coordinates: [prevLng, prevLat]
}
});
portalIconsSourceGeojsonData.features.push({
type: 'Feature',
properties: {
'icon': PORTAL_EXIT_IMAGE_NAME,
'angle': -angle % 360,
'portal-type': 'exit'
},
geometry: {
type: 'Point',
coordinates: [thisLng, thisLat]
}
});
}
function updateModLayerSources() {
map.getSource(PORTAL_ICONS_SOURCE_NAME).setData(portalIconsSourceGeojsonData);
map.getSource(TELEPORT_LINES_SOURCE_NAME).setData(teleportLinesSourceGeojsonData);
}
let lastOldRouteCoord = null;
async function resolveTeleportsInOldRoute() {
const oldRouteLayer = map.getLayer('old-route-layer');
oldRouteLayer.setPaintProperty('line-gradient', 'red');
map.moveLayer('old-route-layer', /* before: */ PORTAL_ICONS_LAYER_NAME);
const oldRouteSource = map.getSource('old-route');
const oldRouteData = await oldRouteSource.getData();
const customOldRouteGeojsonData = {
type: 'Feature',
geometry: {
type: 'MultiLineString',
coordinates: []
}
};
function newLineStringArray() {
const lineStringArray = [];
customOldRouteGeojsonData.geometry.coordinates.push(lineStringArray);
return lineStringArray;
};
let currentLineStringArray = newLineStringArray();
for (let i = 0; i < oldRouteData.geometry.coordinates.length; i++) {
const currentCoords = oldRouteData.geometry.coordinates[i];
currentLineStringArray.push(currentCoords);
if (i >= 1) {
const [prevLng, prevLat] = oldRouteData.geometry.coordinates[i - 1];
const [thisLng, thisLat] = currentCoords;
if (isTeleport([prevLat, prevLng], [thisLat, thisLng])) {
// Remove teleport line from route, and
// setup new line for the rest of the route.
currentLineStringArray.pop();
currentLineStringArray = newLineStringArray();
currentLineStringArray.push(currentCoords);
addTeleportToMap([prevLat, prevLng], [thisLat, thisLng]);
}
}
lastOldRouteCoord = currentCoords;
}
//console.debug(customOldRouteGeojsonData);
//console.debug(portalIconsSourceGeojsonData);
//console.debug(teleportLinesSourceGeojsonData);
oldRouteSource.setData(customOldRouteGeojsonData);
updateModLayerSources();
}
async function patchRouteSourceAndLayer() {
const routeSource = map.getSource('route');
const customRouteGeojsonData = {
type: 'Feature',
geometry: {
type: 'MultiLineString',
coordinates: [
[]
]
}
};
const ogSourceSetData = routeSource.setData;
routeSource.setData = new Proxy(routeSource.setData, {
apply(ogSetData, thisArg, args) {
const [incomingData, ... restArgs] = args;
const incomingCoordinates = incomingData.geometry.coordinates;
const prevCoords = incomingCoordinates.length > 1
? incomingCoordinates[incomingCoordinates.length - 2]
: lastOldRouteCoord;
const currentCoords = incomingCoordinates[incomingCoordinates.length - 1];
const [prevLng, prevLat] = prevCoords;
const [thisLng, thisLat] = currentCoords;
if (isTeleport([prevLat, prevLng], [thisLat, thisLng])) {
const newCoordinatesArray = [];
customRouteGeojsonData.geometry.coordinates.push(newCoordinatesArray);
newCoordinatesArray.push([thisLng, thisLat]);
addTeleportToMap([prevLat, prevLng], [thisLat, thisLng]);
updateModLayerSources();
} else {
const lastCoordinatesArray = customRouteGeojsonData.geometry.coordinates[customRouteGeojsonData.geometry.coordinates.length - 1];
lastCoordinatesArray.push([thisLng, thisLat]);
}
return ogSetData.apply(thisArg, [customRouteGeojsonData, ... restArgs])
}
})
}
mapVDOM.state.getInitialData = new Proxy(mapVDOM.state.getInitialData, {
apply(ogGetInitialData, thisArg, args) {
const promise = ogGetInitialData.apply(thisArg, args);
promise.then(() => {
resolveTeleportsInOldRoute();
patchRouteSourceAndLayer();
});
return promise;
}
});
/*
mapVDOM.state.setMarkerPosition = new Proxy(mapVDOM.state.setMarkerPosition, {
apply(ogSetMarkerPosition, thisArg, args) {
const result = ogSetMarkerPosition.apply(thisArg, args);
if (isMapLoaded) {
const routeSource = map.getSource('route');
routeSource.getData().then(async (routeSourceGeojsonData) => {
const coordinates = routeSourceGeojsonData.geometry.coordinates;
const prevCoords = coordinates.length > 1
? coordinates[coordinates.length - 2]
: lastOldRouteCoord;
const currentCoords = coordinates[coordinates.length - 1];
const [prevLng, prevLat] = prevCoords;
const [thisLng, thisLat] = currentCoords;
if (isTeleport([prevLat, prevLng], [thisLat, thisLng])) {
// Remove teleport line from route
coordinates.pop();
addTeleportToMap([prevLat, prevLng], [thisLat, thisLng]);
routeSource.setData(routeSourceGeojsonData);
updateModLayerSources();
}
});
}
return result;
}
});
*/
{
const tab = IRF.ui.panel.createTabFor(
{ ... GM.info, script: { ... GM.info.script, name: MOD_NAME } },
{
tabName: MOD_NAME,
style: `
.${cssClass('tab-content')} {
& *, *::before, *::after {
box-sizing: border-box;
}
& .${cssClass('field-group')} {
margin-left: calc(var(${cssProp('field-group-indent')}, 0) * 1rem);
margin-block: 1rem;
gap: 0.25rem;
display: flex;
align-items: center;
justify-content: space-between;
& input[type="range"] {
width: 50%;
}
}
}
`,
className: cssClass('tab-content')
}
);
function makeFieldGroup({ id, label, indent = 0 }, renderInput) {
const fieldGroupEl = document.createElement('div');
fieldGroupEl.className = cssClass('field-group');
if (indent > 0) {
fieldGroupEl.style.setProperty(cssProp('field-group-indent'), indent);
}
const labelEl = document.createElement('label');
labelEl.textContent = label;
fieldGroupEl.append(labelEl);
const inputEl = renderInput({ id });
fieldGroupEl.append(inputEl);
return fieldGroupEl;
}
tab.container.append(
makeFieldGroup(
{
id: `${MOD_PREFIX}portal-icons-show-layer`,
label: 'Portal Icons'
},
({ id }) => {
const inputEl = document.createElement('input');
inputEl.type = 'checkbox';
inputEl.className = IRF.ui.panel.styles.toggle;
inputEl.checked = settings.portalIcons.layer.show;
inputEl.addEventListener('change', async () => {
settings.portalIcons.layer.show = inputEl.checked;
await saveSettings();
updateUiFromSettings();
});
return inputEl;
}
),
makeFieldGroup(
{
id: `${MOD_PREFIX}portal-icons-layer-opacity`,
indent: 1,
label: 'Opacity'
},
({ id }) => {
const inputEl = document.createElement('input');
inputEl.type = 'range';
inputEl.className = IRF.ui.panel.styles.slider;
inputEl.min = 0;
inputEl.max = 1;
inputEl.step = 0.05;
inputEl.value = settings.portalIcons.layer.opacity;
inputEl.addEventListener('input', async () => {
let numberValue = Number.parseFloat(inputEl.value);
if (Number.isNaN(numberValue)) {
return;
}
numberValue = Math.min(Math.max(parseFloat(inputEl.min), numberValue), parseFloat(inputEl.max));
settings.portalIcons.layer.opacity = numberValue;
await saveSettings();
updateUiFromSettings();
});
return inputEl;
}
),
makeFieldGroup(
{
id: `${MOD_PREFIX}teleport-lines-show-layer`,
label: 'Teleport Lines'
},
({ id }) => {
const inputEl = document.createElement('input');
inputEl.type = 'checkbox';
inputEl.className = IRF.ui.panel.styles.toggle;
inputEl.checked = settings.teleportLines.layer.show;
inputEl.addEventListener('change', async () => {
settings.teleportLines.layer.show = inputEl.checked;
await saveSettings();
updateUiFromSettings();
});
return inputEl;
}
),
makeFieldGroup(
{
id: `${MOD_PREFIX}teleport-lines-layer-opacity`,
indent: 1,
label: 'Opacity'
},
({ id }) => {
const inputEl = document.createElement('input');
inputEl.type = 'range';
inputEl.className = IRF.ui.panel.styles.slider;
inputEl.min = 0;
inputEl.max = 1;
inputEl.step = 0.05;
inputEl.value = settings.teleportLines.layer.opacity;
inputEl.addEventListener('input', async () => {
let numberValue = Number.parseFloat(inputEl.value);
if (Number.isNaN(numberValue)) {
return;
}
numberValue = Math.min(Math.max(parseFloat(inputEl.min), numberValue), parseFloat(inputEl.max));
settings.teleportLines.layer.opacity = numberValue;
await saveSettings();
updateUiFromSettings();
});
return inputEl;
}
),
)
}
if (typeof unsafeWindow !== 'undefined') {
unsafeWindow.mtpm = {
get map() { return map },
get portalIconsLayer() { return map.getLayer(PORTAL_ICONS_LAYER_NAME); },
get teleportLinesLayer() { return map.getLayer(TELEPORT_LINES_LAYER_NAME); },
debug: {
mockJumpToCoordsOnMap([lat, lng]) {
mapVDOM.state.setMarkerPosition(lat, lng);
}
}
};
}
})();