WME US Government Boundaries

Adds a layer to display US (federal, state, and/or local) boundaries.

// ==UserScript==
// @name            WME US Government Boundaries
// @namespace       https://greasyfork.org/users/45389
// @version         2024.10.12.000
// @description     Adds a layer to display US (federal, state, and/or local) boundaries.
// @author          MapOMatic
// @include         /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/
// @require         https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js
// @require         https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @grant           GM_xmlhttpRequest
// @license         GNU GPLv3
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// @connect         census.gov
// @connect         wazex.us
// @connect         usps.com
// @connect         arcgis.com
// @connect         greasyfork.org
// ==/UserScript==

/* global OpenLayers */
/* global W */
/* global turf */
/* global WazeWrap */

(function main() {
    'use strict';

    const UPDATE_MESSAGE = '';
    const SCRIPT_NAME = GM_info.script.name;
    const CURRENT_VERSION = GM_info.script.version;
    const DOWNLOAD_URL = 'https://greasyfork.org/scripts/25631-wme-us-government-boundaries/code/WME%20US%20Government%20Boundaries.user.js';

    const SETTINGS_STORE_NAME = 'wme_us_government_boundaries';
    // As of 8/8/2021, ZIP code tabulation areas are showing as 1/1/2020.
    const ZIPS_LAYER_URL = 'https://tigerweb.geo.census.gov/arcgis/rest/services/Census2020/PUMA_TAD_TAZ_UGA_ZCTA/MapServer/2/';
    const COUNTIES_LAYER_URL = 'https://tigerweb.geo.census.gov/arcgis/rest/services/Census2020/State_County/MapServer/1/';
    const STATES_LAYER_URL = 'https://tigerweb.geo.census.gov/arcgis/rest/services/Census2020/State_County/MapServer/0/';
    const TIME_ZONES_LAYER_URL = 'https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/World_Time_Zones/FeatureServer/0/';
    const USPS_ROUTE_COLORS = ['#f00', '#0a0', '#00f', '#a0a', '#6c82cb', '#0aa'];
    const USPS_ROUTES_URL_TEMPLATE = 'https://gis.usps.com/arcgis/rest/services/EDDM/selectNear/GPServer/routes/execute?f=json&env%3AoutSR=102100&'
        + 'Selecting_Features=%7B%22geometryType%22%3A%22esriGeometryPoint%22%2C%22features%22%3A%5B%7B%22'
        + 'geometry%22%3A%7B%22x%22%3A{lon}%2C%22y%22%3A{lat}%2C%22spatialReference%22%3A%7B%22wkid%22%3A'
        + '102100%2C%22latestWkid%22%3A3857%7D%7D%7D%5D%2C%22sr%22%3A%7B%22wkid%22%3A102100%2C%22latestWkid'
        + '%22%3A3857%7D%7D&Distance={radius}&Rte_Box=R&userName=EDDM';
    const USPS_ROUTES_RADIUS = 0.5; // miles
    let LAYER_Z_INDEX;

    // Min zoom caps to prevent displaying too many zip and county boundaries (overload user's browser)
    const MIN_COUNTIES_ZOOM = 9;
    const MIN_ZIPS_ZOOM = 12;
    const ZOOM_GRANULARITY = {
        22: 5,
        21: 5,
        20: 5,
        19: 5,
        18: 5,
        17: 5,
        16: 5,
        15: 10,
        14: 15,
        13: 30,
        12: 80,
        11: 120,
        10: 300,
        9: 1000,
        8: 2000,
        7: 3000,
        6: 5000,
        5: 12000,
        4: 20000
    };

    const PROCESS_CONTEXTS = [];
    const ZIP_CITIES = {};
    const ZIPS_STYLE = {
        strokeColor: '#FF0000',
        strokeOpacity: 1,
        strokeWidth: 3,
        strokeDashstyle: 'solid',
        fillOpacity: 0,
        fontSize: '16px',
        fontFamily: 'Arial',
        fontWeight: 'bold',
        fontColor: 'red',
        label: '${label}',
        labelYOffset: -20,
        labelOutlineColor: 'white',
        labelOutlineWidth: 2
    };
    const COUNTIES_STYLE = {
        strokeColor: 'pink',
        strokeOpacity: 1,
        strokeWidth: 6,
        strokeDashstyle: 'solid',
        fillOpacity: 0,
        fontSize: '18px',
        fontFamily: 'Arial',
        fontWeight: 'bold',
        fontColor: 'pink',
        label: '${label}',
        labelOutlineColor: 'black',
        labelOutlineWidth: 2
    };
    const STATES_STYLE = {
        strokeColor: 'blue',
        strokeOpacity: 1,
        strokeWidth: 6,
        strokeDashstyle: 'solid',
        fillOpacity: 0,
        fontSize: '18px',
        fontFamily: 'Arial',
        fontWeight: 'bold',
        fontColor: 'blue',
        label: '${label}',
        labelYOffset: 20,
        labelOutlineColor: 'lightblue',
        labelOutlineWidth: 2
    };
    const TIME_ZONES_STYLE = {
        strokeColor: '#f85',
        strokeOpacity: 1,
        strokeWidth: 6,
        strokeDashstyle: 'solid',
        fillOpacity: 0,
        fontSize: '18px',
        fontFamily: 'Arial',
        fontWeight: 'bold',
        fontColor: '#f85',
        label: '${label}',
        labelYOffset: -40,
        labelOutlineColor: '#831',
        labelOutlineWidth: 2
    };
    const USPS_ROUTES_STYLE = {
        strokeColor: '${color}',
        strokeDashstyle: 'solid',
        strokeWidth: '${strokeWidth}'
    };
    let _zipsLayer;
    let _countiesLayer;
    let _statesLayer;
    let _uspsRoutesLayer;
    let _timeZonesLayer;
    let _circleFeature;
    let _$uspsResultsDiv;
    let _$getRoutesButton;
    let _settings = {};

    function log(message) {
        console.log('USGB:', message);
    }
    function logDebug(message) {
        console.log('USGB:', message);
    }
    function logError(message) {
        console.error('USGB:', message);
    }

    // Recursively checks the settings object and fills in missing properties from the
    // default settings object.
    function checkSettings(obj, defaultObj) {
        Object.keys(defaultObj).forEach(key => {
            if (!obj.hasOwnProperty(key)) {
                obj[key] = defaultObj[key];
            } else if (defaultObj[key] && (defaultObj[key].constructor === {}.constructor)) {
                checkSettings(obj[key], defaultObj[key]);
            }
        });
    }

    function loadSettings() {
        const loadedSettings = $.parseJSON(localStorage.getItem(SETTINGS_STORE_NAME));
        const defaultSettings = {
            lastVersion: null,
            layers: {
                zips: { visible: true, dynamicLabels: false },
                states: { visible: true, dynamicLabels: false },
                counties: { visible: true, dynamicLabels: true },
                timeZones: { visible: true, dynamicLabels: true }
            }
        };
        if (loadedSettings) {
            _settings = loadedSettings;
            checkSettings(_settings, defaultSettings);
        } else {
            _settings = defaultSettings;
        }
    }

    function saveSettings() {
        if (localStorage) {
            _settings.layers.zips.visible = _zipsLayer.visibility;
            _settings.layers.counties.visible = _countiesLayer.visibility;
            _settings.layers.timeZones.visible = _timeZonesLayer.visibility;
            _settings.layers.states.visible = _statesLayer.visibility;
            localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(_settings));
            log('Settings saved');
        }
    }

    function getUrl(baseUrl, extent, zoom, outFields) {
        const geometry = {
            xmin: extent.left,
            ymin: extent.bottom,
            xmax: extent.right,
            ymax: extent.top,
            spatialReference: { wkid: 102100, latestWkid: 3857 }
        };
        const geometryStr = JSON.stringify(geometry);
        let url = `${baseUrl}query?geometry=${encodeURIComponent(geometryStr)}`;
        url += '&returnGeometry=true';
        url += `&outFields=${encodeURIComponent(outFields.join(','))}`;
        url += `&maxAllowableOffset=${ZOOM_GRANULARITY[W.map.getZoom()]}`;
        // url += '&quantizationParameters={tolerance:100}'; // Don't do this.  It returns relative coordinates.
        url += '&spatialRel=esriSpatialRelIntersects&geometryType=esriGeometryEnvelope&inSR=102100&outSR=3857&f=json';
        return url;
    }

    function appendCityToZip(zip, cityState, context) {
        if (!context.cancel) {
            if (!cityState.error) {
                ZIP_CITIES[zip] = cityState;
                $('#zip-text').append(` (${cityState.city}, ${cityState.state})`);
            }
        }
    }

    function updateNameDisplay(context) {
        const center = W.map.getCenter();
        const mapCenter = new OpenLayers.Geometry.Point(center.lon, center.lat);
        let feature;
        let text = '';
        let label;
        let url;

        if (context.cancel) return;
        if (_zipsLayer && _zipsLayer.visibility) {
            const onload = res => appendCityToZip(text, $.parseJSON(res.responseText), res.context);
            for (let i = 0; i < _zipsLayer.features.length; i++) {
                feature = _zipsLayer.features[i];

                if (feature.geometry.containsPoint && feature.geometry.containsPoint(mapCenter)) {
                    // Substr removes leading ZWJ from the ZIP code label. ZWJ needed to fix map display of ZIP codes with leading zeros.
                    text = feature.attributes.name.substr(1);
                    $('<span>', { id: 'zip-text' }).empty().css({ display: 'inline-block' }).append(
                        $('<span>', { href: url, target: '__blank', title: 'Look up USPS zip code' })
                            .text(text)
                            .css({
                                color: 'white',
                                display: 'inline-block',
                                cursor: 'pointer',
                                'text-decoration': 'underline'
                            })
                            // eslint-disable-next-line no-loop-func
                            .click(() => {
                                GM_xmlhttpRequest({
                                    url: 'https://tools.usps.com/tools/app/ziplookup/cityByZip',
                                    headers: { 'Content-type': 'application/x-www-form-urlencoded' },
                                    method: 'POST',
                                    data: `zip=${text}`,
                                    onload: res => {
                                        // "{"resultStatus":"SUCCESS","zip5":"42748","defaultCity":"HODGENVILLE","defaultState":"KY",
                                        // "defaultRecordType": "STANDARD", "citiesList": [{ "city": "WHITE CITY", "state": "KY" }], "nonAcceptList": []}"
                                        const json = JSON.parse(res.responseText);
                                        let otherCities = json.citiesList.map(entry => `<div style="color: #0c1f25;">${entry.city}, ${entry.state}</div>`).join('');
                                        if (otherCities.length) {
                                            // eslint-disable-next-line max-len
                                            otherCities = `<div style="margin-top: 10px;">Other cities recognized for addresses in this ZIP:</div>${otherCities}`;
                                        }
                                        let citiesToAvoid = json.nonAcceptList.map(entry => `<div style="color: #0c1f25;">${entry.city}, ${entry.state}</div>`).join('');
                                        if (citiesToAvoid.length) {
                                            citiesToAvoid = `<div style="margin-top: 10px;">City names to avoid:</div>${citiesToAvoid}`;
                                        }
                                        // eslint-disable-next-line prefer-template
                                        const message = '<div style="margin-bottom: 10px;">From the <a href="https://tools.usps.com/go/ZipLookupAction_input" target="__blank">USPS "Look Up a ZIP Code" website</a></div>'
                                            + '<div>Recommended city:</div>'
                                            + `<div style="margin-bottom: 10px; color: #0c1f25;">${json.defaultCity}, ${json.defaultState}</div>`
                                            + otherCities + citiesToAvoid;
                                        WazeWrap.Alerts.info(null, message, true, false);
                                    }
                                });
                            })
                    ).appendTo($('#zip-boundary'));
                    if (!context.cancel) {
                        if (ZIP_CITIES[text]) {
                            appendCityToZip(text, ZIP_CITIES[text], context);
                        } else {
                            GM_xmlhttpRequest({
                                url: `https://wazex.us/zips/ziptocity2.php?zip=${text}`, context, method: 'GET', onload
                            });
                        }
                    }
                }
            }
        }
        if (_countiesLayer && _countiesLayer.visibility) {
            for (let i = 0; i < _countiesLayer.features.length; i++) {
                feature = _countiesLayer.features[i];
                if (feature.attributes.type !== 'label' && feature.geometry.containsPoint(mapCenter)) {
                    label = feature.attributes.name;
                    $('<span>', { id: 'county-text' }).css({ display: 'inline-block' })
                        .text(label)
                        .appendTo($('#county-boundary'));
                }
            }
        }
    }

    function getOLMapExtent() {
        let extent = W.map.getExtent();
        if (Array.isArray(extent)) {
            extent = new OpenLayers.Bounds(extent);
            extent.transform('EPSG:4326', 'EPSG:3857');
        }
        return extent;
    }

    function arcgisFeatureToOLFeature(feature, attributes) {
        const rings = [];
        const e = getOLMapExtent();
        const width = e.right - e.left;
        const height = e.top - e.bottom;
        const expandBy = 2;
        const clipBox = [
            e.left - width * expandBy,
            e.bottom - height * expandBy,
            e.right + width * expandBy,
            e.top + height * expandBy
        ];

        feature.geometry.rings.forEach(ringIn => {
            pointCount += ringIn.length;
            const polygon = turf.polygon([ringIn]);
            const clippedCoordinates = turf.bboxClip(polygon, clipBox).geometry.coordinates[0];
            if (clippedCoordinates && clippedCoordinates.length > 0) {
                const points = clippedCoordinates.map(coord => new OpenLayers.Geometry.Point(coord[0], coord[1]));
                reducedPointCount += points.length;
                rings.push(new OpenLayers.Geometry.LinearRing(points));
            }
        });
        if (rings.length > 0) {
            return new OpenLayers.Feature.Vector(new OpenLayers.Geometry.Polygon(rings), attributes);
        }
        return null;
    }

    function getRingArrayFromFeature(feature) {
        return feature.geometry.components.map(
            featureRing => featureRing.components.map(pt => [pt.x, pt.y])
        );
    }

    function getLabelPoints(feature) {
        const e = getOLMapExtent();
        const screenPoly = turf.polygon([[
            [e.left, e.top], [e.right, e.top], [e.right, e.bottom], [e.left, e.bottom], [e.left, e.top]
        ]]);
        // The intersect function doesn't seem to like holes in polygons, so assume the
        // first ring is the outer boundary and ignore any holes.
        const featurePoly = turf.polygon([getRingArrayFromFeature(feature)[0]]);
        const intersection = turf.intersect(turf.featureCollection([screenPoly, featurePoly]));
        let pts;

        if (intersection && intersection.geometry && intersection.geometry.coordinates) {
            let turfPt = turf.centerOfMass(intersection);
            if (!turf.booleanWithin(turfPt, intersection)) {
                turfPt = turf.pointOnFeature(intersection);
            }
            const turfCoords = turfPt.geometry.coordinates;
            const pt = new OpenLayers.Geometry.Point(turfCoords[0], turfCoords[1]);
            const { attributes } = feature;
            attributes.label = feature.attributes.name;
            pts = [new OpenLayers.Feature.Vector(pt, attributes)];
        } else {
            pts = null;
        }
        return pts;
    }

    let pointCount;
    let reducedPointCount;
    function processBoundaries(boundaries, context, type, nameField) {
        let layer;
        let layerSettings;
        let style;
        let zoom;

        pointCount = 0;
        reducedPointCount = 0;
        switch (type) {
            case 'zip':
                layerSettings = _settings.layers.zips;
                layer = _zipsLayer;
                // Append ZWJ character to label to prevent OpenLayers from dropping leading zeros in ZIP codes.
                boundaries.forEach(boundary => {
                    const zipzone = `‍${boundary.attributes[nameField]}`;
                    boundary.attributes[nameField] = `${zipzone}`;
                });
                break;
            case 'county':
                layerSettings = _settings.layers.counties;
                layer = _countiesLayer;
                style = layer.styleMap.styles.default.defaultStyle;
                if (W.map.getZoom() <= 9) {
                    style.fontSize = '16px';
                    style.strokeWidth = 3;
                    boundaries.forEach(boundary => {
                        const name = boundary.attributes[nameField].replace(/\s(County|Parish)/, '');
                        boundary.attributes[nameField] = name;
                    });
                } else {
                    style.fontSize = '18px';
                    style.strokeWidth = 6;
                }
                break;
            case 'state':
                zoom = W.map.getZoom();
                layerSettings = _settings.layers.states;
                layer = _statesLayer;
                style = STATES_STYLE;
                if (W.map.getZoom() < 5) {
                    layerSettings.dynamicLabels = false;
                    style.strokeWidth = 1;
                    style.fontSize = '14px';
                    boundaries.forEach(boundary => {
                        boundary.attributes[nameField] = '';
                    });
                } else if (W.map.getZoom() <= 6) {
                    layerSettings.dynamicLabels = false;
                    style.strokeWidth = 3;
                    style.fontSize = '14px';
                } else if (W.map.getZoom() <= 11) {
                    style.strokeWidth = 2;
                    style.fontSize = '16px';
                    layerSettings.dynamicLabels = true;
                } else if (W.map.getZoom() <= 15) {
                    style.strokeWidth = 3;
                    style.fontSize = '18px';
                    layerSettings.dynamicLabels = true;
                } else {
                    style.strokeWidth = 4;
                    style.fontSize = '18px';
                    layerSettings.dynamicLabels = true;
                    boundaries.forEach(boundary => {
                        boundary.attributes[nameField] = '';
                    });
                }
                if (zoom <= 9) {
                    style.labelYOffset = 0;
                } else {
                    style.labelYOffset = 20;
                }
                layer = _statesLayer;
                break;
            case 'timeZone':
                layerSettings = _settings.layers.timeZones;
                layer = _timeZonesLayer;
                boundaries.forEach(boundary => {
                    let zone = boundary.attributes[nameField];
                    if (zone >= 0) zone = `+${zone}`;
                    boundary.attributes[nameField] = `UTC${zone}`;
                });
                break;
            default:
                throw new Error('USGB: Unexpected type argument in processBoundaries');
        }

        if (context.cancel || !layerSettings.visible) {
            // do nothing
        } else {
            layer.removeAllFeatures();
            if (!context.cancel) {
                boundaries.forEach(boundary => {
                    const attributes = {
                        name: boundary.attributes[nameField],
                        label: layerSettings.dynamicLabels ? '' : boundary.attributes[nameField],
                        type
                    };

                    if (!context.cancel) {
                        const feature = arcgisFeatureToOLFeature(boundary, attributes);
                        if (feature) {
                            layer.addFeatures([feature]);
                            if (layerSettings.dynamicLabels) {
                                const labels = getLabelPoints(feature);
                                if (labels) {
                                    labels.forEach(labelFeature => {
                                        labelFeature.attributes.type = 'label';
                                    });
                                    layer.addFeatures(labels);
                                }
                            }
                        }
                    }
                });
            }
        }

        context.callCount--;
        if (context.callCount === 0) {
            updateNameDisplay(context);
            const idx = PROCESS_CONTEXTS.indexOf(context);
            if (idx > -1) {
                PROCESS_CONTEXTS.splice(idx, 1);
            }
        }

        if (W.loginManager && W.loginManager.user.userName === 'MapOMatic') {
            logDebug(`${type} points: ${pointCount} -> ${reducedPointCount} (${((1.0 - reducedPointCount / pointCount) * 100).toFixed(1)}%)`);
        }
    }

    function getUspsRoutesUrl(lon, lat, radius) {
        return USPS_ROUTES_URL_TEMPLATE.replace('{lon}', lon).replace('{lat}', lat).replace('{radius}', radius);
    }

    function getCircleLinearRing() {
        const center = W.map.getCenter();
        const radius = USPS_ROUTES_RADIUS * 1609.344; // miles to meters
        const points = [];

        for (let degree = 0; degree < 360; degree += 5) {
            const radians = degree * (Math.PI / 180);
            const lon = center.lon + radius * Math.cos(radians);
            const lat = center.lat + radius * Math.sin(radians);
            points.push(new OpenLayers.Geometry.Point(lon, lat));
        }
        return new OpenLayers.Geometry.LinearRing(points);
    }

    function getStrokeWidth(feature) {
        const zoom = W.map.getZoom();
        let width = zoom < 3 ? 10 + 2 * zoom : 16;
        width += feature.attributes.zIndex * 6;
        return width;
    }

    function processUspsRoutesResponse(res) {
        const data = $.parseJSON(res.responseText);
        const routes = data.results[0].value.features;

        const zipRoutes = {};
        routes.forEach(route => {
            const id = `${route.attributes.CITY_STATE} ${route.attributes.ZIP_CODE}`;
            let zipRoute = zipRoutes[id];
            if (!zipRoute) {
                zipRoute = { paths: [] };
                zipRoutes[id] = zipRoute;
            }
            zipRoute.paths = zipRoute.paths.concat(route.geometry.paths);
        });

        const features = [];
        _$uspsResultsDiv.empty();

        const routeCount = Object.keys(zipRoutes).length;
        Object.keys(zipRoutes).forEach((zipName, routeIdx) => {
            const route = zipRoutes[zipName];
            const paths = route.paths.map(path => {
                const pointList = path.map(point => new OpenLayers.Geometry.Point(point[0], point[1]));
                return new OpenLayers.Geometry.LineString(pointList);
            });
            const color = USPS_ROUTE_COLORS[routeIdx];
            const lineString = new OpenLayers.Geometry.MultiLineString(paths);
            const vector = new OpenLayers.Feature.Vector(lineString, {
                strokeWidth: getStrokeWidth,
                zIndex: routeCount - routeIdx - 1,
                color
            });
            features.push(vector);
            _$uspsResultsDiv.append($('<div>').text(zipName).css({ color, fontWeight: 'bold' }));
            routeIdx++;
        });
        _$getRoutesButton.removeAttr('disabled').css({ color: '#000' });
        _uspsRoutesLayer.addFeatures(features);
    }

    function fetchUspsRoutesFeatures() {
        const center = W.map.getCenter();
        const url = getUspsRoutesUrl(center.lon, center.lat, USPS_ROUTES_RADIUS);

        _$getRoutesButton.attr('disabled', 'true').css({ color: '#888' });
        _$uspsResultsDiv.empty().append('<i class="fa fa-spinner fa-pulse fa-3x fa-fw"></i>');
        _uspsRoutesLayer.removeAllFeatures();
        GM_xmlhttpRequest({ url, onload: processUspsRoutesResponse, anonymous: true });
    }

    function fetchBoundaries() {
        if (PROCESS_CONTEXTS.length > 0) {
            PROCESS_CONTEXTS.forEach(context => { context.cancel = true; });
        }

        const extent = getOLMapExtent();
        const zoom = W.map.getZoom();
        let url;
        const context = { callCount: 0, cancel: false };
        PROCESS_CONTEXTS.push(context);
        $('.us-boundary-region').remove();
        $('.location-info-region').after(
            $('<div>', { id: 'county-boundary', class: 'us-boundary-region' })
                .css({ color: 'white', float: 'left', marginLeft: '10px' }),
            $('<div>', { id: 'zip-boundary', class: 'us-boundary-region' })
                .css({ color: 'white', float: 'left', marginLeft: '10px' })
        );
        if (_settings.layers.zips.visible) {
            if (zoom > MIN_ZIPS_ZOOM) {
                url = getUrl(ZIPS_LAYER_URL, extent, zoom, ['ZCTA5']);
                context.callCount++;
                $.ajax({
                    url,
                    context,
                    method: 'GET',
                    datatype: 'json',
                    success(data) {
                        if (data.error) {
                            logError(`ZIP codes layer: ${data.error.message}`);
                        } else {
                            processBoundaries(data.features, this, 'zip', 'ZCTA5', 'ZCTA5');
                        }
                    }
                });
            } else {
                // clear zips if zoomed out too far
                processBoundaries([], context, 'zip', 'ZCTA5', 'ZCTA5');
            }
        }
        if (_settings.layers.counties.visible) {
            if (zoom > MIN_COUNTIES_ZOOM) {
                url = getUrl(COUNTIES_LAYER_URL, extent, zoom, ['NAME']);
                context.callCount++;
                $.ajax({
                    url,
                    context,
                    method: 'GET',
                    datatype: 'json',
                    success(data) {
                        if (data.error) {
                            logError(`counties layer: ${data.error.message}`);
                        } else {
                            processBoundaries(data.features, this, 'county', 'NAME', 'NAME');
                        }
                    }
                });
            } else {
                // clear counties if zoomed out too far
                processBoundaries([], context, 'county', 'NAME', 'NAME');
            }
        }
        if (_settings.layers.timeZones.visible) {
            url = getUrl(TIME_ZONES_LAYER_URL, extent, zoom, ['ZONE']);
            context.callCount++;
            $.ajax({
                url,
                context,
                method: 'GET',
                datatype: 'json',
                success(data) {
                    if (data.error) {
                        logError(`timezones layer: ${data.error.message}`);
                    } else {
                        processBoundaries(data.features, this, 'timeZone', 'ZONE', 'ZONE');
                    }
                }
            });
        }
        if (_settings.layers.states.visible) {
            url = getUrl(STATES_LAYER_URL, extent, zoom, ['NAME']);
            context.callCount++;
            $.ajax({
                url,
                context,
                method: 'GET',
                datatype: 'json',
                success(data) {
                    if (data.error) {
                        logError(`states layer: ${data.error.message}`);
                    } else {
                        processBoundaries(data.features, this, 'state', 'NAME', 'NAME');
                    }
                }
            });
        }
    }

    function onZipsLayerVisibilityChanged() {
        _settings.layers.zips.visible = _zipsLayer.visibility;
        saveSettings();
        fetchBoundaries();
    }

    function onCountiesLayerVisibilityChanged() {
        _settings.layers.counties.visible = _countiesLayer.visibility;
        saveSettings();
        fetchBoundaries();
    }

    function onStatesLayerVisibilityChanged() {
        _settings.layers.states.visible = _statesLayer.visibility;
        saveSettings();
        fetchBoundaries();
    }

    function onTimeZonesLayerVisibilityChanged() {
        _settings.layers.timeZones.visible = _timeZonesLayer.visibility;
        saveSettings();
        fetchBoundaries();
    }

    function onZipsLayerToggleChanged(checked) {
        _zipsLayer.setVisibility(checked);
    }

    function onCountiesLayerToggleChanged(checked) {
        _countiesLayer.setVisibility(checked);
    }

    function onStatesLayerToggleChanged(checked) {
        _statesLayer.setVisibility(checked);
    }

    function onTimeZonesLayerToggleChanged(checked) {
        _timeZonesLayer.setVisibility(checked);
    }

    function onDynamicLabelsCheckboxChanged(settingName, checkboxId) {
        _settings.layers[settingName].dynamicLabels = $(`#${checkboxId}`).is(':checked');
        saveSettings();
        fetchBoundaries();
    }

    function onGetRoutesButtonClick() {
        fetchUspsRoutesFeatures();
    }

    function onGetRoutesButtonMouseEnter() {
        _$getRoutesButton.css({ color: '#00a' });
        const style = {
            strokeColor: '#ff0',
            strokeDashstyle: 'solid',
            strokeWidth: 6,
            fillColor: '#ff0',
            fillOpacity: 0.2
        };
        _circleFeature = new OpenLayers.Feature.Vector(getCircleLinearRing(), null, style);
        _uspsRoutesLayer.addFeatures([_circleFeature]);
    }

    function onGetRoutesButtonMouseLeave() {
        _$getRoutesButton.css({ color: '#000' });
        _uspsRoutesLayer.removeFeatures([_circleFeature]);
    }

    function onClearRoutesButtonClick() {
        _uspsRoutesLayer.removeAllFeatures();
        _$uspsResultsDiv.empty();
    }

    function showScriptInfoAlert() {
        WazeWrap.Interface.ShowScriptUpdate(
            GM_info.script.name,
            GM_info.script.version,
            UPDATE_MESSAGE,
            '',
            'https://www.waze.com/discuss/t/115019'
        );
    }

    function initLayers() {
        _zipsLayer = new OpenLayers.Layer.Vector('US Gov\'t Boundaries - Zip Codes', {
            uniqueName: '__WME_USGB_Zips',
            styleMap: new OpenLayers.StyleMap({ default: ZIPS_STYLE })
        });
        _countiesLayer = new OpenLayers.Layer.Vector('US Gov\'t Boundaries - Counties', {
            uniqueName: '__WME_USGB_Counties',
            styleMap: new OpenLayers.StyleMap({ default: COUNTIES_STYLE })
        });
        _statesLayer = new OpenLayers.Layer.Vector('US Gov\'t Boundaries - States', {
            uniqueName: '__WME_USGB_States',
            styleMap: new OpenLayers.StyleMap({ default: STATES_STYLE })
        });
        _timeZonesLayer = new OpenLayers.Layer.Vector('US Gov\'t Boundaries - Time Zones', {
            uniqueName: '__WME_USGB_Time_Zones',
            styleMap: new OpenLayers.StyleMap({ default: TIME_ZONES_STYLE })
        });
        _uspsRoutesLayer = new OpenLayers.Layer.Vector('USPS Routes', {
            uniqueName: '__wmeUSPSroutes',
            styleMap: new OpenLayers.StyleMap({ default: USPS_ROUTES_STYLE })
        });

        _zipsLayer.setOpacity(0.6);
        _countiesLayer.setOpacity(0.6);
        _statesLayer.setOpacity(0.6);
        _timeZonesLayer.setOpacity(0.6);

        _zipsLayer.setVisibility(_settings.layers.zips.visible);
        _countiesLayer.setVisibility(_settings.layers.counties.visible);
        _statesLayer.setVisibility(_settings.layers.states.visible);
        _timeZonesLayer.setVisibility(_settings.layers.timeZones.visible);

        W.map.addLayers([_countiesLayer, _zipsLayer, _timeZonesLayer, _statesLayer, _uspsRoutesLayer]);

        // W.map.setLayerIndex(_uspsRoutesMapLayer, W.map.getLayerIndex(W.map.roadLayers[0])-1);
        // HACK to get around conflict with URO+.  If URO+ is fixed, this can be replaced with the setLayerIndex line above.
        LAYER_Z_INDEX = W.map.roadLayer.getZIndex() - 1;
        _uspsRoutesLayer.setZIndex(LAYER_Z_INDEX);
        const checkLayerZIndex = () => { if (_uspsRoutesLayer.getZIndex() !== LAYER_Z_INDEX) _uspsRoutesLayer.setZIndex(LAYER_Z_INDEX); };
        setInterval(checkLayerZIndex, 100);
        // END HACK

        _uspsRoutesLayer.setOpacity(0.8);

        _zipsLayer.events.register('visibilitychanged', null, onZipsLayerVisibilityChanged);
        _countiesLayer.events.register('visibilitychanged', null, onCountiesLayerVisibilityChanged);
        _statesLayer.events.register('visibilitychanged', null, onStatesLayerVisibilityChanged);
        _timeZonesLayer.events.register('visibilitychanged', null, onTimeZonesLayerVisibilityChanged);
        W.map.events.register('moveend', W.map, () => {
            try {
                fetchBoundaries();
                return true;
            } catch (e) {
                logError(e);
                return false;
            }
        }, true);

        // Add the layer checkbox to the Layers menu.
        WazeWrap.Interface.AddLayerCheckbox('display', 'States', _settings.layers.states.visible, onStatesLayerToggleChanged);
        WazeWrap.Interface.AddLayerCheckbox('display', 'Counties', _settings.layers.counties.visible, onCountiesLayerToggleChanged);
        WazeWrap.Interface.AddLayerCheckbox('display', 'ZIP codes', _settings.layers.zips.visible, onZipsLayerToggleChanged);
        WazeWrap.Interface.AddLayerCheckbox('display', 'Time zones', _settings.layers.timeZones.visible, onTimeZonesLayerToggleChanged);
    }

    function initTab() {
        const $content = $('<div>').append(
            $('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;' }).append(
                $('<legend>', { style: 'margin-bottom:0px;borer-bottom-style:none;width:auto;' }).append(
                    $('<h4>').text('ZIP Codes')
                ),
                $('<div>', { class: 'controls-container', style: 'padding-top:0px' }).append(
                    $('<input>', { type: 'checkbox', id: 'usgb-zips-dynamicLabels' }),
                    $('<label>', { for: 'usgb-zips-dynamicLabels' }).text('Dynamic label positions')
                )
            ),
            $('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;' }).append(
                $('<legend>', { style: 'margin-bottom:0px;borer-bottom-style:none;width:auto;' }).append(
                    $('<h4>').text('Counties')
                ),
                $('<div>', { class: 'controls-container', style: 'padding-top:0px' }).append(
                    $('<input>', { type: 'checkbox', id: 'usgb-counties-dynamicLabels' }),
                    $('<label>', { for: 'usgb-counties-dynamicLabels' }).text('Dynamic label positions')
                )
            ),
            $('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;' }).append(
                $('<legend>', { style: 'margin-bottom:0px;borer-bottom-style:none;width:auto;' }).append(
                    $('<h4>').text('Time zones')
                ),
                $('<div>', { class: 'controls-container', style: 'padding-top:0px' }).append(
                    $('<input>', { type: 'checkbox', id: 'usgb-timezones-dynamicLabels' }),
                    $('<label>', { for: 'usgb-timezones-dynamicLabels' }).text('Dynamic label positions')
                )
            ),
            $('<div>').append(
                $('<span>', { style: 'font-style: italic; white-space: pre-line' })
                    .text('Notes:'
                        + '\n- ZIP code boundaries are rough approximations because '
                        + 'ZIP codes are not actually areas. Prefer the "Get USPS routes" '
                        + 'feature whenever possible.'
                        + '\n- Time zone boundaries are rough approximations, '
                        + 'and may not display properly above zoom level 5.')
            )
        );

        WazeWrap.Interface.Tab('USGB', $content.html(), () => {
            $('#usgb-zips-dynamicLabels').prop('checked', _settings.layers.zips.dynamicLabels).change(() => {
                onDynamicLabelsCheckboxChanged('zips', 'usgb-zips-dynamicLabels');
            });
            $('#usgb-counties-dynamicLabels').prop('checked', _settings.layers.counties.dynamicLabels).change(() => {
                onDynamicLabelsCheckboxChanged('counties', 'usgb-counties-dynamicLabels');
            });
            $('#usgb-timezones-dynamicLabels').prop('checked', _settings.layers.counties.dynamicLabels).change(() => {
                onDynamicLabelsCheckboxChanged('timeZones', 'usgb-timezones-dynamicLabels');
            });
        }, null);
    }

    function onSelectionChanged() {
        const container = $('#usps-routes-container');
        const selected = W.selectionManager.getSelectedDataModelObjects();
        if (selected.length && selected[0].type === 'segment') {
            container.show();
        } else {
            container.hide();
        }
    }

    function initUspsRoutes() {
        _$uspsResultsDiv = $('<div>', { id: 'usps-route-results', style: 'margin-top:3px;' });
        _$getRoutesButton = $('<button>', { id: 'get-usps-routes', style: 'height:23px;' }).text('Get USPS routes');
        // TODO: 2022-11-22 - This is temporary to determine which parent element to add the div to, depending on beta or production WME.
        // Remove once new side panel is pushed to production.
        const $parent = $('wz-navigation-item').length > 0 ? $('#edit-panel > div.contents') : $('#user-info > div.flex-parent');
        $parent.prepend( // '#user-info > div.flex-parent'
            $('<div>', { id: 'usps-routes-container', style: 'margin-left:10px;margin-top:5px;' }).append(
                _$getRoutesButton
                    .click(onGetRoutesButtonClick)
                    .mouseenter(onGetRoutesButtonMouseEnter)
                    .mouseout(onGetRoutesButtonMouseLeave),
                $('<button>', { id: 'clear-usps-routes', style: 'height:23px; margin-left:4px;' })
                    .text('Clear')
                    .click(onClearRoutesButtonClick),
                _$uspsResultsDiv
            )
        );
        W.selectionManager.events.on('selectionchanged', onSelectionChanged);
    }

    function loadScriptUpdateMonitor() {
        try {
            const updateMonitor = new WazeWrap.Alerts.ScriptUpdateMonitor(SCRIPT_NAME, CURRENT_VERSION, DOWNLOAD_URL, GM_xmlhttpRequest);
            updateMonitor.start();
        } catch (ex) {
            // Report the error, but not a critical failure.
            console.error(SCRIPT_NAME, ex);
        }
    }

    function init() {
        loadScriptUpdateMonitor();
        loadSettings();
        initLayers();
        initTab();
        showScriptInfoAlert();
        fetchBoundaries();
        initUspsRoutes();
        log('Initialized.');
    }

    function onWmeReady(tries = 0) {
        if (WazeWrap.Ready) {
            init();
        } else if (tries === 40) {
            log('WazeWrap not loaded. Giving up.');
        } else {
            setTimeout(onWmeReady, 250, ++tries);
        }
    }

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

    bootstrap();
}());