WME Permanent Hazard Direction

Makes selecting permanent hazard direction easier in the Waze Map Editor.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         WME Permanent Hazard Direction
// @description  Makes selecting permanent hazard direction easier in the Waze Map Editor.
// @version      1.1.2
// @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;

    const storageKey = 'wme_permanent_hazard_direction_keyboard_shortcut_enabled';
    let shortcutsEnabled = localStorage.getItem(storageKey) === 'true';

    const cookie = document.cookie.split('; ').find(row => row.startsWith(storageKey + '='));
    if (cookie) {
        if (localStorage.getItem(storageKey) === null) {
            shortcutsEnabled = cookie.split('=')[1] === 'true';
            localStorage.setItem(storageKey, shortcutsEnabled);
        }
        document.cookie = `${storageKey}=; max-age=0; path=/; domain=.waze.com`;
    }

    /**
     * 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;
    let keydownListener = null;

    /**
     * Updates the WME UI to display the calculated bearings on the direction chips.
     */
    function displayBearings(bearing, hazardId) {
        const editorSelector = '.permanent-hazard-feature-editor';
        const directionEditorSelector = '[class*="direction-editor--"]';

        const currentHazardEditor = document.querySelector(editorSelector);
        if (currentHazardEditor) {
            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) {
                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) {
                    updateChips(directionEditor, bearing, { chipAB, chipBA, chipTwoWay });
                    return;
                }
            }
        }

        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 and keyboard shortcut UI.
     */
    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) return;

        const createIconElement = (svgString) => {
            const template = document.createElement('template');
            template.innerHTML = svgString.trim();
            const icon = template.content.firstChild;
            icon.classList.add(iconClass);
            return icon;
        };

        const updateIconRotation = (icon, newAngle, updateTitle = true) => {
            const currentTotalAngle = parseFloat(icon.dataset.currentRotation) || 0;
            let diff = newAngle - (currentTotalAngle % 360);
            diff = ((diff + 180) % 360) - 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;
        chipAB.dataset.bearing = roundedBearing;
        chipBA.dataset.bearing = oppositeBearing;

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

        if (document.getElementsByName('wmePermanentHazardKeyboardShortcut').length === 0) {
            const checkbox = document.createElement('wz-checkbox');
            checkbox.name = 'wmePermanentHazardKeyboardShortcut';
            checkbox.checked = shortcutsEnabled;
            checkbox.textContent = 'Enable arrow keys';
            directionEditor.append(checkbox);

            checkbox.addEventListener('change', () => {
                shortcutsEnabled = checkbox.checked;
                localStorage.setItem(storageKey, shortcutsEnabled);
                updateHazardUI();
            });
        }
    }

    /**
     * Main function called on selection change.
     */
    async function updateHazardUI() {
        if (hazardDirectionObserver) hazardDirectionObserver.disconnect();
        if (keydownListener) {
            document.removeEventListener('keydown', keydownListener, true);
            keydownListener = null;
        }

        const selectedFeatures = wmeSDK.Editing.getSelection();
        if (!selectedFeatures || selectedFeatures.objectType !== 'permanentHazard' || !selectedFeatures.ids || selectedFeatures.ids.length !== 1) return;

        const hazardId = selectedFeatures.ids[0];
        if (!hazardId) return;

        const hazardModel = W.model.permanentHazards.getObjectById(hazardId);
        if (!hazardModel || !hazardModel.attributes?.direction) return;

        const coordinates = hazardModel.attributes.geoJSONGeometry?.coordinates;
        const segmentId = hazardModel.attributes?.segmentId;
        if (!coordinates || !segmentId) return;

        let segment;
        try { segment = await wmeSDK.DataModel.Segments.getById({ segmentId }); }
        catch (error) { return; }
        if (!segment || !segment.isTwoWay) return;

        const closestLine = findClosestLineSegment(coordinates, segment.geometry.coordinates);
        if (!closestLine) return;

        const bearing = calculateBearing(closestLine.lineSegment[0], closestLine.lineSegment[1]);
        displayBearings(bearing, hazardId);

        if (shortcutsEnabled) {
            keydownListener = (e) => {
                const editor = document.querySelector('.permanent-hazard-feature-editor');
                const directionEditor = editor?.querySelector('[class*="direction-editor--"]');
                if (!directionEditor) return;

                const chipAB = directionEditor.querySelector('wz-checkable-chip[value="1"]');
                const chipBA = directionEditor.querySelector('wz-checkable-chip[value="2"]');
                if (!chipAB || !chipBA) return;

                const press = (key) => {
                    const bearingAB = parseFloat(chipAB.dataset.bearing);
                    const bearingBA = parseFloat(chipBA.dataset.bearing);
                    let targetChip = null;

                    const conditions = {
                        ArrowUp: (d) => (d >= 270 && d <= 359) || (d >= 0 && d <= 89),
                        ArrowRight: (d) => d >= 0 && d <= 179,
                        ArrowDown: (d) => d >= 90 && d <= 269,
                        ArrowLeft: (d) => d >= 180 && d <= 359
                    };

                    if (conditions[key](bearingAB)) targetChip = chipAB;
                    else if (conditions[key](bearingBA)) targetChip = chipBA;

                    if (targetChip && !targetChip.checked) {
                        targetChip.click();
                        e.preventDefault();
                        e.stopPropagation();
                        e.stopImmediatePropagation();
                    }
                };
                
                if (['ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowLeft'].includes(e.key)) {
                    press(e.key);
                }
            };
            document.addEventListener('keydown', keydownListener, true);
        }
    }

    wmeSDK.Events.on({
        eventName: 'wme-selection-changed',
        eventHandler: updateHazardUI
    });

    wmeSDK.Events.on({
        eventName: 'wme-after-edit',
        eventHandler: updateHazardUI
    });

    wmeSDK.Events.on({
        eventName: 'wme-after-undo',
        eventHandler: updateHazardUI
    });

    updateHazardUI();
}

window.SDK_INITIALIZED.then(init);