// ==UserScript==
// @name WME RA Util
// @namespace https://greasyfork.org/users/30701-justins83-waze
// @version 2025.12.30.01
// @description Providing basic utility for RA adjustment without the need to delete & recreate
// @include https://www.waze.com/editor*
// @include https://www.waze.com/*/editor*
// @include https://beta.waze.com/*
// @exclude https://www.waze.com/user/editor*
// @require https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @require https://cdn.jsdelivr.net/npm/@turf/turf@7.2.0/turf.min.js
// @connect greasyfork.org
// @author JustinS83
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @license GPLv3
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// @downloadURL https://update.greasyfork.icu/scripts/23616/WME%20RA%20Util.user.js
// @updateURL https://update.greasyfork.icu/scripts/23616/WME%20RA%20Util.meta.js
// ==/UserScript==
/* global getWmeSdk */
/* global WazeWrap */
/* global turf */
/* global $ */
/* global jQuery */
/* global I18n */
/* eslint curly: ["warn", "multi-or-nest"] */
(function () {
const SCRIPT_VERSION = GM_info.script.version.toString();
const SCRIPT_NAME = GM_info.script.name;
const DOWNLOAD_URL = GM_info.scriptUpdateURL;
const DIRECTION = {
NORTH: 0,
EAST: 90,
SOUTH: 180,
WEST: 270
};
const COLOR = {
NORMAL_LINES: '#0040FF',
NON_NORMAL_LINES: '#002080',
NORMAL_ANGLES: '#004000',
NON_NORMAL_ANGLES: '#FF0000',
AVOID_ANGLES: '#FFC000'
};
let sdk;
let roundaboutPopup = null;
let _settings;
const updateMessage = 'Conversion to WME SDK. Now uses turf for calculations and geometry. Thank you to lacmac for undertaking this conversion, and the others that have reviewed and added their insight.';
function waitUntil(callback, interval = 200, timeout = 60000) {
return new Promise((resolve, reject) => {
const start = Date.now();
const timer = setInterval(() => {
if (callback()) {
clearInterval(timer);
resolve();
} else if (Date.now() - start > timeout) {
clearInterval(timer);
reject(new Error(`${SCRIPT_NAME} timeout waiting for object`));
}
}, interval);
});
}
async function bootstrap() {
await unsafeWindow.SDK_INITIALIZED;
sdk = getWmeSdk({ scriptId: 'wme-ra-util', scriptName: 'WME RA Util' });
await sdk.Events.once({ eventName: 'wme-ready' });
await waitUntil(() => WazeWrap?.Ready);
loadScriptUpdateMonitor();
init();
}
bootstrap();
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('RA UTIL', GM_info.script);
injectCss();
sdk.Map.addLayer({
layerName: '__DrawRoundaboutAngles',
styleRules: styleConfig.styleRules,
styleContext: styleConfig.styleContext
});
sdk.Map.setLayerVisibility({ layerName: '__DrawRoundaboutAngles', visibility: true });
roundaboutPopup = document.createElement('div');
roundaboutPopup.id = 'RAUtilWindow';
roundaboutPopup.style.position = 'fixed';
roundaboutPopup.style.visibility = 'hidden';
roundaboutPopup.style.top = '15%';
roundaboutPopup.style.left = '25%';
roundaboutPopup.style.width = '510px';
roundaboutPopup.style.zIndex = 100;
roundaboutPopup.style.backgroundColor = '#FFFFFE';
roundaboutPopup.style.borderWidth = '0px';
roundaboutPopup.style.borderStyle = 'solid';
roundaboutPopup.style.borderRadius = '10px';
roundaboutPopup.style.boxShadow = '5px 5px 10px Silver';
roundaboutPopup.style.padding = '4px';
let roundaboutPopupHTML = '
';
// start collapse // I put it al the beginning
roundaboutPopupHTML += '';
//***************** Round About Angles **************************
roundaboutPopupHTML += '
Enable Roundabout Angles
';
//***************** Shift Amount **************************
// Define BOX
roundaboutPopupHTML += '
';
roundaboutPopupHTML += '
Shift amount Meter(s)';
// Shift amount controls
roundaboutPopupHTML += '
';
//Single Shift Up Button
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '
';
//Single Shift Left Button
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '';
//Single Shift Right Button
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '
';
//Single Shift Down Button
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '
';
//***************** Rotation **************************
// Define BOX
roundaboutPopupHTML += '
';
roundaboutPopupHTML += '
Rotation amount Degree(s)';
// Rotation controls
roundaboutPopupHTML += '
';
// Rotate Button on the Left
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '';
// Rotate button on the Right
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '
';
//********************* Diameter change ******************
// Define BOX
roundaboutPopupHTML += '
';
roundaboutPopupHTML += '
Change diameter';
// Diameter Change controls
roundaboutPopupHTML += '
';
// Decrease Button
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '';
// Increase Button
roundaboutPopupHTML += '';
roundaboutPopupHTML += ' ';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '';
roundaboutPopupHTML += '
';
//***************** Bump nodes **********************
// Define BOX
roundaboutPopupHTML += '
';
roundaboutPopupHTML += '
Move nodes';
// Move Nodes controls
roundaboutPopupHTML += '
';
// Button A
roundaboutPopupHTML += '
A Node';
// Move node IN
roundaboutPopupHTML += '
in';
// Move node OUT
roundaboutPopupHTML += 'out';
roundaboutPopupHTML += '
';
// Button B
roundaboutPopupHTML += '
B Node';
// Move node IN
roundaboutPopupHTML += '
in';
// Move node OUT
roundaboutPopupHTML += 'out';
roundaboutPopupHTML += '
';
roundaboutPopupHTML += '
';
roundaboutPopup.innerHTML = roundaboutPopupHTML;
document.body.appendChild(roundaboutPopup);
$('#RAShiftLeftBtn').click(handleShiftLeftClick);
$('#RAShiftRightBtn').click(handleShiftRightClick);
$('#RAShiftUpBtn').click(handleShiftUpClick);
$('#RAShiftDownBtn').click(handleShiftDownClick);
$('#RARotateLeftBtn').click(handleRotateLeftClick);
$('#RARotateRightBtn').click(handleRotateRightClick);
$('#diameterChangeDecreaseBtn').click(handleDiameterDecreaseClick);
$('#diameterChangeIncreaseBtn').click(handleDiameterIncreaseClick);
$('#btnMoveANodeIn').click(handleNodeAInClick);
$('#btnMoveANodeOut').click(handleNodeAOutClick);
$('#btnMoveBNodeIn').click(handleNodeBInClick);
$('#btnMoveBNodeOut').click(handleNodeBOutClick);
$('#shiftAmount').keypress(function (event) {
if ((event.which != 46 || $(this).val().indexOf('.') != -1) && (event.which < 48 || event.which > 57)) event.preventDefault();
});
$('#rotationAmount').keypress(function (event) {
if ((event.which != 46 || $(this).val().indexOf('.') != -1) && (event.which < 48 || event.which > 57)) event.preventDefault();
});
$('#collapserLink').click(function () {
$('#divWrappers').slideToggle('fast');
if ($('#collapser').attr('class') == 'fa fa-caret-square-o-down') {
$('#collapser').removeClass('fa-caret-square-o-down');
$('#collapser').addClass('fa-caret-square-o-up');
} else {
$('#collapser').removeClass('fa-caret-square-o-up');
$('#collapser').addClass('fa-caret-square-o-down');
}
saveSettingsToStorage();
});
const loadedSettings = JSON.parse(localStorage.getItem('WME_RAUtil'));
const defaultSettings = {
divTop: '15%',
divLeft: '25%',
Expanded: true,
RoundaboutAngles: true
};
_settings = loadedSettings ?? defaultSettings;
$('#RAUtilWindow').css('left', _settings.divLeft);
$('#RAUtilWindow').css('top', _settings.divTop);
$('#chkRARoundaboutAngles').prop('checked', _settings.RoundaboutAngles);
$('#chkRARoundaboutAngles').prop('checked', _settings.RoundaboutAngles);
if (!_settings.Expanded) {
$('#divWrappers').hide();
$('#collapser').removeClass('fa-caret-square-o-up');
$('#collapser').addClass('fa-caret-square-o-down');
}
sdk.Events.on({ eventName: 'wme-selection-changed', eventHandler: checkDisplayTool });
$('#chkRARoundaboutAngles').click(function () {
saveSettingsToStorage();
if ($('#chkRARoundaboutAngles').is(':checked')) {
sdk.Events.on({ eventName: 'wme-map-zoom-changed', eventHandler: drawRoundaboutAngles });
sdk.Events.on({ eventName: 'wme-map-move-end', eventHandler: drawRoundaboutAngles });
sdk.Map.setLayerVisibility({ layerName: '__DrawRoundaboutAngles', visibility: true });
drawRoundaboutAngles();
} else {
sdk.Events.off({ eventName: 'wme-map-zoom-changed', eventHandler: drawRoundaboutAngles });
sdk.Events.off({ eventName: 'wme-map-move-end', eventHandler: drawRoundaboutAngles });
sdk.Map.setLayerVisibility({ layerName: '__DrawRoundaboutAngles', visibility: false });
}
});
if (_settings.RoundaboutAngles) {
sdk.Events.on({ eventName: 'wme-map-zoom-changed', eventHandler: drawRoundaboutAngles });
sdk.Events.on({ eventName: 'wme-map-move-end', eventHandler: drawRoundaboutAngles });
drawRoundaboutAngles();
}
WazeWrap.Interface.ShowScriptUpdate('WME RA Util', GM_info.script.version, updateMessage, 'https://greasyfork.org/en/scripts/23616-wme-ra-util', 'https://www.waze.com/forum/viewtopic.php?f=819&t=211079');
}
function saveSettingsToStorage() {
if (localStorage) {
_settings.divLeft = $('#RAUtilWindow').css('left');
_settings.divTop = $('#RAUtilWindow').css('top');
_settings.Expanded = $('#collapser').attr('class').indexOf('fa-caret-square-o-up') > -1;
_settings.RoundaboutAngles = $('#chkRARoundaboutAngles').is(':checked');
localStorage.setItem('WME_RAUtil', JSON.stringify(_settings));
}
}
function checkDisplayTool() {
if (sdk.Editing.getSelection()?.objectType === 'segment') {
if (!allRoundaboutSegmentsSelected()) {
$('#RAUtilWindow').css({ visibility: 'hidden' });
} else {
$('#RAUtilWindow').css({ visibility: 'visible' });
if (typeof jQuery.ui !== 'undefined') {
$('#RAUtilWindow').draggable({
//Gotta nuke the height setting the dragging inserts otherwise the panel cannot collapse
stop: () => {
$('#RAUtilWindow').css('height', '');
saveSettingsToStorage();
}
});
}
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
const junction = sdk.DataModel.Junctions.getById({ junctionId: segment.junctionId });
const connectedSegments = getSegmentsFromIds(junction.segmentIds);
checkAndDisplaySegmentEditability(connectedSegments);
}
} else {
$('#RAUtilWindow').css({ visibility: 'hidden' });
if (typeof jQuery.ui !== 'undefined') {
$('#RAUtilWindow').draggable({
stop: () => {
$('#RAUtilWindow').css('height', '');
saveSettingsToStorage();
}
});
}
}
}
function getSegmentsFromIds(segmentIds) {
return segmentIds.map((segmentId) => sdk.DataModel.Segments.getById({ segmentId }));
}
function checkAndDisplaySegmentEditability(segments) {
const errorElement = $('#RAEditable');
let allEditable = true;
for (let segment of segments) {
const fromNode = sdk.DataModel.Nodes.getById({ nodeId: segment.fromNodeId });
const toNode = sdk.DataModel.Nodes.getById({ nodeId: segment.toNodeId });
const userRank = sdk.State.getUserInfo().rank;
if (segment) {
if (toNode) {
let toConnectedSegments = getSegmentsFromIds(toNode.connectedSegmentIds);
for (let toConnectedSegment of toConnectedSegments) {
if ((toConnectedSegment && toConnectedSegment.hasClosures) || toConnectedSegment.lockRank > userRank) {
allEditable = false;
}
}
}
if (fromNode) {
let fromConnectedSegments = getSegmentsFromIds(fromNode.connectedSegmentIds);
for (let fromConnectedSegment of fromConnectedSegments) {
if ((fromConnectedSegment && fromConnectedSegment.hasClosures) || fromConnectedSegment.lockRank > userRank) {
allEditable = false;
}
}
}
}
}
if (allEditable) {
errorElement.remove();
} else {
if (errorElement.length === 0) {
errorElement = $('', { id: 'RAEditable', style: 'color:red' });
errorElement.text('One or more segments are locked above your rank or have a closure.');
$('#RAUtilWindow').append(errorElement);
}
}
return allEditable;
}
function allRoundaboutSegmentsSelected() {
for (const segmentId of sdk.Editing.getSelection().ids) {
if (segmentId < 0 || !sdk.DataModel.Segments.getById({ segmentId: segmentId }).junctionId) {
return false;
}
}
return true;
}
function handleShiftUpClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
shiftRoundaboutLat(segment, $('#shiftAmount').val());
}
function handleShiftDownClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
shiftRoundaboutLat(segment, -$('#shiftAmount').val());
}
function shiftRoundaboutLat(segment, offset) {
const segmentIds = sdk.DataModel.Junctions.getById({ junctionId: segment.junctionId }).segmentIds;
const segments = getSegmentsFromIds(segmentIds);
if (checkAndDisplaySegmentEditability(segments)) {
for (const segmentId of segmentIds) {
// Fetch new segment data, as we can be changing other segments by moving nodes
const segment = sdk.DataModel.Segments.getById({ segmentId });
// Move all segment points
let newGeometry = structuredClone(segment.geometry);
const originalLength = segment.geometry.coordinates.length;
for (let i = 1; i < originalLength - 1; i++) {
const bearing = offset > 0 ? DIRECTION.NORTH : DIRECTION.SOUTH;
const distance = Math.abs(offset);
const currentPoint = segment.geometry.coordinates[i];
const newPoint = turf.destination(currentPoint, distance, bearing, { units: 'meters' });
newGeometry.coordinates[i] = newPoint.geometry.coordinates;
}
sdk.DataModel.Segments.updateSegment({ segmentId: segment.id, geometry: newGeometry });
//Move node
const nodeId = segment.isAtoB ? segment.toNodeId : segment.fromNodeId;
const node = sdk.DataModel.Nodes.getById({ nodeId });
let newNodeGeometry = structuredClone(node.geometry);
const nodeBearing = offset > 0 ? DIRECTION.NORTH : DIRECTION.SOUTH;
const nodeDistance = Math.abs(offset);
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 });
}
}
}
function handleShiftLeftClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
shiftRoundaboutLon(segment, -$('#shiftAmount').val());
}
function handleShiftRightClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
shiftRoundaboutLon(segment, $('#shiftAmount').val());
}
function shiftRoundaboutLon(segment, longOffset) {
const segmentIds = sdk.DataModel.Junctions.getById({ junctionId: segment.junctionId }).segmentIds;
const segments = getSegmentsFromIds(segmentIds);
if (checkAndDisplaySegmentEditability(segments)) {
for (const segmentId of segmentIds) {
// Fetch new segment data, as we can be changing other segments by moving nodes
const segment = sdk.DataModel.Segments.getById({ segmentId });
// Move segment
let newGeometry = structuredClone(segment.geometry);
const originalLength = segment.geometry.coordinates.length;
for (let i = 1; i < originalLength - 1; i++) {
const bearing = longOffset > 0 ? DIRECTION.EAST : DIRECTION.WEST;
const distance = Math.abs(longOffset);
const currentPoint = segment.geometry.coordinates[i];
const newPoint = turf.destination(currentPoint, distance, bearing, { units: 'meters' });
newGeometry.coordinates[i] = newPoint.geometry.coordinates;
}
sdk.DataModel.Segments.updateSegment({ segmentId: segment.id, geometry: newGeometry });
// Move node
const nodeId = segment.isAtoB ? segment.toNodeId : segment.fromNodeId;
const node = sdk.DataModel.Nodes.getById({ nodeId });
let newNodeGeometry = structuredClone(node.geometry);
const nodeBearing = longOffset > 0 ? DIRECTION.EAST : DIRECTION.WEST;
const nodeDistance = Math.abs(longOffset);
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 });
}
}
}
function handleRotateLeftClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
rotateRoundabout(segment, $('#rotationAmount').val());
}
function handleRotateRightClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
rotateRoundabout(segment, -$('#rotationAmount').val());
}
function rotateRoundabout(segment, angle) {
const junction = sdk.DataModel.Junctions.getById({ junctionId: segment.junctionId });
const segmentIds = junction.segmentIds;
const centerCoordinates = junction.geometry.coordinates;
let segments = getSegmentsFromIds(segmentIds);
if (checkAndDisplaySegmentEditability(segments)) {
for (const segmentId of segmentIds) {
// Fetch new segment data, as we can be changing other segments by moving nodes
const segment = sdk.DataModel.Segments.getById({ segmentId });
// Rotate segment
let newGeometry = structuredClone(segment.geometry);
const originalLength = segment.geometry.coordinates.length;
for (let i = 1; i < originalLength - 1; i++) {
const currentPoint = segment.geometry.coordinates[i];
const rotatedPoint = rotatePointAroundCenter(currentPoint, centerCoordinates, angle);
newGeometry.coordinates[i] = rotatedPoint.geometry.coordinates;
}
sdk.DataModel.Segments.updateSegment({ segmentId: segment.id, geometry: newGeometry });
// Rotate nodes
const nodeId = segment.isAtoB ? segment.toNodeId : segment.fromNodeId;
const node = sdk.DataModel.Nodes.getById({ nodeId });
let newNodeGeometry = structuredClone(node.geometry);
const currentNodePoint = node.geometry.coordinates;
const rotatedNodePoint = rotatePointAroundCenter(currentNodePoint, centerCoordinates, angle);
newNodeGeometry.coordinates = rotatedNodePoint.geometry.coordinates;
sdk.DataModel.Nodes.moveNode({ id: node.id, geometry: newNodeGeometry });
}
if (_settings.RoundaboutAngles) {
drawRoundaboutAngles();
}
}
}
function rotatePointAroundCenter(point, center, angleDegrees) {
const distance = turf.distance(center, point, { units: 'meters' });
const currentBearing = turf.bearing(center, point);
const newBearing = currentBearing - angleDegrees;
return turf.destination(center, distance, newBearing, { units: 'meters' });
}
function handleDiameterDecreaseClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
changeRoundaboutDiameter(segment, -1);
}
function handleDiameterIncreaseClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
changeRoundaboutDiameter(segment, 1);
}
function changeRoundaboutDiameter(segment, amount) {
const junction = sdk.DataModel.Junctions.getById({ junctionId: segment.junctionId });
const segmentIds = junction.segmentIds;
const centerCoordinates = junction.geometry.coordinates;
let segments = getSegmentsFromIds(segmentIds);
if (checkAndDisplaySegmentEditability(segments)) {
for (const segmentId of segmentIds) {
// Fetch new segment data, as we can be changing other segments by moving nodes
const segment = sdk.DataModel.Segments.getById({ segmentId });
// Modify segment
let newGeometry = structuredClone(segment.geometry);
const originalLength = segment.geometry.coordinates.length;
for (let i = 1; i < originalLength - 1; i++) {
const currentPoint = segment.geometry.coordinates[i];
const currentDistance = turf.distance(centerCoordinates, currentPoint, { units: 'meters' });
const newDistance = currentDistance + amount;
let bearing = turf.bearing(centerCoordinates, currentPoint);
let newPoint = turf.destination(centerCoordinates, newDistance, bearing, { units: 'meters' });
newGeometry.coordinates[i] = newPoint.geometry.coordinates;
}
sdk.DataModel.Segments.updateSegment({ segmentId: segment.id, geometry: newGeometry });
// Move node
const nodeId = segment.isAtoB ? segment.toNodeId : segment.fromNodeId;
const node = sdk.DataModel.Nodes.getById({ nodeId });
let newNodeGeometry = structuredClone(node.geometry);
const currentNodeDistance = turf.distance(centerCoordinates, newNodeGeometry.coordinates, { units: 'meters' });
const newNodeDistance = currentNodeDistance + amount;
const nodeBearing = turf.bearing(centerCoordinates, newNodeGeometry.coordinates);
const newNodePoint = turf.destination(centerCoordinates, newNodeDistance, nodeBearing, { units: 'meters' });
newNodeGeometry.coordinates = newNodePoint.geometry.coordinates;
sdk.DataModel.Nodes.moveNode({ id: node.id, geometry: newNodeGeometry });
}
if (_settings.RoundaboutAngles) {
drawRoundaboutAngles();
}
}
}
function handleNodeAInClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
moveNodeIn(segment, segment.fromNodeId);
}
function handleNodeBInClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
moveNodeIn(segment, segment.toNodeId);
}
function moveNodeIn(segment, nodeId) {
let isANode = true;
// Segment needs at least 3 coords (A node, one geonode and B node)
if (segment.geometry.coordinates.length > 2) {
if (nodeId === segment.toNodeId) {
isANode = false;
}
// Find the other segment on the roundabout connected to the node
const node = sdk.DataModel.Nodes.getById({ nodeId: nodeId });
let nodeSegmentIds = node.connectedSegmentIds.filter((segmentId) => segmentId !== segment.id);
const nodeSegments = getSegmentsFromIds(nodeSegmentIds);
let otherSegment;
for (const nodeSegment of nodeSegments) {
if (nodeSegment.junctionId) {
otherSegment = nodeSegment;
break;
}
}
// Copy the coordinate of the geonode to be replaced with a node
const newNodeGeometry = {
type: 'Point',
coordinates: structuredClone(segment.geometry.coordinates[isANode ? 1 : segment.geometry.coordinates.length - 2])
};
// Update the segment (remove a geonode)
let newSegmentGeometry = structuredClone(segment.geometry);
newSegmentGeometry.coordinates.splice(isANode ? 1 : newSegmentGeometry.coordinates.length - 2, 1);
sdk.DataModel.Segments.updateSegment({ segmentId: segment.id, geometry: newSegmentGeometry });
// Move node
sdk.DataModel.Nodes.moveNode({ id: node.id, geometry: newNodeGeometry });
// The other segment will be the opposite of A or B
if ((otherSegment.isBtoA && !segment.isBtoA) || (!otherSegment.isBtoA && segment.isBtoA)) {
isANode = !isANode;
}
// Update the other segment (add a geonode)
let newOtherSegmentGeometry = structuredClone(otherSegment.geometry);
newOtherSegmentGeometry.coordinates.splice(isANode ? newOtherSegmentGeometry.coordinates.length : 0, 0, newNodeGeometry.coordinates);
sdk.DataModel.Segments.updateSegment({ segmentId: otherSegment.id, geometry: newOtherSegmentGeometry });
if (_settings.RoundaboutAngles) {
drawRoundaboutAngles();
}
}
}
function handleNodeAOutClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
moveNodeOut(segment, segment.fromNodeId);
}
function handleNodeBOutClick(e) {
e.stopPropagation();
const segment = sdk.DataModel.Segments.getById({ segmentId: sdk.Editing.getSelection().ids[0] });
moveNodeOut(segment, segment.toNodeId);
}
function moveNodeOut(segment, nodeId) {
let isANode = true;
if (nodeId === segment.toNodeId) {
isANode = false;
}
// Find the other segment on the roundabout connected to the node
const node = sdk.DataModel.Nodes.getById({ nodeId: nodeId });
let nodeSegmentIds = node.connectedSegmentIds.filter((segmentId) => segmentId !== segment.id);
const nodeSegments = getSegmentsFromIds(nodeSegmentIds);
let otherSegment;
for (const nodeSegment of nodeSegments) {
if (nodeSegment.junctionId) {
otherSegment = nodeSegment;
break;
}
}
// The other segment needs at least 3 coords (A node, one geonode and B node)
if (otherSegment.geometry.coordinates.length > 2) {
// Update the segment (add a geonode)
let newSegmentGeometry = structuredClone(segment.geometry);
newSegmentGeometry.coordinates.splice(isANode ? 1 : newSegmentGeometry.coordinates.length - 1, 0, node.geometry.coordinates);
sdk.DataModel.Segments.updateSegment({ segmentId: segment.id, geometry: newSegmentGeometry });
// The other segment will be the opposite of A or B
if ((otherSegment.isBtoA && !segment.isBtoA) || (!otherSegment.isBtoA && segment.isBtoA)) {
isANode = !isANode;
}
// Update the other segment (remove a geonode)
let newOtherSegmentGeometry = structuredClone(otherSegment.geometry);
newOtherSegmentGeometry.coordinates.splice(isANode ? -2 : 1, 1);
sdk.DataModel.Segments.updateSegment({ segmentId: otherSegment.id, geometry: newOtherSegmentGeometry });
// Move the node
const newNodeGeometry = {
type: 'Point',
coordinates: structuredClone(otherSegment.geometry.coordinates[isANode ? otherSegment.geometry.coordinates.length - 2 : 1])
};
sdk.DataModel.Nodes.moveNode({ id: node.id, geometry: newNodeGeometry });
if (_settings.RoundaboutAngles) {
drawRoundaboutAngles();
}
}
}
//*************** Roundabout Angles **********************
function drawRoundaboutAngles() {
if (sdk.Map.isLayerVisible({ layerName: '__DrawRoundaboutAngles' }) == false) {
sdk.Map.removeAllFeaturesFromLayer({ layerName: '__DrawRoundaboutAngles' });
return;
}
if (sdk.Map.getZoomLevel() < 15) {
sdk.Map.removeAllFeaturesFromLayer({ layerName: '__DrawRoundaboutAngles' });
return;
}
//---------collect all roundabouts first
let segmentsByJunctionId = {};
for (const segment of sdk.DataModel.Segments.getAll()) {
let junctionId = segment.junctionId;
if (junctionId) {
if (!segmentsByJunctionId[junctionId]) {
segmentsByJunctionId[junctionId] = [];
}
segmentsByJunctionId[junctionId].push(segment);
}
}
let layerFeatures = [];
//-------for each roundabout do...
for (const junctionId in segmentsByJunctionId) {
const junctionSegments = segmentsByJunctionId[junctionId];
let nodes = junctionSegments.map((segment) => segment.fromNodeId); //get from nodes
nodes.push(...junctionSegments.map((segment) => segment.toNodeId));
nodes = [...new Set(nodes)]; //remove duplicates
const nodeCoordinates = nodes.map((nodeId) => sdk.DataModel.Nodes.getById({ nodeId }).geometry.coordinates);
let radius = -1;
const nodeCount = nodeCoordinates.length;
if (nodeCount >= 1) {
const junction = sdk.DataModel.Junctions.getById({ junctionId: parseInt(junctionId) });
let centerCoordinate = junction.geometry.coordinates;
let angles = [];
for (const nodeCoordinate of nodeCoordinates) {
let currentRadius = turf.distance(centerCoordinate, nodeCoordinate, { units: 'meters' });
if (radius < currentRadius) {
radius = currentRadius;
}
let angle = turf.bearing(centerCoordinate, nodeCoordinate);
angles.push(angle);
}
//---------sorting angles for calulating angle difference between two segments
angles = angles.sort(function (a, b) {
return a - b;
});
angles.push(angles[0] + 360.0);
angles = angles.sort(function (a, b) {
return a - b;
});
let strokeColor = nodeCount <= 4 ? COLOR.NORMAL_LINES : COLOR.NON_NORMAL_LINES;
let circle = turf.circle(centerCoordinate, radius, { units: 'meters', steps: sdk.Map.getZoomLevel() * 5 });
let circleFeature = turf.polygon(
circle.geometry.coordinates,
{
styleName: 'roundaboutCircleStyle',
layerName: '__DrawRoundaboutAngles',
style: {
strokeColor
}
},
{ id: `polygon_${centerCoordinate.toString()}_${radius}` }
);
layerFeatures.push(circleFeature);
if (nodeCount >= 2 && nodeCount <= 4) {
//Normal roundabouts
for (let nodeCoordinate of nodeCoordinates) {
let lineFeature = turf.lineString(
[centerCoordinate, nodeCoordinate],
{
styleName: 'roundaboutLineStyle',
layerName: '__DrawRoundaboutAngles',
style: { strokeColor }
},
{ id: `line_${[centerCoordinate, nodeCoordinate].toString()}` }
);
layerFeatures.push(lineFeature);
}
let anglesFloat = [];
let anglesSum = 0;
for (let i = 0; i < angles.length - 1; i++) {
// Find the angle between the two nodes
let angle = angles[i + 1] - angles[i + 0];
if (angle < 0) {
angle += 360.0;
}
if (angle < 0) {
angle += 360.0;
}
// Is the angle closer to 90 or 180, how many degrees off?
if (angle < 135.0) {
angle = angle - 90.0;
} else {
angle = angle - 180.0;
}
anglesSum += parseInt(angle);
anglesFloat.push(angle);
}
if (nodeCount == 2) {
anglesFloat[1] = -anglesFloat[0];
}
for (let i = 0; i < angles.length - 1; i++) {
let labelDistance = radius / 2;
let angleMidpoint = (angles[i + 0] + angles[i + 1]) * 0.5;
let labelPoint = turf.destination(centerCoordinate, labelDistance, angleMidpoint, { units: 'meters' });
//*** Angle Display Rounding ***
let angleRounded = Math.round(anglesFloat[i] * 100) / 100;
let labelColor = COLOR.NORMAL_ANGLES;
if (angleRounded <= -15 || angleRounded >= 15) {
labelColor = COLOR.NON_NORMAL_ANGLES;
} else if (angleRounded <= -13 || angleRounded >= 13) {
labelColor = COLOR.AVOID_ANGLES;
}
let angleLabelFeature = turf.point(
labelPoint.geometry.coordinates,
{
styleName: 'roundaboutLabelStyle',
layerName: '__DrawRoundaboutAngles',
style: {
labelText: angleRounded + '°',
labelColor: labelColor
}
},
{ id: `label_${labelPoint.geometry.coordinates.toString()}` }
);
layerFeatures.push(angleLabelFeature);
}
} else {
// Non-normal roundabouts
for (let nodeCoordinate of nodeCoordinates) {
let lineFeature = turf.lineString(
[centerCoordinate, nodeCoordinate],
{
styleName: 'roundaboutLineStyle',
layerName: '__DrawRoundaboutAngles',
style: { strokeColor }
},
{ id: `line_${[centerCoordinate, nodeCoordinates].toString()}` }
);
layerFeatures.push(lineFeature);
}
}
let centerLabelFeature = turf.point(
centerCoordinate,
{
styleName: 'roundaboutLabelStyle',
layerName: '__DrawRoundaboutAngles',
style: {
labelText: (radius * 2.0).toFixed(0) + 'm',
labelColor: '#000000'
}
},
{ id: `centerLabel_${centerCoordinate.toString()}` }
);
layerFeatures.push(centerLabelFeature);
}
}
sdk.Map.removeAllFeaturesFromLayer({ layerName: '__DrawRoundaboutAngles' });
sdk.Map.addFeaturesToLayer({ layerName: '__DrawRoundaboutAngles', features: layerFeatures });
}
function injectCss() {
const css = ['.btnMoveNode {width=25px; height=25px; background-color:#92C3D3; cursor:pointer; padding:5px; font-size:14px; border:thin outset black; border-style:solid; border-width: 1px;border-radius:50%; -moz-border-radius:50%; -webkit-border-radius:50%; box-shadow:inset 0px 0px 20px -14px rgba(0,0,0,1); -moz-box-shadow:inset 0px 0px 20px -14px rgba(0,0,0,1); -webkit-box-shadow: inset 0px 0px 20px -14px rgba(0,0,0,1);}', '.btnRotate { width=45px; height=45px; background-color:#92C3D3; cursor:pointer; padding: 5px; font-size:14px; border:thin outset black; border-style:solid; border-width: 1px;border-radius: 50%;-moz-border-radius: 50%;-webkit-border-radius: 50%;box-shadow: inset 0px 0px 20px -14px rgba(0,0,0,1);-moz-box-shadow: inset 0px 0px 20px -14px rgba(0,0,0,1);-webkit-box-shadow: inset 0px 0px 20px -14px rgba(0,0,0,1);}'].join(' ');
$('').appendTo('head');
}
function applyRoundaboutCircleStyle(properties) {
return properties.styleName === 'roundaboutCircleStyle' && properties.layerName === '__DrawRoundaboutAngles';
}
function applyRoundaboutLineStyle(properties) {
return properties.styleName === 'roundaboutLineStyle' && properties.layerName === '__DrawRoundaboutAngles';
}
function applyRoundaboutLabelStyle(properties) {
return properties.styleName === 'roundaboutLabelStyle' && properties.layerName === '__DrawRoundaboutAngles';
}
const styleConfig = {
styleContext: {
labelText: (context) => {
return context?.feature?.properties?.style?.labelText;
},
strokeColor: (context) => {
return context?.feature?.properties?.style?.strokeColor;
},
strokeWidth: (context) => {
return context?.feature?.properties?.style?.strokeWidth;
},
labelColor: (context) => {
return context?.feature?.properties?.style?.labelColor;
}
},
styleRules: [
{
predicate: applyRoundaboutCircleStyle,
style: {
fillOpacity: 0.0,
strokeWidth: 10,
strokeColor: '${strokeColor}',
pointRadius: 0
}
},
{
predicate: applyRoundaboutLineStyle,
style: {
strokeWidth: 2,
strokeColor: '${strokeColor}',
pointRadius: 0
}
},
{
predicate: applyRoundaboutLabelStyle,
style: {
label: '${labelText}',
labelOutlineColor: '#FFFFFF',
labelOutlineWidth: 3,
fontFamily: 'Tahoma, Courier New',
fontWeight: 'bold',
fontColor: '${labelColor}',
fontSize: '10px'
}
}
]
};
})();