// ==UserScript==
// @name WME Permanent Hazard Direction
// @description Adds directional arrow to replace the A→B and B→A buttons for permanent hazards in the Waze Map Editor feature editor pane.
// @version 1.0.3
// @author brandon28au
// @license MIT
// @match *://*.waze.com/*editor*
// @exclude *://*.waze.com/user/editor*
// @grant none
// @namespace https://greasyfork.org/users/1253347
// ==/UserScript==
/* jshint esversion: 11 */
/* global getWmeSdk, W */
const arrowSvgString = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" width="20" height="20" style="transition: transform 0.4s ease; vertical-align: middle;"><title></title><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18" /></svg>`;
const twoWayArrowSvgString = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" width="20" height="20" style="transition: transform 0.4s ease; vertical-align: middle;"><title></title><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg>`;
const iconClass = 'wme-hazard-direction-arrow';
function init() {
const wmeSDK = getWmeSdk({scriptId: "wme-permanent-hazard-direction", scriptName: "WME Permanent Hazard Direction"});
const toRadians = (deg) => deg * (Math.PI / 180);
const toDegrees = (rad) => (rad * (180 / Math.PI) + 360) % 360;
/**
* Calculates the bearing in degrees between two geographic coordinates.
* @param {[number, number]} p1 - The starting point [lon, lat].
* @param {[number, number]} p2 - The ending point [lon, lat].
* @returns {number} The bearing in degrees from 0 to 360.
*/
function calculateBearing(p1, p2) {
const [lon1, lat1] = p1;
const [lon2, lat2] = p2;
const lat1Rad = toRadians(lat1);
const lat2Rad = toRadians(lat2);
const deltaLonRad = toRadians(lon2 - lon1);
const y = Math.sin(deltaLonRad) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(deltaLonRad);
return toDegrees(Math.atan2(y, x));
}
/**
* Finds the line segment of a LineString's geometry that is closest to a given point.
* @param {[number, number]} point - The point [lon, lat].
* @param {Array<[number, number]>} lineStringCoords - The array of coordinates for the LineString.
* @returns {{lineSegment: [[number, number], [number, number]], distance: number}|null} The closest line segment and the distance to it.
*/
function findClosestLineSegment(point, lineStringCoords) {
let closestLineSegment = null;
let minDistanceSq = Infinity;
const tolerance = 1e-20; // A very small number to treat near-zero distances as equal
// Iterate backwards to prioritize outgoing segments when a point is on a vertex
for (let i = lineStringCoords.length - 2; i >= 0; i--) {
const p1 = lineStringCoords[i];
const p2 = lineStringCoords[i + 1];
const dx = p2[0] - p1[0];
const dy = p2[1] - p1[1];
const lenSq = dx * dx + dy * dy;
let t = 0;
if (lenSq !== 0) {
t = ((point[0] - p1[0]) * dx + (point[1] - p1[1]) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
}
const closestPointOnSegment = [p1[0] + t * dx, p1[1] + t * dy];
const distSq = (point[0] - closestPointOnSegment[0]) ** 2 + (point[1] - closestPointOnSegment[1]) ** 2;
// Only update if the new distance is meaningfully smaller than the current minimum.
// This prevents tiny floating point differences from incorrectly changing the chosen segment.
// Designed to help align with the choice that Waze will make in its UI.
if (distSq < minDistanceSq - tolerance) {
minDistanceSq = distSq;
closestLineSegment = [p1, p2];
}
}
return closestLineSegment ? { lineSegment: closestLineSegment, distance: Math.sqrt(minDistanceSq) } : null;
}
let hazardDirectionObserver = null; // To hold our observer instance
/**
* Updates the WME UI to display the calculated bearings on the direction chips.
* @param {number} bearing - The bearing for the A->B direction.
* @param {number} hazardId - The ID of the hazard being edited.
*/
function displayBearings(bearing, hazardId) {
const editorSelector = '.permanent-hazard-feature-editor';
const directionEditorSelector = '[class*="direction-editor--"]';
// 1. First, try to find the element immediately.
const currentHazardEditor = document.querySelector(editorSelector);
if (currentHazardEditor) {
// Verify the UI is for the correct segment before updating.
const headerElement = currentHazardEditor.querySelector('wz-section-header');
const subtitle = headerElement ? headerElement.getAttribute('subtitle') : '';
const match = subtitle.match(/-?\d+/);
const uiHazardId = match ? parseInt(match[0], 10) : null;
const directionEditor = currentHazardEditor.querySelector(directionEditorSelector);
if (uiHazardId === hazardId && directionEditor) {
updateChips(directionEditor, bearing);
return; // UI is correct and has been updated.
}
}
// 2. If not found or stale, set up an observer to wait for the correct editor to appear.
hazardDirectionObserver = new MutationObserver((mutations, obs) => {
const newHazardEditor = document.querySelector(editorSelector);
if (!newHazardEditor) return;
const directionEditor = newHazardEditor.querySelector(directionEditorSelector);
if (!directionEditor) return;
const chipAB = directionEditor.querySelector('wz-checkable-chip[value="1"]');
const chipBA = directionEditor.querySelector('wz-checkable-chip[value="2"]');
const chipTwoWay = directionEditor.querySelector('wz-checkable-chip[value="3"]');
if (chipAB && chipBA && chipTwoWay) {
obs.disconnect();
updateChips(directionEditor, bearing, { chipAB, chipBA, chipTwoWay });
}
});
hazardDirectionObserver.observe(document.getElementById('edit-panel'), {
childList: true,
subtree: true
});
}
/**
* Updates the direction chips with bearing information.
* @param {HTMLElement} directionEditor - The container element for the direction chips.
* @param {number} bearing - The bearing for the A->B direction.
* @param {object} [chips] - Optional object containing the chip elements.
* @param {HTMLElement} [chips.chipAB] - The A->B chip.
* @param {HTMLElement} [chips.chipBA] - The B->A chip.
* @param {HTMLElement} [chips.chipTwoWay] - The two-way chip.
*/
function updateChips(directionEditor, bearing, chips) {
const {
chipAB = directionEditor.querySelector('wz-checkable-chip[value="1"]'),
chipBA = directionEditor.querySelector('wz-checkable-chip[value="2"]'),
chipTwoWay = directionEditor.querySelector('wz-checkable-chip[value="3"]')
} = chips || {};
if (!chipAB || !chipBA || !chipTwoWay) {
console.log('WME Permanent Hazard Direction: Could not find all direction chips.');
return;
}
/**
* Creates a new SVG icon element.
* @param {string} svgString - The SVG content.
* @returns {SVGElement} The created icon element.
*/
const createIconElement = (svgString) => {
const template = document.createElement('template');
template.innerHTML = svgString.trim();
const icon = template.content.firstChild;
icon.classList.add(iconClass);
return icon;
};
/**
* Updates an icon's rotation with a smooth transition to the shortest angle.
* @param {SVGElement} icon - The icon element to rotate.
* @param {number} newAngle - The target rotation angle in degrees.
* @param {boolean} [updateTitle=true] - Whether to update the icon's title with the new angle.
*/
const updateIconRotation = (icon, newAngle, updateTitle = true) => {
const currentTotalAngle = parseFloat(icon.dataset.currentRotation) || 0;
// Calculate the shortest path to the new angle
let diff = newAngle - (currentTotalAngle % 360);
diff = ((diff + 180) % 360) - 180; // Ensures diff is between -180 and 180
const newTotalAngle = currentTotalAngle + diff;
icon.dataset.currentRotation = newTotalAngle;
icon.style.transform = `rotate(${newTotalAngle}deg)`;
if (updateTitle) {
icon.querySelector('title').textContent = `${newAngle}°`;
}
};
const getOrCreateIcon = (chip, svgString, defaultTitle) => {
let icon = chip.querySelector(`.${iconClass}`);
if (!icon) {
const innerChipDiv = chip.shadowRoot?.querySelector('div.wz-chip');
if (innerChipDiv) {
Object.assign(innerChipDiv.style, {
width: '60px',
height: '24px',
padding: '0',
alignItems: 'center',
justifyContent: 'center'
});
const textSpan = innerChipDiv.querySelector('span.text');
if (textSpan) {
textSpan.style.margin = '0';
}
}
icon = createIconElement(svgString);
icon.querySelector('title').textContent = defaultTitle;
chip.replaceChildren(icon);
}
return icon;
};
const roundedBearing = Math.round(bearing);
const oppositeBearing = (roundedBearing + 180) % 360;
updateIconRotation(getOrCreateIcon(chipAB, arrowSvgString, `${roundedBearing}°`), roundedBearing, true);
updateIconRotation(getOrCreateIcon(chipBA, arrowSvgString, `${oppositeBearing}°`), oppositeBearing, true);
updateIconRotation(getOrCreateIcon(chipTwoWay, twoWayArrowSvgString, chipTwoWay.textContent.trim() || 'Two-way'), bearing, false);
}
/**
* Main function called on selection change.
*/
async function updateHazardUI() {
// Disconnect any lingering observer from a previous selection
if (hazardDirectionObserver) {
hazardDirectionObserver.disconnect();
}
// 1. Check that a single permanent hazard is selected
const selectedFeatures = wmeSDK.Editing.getSelection();
if (!selectedFeatures || selectedFeatures.objectType !== 'permanentHazard' || !selectedFeatures.ids || selectedFeatures.ids.length !== 1) {
return;
}
const hazardId = selectedFeatures.ids[0];
if (!hazardId) {
console.log('WME Permanent Hazard Direction: Could not find ID for selected hazard.');
return;
}
// 2. Get the full hazard object from the model
const hazardModel = W.model.permanentHazards.getObjectById(hazardId);
if (!hazardModel) {
console.log(`WME Permanent Hazard Direction: Could not find hazard model for ID ${hazardId}.`);
return;
}
// 3. Filter for hazards that support direction
if (!hazardModel.attributes?.direction) {
return;
}
// 4. Safely extract hazard coordinates and segment ID
const coordinates = hazardModel.attributes.geoJSONGeometry?.coordinates;
const segmentId = hazardModel.attributes?.segmentId;
if (!coordinates) {
console.log(`WME Permanent Hazard Direction: Could not find coordinates for hazard ID ${hazardId}.`);
return;
}
// Filter out if not segment based
if (!segmentId) {
return;
}
// 5. Get the segment data model
let segment;
try {
segment = await wmeSDK.DataModel.Segments.getById({segmentId});
} catch (error) {
console.error(`WME Permanent Hazard Direction: Failed to get segment model for ID ${segmentId}.`, error);
return;
}
if (!segment) {
console.log(`WME Permanent Hazard Direction: Could not find segment model for ID ${segmentId}.`);
return;
}
// 6. Filter for two-way segments
if (!segment.isTwoWay) {
return;
}
const segmentGeometry = segment.geometry;
// 7. Find the closest line segment on the road's geometry to the hazard
const closestLine = findClosestLineSegment(coordinates, segmentGeometry.coordinates);
if (!closestLine) {
console.log('WME Permanent Hazard Direction: Could not determine closest line segment.');
return;
}
// 8. Calculate the bearing and update the UI
const bearing = calculateBearing(closestLine.lineSegment[0], closestLine.lineSegment[1]);
displayBearings(bearing, hazardId);
}
wmeSDK.Events.on({
eventName: 'wme-selection-changed',
eventHandler: updateHazardUI
});
updateHazardUI(); // Initial call in case something is already selected
}
window.SDK_INITIALIZED.then(init);