// ==UserScript==
// @name WME Mods
// @version 2025.09.04.00
// @description Modifies the Waze Map Editor to suit my needs
// @author fuji2086
// @match *://*.waze.com/*editor*
// @exclude *://*.waze.com/user/editor*
// @grant GM_xmlhttpRequest
// @require https://greasyfork.org/scripts/39002-bluebird/code/Bluebird.js?version=255146
// @require https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @require https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js
// @require https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.js
// @connect greasyfork.org
// @connect wv.gov
// @license GNU GPLv3
// @namespace https://greasyfork.org/en/users/456696
// ==/UserScript==
/* global $ */
/* global turf */
/* global bootstrap */
(async function main() {
'use strict';
const settingsStoreName = 'wme_mods';
const debug = false;
const scriptVersion = GM_info.script.version;
const downloadUrl = 'https://greasyfork.org/scripts/491345/code/WME%20Mods.user.js';
const sdk = await bootstrap({ scriptUpdateMonitor: { downloadUrl } });
const layerName = 'RT Layer';
let isAM = false;
let userNameLC;
let settings = {};
let rank;
let MAP_LAYER_Z_INDEX;
let _lastPromise = null;
let _lastContext = null;
let _rtCallCount = 0;
const MIN_ZOOM_LEVEL = 11;
const STATES_HASH = {
Alabama: 'AL',
Alaska: 'AK',
'American Samoa': 'AS',
Arizona: 'AZ',
Arkansas: 'AR',
California: 'CA',
Colorado: 'CO',
Connecticut: 'CT',
Delaware: 'DE',
'District of Columbia': 'DC',
'Federated States Of Micronesia': 'FM',
Florida: 'FL',
Georgia: 'GA',
Guam: 'GU',
Hawaii: 'HI',
Idaho: 'ID',
Illinois: 'IL',
Indiana: 'IN',
Iowa: 'IA',
Kansas: 'KS',
Kentucky: 'KY',
Louisiana: 'LA',
Maine: 'ME',
'Marshall Islands': 'MH',
Maryland: 'MD',
Massachusetts: 'MA',
Michigan: 'MI',
Minnesota: 'MN',
Mississippi: 'MS',
Missouri: 'MO',
Montana: 'MT',
Nebraska: 'NE',
Nevada: 'NV',
'New Hampshire': 'NH',
'New Jersey': 'NJ',
'New Mexico': 'NM',
'New York': 'NY',
'North Carolina': 'NC',
'North Dakota': 'ND',
'Northern Mariana Islands': 'MP',
Ohio: 'OH',
Oklahoma: 'OK',
Oregon: 'OR',
Palau: 'PW',
Pennsylvania: 'PA',
'Puerto Rico': 'PR',
'Rhode Island': 'RI',
'South Carolina': 'SC',
'South Dakota': 'SD',
Tennessee: 'TN',
Texas: 'TX',
Utah: 'UT',
Vermont: 'VT',
'Virgin Islands': 'VI',
Virginia: 'VA',
Washington: 'WA',
'West Virginia': 'WV',
Wisconsin: 'WI',
Wyoming: 'WY'
};
function reverseStatesHash(stateAbbr) {
// eslint-disable-next-line no-restricted-syntax
for (const stateName in STATES_HASH) {
if (STATES_HASH[stateName] === stateAbbr) return stateName;
}
throw new Error(`RT Layer: reverseStatesHash function did not return a value for ${stateAbbr}.`);
}
const STATE_SETTINGS = {
global: {
roadTypes: ['St', 'StUp', 'OR'],
getFeatureRoadType(feature, layer) {
const rt = feature.attributes[layer.rtPropName];
return this.getRoadTypeFromRT(rt, layer);
},
getRoadTypeFromRT(rt, layer) {
return Object.keys(layer.roadTypeMap).find(rti => layer.roadTypeMap[rti].indexOf(rt) !== -1);
},
isPermitted(stateAbbr) {
return (true);
},
getMapLayer(stateAbbr, layerID) {
let returnValue;
STATE_SETTINGS[stateAbbr].rtMapLayers.forEach(layer => {
if (layer.layerID === layerID) {
returnValue = layer;
}
});
return returnValue;
}
},
WV: {
baseUrl: 'https://gis.transportation.wv.gov/arcgis/rest/services/Roads_And_Highways/Publication_LRS/MapServer/',
defaultColors: {
OR: '#000000', StUp: '#ffa500', St: '#eeeeee'
},
zoomSettings: { maxOffset: [30, 15, 8, 4, 2, 1, 1, 1, 1, 1], excludeRoadTypes: [[], [], [], [], [], [], [], [], [], [], []] },
rtMapLayers: [
{
layerID: 70,
rtPropName: 'SURFACE_TYPE',
idPropName: 'OBJECTID',
outFields: ['OBJECTID', 'SURFACE_TYPE', 'ROUTE_ID'],
maxRecordCount: 1000,
supportsPagination: true,
roadTypeMap: {
OR: [1], StUp: [3], St: [6]
}
}
],
information: { Source: 'WV DOT' },
isPermitted() { return true; },
getWhereClause(context) {
if (context.mapContext.zoom < 16) {
return `${context.layer.rtPropName} NOT IN (9,19)`;
}
return null;
},
getFeatureRoadType(feature, layer) {
if (layer.getFeatureRoadType) {
return layer.getFeatureRoadType(feature);
}
const rtCode = feature.attributes[layer.rtPropName];
let rt = 6;
if (rtCode == 99 || rtCode == 1.1) rt = 1;
else if (rtCode == 1.3 || rtCode == 1.2) rt = 3;
const id = feature.attributes.ROUTE_ID;
return STATE_SETTINGS.global.getRoadTypeFromRT(rt, layer);
}
}
};
function log(message) {
console.log('RT Layer: ', message);
}
function debugLog(message) {
console.debug('RT Layer: ', message);
}
function errorLog(message) {
console.error('RT Layer: ', message);
}
function UpdateZoomDisplay() {
try {
const zoomBar = $('.zoom-bar-container')[0];
const zoomDisplayLevel = $('#zoomdisplaycontainer > p')[0];
const zoomLevel = sdk.Map.getZoomLevel();
zoomDisplayLevel.innerText = zoomLevel;
switch (zoomLevel) {
case 4:
case 5:
case 6:
case 7:
case 8:
case 9:
case 10:
case 11:
case 12:
case 13:
zoomBar.style.background = '#ef9a9a';
break;
case 14:
case 15:
zoomBar.style.background = '#ffe082';
break;
default:
zoomBar.style.background = '#ffffff';
break;
}
}
catch {
AddZoomDisplay();
}
}
async function AddZoomDisplay() {
const zoomBar = $('.zoom-bar-container')[0];
const zoomDisplayContainer = $('<div>', {id:'zoomdisplaycontainer', style:'width:100%;'});
zoomDisplayContainer.append($('<p>', {id:'zoomdisplaylevel', style:'font-size:20px;text-align:center;margin:0px;'}));
zoomDisplayContainer.insertAfter(zoomBar.firstChild);
UpdateZoomDisplay();
}
function waitForElm(selector) {
return new Promise(resolve => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) {
observer.disconnect();
resolve(document.querySelector(selector));
}
});
// If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
function getLineWidth() {
return 12 * (1.15 ** (sdk.Map.getZoomLevel() - 13));
}
function sortArray(array) {
array.sort((a, b) => { if (a < b) return -1; if (a > b) return 1; return 0; });
}
function getVisibleStateAbbreviations() {
const { activeStateAbbr } = settings;
return sdk.DataModel.States.getAll()
.map(state => STATES_HASH[state.name])
.filter(stateAbbr => STATE_SETTINGS[stateAbbr]
&& STATE_SETTINGS.global.isPermitted(stateAbbr)
&& (!activeStateAbbr || activeStateAbbr === 'ALL' || activeStateAbbr === stateAbbr));
}
function getUrl(context, queryType, queryParams) {
const { extent } = context.mapContext;
const { zoom } = context.mapContext;
const { layer } = context;
const { state } = context;
const whereParts = [];
const geometry = {
xmin: extent[0], ymin: extent[1], xmax: extent[2], ymax: extent[3], spatialReference: { wkid: 4326 }
};
const geometryStr = JSON.stringify(geometry);
const stateWhereClause = state.getWhereClause(context);
const layerPath = layer.layerPath || '';
let url = `${state.baseUrl + layerPath + layer.layerID}/query?geometry=${encodeURIComponent(geometryStr)}`;
if (queryType === 'countOnly') {
url += '&returnCountOnly=true';
} else if (queryType === 'idsOnly') {
url += '&returnIdsOnly=true';
} else if (queryType === 'paged') {
// TODO
} else {
url += `&returnGeometry=true&maxAllowableOffset=${state.zoomSettings.maxOffset[zoom - 12]}`;
url += `&outFields=${encodeURIComponent(layer.outFields.join(','))}`;
if (queryType === 'idRange') {
whereParts.push(`(${queryParams.idFieldName}>=${queryParams.range[0]} AND ${queryParams.idFieldName}<=${queryParams.range[1]})`);
}
}
if (stateWhereClause) whereParts.push(stateWhereClause);
if (whereParts.length > 0) url += `&where=${encodeURIComponent(whereParts.join(' AND '))}`;
url += '&spatialRel=esriSpatialRelIntersects&geometryType=esriGeometryEnvelope&inSR=102100&outSR=3857&f=json';
return url;
}
function getVisibleStateAbbreviations() {
const { activeStateAbbr } = settings;
return sdk.DataModel.States.getAll()
.map(state => STATES_HASH[state.name])
.filter(stateAbbr => STATE_SETTINGS[stateAbbr]
&& STATE_SETTINGS.global.isPermitted(stateAbbr)
&& (!activeStateAbbr || activeStateAbbr === 'ALL' || activeStateAbbr === stateAbbr));
}
function getAsync(url, context) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
context,
method: 'GET',
url,
onload(res) {
if (res.status.toString() === '200') {
resolve({ responseText: res.responseText, context });
} else {
reject(new Error({ responseText: res.responseText, context }));
}
},
onerror() {
reject(Error('Network Error'));
}
});
});
}
function onSave() {
if (!$('.zoom-bar-container')) {
waitForElm('.zoom-bar-container').then(AddZoomDisplay);
}
}
function fetchLayerRT(context) {
const url = getUrl(context, 'idsOnly');
debugLog(url);
if (!context.parentContext.cancel) {
return getAsync(url, context).bind(context).then(res => {
const ids = $.parseJSON(res.responseText);
if (!ids.objectIds) ids.objectIds = [];
sortArray(ids.objectIds);
debugLog(ids);
return ids;
}).then(res => {
const idRanges = [];
if (res.objectIds) {
const len = res.objectIds ? res.objectIds.length : 0;
let currentIndex = 0;
const offset = Math.min(context.layer.maxRecordCount, 1000);
while (currentIndex < len) {
let nextIndex = currentIndex + offset;
if (nextIndex >= len) nextIndex = len - 1;
idRanges.push({ range: [res.objectIds[currentIndex], res.objectIds[nextIndex]], idFieldName: res.objectIdFieldName });
currentIndex = nextIndex + 1;
}
debugLog(context.layer.layerID);
debugLog(idRanges);
}
return idRanges;
}).map(idRange => {
if (!context.parentContext.cancel) {
const newUrl = getUrl(context, 'idRange', idRange);
debugLog(newUrl);
return getAsync(newUrl, context).then(res => {
if (!context.parentContext.cancel) {
let { features } = $.parseJSON(res.responseText);
context.parentContext.callCount++;
debugLog('Feature Count=' + (features ? features.length : 0));
features = features || [];
return features.map(feature => convertRTToRoadTypeLineStrings(feature, context));
}
return null;
});
}
debugLog('Async call cancelled');
return null;
});
}
return null;
}
function convertRTToRoadTypeLineStrings(feature, context) {
const { state, stateAbbr, layer } = context;
const roadType = state.getFeatureRoadType(feature, layer);
// debugLog(feature);
const attr = {
state: stateAbbr,
layerID: layer.layerID,
roadType,
color: state.defaultColors[roadType]
};
const lineStrings = feature.geometry.paths.map(path => {
const line = turf.toWgs84(turf.lineString(path, attr));
line.id = 0;
return line;
});
return lineStrings;
}
function fetchStateRT(context) {
const state = STATE_SETTINGS[context.stateAbbr];
const contexts = state.rtMapLayers.map(layer => ({
parentContext: context.parentContext, layer, state, stateAbbr: context.stateAbbr, mapContext: context.mapContext
}));
return Promise.map(contexts, ctx => fetchLayerRT(ctx));
}
function fetchAllRT() {
if (!sdk.Map.isLayerVisible({ layerName })) return;
if (_lastPromise) { _lastPromise.cancel(); }
$('#mods-loading-indicator').text('Loading RT...');
const mapContext = { zoom: sdk.Map.getZoomLevel(), extent: sdk.Map.getMapExtent() };
if (mapContext.zoom > MIN_ZOOM_LEVEL) {
const parentContext = { callCount: 0, startTime: Date.now() };
if (_lastContext) _lastContext.cancel = true;
_lastContext = parentContext;
const contexts = getVisibleStateAbbreviations().map(stateAbbr => ({ parentContext, stateAbbr, mapContext }));
const map = Promise.map(contexts, ctx => fetchStateRT(ctx)).then(statesLineStringArrays => {
if (!parentContext.cancel) {
sdk.Map.removeAllFeaturesFromLayer({ layerName });
statesLineStringArrays.forEach(stateLineStringsArray => {
stateLineStringsArray.forEach(lineStringsArray1 => {
lineStringsArray1.forEach(lineStringsArray2 => {
lineStringsArray2.forEach(lineStringsArray3 => {
lineStringsArray3.forEach(feature => {
sdk.Map.addFeatureToLayer({ layerName, feature });
});
});
});
});
});
}
return statesLineStringArrays;
}).catch(e => {
$('#mods-loading-indicator').text('RT Error! (check console for details)');
errorLog(e);
}).finally(() => {
_rtCallCount -= 1;
if (_rtCallCount === 0) {
$('#mods-loading-indicator').text('');
}
});
_rtCallCount += 1;
_lastPromise = map;
} else {
// if zoomed out too far, clear the layer
sdk.Map.removeAllFeaturesFromLayer({ layerName });
}
}
function onLayerCheckboxChanged(args) {
setEnabled(args.checked);
}
function checkLayerZIndex() {
try {
if (sdk.Map.getLayerZIndex({ layerName }) !== MAP_LAYER_Z_INDEX) {
// ("ADJUSTED RT LAYER Z-INDEX " + mapLayerZIndex + ', ' + mapLayer.getZIndex());
sdk.Map.setLayerZIndex({ layerName, zIndex: MAP_LAYER_Z_INDEX });
}
} catch {
// ignore this hack if it crashes
}
}
function loadSettingsFromStorage() {
const loadedSettings = $.parseJSON(localStorage.getItem(settingsStoreName));
const defaultSettings = {
lastVersion: null,
layerVisible: true,
roadTypeEnabled: false
};
settings = loadedSettings || defaultSettings;
Object.keys(defaultSettings).filter(prop => !settings.hasOwnProperty(prop)).forEach(prop => {
settings[prop] = defaultSettings[prop];
});
}
function saveSettingsToStorage() {
if (localStorage) {
// In case the layer is turned off some other way...
settings.layerVisible = sdk.Map.isLayerVisible({ layerName });
localStorage.setItem(settingsStoreName, JSON.stringify(settings));
}
}
function addLoadingIndicator() {
$('.loading-indicator').after($('<div class="loading-indicator" style="margin-right:10px" id="mods-loading-indicator">'));
}
function initLayer() {
const styleRules = [
{
style: {
strokeColor: 'black',
strokeDashstyle: 'solid',
strokeOpacity: 1.0,
strokeWidth: '15'
}
}
];
for (let zoom = 12; zoom < 22; zoom++) {
styleRules.push({
// eslint-disable-next-line no-loop-func
predicate: () => sdk.Map.getZoomLevel() === zoom,
style: {
strokeWidth: 12 * (1.15 ** (zoom - 13))
}
});
}
Object.values(STATE_SETTINGS)
.filter(state => !!state.defaultColors)
.forEach(state => Object.values(state.defaultColors)
.forEach(color => {
if (!styleRules.some(rule => rule.style.strokeColor === color)) {
styleRules.push({
predicate: props => props.color === color,
style: { strokeColor: color }
});
}
}));
STATE_SETTINGS.global.roadTypes.forEach((roadType, index) => {
styleRules.push({
predicate: props => props.roadType === roadType,
style: { graphicZIndex: index * 100 }
});
});
sdk.Map.addLayer({
layerName,
styleRules,
zIndexing: true
});
sdk.Map.setLayerOpacity({ layerName, opacity: 0.5 });
sdk.Map.setLayerVisibility({ layerName, visibility: settings.layerVisible });
MAP_LAYER_Z_INDEX = sdk.Map.getLayerZIndex({ layerName: 'roads' }) - 3;
sdk.Map.setLayerZIndex({ layerName, zIndex: MAP_LAYER_Z_INDEX });
window.addEventListener('beforeunload', () => saveSettingsToStorage);
sdk.LayerSwitcher.addLayerCheckbox({ name: 'RT Layer' });
sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'RT Layer', isChecked: settings.layerVisible });
sdk.Events.on({ eventName: 'wme-layer-checkbox-toggled', eventHandler: onLayerCheckboxChanged });
// Hack to fix layer zIndex. Some other code is changing it sometimes but I have not been able to figure out why.
// It may be that the RT layer is added to the map before some Waze code loads the base layers and forces other layers higher. (?)
setInterval(checkLayerZIndex, 1000);
sdk.Events.on({ eventName: 'wme-map-move-end', eventHandler: fetchAllRT });
}
function setEnabled(value) {
sdk.Map.setLayerVisibility({ layerName, visibility: value });
settings.layerVisible = value;
saveSettingsToStorage();
if (value) fetchAllRT();
sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'RT Layer', isChecked: value });
$('#mods-hlrt').prop('checked', value);
}
async function initUserPanel() {
let $panel = $("<div>", {style:"padding:8px 16px", id:"mods-settings"});
$panel.html([
'<b>WME Mods</b> v' + GM_info.script.version,
'</br>',
'<div><input type="checkbox" name="mods-hlrt" title="Turn this on to highlight segments based on road type" id="mods-hlrt"><label for="mods-hlrt">Highlight Segment Road Type</label></div>',
].join(' '));
const { tabLabel, tabPane } = await sdk.Sidebar.registerScriptTab();
$(tabLabel).text('Mods');
$(tabPane).append($panel);
initGUI2();
}
function initGUI2() {
$('#mods-hlrt').change(function() {
setEnabled(this.checked)
});
$('#mods-hlrt').prop('checked', settings.layerVisible);
}
function initZoom() {
AddZoomDisplay();
sdk.Events.on({ eventName: 'wme-map-zoom-changed', eventHandler: UpdateZoomDisplay });
//W.editingMediator.actionManager.events.register("afterclearactions",null,onSave);
}
async function initGui() {
initLayer();
await initUserPanel();
}
async function init() {
if (debug && Promise.config) {
Promise.config({
warnings: true,
longStackTraces: true,
cancellation: true,
monitoring: false
});
} else {
Promise.config({
warnings: false,
longStackTraces: false,
cancellation: true,
monitoring: false
});
}
initZoom();
const u = sdk.State.getUserInfo();
rank = u.rank + 1;
isAM = u.isAreaManager;
userNameLC = u.userName.toLowerCase();
loadSettingsFromStorage();
await initGui();
fetchAllRT();
log('Initialized.');
}
init();
})();