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         2025.01.08.001
// @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
// @require         https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.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 turf */
/* global WazeWrap */
/* global bootstrap */

(async function main() {
    'use strict';

    const UPDATE_MESSAGE = '';
    const downloadUrl = '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

    // 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 sdk = await bootstrap({ scriptUpdateMonitor: { downloadUrl } });
    const ZIPS_LAYER_NAME = 'US Gov\'t Boundaries - Zip Codes';
    const COUNTIES_LAYER_NAME = 'US Gov\'t Boundaries - Counties';
    const STATES_LAYER_NAME = 'US Gov\'t Boundaries - States';
    const TIME_ZONES_LAYER_NAME = 'US Gov\'t Boundaries - Time Zones';
    const USPS_ROUTES_LAYER_NAME = 'USPS Routes';

    const zipsLayerCheckboxName = 'USGB - Zip codes';
    const countiesLayerCheckboxName = 'USGB - Counties';
    const statesLayerCheckboxName = 'USGB - States';
    const timeZonesLayerCheckboxName = 'USGB - Time zones';
    let _$uspsResultsDiv;
    let _$getRoutesButton;
    let _settings = {};
    // SDK: Remove these variables
    let _zipsLayer;
    let _countiesLayer;
    let _statesLayer;
    let _timeZonesLayer;

    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) {
            localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(_settings));
            log('Settings saved');
        }
    }

    function getUrl(baseUrl, extent, zoom, outFields) {
        const extentLeftBottom = turf.toMercator([extent[0], extent[1]]);
        const extentRightTop = turf.toMercator([extent[2], extent[3]]);
        const geometry = {
            xmin: extentLeftBottom[0],
            ymin: extentLeftBottom[1],
            xmax: extentRightTop[0],
            ymax: extentRightTop[1],
            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[sdk.Map.getZoomLevel()]}`;
        // 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})`);
            }
        }
    }

    // The SDK doesn't have a way to retrieve features from a layer (yet), so store them here
    // so they can be referenced later.
    let lastZipFeatures;
    let lastCountyFeatures;
    function updateNameDisplay(context) {
        const center = sdk.Map.getMapCenter();
        const mapCenter = turf.point([center.lon, center.lat]);
        let text = '';
        let label;
        let url;

        if (context.cancel) return;
        if (_settings.layers.zips.visible) {
            const onload = res => appendCityToZip(text, $.parseJSON(res.responseText), res.context);
            for (let i = 0; i < lastZipFeatures.length; i++) {
                const feature = lastZipFeatures[i];

                if (turf.booleanPointInPolygon(mapCenter, feature)) {
                    // Substr removes leading ZWJ from the ZIP code label. ZWJ needed to fix map display of ZIP codes with leading zeros.
                    text = feature.properties.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 (_settings.layers.counties.visible) {
            for (let i = 0; i < lastCountyFeatures.length; i++) {
                const feature = lastCountyFeatures[i];
                if (turf.booleanPointInPolygon(mapCenter, feature)) {
                    label = feature.properties.name;
                    $('<span>', { id: 'county-text' }).css({ display: 'inline-block' })
                        .text(label)
                        .appendTo($('#county-boundary'));
                }
            }
        }
    }

    /**
 * Separates a polygon into the main outer ring (with holes) and additional external rings using spatial checks.
 * @param {Object} boundary - An ArcGIS polygon feature (GeoJSON format expected).
 * @param {Object} attributes - An object containing attributes.
 * @returns {Array} - Array of GeoJSON Polygon features (outer polygon with holes, and external polygons).
 */
    function extractPolygonsWithExternalRings(boundary, attributes) {
        const coordinates = boundary.geometry.rings;
        const externalPolygons = [];

        const e = sdk.Map.getMapExtent();
        const width = e[2] - e[0];
        const height = e[3] - e[1];
        const expandBy = 2;
        const clipBox = [
            e[0] - width * expandBy,
            e[1] - height * expandBy,
            e[2] + width * expandBy,
            e[3] + height * expandBy
        ];
        const clipPolygon = turf.bboxPolygon(clipBox);

        let mainOuterPolygon = null;
        // First ring is assumed to be the main outer ring
        mainOuterPolygon = turf.toWgs84(turf.polygon([coordinates[0]], attributes));
        mainOuterPolygon.id = 0;

        // Process additional rings
        for (let i = 1; i < coordinates.length; i++) {
            const testPolygon = turf.toWgs84(turf.polygon([coordinates[i]]));

            if (turf.booleanContains(mainOuterPolygon, testPolygon)) {
                // If the main polygon contains the ring, it's a hole
                mainOuterPolygon = turf.difference(turf.featureCollection([mainOuterPolygon, testPolygon]));
                mainOuterPolygon.id = 0;
            } else {
                // SDK: MultiPolygon not supported yet, so add these as separate polygons.
                // Otherwise, it's an external ring
                testPolygon.properties = attributes;
                externalPolygons.push(testPolygon);
            }
        }

        const clippedPolygons = [];
        [mainOuterPolygon, ...externalPolygons].forEach(polygon => {
            const clippedFeature = turf.intersect(turf.featureCollection([polygon, clipPolygon]));
            if (clippedFeature) {
                switch (clippedFeature.geometry.type) {
                    case 'Polygon':
                        clippedPolygons.push(clippedFeature);
                        break;
                    case 'MultiPolygon':
                        // SDK: MultiPolygon not supported yet.
                        clippedFeature.geometry.coordinates.forEach(ring => clippedPolygons.push(turf.polygon(ring)));
                        break;
                    default:
                        throw new Error('Unexpected feature type');
                }
            }
        });
        clippedPolygons
            .filter(polygon => polygon.geometry.coordinates.length)
            .forEach(polygon => {
                polygon.id = 0;
                polygon.properties = attributes;
            });

        // Return an array with the main outer polygon and additional external polygons
        return clippedPolygons;
    }

    function getLabelPoints(feature) {
        const e = sdk.Map.getMapExtent();
        const screenPolygon = turf.polygon([[
            [e[0], e[3]], [e[2], e[3]], [e[2], e[1]], [e[0], e[1]], [e[0], e[3]]
        ]]);
        const intersection = turf.intersect(turf.featureCollection([screenPolygon, feature]));
        const polygons = [];
        if (intersection) {
            switch (intersection.geometry.type) {
                case 'Polygon':
                    polygons.push(intersection);
                    break;
                case 'MultiPolygon':
                    intersection.geometry.coordinates.forEach(ring => polygons.push(turf.polygon(ring)));
                    break;
                default:
                    throw new Error('Unexpected geometry type');
            }
        }

        const screenArea = turf.area(screenPolygon);
        const points = polygons
            .filter(polygon => {
                // Only include labels on polygons that are large enough
                const polygonArea = turf.area(polygon);
                return polygonArea / screenArea > 0.005;
            })
            .map(polygon => {
                let point = turf.centerOfMass(polygon);
                if (!turf.booleanPointInPolygon(point, polygon)) {
                    point = turf.pointOnFeature(polygon);
                }
                point.properties = { type: 'label', label: feature.properties.name };
                point.id = 0;
                return point;
            });
        return points;
    }

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

        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;
                break;
            case 'state':
                zoomLevel = sdk.Map.getZoomLevel();
                layerSettings = _settings.layers.states;
                layer = _statesLayer;
                if (zoomLevel < 5) {
                    layerSettings.dynamicLabels = false;
                    boundaries.forEach(boundary => {
                        boundary.attributes[nameField] = '';
                    });
                } else if (zoomLevel <= 6) {
                    layerSettings.dynamicLabels = false;
                } else if (zoomLevel <= 11) {
                    layerSettings.dynamicLabels = true;
                } else if (zoomLevel <= 15) {
                    layerSettings.dynamicLabels = true;
                } else {
                    layerSettings.dynamicLabels = true;
                    boundaries.forEach(boundary => {
                        boundary.attributes[nameField] = '';
                    });
                }
                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');
        }

        const allFeatures = [];
        if (context.cancel || !layerSettings.visible) {
            // do nothing
        } else {
            const ext = sdk.Map.getMapExtent();
            const screenPolygon = turf.polygon([[
                [ext[0], ext[3]], [ext[2], ext[3]], [ext[2], ext[1]], [ext[0], ext[1]], [ext[0], ext[3]]
            ]]);
            const screenArea = turf.area(screenPolygon);
            layer.removeAllFeatures();
            if (!context.cancel) {
                boundaries.forEach(boundary => {
                    const attributes = {
                        name: boundary.attributes[nameField],
                        label: boundary.attributes[nameField],
                        type
                    };

                    if (!context.cancel) {
                        const features = extractPolygonsWithExternalRings(boundary, attributes);
                        if (features.length) {
                            if (type === 'zip' || type === 'county') {
                                allFeatures.push(...features);
                            }
                            features.forEach(polygon => {
                                if (layerSettings.dynamicLabels) {
                                    polygon.properties.label = '';
                                } else {
                                    // Only include labels on polygons that are large enough
                                    const polygonArea = turf.area(polygon);
                                    if (polygonArea / screenArea <= 0.005) {
                                        polygon.properties.label = '';
                                    }
                                }
                            });
                            try {
                                sdk.Map.addFeaturesToLayer({ layerName: layer.name, features });
                                // console.log('OK: ', features);
                            } catch (ex) {
                                console.log('FAIL: ', features);
                                // console.log(JSON.stringify(features[0]));
                            }
                            if (layerSettings.dynamicLabels) {
                                const allLabels = [];
                                features.forEach(feature => {
                                    const labels = getLabelPoints(feature);
                                    if (labels?.length) {
                                        allLabels.push(...labels);
                                    }
                                });
                                if (allLabels.length) {
                                    sdk.Map.addFeaturesToLayer({ layerName: layer.name, features: allLabels });
                                }
                            }
                        }
                    }
                });
            }
        }

        if (type === 'zip') {
            lastZipFeatures = allFeatures;
        } else if (type === 'county') {
            lastCountyFeatures = allFeatures;
        }

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

        if (sdk.State.getUserInfo().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 getUspsCircleFeature() {
        let center = sdk.Map.getMapCenter();
        center = [center.lon, center.lat];
        const radius = USPS_ROUTES_RADIUS;
        const options = {
            steps: 72,
            units: 'miles',
            properties: { type: 'circle' }
        };
        return turf.circle(center, radius, options);
    }

    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 color = USPS_ROUTE_COLORS[routeIdx];
            const feature = turf.toWgs84(turf.multiLineString(route.paths), { type: 'route', color, zIndex: routeCount - routeIdx - 1 });

            // SDK: MultiLineString is not supported in the SDK yet, so convert to LineStrings.
            // feature.id = 'route';
            // features.push(feature);
            const lineStrings = feature.geometry.coordinates.map(coords => {
                const ls = turf.lineString(coords, { type: 'route', color, zIndex: routeCount - routeIdx - 1 });
                ls.id = 'route';
                return ls;
            });
            features.push(...lineStrings);

            _$uspsResultsDiv.append($('<div>').text(zipName).css({ color, fontWeight: 'bold' }));
            routeIdx++;
        });
        _$getRoutesButton.removeAttr('disabled').css({ color: '#000' });
        sdk.Map.addFeaturesToLayer({ layerName: USPS_ROUTES_LAYER_NAME, features });
    }

    function fetchUspsRoutesFeatures() {
        const centerLonLat = sdk.Map.getMapCenter();
        const centerPoint = turf.toMercator(turf.point([centerLonLat.lon, centerLonLat.lat]));
        const url = getUspsRoutesUrl(
            centerPoint.geometry.coordinates[0],
            centerPoint.geometry.coordinates[1],
            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>');
        sdk.Map.removeAllFeaturesFromLayer({ layerName: USPS_ROUTES_LAYER_NAME });
        GM_xmlhttpRequest({ url, onload: processUspsRoutesResponse, anonymous: true });
    }

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

        const extent = sdk.Map.getMapExtent();
        const zoom = sdk.Map.getZoomLevel();
        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 onLayerCheckboxToggled(args) {
        let layerName;
        let settingsObj;
        switch (args.name) {
            case zipsLayerCheckboxName:
                layerName = ZIPS_LAYER_NAME;
                settingsObj = _settings.layers.zips;
                break;
            case countiesLayerCheckboxName:
                layerName = COUNTIES_LAYER_NAME;
                settingsObj = _settings.layers.counties;
                break;
            case statesLayerCheckboxName:
                layerName = STATES_LAYER_NAME;
                settingsObj = _settings.layers.states;
                break;
            case timeZonesLayerCheckboxName:
                layerName = TIME_ZONES_LAYER_NAME;
                settingsObj = _settings.layers.timeZones;
                break;
            default:
                throw new Error('Unexpected layer switcher checkbox name.');
        }
        const visibility = args.checked;
        settingsObj.visible = visibility;
        sdk.Map.setLayerVisibility({ layerName, visibility });
    }

    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);
        _settings.layers.zips.visible = checked;
    }

    function onCountiesLayerToggleChanged(checked) {
        _countiesLayer.setVisibility(checked);
        _settings.layers.counties.visible = checked;
    }

    function onStatesLayerToggleChanged(checked) {
        _statesLayer.setVisibility(checked);
        _settings.layers.states.visible = checked;
    }

    function onTimeZonesLayerToggleChanged(checked) {
        _timeZonesLayer.setVisibility(checked);
        _settings.layers.timeZones.visible = checked;
    }

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

    function onGetRoutesButtonClick() {
        fetchUspsRoutesFeatures();
    }

    function onGetRoutesButtonMouseEnter() {
        _$getRoutesButton.css({ color: '#00a' });
        const feature = getUspsCircleFeature();
        feature.id = 'uspsCircle';
        sdk.Map.addFeatureToLayer({ layerName: USPS_ROUTES_LAYER_NAME, feature });
    }

    function onGetRoutesButtonMouseLeave() {
        _$getRoutesButton.css({ color: '#000' });
        sdk.Map.removeFeatureFromLayer({ layerName: USPS_ROUTES_LAYER_NAME, featureId: 'uspsCircle' });
    }

    function onClearRoutesButtonClick() {
        sdk.Map.removeAllFeaturesFromLayer({ layerName: USPS_ROUTES_LAYER_NAME });
        _$uspsResultsDiv.empty();
    }

    function onMapMoveEnd() {
        try {
            fetchBoundaries();
        } catch (e) {
            logError(e);
        }
    }

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

    function initCountiesLayer() {
        sdk.Map.addLayer({
            layerName: COUNTIES_LAYER_NAME,
            styleContext: {
                getLabel: context => {
                    const zoom = sdk.Map.getZoomLevel();
                    const { label } = context.feature.properties;
                    if (zoom <= 9) {
                        return label.replace(/\s(County|Parish)/, '');
                    }
                    return label;
                },
                getFontSize: () => {
                    const zoom = sdk.Map.getZoomLevel();
                    if (zoom <= 9) {
                        return '16px';
                    }
                    return '18px';
                },
                getStrokeWidth: () => {
                    const zoom = sdk.Map.getZoomLevel();
                    if (zoom <= 9) {
                        return 3;
                    }
                    return 6;
                }
            },
            styleRules: [
                {
                    style: {
                        strokeColor: 'pink',
                        strokeOpacity: 1,
                        strokeWidth: '${getStrokeWidth}',
                        strokeDashstyle: 'solid',
                        fillOpacity: 0,
                        pointRadius: 0,
                        label: '${getLabel}',
                        fontSize: '${getFontSize}',
                        fontFamily: 'Arial',
                        fontWeight: 'bold',
                        fontColor: 'pink',
                        labelOutlineColor: 'black',
                        labelOutlineWidth: 2
                    }
                }
            ]
        });
    }

    function initStatesLayer() {
        sdk.Map.addLayer({
            layerName: STATES_LAYER_NAME,
            styleContext: {
                getStrokeWidth: () => {
                    const zoomLevel = sdk.Map.getZoomLevel();
                    if (zoomLevel < 5) {
                        return 1;
                    }
                    if (zoomLevel <= 6) {
                        return 3;
                    }
                    if (zoomLevel <= 11) {
                        return 2;
                    }
                    if (zoomLevel <= 15) {
                        return 3;
                    }
                    return 4;
                },
                getFontSize: () => {
                    const zoomLevel = sdk.Map.getZoomLevel();
                    if (zoomLevel < 5) {
                        return '14px';
                    }
                    if (zoomLevel <= 11) {
                        return '16px';
                    }
                    return '18px';
                },
                getLabelYOffset: () => {
                    const zoomLevel = sdk.Map.getZoomLevel();
                    if (zoomLevel <= 9) {
                        return 0;
                    }
                    return 20;
                },
                getLabel: context => {
                    const zoomLevel = sdk.Map.getZoomLevel();
                    if (zoomLevel < 5 || zoomLevel > 15) {
                        return '';
                    }
                    return context.feature.properties.label;
                }
            },
            styleRules: [
                {
                    predicate: properties => properties.type === 'label',
                    style: {
                        pointRadius: 0,
                        fontSize: '${getFontSize}',
                        fontFamily: 'Arial',
                        fontWeight: 'bold',
                        fontColor: 'blue',
                        label: '${getLabel}',
                        labelYOffset: '${getLabelYOffset}',
                        labelOutlineColor: 'lightblue',
                        labelOutlineWidth: 2
                    }
                },
                {
                    predicate: properties => properties.type === 'state',
                    style: {
                        strokeColor: 'blue',
                        strokeOpacity: 1,
                        strokeWidth: '${getStrokeWidth}',
                        strokeDashstyle: 'solid',
                        fillOpacity: 0
                    }
                }
            ]
        });
    }

    function initZipsLayer() {
        sdk.Map.addLayer({
            layerName: ZIPS_LAYER_NAME,
            styleContext: {
                getLabel: context => context.feature.properties.label
            },
            styleRules: [
                {
                    style: {
                        pointRadius: 0,
                        strokeColor: '#FF0000',
                        strokeOpacity: 1,
                        strokeWidth: 3,
                        strokeDashstyle: 'solid',
                        fillOpacity: 0,
                        fontSize: '16px',
                        fontFamily: 'Arial',
                        fontWeight: 'bold',
                        fontColor: 'red',
                        label: '${getLabel}',
                        labelYOffset: -20,
                        labelOutlineColor: 'white',
                        labelOutlineWidth: 2
                    }
                }
            ]
        });
    }

    function initTimeZonesLayer() {
        sdk.Map.addLayer({
            layerName: TIME_ZONES_LAYER_NAME,
            styleContext: {
                getLabel: context => context.feature.properties.label
            },
            styleRules: [
                {
                    style: {
                        pointRadius: 0,
                        strokeColor: '#f85',
                        strokeOpacity: 1,
                        strokeWidth: 6,
                        strokeDashstyle: 'solid',
                        fillOpacity: 0,
                        fontSize: '18px',
                        fontFamily: 'Arial',
                        fontWeight: 'bold',
                        fontColor: '#f85',
                        label: '${getLabel}',
                        labelYOffset: -40,
                        labelOutlineColor: '#831',
                        labelOutlineWidth: 2
                    }
                }
            ]
        });
    }

    function initLayers() {
        initZipsLayer();
        initCountiesLayer();
        initStatesLayer();
        initTimeZonesLayer();

        sdk.Map.addLayer({
            layerName: USPS_ROUTES_LAYER_NAME,
            styleContext: {
                getStrokeWidth: context => {
                    const zoom = sdk.Map.getZoomLevel();
                    let width = zoom < 3 ? 10 + 2 * zoom : 16;
                    width += context.feature.properties.zIndex * 6;
                    return width;
                },
                getStrokeColor: context => context.feature.properties.color
            },
            styleRules: [
                {
                    predicate: properties => properties.type === 'route',
                    style: {
                        strokeWidth: '${getStrokeWidth}',
                        strokeColor: '${getStrokeColor}'
                    }
                },
                {
                    predicate: properties => properties.type === 'circle',
                    style: {
                        strokeWidth: 6,
                        strokeColor: '#ff0',
                        fillColor: '#ff0',
                        fillOpacity: 0.2
                    }
                }
            ]
        });

        // SDK: Remove these references
        [_zipsLayer] = W.map.getLayersByName(ZIPS_LAYER_NAME);
        [_countiesLayer] = W.map.getLayersByName(COUNTIES_LAYER_NAME);
        [_statesLayer] = W.map.getLayersByName(STATES_LAYER_NAME);
        [_timeZonesLayer] = W.map.getLayersByName(TIME_ZONES_LAYER_NAME);

        sdk.Map.setLayerOpacity({ layerName: ZIPS_LAYER_NAME, opacity: 0.6 });
        sdk.Map.setLayerOpacity({ layerName: COUNTIES_LAYER_NAME, opacity: 0.6 });
        sdk.Map.setLayerOpacity({ layerName: STATES_LAYER_NAME, opacity: 0.6 });
        sdk.Map.setLayerOpacity({ layerName: TIME_ZONES_LAYER_NAME, opacity: 0.6 });
        sdk.Map.setLayerOpacity({ layerName: USPS_ROUTES_LAYER_NAME, opacity: 0.8 });

        sdk.Map.setLayerVisibility({ layerName: ZIPS_LAYER_NAME, visibility: _settings.layers.zips.visible });
        sdk.Map.setLayerVisibility({ layerName: COUNTIES_LAYER_NAME, visibility: _settings.layers.counties.visible });
        sdk.Map.setLayerVisibility({ layerName: STATES_LAYER_NAME, visibility: _settings.layers.states.visible });
        sdk.Map.setLayerVisibility({ layerName: TIME_ZONES_LAYER_NAME, visibility: _settings.layers.timeZones.visible });

        const zIndex = sdk.Map.getLayerZIndex({ layerName: 'roads' }) - 2;
        sdk.Map.setLayerZIndex({ layerName: USPS_ROUTES_LAYER_NAME, zIndex });

        // HACK to get around conflict with URO+.  If URO+ is fixed, this can be replaced with the setLayerIndex line above.
        const checkLayerZIndex = () => {
            if (sdk.Map.getLayerZIndex({ layerName: USPS_ROUTES_LAYER_NAME }) !== zIndex) {
                sdk.Map.setLayerZIndex({ layerName: USPS_ROUTES_LAYER_NAME, zIndex });
            }
        };
        setInterval(() => { checkLayerZIndex(); }, 100);
        // END HACK

        // SDK: Waiting on FR to set layer switcher toggle state: https://issuetracker.google.com/u/1/issues/375867296
        // When that's done, remove WazeWrap lines and old visibilitychanged event handlers.
        // Add the layer checkbox to the Layers menu.
        // sdk.LayerSwitcher.addLayerCheckbox({ name: statesLayerCheckboxName });
        // sdk.LayerSwitcher.addLayerCheckbox({ name: countiesLayerCheckboxName });
        // sdk.LayerSwitcher.addLayerCheckbox({ name: zipsLayerCheckboxName });
        // sdk.LayerSwitcher.addLayerCheckbox({ name: timeZonesLayerCheckboxName });
        // sdk.Events.on({ eventName: 'wme-layer-checkbox-toggled', eventHandler: onLayerCheckboxToggled });

        _zipsLayer.events.register('visibilitychanged', null, onZipsLayerVisibilityChanged);
        _countiesLayer.events.register('visibilitychanged', null, onCountiesLayerVisibilityChanged);
        _statesLayer.events.register('visibilitychanged', null, onStatesLayerVisibilityChanged);
        _timeZonesLayer.events.register('visibilitychanged', null, onTimeZonesLayerVisibilityChanged);

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

        sdk.Events.on({ eventName: 'wme-map-move-end', eventHandler: onMapMoveEnd });
    }

    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 selection = sdk.Editing.getSelection();
        if (selection?.objectType === '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
            )
        );
        document.addEventListener('wme-selection-changed', onSelectionChanged);
    }

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

    init();
}());