Straighten selected WME segment(s) by aligning along straight line between two end points and removing geometry nodes.
// ==UserScript==
// @name WME Straighten Up!
// @namespace https://greatest.deepsurf.us/users/166843
// @version 2026.04.06.01
// @description Straighten selected WME segment(s) by aligning along straight line between two end points and removing geometry nodes.
// @author JS55CT
// @match http*://*.waze.com/*editor*
// @exclude http*://*.waze.com/user/editor*
// @require https://greatest.deepsurf.us/scripts/509664/code/WME%20Utils%20-%20Bootstrap.js
// @require https://greatest.deepsurf.us/scripts/24851-wazewrap/code/WazeWrap.js
// @require https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js
// @grant GM_xmlhttpRequest
// @connect greatest.deepsurf.us
// @license GPLv3
// ==/UserScript==
// Original credit to jonny3D and impulse200, dBsooner
/* global I18n, GM_info, GM_xmlhttpRequest, WazeWrap, bootstrap, turf */
(async function () {
'use strict';
// ── Script metadata ──────────────────────────────────────────────────
const SHOW_UPDATE_MESSAGE = true;
const SCRIPT_VERSION_CHANGES = ['Small Bug Fix for Shortcuts at load time!'];
const SCRIPT_VERSION = GM_info.script.version.toString();
const DOWNLOAD_URL = 'https://greatest.deepsurf.us/scripts/388349-wme-straighten-up/code/WME%20Straighten%20Up!.user.js';
const SCRIPT_PAGE_URL = 'https://greatest.deepsurf.us/scripts/388349-wme-straighten-up/';
const SETTINGS_STORE_NAME = 'WMESU';
const LOAD_BEGIN_TIME = performance.now();
// ── Debug & execution state ──────────────────────────────────────────
let debug = false; // Set to false before release
let wmeSdk; // WME SDK instance - assigned by bootstrap()
// ── UI element cache ─────────────────────────────────────────────────
const elemCache = {
b: document.createElement('b'),
br: document.createElement('br'),
div: document.createElement('div'),
li: document.createElement('li'),
ol: document.createElement('ol'),
option: document.createElement('option'),
p: document.createElement('p'),
select: document.createElement('select'),
'wz-button': document.createElement('wz-button'),
'wz-card': document.createElement('wz-card'),
'wz-chip': document.createElement('wz-chip'),
'wz-chip-select': document.createElement('wz-chip-select'),
'wz-checkable-chip': document.createElement('wz-checkable-chip'),
};
// ── Settings & timeouts ──────────────────────────────────────────────
let settings = {};
const timeouts = { saveSettingsToStorage: undefined };
/**
* Batch-fetches node objects from SDK by ID array
* @param {number[]} nodeIds - Array of node IDs
* @returns {Object[]} Array of node objects from SDK
*/
function getNodesByIds(nodeIds) {
return nodeIds.map((nodeId) => wmeSdk.DataModel.Nodes.getById({ nodeId }));
}
/**
* Batch-fetches segment objects from SDK by ID array
* @param {number[]} segmentIds - Array of segment IDs
* @returns {Object[]} Array of segment objects from SDK
*/
function getSegmentsByIds(segmentIds) {
const segments = segmentIds.map((segmentId) => {
const seg = wmeSdk.DataModel.Segments.getById({ segmentId });
return seg;
});
return segments;
}
/**
* Detects if selected segments form a continuous connected path
* Returns true if segments have multiple disconnected components
* @param {Object[]} segments - Array of segment objects
* @returns {boolean} True if multiple connected components detected (non-continuous)
*/
function hasMultipleConnectedComponents(segments) {
if (!segments || segments.length <= 1) {
return false;
}
try {
// Build a map of node IDs to segments that use that node
const nodeToSegments = {};
segments.forEach((seg) => {
if (!seg?.fromNodeId || !seg?.toNodeId) {
logWarning(`Segment ${seg?.id} missing node IDs, skipping connectivity check`);
return;
}
if (!nodeToSegments[seg.fromNodeId]) nodeToSegments[seg.fromNodeId] = [];
if (!nodeToSegments[seg.toNodeId]) nodeToSegments[seg.toNodeId] = [];
nodeToSegments[seg.fromNodeId].push(seg.id);
nodeToSegments[seg.toNodeId].push(seg.id);
});
// Track which segments belong to which connected component using union-find
const componentMap = new Map(); // segmentId -> componentId
let componentCount = 0;
// Assign segments to connected components
const visited = new Set();
for (const segment of segments) {
if (visited.has(segment.id)) continue;
// BFS to find all segments in this connected component
const queue = [segment.id];
const component = componentCount++;
while (queue.length > 0) {
const segId = queue.shift();
if (visited.has(segId)) continue;
visited.add(segId);
componentMap.set(segId, component);
// Find the actual segment object
const seg = segments.find((s) => s.id === segId);
if (!seg) continue;
// Find other segments connected through this segment's nodes
const connectedNodeIds = [seg.fromNodeId, seg.toNodeId];
connectedNodeIds.forEach((nodeId) => {
if (nodeToSegments[nodeId]) {
nodeToSegments[nodeId].forEach((connectedSegId) => {
if (!visited.has(connectedSegId)) {
queue.push(connectedSegId);
}
});
}
});
}
}
const isNonContinuous = componentCount > 1;
logDebug(`Segment connectivity check: ${componentCount} connected component(s) - ${isNonContinuous ? 'NON-CONTINUOUS' : 'continuous'}`);
return isNonContinuous;
} catch (err) {
logError('Error checking segment connectivity:', err);
return false; // Assume continuous on error to allow proceeding
}
}
// ===== SHORTCUT VALIDATION & MIGRATION =====
/**
* Validates and migrates shortcut from any format to { raw, combo }
* Handles old string format, new object format, and invalid data
* @param {*} shortcutValue - Shortcut value from any source (string, object, etc)
* @param {string} source - Source label for logging ("localStorage", "server", etc)
* @returns {{ raw: string|null, combo: string|null }} - Validated/migrated shortcut
*/
function validateAndMigrateShortcut(shortcutValue, source = 'settings') {
if (!shortcutValue) {
return { raw: null, combo: null };
}
// Handle stringified JSON (edge case)
if (typeof shortcutValue === 'string') {
try {
// Try to parse if it's a stringified object
if (shortcutValue.startsWith('{')) {
shortcutValue = JSON.parse(shortcutValue);
} else {
// Old format: string value from previous version (e.g., "A+X")
logDebug(`Detected old shortcut format (${source}): "${shortcutValue}"`);
const raw = comboToRawKeycodes(shortcutValue);
const combo = shortcutKeycodesToCombo(raw);
if (raw && combo) {
logDebug(`Migrated shortcut from old format: RAW="${raw}", COMBO="${combo}"`);
return { raw, combo };
} else {
logWarning(`Failed to migrate old shortcut format (${source}), resetting to null`);
return { raw: null, combo: null };
}
}
} catch (e) {
logWarning(`Failed to parse shortcut string (${source}), resetting to null`);
return { raw: null, combo: null };
}
}
if (typeof shortcutValue === 'object' && shortcutValue !== null) {
// New format: should be { raw, combo }
if (typeof shortcutValue.raw === 'string' && typeof shortcutValue.combo === 'string') {
// Valid new format
logDebug(`Loaded shortcut (${source}, valid): RAW="${shortcutValue.raw}", COMBO="${shortcutValue.combo}"`);
return { raw: shortcutValue.raw, combo: shortcutValue.combo };
}
if ((shortcutValue.raw === null || shortcutValue.raw === undefined) && (shortcutValue.combo === null || shortcutValue.combo === undefined)) {
// Valid: no shortcut set
logDebug(`Loaded shortcut (${source}): (none)`);
return { raw: null, combo: null };
}
// Invalid structure
logWarning(`Invalid shortcut format (${source}), resetting to null`);
return { raw: null, combo: null };
}
// Invalid type
logWarning(`Invalid shortcut type (${source}): ${typeof shortcutValue}, resetting to null`);
return { raw: null, combo: null };
}
// ===== SHORTCUT HANDLING WITH SDK FIX =====
// The SDK returns different formats at different times, so we normalize to both RAW and COMBO formats
// RAW: "modifier,keycode" (e.g., "0,48", "4,88", "3,75") - for consistent storage
// COMBO: "key" or "MOD+key" (e.g., "0", "A+X", "CS+K") - for display and SDK registration
const MOD_LOOKUP = { C: 1, S: 2, A: 4 };
const MOD_FLAGS = [
{ flag: 1, char: 'C' },
{ flag: 2, char: 'S' },
{ flag: 4, char: 'A' },
];
const KEYCODE_MAP = Object.fromEntries([...Array.from({ length: 26 }, (_, i) => [65 + i, String.fromCharCode(65 + i)]), ...Array.from({ length: 10 }, (_, i) => [48 + i, String(i)])]);
/**
* Converts SDK combo/raw format to normalized RAW format "modifier,keycode"
* Handles inconsistent SDK return values (sometimes combo, sometimes raw)
*/
function comboToRawKeycodes(comboStr) {
if (!comboStr || typeof comboStr !== 'string') return comboStr;
// Already in raw form (modifier,keycode)
if (/^\d+,\d+$/.test(comboStr)) return comboStr;
// Single digit/letter (no modifiers) - SDK returns "0" but we need "0,48"
if (/^[A-Z0-9]$/.test(comboStr)) {
return `0,${comboStr.charCodeAt(0)}`;
}
// Combo format like "A+X", "CS+K", etc.
const match = comboStr.match(/^([ACS]+)\+([A-Z0-9])$/);
if (!match) return comboStr;
const [, modStr, keyStr] = match;
const modValue = modStr.split('').reduce((acc, m) => acc | (MOD_LOOKUP[m] || 0), 0);
return `${modValue},${keyStr.charCodeAt(0)}`;
}
/**
* Converts RAW format "modifier,keycode" to human-readable COMBO format
* Used for display and SDK registration
*/
function shortcutKeycodesToCombo(keycodeStr) {
if (!keycodeStr || keycodeStr === 'None') return null;
// Already in combo form
if (/^([ACS]+\+)?[A-Z0-9]$/.test(keycodeStr)) return keycodeStr;
// Handle raw format "modifier,keycode"
const parts = keycodeStr.split(',');
if (parts.length !== 2) return keycodeStr;
const intMod = parseInt(parts[0], 10);
const keyNum = parseInt(parts[1], 10);
if (isNaN(intMod) || isNaN(keyNum)) return keycodeStr;
const modLetters = MOD_FLAGS.filter(({ flag }) => intMod & flag)
.map(({ char }) => char)
.join('');
const keyChar = KEYCODE_MAP[keyNum] || String(keyNum);
return modLetters ? `${modLetters}+${keyChar}` : keyChar;
}
/**
* Logs a message to console with script name prefix
* @param {string} message - Message to log
* @param {*} data - Optional data object to log
*/
function log(message, data = '') {
console.log(`${GM_info.script.name}:`, message, data);
}
/**
* Logs an error to console with Error object
* @param {string} message - Error message
* @param {*} data - Optional error details
*/
function logError(message, data = '') {
console.error(`${GM_info.script.name}:`, new Error(message), data);
}
/**
* Logs a warning to console
* @param {string} message - Warning message
* @param {*} data - Optional warning details
*/
function logWarning(message, data = '') {
console.warn(`${GM_info.script.name}:`, message, data);
}
/**
* Logs a debug message (only when debug=true)
* @param {string} message - Debug message
* @param {*} data - Optional debug data
*/
function logDebug(message, data = '') {
if (debug) log(message, data);
}
/**
* Deep or shallow merge objects (like jQuery.extend)
* @param {boolean|object} [deep=false] - If true, do deep merge; otherwise first param is source
* @param {...object} objects - Objects to merge
* @returns {object} Merged object
*/
function $extend(...args) {
const extended = {},
deep = Object.prototype.toString.call(args[0]) === '[object Boolean]' ? args[0] : false,
merge = function (obj) {
Object.keys(obj).forEach((prop) => {
if (Object.prototype.hasOwnProperty.call(obj, prop)) {
if (deep && Object.prototype.toString.call(obj[prop]) === '[object Object]') extended[prop] = $extend(true, extended[prop], obj[prop]);
else if (obj[prop] !== undefined && obj[prop] !== null) extended[prop] = obj[prop];
}
});
};
for (let i = deep ? 1 : 0, { length } = args; i < length; i++) {
if (args[i]) merge(args[i]);
}
return extended;
}
/**
* Creates a DOM element with attributes and event listeners
* @param {string} type - Element type cached in elemCache (div, button, p, etc.)
* @param {object} attrs - Attributes to set (class, id, textContent, innerHTML, disabled, checked, etc.)
* @param {object[]} eventListener - Array of {eventName: callback} objects to attach as listeners
* @returns {Element} Configured DOM element
*/
function createElem(type = '', attrs = {}, eventListener = []) {
const el = elemCache[type]?.cloneNode(false) || elemCache.div.cloneNode(false),
applyEventListeners = function ([evt, cb]) {
return this.addEventListener(evt, cb);
};
Object.keys(attrs).forEach((attr) => {
if (attrs[attr] !== undefined && attrs[attr] !== 'undefined' && attrs[attr] !== null && attrs[attr] !== 'null') {
if (attr === 'disabled' || attr === 'checked' || attr === 'selected' || attr === 'textContent' || attr === 'innerHTML') el[attr] = attrs[attr];
else el.setAttribute(attr, attrs[attr]);
}
});
if (eventListener.length > 0) {
eventListener.forEach((obj) => {
Object.entries(obj).map(applyEventListeners.bind(el));
});
}
return el;
}
/**
* Clears a pending timeout
* @param {Object} obj - Timeout info {timeout: 'name', toIndex: optional}
*/
function checkTimeout(obj) {
if (obj.toIndex) {
if (timeouts[obj.timeout]?.[obj.toIndex]) {
window.clearTimeout(timeouts[obj.timeout][obj.toIndex]);
delete timeouts[obj.timeout][obj.toIndex];
}
} else {
if (timeouts[obj.timeout]) window.clearTimeout(timeouts[obj.timeout]);
timeouts[obj.timeout] = undefined;
}
}
/**
* Loads user settings from localStorage and merges with server settings
* Validates and migrates old shortcut format if needed
* @async
* @returns {Promise<void>}
*/
async function loadSettingsFromStorage() {
const defaultSettings = {
conflictingNames: 'warning',
longJnMove: 'warning',
microDogLegs: 'warning',
nonContinuousSelection: 'warning',
sanityCheck: 'warning',
simplifyTolerance: 1, // Tolerance in meters: 1 (Low) to 10 (Max)
runStraightenUpShortcut: { raw: null, combo: null }, // Store both formats like ZoomShortcuts
lastSaved: 0,
lastVersion: undefined,
},
loadedSettings = JSON.parse(localStorage.getItem(SETTINGS_STORE_NAME));
settings = $extend(true, {}, defaultSettings, loadedSettings);
// Validate and migrate shortcut format from localStorage
const migrated = validateAndMigrateShortcut(settings.runStraightenUpShortcut, 'localStorage');
const needsMigration = JSON.stringify(settings.runStraightenUpShortcut) !== JSON.stringify(migrated);
settings.runStraightenUpShortcut = migrated;
// Save migrated settings so we don't need to migrate again on next load
if (needsMigration) {
settings.lastSaved = Date.now();
localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(settings));
logDebug('Settings migrated and saved to localStorage');
}
timeouts.saveSettingsToStorage = window.setTimeout(saveSettingsToStorage, 5000);
return Promise.resolve();
}
/**
* Saves user settings to localStorage
* Queries SDK for current shortcut state to detect user changes
*/
function saveSettingsToStorage() {
checkTimeout({ timeout: 'saveSettingsToStorage' });
if (localStorage) {
// Query SDK for current shortcut value (in case user changed it)
if (wmeSdk && wmeSdk.Shortcuts && wmeSdk.Shortcuts.getAllShortcuts) {
try {
const allShortcuts = wmeSdk.Shortcuts.getAllShortcuts();
const suShortcut = allShortcuts.find((s) => s.shortcutId === 'runStraightenUpShortcut');
if (suShortcut) {
const sdkValue = suShortcut.shortcutKeys;
const raw = comboToRawKeycodes(sdkValue);
const combo = shortcutKeycodesToCombo(raw);
const newShortcut = { raw, combo };
// Only log and update if value actually changed
if (JSON.stringify(settings.runStraightenUpShortcut) !== JSON.stringify(newShortcut)) {
logDebug(`Shortcut changed in SDK: "${sdkValue}" → raw="${raw}", combo="${combo}"`);
settings.runStraightenUpShortcut = newShortcut;
}
}
} catch (err) {
logError('Failed to query shortcut from SDK:', err);
}
}
settings.lastVersion = SCRIPT_VERSION;
settings.lastSaved = Date.now();
localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(settings));
logDebug('Settings saved.');
}
}
/**
* Displays "What's New" update notification on version change
*/
function showScriptInfoAlert() {
if (SHOW_UPDATE_MESSAGE && SCRIPT_VERSION !== settings.lastVersion) {
let releaseNotes = "<p>What's New:</p>";
if (SCRIPT_VERSION_CHANGES.length > 0) {
releaseNotes += '<ul>';
for (let idx = 0; idx < SCRIPT_VERSION_CHANGES.length; idx++) releaseNotes += `<li>${SCRIPT_VERSION_CHANGES[idx]}</li>`;
releaseNotes += '</ul>';
} else {
releaseNotes += '<ul><li>Nothing major.</li></ul>';
}
WazeWrap.Interface.ShowScriptUpdate(GM_info.script.name, SCRIPT_VERSION, releaseNotes, SCRIPT_PAGE_URL);
// Update version after alert is shown
settings.lastVersion = SCRIPT_VERSION;
settings.lastSaved = Date.now();
localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(settings));
}
}
/**
* Determines direction indicator between two coordinates
* @param {number} a - First coordinate
* @param {number} b - Second coordinate
* @returns {number} -1 (a>b), 0 (equal), 1 (a<b)
*/
function getDeltaDirect(a, b) {
let d = 0.0;
if (a < b) d = 1.0;
else if (a > b) d = -1.0;
return d;
}
/**
* Checks if selected segments share at least one street ID (primary or alternate)
* First segment establishes the "acceptable street IDs" pool (primary + all alternates)
* All subsequent segments must have at least one street ID matching that pool
* @param {Object[]} segmentSelectionArr - Array of segment objects
* @returns {boolean} True if all segments have name continuity with first segment
*/
function checkNameContinuity(segmentSelectionArr = []) {
const streetIds = [],
streetIdsForEach = (streetId) => {
streetIds.push(streetId);
};
for (let idx = 0, { length } = segmentSelectionArr; idx < length; idx++) {
if (idx > 0) {
if (segmentSelectionArr[idx].primaryStreetId > 0 && streetIds.includes(segmentSelectionArr[idx].primaryStreetId))
// eslint-disable-next-line no-continue
continue;
const segStreetIds = segmentSelectionArr[idx].alternateStreetIds || [];
if (segStreetIds.length > 0) {
let included = false;
for (let idx2 = 0, len = segStreetIds.length; idx2 < len; idx2++) {
included = streetIds.includes(segStreetIds[idx2]);
if (included) break;
}
if (included === true)
// eslint-disable-next-line no-continue
continue;
else return false;
}
return false;
}
if (idx === 0) {
if (segmentSelectionArr[idx].primaryStreetId > 0) streetIds.push(segmentSelectionArr[idx].primaryStreetId);
const segStreetIds0 = segmentSelectionArr[idx].alternateStreetIds || [];
if (segStreetIds0.length > 0) segStreetIds0.forEach(streetIdsForEach);
}
}
return true;
}
/**
* Calculates distance between two WGS84 (EPSG:4326) coordinates using Turf.js
* Wrapper around turf.distance() for compatibility with existing code
* @param {number} lon1 - Longitude 1
* @param {number} lat1 - Latitude 1
* @param {number} lon2 - Longitude 2
* @param {number} lat2 - Latitude 2
* @param {string} [measurement='kilometers'] - Unit: 'meters', 'miles', 'feet', 'kilometers', 'nautical miles', 'degrees', or 'radians'
* @returns {number} Distance in specified unit
*/
function distanceBetweenPoints(lon1, lat1, lon2, lat2, measurement = 'kilometers') {
// Turf.distance expects [lon, lat] coordinates
const from = [lon1, lat1];
const to = [lon2, lat2];
// Map measurement names to Turf units (turf uses 'meters', 'miles', 'feet', etc.)
const unitMap = {
meters: 'meters',
miles: 'miles',
feet: 'feet',
kilometers: 'kilometers',
nm: 'nauticalmiles',
'nautical miles': 'nauticalmiles',
degrees: 'degrees',
radians: 'radians',
};
const turfUnit = unitMap[measurement] || 'kilometers';
return turf.distance(from, to, { units: turfUnit });
}
/**
* Calculates angle at point2 using dot product of vectors
* Used for detecting nearly-straight geometry nodes
* @param {number[]} point1 - First point [lon, lat]
* @param {number[]} point2 - Middle point (vertex) [lon, lat]
* @param {number[]} point3 - Third point [lon, lat]
* @returns {number} Angle in degrees (0-180)
*/
/**
* Detects micro dog legs: geometry nodes within 2m of junction nodes
* Indicates possible mapping issues that should be fixed before straightening
* @param {number[]} distinctNodes - Array of node IDs to check
* @param {number} singleSegmentId - Optional: only check this segment
* @returns {boolean} True if micro dog legs detected
*/
/**
* Checks if any geometry nodes are within 2m of segment junction nodes
* Used by both single-segment and multi-segment straightening paths
* @param {Object[]} segments - Array of segment objects to check
* @returns {boolean} True if any geometry node is < 2m from a junction
*/
function checkSegmentsForMicroDogLegs(segments) {
if (!segments || segments.length === 0) return false;
for (let segIdx = 0; segIdx < segments.length; segIdx++) {
const seg = segments[segIdx];
if (!seg || !seg.geometry || !seg.geometry.coordinates) continue;
const coords = seg.geometry.coordinates;
if (coords.length < 3) continue; // Need at least 3 nodes to have geometry nodes
const fromNodeCoord = coords[0];
const toNodeCoord = coords[coords.length - 1];
// Check each geometry node (skip first and last which are endpoints)
for (let i = 1; i < coords.length - 1; i++) {
const coord = coords[i];
const distToFromNode = distanceBetweenPoints(coord[0], coord[1], fromNodeCoord[0], fromNodeCoord[1], 'meters');
const distToToNode = distanceBetweenPoints(coord[0], coord[1], toNodeCoord[0], toNodeCoord[1], 'meters');
const minDist = Math.min(distToFromNode, distToToNode);
if (minDist < 2) {
logDebug(`Micro dog leg: Segment ${seg.id}, node ${i} is ${minDist.toFixed(2)}m from junction`);
return true;
}
}
}
return false;
}
/**
* Main straightening algorithm: aligns segments along line from endpoint to endpoint
* Removes intermediate geometry nodes and moves junction nodes to align with endpoints
* Performs multiple validation checks (name continuity, micro dog legs, long moves, etc.)
* @param {boolean} sanityContinue - User confirmed sanity check (>10 segments)
* @param {boolean} nonContinuousContinue - User confirmed non-continuous selection
* @param {boolean} conflictingNamesContinue - User confirmed conflicting street names
* @param {boolean} microDogLegsContinue - User confirmed micro dog legs present
* @param {boolean} longJnMoveContinue - User confirmed long junction node moves
* @param {Object} passedObj - Pre-calculated straightening data (internal use)
* @returns {void}
*/
function doStraightenSegments(sanityContinue, nonContinuousContinue, conflictingNamesContinue, microDogLegsContinue, longJnMoveContinue, passedObj) {
log('doStraightenSegments called');
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 1: RETRIEVE SELECTION
// Gets the currently selected segments from the WME editor
// ════════════════════════════════════════════════════════════════════════════════
const selection = wmeSdk.Editing.getSelection();
const segments = selection && selection.objectType === 'segment' && selection.ids ? getSegmentsByIds(selection.ids) : [];
const segmentSelection = {
segments: segments,
multipleConnectedComponents: hasMultipleConnectedComponents(segments),
};
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 2: EXECUTE STRAIGHTENING (if all validations passed)
// Only runs when passedObj exists, meaning user has confirmed all warning dialogs
// Applies the pre-calculated geometry updates and node movements
// ════════════════════════════════════════════════════════════════════════════════
if (longJnMoveContinue && passedObj) {
logDebug('Processing with passed object (continuing from confirmation)');
const { segmentsToRemoveGeometryArr, nodesToMoveArr, distinctNodes, endPointNodeIds } = passedObj;
logDebug(`${I18n.t('wmesu.log.StraighteningSegments')}: ${distinctNodes.join(', ')} (${distinctNodes.length})`);
logDebug(`${I18n.t('wmesu.log.EndPoints')}: ${endPointNodeIds.join(' & ')}`);
logDebug(`Segments to update: ${segmentsToRemoveGeometryArr?.length || 0}, Nodes to move: ${nodesToMoveArr?.length || 0}`);
if (segmentsToRemoveGeometryArr?.length > 0) {
logDebug(`Updating geometry for ${segmentsToRemoveGeometryArr.length} segment(s)`);
// Use SDK method to update segment geometry
segmentsToRemoveGeometryArr.forEach((obj) => {
try {
wmeSdk.DataModel.Segments.updateSegment({
segmentId: obj.segment.id,
geometry: obj.newGeo,
});
logDebug(`Removed geometry from segment ${obj.segment.id}: ${obj.segment.geometry.coordinates.length} → ${obj.newGeo.coordinates.length} nodes`);
} catch (err) {
logError(`Failed to update segment ${obj.segment.id}:`, err);
}
});
}
if (nodesToMoveArr?.length > 0) {
// Use SDK method to move nodes
let straightened = false;
nodesToMoveArr.forEach((node) => {
if (Math.abs(node.geometry.coordinates[0] - node.nodeGeo.coordinates[0]) > 0.00000001 || Math.abs(node.geometry.coordinates[1] - node.nodeGeo.coordinates[1]) > 0.00000001) {
try {
wmeSdk.DataModel.Nodes.moveNode({
id: node.node.id,
geometry: node.nodeGeo,
});
straightened = true;
} catch (err) {
logError(`Failed to move node ${node.node.id}:`, err);
}
}
});
if (!straightened) {
logDebug(I18n.t('wmesu.log.AllNodesStraight'));
WazeWrap.Alerts.info(GM_info.script.name, I18n.t('wmesu.log.AllNodesStraight'));
}
}
} else if (segmentSelection.segments.length > 1) {
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 3: MULTI-SEGMENT PROCESSING WITH VALIDATION CHECKS
// ════════════════════════════════════════════════════════════════════════════════
logDebug(`Processing ${segmentSelection.segments.length} segments`);
// Arrays to collect segments and nodes that need updating
const segmentsToRemoveGeometryArr = [], // Segments needing geometry node removal
nodesToMoveArr = []; // Junction nodes that need repositioning
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 1: SANITY CHECK
// Prevents accidental mass edits by requiring confirmation for >10 segments
// Flag: sanityContinue - stays true once confirmed, prevents repeated prompts
// ────────────────────────────────────────────────────────────────────────────────
if (segmentSelection.segments.length > 10 && !sanityContinue) {
logDebug('Sanity check: more than 10 segments');
if (settings.sanityCheck === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.TooManySegments'));
return;
}
if (settings.sanityCheck === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.SanityCheckConfirm'),
() => {
doStraightenSegments(true, false, false, false, false, undefined);
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
sanityContinue = true;
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 2: NON-CONTINUOUS SEGMENTS
// Detects if selected segments are not all connected to each other
// Can cause unexpected results when straightening disconnected groups
// Flag: nonContinuousContinue - stays true once confirmed
// ────────────────────────────────────────────────────────────────────────────────
if (segmentSelection.multipleConnectedComponents === true && !nonContinuousContinue) {
if (settings.nonContinuousSelection === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.NonContinuous'));
return;
}
if (settings.nonContinuousSelection === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.NonContinuousConfirm'),
() => {
doStraightenSegments(sanityContinue, true, false, false, false, undefined);
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
nonContinuousContinue = true;
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 3: NAME CONTINUITY
// Ensures all selected segments share at least one street name (primary or alternate)
// Straightening segments with different street names could create mapping errors
// Flag: conflictingNamesContinue - stays true once confirmed
// ────────────────────────────────────────────────────────────────────────────────
if (settings.conflictingNames !== 'nowarning') {
const continuousNames = checkNameContinuity(segmentSelection.segments);
if (!continuousNames && !conflictingNamesContinue && settings.conflictingNames === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.ConflictingNames'));
return;
}
if (!continuousNames && !conflictingNamesContinue && settings.conflictingNames === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.ConflictingNamesConfirm'),
() => {
doStraightenSegments(sanityContinue, nonContinuousContinue, true, false, false, undefined);
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
conflictingNamesContinue = true;
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 4: DATA PREPARATION & GEOMETRY SIMPLIFICATION
// Collects all endpoint nodes and prepares simplified geometry
// ════════════════════════════════════════════════════════════════════════════════
// allNodeIds: every node ID from every segment's from/to endpoints (includes duplicates)
// dupNodeIds: node IDs that appear multiple times (junction nodes connecting segments)
// endPointNodeIds: node IDs appearing only once (true start/end of selection)
const allNodeIds = [],
dupNodeIds = [];
let endPointNodeIds,
longMove = false;
// Collect all endpoint nodes and prepare geometry for simplification
for (let idx = 0, { length } = segmentSelection.segments; idx < length; idx++) {
allNodeIds.push(segmentSelection.segments[idx].fromNodeId);
allNodeIds.push(segmentSelection.segments[idx].toNodeId);
// Process all segments (already filtered by objectType === 'segment')
const newGeo = structuredClone(segmentSelection.segments[idx].geometry);
// Remove the geometry nodes
if (newGeo.coordinates.length > 2) {
newGeo.coordinates.splice(1, newGeo.coordinates.length - 2);
segmentsToRemoveGeometryArr.push({ segment: segmentSelection.segments[idx], geometry: segmentSelection.segments[idx].geometry, newGeo });
}
}
// Identify which nodes appear more than once (these are junction nodes connecting segments)
allNodeIds.forEach((nodeId, idx) => {
if (allNodeIds.indexOf(nodeId, idx + 1) > -1) {
if (!dupNodeIds.includes(nodeId)) dupNodeIds.push(nodeId);
}
});
// distinctNodes: unique node IDs in the selection (removes duplicates)
// These will be used to calculate straightening positions for all junction nodes
const distinctNodes = [...new Set(allNodeIds)];
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 4: MICRO DOG LEGS
// Detects if any geometry nodes are within 2m of segment endpoints (potential mapping issues)
// Straightening with micro dog legs could make the issues worse
// Flag: microDogLegsContinue - stays true once confirmed
// ────────────────────────────────────────────────────────────────────────────────
if (!microDogLegsContinue && checkSegmentsForMicroDogLegs(segmentSelection.segments) === true) {
if (settings.microDogLegs === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.MicroDogLegs'));
return;
}
if (settings.microDogLegs === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.MicroDogLegsConfirm'),
() => {
doStraightenSegments(sanityContinue, nonContinuousContinue, conflictingNamesContinue, true, false, undefined);
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
microDogLegsContinue = true;
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 5: IDENTIFY ENDPOINTS & CALCULATE STRAIGHTENING LINE
// Determines which nodes are the true endpoints and calculates the straightening line
// ════════════════════════════════════════════════════════════════════════════════
// Determine endpoint nodes based on segment connectivity
// If continuous: endpoints are nodes that appear only once (not junctions)
// If discontinuous: endpoints are first segment's start and last segment's end
if (segmentSelection.multipleConnectedComponents === false) endPointNodeIds = distinctNodes.filter((nodeId) => !dupNodeIds.includes(nodeId));
else endPointNodeIds = [segmentSelection.segments[0].fromNodeId, segmentSelection.segments[segmentSelection.segments.length - 1].toNodeId];
// Get the actual endpoint node objects and their coordinates
const endPointNodeObjs = getNodesByIds(endPointNodeIds),
endPointNode1Geo = structuredClone(endPointNodeObjs[0].geometry),
endPointNode2Geo = structuredClone(endPointNodeObjs[1].geometry);
// Normalize endpoints so endpoint1 is always westward (lower longitude) of endpoint2
// This ensures consistent straightening direction regardless of selection order
if (getDeltaDirect(endPointNode1Geo.coordinates[0], endPointNode2Geo.coordinates[0]) < 0) {
let [t] = endPointNode1Geo.coordinates;
[endPointNode1Geo.coordinates[0]] = endPointNode2Geo.coordinates;
endPointNode2Geo.coordinates[0] = t;
[, t] = endPointNode1Geo.coordinates;
[, endPointNode1Geo.coordinates[1]] = endPointNode2Geo.coordinates;
endPointNode2Geo.coordinates[1] = t;
endPointNodeIds.push(endPointNodeIds[0]);
endPointNodeIds.splice(0, 1);
endPointNodeObjs.push(endPointNodeObjs[0]);
endPointNodeObjs.splice(0, 1);
}
// Create straightening line as a Turf LineString for Turf.js perpendicular projection
// This line passes through both endpoint nodes and represents the straightening target
const straighteningLine = turf.lineString([endPointNode1Geo.coordinates, endPointNode2Geo.coordinates]);
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 6: CALCULATE NODE POSITIONS & DETECT LONG MOVES
// For each junction node: calculate its perpendicular projection onto the straightening line
// Also determines if any node would move >10m (triggers separate validation)
// Uses turf.nearestPointOnLine() to project each node onto the straightening line
// ════════════════════════════════════════════════════════════════════════════════
distinctNodes.forEach((nodeId) => {
if (!endPointNodeIds.includes(nodeId)) {
const node = wmeSdk.DataModel.Nodes.getById({ nodeId }),
nodeGeo = structuredClone(node.geometry);
// Use Turf to calculate perpendicular projection of this node onto the straightening line
const nodePoint = turf.point(node.geometry.coordinates);
const projectedPoint = turf.nearestPointOnLine(straighteningLine, nodePoint);
const projectedCoords = projectedPoint.geometry.coordinates;
nodeGeo.coordinates[0] = projectedCoords[0];
nodeGeo.coordinates[1] = projectedCoords[1];
const connectedSegObjs = {};
const segmentIds = node.segmentIds || [];
for (let idx = 0, { length } = segmentIds; idx < length; idx++) {
const segId = segmentIds[idx];
connectedSegObjs[segId] = structuredClone(wmeSdk.DataModel.Segments.getById({ segmentId: segId }).geometry);
}
// Calculate distance node would move to check for long moves (>10m)
const originalCoords = node.geometry.coordinates;
const moveDistance = distanceBetweenPoints(originalCoords[0], originalCoords[1], projectedCoords[0], projectedCoords[1], 'meters');
if (moveDistance > 10) longMove = true;
nodesToMoveArr.push({
node,
geometry: node.geometry,
nodeGeo,
connectedSegObjs,
});
}
});
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 5: LONG JUNCTION NODE MOVES
// Prevents accidentally moving junction nodes more than 10m (could create misalignment)
// Flag: longJnMoveContinue - stays true once confirmed
// When confirmed with passedObj, the actual updates are applied (Section 2)
// ────────────────────────────────────────────────────────────────────────────────
if (longMove && settings.longJnMove === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.LongJnMove'));
return;
}
if (longMove && settings.longJnMove === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.LongJnMoveConfirm'),
() => {
doStraightenSegments(sanityContinue, nonContinuousContinue, conflictingNamesContinue, microDogLegsContinue, true, {
segmentsToRemoveGeometryArr,
nodesToMoveArr,
distinctNodes,
endPointNodeIds,
});
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
doStraightenSegments(sanityContinue, nonContinuousContinue, conflictingNamesContinue, microDogLegsContinue, true, {
segmentsToRemoveGeometryArr,
nodesToMoveArr,
distinctNodes,
endPointNodeIds,
});
} else if (segmentSelection.segments.length === 1) {
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 7: SINGLE SEGMENT PROCESSING
// For a single segment: only removes geometry nodes (no junction node movement needed)
// Still performs micro dog leg check before proceeding
// ════════════════════════════════════════════════════════════════════════════════
const seg = segmentSelection.segments[0];
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 4B: MICRO DOG LEGS (single segment variant)
// Check if any geometry node is within 2m of a segment endpoint
// ────────────────────────────────────────────────────────────────────────────────
if (!microDogLegsContinue && checkSegmentsForMicroDogLegs([seg]) === true) {
if (settings.microDogLegs === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.MicroDogLegs'));
return;
}
if (settings.microDogLegs === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.MicroDogLegsConfirm'),
() => {
doStraightenSegments(sanityContinue, nonContinuousContinue, conflictingNamesContinue, true, false, undefined);
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
microDogLegsContinue = true;
const newGeo = structuredClone(seg.geometry);
// Remove the geometry nodes using SDK method
if (newGeo.coordinates.length > 2) {
const beforeCount = seg.geometry.coordinates.length;
newGeo.coordinates.splice(1, newGeo.coordinates.length - 2);
wmeSdk.DataModel.Segments.updateSegment({
segmentId: seg.id,
geometry: newGeo,
});
logDebug(`Straightened segment ${seg.id}: ${beforeCount} → ${newGeo.coordinates.length} nodes`);
}
} else {
// ════════════════════════════════════════════════════════════════════════════════
// SECTION 8: NO VALID SEGMENTS SELECTED
// Alert user to select at least one segment before running the script
// ════════════════════════════════════════════════════════════════════════════════
logDebug('No segments selected or segments not found');
logWarning(I18n.t('wmesu.log.NoSegmentsSelected'));
}
}
/**
* Simplifies selected segments using Ramer-Douglas-Peucker algorithm via Turf.js
* Preserves original path shape, detects intentional micro dog legs
* @param {boolean} sanityContinue - If true, skip sanity check confirmation prompt
* @param {boolean} microDogLegsContinue - If true, skip micro dog leg confirmation prompt
* @param {number} tolerance - Simplification tolerance in meters (default 1m)
*/
function doSimplifySegments(sanityContinue = false, microDogLegsContinue = false, tolerance = 1) {
log('doSimplifySegments called');
try {
// Get selected segments
const selection = wmeSdk.Editing.getSelection();
if (!selection || selection.objectType !== 'segment' || !selection.ids || selection.ids.length === 0) {
logWarning(I18n.t('wmesu.log.NoSegmentsSelected'));
return;
}
const segmentIds = selection.ids;
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 1: SANITY CHECK (>10 segments)
// Prevents accidental mass edits by requiring confirmation for large selections
// ────────────────────────────────────────────────────────────────────────────────
if (segmentIds.length > 10 && !sanityContinue) {
logDebug('Sanity check: more than 10 segments');
if (settings.sanityCheck === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, I18n.t('wmesu.error.TooManySegments'));
return;
}
if (settings.sanityCheck === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
I18n.t('wmesu.prompts.SanityCheckConfirm'),
() => {
doSimplifySegments(true, false, tolerance); // Recursive call with sanityContinue=true
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
sanityContinue = true;
// ────────────────────────────────────────────────────────────────────────────────
// VALIDATION CHECK 2: MICRO DOG LEGS (before simplification)
// Check ALL geometry nodes for micro dog legs BEFORE running RDP
// This catches intentional micro dog legs that might be removed by simplification
// ────────────────────────────────────────────────────────────────────────────────
const segmentsToCheck = getSegmentsByIds(segmentIds);
if (!microDogLegsContinue && checkSegmentsForMicroDogLegs(segmentsToCheck) === true) {
logDebug('Micro dog legs detected in selected segments');
if (settings.microDogLegs === 'error') {
WazeWrap.Alerts.error(GM_info.script.name, 'Geometry nodes within 2m of junctions detected (possible micro dog legs). Simplification blocked by settings.');
return;
}
if (settings.microDogLegs === 'warning') {
WazeWrap.Alerts.confirm(
GM_info.script.name,
'Geometry nodes detected within 2m of junctions (possible micro dog legs). Continue simplifying anyway?',
() => {
doSimplifySegments(sanityContinue, true, tolerance); // Recursive call with microDogLegsContinue=true
},
() => {},
I18n.t('wmesu.common.Yes'),
I18n.t('wmesu.common.No'),
);
return;
}
}
microDogLegsContinue = true;
const segmentsToUpdate = []; // Store segments with changes
// Convert tolerance from meters to degrees for Turf.simplify()
// Coordinates are in WGS84 (degrees), so we need to convert the tolerance
// At the equator, 1 meter ≈ 1/111111 degrees
// At higher latitudes, this varies, but we'll use equatorial approximation for simplicity
const toleranceDegrees = tolerance / 111111;
// Process each segment independently
for (let segIdx = 0; segIdx < segmentIds.length; segIdx++) {
const segmentId = segmentIds[segIdx];
const segment = wmeSdk.DataModel.Segments.getById({ segmentId });
if (!segment || !segment.geometry || !segment.geometry.coordinates) continue;
const coords = segment.geometry.coordinates;
if (coords.length < 3) continue;
// Use turf.simplify() with Ramer-Douglas-Peucker algorithm
const lineString = turf.lineString(coords);
const simplified = turf.simplify(lineString, { tolerance: toleranceDegrees });
const newCoords = simplified.geometry.coordinates;
// If no change, skip
if (newCoords.length === coords.length) continue;
// Identify removed nodes
const nodesToRemove = [];
for (let i = 0; i < coords.length; i++) {
const coord = coords[i];
const found = newCoords.some((nc) => nc[0] === coord[0] && nc[1] === coord[1]);
if (!found) nodesToRemove.push(i);
}
// Add to update list (micro dog legs already validated before RDP)
segmentsToUpdate.push({
segmentId,
originalCoordCount: coords.length,
newCoords,
nodesToRemove,
});
}
// Update segments (micro dog legs already validated before processing)
let successCount = 0;
for (let segUpdate of segmentsToUpdate) {
try {
wmeSdk.DataModel.Segments.updateSegment({
segmentId: segUpdate.segmentId,
geometry: {
type: 'LineString',
coordinates: segUpdate.newCoords,
},
});
successCount++;
} catch (err) {
logError(`Failed to simplify segment ${segUpdate.segmentId}: ${err.message}`);
}
}
// Show result
if (successCount > 0) {
const totalNodesRemoved = segmentsToUpdate.reduce((sum, s) => sum + s.nodesToRemove.length, 0);
WazeWrap.Alerts.info('WME Straighten Up! - Simplify', `Simplified ${successCount} segment(s): removed ${totalNodesRemoved} redundant node(s) with ${tolerance}m tolerance`);
log(`Simplification complete: ${successCount} segments, ${totalNodesRemoved} nodes removed`);
} else {
WazeWrap.Alerts.info('WME Straighten Up! - Simplify', 'No redundant nodes found to remove');
}
} catch (err) {
logError(`doSimplifySegments error: ${err.message}`);
WazeWrap.Alerts.error('WME Straighten Up! - Simplify', `Error: ${err.message}`);
}
}
/**
* Updates "Do It" button state based on current segment selection
* Enables button only when segments are selected
*/
/**
* Creates a card div with an icon header and returns { card, body }
*/
function makeCard(iconClass, title) {
const card = document.createElement('div');
card.className = 'su-card';
const cardHeader = document.createElement('div');
cardHeader.className = 'su-card-header';
const icon = document.createElement('i');
icon.className = `fa ${iconClass}`;
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
cardHeader.appendChild(icon);
cardHeader.appendChild(titleSpan);
card.appendChild(cardHeader);
const body = document.createElement('div');
body.className = 'su-card-body';
card.appendChild(body);
return { card, body };
}
/**
* Creates the "WME Straighten Up!" button panel for insertion into Segment Edit panel
* @returns {HTMLElement} form-group element containing the buttons
*/
function createSegmentEditButtonPanel() {
const formGroup = document.createElement('div');
formGroup.className = 'form-group wme-su-segment-edit-panel';
formGroup.style.marginBottom = '12px';
// Card with title
const buttonCard = makeCard('fa-arrows', 'WME Straighten Up!');
// Buttons container (flex row for side-by-side layout)
const buttonsContainer = document.createElement('div');
buttonsContainer.style.display = 'flex';
buttonsContainer.style.gap = '8px';
buttonsContainer.style.padding = '10px';
// "Straighten" button
const straightenBtn = createElem(
'wz-button',
{
id: 'WME-SU-SEGMENT-EDIT',
color: 'outline',
size: 'sm',
style: 'height: 26px;',
textContent: 'Straighten',
},
[{ click: doStraightenSegments }],
);
buttonsContainer.appendChild(straightenBtn);
// "Simplify" button
const simplifyBtn = createElem(
'wz-button',
{
id: 'WME-SU-SIMPLIFY',
color: 'outline',
size: 'sm',
style: 'height: 28px;',
textContent: 'Simplify',
},
[{ click: () => doSimplifySegments(false, false, settings.simplifyTolerance) }],
);
buttonsContainer.appendChild(simplifyBtn);
buttonCard.body.appendChild(buttonsContainer);
formGroup.appendChild(buttonCard.card);
return formGroup;
}
// MutationObserver for watching form changes and reinserting buttons
let formObserver = null;
let isInsertingButton = false; // Guard against infinite loops
function setupButtonObserver(contentsDiv) {
// Stop any existing observer
if (formObserver) {
formObserver.disconnect();
formObserver = null;
}
// Create new observer that watches for form changes
formObserver = new MutationObserver((_mutations) => {
// Skip if we're already in the process of inserting
if (isInsertingButton) return;
// Look for form changes or segment-feature-editor changes
const form = contentsDiv.querySelector('form.attributes-form') || contentsDiv.querySelector('form');
const existingPanel = contentsDiv.querySelector('.wme-su-segment-edit-panel');
if (form && !existingPanel) {
// Form exists but button doesn't - insert it
isInsertingButton = true;
try {
const panel = createSegmentEditButtonPanel();
form.insertAdjacentElement('afterend', panel);
} catch (err) {
logError(`Error inserting button via observer: ${err.message}`);
} finally {
isInsertingButton = false;
}
}
});
// Watch the contents div for form changes
formObserver.observe(contentsDiv, {
childList: true,
subtree: true,
attributes: false,
});
}
function insertSimplifyStreetGeometryButtons() {
try {
// Check if we actually have a selection
const selection = wmeSdk.Editing.getSelection();
if (!selection || selection.objectType !== 'segment' || !selection.ids || selection.ids.length === 0) {
return;
}
// Get the segment edit panel
const editPanel = document.getElementById('edit-panel');
const contentsDiv = editPanel?.querySelector('div.contents');
if (!contentsDiv) {
return;
}
// Remove any existing button panel before we add a new one
const existingPanel = contentsDiv.querySelector('.wme-su-segment-edit-panel');
if (existingPanel) {
existingPanel.remove();
}
// Try immediate insert
try {
const form = contentsDiv.querySelector('form.attributes-form') || contentsDiv.querySelector('form');
if (form) {
isInsertingButton = true;
try {
const panel = createSegmentEditButtonPanel();
form.insertAdjacentElement('afterend', panel);
} finally {
isInsertingButton = false;
}
}
} catch (err) {
logError(`Failed to insert button: ${err.message}`);
}
// Setup observer to handle future form changes
setupButtonObserver(contentsDiv);
} catch (err) {
logError(`insertSimplifyStreetGeometryButtons error: ${err.message}`);
}
}
/**
* Loads and registers i18n translations for UI strings
* @returns {Promise<void>}
*/
function loadTranslations() {
return new Promise((resolve) => {
const translations = {
en: {
StraightenUp: 'Straighten Up!',
StraightenUpTitle: 'Click here to straighten the selected segment(s) by removing geometry nodes and moving junction nodes as needed.',
common: {
DoIt: 'Do It',
From: 'from',
Help: 'Help',
No: 'No',
Note: 'Note',
NothingMajor: 'Nothing major.',
To: 'to',
Warning: 'Warning',
WhatsNew: "What's new",
Yes: 'Yes',
},
error: {
ConflictingNames:
'You selected segments that do not share at least one name in common amongst all the segments and have the conflicting names setting set to error. ' + 'Segments not straightened.',
LongJnMove:
'One or more of the junction nodes that were to be moved would have been moved further than 10m and you have the long junction node move setting set to ' +
'give error. Segments not straightened.',
MicroDogLegs: 'Geometry nodes within 2m of junctions detected (possible micro dog legs). Straightening blocked by settings.',
NonContinuous: 'You selected segments that are not all connected and have the non-continuous selected segments setting set to give error. Segments not straightened.',
TooManySegments: 'You selected too many segments and have the sanity check setting set to give error. Segments not straightened.',
},
help: {
Note01: 'This script uses the action manager, so changes can be undone before saving.',
Warning01: 'Enabling (Give warning, No warning) any of these settings can cause unexpected results. Use with caution!',
Step01: 'Select the starting segment.',
Step02: 'ALT+click the ending segment.',
Step02note: 'If the segments you wanted to straighten are not all selected, unselect them and start over using CTRL+click to select each segment instead.',
Step03: 'Click "Straighten up!" button in the sidebar.',
},
log: {
AllNodesStraight: "All junction nodes that would be moved are already considered 'straight'. No junction nodes were moved.",
EndPoints: 'End points',
MovingJunctionNode: 'Moving junction node',
NoSegmentsSelected: 'No segments selected.',
RemovedGeometryNodes: 'Removed geometry nodes for segment',
Segment: I18n.t('objects.segment.name'),
StraighteningSegments: 'Straightening segments',
},
prompts: {
ConflictingNamesConfirm: 'You selected segments that do not share at least one name in common amongst all the segments. Are you sure you wish to continue straightening?',
LongJnMoveConfirm: 'One or more of the junction nodes that are to be moved would be moved further than 10m. Are you sure you wish to continue straightening?',
MicroDogLegsConfirm: 'Geometry nodes detected within 2m of junctions (possible micro dog legs). Continue straightening anyway?',
NonContinuousConfirm: 'You selected segments that do not all connect. Are you sure you wish to continue straightening?',
SanityCheckConfirm: 'You selected many segments. Are you sure you wish to continue straightening?',
},
settings: {
GiveError: 'Give error',
GiveWarning: 'Give warning',
NoWarning: 'No warning',
ConflictingNames: 'Segments with conflicting names',
ConflictingNamesTitle: 'Select what to do if the selected segments do not share at least one name among their primary and alternate names (based on name, city and state).',
LongJnMove: 'Long junction node moves',
LongJnMoveTitle: 'Select what to do if one or more of the junction nodes would move further than 10m.',
MicroDogLegs: 'Possible micro doglegs (mDL)',
MicroDogLegsTitle: 'Select what to do if one or more of the junction nodes in the selection have a geometry node within 2m of itself, which is a possible micro dogleg (mDL).',
NonContinuousSelection: 'Non-continuous selected segments',
NonContinuousSelectionTitle: 'Select what to do if the selected segments are not continuous.',
SanityCheck: 'Sanity check',
SanityCheckTitle: 'Select what to do if you selected a many segments.',
},
},
ru: {
StraightenUp: 'Выпрямить сегменты!',
StraightenUpTitle: 'Нажмите, чтобы выпрямить выбранные сегменты, удалив лишние геометрические точки и переместив узлы перекрёстков в ровную линию.',
common: {
DoIt: 'Сделай это',
From: 'с',
Help: 'Помощь',
No: 'Нет',
Note: 'Примечание',
NothingMajor: 'Не критично.',
To: 'до',
Warning: 'Предупреждение',
WhatsNew: 'Что нового',
Yes: 'Да',
},
error: {
ConflictingNames: 'Вы выбрали сегменты, которые не имеют хотя бы одного общего названия улицы среди выделенных.' + 'Сегменты не были выпрямлены.',
LongJnMove:
'Для выпрямления сегментов, их узлы должны быть перемещены более чем на 10 м, но в настройках у вас установлено ограничение перемещения на такое большое ' +
'расстояние. Сегменты не были выпрямлены.',
MicroDogLegs:
'Один или несколько узлов выбранных сегментов имеют точку в пределах 2 метров. Обычно это признак “<a href=”https://wazeopedia.waze.com/wiki/Benelux/Junction_Arrows” target=”blank”>микроискривления</a>”.<br><br>' +
'В настройках для возможных микроискривлений у вас выставлено ограничение, чтобы выдать ошибку. Сегменты не были выпрямлены.',
NonContinuous: 'Вы выбрали сегменты, которые не соединены между собой, но в настройках у вас установлено ограничение для работы с такими сегментами. Сегменты не были ' + 'выпрямлены.',
TooManySegments: 'Вы выбрали слишком много сегментов, но в настройках у вас включено ограничение на количество одновременно обрабатываемых сегментов. Сегменты не были ' + 'выпрямлены.',
},
help: {
Note01: 'Этот скрипт использует историю действий, поэтому перед их сохранением изменения можно отменить.',
Warning01: 'Настройка любого из этих параметров в положение (Выдать предупреждение, Не предупреждать) может привести к неожиданным результатам. Используйте с осторожностью!',
Step01: 'Выделите начальный сегмент.',
Step02: 'При помощи Alt-кнопки, выделите конечный сегмент.',
Step02note: 'Если выделены не все нужные вам сегменты, при помощи Ctrl-кнопки можно дополнительно выделить или снять выделения сегментов.',
Step03: 'Нажмите ‘Выпрямить сегменты!’ на левой панели.',
},
log: {
AllNodesStraight: 'Все узлы, которые нужно было выпрямить, уже выровнены в линию. Сегменты оставлены без изменений.',
EndPoints: 'конечные точки',
MovingJunctionNode: 'Перемещение узла',
NoSegmentsSelected: 'Сегменты не выделены.',
RemovedGeometryNodes: 'Удалены лишние точки сегмента',
Segment: I18n.t('objects.segment.name'),
StraighteningSegments: 'Выпрямление сегментов',
},
prompts: {
ConflictingNamesConfirm: 'Вы выбрали сегменты, которые не имеют хотя бы одного общего названия среди всех сегментов. Вы уверены, что хотите продолжить выпрямление?',
LongJnMoveConfirm: 'Один или несколько узлов будут перемещены более, чем на 10 метров. Вы уверены, что хотите продолжить выпрямление?',
MicroDogLegsConfirm:
'Один или несколько узлов выбранных сегментов имеют точки в пределах 2 метров. Обычно это признак “<a href=”https://wazeopedia.waze.com/wiki/Benelux/Junction_Arrows” target=”blank”>микроискривления</a>”.<br>' +
'Такая точка может находиться в любом сегменте, соединенном с выбранными вами сегментами и узлами, а не только на них самих.<br><br>' +
'<b>Вы не должны продолжать до тех пор, пока не убедитесь, что у вас нет “микроискривлений”.<b><br><br>' +
'Вы уверены,что готовы продолжать выпрямление?',
NonContinuousConfirm: 'Вы выбрали сегменты, которые не соединяются друг с другом. Вы уверены, что хотите продолжить выпрямление?',
SanityCheckConfirm: 'Вы выбрали слишком много сегментов. Вы уверены, что хотите продолжить выпрямление?',
},
settings: {
GiveError: 'Выдать ошибку',
GiveWarning: 'Выдать предупреждение',
NoWarning: 'Не предупреждать',
ConflictingNames: 'Сегменты с разными названиями',
ConflictingNamesTitle:
'Выберите, что делать, если выбранные сегменты не содержат хотя бы одно название среди своих основных и альтернативных названий (на основе улицы, ' + 'города и района).',
LongJnMove: 'Перемещение узлов на большие расстояния',
LongJnMoveTitle: 'Выберите, что делать, если один или несколько узлов будут перемещаться дальше, чем на 10 метров.',
MicroDogLegs: 'Допускать “<a href=”https://wazeopedia.waze.com/wiki/Benelux/Junction_Arrows” target=”blank”>микроискривления</a>”',
MicroDogLegsTitle: 'Выберите, что делать, если один или несколько узлов соединения в выделении имеют точку в пределах 2 м от себя, что является возможным “микроискривлением”.',
NonContinuous: 'Не соединённые сегменты',
NonContinuousTitle: 'Выберите, что делать, если выбранные сегменты не соединены друг с другом.',
SanityCheck: 'Ограничение нагрузки',
SanityCheckTitle: 'Выберите, что делать, если вы выбрали слишком много сегментов.',
},
},
},
locale = I18n.currentLocale();
I18n.translations[locale].wmesu = translations.en;
translations['en-US'] = { ...translations.en };
I18n.translations[locale].wmesu = $extend(true, {}, translations.en, translations[locale]);
resolve();
});
}
/**
* Starts the script after SDK and WazeWrap initialization
* Loads settings, registers UI components, shortcuts, and event listeners
* @async
*/
async function start() {
log('Initializing.');
// Check user rank using SDK
const userInfo = wmeSdk.State.getUserInfo();
logDebug('User info:', userInfo);
if (!userInfo || userInfo.rank < 2) {
logWarning(`Script requires rank ≥ 2. User rank: ${userInfo?.rank || 'unknown'}`);
return;
}
await loadSettingsFromStorage();
await loadTranslations();
const onSelectionChange = function () {
const setting = this.id.substr(6);
if (this.value.toLowerCase() !== settings[setting]) {
settings[setting] = this.value.toLowerCase();
saveSettingsToStorage();
}
},
// ────────────────────────────────────────────────────────────────────────────────
// HELPER FUNCTIONS: UI construction for card-based layout
// ────────────────────────────────────────────────────────────────────────────────
/**
* Creates a flex row with a label on left and control on right
* @param {string} labelText - Label to display
* @param {Element} control - Control element (select, etc.)
* @param {string} extraClass - Extra CSS class for row
* @param {string} tooltipText - Optional tooltip text (shows as info icon)
*/
makeRow = (labelText, control, extraClass, tooltipText) => {
const row = document.createElement('div');
row.className = `su-row${extraClass ? ` ${extraClass}` : ''}`;
const labelEl = document.createElement('span');
labelEl.className = 'su-row-label';
labelEl.textContent = labelText;
// Add info icon if tooltip provided
if (tooltipText) {
const infoIcon = document.createElement('span');
infoIcon.className = 'su-info-icon';
infoIcon.setAttribute('data-tooltip', tooltipText);
labelEl.appendChild(infoIcon);
}
row.appendChild(labelEl);
row.appendChild(control);
return row;
},
/**
* Creates a <select> element wired to onSelectionChange
*/
makeSelect = (settingKey) => {
const select = document.createElement('select');
select.id = `WMESU-${settingKey}`;
select.title = I18n.t(`wmesu.settings.${settingKey.charAt(0).toUpperCase()}${settingKey.slice(1)}Title`);
const docFrags = document.createDocumentFragment();
docFrags.appendChild(createElem('option', { value: 'nowarning', selected: settings[settingKey] === 'nowarning', textContent: I18n.t('wmesu.settings.NoWarning') }));
docFrags.appendChild(createElem('option', { value: 'warning', selected: settings[settingKey] === 'warning', textContent: I18n.t('wmesu.settings.GiveWarning') }));
docFrags.appendChild(createElem('option', { value: 'error', selected: settings[settingKey] === 'error', textContent: I18n.t('wmesu.settings.GiveError') }));
select.appendChild(docFrags);
select.addEventListener('change', onSelectionChange);
return select;
},
tabContent = () => {
const docFrags = document.createDocumentFragment();
logDebug('Building sidebar tab content...');
// ────────────────────────────────────────────────────────────────────────────────
// CSS: Scoped styles for .wme-su-panel (card layout, flexbox, responsive)
// ────────────────────────────────────────────────────────────────────────────────
const style = document.createElement('style');
style.textContent = [
'.wme-su-panel { padding: 8px; box-sizing: border-box; }',
'.wme-su-panel .su-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; padding: 8px 10px; background: linear-gradient(135deg, #0066cc, #0052a3); color: #fff; border-radius: 8px; }',
'.wme-su-panel .su-header-left { display: flex; align-items: center; gap: 6px; }',
'.wme-su-panel .su-header-icon { color: #fff; font-size: 1.2em; }',
'.wme-su-panel .su-header-name { font-weight: 700; font-size: 13px; color: #fff; }',
'.wme-su-panel .su-header-version { font-size: 10px; opacity: 0.8; color: #fff; }',
'.wme-su-panel .su-card { border: 1px solid var(--hairline, #ddd); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }',
'.wme-su-panel .su-card-header { display: flex; align-items: center; gap: 7px; padding: 7px 10px; font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; border-bottom: 1px solid var(--hairline, #ddd); background: linear-gradient(135deg, #f8f9fa, #f0f1f3); color: #333; }',
'.wme-su-panel .su-card-header:hover { background: linear-gradient(135deg, #f0f1f3, #e8eaed); }',
'.wme-su-panel .su-card-header i { color: #0066cc; font-size: 11px; width: 14px; text-align: center; }',
'.wme-su-panel .su-card-body { padding: 2px 0; }',
'.wme-su-panel .su-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 10px; min-height: 32px; box-sizing: border-box; }',
'.wme-su-panel .su-sub-row { padding-left: 22px; }',
'.wme-su-panel .su-row.disabled { opacity: 0.4; pointer-events: none; }',
'.wme-su-panel select { font-size: 12px; border: 1px solid var(--hairline, #ccc); border-radius: 4px; padding: 3px 5px; width: 130px; max-width: 130px; box-sizing: border-box; background: var(--background_default, #fff); color: var(--content_default, #333); }',
'.wme-su-panel input[type="number"] { font-size: 12px; border: 1px solid var(--hairline, #ccc); border-radius: 4px; padding: 3px 5px; width: 52px; text-align: right; box-sizing: border-box; background: var(--background_default, #fff); color: var(--content_default, #333); }',
'.wme-su-panel .su-button-group { margin-bottom: 8px; }',
'.wme-su-panel .su-footer { margin-top: 8px; font-size: 11px; }',
'.wme-su-panel .su-help-list { margin: 0; padding-left: 16px; font-size: 11px; line-height: 1.4; }',
'.wme-su-panel .su-help-list li { margin-bottom: 2px; }',
'.wme-su-panel .su-row-label { flex: 1; font-size: 12px; padding-right: 8px; line-height: 1.3; display: flex; align-items: center; }',
'.wme-su-panel .su-info-icon { display: inline-block; margin-left: 4px; color: #0066cc; font-size: 11px; cursor: help; position: relative; opacity: 0.7; transition: opacity 0.2s; flex-shrink: 0; }',
'.wme-su-panel .su-info-icon:hover { opacity: 1; }',
'.wme-su-panel .su-info-icon::before { content: "ⓘ"; }',
'.wme-su-panel .su-info-icon:hover::after { content: attr(data-tooltip); display: block; position: absolute; bottom: 100%; left: -60px; right: auto; background: #1a1a1a; color: #fff; padding: 6px 8px; border-radius: 4px; font-size: 9px; white-space: normal; width: 130px; z-index: 10000; line-height: 1.3; box-shadow: 0 2px 8px rgba(0,0,0,0.3); font-weight: 400; margin-bottom: 4px; }',
'[wz-theme="dark"] .wme-su-panel .su-header { background: linear-gradient(135deg, #0052a3, #003d7a); }',
'[wz-theme="dark"] .wme-su-panel .su-card-header { background: linear-gradient(135deg, #2a2c30, #202124); color: #e8eaed; }',
'[wz-theme="dark"] .wme-su-panel .su-card-header:hover { background: linear-gradient(135deg, #333538, #2a2c30); }',
'[wz-theme="dark"] .wme-su-panel .su-card-header i { color: #33ccff; }',
].join('\n');
docFrags.appendChild(style);
// ────────────────────────────────────────────────────────────────────────────────
// Header: Script name + version
// ────────────────────────────────────────────────────────────────────────────────
const header = document.createElement('div');
header.className = 'su-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'su-header-left';
const headerIcon = document.createElement('i');
headerIcon.className = 'fa fa-arrows su-header-icon';
const headerName = document.createElement('span');
headerName.className = 'su-header-name';
headerName.textContent = GM_info.script.name;
headerLeft.appendChild(headerIcon);
headerLeft.appendChild(headerName);
const headerVersion = document.createElement('span');
headerVersion.className = 'su-header-version';
headerVersion.textContent = `v${SCRIPT_VERSION}`;
header.appendChild(headerLeft);
header.appendChild(headerVersion);
docFrags.appendChild(header);
// ────────────────────────────────────────────────────────────────────────────────
// Validation Settings Card
// (Button now inserted into Segment Edit panel via jQuery segment.wme event)
// ────────────────────────────────────────────────────────────────────────────────
const validationCard = makeCard('fa-check-circle', 'Validation Settings');
validationCard.body.appendChild(makeRow(I18n.t('wmesu.settings.SanityCheck'), makeSelect('sanityCheck'), undefined, I18n.t('wmesu.settings.SanityCheckTitle')));
validationCard.body.appendChild(
makeRow(I18n.t('wmesu.settings.NonContinuousSelection'), makeSelect('nonContinuousSelection'), undefined, I18n.t('wmesu.settings.NonContinuousSelectionTitle')),
);
validationCard.body.appendChild(makeRow(I18n.t('wmesu.settings.ConflictingNames'), makeSelect('conflictingNames'), undefined, I18n.t('wmesu.settings.ConflictingNamesTitle')));
validationCard.body.appendChild(makeRow(I18n.t('wmesu.settings.MicroDogLegs'), makeSelect('microDogLegs'), undefined, I18n.t('wmesu.settings.MicroDogLegsTitle')));
validationCard.body.appendChild(makeRow(I18n.t('wmesu.settings.LongJnMove'), makeSelect('longJnMove'), undefined, I18n.t('wmesu.settings.LongJnMoveTitle')));
docFrags.appendChild(validationCard.card);
// ────────────────────────────────────────────────────────────────────────────────
// Simplify Options Card
// ────────────────────────────────────────────────────────────────────────────────
const simplifyCard = makeCard('fa-compress', 'Simplify Options');
const toleranceRow = document.createElement('div');
toleranceRow.className = 'su-row';
toleranceRow.style.justifyContent = 'flex-start';
toleranceRow.style.alignItems = 'center';
toleranceRow.style.gap = '3px';
toleranceRow.style.flexWrap = 'nowrap';
toleranceRow.style.padding = '5px 6px';
const toleranceLabel = document.createElement('label');
toleranceLabel.textContent = 'Tol:';
toleranceLabel.style.fontSize = '10px';
toleranceLabel.style.fontWeight = '600';
toleranceLabel.style.whiteSpace = 'nowrap';
toleranceLabel.style.flexShrink = 0;
toleranceLabel.style.marginRight = '2px';
const toleranceChips = document.createElement('wz-chip-select');
toleranceChips.style.display = 'flex';
toleranceChips.style.gap = '2px';
toleranceChips.style.flexWrap = 'nowrap';
toleranceChips.style.flex = '1';
const toleranceOptions = [
{ value: 1, label: '1m' },
{ value: 3, label: '3m' },
{ value: 5, label: '5m' },
{ value: 10, label: '10m' },
];
// Store chip refs and observers for toggling selected state
const toleranceChips_els = {};
const toleranceObservers = {};
toleranceOptions.forEach((opt) => {
const isSelected = settings.simplifyTolerance === opt.value;
const chip = document.createElement('wz-checkable-chip');
chip.id = `WMESU-toleranceChip-${opt.value}`;
chip.value = opt.value.toString();
chip.size = 'md';
if (isSelected) chip.setAttribute('checked', '');
chip.textContent = opt.label;
// Use MutationObserver to detect when chip becomes checked
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'checked') {
if (chip.hasAttribute('checked')) {
// Temporarily disconnect all observers to avoid infinite loop
toleranceOptions.forEach((o) => {
toleranceObservers[o.value]?.disconnect();
});
// Update all chips: uncheck others, keep this one checked
toleranceOptions.forEach((o) => {
const c = toleranceChips_els[o.value];
if (c) {
if (o.value === opt.value) {
c.setAttribute('checked', '');
} else {
c.removeAttribute('checked');
}
}
});
// Reconnect all observers
toleranceOptions.forEach((o) => {
toleranceObservers[o.value]?.observe(toleranceChips_els[o.value], { attributes: true, attributeFilter: ['checked'] });
});
settings.simplifyTolerance = opt.value;
logDebug(`Simplify tolerance changed to ${opt.value}m`);
window.setTimeout(() => saveSettingsToStorage(), 100);
}
}
});
});
toleranceChips_els[opt.value] = chip;
toleranceObservers[opt.value] = observer;
observer.observe(chip, { attributes: true, attributeFilter: ['checked'] });
toleranceChips.appendChild(chip);
});
toleranceRow.appendChild(toleranceLabel);
toleranceRow.appendChild(toleranceChips);
simplifyCard.body.appendChild(toleranceRow);
const toleranceHelp = document.createElement('div');
toleranceHelp.style.fontSize = '10px';
toleranceHelp.style.color = '#999';
toleranceHelp.style.padding = '4px 10px 0 10px';
toleranceHelp.textContent = 'Lower = detail, Higher = aggressive';
simplifyCard.body.appendChild(toleranceHelp);
docFrags.appendChild(simplifyCard.card);
// ────────────────────────────────────────────────────────────────────────────────
// Help Card (compact version)
// ────────────────────────────────────────────────────────────────────────────────
const helpCard = makeCard('fa-question-circle', I18n.t('wmesu.common.Help'));
const helpList = document.createElement('ul');
helpList.className = 'su-help-list';
const li1 = document.createElement('li');
li1.appendChild(document.createTextNode(I18n.t('wmesu.help.Step01')));
helpList.appendChild(li1);
const li2 = document.createElement('li');
li2.appendChild(document.createTextNode(I18n.t('wmesu.help.Step02')));
helpList.appendChild(li2);
const li3 = document.createElement('li');
li3.appendChild(document.createTextNode(I18n.t('wmesu.help.Step03')));
helpList.appendChild(li3);
helpCard.body.appendChild(helpList);
docFrags.appendChild(helpCard.card);
return docFrags;
};
// Register sidebar tab using SDK
const { tabLabel, tabPane } = await wmeSdk.Sidebar.registerScriptTab();
tabLabel.textContent = 'SU!';
tabLabel.title = GM_info.script.name;
tabPane.className = 'wme-su-panel';
tabPane.appendChild(tabContent());
tabPane.id = 'WMESUSettings';
// Listen to selection changes using SDK
wmeSdk.Events.on({
eventName: 'wme-selection-changed',
eventHandler: insertSimplifyStreetGeometryButtons,
});
// Check initial selection
const initialSelection = wmeSdk.Editing.getSelection();
if (initialSelection && initialSelection.objectType === 'segment' && initialSelection.ids?.length > 0) insertSimplifyStreetGeometryButtons();
// Save settings and cleanup on page unload
window.addEventListener('beforeunload', () => {
try {
// Stop observing DOM mutations to prevent memory leaks
if (observer) {
observer.disconnect();
logDebug('MutationObserver cleaned up');
}
// Remove button elements from DOM
document.getElementById('WME-SU-SEGMENT-EDIT')?.remove();
document.querySelectorAll('div.wme-su-segment-edit-panel').forEach((el) => el.remove());
// Clear any pending observer timeouts
if (observerTimeout) {
clearTimeout(observerTimeout);
}
// Save settings
saveSettingsToStorage();
logDebug('Settings saved and cleanup completed on page unload');
} catch (err) {
logWarning('Error during cleanup:', err);
}
});
// Register shortcut with SDK - like ZoomShortcuts does, handle duplicate key errors
try {
// SDK expects combo format for shortcutKeys
const shortcutCombo = settings.runStraightenUpShortcut?.combo || null;
wmeSdk.Shortcuts.createShortcut({
shortcutId: 'runStraightenUpShortcut',
shortcutKeys: shortcutCombo, // SDK expects COMBO format
description: 'Straighten Up',
callback: () => {
// Button can be in Segment Edit panel (new) or sidebar (legacy)
let btn = document.getElementById('WME-SU-SEGMENT-EDIT') || document.getElementById('WME-SU');
if (btn && !btn.disabled) {
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} else {
logWarning('Straighten Up button not found or is disabled');
}
},
});
logDebug('Shortcut registered with SDK:', shortcutCombo || '(none)');
} catch (err) {
// Handle duplicate key conflicts by resetting to null - like ZoomShortcuts does
if (err.message && err.message.includes('already in use')) {
logWarning(`Duplicate key detected for runStraightenUpShortcut, resetting: ${err.message}`);
settings.runStraightenUpShortcut = { raw: null, combo: null };
// Try to register again with null (no shortcut)
try {
wmeSdk.Shortcuts.createShortcut({
shortcutId: 'runStraightenUpShortcut',
shortcutKeys: null,
description: 'Straighten Up',
callback: () => {
// Button can be in Segment Edit panel (new) or sidebar (legacy)
let btn = document.getElementById('WME-SU-SEGMENT-EDIT') || document.getElementById('WME-SU');
if (btn && !btn.disabled) {
btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
} else {
logWarning('Straighten Up button not found or is disabled');
}
},
});
logDebug('Successfully registered runStraightenUpShortcut with no shortcut key');
saveSettingsToStorage(); // Save the reset
} catch (retryErr) {
logError(`Failed to register runStraightenUpShortcut even with null keys: ${retryErr.message}`);
}
} else {
logError('Failed to register shortcut:', err);
}
}
showScriptInfoAlert();
log(`Fully initialized in ${Math.round(performance.now() - LOAD_BEGIN_TIME)} ms.`);
}
/**
* Bootstrap script using WME-Utils Bootstrapper
* Initializes SDK, WazeWrap, and starts main initialization
* @async
*/
async function initScript() {
wmeSdk = await bootstrap({
scriptId: SETTINGS_STORE_NAME,
useWazeWrap: true,
scriptUpdateMonitor: {
downloadUrl: DOWNLOAD_URL,
},
});
await start();
}
initScript();
})();