WME Permanent Hazard Direction

Adds directional arrow to replace the A→B and B→A buttons for permanent hazards in the Waze Map Editor feature editor pane.

// ==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);