WME Edit Area Age

Displays age of editable areas

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         WME Edit Area Age
// @namespace    https://greasyfork.org/users/1365511
// @version      2025.01.21.002
// @description  Displays age of editable areas
// @author       robosphinx_
// @match        *://*.waze.com/*editor*
// @exclude      *://*.waze.com/user/editor*
// @require      https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @grant        none
// @license      GPLv3
// ==/UserScript==

/* global W */
/* global WazeWrap */

(function main() {
    'use strict';

    const SCRIPT_LONG_NAME = GM_info.script.name;
    const SCRIPT_SHORT_NAME = "WME-EAA";
    const SCRIPT_SHORTEST_NAME = "EAA";
    const SCRIPT_VERSION = GM_info.script.version;

    const TIME_PER_DRIVE_MS = 250;
    const DRIVES_PER_PAGE   = 15;
    const TIME_PER_PAGE_MS  = (DRIVES_PER_PAGE + 5) * TIME_PER_DRIVE_MS;
    const MAX_DRIVES = 300;

    const EAA_STYLE = {
        strokeWidth: 0
    };

    // For OL geometry conversion
    // Grabbing geometry from WME segments gives typical lat/lon coordinates as we humans know them.  We must convert to OL scale :)
    // Discovered through trial and error :')
    // See: https://openlayers.org/en/latest/apidoc/module-ol_proj_Projection-Projection.html
    const srcProjection = 'EPSG:4326'; // WGS 84 / Geographic (human-readable degrees lat/lon)
    const destProjection = 'EPSG:3857'; // WGS 84 / Spherical Mercator (meters, cartesian/planar?)
    
    // For spherical (ESPG:4326) math.  It's what I've worked on before, even if it's mathematically
    // more complex than planar math.  Why reinvent the wheel when the planar math doesn't seem to
    // fully extend to the EA bounds??  Seems Waze is using something other than planar math when
    // calculating it...
    const EQUATORIAL_RADIUS_METERS = 6378137.0;
    const POLAR_RADIUS_METERS = 6356752.31424518;
    // This is gross, but I've played with a LOT of projection code to figure out how EA is
    // calculated based on drive traces and it's nothing obvious...
    // Time to break out the magic numbers </3
    // oof even the magic numbers don't fix this because it's varying with latitude still.
    // I'm NOT going to find a magic equation to fudge the sizes dynamically.  This gets pretty close.
    const VERTICAL_SCALAR = 1.12;
    const HORIZONTAL_SCALAR = 0.90;


    // TODO: Configurable
    const DAYS_TO_EXPIRY = 7;


    // One-time load/set
    let WM;
    let DL;
    let editRadius = 0;
    let wmeeaaEditAgeLayer;
    let _$scanDrivesButton = null;

    // Volatile things and stuff - changes on user input
    let layerEnabled = true;
    let centerAtProcessDrivesStart;
    let zoomAtProcessDrivesStart;
    let numDrives = 0;
    let driveDates = [];
    let features = [];

    /*
     * log does what you expect.  Intended use is a severity and a message, but it really just
     * appends the two strings together.  Not really any rules on severity tags, but ERROR will
     * be printed as an error to the js console.
     */
    function log(tag, message) {
        if (tag == "ERROR") {
            console.error(SCRIPT_SHORT_NAME + ": " + tag + ": " + message);
        }
        else {
            console.log(SCRIPT_SHORT_NAME + ": " + tag + ": " + message);
        }
    }

    /*
     * Event handler for when the map move finishes.
     * Set this as an active event handler only when scanning.
     * Remove from event handlers when finished to avoid headache.
     * Expects a drive to be added to the map from the user drives history list.
     * Takes the OpenLayers geometry and saves all points from the drive trace along with the date of the drive.
     */
    function mapMoveEnd() {
        try {
            if (DL.features.length > 0) {
                let numFeatures = DL.features.length;
                let coordinatesList = [];
                // log("DEBUG", "NumFeatures: " + numFeatures);
                for (let featureIndex = 0; featureIndex < numFeatures; featureIndex++) {
                    // log("DEBUG", "Processing feature " + featureIndex);
                    let specificGeometry = DL.features[featureIndex].attributes.wazeFeature.geometry; // OL.Geometry.LineString

                    let drivePoints = [];
                    for (let i = 0; i < specificGeometry.coordinates.length; i++) {
                        let lon = specificGeometry.coordinates[i][0];
                        let lat = specificGeometry.coordinates[i][1];
                        drivePoints.push(new OpenLayers.Geometry.Point(lon, lat));
                    }

                    let lineString = new OpenLayers.Geometry.LineString(drivePoints);
                    // log("DEBUG", "Found " + specificGeometry.coordinates.length + " coordinates.");
                    // log("DEBUG", "Captured " + drivePoints.length + " of them.");

                    let age = getDuration(driveDates[numDrives]);
                    let color = getColorFromAge(age);
                    let z = (MAX_DRIVES * 5) - (numDrives * 5);

                    let vertices = lineString.getVertices();

                    // log("DEBUG", "Creating start cap");
                    // Create start cap of the polygon
                    let startCap = getSemiCircularCap(vertices[0], vertices[1], editRadius);
                    let polygon = new OpenLayers.Geometry.LinearRing(startCap)

                    // log("DEBUG", "Polygon has " + polygon.getVertices().length + " points and area " + polygon.getArea());

                    // log("DEBUG", "Creating edges");
                    for (let i = 1; i < vertices.length - 1; i++) {
                        // getPolygonEdges gives right and then left point
                        let edges = getPolygonEdges(vertices[i], vertices[i + 1], editRadius);
                        // log("DEBUG", "Created edges");
                        polygon.addComponent(edges[0], 0); // Add to start of list
                        // log("DEBUG", "Inserted first edge at index 0");
                        polygon.addComponent(edges[1], polygon.getVertices().length); // Add to end of list
                        // log("DEBUG", "Appended second edge to end of list");
                    }

                    // log("DEBUG", "Polygon has " + polygon.getVertices().length + " points and area " + polygon.getArea());

                    // log("DEBUG", "Creating end cap");
                    // Create end cap of polygon
                    let endCap = getSemiCircularCap(vertices[vertices.length - 1], vertices[vertices.length - 2], editRadius);
                    // TODO: Do we need to reverse the end cap point order before appending?
                    for (let i = 0; i < endCap.length; i++) {
                        polygon.addComponent(endCap[i]); // Add to end of list
                    }

                    // log("DEBUG", "Polygon has " + polygon.getVertices().length + " points and area " + polygon.getArea());

                    // Transform projections after calculating circle to properly stretch to
                    // EA bounds
                    // log("DEBUG", "Transforming polygon");
                    polygon.transform(srcProjection, destProjection);
                    
                    // log("DEBUG", "Adding polygon to vector");
                    // Create a vector containing the geometry to add to the map layer
                    let vector = new OpenLayers.Feature.Vector(polygon, null, {
                        strokeWidth: 0,
                        zIndex: z,
                        fillOpacity: 1.0,
                        fillColor: color
                    });
                    // log("DEBUG", "Adding vector to features");
                    features.push(vector);

                    // log("DEBUG", "Drive " + numDrives + " was " + age + " days old.  Using color " + color + " at z index " + z);
                }

                numDrives++;

                // log("DEBUG", "Found " + coordinatesList.length + " coordinates in this drive - processed " + fullyProcessedCoordinates + " of them.");
                // log("DEBUG", "Processed " + numDrives + " drives.");
            }
        }
        catch (err) {
            log("ERROR", "mapMoveEnd returned error " + err);
        }
    }

    function openDrivesTab() {
        // Check if div id="sidepanel-drives" class contains active (if not, click drives)
        if ( $('[data-for="drives"]').attr('selected').includes("true") ) {
            // log("DEBUG", "Pane is active");
        }
        else {
            // log("DEBUG", "Pane is NOT active, clicking");
            // Select wz-navigation-item data-for="drives" (drives button in left sidebar)
            $('[data-for="drives"]').click();
        }
    }

    function closeDrivesTab() {
        // Check if div id="sidepanel-drives" class contains active (if not, click drives)
        if ( $('[data-for="drives"]').attr('selected').includes("true") ) {
            log("DEBUG", "Pane is active, clicking");
            // Select wz-navigation-item data-for="drives" (drives button in left sidebar)
            $('[data-for="drives"]').click();
        }
        else {
            // log("DEBUG", "Pane is NOT active");
        }
        // closeLastDrive();
    }

    function closeLastDrive() {
        let allButtons = $('wz-button')
        for (let i = 0; i < allButtons.length; i++) {
            if ($(allButtons[i]).attr('color')) {
                // log("DEBUG", "Button included color attribute");
                if ($(allButtons[i]).attr('color').includes("clear-icon")) {
                    // log("DEBUG", "Button included correct color attribute value");
                    if ($(allButtons[i]).attr('size')) {
                        // log("DEBUG", "Button included size attribute");
                        if ($(allButtons[i]).attr('size').includes("xs")) {
                            // log("DEBUG", "Button included correct size attribute value");
                            $(allButtons[i]).click();
                            return;
                        }
                    }
                }
            }
        }
    }

    function openScriptsTab() {
        // Check if div id="sidepanel-drives" class contains active (if not, click drives)
        if ( $('#user-tabs').attr('hidden') ) {
            // log("DEBUG", "Pane is NOT active, clicking");
            // Select wz-navigation-item data-for="userscript_tab" (scripts button in left sidebar)
            $('[data-for="userscript_tab"]').click();
        }
        // else {
        //     log("DEBUG", "Pane is active");
        // }
    }

    function clickDriveAndCaptureDate(driveCard) {
        let dateString = $(driveCard).find('.list-item-card-title')[0].innerText;
        // log("DEBUG", "Date: " + dateString);
        driveDates.push(new Date(dateString));
        $(driveCard).click();
    }

    function selectEachDriveAndGoToNextPage() {
        try {
            let nextPageButtonEnabled = false;
            let maxPages = MAX_DRIVES / 15; // 20 pages, 300 drives, up to 3.5 drives per day.  Might need more for daily wazers?  90 days' worth
            //log("DEBUG", "Selecting all wz-card children of sidepanel-drives");
            let visibleCards = $('#sidepanel-drives .drive-list wz-card');
            //log("DEBUG", "Selected " + visibleCards.length + " elements");
            let numCards = 0;
            //log("DEBUG", "Iterating over selected wz-card");
            let totalDelay = TIME_PER_DRIVE_MS;
            for (let cardIndex = 0; cardIndex < visibleCards.length; cardIndex++) {
                //log("DEBUG", "Iterating over card " + cardIndex);
                setTimeout(clickDriveAndCaptureDate(visibleCards[cardIndex]), totalDelay);
                totalDelay += TIME_PER_DRIVE_MS;
            }
            //log("DEBUG", "Done with wz-card");
            //log("DEBUG", "Looking for paginator");
            let pageButtons = $('.drive-list .paginator wz-button');
            for (let buttonIndex = 0; buttonIndex < pageButtons.length; buttonIndex++) {
                let nextPageButtonIcon = $(pageButtons[buttonIndex]).find('i');

                if ($(nextPageButtonIcon).attr('class').split('-').includes("right")) {
                    //log("DEBUG", "found next page button");

                    nextPageButtonEnabled = !($(pageButtons[buttonIndex]).prop("disabled"));
                    //log("DEBUG", "next page button is " + (nextPageButtonEnabled ? "" : "NOT " ) + "enabled.");
                    if (nextPageButtonEnabled) {
                        $(pageButtons[buttonIndex]).click();
                        setTimeout(selectEachDriveAndGoToNextPage, TIME_PER_PAGE_MS);
                    }
                    else {
                        // TODO: Do something after collecting all geoms.  Wait with a timeout then call some func
                        // This executes right after we START the timeout for the last page drives.  So we should get the number of drives on the last page and set a delay accordingly, much like the next page timeout

                        setTimeout(processedAllDrives, TIME_PER_PAGE_MS); // 7 minutes later... Need to insert a Spongebob transition screen here.
                        // That seems to trigger too early no matter how long the timeout is... SOme other callback?
                    }
                }
            }
        }
        catch (err) {
            log("ERROR", "Clicking drives encountered an error: " + err);
        }
    }

    function processedAllDrives() {
        try {
            // log("DEBUG", "Finished creating gargantuan geoms.  Processed " + numDrives + " drives.");
            // Update script panel label for number of processed drives.  TODO: Probably remove later
            $('#wmeeaaDrives').text( numDrives );
            WM.events.unregister('moveend', null, mapMoveEnd);
            // Return to center and zoom from before we started
            W.map.setCenter(centerAtProcessDrivesStart);
            W.map.getOLMap().zoomTo(zoomAtProcessDrivesStart);
            // log("DEBUG", "Adding all features (" + features.length + " components)");
            // Reverse features so newest drive is added last
            features.reverse();
            // Add accumulated drive features to layer
            wmeeaaEditAgeLayer.addFeatures(features);
            // log("DEBUG", "Added all features (" + features.length + " components)");

            // openScriptsTab();
            closeDrivesTab();

            // for (let i = 0; i < driveDates.length; i++) {
            //     log("DEBUG", "Drive " + (i + 1) + " date: " + driveDates[i]);
            // }
        }
        catch (err) {
            log("ERROR", "final drives processing encountered an error: " + err);
        }
    }

    function iterateDrives() {
        try {
            WM.events.register('moveend', null, mapMoveEnd);
            // Record current center and zoom level so we can return after scanning drives
            centerAtProcessDrivesStart = W.map.getCenter();
            zoomAtProcessDrivesStart = W.map.getOLMap().getZoom();
            // Clear any previous scans.  Drives may have updated
            numDrives = 0;
            features = [];
            wmeeaaEditAgeLayer.removeAllFeatures();
            // Start scanning drives
            setTimeout(selectEachDriveAndGoToNextPage, 1000);
        }
        catch (err) {
            log("ERROR", "Iterating over drives pages encountered an error: " + err);
        }
    }

    function onScanDrivesButtonClick() {
        try {
            try {
                WM.events.unregister('moveend', null, mapMoveEnd);
            }
            catch (err) {
                log("DEBUG", "MapMoveEnd was not registered");
            }
            numDrives = 0;
            // log("INFO", "Opening drives tab");
            openDrivesTab();
            // log("DEBUG", "Drives tab should be open now");
            setTimeout(iterateDrives, 1000);
        }
        catch (err) {
            log("ERROR", "Button click handler encountered error: " + err);
        }
    }

    // Shamelessly ripped from UR-MP and modified to fit my needs
    // Takes a timestamp and calculates its age (delta from now)
    function getDuration(ts) {
        const aDate = new Date()
        const now = aDate.getTime()
        const duration = now - ts
        aDate.setHours(0)
        aDate.setMinutes(0)
        aDate.setSeconds(0)
        aDate.setMilliseconds(0)
        const startOfDay = aDate.getTime()
        if (duration < now - startOfDay) {
            return 0
        }
        return Math.ceil((duration - (now - startOfDay)) / 86400000)
    }

    // Shamelessly ripped from UR-MP and modified to fit my needs
    // Takes a decimal value and a total number of characters and returns an equivalent 0-padded hex value
    function decimalToHex(d, padding) {
        let hex = Number(d).toString(16);
        padding = typeof padding === 'undefined' || padding === null ? padding = 2 : padding;
        while (hex.length < padding) {
            hex = '0' + hex;
        }
        return hex;
    }

    // Shamelessly ripped from UR-MP and modified to fit my needs
    // Takes a number of days (generally age of a drive) and returns a corresponding color
    // Drive-based EA lasts for 90 days
    // Newest drives should be green, shifting through yellow @ 45 days, reaching 90 days age at full red.
    function getColorFromAge(ageInDays) {
        let r = 0;
        let g = 0;
        let b = 255;

        r = -15 + (6 * ageInDays);
        g = 525 - (6 * ageInDays);
        if (r < 0) {
            r = 0;
        }
        if (r > 255) {
            r = 255;
        }
        if (g < 0) {
            g = 0;
        }
        if (g > 255) {
            g = 255;
        }
        b = 0;
        if (ageInDays > (90 - DAYS_TO_EXPIRY)) {
            r = 255;
            g = 0;
            b = 255;
        }
        return '#' + decimalToHex(r, 2) + decimalToHex(g, 2) + decimalToHex(b, 2);
    }

    /*
     * Creates a pair of points around a given center point, perpendicular to the ray created between the center and a provided reference point, with the specified radius.
     * Ex: Providing a center at 0, 0 and a reference at 1,1 will create a pair of points at 135 degrees and 315 degrees
     */
    function getPolygonEdges(center, nextPoint, radius_mi) {
        // log("DEBUG", "Circle center: " + center);
        // For use with EPSG:3857 projection, meter-based cartesian/planar
        const radius_m = radius_mi * 1609.344; // miles to meters
        let points = [];
        let referenceBearing = getBearingFromCoordinatePair(center.y, center.x, nextPoint.y, nextPoint.x);

        for (let degree = referenceBearing + 90; degree <= referenceBearing + 270; degree += 180) {
            // Degrees use 0-north
            // For use with EPSG:4326 projection, WGS-84/human-readable degrees lat/lon
            const relativePoint = getNewScaledRelativeCoordinates(center.y, center.x, radius_m, (degree % 360));
            const globalPoint = new OpenLayers.Geometry.Point(center.x + relativePoint.x, center.y + relativePoint.y)
            // log("DEBUG", "Edge " + (degree % 360) + "\t" + center.x + "\t" + center.y + ":\t" + globalPoint.x + "\t" + globalPoint.y);
            points.push(globalPoint);
        }
        return points;
    }

    /*
     * Creates a semi-circle around a given center point, opposite a provided reference point, with the specified radius.
     * Ex: Providing a center at 0, 0 and a reference at 1,1 will create an arc from 135 degrees to 315 degrees
     */
    function getSemiCircularCap(center, referencePoint, radius_mi) {
        // log("DEBUG", "Circle center: " + center);
        // For use with EPSG:3857 projection, meter-based cartesian/planar
        const radius_m = radius_mi * 1609.344; // miles to meters
        let points = [];
        let referenceBearing = getBearingFromCoordinatePair(center.y, center.x, referencePoint.y, referencePoint.x);

        for (let degree = referenceBearing + 90; degree <= referenceBearing + 270; degree += 5) {
            // Degrees use 0-north
            // For use with EPSG:4326 projection, WGS-84/human-readable degrees lat/lon
            const relativePoint = getNewScaledRelativeCoordinates(center.y, center.x, radius_m, (degree % 360));
            const globalPoint = new OpenLayers.Geometry.Point(center.x + relativePoint.x, center.y + relativePoint.y)
            // log("DEBUG", "Cap " + (degree % 360) + "\t" + center.x + "\t" + center.y + ":\t" + globalPoint.x + "\t" + globalPoint.y);
            points.push(globalPoint);
        }
        return points;
    }

    // Shamelessly ripped from WME-USGB and modified to fit my needs
    // Takes a center point and a radius and makes a corresponding circle
    function getCircleLinearRing(center, radius_mi) {
        // log("DEBUG", "Circle center: " + center);
        // For use with EPSG:3857 projection, meter-based cartesian/planar
        const radius_m = radius_mi * 1609.344; // miles to meters
        const points = [];

        for (let degree = 0; degree < 360; degree += 5) {
            // Degrees use 0-north
            // For use with EPSG:4326 projection, WGS-84/human-readable degrees lat/lon
            const relativePoint = getNewScaledRelativeCoordinates(center.y, center.x, radius_m, degree);
            const globalPoint = new OpenLayers.Geometry.Point(center.x + relativePoint.x, center.y + relativePoint.y)
            points.push(globalPoint);
        }
        return new OpenLayers.Geometry.LinearRing(points);
    }

    /*
     * Gets an approximate radius in meters of the earth at a given latitude.
     * Useful for more locally accurate calculations.
     * Would it be nice to be a flat-earther?  Yes.  It would make this whole thing much easier.
     * Unfortunately the sphereical-earthers are wrong, too.  Turns out the earth is squishy
     * enough to deform along the equator because that's what happens when you spin a really large
     * mass really fast.  Science is cool.
     */
    function getEarthRadiusMeters(latitudeRadians)
    {
        return Math.sqrt(
            (Math.pow(Math.pow(EQUATORIAL_RADIUS_METERS, 2) * Math.cos(latitudeRadians), 2)
                + Math.pow(Math.pow(POLAR_RADIUS_METERS, 2) * Math.sin(latitudeRadians), 2)) /* end numerator */
            / (Math.pow(EQUATORIAL_RADIUS_METERS * Math.cos(latitudeRadians), 2)
                + Math.pow(POLAR_RADIUS_METERS * Math.sin(latitudeRadians), 2)) /* end denominator */
            ); /* End sqrt */
    }

    /*
     * Calculates new coordinates on a spheroid surface, given some initial point along with a
     * direction and distance to the desired new point.
     */
    function getNewCoordinates(latDegrees, lonDegrees, distanceMeters, bearingDegrees) {
        if (distanceMeters == 0) return new OpenLayers.Geometry.Point(lonDegrees, latDegrees);

        let latitudeRadians = toRadians(latDegrees);
        let longitudeRadians = toRadians(lonDegrees);
        let bearingRadians = toRadiansZeroEastPositiveCCW(bearingDegrees);
        let distanceRadians = distanceMeters / getEarthRadiusMeters(latitudeRadians);
        let newLatRadians = Math.asin(Math.sin(latitudeRadians) * Math.cos(distanceRadians) + Math.cos(latitudeRadians) * Math.cos(bearingRadians) * Math.sin(distanceRadians));
        let newLonRadians = longitudeRadians + Math.atan2(Math.sin(bearingRadians) * Math.sin(distanceRadians) * Math.cos(latitudeRadians), Math.cos(distanceRadians) - Math.sin(latitudeRadians) * Math.sin(newLatRadians));

        return new OpenLayers.Geometry.Point(toDegrees(newLonRadians), toDegrees(newLatRadians));
    }

    /*
     * Calculates new coordinates on a spheroid surface, treating the input coordinates as the origin (0, 0)
     * Creates relative or offset coordinates.
     */
    function getNewRelativeCoordinates(latDegrees, lonDegrees, distanceMeters, bearingDegrees) {
        if (distanceMeters == 0) return new OpenLayers.Geometry.Point(lonDegrees, latDegrees);

        let latitudeRadians = toRadians(latDegrees);
        let longitudeRadians = toRadians(lonDegrees);
        let bearingRadians = toRadians(bearingDegrees);
        let distanceRadians = distanceMeters / getEarthRadiusMeters(latitudeRadians);
        let newLatRadians = Math.asin(Math.sin(latitudeRadians) * Math.cos(distanceRadians) + Math.cos(latitudeRadians) * Math.cos(bearingRadians) * Math.sin(distanceRadians));
        let newLonRadians = longitudeRadians + Math.atan2(Math.sin(bearingRadians) * Math.sin(distanceRadians) * Math.cos(latitudeRadians), Math.cos(distanceRadians) - Math.sin(latitudeRadians) * Math.sin(newLatRadians));
        // Subtract original coordinates from resultant coordinates to provide relative/offset coordinates
        return new OpenLayers.Geometry.Point(toDegrees(newLonRadians) - lonDegrees, toDegrees(newLatRadians) - latDegrees);
    }

    /*
     * Calculates new coordinates on a spheroid surface, treating the input coordinates as the origin (0, 0)
     * Creates relative or offset coordinates that include magic number scalars.
     */
    function getNewScaledRelativeCoordinates(latDegrees, lonDegrees, distanceMeters, bearingDegrees) {
        let originalRelativePoint = getNewRelativeCoordinates(latDegrees, lonDegrees, distanceMeters, bearingDegrees);
        return new OpenLayers.Geometry.Point(originalRelativePoint.x * HORIZONTAL_SCALAR, originalRelativePoint.y * VERTICAL_SCALAR);
    }

    function toRadians(degrees) {
        return degrees * Math.PI / 180;
    }

    function toDegrees(radians) {
        return radians * 180 / Math.PI;
    }

    /*
     * Converts from
     * degree with zero-north origin, increasing in the clockwise direction (earth, navigation)
     * to
     * radian with zero-west origin, increasing in the counterclockwise direction (math)
     */
    function toRadiansZeroEastPositiveCCW(degrees) {
        // Flip across line y = x
        let inputRadians = toRadians(degrees);
        let inputX = Math.cos(inputRadians);
        let inputY = Math.sin(inputRadians);
        // Here's the flip ladies and gents
        let flippedRadians = Math.atan2( inputX, inputY );
        // log("DEBUG", "Input degrees: " + degrees + "\t x: " + inputX + "\ty: " + inputY + "\tflipped: " + toDegrees(flippedRadians) + "\tradians: " + flippedRadians);
        return flippedRadians;
    }

    /*
     * Converts from
     * radian with zero-west origin, increasing in the counterclockwise direction (math)
     * to
     * degree with zero-north origin, increasing in the clockwise direction (earth, navigation)
     */
    function toDegreesZeroNorthPositiveCW(radians) {
        // Flip across line y = x
        let inputX = Math.cos(radians);
        let inputY = Math.sin(radians);
        // Here's the flip ladies and gents
        let flippedRadians = Math.atan2( inputX, inputY );
        return toDegrees(flippedRadians);
    }

    /*
     * Takes the given pair of coordinates and calculates an approximate bearing betwixt them
     */
    function getBearingFromCoordinatePair(lat1, lon1, lat2, lon2) {
        let lat1rad = toRadians(lat1);
        let lat2rad = toRadians(lat2);
        let lon1rad = toRadians(lon1);
        let lon2rad = toRadians(lon2);
        let y = Math.sin(lon2rad - lon1rad) * Math.cos(lat2rad);
        let x = Math.cos(lat1rad) * Math.sin(lat2rad) - Math.sin(lat1rad) * Math.cos(lat2rad) * Math.cos(lon2rad - lon1rad);
        return toDegrees(Math.atan2(y, x));
    }


    function initializeSettings() {
        // TODO: All the things
        // TODO: loadSettings if we ever have settings to load/save (can we save the area?  That's a lot of data but probably nicer than rescanning every time...)
        // Layer on/off status?
        $('#wmeeaaRadius').text(editRadius + " miles"); // TODO: Support km
        $('#wmeeaaDrives').text("Unknown! Please scan.");
        // $('#wmeeaaArea').text("Unknown! Please scan.");
    }

    function onAgeLayerToggleChanged(checked) {
        wmeeaaEditAgeLayer.setVisibility(checked);
        // log("DEBUG", "Layer checkbox is " + (checked ? "" : "not ") + "checked.");
    }

    function init() {
        try {
            log("INFO", SCRIPT_LONG_NAME + " " + SCRIPT_VERSION + " started");

            WM = W.map;
            DL = WM.driveLayer;
            editRadius = W.loginManager.user.attributes.editableMiles;

            // WazeWrap and whatever else initialization
            // Create our layer
            wmeeaaEditAgeLayer = new OpenLayers.Layer.Vector('wmeeaaEditAgeLayer', {
                uniqueName: '__wmeeaaEditAgeLayer',
                styleMap: new OpenLayers.StyleMap({ default: EAA_STYLE })
            } );
            // TODO: Set visibility to loaded value
            wmeeaaEditAgeLayer.setVisibility(true);
            wmeeaaEditAgeLayer.setZIndex(W.map.roadLayer.getZIndex() - 1);
            wmeeaaEditAgeLayer.setOpacity(0.3);

            // Add the layer checkbox to the Layers menu.
            WazeWrap.Interface.AddLayerCheckbox('display', 'Edit Age', layerEnabled, onAgeLayerToggleChanged);

            WM.addLayer(wmeeaaEditAgeLayer);

            _$scanDrivesButton = $('<button>', { id: 'wmeeaaStartScan', class: 'wmeeaaSettingsButton' }).text('Scan Area');


            // Userscripts tab section
            var $section = $("<div>");
            $section.append([
                '<div>',
                '<h2>WME Edit Area Age</h2>'
            ].join(' '));
            $section.append(_$scanDrivesButton);
            // TODO: Adjustable expiry age to only show area near expiry
            $section.append([
                '<hr>',
                '<div>',
                '<h3>Edit Age Info</h3>',
                'Editable radius: <span id="wmeeaaRadius"></span></br>',
                'Drives: <span id="wmeeaaDrives"></span></br>',
                // 'Editable area from drives: <span id="wmeeaaArea"></span></br>',
                '</div>',
                '</div>'
            ].join(' '));

            WazeWrap.Interface.Tab(SCRIPT_SHORTEST_NAME, $section.html(), initializeSettings);

            log("INFO", SCRIPT_LONG_NAME + " initialized!");

            $("#wmeeaaStartScan").click(onScanDrivesButtonClick);
        }
        catch (err) {
            log("ERROR", SCRIPT_LONG_NAME + " could not initialize: " + err);
        }
    }

    function onWmeReady() {
        if (WazeWrap && WazeWrap.Ready) {
            init();
        } else {
            setTimeout(onWmeReady, 100);
        }
    }

    function bootstrap() {
        if (typeof W === 'object' && W.userscripts?.state.isReady) {
            onWmeReady();
        } else {
            document.addEventListener('wme-ready', onWmeReady, { once: true });
        }
    }

    bootstrap();
})();