WME Segment Shift Utility

Utility for shifting street segments in WME without disconnecting nodes

目前為 2025-06-02 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         WME Segment Shift Utility
// @namespace    https://github.com/kid4rm90s/Segment-Shift-Utility
// @version      2025.06.02.04
// @description  Utility for shifting street segments in WME without disconnecting nodes
// @include      /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/*
// @author       kid4rm90s
// @connect      greasyfork.org
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @require      https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @require      https://cdn.jsdelivr.net/gh/wazeSpace/wme-sdk-plus@06108853094d40f67e923ba0fe0de31b1cec4412/wme-sdk-plus.js
// @exclude      https://cdn.jsdelivr.net/gh/WazeSpace/wme-sdk-plus@latest/wme-sdk-plus.js
// @require      https://cdn.jsdelivr.net/npm/@turf/[email protected]/turf.min.js
// @license      MIT
// ==/UserScript==

/* global getWmeSdk */
/* global initWmeSdkPlus */
/* global WazeWrap */
/* global turf */
/* global $ */
/* global jQuery */
/* global I18n */
/* eslint curly: ["warn", "multi-or-nest"] */

/*Scripts modified from WME RA Util (https://greasyfork.org/en/scripts/23616-wme-ra-util)
orgianl author: JustinS83 Waze*/
(function () {

    const updateMessage = 'Conversion to WME SDK. Now uses turf for calculations and geometry.';
    const SCRIPT_VERSION = GM_info.script.version.toString();
    const SCRIPT_NAME = GM_info.script.name;
    const DOWNLOAD_URL = GM_info.script.downloadURL;
    
    const DIRECTION = {
        NORTH: 0,
        EAST: 90,
        SOUTH: 180,
        WEST: 270
    }; 

    let sdk;
    let _settings;
    

    async function bootstrap() {
        const wmeSdk = getWmeSdk({ scriptId: 'wme-ss-util', scriptName: 'WME SS Util' });
        const sdkPlus = await initWmeSdkPlus(wmeSdk, {
            hooks: ['Editing.Transactions']
        });
        sdk = sdkPlus || wmeSdk;
        sdk.Events.once({ eventName: 'wme-ready' }).then(() => {
            loadScriptUpdateMonitor();
            init();
        });
    }

    function waitForWME() {
        if (!unsafeWindow.SDK_INITIALIZED) {
            setTimeout(waitForWME, 500);
            return;
        }
        unsafeWindow.SDK_INITIALIZED.then(bootstrap);
    }
    waitForWME();

    function loadScriptUpdateMonitor() {
        try {
            const updateMonitor = new WazeWrap.Alerts.ScriptUpdateMonitor(SCRIPT_NAME, SCRIPT_VERSION, DOWNLOAD_URL, GM_xmlhttpRequest);
            updateMonitor.start();
        } catch (ex) {
            // Report, but don't stop if ScriptUpdateMonitor fails.
            console.error(`${SCRIPT_NAME}:`, ex);
        }
    }

    function init() {
        console.log('SS UTIL', GM_info.script);
        injectCss();
        // UpdateSegmentGeometry = require('Waze/Action/UpdateSegmentGeometry'); // Replaced by SDK
        // MoveNode = require("Waze/Action/MoveNode"); // Replaced by SDK
        // MultiAction = require("Waze/Action/MultiAction"); // Replaced by SDK

        SSUtilWindow = document.createElement('div');
        SSUtilWindow.id = "SSUtilWindow"; // Consistent ID
        SSUtilWindow.style.position = 'fixed';
        SSUtilWindow.style.visibility = 'hidden';
        SSUtilWindow.style.top = '15%';
        SSUtilWindow.style.left = '25%';
        SSUtilWindow.style.width = '250px';
        SSUtilWindow.style.zIndex = 100;
        SSUtilWindow.style.backgroundColor = '#FFFFFE';
        SSUtilWindow.style.borderWidth = '0px';
        SSUtilWindow.style.borderStyle = 'solid';
        SSUtilWindow.style.borderRadius = '10px';
        SSUtilWindow.style.boxShadow = '5px 5px 10px Silver';
        SSUtilWindow.style.padding = '4px';

        let SSUtilWindowHTML = '<div id="header" style="padding: 4px; background-color:#92C3D3; border-radius: 5px;-moz-border-radius: 5px;-webkit-border-radius: 5px; color: white; font-weight: bold; text-align:center; letter-spacing: 1px;text-shadow: black 0.1em 0.1em 0.2em;"><img src="https://storage.googleapis.com/wazeopedia-files/1/1e/RA_Util.png" style="float:left"></img> Segment Shift Utility <a data-toggle="collapse" href="#divWrappers1" id="collapserLink1" style="float:right"><span id="collapser1" style="cursor:pointer;padding:2px;color:white;" class="fa fa-caret-square-o-up"></a></span></div>';
        // start collapse // I put it al the beginning
	    SSUtilWindowHTML += '<div id="divWrappers1" class="collapse in">';
        //***************** Disconnect Nodes Checkbox **************************
        SSUtilWindowHTML += '<p style="margin: 10px 0px 0px 20px;"><input type="checkbox" id="chkDisconnectNodes"> Disconnect Nodes</p>';
        //***************** Shift Amount **************************
        // Define BOX
        SSUtilWindowHTML += '<div id="contentShift" style="text-align:center;float:left; width: 120px;max-width: 49%;height: 170px;margin: 1em 5px 0px 0px;opacity:1;border-radius: 2px;-moz-border-radius: 2px;-webkit-border-radius: 4px;border-width:1px;border-style:solid;border-color:#92C3D3;padding:2px;}">';
        SSUtilWindowHTML += '<b>Shift amount</b></br><input type="text" name="shiftAmount" id="shiftAmount" size="1" style="float: left; text-align: center;font: inherit; line-height: normal; width: 30px; height: 20px; margin: 5px 4px; box-sizing: border-box; display: block; padding-left: 0; border-bottom-color: rgba(black,.3); background: transparent; outline: none; color: black;" value="1"/> <div style="margin: 5px 4px;">Metre(s)';
        // Shift amount controls
        SSUtilWindowHTML += '<div id="controls" style="text-align:center; padding:06px 4px;width=100px; height=100px;margin: 5px 0px;border-style:solid; border-width: 2px;border-radius: 50%;-moz-border-radius: 50%;-webkit-border-radius: 50%;box-shadow: inset 0px 0px 50px -14px rgba(0,0,0,1);-moz-box-shadow: inset 0px 0px 50px -14px rgba(0,0,0,1);-webkit-box-shadow: inset 0px 0px 50px -14px rgba(0,0,0,1); background:#92C3D3;align:center;">';
        //Single Shift Up Button
        SSUtilWindowHTML += '<span id="SSShiftUpBtn" style="cursor:pointer;font-size:14px;">';
        SSUtilWindowHTML += '<i class="fa fa-angle-double-up fa-2x" style="color: white; text-shadow: black 0.1em 0.1em 0.2em; vertical-align: top;"> </i>';
        SSUtilWindowHTML += '<span id="UpBtnCaption" style="font-weight: bold;"></span>';
        SSUtilWindowHTML += '</span><br>';
        //Single Shift Left Button
        SSUtilWindowHTML += '<span id="SSShiftLeftBtn" style="cursor:pointer;font-size:14px;margin-left:-40px;">';
        SSUtilWindowHTML += '<i class="fa fa-angle-double-left fa-2x" style="color: white; text-shadow: black 0.1em 0.1em 0.2em; vertical-align: middle"> </i>';
        SSUtilWindowHTML += '<span id="LeftBtnCaption" style="font-weight: bold;"></span>';
        SSUtilWindowHTML += '</span>';
        //Single Shift Right Button
        SSUtilWindowHTML += '<span id="SSShiftRightBtn" style="float: right;cursor:pointer;font-size:14px;margin-right:5px;">';
        SSUtilWindowHTML += '<i class="fa fa-angle-double-right fa-2x" style="color: white;text-shadow: black 0.1em 0.1em 0.2em;  vertical-align: middle"> </i>';
        SSUtilWindowHTML += '<span id="RightBtnCaption" style="font-weight: bold;"></span>';
        SSUtilWindowHTML += '</span><br>';
        //Single Shift Down Button
        SSUtilWindowHTML += '<span id="SSShiftDownBtn" style="cursor:pointer;font-size:14px;margin-top:0px;">';
        SSUtilWindowHTML += '<i class="fa fa-angle-double-down fa-2x" style="color: white;text-shadow: black 0.1em 0.1em 0.2em;  vertical-align: middle"> </i>';
        SSUtilWindowHTML += '<span id="DownBtnCaption" style="font-weight: bold;"></span>';
        SSUtilWindowHTML += '</span>';
        SSUtilWindowHTML += '</div></div></div>';

        SSUtilWindow.innerHTML = SSUtilWindowHTML;
        document.body.appendChild(SSUtilWindow);

        $('#SSShiftLeftBtn').click(SSShiftLeftClick);
        $('#SSShiftRightBtn').click(SSShiftRightClick);
        $('#SSShiftUpBtn').click(SSShiftUpClick);
        $('#SSShiftDownBtn').click(SSShiftDownClick);

        $('#shiftAmount').keypress(function (event) {
            if ((event.which != 46 || $(this).val().indexOf('.') != -1) && (event.which < 48 || event.which > 57)) event.preventDefault();
        });

        $('#collapserLink1').click(function () {
			$("#divWrappers1").slideToggle("fast");
            if ($('#collapser1').attr('class') == "fa fa-caret-square-o-down"){
                $("#collapser1").removeClass("fa-caret-square-o-down");
                $("#collapser1").addClass("fa-caret-square-o-up");
            }
            else{
                $("#collapser1").removeClass("fa-caret-square-o-up");
                $("#collapser1").addClass("fa-caret-square-o-down");
            }
            saveSettingsToStorage();
        });

        const loadedSettings = JSON.parse(localStorage.getItem("WME_SSUtil"));
        const defaultSettings = {
            divTop: "15%",
            divLeft: "25%",
            Expanded: true,
            DisconnectNodes: false // default to false (normal behavior)
        };
        _settings = loadedSettings ?? defaultSettings;

        $('#SSUtilWindow').css('left', _settings.divLeft);
        $('#SSUtilWindow').css('top', _settings.divTop);
        $('#chkDisconnectNodes').prop('checked', _settings.DisconnectNodes); // Set checkbox state from settings

        if(!_settings.Expanded){
			$("#divWrappers1").hide();
            $("#collapser1").removeClass("fa-caret-square-o-up");
            $("#collapser1").addClass("fa-caret-square-o-down");
        }

        sdk.Events.on({ eventName: 'wme-selection-changed', eventHandler: checkDisplayTool });
        WazeWrap.Interface.ShowScriptUpdate("WME SS Util", GM_info.script.version, updateMessage, "https://update.greasyfork.org/scripts/537258/WME%20Segment%20Shift%20Utility.user.js", "https://github.com/kid4rm90s/Segment-Shift-Utility");
    }

    function saveSettingsToStorage() {
        if (localStorage) {
            _settings.divLeft = $('#SSUtilWindow').css('left');
            _settings.divTop = $('#SSUtilWindow').css('top');
            _settings.Expanded = $("#collapser1").attr('class').indexOf("fa-caret-square-o-up") > -1;
            _settings.DisconnectNodes = $('#chkDisconnectNodes').is(':checked'); // Save checkbox state
            localStorage.setItem("WME_SSUtil", JSON.stringify(_settings));
        }
    }

    function checkDisplayTool() {
        if (sdk.Editing.getSelection()?.objectType === 'segment') {
            if (sdk.Editing.getSelection().length === 0){
                $('#SSUtilWindow').css({ visibility: 'hidden' });
        } else {
                $('#SSUtilWindow').css({ visibility: 'visible' });
                if (typeof jQuery.ui !== 'undefined') {
                    $('#SSUtilWindow' ).draggable({ //Gotta nuke the height setting the dragging inserts otherwise the panel cannot collapse
                        stop: () => {
                            $('#SSUtilWindow').css("height", "");
                            saveSettingsToStorage();
                        }
                    });
                }
            }
        } else {
            $('#SSUtilWindow').css({ visibility: 'hidden' });
            if (typeof jQuery.ui !== 'undefined') {
                $('#SSUtilWindow').draggable({
                    stop: () => {
                        $('#SSUtilWindow').css("height", "");
                        saveSettingsToStorage();
                    }
                });
            }     
        }
    }

    function ShiftSegmentNodesLat(offset) {
        const selectedSegmentIds = sdk.Editing.getSelection()?.ids;
        if (!selectedSegmentIds || selectedSegmentIds.length === 0) {
            return;
        }

        const numOffset = parseFloat(offset);
        if (isNaN(numOffset)) {
            console.error("SS UTIL: Invalid shift amount for Latitude.");
            return;
        }

        const disconnectNodes = $('#chkDisconnectNodes').is(':checked'); // Checkbox state

        sdk.Editing.doActions(() => {
            const uniqueNodeIds = new Set();

            if (!disconnectNodes) {
                // Collect unique nodes from selected segments
                for (const segmentId of selectedSegmentIds) {
                    const currentSegment = sdk.DataModel.Segments.getById({ segmentId });
                    if (currentSegment) {
                        uniqueNodeIds.add(currentSegment.fromNodeId);
                        uniqueNodeIds.add(currentSegment.toNodeId);
                    }
                }

                // Shift unique nodes
                for (const nodeId of uniqueNodeIds) {
                    const node = sdk.DataModel.Nodes.getById({ nodeId });
                    if (node) {
                        let newNodeGeometry = structuredClone(node.geometry);
                        const nodeBearing = numOffset > 0 ? DIRECTION.NORTH : DIRECTION.SOUTH;
                        const nodeDistance = Math.abs(numOffset);
                        const currentNodePoint = node.geometry.coordinates;
                        const newNodePoint = turf.destination(currentNodePoint, nodeDistance, nodeBearing, { units: 'meters' });
                        newNodeGeometry.coordinates = newNodePoint.geometry.coordinates;
                        sdk.DataModel.Nodes.moveNode({ id: node.id, geometry: newNodeGeometry });
                    }
                }
            }

            // Update Segment Geometries
            for (const segmentId of selectedSegmentIds) {
                const currentSegment = sdk.DataModel.Segments.getById({ segmentId });
                if (currentSegment) {
                    let newGeometry = structuredClone(currentSegment.geometry);
                    const originalLength = currentSegment.geometry.coordinates.length;
                    const shiftDistance = Math.abs(numOffset);
                    const shiftBearing = numOffset > 0 ? DIRECTION.NORTH : DIRECTION.SOUTH;

                    if (disconnectNodes) {
                        // Shift all points including end nodes
                        for (let j = 0; j < originalLength; j++) {
                            const currentPoint = currentSegment.geometry.coordinates[j];
                            const newPoint = turf.destination(currentPoint, shiftDistance, shiftBearing, { units: 'meters' });
                            newGeometry.coordinates[j] = newPoint.geometry.coordinates;
                        }
                    } else {
                        // Shift only inner points
                        for (let j = 1; j < originalLength - 1; j++) {
                            const currentPoint = currentSegment.geometry.coordinates[j];
                            const newPoint = turf.destination(currentPoint, shiftDistance, shiftBearing, { units: 'meters' });
                            newGeometry.coordinates[j] = newPoint.geometry.coordinates;
                        }
                        // Update end points to match (potentially) moved nodes
                        const fromNodeAfterMove = sdk.DataModel.Nodes.getById({ nodeId: currentSegment.fromNodeId });
                        const toNodeAfterMove = sdk.DataModel.Nodes.getById({ nodeId: currentSegment.toNodeId });

                        if (fromNodeAfterMove && newGeometry.coordinates.length > 0) {
                            newGeometry.coordinates[0] = fromNodeAfterMove.geometry.coordinates;
                        }
                        if (toNodeAfterMove && newGeometry.coordinates.length > 1) {
                            newGeometry.coordinates[originalLength - 1] = toNodeAfterMove.geometry.coordinates;
                        }
                    }
                    sdk.DataModel.Segments.updateSegment({ segmentId: currentSegment.id, geometry: newGeometry });
                }
            }
        }, 'Shifted segments vertically');
    }

    function ShiftSegmentNodesLon(offset) {
        const selectedSegmentIds = sdk.Editing.getSelection()?.ids;
        if (!selectedSegmentIds || selectedSegmentIds.length === 0) {
            return;
        }

        const numOffset = parseFloat(offset);
        if (isNaN(numOffset)) {
            console.error("SS UTIL: Invalid shift amount for Longitude.");
            return;
        }

        const disconnectNodes = $('#chkDisconnectNodes').is(':checked'); // Checkbox state

        sdk.Editing.doActions(() => {
            const uniqueNodeIds = new Set();

            if (!disconnectNodes) {
                for (const segmentId of selectedSegmentIds) {
                    const currentSegment = sdk.DataModel.Segments.getById({ segmentId });
                    if (currentSegment) {
                        uniqueNodeIds.add(currentSegment.fromNodeId);
                        uniqueNodeIds.add(currentSegment.toNodeId);
                    }
                }

                for (const nodeId of uniqueNodeIds) {
                    const node = sdk.DataModel.Nodes.getById({ nodeId });
                    if (node) {
                        let newNodeGeometry = structuredClone(node.geometry);
                        const nodeBearing = numOffset > 0 ? DIRECTION.EAST : DIRECTION.WEST;
                        const nodeDistance = Math.abs(numOffset);
                        const currentNodePoint = node.geometry.coordinates;
                        const newNodePoint = turf.destination(currentNodePoint, nodeDistance, nodeBearing, { units: 'meters' });
                        newNodeGeometry.coordinates = newNodePoint.geometry.coordinates;
                        sdk.DataModel.Nodes.moveNode({ id: node.id, geometry: newNodeGeometry });
                    }
                }
            }

            for (const segmentId of selectedSegmentIds) {
                const currentSegment = sdk.DataModel.Segments.getById({ segmentId });
                if (currentSegment) {
                    let newGeometry = structuredClone(currentSegment.geometry);
                    const originalLength = currentSegment.geometry.coordinates.length;
                    const shiftDistance = Math.abs(numOffset);
                    const shiftBearing = numOffset > 0 ? DIRECTION.EAST : DIRECTION.WEST;

                    if (disconnectNodes) {
                        for (let j = 0; j < originalLength; j++) {
                            const currentPoint = currentSegment.geometry.coordinates[j];
                            const newPoint = turf.destination(currentPoint, shiftDistance, shiftBearing, { units: 'meters' });
                            newGeometry.coordinates[j] = newPoint.geometry.coordinates;
                        }
                    } else {
                        for (let j = 1; j < originalLength - 1; j++) {
                            const currentPoint = currentSegment.geometry.coordinates[j];
                            const newPoint = turf.destination(currentPoint, shiftDistance, shiftBearing, { units: 'meters' });
                            newGeometry.coordinates[j] = newPoint.geometry.coordinates;
                        }
                        const fromNodeAfterMove = sdk.DataModel.Nodes.getById({ nodeId: currentSegment.fromNodeId });
                        const toNodeAfterMove = sdk.DataModel.Nodes.getById({ nodeId: currentSegment.toNodeId });

                        if (fromNodeAfterMove && newGeometry.coordinates.length > 0) {
                            newGeometry.coordinates[0] = fromNodeAfterMove.geometry.coordinates;
                        }
                        if (toNodeAfterMove && newGeometry.coordinates.length > 1) {
                            newGeometry.coordinates[originalLength - 1] = toNodeAfterMove.geometry.coordinates;
                        }
                    }
                    sdk.DataModel.Segments.updateSegment({ segmentId: currentSegment.id, geometry: newGeometry });
                }
            }
        }, 'Shifted segments horizontally');
    }

    //Left
    function SSShiftLeftClick(e){
        e.stopPropagation();
        ShiftSegmentNodesLon(-parseFloat($('#shiftAmount').val())); // Negative for West
		WazeWrap.Alerts.info('WME Segment Shift Utility', `The segments are shifted by <b>${$('#shiftAmount').val()} Metres</b> to the left.`, false, false, 1500);		
    }
    //Right
    function SSShiftRightClick(e){
        e.stopPropagation();
        ShiftSegmentNodesLon(parseFloat($('#shiftAmount').val())); // Positive for East
		WazeWrap.Alerts.info('WME Segment Shift Utility', `The segments are shifted by <b>${$('#shiftAmount').val()} Metres</b> to the right.`, false, false, 1500);
    }
    //Up
    function SSShiftUpClick(e){
       e.stopPropagation();
        ShiftSegmentNodesLat(parseFloat($('#shiftAmount').val()));
		WazeWrap.Alerts.info('WME Segment Shift Utility', `The segments are shifted by <b>${$('#shiftAmount').val()} Metres</b> to the up.`, false, false, 1500);
    }
    //Down
    function SSShiftDownClick(e){
        e.stopPropagation();
        ShiftSegmentNodesLat(-parseFloat($('#shiftAmount').val()));
		WazeWrap.Alerts.info('WME Segment Shift Utility', `The segments are shifted by <b>${$('#shiftAmount').val()} Metres</b> to the down.`, false, false, 1500);
    }

    function injectCss() {
        const css = [].join(' '); // No custom CSS needed if these were the only ones
        $('<style type="text/css">' + css + '</style>').appendTo('head');
    }

})();