WME StR Simplified

Convert a selected WME segment into a River / Stream area venue.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         WME StR Simplified
// @namespace    https://greasyfork.org/en/users/1614344-alanoftheberg
// @version      2026.06.22.03
// @description  Convert a selected WME segment into a River / Stream area venue.
// @author       AlanOfTheBerg
// @match        https://www.waze.com/*editor*
// @match        https://beta.waze.com/*editor*
// @grant        GM_xmlhttpRequest
// @grant        GM_info
// @grant        unsafeWindow
// @connect      update.greasyfork.org
// @connect      greasyfork.org
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";
    // Granting GM_xmlhttpRequest moves Tampermonkey execution into a sandbox.
    // Bridge WME page globals back into that sandbox so the script can still see WME SDK globals.
    const WME_PAGE = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
    ["getWmeSdk", "SDK_INITIALIZED"].forEach(function (key) {
        try {
            Object.defineProperty(window, key, {
                configurable: true,
                enumerable: false,
                get: function () { return WME_PAGE[key]; }
            });
        } catch (ignored) {}
    });
    function getInstalledScriptVersion() {
        try {
            if (typeof GM_info !== "undefined" && GM_info.script && GM_info.script.version) {
                return GM_info.script.version;
            }
        } catch (ignored) {}
        return "unknown";
    }

    const SCRIPT_ID = "wme-str-street-to-river-simplified";
    const SCRIPT_NAME = "WME StR Simplified";
    const SCRIPT_VERSION = getInstalledScriptVersion();

    const DEFAULT_WIDTH_METERS = 12;
    const DEFAULT_MITER_LIMIT = 4;

    const IDS = {
        root: "wme-str-root",
        selectedPanel: "wme-str-selected-panel",
        noSelectionPanel: "wme-str-no-selection-panel",
        widthInput: "wme-str-width-input",
        deleteCheckbox: "wme-str-delete-checkbox",
        riverButton: "wme-str-river-button",
        canalButton: "wme-str-canal-button",
        status: "wme-str-status",
        selectedSegment: "wme-str-selected-segment",
        selectedSegmentLength: "wme-str-selected-segment-length",
        selectedSegmentVertices: "wme-str-selected-segment-vertices"
    };

    let wmeSDK = null;
    let ui = null;

    waitForWmeSdk()
        .then((sdk) => {
            wmeSDK = sdk;
            return initializeScript();
        })
        .catch((error) => {
            console.error(`${SCRIPT_NAME}: failed to initialize`, error);
        });

    function waitForWmeSdk() {
        return new Promise((resolve, reject) => {
            const timeoutMs = 30000;
            const startedAt = Date.now();

            const check = () => {
                try {
                    if (
                        window.SDK_INITIALIZED &&
                        typeof window.SDK_INITIALIZED.then === "function" &&
                        typeof window.getWmeSdk === "function"
                    ) {
                        window.SDK_INITIALIZED
                            .then(() => {
                                const sdk = window.getWmeSdk({
                                    scriptId: SCRIPT_ID,
                                    scriptName: SCRIPT_NAME
                                });
                                resolve(sdk);
                            })
                            .catch(reject);
                        return;
                    }

                    if (Date.now() - startedAt > timeoutMs) {
                        reject(new Error("Timed out waiting for WME SDK. Make sure this is running in WME."));
                        return;
                    }

                    window.setTimeout(check, 250);
                } catch (error) {
                    reject(error);
                }
            };

            check();
        });
    }

    async function initializeScript() {
        injectStyles();
        await createSidebarTab();

        wmeSDK.Events.on({
            eventName: "wme-selection-changed",
            eventHandler: updateUiForSelection
        });

        updateUiForSelection();

        console.log(`${SCRIPT_NAME}: initialized`);
    }

    async function createSidebarTab() {
        const section = document.createElement("section");
        section.id = IDS.root;

        section.innerHTML = `
            <div class="str-header">
                <h3>StR Simplified</h3>
                <div class="str-version">VERSION ${SCRIPT_VERSION}</div>
                <p>Converts a selected segment into a River / Stream area venue.</p>
            </div>

            <div id="${IDS.noSelectionPanel}" class="str-card">
                <strong>No segment selected</strong>
                <p>Select exactly one segment to enable the conversion button.</p>
            </div>

            <div id="${IDS.selectedPanel}" class="str-card" style="display:none;">
                <div class="str-field">
                    <label>Selected segment</label>

                    <div class="str-detail-grid">
                        <div class="str-detail-label">ID</div>
                        <div id="${IDS.selectedSegment}" class="str-detail-value"></div>

                        <div class="str-detail-label">Length</div>
                        <div id="${IDS.selectedSegmentLength}" class="str-detail-value"></div>

                        <div class="str-detail-label">Vertices</div>
                        <div id="${IDS.selectedSegmentVertices}" class="str-detail-value"></div>
                    </div>
                </div>

                <div class="str-field">
                    <label for="${IDS.widthInput}">River width, meters</label>
                    <input
                        id="${IDS.widthInput}"
                        type="number"
                        min="1"
                        step="1"
                        value="${DEFAULT_WIDTH_METERS}"
                    />
                </div>

                <label class="str-checkbox">
                    <input id="${IDS.deleteCheckbox}" type="checkbox" />
                    Delete source segment after creating venue
                </label>

                <div class="str-button-row">
                    <button id="${IDS.riverButton}" type="button">Make river</button>
                    <button id="${IDS.canalButton}" type="button">Make canal</button>
                </div>

                <div id="${IDS.status}" class="str-status"></div>
            </div>
        `;

        const { tabLabel, tabPane } = await wmeSDK.Sidebar.registerScriptTab();

        tabLabel.textContent = "StR";
        tabLabel.title = SCRIPT_NAME;

        tabPane.appendChild(section);

        ui = {
            root: section,
            noSelectionPanel: section.querySelector(`#${IDS.noSelectionPanel}`),
            selectedPanel: section.querySelector(`#${IDS.selectedPanel}`),
            selectedSegment: section.querySelector(`#${IDS.selectedSegment}`),
            selectedSegmentLength: section.querySelector(`#${IDS.selectedSegmentLength}`),
            selectedSegmentVertices: section.querySelector(`#${IDS.selectedSegmentVertices}`),
            widthInput: section.querySelector(`#${IDS.widthInput}`),
            deleteCheckbox: section.querySelector(`#${IDS.deleteCheckbox}`),
            riverButton: section.querySelector(`#${IDS.riverButton}`),
            canalButton: section.querySelector(`#${IDS.canalButton}`),
            status: section.querySelector(`#${IDS.status}`)
        };

        ui.riverButton.addEventListener("click", () => handleStreetToWaterClick("river_stream"));
        ui.canalButton.addEventListener("click", () => handleStreetToWaterClick("canal"));
    }

    function updateUiForSelection() {
        if (!ui) return;

        const selectedSegment = tryGetSingleSelectedSegment();

        if (!selectedSegment) {
            ui.noSelectionPanel.style.display = "block";
            ui.selectedPanel.style.display = "none";
            ui.status.textContent = "";
            return;
        }

        ui.noSelectionPanel.style.display = "none";
        ui.selectedPanel.style.display = "block";

        ui.selectedSegment.textContent = String(selectedSegment.id);
        ui.selectedSegmentLength.textContent = formatSegmentLength(selectedSegment);
        ui.selectedSegmentVertices.textContent = String(getSegmentVertexCount(selectedSegment));
        ui.status.textContent = "";
    }

    function setStatus(message, type = "info") {
        if (!ui || !ui.status) return;

        ui.status.textContent = message;
        ui.status.className = `str-status str-status-${type}`;
    }


    function setActionButtonsDisabled(disabled) {
        if (!ui) return;
        if (ui.riverButton) ui.riverButton.disabled = disabled;
        if (ui.canalButton) ui.canalButton.disabled = disabled;
    }

    async function handleStreetToWaterClick(categoryType) {
        if (!ui) return;

        try {
            setActionButtonsDisabled(true);
            setStatus(`Creating ${getCategoryDisplayName(categoryType)} area...`, "info");

            const widthMeters = Number(ui.widthInput.value);
            const deleteOriginalSegment = Boolean(ui.deleteCheckbox.checked);

            if (!Number.isFinite(widthMeters) || widthMeters <= 0) {
                throw new Error("Enter a valid positive width in meters.");
            }

            const result = createWaterAreaFromSelectedSegment({
                widthMeters,
                deleteOriginalSegment,
                categoryType
            });

            setStatus(`Created ${getCategoryDisplayName(categoryType)} venue ${result.venueId}. Save your edits when ready.`, "success");

            console.log(`${SCRIPT_NAME}: created ${getCategoryDisplayName(categoryType)} venue`, result);

            updateUiForSelection();
        } catch (error) {
            console.error(`${SCRIPT_NAME}: conversion failed`, error);
            setStatus(error.message || String(error), "error");
        } finally {
            setActionButtonsDisabled(false);
        }
    }

