Internet Roadtrip Minimap tricks

Provide some bonus options for the Internet Roadtrip minimap.

目前為 2025-05-27 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Internet Roadtrip Minimap tricks
// @namespace   jdranczewski.github.io
// @match       https://neal.fun/internet-roadtrip/*
// @version     0.1.17
// @author      jdranczewski (+netux +GameRoMan)
// @description Provide some bonus options for the Internet Roadtrip minimap.
// @license     MIT
// @icon         https://neal.fun/favicons/internet-roadtrip.png
// @grant        GM.setValues
// @grant        GM.getValues
// @grant        GM.addStyle
// @run-at      document-end
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// ==/UserScript==

// import * as IRF from 'internet-roadtrip-framework';

(async function() {
    // Get map methods and various objects
    const map = await IRF.vdom.map;
    const ml_map = map.data.map;
    const mapMethods = map.methods;
    const mapContainerEl = await IRF.dom.map;
    const miniMapEl = mapContainerEl.querySelector('#mini-map');
    const expandButtonEl = mapContainerEl.querySelector('.expand-button');

    // Settings
    const settings = {
        "expand_map": true,
        "default_zoom": 12.5,
        "reset_zoom": false,
        "show_scale": true,
        "km_units": false,
        "map_size": {
            width: undefined,
            height: undefined,
            expanded_width: undefined,
            expanded_height: undefined
        },
        "map_opacity": 1,
    }
    const storedSettings = await GM.getValues(Object.keys(settings))
    Object.assign(
        settings,
        storedSettings
    );
    await GM.setValues(settings);

    // Settings panel GUI
    let gm_info = GM.info
    gm_info.script.name = "Minimap tricks"
    const irf_settings = IRF.ui.panel.createTabFor(
        gm_info, {tabName: "Minimap"}
    );
    function add_checkbox(name, identifier, callback=undefined) {
        let label = document.createElement("label");

        let checkbox = document.createElement("input");
        checkbox.type = "checkbox";
        checkbox.checked = settings[identifier];
        checkbox.className = IRF.ui.panel.styles.toggle;
        label.appendChild(checkbox);

        let text = document.createElement("span");
        text.innerText = " " + name;
        label.appendChild(text);

        checkbox.onchange = () => {
            settings[identifier] = checkbox.checked;
            GM.setValues(settings);
            if (callback) callback(checkbox.checked);
        }

        irf_settings.container.appendChild(label);
        irf_settings.container.appendChild(document.createElement("br"));
        irf_settings.container.appendChild(document.createElement("br"));
    }
    add_checkbox("Auto-expand map", "expand_map");
    add_checkbox("Reset zoom with map re-centre", "reset_zoom");

    function add_slider(
        name, identifier, callback=undefined,
        slider_bits=[1, 17, .5]
    ) {
        let label = document.createElement("label");

        let text = document.createElement("span");
        text.innerText = " " + name + ": ";
        label.appendChild(text);

        let value_label = document.createElement("span");
        value_label.innerText = settings[identifier];
        label.appendChild(value_label);

        let slider = document.createElement("input");
        slider.type = "range";
        slider.min = slider_bits[0];
        slider.max = slider_bits[1];
        slider.step = slider_bits[2];
        slider.value = settings[identifier];
        slider.className = IRF.ui.panel.styles.slider;
        label.appendChild(slider);

        slider.oninput = () => {
            settings[identifier] = slider.value;
            value_label.innerText = slider.value;
            GM.setValues(settings);
            if (callback) callback(slider.value);
        }
        slider.onmousedown = (e) => {e.stopPropagation()}

        irf_settings.container.appendChild(label);
        irf_settings.container.appendChild(document.createElement("br"));
        irf_settings.container.appendChild(document.createElement("br"));
    }
    add_slider("Default map zoom", "default_zoom");

    // Fly to a location
    let first_fly = true;
    const zoom_subscription = ml_map.on("moveend", () => {
        if (Math.abs(ml_map.getZoom() - settings.default_zoom) < 0.2) {
            first_fly = false;
            zoom_subscription.unsubscribe();
        }
    })
    function flyTo(map, coords) {
        let args = {
            center: [
                coords[1],
                coords[0]
            ],
            essential: !0
        }
        if (first_fly || settings.reset_zoom) {
            args["zoom"] = settings.default_zoom;
        }
        map.flyTo(args)
    }

	// Proxy the map resetting
	(await IRF.vdom.map).state.flyTo = new Proxy(mapMethods.flyTo, {
		apply: (target, thisArg, args) => {
			Date.now() - thisArg.lastUserInteraction > 30000 &&
            flyTo(thisArg.map, args)
		},
	});

    // Add buttons to the map - define the Control object that holds them
    class TricksControl {
        constructor() {
            this._container = document.createElement('div');
        }

        onAdd(map) {
            this._map = map;
            this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
            return this._container;
        }

        onRemove() {
            this._container.parentNode.removeChild(this._container);
            this._map = undefined;
        }

        addButton(icon, callback) {
            let button = document.createElement("button");
            let button_icon = document.createElement("span");
            button_icon.className = "maplibregl-ctrl-icon";
            button_icon.style.backgroundImage = `url("${icon}")`;
            button_icon.style.backgroundSize = "contain";
            button.appendChild(button_icon);
            button.onclick = callback;
            this._container.appendChild(button);
        }
    }

    // Define map controls to add buttons for
    let control = new TricksControl();

    control.addButton(
        "https://storage.googleapis.com/support-kms-prod/SNP_E2308F5561BE1525D2C88838252137BC5634_4353424_en_v0",
        async () => {
            let data = (await IRF.vdom.container).data;
            // URL pattern from https://roadtrip.pikarocks.dev/
            const url = (
                "https://www.google.com/maps/@?api=1&map_action=pano" +
                `&viewpoint=${data.currentCoords.lat},${data.currentCoords.lng}` +
                `&pano=${data.currentPano}&heading=${data.currentHeading}` +
                "&fov=90"
            )
		    window.open(url, "_blank")
        }
    );

    control.addButton(
        "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xml%3Aspace%3D%22preserve%22%20width%3D%22122.9%22%20height%3D%22122.9%22%3E%3Cpath%20d%3D%22M24.7%2062.4c1.8%201.6%203.6%203.2%205.4%204.6%202.4-3.6%205-7%208-10a7.2%207.2%200%200%201-.7-5.2c-3-1.9-6-4-9.2-6.4a38%2038%200%200%200-3.7%2017.3zm5.2-20.3c3.2%202.4%206.3%204.6%209.2%206.5a7.2%207.2%200%200%201%209.7-.8%2058.2%2058.2%200%200%201%2014.8-7%208%208%200%200%201%20.6-4c-4.4-3.8-9.6-7-15.6-10A37%2037%200%200%200%2029.9%2042zm23.7-16.8a75%2075%200%200%201%2012.7%208.5%208%208%200%200%201%204.6-2L72%2026a37%2037%200%200%200-18.4-.7zm21.9%202-1%205c2.4%201%204.3%203%205%205.5%203.3-.3%206.7-.3%2010.2-.1a37.7%2037.7%200%200%200-14.2-10.5zm17%2014.2c-4.5-.4-8.8-.4-12.9%200a8%208%200%200%201-2.5%204.4%2049%2049%200%200%201%206%2013.3h.8c3.3%200%206%202%207%205l7.4.2.1-3c0-7.3-2.1-14.2-5.9-20zM97.8%2068l-6.8-.1c-.6%202.8-2.8%205-5.6%205.6.1%203.3%200%206.9-.4%2010.6%202-.2%204.2-.5%206.3-1%203.3-4.3%205.5-9.5%206.5-15.1zm-4.4%2018.4a40.5%2040.5%200%200%201-32%2015.6A40.5%2040.5%200%200%201%2021%2061.4%2040.5%2040.5%200%200%201%2061.4%2021%2040.5%2040.5%200%200%201%20102%2061.4a40%2040%200%200%201-8.6%2025zm-5.7%201-3.1.4-.5%202.8%203.5-3zm-7.8%206%201-5.4c-6.6.4-13%200-19.1-1.4a6.5%206.5%200%200%201-5.4%202.3L53%2097.5c2.7.6%205.5%201%208.3%201v-.1c6.8%200%2013-1.8%2018.5-5zm-30.3%203%203.4-8.7a6.5%206.5%200%200%201-2.7-4.5%2086%2086%200%200%201-19.2-10.9l-3%205.3a37.1%2037.1%200%200%200%2021.5%2018.9zM26.4%2073.3l1.8-3.1-3.2-2.6c.3%202%20.8%203.9%201.4%205.7zM51%2050.7a7.2%207.2%200%200%201%20.4%204.9c4%201.9%207.9%203.4%2011.8%204.6a261%20261%200%200%200%204.1-13.5c-1-.6-1.8-1.5-2.5-2.5A55.5%2055.5%200%200%200%2051%2050.7zm-1.5%208a7.2%207.2%200%200%201-9%201c-2.7%202.8-5.2%206-7.5%209.5a83.2%2083.2%200%200%200%2018%2010.3%206.5%206.5%200%200%201%206.5-3.6c1.6-4%203-8%204.5-12.3a85.6%2085.6%200%200%201-12.5-4.9zm24.4-11a8.1%208.1%200%200%201-3.1.2l-4%2013.3c3.4.8%207%201.5%2010.6%202%20.6-1.1%201.4-2%202.4-2.7a46.8%2046.8%200%200%200-5.9-12.8zm7.8%2025.6a7.2%207.2%200%200%201-5-6.6c-3.8-.5-7.5-1.2-11.1-2a419%20419%200%200%201-4.7%2012.6%206.5%206.5%200%200%201%202.4%206%2070.3%2070.3%200%200%200%2018%201c.4-3.8.6-7.5.4-11z%22%20style%3D%22fill%3A%235fbdff%3Bfill-opacity%3A1%3Bstroke-width%3A.660746%22%2F%3E%3C%2Fsvg%3E",
        async () => {
            let data = (await IRF.vdom.container).data;
            // URL pattern from https://roadtrip.pikarocks.dev/
            const url = (
                "https://sv-map.netlify.app/#base=roadmap&cov=all&" +
                `panos=&zoom=${ml_map.getZoom()+1}&center=${data.currentCoords.lat}%2C${data.currentCoords.lng}`
            )
		    window.open(url, "_blank")
        }
    );

    control.addButton(
        "data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='29' height='29' fill='%23333' viewBox='0 0 20 20'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1m0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E",
        async () => {
            let coords = (await IRF.vdom.container).data.currentCoords;
            flyTo(
                ml_map,
                [coords.lat, coords.lng]
            )
        }
    );

    ml_map.addControl(control, "bottom-left");

    // Add a scale bar
    const scale_control = new (await IRF.modules.maplibre).ScaleControl({
        unit: (await IRF.vdom.odometer).data.isKilometers ? "metric": "imperial"
    })
    ml_map.addControl(scale_control, "bottom-right");
    scale_control._container.style.margin = "0px 36px 5px 0px";
    scale_control._container.style.display = settings.show_scale ? "block" : "none";

    // Sync the scale bar units to the odometer
    // Get the original setter
    const { set: isKilometersSetter } = Object.getOwnPropertyDescriptor((await IRF.vdom.odometer).state, 'isKilometers');
    // Override the setter
    Object.defineProperty((await IRF.vdom.odometer).state, 'isKilometers', {
        set(isKilometers) {
            // Set the units on the scale bar
            scale_control.setUnit(isKilometers ? "metric": "imperial");
            return isKilometersSetter.call(this, isKilometers);
        },
        configurable: true,
        enumerable: true,
    });

    add_checkbox("Show map scale", "show_scale", (show) => {
        scale_control._container.style.display = show ? "block" : "none";
    })

    // Default to kilometres if desired
    if (settings.km_units) {
        (await IRF.vdom.odometer).state.isKilometers = true;
    }
    add_checkbox("Use metric units", "km_units", async (value) => {
        (await IRF.vdom.odometer).state.isKilometers = value;
    })

    // Map opacity
    mapContainerEl.style.opacity = settings.map_opacity;
    add_slider("Map opacity", "map_opacity", (value) => {
        mapContainerEl.style.opacity = value;
    }, [0, 1, 0.05]);

    // Resizeable minimap - styles
    const DESKTOP_UI_MEDIA = `(min-width: 900px)`;

    GM.addStyle(`
    @media ${DESKTOP_UI_MEDIA} {
        .map-container {
            & .expand-button {
                cursor: nesw-resize;
                display: flex !important;
            }

            & #mini-map {
                position: relative;
                width: var(--map-width, 250px) !important;
                height: var(--map-height, 170px) !important;
            }
        }
        .expanded #mini-map {
            width: var(--map-width-expanded, 450px) !important;
            height: var(--map-height-expanded, 300px) !important;
        }
        .expanded .expand-button img {
            rotate: 180deg;
        }
    }
    .map-container {
        transition: opacity .5s;
        &:hover {
            opacity: 1 !important;
        }
    }
    `);

    // Set the variables for map resizing if not undefined

    function setMiniMapSize({ width, height, expanded_width, expanded_height }) {
        miniMapEl.style.setProperty('--map-width', width ? `${Math.min(Math.max(0, width), 100)}vw` : "");
        miniMapEl.style.setProperty('--map-height', height ? `${Math.min(Math.max(0, height), 100)}vh` : "");
        miniMapEl.style.setProperty('--map-width-expanded', expanded_width ? `${Math.min(Math.max(0, expanded_width), 100)}vw` : "");
        miniMapEl.style.setProperty('--map-height-expanded', expanded_height ? `${Math.min(Math.max(0, expanded_height), 100)}vh` : "");
    }

    // Handle the dragging logic for resizing

    let isClicked = false; // Clicked determines if we should be listening to mousemove
    let isResizing = false; // Resizing determines if the expanded state should be switched
    let lastX, lastY;

    expandButtonEl.addEventListener('mousedown', (e) => {
        isClicked = true;
        lastX = e.clientX;
        lastY = e.clientY;
        e.preventDefault();
    });

    document.addEventListener('mousemove', (e) => {
        if (!isClicked) return;
        if (e.buttons == 0) {
            isClicked = false;
            isResizing = false;
            return;
        }

        const deltaX = e.clientX - lastX;
        const deltaY = e.clientY - lastY;

        // Set the resizing flag if we moved
        // The call to switch expanded state will then not be sent
        isResizing = true;

        const currentSizePx = {
            width: miniMapEl.offsetWidth,
            height: miniMapEl.offsetHeight
        };

        const e_mod = mapContainerEl.classList.contains("expanded") ? "expanded_" : "";
        settings.map_size[e_mod+"width"] = (currentSizePx.width + deltaX) / window.innerWidth * 100
        settings.map_size[e_mod+"height"] = (currentSizePx.height - deltaY) / window.innerHeight * 100

        setMiniMapSize(settings.map_size);
        GM.setValues(settings);

        lastX = e.clientX;
        lastY = e.clientY;
    });

    document.addEventListener('mouseup', (e) => {
        isClicked = false;
    });

    // Override toggleExpand, which always fires on mouseup on the expand button
    map.state.toggleExpand = new Proxy(map.methods.toggleExpand, {
        apply(ogToggleExpand, thisArg, args) {
            if (isResizing) {
                isResizing = false;
                return;
            }
            isClicked = false;

            return ogToggleExpand.apply(thisArg, args);
        }
    });

    setMiniMapSize(settings.map_size);
    ml_map.resize();

    // Automatically expand the map
    if (window.innerWidth > 900 && settings.expand_map) {
        map.state.isExpanded = true;
    }
    ml_map.on('load', () => {
        // Redraw when loaded, as map.state.isExpanded is not immediate
        ml_map.resize();
    });

    // Add UI button to reset map scale
    {
        let button = document.createElement("button");
        button.innerText = "Reset map size";
        button.onclick = () => {
            settings.map_size = {};
            GM.setValues(settings);
            setMiniMapSize(settings.map_size);
        }
        irf_settings.container.appendChild(button);
    }

})();