Internet Roadtrip - Minimap Teleport Markers

Replace teleport-like jumps on the neal.fun/internet-roadtrip minimap with portal markers

// ==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);
        }
      }
    };
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址