WME StR Simplified

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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