WME Segment Shift Utility

Utility for shifting street segments in WME without disconnecting nodes

当前为 2025-06-02 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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');
    }

})();