function injectStyles() {
    const styleId = "wme-str-style";

    if (document.getElementById(styleId)) return;

    const css = `
#${IDS.root} {
    padding: 22px 10px 10px 10px;
    font-family: inherit;
    font-size: 13px;
    line-height: 1.35;
    color: inherit;
}

#${IDS.root} .str-header {
    margin: 0 0 10px 0;
}

#${IDS.root} .str-header h3 {
    margin: 0 0 8px 0;
    font-size: 16px;
    font-weight: 600;
    line-height: 1.25;
}

#${IDS.root} .str-version {
    margin: 0px 0 8px 0;
    font-size: 10px;
    line-height: 1.2;
    color: #777;
    font-weight: 600;
    letter-spacing: 0.6px;
}

#${IDS.root} .str-header p {
    margin: 8px 0 0 0;
    font-size: 12px;
    line-height: 1.35;
    opacity: 0.75;
}

        #${IDS.root} .str-card {
            padding: 0;
            margin: 0 0 12px 0;
            background: transparent;
            border: 0;
        }

        #${IDS.root} .str-card p {
            margin: 4px 0 0 0;
            font-size: 12px;
            line-height: 1.35;
            opacity: 0.75;
        }

        #${IDS.root} .str-field {
            margin-bottom: 12px;
        }

        #${IDS.root} .str-field > label {
            display: block;
            margin-bottom: 4px;
            font-size: 12px;
            font-weight: 600;
            line-height: 1.25;
        }

        #${IDS.root} input[type="number"] {
            width: 100%;
            box-sizing: border-box;
            min-height: 30px;
            padding: 4px 8px;
            font: inherit;
        }

        #${IDS.root} .str-detail-grid {
            display: grid;
            grid-template-columns: 72px 1fr;
            gap: 4px 8px;
            padding: 8px;
            border-radius: 4px;
            background: rgba(0, 0, 0, 0.04);
        }

        #${IDS.root} .str-detail-label {
            font-size: 12px;
            font-weight: 600;
            opacity: 0.75;
        }

        #${IDS.root} .str-detail-value {
            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
            font-size: 12px;
            overflow-wrap: anywhere;
        }

        #${IDS.root} .str-checkbox {
            display: flex;
            align-items: flex-start;
            gap: 6px;
            margin: 4px 0 12px 0;
            font-size: 12px;
            line-height: 1.35;
        }

        #${IDS.root} .str-checkbox input {
            margin-top: 2px;
        }


        #${IDS.root} .str-button-row {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 6px;
            margin-top: 4px;
        }
        #${IDS.root} .str-button-row button {
           min-height: 32px;
           padding: 6px 8px;
           border: 1px solid #0078d4;
           border-radius: 4px;
           background: #0078d4;
           color: #fff;
           font: inherit;
           font-weight: 600;
           cursor: pointer;
        }
        #${IDS.root} .str-button-row button:hover:not(:disabled),
        #${IDS.root} .str-button-row button:focus:not(:disabled) {
            background: #106ebe;
            border-color: #106ebe;
        }
        #${IDS.root} .str-button-row button:disabled {
            opacity: 0.6;
            cursor: default;
        }
        #${IDS.root} .str-status {
            margin-top: 10px;
            font-size: 12px;
            line-height: 1.35;
        }

        #${IDS.root} .str-status-info {
            opacity: 0.75;
        }

        #${IDS.root} .str-status-success {
            color: #107c10;
        }

        #${IDS.root} .str-status-error {
            color: #a80000;
        }
    `;

    if ("adoptedStyleSheets" in document && "replaceSync" in CSSStyleSheet.prototype) {
        const sheet = new CSSStyleSheet();
        sheet.replaceSync(css);
        document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];

        const marker = document.createElement("meta");
        marker.id = styleId;
        marker.dataset.source = SCRIPT_ID;
        document.head.appendChild(marker);
        return;
    }

    const style = document.createElement("style");
    style.id = styleId;
    style.textContent = css;
    document.head.appendChild(style);
}

    function createWaterAreaFromSelectedSegment({
        widthMeters,
        deleteOriginalSegment,
        categoryType
    }) {
        const selectedSegment = getSingleSelectedSegment();
        const venueCategoryId = getVenueCategoryId(categoryType);

        const polygon = segmentToCenteredAreaPolygon(selectedSegment, widthMeters, {
            miterLimit: DEFAULT_MITER_LIMIT
        });

        const venueId = wmeSDK.DataModel.Venues.addVenue({
            category: venueCategoryId,
            geometry: polygon
        });

        if (deleteOriginalSegment) {
            wmeSDK.DataModel.Segments.deleteSegment({
                segmentId: selectedSegment.id
            });
        }

        return {
            sourceSegmentId: selectedSegment.id,
            venueId,
            category: venueCategoryId,
            deletedSourceSegment: deleteOriginalSegment,
            polygon
        };
    }

    function getSingleSelectedSegment() {
        const segment = tryGetSingleSelectedSegment();

        if (!segment) {
            throw new Error("Select exactly one segment first.");
        }

        return segment;
    }

    function tryGetSingleSelectedSegment() {
        const selection = wmeSDK.Editing.getSelection();

        if (!selection || !Array.isArray(selection.ids) || selection.ids.length !== 1) {
            return null;
        }

        const objectType = String(selection.objectType || "").toLowerCase();

        if (!objectType.includes("segment")) {
            return null;
        }

        const segmentId = Number(selection.ids[0]);

        if (!Number.isFinite(segmentId)) {
            return null;
        }

        return wmeSDK.DataModel.Segments.getById({ segmentId });
    }

    function getVenueCategoryId(categoryType) {
        if (categoryType === "canal") {
            return getVenueCategoryIdByTargets(["canal"], "Canal");
        }
        return getVenueCategoryIdByTargets([
            "river / stream",
            "river/stream",
            "river stream",
            "river",
            "stream"
        ], "River / Stream");
    }

    function getCategoryDisplayName(categoryType) {
        return categoryType === "canal" ? "Canal" : "River / Stream";
    }

    function getVenueCategoryIdByTargets(normalizedTargets, displayName) {
        const categories = wmeSDK.DataModel.Venues.getAllVenueCategories();
        const match = categories.find((category) => {
            const valuesToCheck = [
                category.id,
                category.name,
                category.localizedName
            ].map(normalizeCategoryText);
            return normalizedTargets.some((target) => {
                const normalizedTarget = normalizeCategoryText(target);
                return valuesToCheck.some((value) =>
                    value === normalizedTarget ||
                    value.includes(normalizedTarget)
                );
            });
        });
        if (!match) {
            console.warn(
                `${SCRIPT_NAME}: Could not find ${displayName} category. Available categories:`,
                categories
            );
            throw new Error(`Could not find the ${displayName} venue category. See console for available categories.`);
        }
        return match.id;
    }

    function normalizeCategoryText(value) {
        return String(value || "")
            .trim()
            .toLowerCase()
            .replace(/[_-]+/g, " ")
            .replace(/\s*\/\s*/g, " / ")
            .replace(/\s+/g, " ");
    }

    function formatSegmentLength(segment) {
        const lengthMeters = Number(segment.length);

        if (!Number.isFinite(lengthMeters)) {
            return "Unknown";
        }

        const userSettings = wmeSDK.Settings.getUserSettings();
        const isImperial = Boolean(userSettings && userSettings.isImperial);

        if (isImperial) {
            const feet = lengthMeters * 3.280839895;

            if (feet >= 5280) {
                return `${formatLengthNumber(feet / 5280)} mi`;
            }

            return `${formatLengthNumber(feet)} ft`;
        }

        if (lengthMeters >= 1000) {
            return `${formatLengthNumber(lengthMeters / 1000)} km`;
        }

        return `${formatLengthNumber(lengthMeters)} m`;
    }

    function formatLengthNumber(value) {
        if (value >= 100) return value.toFixed(0);
        if (value >= 10) return value.toFixed(1);
        return value.toFixed(2);
    }

    function getSegmentVertexCount(segment) {
        if (
            !segment ||
            !segment.geometry ||
            segment.geometry.type !== "LineString" ||
            !Array.isArray(segment.geometry.coordinates)
        ) {
            return 0;
        }

        return segment.geometry.coordinates.length;
    }

    function segmentToCenteredAreaPolygon(segment, widthMeters, options = {}) {
        const miterLimit = options.miterLimit ?? 4.0;
        const minSegmentLengthMeters = options.minSegmentLengthMeters ?? 0.05;

        if (!segment || !segment.geometry || segment.geometry.type !== "LineString") {
            throw new Error("Expected a WME Segment with GeoJSON LineString geometry.");
        }

        const coordinates = segment.geometry.coordinates;

        if (!Array.isArray(coordinates) || coordinates.length < 2) {
            throw new Error("Segment geometry must contain at least two coordinates.");
        }

        if (!Number.isFinite(widthMeters) || widthMeters <= 0) {
            throw new Error("widthMeters must be a positive number.");
        }

        const halfWidth = widthMeters / 2;
        const projection = createLocalProjection(coordinates);

        const sourcePoints = coordinates.map(([lon, lat]) =>
            projection.toXY(lon, lat)
        );

        const points = removeConsecutiveDuplicatePoints(
            sourcePoints,
            minSegmentLengthMeters
        );

        if (points.length < 2) {
            throw new Error("Segment has no usable non-zero-length geometry.");
        }

        const offsetSegments = [];

        for (let i = 0; i < points.length - 1; i++) {
            const p0 = points[i];
            const p1 = points[i + 1];

            const dx = p1.x - p0.x;
            const dy = p1.y - p0.y;
            const len = Math.hypot(dx, dy);

            if (len <= minSegmentLengthMeters) {
                continue;
            }

            const ux = dx / len;
            const uy = dy / len;

            const leftNormal = { x: -uy, y: ux };
            const rightNormal = { x: uy, y: -ux };

            offsetSegments.push({
                p0,
                p1,
                leftLine: {
                    a: addScaled(p0, leftNormal, halfWidth),
                    b: addScaled(p1, leftNormal, halfWidth)
                },
                rightLine: {
                    a: addScaled(p0, rightNormal, halfWidth),
                    b: addScaled(p1, rightNormal, halfWidth)
                }
            });
        }

        if (offsetSegments.length === 0) {
            throw new Error("Segment has no usable non-zero-length segments.");
        }

        const leftOffsets = [];
        const rightOffsets = [];

        leftOffsets.push(offsetSegments[0].leftLine.a);
        rightOffsets.push(offsetSegments[0].rightLine.a);

        for (let i = 1; i < offsetSegments.length; i++) {
            const previousSegment = offsetSegments[i - 1];
            const nextSegment = offsetSegments[i];
            const originalVertex = previousSegment.p1;

            appendJoinPoint({
                outputPoints: leftOffsets,
                previousOffsetLine: previousSegment.leftLine,
                nextOffsetLine: nextSegment.leftLine,
                originalVertex,
                halfWidth,
                miterLimit
            });

            appendJoinPoint({
                outputPoints: rightOffsets,
                previousOffsetLine: previousSegment.rightLine,
                nextOffsetLine: nextSegment.rightLine,
                originalVertex,
                halfWidth,
                miterLimit
            });
        }

        const lastSegment = offsetSegments[offsetSegments.length - 1];

        leftOffsets.push(lastSegment.leftLine.b);
        rightOffsets.push(lastSegment.rightLine.b);

        const ringXY = leftOffsets.concat([...rightOffsets].reverse());

        const cleanedRingXY = removeConsecutiveDuplicatePoints(
            ringXY,
            minSegmentLengthMeters
        );

        if (cleanedRingXY.length < 3) {
            throw new Error("Generated polygon is invalid; fewer than three unique points.");
        }

        const ringLonLat = cleanedRingXY.map((point) => {
            const lonLat = projection.toLonLat(point.x, point.y);
            return [lonLat.lon, lonLat.lat];
        });

        closeGeoJsonRing(ringLonLat);

        return {
            type: "Polygon",
            coordinates: [ringLonLat]
        };
    }

    function appendJoinPoint({
        outputPoints,
        previousOffsetLine,
        nextOffsetLine,
        originalVertex,
        halfWidth,
        miterLimit
    }) {
        const intersection = intersectInfiniteLines(
            previousOffsetLine.a,
            previousOffsetLine.b,
            nextOffsetLine.a,
            nextOffsetLine.b
        );

        const maxMiterDistance = halfWidth * miterLimit;

        if (
            intersection &&
            distance(intersection, originalVertex) <= maxMiterDistance
        ) {
            outputPoints.push(intersection);
            return;
        }

        outputPoints.push(previousOffsetLine.b);
        outputPoints.push(nextOffsetLine.a);
    }

    function addScaled(point, vector, scale) {
        return {
            x: point.x + vector.x * scale,
            y: point.y + vector.y * scale
        };
    }

    function distance(a, b) {
        return Math.hypot(a.x - b.x, a.y - b.y);
    }

    function intersectInfiniteLines(a, b, c, d) {
        const r = {
            x: b.x - a.x,
            y: b.y - a.y
        };

        const s = {
            x: d.x - c.x,
            y: d.y - c.y
        };

        const denominator = cross(r, s);

        if (Math.abs(denominator) < 1e-12) {
            return null;
        }

        const cMinusA = {
            x: c.x - a.x,
            y: c.y - a.y
        };

        const t = cross(cMinusA, s) / denominator;

        return {
            x: a.x + t * r.x,
            y: a.y + t * r.y
        };
    }

    function cross(a, b) {
        return a.x * b.y - a.y * b.x;
    }

    function removeConsecutiveDuplicatePoints(points, toleranceMeters) {
        const cleaned = [];

        for (const point of points) {
            if (
                cleaned.length === 0 ||
                distance(cleaned[cleaned.length - 1], point) > toleranceMeters
            ) {
                cleaned.push(point);
            }
        }

        return cleaned;
    }

    function closeGeoJsonRing(ring) {
        if (ring.length === 0) return;

        const first = ring[0];
        const last = ring[ring.length - 1];

        if (first[0] !== last[0] || first[1] !== last[1]) {
            ring.push([first[0], first[1]]);
        }
    }

    function createLocalProjection(lonLatCoordinates) {
        const earthRadiusMeters = 6378137;

        const averageLon =
            lonLatCoordinates.reduce((sum, coordinate) => sum + coordinate[0], 0) /
            lonLatCoordinates.length;

        const averageLat =
            lonLatCoordinates.reduce((sum, coordinate) => sum + coordinate[1], 0) /
            lonLatCoordinates.length;

        const lon0 = degreesToRadians(averageLon);
        const lat0 = degreesToRadians(averageLat);
        const cosLat0 = Math.cos(lat0);

        return {
            toXY(lon, lat) {
                const lonRad = degreesToRadians(lon);
                const latRad = degreesToRadians(lat);

                return {
                    x: earthRadiusMeters * (lonRad - lon0) * cosLat0,
                    y: earthRadiusMeters * (latRad - lat0)
                };
            },

            toLonLat(x, y) {
                const lonRad = x / (earthRadiusMeters * cosLat0) + lon0;
                const latRad = y / earthRadiusMeters + lat0;

                return {
                    lon: radiansToDegrees(lonRad),
                    lat: radiansToDegrees(latRad)
                };
            }
        };
    }

    function degreesToRadians(degrees) {
        return degrees * Math.PI / 180;
    }

    function radiansToDegrees(radians) {
        return radians * 180 / Math.PI;
    }
})();
// ===== Update Indicator =====
(function () {
    const META_URL = "https://update.greasyfork.org/scripts/583361.meta.js";
    const SCRIPT_URL = "https://greasyfork.org/en/scripts/583361-wme-str-simplified";
    const REMIND_KEY = "WMESTR_UpdateRemindUntil";
    function getCurrentVersion() { try { if (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) return GM_info.script.version; } catch (ignored) {} return ''; }
    function versionToNumber(version) { return Number(String(version || '').replace(/\./g, '')); }
    function isNewerVersion(latest, current) {
        const latestNumber = versionToNumber(latest);
        const currentNumber = versionToNumber(current);
        return Number.isFinite(latestNumber) && Number.isFinite(currentNumber) && latestNumber > currentNumber;
    }
    function checkUpdate() {
        try {
            const remindUntil = localStorage.getItem(REMIND_KEY);
            if (remindUntil && Date.now() < Number(remindUntil)) return;
            if (typeof GM_xmlhttpRequest !== 'function') return;
            GM_xmlhttpRequest({ method: 'GET', url: META_URL, onload: function (res) {
                const match = res.responseText.match(/@version\s+(.+)/);
                if (!match) return;
                const latest = match[1].trim();
                const current = getCurrentVersion();
                if (!current) return;
                if (isNewerVersion(latest, current)) showUI(latest);
            }});
        } catch (e) { console.warn('StR update check failed', e); }
    }
    function showUI(version) {
        const root = document.querySelector("#wme-str-root");
        if (!root) return;
        let el = document.querySelector("#str-update-inline");
        if (!el) {
            el = document.createElement("div");
            el.id = "str-update-inline";
            el.style.margin = "0px 0 6px 0";
            el.style.fontSize = "11px";
            const versionEl = root.querySelector(".str-version");
            if (versionEl && versionEl.parentNode) versionEl.insertAdjacentElement("afterend", el);
            else root.prepend(el);
        }
        el.innerHTML = `
            <div>
                <span style="color:#0b5c20; font-weight:600;">Update available</span> (${version})
            </div>
            <div>
                <a href="#" id="str-update-now">Update now</a>
                &nbsp;|&nbsp;
                <a href="#" id="str-update-later">Remind me later</a>
            </div>
        `;
        el.querySelector("#str-update-now").onclick = function (e) {
            e.preventDefault();
            window.open(SCRIPT_URL, "_blank");
        };
        el.querySelector("#str-update-later").onclick = function (e) {
            e.preventDefault();
            const twoDays = 2 * 24 * 60 * 60 * 1000;
            localStorage.setItem(REMIND_KEY, Date.now() + twoDays);
            el.remove();
        };
    }
    setTimeout(checkUpdate, 4000);
})();