Internet Roadtrip Minimap tricks

Provide some bonus options for the Internet Roadtrip minimap.

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

})();