WME Cities Overlay

Adds a city overlay for selected states

// ==UserScript==
// @name         WME Cities Overlay
// @namespace    https://gf.qytechs.cn/en/users/166843-wazedev
// @version      2025.07.02.00
// @description  Adds a city overlay for selected states
// @author       WazeDev
// @match        https://www.waze.com/*/editor*
// @match        https://www.waze.com/editor*
// @match        https://beta.waze.com/*
// @exclude      https://www.waze.com/*user/*editor/*
// @require      https://gf.qytechs.cn/scripts/24851-wazewrap/code/WazeWrap.js
// @require      https://gf.qytechs.cn/scripts/369729-wme-cities-overlay-db/code/WME%20Cities%20Overlay%20DB.js
// @require      https://update.gf.qytechs.cn/scripts/524747/1542062/GeoKMLer.js
// @license      GNU GPLv3
// @grant        GM_xmlhttpRequest
// @connect      api.github.com
// @connect      raw.githubusercontent.com
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// ==/UserScript==

/* ecmaVersion 2017 */
/* global $ */
/* global idbKeyval */
/* global WazeWrap */
/* global I18n */
/* eslint curly: ["warn", "multi-or-nest"] */

(function () {
  'use strict';
  const debug = false;
  const scriptMetadata = GM_info.script;
  const scriptName = scriptMetadata.name;
  const repoOwner = scriptMetadata.author; // Change this to a different repo username when testing a forked branch!

  const _settingsStoreName = '_wme_cities';
  let _settings;

  let _kml; // Holds the raw input KML File data
  let _layer = null; // Holds the geoJSON converted features to map with the SDK
  const layerid = scriptName.replace(/[^a-z0-9_-]/gi, '_');

  //Default settings
  const _color = '#E6E6E6';
  const defaultFillOpacity = 0.3;
  const defaultStrokeOpacity = 0.6;
  const noFillStrokeOpacity = 0.9;
  let currState = '';
  let currCity = [];
  let kmlCache = {};

  loadSettings();

  const _US_States = {
    Alabama: 'AL',
    Alaska: 'AK',
    Arizona: 'AZ',
    Arkansas: 'AR',
    California: 'CA',
    Colorado: 'CO',
    Connecticut: 'CT',
    'District of Columbia': 'DC',
    Delaware: 'DE',
    Florida: 'FL',
    Georgia: 'GA',
    Hawaii: 'HI',
    Idaho: 'ID',
    Illinois: 'IL',
    Indiana: 'IN',
    Iowa: 'IA',
    Kansas: 'KS',
    Kentucky: 'KY',
    Louisiana: 'LA',
    Maine: 'ME',
    Maryland: 'MD',
    Massachusetts: 'MA',
    Michigan: 'MI',
    Minnesota: 'MN',
    Mississippi: 'MS',
    Missouri: 'MO',
    Montana: 'MT',
    Nebraska: 'NE',
    Nevada: 'NV',
    'New Hampshire': 'NH',
    'New Jersey': 'NJ',
    'New Mexico': 'NM',
    'New York': 'NY',
    'North Carolina': 'NC',
    'North Dakota': 'ND',
    Ohio: 'OH',
    Oklahoma: 'OK',
    Oregon: 'OR',
    Pennsylvania: 'PA',
    'Rhode Island': 'RI',
    'South Carolina': 'SC',
    'South Dakota': 'SD',
    Tennessee: 'TN',
    Texas: 'TX',
    Utah: 'UT',
    Vermont: 'VT',
    Virginia: 'VA',
    Washington: 'WA',
    'West Virginia': 'WV',
    Wisconsin: 'WI',
    Wyoming: 'WY',
    getAbbreviation: function (state) {
      return this[state];
    },
    getStateFromAbbr: function (abbr) {
      return Object.entries(_US_States).filter((x) => {
        if (x[1] == abbr) return x;
      })[0][0];
    },
    getStatesArray: function () {
      return Object.keys(_US_States).filter((x) => {
        if (typeof _US_States[x] !== 'function') return x;
      });
    },
    getStateAbbrArray: function () {
      return Object.values(_US_States).filter((x) => {
        if (typeof x !== 'function') return x;
      });
    },
  };

  const _MX_States = {
    Aguascalientes: 'AGS',
    'Baja California': 'BC',
    'Baja California Sur': 'BCS',
    Campeche: 'CAM',
    'Coahuila de Zaragoza': 'COAH',
    Colima: 'COL',
    Chiapas: 'CHIS',
    Durango: 'DGO',
    'Ciudad de México': 'CDMX',
    Guanajuato: 'GTO',
    Guerrero: 'GRO',
    Hidalgo: 'HGO',
    Jalisco: 'JAL',
    'Estado de México': 'EM',
    'Michoacán de Ocampo': 'MICH',
    Morelos: 'MOR',
    Nayarit: 'NAY',
    'Nuevo León': 'NL',
    Oaxaca: 'OAX',
    Puebla: 'PUE',
    'Quintana Roo': 'QROO',
    Querétaro: 'QRO',
    'San Luis Potosí': 'SLP',
    Sinaloa: 'SIN',
    Sonora: 'SON',
    Tabasco: 'TAB',
    Tamaulipas: 'TAM',
    Tlaxcala: 'TLAX',
    'Veracruz Ignacio de la Llave': 'VER',
    Yucatán: 'YUC',
    Zacatecas: 'ZAC',
    getAbbreviation: function (state) {
      return this[state];
    },
    getStateFromAbbr: function (abbr) {
      return Object.entries(_MX_States).filter((x) => {
        if (x[1] == abbr) return x;
      })[0][0];
    },
    getStatesArray: function () {
      return Object.keys(_MX_States).filter((x) => {
        if (typeof _MX_States[x] !== 'function') return x;
      });
    },
    getStateAbbrArray: function () {
      return Object.values(_MX_States).filter((x) => {
        if (typeof x !== 'function') return x;
      });
    },
  };

  let wmeSDK; // Declare wmeSDK globally

  // Ensure SDK_INITIALIZED is available
  if (unsafeWindow.SDK_INITIALIZED) {
    unsafeWindow.SDK_INITIALIZED.then(bootstrap).catch((err) => {
      console.error(`${scriptName}: SDK initialization failed`, err);
    });
  } else {
    console.warn(`${scriptName}: SDK_INITIALIZED is undefined`);
  }

  function bootstrap() {
    wmeSDK = unsafeWindow.getWmeSdk({
      scriptId: scriptName.replaceAll(' ', ''),
      scriptName: scriptName,
    });

    // Use Promise.all to check readiness of all dependencies
    Promise.all([isWmeReady(), isWazeWrapReady(), isGeoKMLerReady()])
      .then(() => {
        console.log(`${scriptName}: All dependencies are ready.`);
        init();
        console.log(`${scriptName}: Initialized`);
      })
      .catch((error) => {
        console.error(`${scriptName}: Error during bootstrap -`, error);
      });
  }

  function isWmeReady() {
    return new Promise((resolve, reject) => {
      if (wmeSDK && wmeSDK.State.isReady() && wmeSDK.Sidebar && wmeSDK.LayerSwitcher && wmeSDK.Shortcuts && wmeSDK.Events) {
        console.log(`${scriptName}: WME is already ready.`);
        resolve();
      } else {
        wmeSDK.Events.once({ eventName: 'wme-ready' })
          .then(() => {
            if (wmeSDK.Sidebar && wmeSDK.LayerSwitcher && wmeSDK.Shortcuts && wmeSDK.Events) {
              console.log(`${scriptName}: WME is fully ready now.`);
              resolve();
            } else {
              reject(`${scriptName}: Some SDK components are not loaded.`);
            }
          })
          .catch((error) => {
            console.error(`${scriptName}: Error while waiting for WME to be ready:`, error);
            reject(error);
          });
      }
    });
  }

  function isWazeWrapReady() {
    return new Promise((resolve, reject) => {
      const maxTries = 1000;
      const checkInterval = 500;

      (function check(tries = 0) {
        if (unsafeWindow.WazeWrap && unsafeWindow.WazeWrap.Ready) {
          console.log(`${scriptName}: WazeWrap is successfully loaded.`);
          resolve();
        } else if (tries < maxTries) {
          setTimeout(() => check(++tries), checkInterval);
        } else {
          reject(`${scriptName}: WazeWrap took too long to load.`);
        }
      })();
    });
  }

  function isGeoKMLerReady() {
    return new Promise((resolve, reject) => {
      try {
        if (typeof GeoKMLer !== 'undefined') {
          const geoKMLer = new GeoKMLer();
          if (geoKMLer) {
            console.log(`${scriptName}: GeoKMLer is successfully loaded and ready.`);
            resolve();
          } else {
            reject(`${scriptName}: GeoKMLer instance could not be created.`);
          }
        } else {
          reject(`${scriptName}: GeoKMLer is not defined.`);
        }
      } catch (error) {
        console.error(`${scriptName}: Error during GeoKMLer readiness check:`, error);
        reject(error);
      }
    });
  }

  function isChecked(checkboxId) {
    return $('#' + checkboxId).is(':checked');
  }

  function setChecked(checkboxId, checked) {
    $('#' + checkboxId).prop('checked', checked);
  }

  function loadSettings() {
    _settings = $.parseJSON(localStorage.getItem(_settingsStoreName));
    let _defaultsettings = {
      layerVisible: true,
      ShowCityLabels: true,
      FillPolygons: true,
      HighlightFocusedCity: true,
      AutoUpdateKMLs: true,
    };
    if (!_settings) _settings = _defaultsettings;
    for (var prop in _defaultsettings) {
      if (!_settings.hasOwnProperty(prop)) _settings[prop] = _defaultsettings[prop];
    }
  }

  function saveSettings() {
    if (localStorage) {
      var settings = {
        layerVisible: _settings.layerVisible,
        ShowCityLabels: _settings.ShowCityLabels,
        FillPolygons: _settings.FillPolygons,
        HighlightFocusedCity: _settings.HighlightFocusedCity,
        AutoUpdateKMLs: _settings.AutoUpdateKMLs,
      };
      localStorage.setItem(_settingsStoreName, JSON.stringify(settings));
    }
  }

  function stripElevation(coordinates) {
    if (Array.isArray(coordinates[0])) {
      // If coordinates are nested, recursively strip elevation
      return coordinates.map((coord) => stripElevation(coord));
    }
    // Remove third element from a single set of coordinates
    return coordinates.slice(0, 2);
  }

  /**
   * Function: flattenGeoJSON
   * ------------------------
   * Flattens a GeoJSON "FeatureCollection" into an array of individual GeoJSON features, ensuring consistent
   * type casing and performing property cleanup, including the removal of unwanted characters.
   *
   * Parameters:
   * @param {Object} geoJson - The GeoJSON object to be flattened, expected to be a FeatureCollection.
   * @returns {Array} - An array of individual GeoJSON features, each with cleaned properties and standardized types.
   *
   * Throws:
   * - {Error} Throws an error if the GeoJSON input is invalid by not being a FeatureCollection.
   *
   * Description:
   * - Processes a FeatureCollection by iterating over each feature using `geomEach` to handle various geometry types.
   * - Geometry types such as MultiPoint, MultiLineString, and MultiPolygon are decomposed into individual features.
   * - Cleans feature properties, stripping unwanted markers and creating a `labelText` from the `name` attribute.
   * - Supports these GeoJSON geometry types: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection.
   *
   * Internal Functions:
   * - `updateGeoJSONType`: Ensures consistent casing of GeoJSON types using a lookup map.
   * - `stripElevation`: Removes elevation data from coordinates for more straightforward processing.
   *
   * Example Usage:
   * ```
   * const flattenedFeatures = flattenGeoJSON(myGeoJSON);
   * ```
   */
  function flattenGeoJSON(geoJson) {
    // Verify and extract features array from the input GeoJSON
    if (geoJson.type !== 'FeatureCollection' || !Array.isArray(geoJson.features)) {
      throw new Error('Invalid GeoJSON input: expected a FeatureCollection.');
    }

    const features = geoJson.features;

    const geoJSONTypeMap = {
      FEATURECOLLECTION: 'FeatureCollection',
      FEATURE: 'Feature',
      GEOMETRYCOLLECTION: 'GeometryCollection',
      POINT: 'Point',
      LINESTRING: 'LineString',
      POLYGON: 'Polygon',
      MULTIPOINT: 'MultiPoint',
      MULTILINESTRING: 'MultiLineString',
      MULTIPOLYGON: 'MultiPolygon',
    };

    const updateGeoJSONType = (type) => geoJSONTypeMap[type.toUpperCase()] || type;

    return features.flatMap((feature) => {
      const flattenedGeometries = [];

      if (feature.properties) {
        const nameKey = ['name', 'Name', 'NAME'].find((key) => feature.properties[key]);
        if (nameKey) {
          feature.properties[nameKey] = feature.properties[nameKey].replace(/<at><openparen>/gi, '').replace(/<closeparen>/gi, '');
          feature.properties.labelText = feature.properties[nameKey];
        }
      }

      geomEach(feature.geometry, (geometry) => {
        const type = geometry === null ? null : updateGeoJSONType(geometry.type);
        switch (type) {
          case 'Point':
          case 'LineString':
          case 'Polygon':
            flattenedGeometries.push({
              type: updateGeoJSONType('Feature'),
              geometry: {
                type: type,
                coordinates: stripElevation(geometry.coordinates),
              },
              properties: feature.properties,
            });
            break;
          case 'MultiPoint':
          case 'MultiLineString':
          case 'MultiPolygon':
            const geomType = updateGeoJSONType(type.split('Multi')[1]);
            geometry.coordinates.forEach((coordinate) => {
              flattenedGeometries.push({
                type: updateGeoJSONType('Feature'),
                geometry: {
                  type: geomType,
                  coordinates: stripElevation(coordinate),
                },
                properties: feature.properties,
              });
            });
            break;
          case 'GeometryCollection':
            geometry.geometries.forEach((geom) => {
              const updatedType = updateGeoJSONType(geom.type);
              flattenedGeometries.push({
                type: updateGeoJSONType('Feature'),
                geometry: {
                  type: updatedType,
                  coordinates: stripElevation(geom.coordinates),
                },
                properties: feature.properties,
              });
            });
            break;
          default:
            throw new Error(`Unknown Geometry Type: ${type}`);
        }
      });

      return flattenedGeometries;
    });
  }

  /**
   * Function: geomEach
   * ------------------
   * Iterates over each geometry within a feature, handling different types of geometries
   * and their coordinate structures, and executing a specified callback function.
   *
   * This function supports:
   * - Simple geometries: Point, LineString, Polygon
   * - Compound geometries: MultiPoint, MultiLineString, MultiPolygon
   * - Geometry collections: GeometryCollection
   *
   * Parameters:
   * @param {Object} geometry - The geometry object extracted from a feature, containing
   *                            type and coordinates or sub-geometries.
   * @param {Function} callback - A callback function to execute for each geometry type,
   *                              receiving a geometry object as an argument.
   *
   * Notes:
   * - For compound geometries, coordinates are processed individually, and elevation data
   *   is stripped using `stripElevation`.
   * - Throws an error if an unknown geometry type is encountered.
   **/
  function geomEach(geometry, callback) {
    const type = geometry === null ? null : geometry.type;
    switch (type) {
      case 'Point':
      case 'LineString':
      case 'Polygon':
        callback(geometry);
        break;
      case 'MultiPoint':
      case 'MultiLineString':
      case 'MultiPolygon':
        geometry.coordinates.forEach((coordinate) => {
          callback({
            type: type.split('Multi')[1],
            coordinates: stripElevation(coordinate), // Use stripElevation here
          });
        });
        break;
      case 'GeometryCollection':
        geometry.geometries.forEach(callback);
        break;
      default:
        throw new Error(`Unknown Geometry Type: ${type}`);
    }
  }

  function GetFeaturesFromKMLString(strKML) {
    const geoKMLer = new GeoKMLer();
    const kmlDoc = geoKMLer.read(strKML);
    const GeoJSONflat = flattenGeoJSON(geoKMLer.toGeoJSON(kmlDoc, false)); // false = don't need the added CRS info section
    return GeoJSONflat;
  }

  /**
   * Function: findCurrCity
   * ----------------------
   * Determines the current city based on the map's center point, identifying its feature
   * within GeoJSON layers, and handling DOM element retrieval for the current feature.
   *
   * Steps:
   * 1. Initialize the `cityData` object with default properties.
   * 2. Retrieve the current map center coordinates using `wmeSDK.Map`.
   * 3. Iterate over all features in the global `_layer` array to check if the map center is
   *    within any polygon feature using `isPointInPolygon`.
   *     - If a match is found, update `cityData` with the feature's details.
   * 4. Perform a debug-only operation to find the DOM element associated with the feature:
   *     - Retrieve using `wmeSDK.Map.getFeatureDomElement` if the `featureId` is valid.
   *     - Handle cases where the DOM element is not found or retrieval errors occur.
   * 5. Log the finalized `cityData` object for debugging purposes.
   *
   * Globals:
   * - `scriptName`: Used for logging errors and debug information.
   * - `_layer`: Array of GeoJSON features representing map polygons and properties.
   * - `debug`: Flag to enable additional logging for troubleshooting.
   * - `layerid`: Identifier for the map layer, needed for DOM element retrieval.
   *
   * Error Handling and Debugging:
   * - Includes additional logging and checks to address missing elements and potential errors.
   * - Detailed console warnings and errors facilitate debugging when `debug` mode is activated.
   *
   * Returns:
   * - `cityData`: An object containing the current city's name, associated feature ID, and optional DOM element.
   */
  function findCurrCity() {
    let cityData = {
      name: '',
      featureId: '',
      domElement: null, // Initialize as null for safety
    };

    // Get the current map center using wmeSDK
    const mapCenter = wmeSDK.Map.getMapCenter(); // Returns { lat: number, lon: number }
    const mapCenterPoint = [mapCenter.lon, mapCenter.lat];

    // Check if _layer is defined and not null before proceeding
    if (!_layer || !_layer.length) {
      if (debug) console.warn(`${scriptName}: _layer is null or undefined. Unable to find current city.`);
      return cityData;
    }

    for (let i = 0; i < _layer.length; i++) {
      const feature = _layer[i];
      const geometry = feature.geometry;
      const properties = feature.properties;
      const id = feature.id;

      // Check if the map center point is inside the feature's geometry (polygon)
      if (isPointInPolygon(mapCenterPoint, geometry.coordinates[0])) {
        cityData.name = properties.name;
        cityData.featureId = id;
        if (debug) {
          cityData.geojson = feature;
        }
        break;
      }
    }

    if (debug) {
      // Only attempt to get the DOM element if a valid featureId has been set
      if (cityData.featureId) {
        try {
          const currCityFeatureDomElement = wmeSDK.Map.getFeatureDomElement({
            featureId: cityData.featureId,
            layerName: layerid,
          });

          if (currCityFeatureDomElement !== null) {
            cityData.domElement = currCityFeatureDomElement;
          } else {
            console.warn(`${scriptName}: DOM element for feature ID ${cityData.featureId} not found.`);
          }
        } catch (error) {
          console.error(`${scriptName}: Error retrieving DOM element for feature ID ${cityData.featureId}:`, error);
        }
      }
    }

    if (debug) {
      console.log(`${scriptName}: Current Focused City Object is:`, cityData);
    }

    return cityData;
  }

  /**
   * Function: updateCitiesLayer
   * ---------------------------
   * Asynchronously updates the cities layer on the map based on the current state and zoom level,
   * ensuring proper display of city polygons and region names.
   *
   * Steps:
   * 1. Check the map's current zoom level and exit early if it's below 12, as detailed city view is unnecessary.
   * 2. Retrieve the top state from the map data model. If different from the current state (`currState`),
   *    invoke `updateCityPolygons` to refresh city polygon data.
   * 3. Identify the current city using `findCurrCity`. Ensure the city data is valid before proceeding.
   * 4. Update the display name of the district or region using `updateDistrictNameDisplay`.
   * 5. Redraw the map layer to reflect the updated city data.
   *
   * Error Handling:
   * - Try-catch block used to handle any runtime errors gracefully, logging details for debugging.
   * - Checks for valid `currCity` and `currCity.name` to prevent operations on missing data.
   *
   * Globals:
   * - `scriptName`: Used for logging errors and operation details.
   * - `currState`: Tracks the name of the state currently being processed.
   * - `layerid`: Identifier for the target layer where cities are displayed.
   * - `currCity`: Object to store the currently identified city, utilized in display logic.
   */
  async function updateCitiesLayer() {
    try {
      const zoom = wmeSDK.Map.getZoomLevel();
      if (zoom < 12) {
        return;
      }

      const topState = wmeSDK.DataModel.States.getTopState();
      if (!topState) {
        if (debug) console.log(`${scriptName}: topState is null. Skipping updateCityPolygons.`);
        return;
      }

      if (currState !== topState.name) {
        await updateCityPolygons();
      }

      currCity = findCurrCity();

      if (!currCity || !currCity.name) {
        if (debug) console.log(`${scriptName}: No Current city Polygon found for this location....`);
        return;
      }

      updateDistrictNameDisplay();
      wmeSDK.Map.redrawLayer({ layerName: layerid });
    } catch (error) {
      console.error(`${scriptName}: Error in updateCitiesLayer -`, error);
    }
  }

  function updateDistrictNameDisplay() {
    // Remove existing district name displays
    $('.wmecitiesoverlay-region').remove();

    // Verify if _layer has features and a current city is specified
    if (Array.isArray(_layer) && _layer.length > 0 && currCity.name != '') {
      let color = '#00ffff';

      // Create a new div element for displaying the current city
      var $div = $('<div>', {
        id: 'wmecitiesoverlay',
        class: 'wmecitiesoverlay-region',
        style: 'float:left; margin-left:10px;',
      }).css({
        color: color,
        cursor: 'pointer',
      });

      var $span = $('<span>').css({ display: 'inline-block' });
      $span.text(currCity.name).appendTo($div);

      // Append the new element after the location-info-region
      $('.location-info-region').after($div);
    }
  }

  /**
   * Determines if a given point is inside a polygon using the ray-casting algorithm.
   *
   * This function checks whether a point, defined by its coordinates, is inside a polygon.
   * The polygon is represented by an array of vertices (points), and the function uses the
   * ray-casting technique to toggle the state whenever the ray crosses a polygon edge.
   *
   * @param {Array} point - An array [x, y] representing the coordinates of the point to test.
   * @param {Array} vs - An array of vertices, where each vertex is represented as [x, y].
   * @returns {boolean} - True if the point is inside the polygon, false otherwise.
   **/
  function isPointInPolygon(point, vs) {
    const [x, y] = point;
    let inside = false;
    for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
      const [xi, yi] = vs[i];
      const [xj, yj] = vs[j];
      const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
      if (intersect) inside = !inside;
    }
    return inside;
  }

  async function fetch(url) {
    //return await $.get(url);
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        url: url,
        method: 'GET',
        onload(res) {
          if (res.status < 400) {
            resolve(res.responseText);
          } else {
            reject(res);
          }
        },
        onerror(res) {
          reject(res);
        },
      });
    });
  }

  /**
   * Function: updateAllMaps
   * -----------------------
   * Asynchronously updates KML data for all states in the current country, comparing
   * local storage against the latest content available in a GitHub repository.
   *
   * Steps:
   * 1. Get the top country from the map data model and retrieve its abbreviation.
   * 2. Fetch the keys for all states' city data stored locally.
   * 3. Determine the appropriate state abbreviation object based on the country's abbreviation.
   * 4. Retrieve the list of KML files from the GitHub repository, parsing the response.
   * 5. For each state in local storage, check if the KML file size differs from the server's version.
   *    If so, fetch the updated KML file, update local storage, and cache if necessary.
   * 6. Log the count and names of states updated in the user's interface.
   * 7. Finally, refresh city layers using `updateCitiesLayer`.
   *
   * Note:
   * - Utilizes persistent local storage (`idbKeyval`) and caching (`kmlCache`) to reduce unnecessary data loads.
   * - Updates DOM element `#WMECOupdateStatus` to reflect operation results, aiding user interaction and feedback.
   *
   * Globals:
   * - `scriptName`: The name used for logging and user feedback.
   * - `repoOwner`: Identifier for the GitHub repository owner, used for URL generation.
   * - `currState`: Tracks the current state being processed, updated during KML fetching.
   * - `_kml`: Stores KML data when a matching state is currently active.
   * - `layerid`: Identifier for the map layer where updates are applied.
   * - `_US_States` and `_MX_States`: Objects managing state abbreviation lookup.
   * - `kmlCache`: Object to locally cache loaded KML data for efficient retrieval.
   */
  async function updateAllMaps() {
    const topCountry = wmeSDK.DataModel.Countries.getTopCountry();
    let countryAbbr = topCountry.abbr;
    let keys = await idbKeyval.keys(`${countryAbbr}_states_cities`);
    let updatedCount = 0;
    let updatedStates = '';
    let countryAbbrObj;

    if (countryAbbr === 'US') countryAbbrObj = _US_States;
    else if (countryAbbr === 'MX') countryAbbrObj = _MX_States;

    let KMLinfoArr = await fetch(`https://api.github.com/repos/${repoOwner}/WME-Cities-Overlay/contents/KMLs/${countryAbbr}`);
    KMLinfoArr = $.parseJSON(KMLinfoArr);
    let state;
    for (let i = 0; i < keys.length; i++) {
      state = keys[i];

      for (let j = 0; j < KMLinfoArr.length; j++) {
        if (KMLinfoArr[j].name === `${state}_Cities.kml`) {
          //check the size in db against server - if different, update db
          let stateObj = await idbKeyval.get(`${countryAbbr}_states_cities`, state);

          if (stateObj.kmlsize !== KMLinfoArr[j].size) {
            let kml = await fetch(`https://raw.githubusercontent.com/${repoOwner}/WME-Cities-Overlay/master/KMLs/${countryAbbr}/${state}_Cities.kml`);

            if (state === countryAbbrObj.getAbbreviation(currState)) _kml = kml;

            await idbKeyval.set(`${countryAbbr}_states_cities`, {
              kml: kml,
              state: state,
              kmlsize: KMLinfoArr[j].size,
            });
            if (kmlCache[state] != null) kmlCache[state] = _kml;
            if (updatedStates != '') updatedStates += `, ${state}`;
            else updatedStates += state;
            updatedCount += 1;
          }
          break;
        }
      }
    }
    if (updatedCount > 0) $('#WMECOupdateStatus').text(`${updatedCount} state file${updatedCount > 1 ? 's' : ''} updated - ${updatedStates}`);
    else $('#WMECOupdateStatus').text('No updates available');

    updateCitiesLayer();
  }

  async function init() {
    initTab();
    //I18n.translations[I18n.locale].layers.name[layerid] = "Cities Overlay";
    const layerConfig = {
      styleRules: [
        {
          predicate: () => true,
          style: {
            strokeDashstyle: 'solid',
            strokeColor: '${dynamicStrokeColor}',
            strokeOpacity: '${dynamicStrokeOpacity}',
            strokeWidth: '${dynamicStrokeWidth}',
            fillOpacity: '${dynamicFillOpacity}',
            fillColor: '${dynamicFillColor}',
            fontColor: '#ffffff',
            label: '${formatLabel}',
            labelOutlineColor: '#000000',
            labelOutlineWidth: 4,
            labelAlign: 'cm',
            fontSize: '16px',
          },
        },
      ],
      styleContext: {
        dynamicStrokeColor: (context) => {
          // Check if focused city highlighting is enabled and feature matches currCity
          if (_settings.HighlightFocusedCity && context.feature.id === currCity.featureId) {
            return '#f7ad25'; // Highlight stroke color
          }
          return _color; // Default stroke color
        },
        dynamicFillColor: (context) => {
          // Check if focused city highlighting is enabled and feature matches currCity
          if (_settings.HighlightFocusedCity && context.feature.id === currCity.featureId) {
            return '#f7ad25'; // Highlight fill color
          }
          return _color; // Default fill color
        },
        dynamicStrokeWidth: (context) => {
          // Increase stroke width if focused city highlighting is enabled and feature matches currCity
          if (_settings.HighlightFocusedCity && context.feature.id === currCity.featureId) {
            return 6; // Highlight stroke width
          }
          return 2; // Default stroke width
        },
        dynamicStrokeOpacity: () => {
          return _settings.FillPolygons ? defaultStrokeOpacity : noFillStrokeOpacity;
        },
        dynamicFillOpacity: () => {
          return _settings.FillPolygons ? defaultFillOpacity : 0;
        },
        formatLabel: (context) => {
          let labelTemplate = '';
          if (!_settings.ShowCityLabels) {
            return ''; // Skip rendering if disabled in settings
          }
          // Confirm necessary properties exist in the context
          if (!context || !context.feature || !context.feature.properties || !context.feature.properties.labelText) {
            console.error(`${scriptName}: Invalid context or missing 'labelText' property.`);
            return '';
          }
          // Direct assignment of label text
          labelTemplate = context.feature.properties.labelText.trim();
          return labelTemplate; // Return trimmed label for display
        },
      },
    };

    wmeSDK.Map.addLayer({
      layerName: layerid,
      styleRules: layerConfig.styleRules,
      styleContext: layerConfig.styleContext,
      zIndexing: true,
    });

    // Set visibility to true for the layer
    wmeSDK.Map.setLayerVisibility({ layerName: layerid, visibility: _settings.layerVisible });
    wmeSDK.LayerSwitcher.addLayerCheckbox({ name: 'Cities Overlay' });
    wmeSDK.LayerSwitcher.setLayerCheckboxChecked({ name: 'Cities Overlay', isChecked: _settings.layerVisible });
    wmeSDK.Events.on({ eventName: 'wme-layer-checkbox-toggled', eventHandler: layerToggled });
    wmeSDK.Events.on({ eventName: 'wme-map-move-end', eventHandler: onMapMove });

    if (_settings.layerVisible) {
      await updateCityPolygons();
      currCity = findCurrCity();
      if (_settings.AutoUpdateKMLs) {
        updateAllMaps();
      }
    }
  } // END int() function

  function initTab() {
    // Create the section element using jQuery
    var $section = $('<div>', {
      style: 'padding:8px 16px',
      id: 'WMECitiesOverlaySettings',
    });

    // Function to inject custom CSS
    function addCustomStyles() {
      const style = document.createElement('style');
      style.textContent = `
    .wmecoSettingsCheckbox {
      margin-right: 5px;  /* Adds space to the right of the checkbox */
      cursor: pointer;  /* Pointer indicates interactivity */
      appearance: none;  /* Remove default styling */
      width: 16px;  /* Width of checkbox */
      height: 16px;  /* Height of checkbox */
      background-color: #e0e0e0;  /* Light gray background for unselected state */
      border: 2px solid #bbb;  /* Soft border */
      border-radius: 4px;  /* Slight rounded corners */
      position: relative;  /* Position relative for inner elements */
      transition: all 0.3s ease;  /* Smooth transition for hover effects */
      box-shadow: 0 2px 5px rgba(0,0,0,0.15);  /* Adds a subtle shadow */
    }
    
    .wmecoSettingsCheckbox:hover {
      background-color: #d1d1d1;  /* Slightly darker on hover */
      border-color: #999;  /* Darker border on hover */
      margin-right: 5px;

    }
    
    .wmecoSettingsCheckbox:checked {
      background-color: #4caf50;  /* Green background for checked state */
      border-color: #3e8e41;  /* Darker green border for checked */
      margin-right: 5px;
    }
    
    .wmecoSettingsCheckbox:checked::after {
      content: '';  /* Content for checkmark */
      position: absolute;
      left: 4px;  /* Horizontal position for checkmark */
      top: 0px;  /* Vertical position for checkmark */
      width: 12px;  /* Width of checkmark */
      height: 12px;  /* Height of checkmark */
      border: solid white;  /* White checkmark */
      border-width: 0 3px 3px 0;
      transform: rotate(45deg);  /* Rotation to create checkmark */
      margin-right: 5px;
    }
  `;
      document.head.appendChild(style);
    }

    // Append the HTML content to the section
    $section.append(
      `<h4 style="margin-bottom:0px;">
    <i id="citiesPower" class="fa fa-power-off" aria-hidden="true" 
       style="color:${_settings.layerVisible ? 'rgb(0,180,0)' : 'rgb(255, 0, 0)'}; cursor:pointer;">
    </i> 
    <b>WME Cities Overlay</b>
  </h4>`,
      `<h6 style="margin-top:0px;">${GM_info.script.version}</h6>`,
      '<div id="divWMECOFillPolygons"><input type="checkbox" id="_cbCOFillPolygons" class="wmecoSettingsCheckbox" /><label for="_cbCOFillPolygons">Fill polygons</label></div>',
      '<div id="divWMECOShowCityLabels"><input type="checkbox" id="_cbCOShowCityLabels" class="wmecoSettingsCheckbox" /><label for="_cbCOShowCityLabels">Show city labels</label></div>',
      '<div id="divWMECOHighlightFocusedCity"><input type="checkbox" id="_cbCOHighlightFocusedCity" class="wmecoSettingsCheckbox" /><label for="_cbCOHighlightFocusedCity">Highlight focused city</label></div>',
      '<fieldset id="fieldUpdates" style="border: 1px solid silver; padding: 8px; border-radius: 4px;">' +
        '<legend style="margin-bottom:0px; border-bottom-style:none;width:auto;"><h4>Update Settings</h4></legend>' +
        '<div id="divWMECOUpdateMaps" title="Checks for new state files for the current country"><button id="WMECOupdateMaps" type="button">Refresh / Update database</button></div>' +
        '<div id="WMECOupdateStatus"></div>' +
        '<div id="divWMECOAutoUpdateKMLs" title="Checks for updated state files for the current country when WME loads"><input type="checkbox" id="_cbCOAutoUpdateKMLs" class="wmecoSettingsCheckbox" /><label for="_cbCOAutoUpdateKMLs">Automatically update database</label></div>' +
        '</fieldset>'
    );

    // Add styles
    addCustomStyles();

    // Register the script tab with the sidebar
    wmeSDK.Sidebar.registerScriptTab()
      .then(({ tabLabel, tabPane }) => {
        // Set the tab label and title
        tabLabel.textContent = 'Cities';
        tabLabel.title = scriptName;

        // Append the section to the tab pane
        tabPane.appendChild($section.get(0));

        // Set initial checkbox states based on settings
        setChecked('_cbCOShowCityLabels', _settings.ShowCityLabels);
        setChecked('_cbCOFillPolygons', _settings.FillPolygons);
        setChecked('_cbCOHighlightFocusedCity', _settings.HighlightFocusedCity);
        setChecked('_cbCOAutoUpdateKMLs', _settings.AutoUpdateKMLs);

        // Add event listeners
        $('.wmecoSettingsCheckbox').change(function () {
          var settingName = $(this)[0].id.substr(5);
          _settings[settingName] = this.checked;
          saveSettings();
        });

        $('#citiesPower').click(function () {
          layerToggled();
        });

        $('#WMECOupdateMaps').click(updateAllMaps);

        $('#_cbCOFillPolygons').change(function () {
          _settings.FillPolygons = this.checked;
          saveSettings();
          wmeSDK.Map.redrawLayer({ layerName: layerid });
        });

        $('#_cbCOShowCityLabels').change(function () {
          _settings.ShowCityLabels = this.checked;
          saveSettings();
          wmeSDK.Map.redrawLayer({ layerName: layerid });
        });

        $('#_cbCOHighlightFocusedCity').change(function () {
          _settings.HighlightFocusedCity = this.checked;
          saveSettings();
          wmeSDK.Map.redrawLayer({ layerName: layerid });
        });
      })
      .catch((error) => {
        console.error(`${scriptName}: Error registering the script tab:`, error);
      });
  }

  function onMapMove() {
    if (_settings.layerVisible) {
      updateCitiesLayer();
    }
  }

  function layerToggled() {
    // Toggle the visibility state
    _settings.layerVisible = !_settings.layerVisible;
    const visible = _settings.layerVisible;

    wmeSDK.Map.setLayerVisibility({ layerName: layerid, visibility: visible });
    wmeSDK.LayerSwitcher.setLayerCheckboxChecked({ name: 'Cities Overlay', isChecked: visible });

    if (visible) {
      $('#citiesPower').css('color', 'rgb(0,180,0)');
      // Add a custom event listener for visibility changes
      document.getElementById('citiesPower').addEventListener('visibilityChange', updateCitiesLayer);
      // Dispatch or trigger the custom event
      const visibilityChangeEvent = new Event('visibilityChange');
      document.getElementById('citiesPower').dispatchEvent(visibilityChangeEvent);
    } else {
      $('#citiesPower').css('color', 'rgb(255, 0, 0)'); // Dark mode color
      $('.wmecitiesoverlay-region').remove(); // Remove existing district name displays
      // Remove the custom event listener when not visible
      document.getElementById('citiesPower').removeEventListener('visibilityChange', updateCitiesLayer);
    }
    saveSettings();
  }

  /**
   * Function: updateCityPolygons
   * ----------------------------
   * Asynchronously loads and updates city polygons for the top state on the map,
   * utilizing local storage and caching strategies to optimize data retrieval.
   *
   * Steps:
   * 1. Retrieve the current top state and check if it differs from `currState`.
   *    If so, proceed to load new city polygon data.
   * 2. Clear existing features from the map layer to prepare for new data.
   * 3. Determine the state abbreviation based on the country's abbreviation.
   * 4. Check local storage for cached KML data; if absent, fetch from a remote
   *    GitHub repository and cache it locally.
   * 5. Use the KML data to update the map layer's polygons with `updatePolygons`.
   * 6. Log the loading time and redraw the map layer to reflect updates.
   *
   * Note:
   * - Utilizes caching (`kmlCache`) and persistent storage (`idbKeyval`) for data
   *   efficiency across function executions.
   * - Displays console logs and timers to assist with monitoring load times and debugging.
   *
   * Globals:
   * - `scriptName`: Name used for logging.
   * - `_kml`: KML data used for polygon updates.
   * - `currState`: Tracks the currently processed state name.
   * - `layerid`: Identifier for the target map layer.
   * - `repoOwner`: GitHub repository owner used for remote KML extraction.
   * - `debug`: Flag to enable detailed logging for troubleshooting.
   * - `_US_States` and `_MX_States`: Modules for state abbreviation lookup.
   * - `kmlCache`: Object to store loaded KML data for future use.
   */
  async function updateCityPolygons() {
    const topState = wmeSDK.DataModel.States.getTopState();

    if (!topState) {
      if (debug) console.warn(`${scriptName}: topState is null. Exiting update.`);
      return;
    }

    if (currState !== topState.name) {
      const topCountry = wmeSDK.DataModel.Countries.getTopCountry();

      if (!topCountry) {
        if (debug) console.warn(`${scriptName}: topCountry is null. Exiting update.`);
        return;
      }

      // Start loading indicator
      console.log(`${scriptName}: Loading City Polygons for ${topState.name}`);
      console.time(`${scriptName}: Loaded City Polygons for ${topState.name} in`);

      // Clear all features from layer before loading new data
      wmeSDK.Map.removeAllFeaturesFromLayer({ layerName: layerid });
      currState = topState.name;

      let countryAbbr = topCountry.abbr;
      let stateAbbr;

      if (countryAbbr === 'US') stateAbbr = _US_States.getAbbreviation(currState);
      else if (countryAbbr === 'MX') stateAbbr = _MX_States.getAbbreviation(currState);

      if (typeof stateAbbr !== 'undefined') {
        if (typeof kmlCache[stateAbbr] === 'undefined') {
          // Try to retrieve state info from local storage
          var request = await idbKeyval.get(`${countryAbbr}_states_cities`, stateAbbr);

          if (!request) {
            // Fetch from GitHub if not found locally
            let kmlURL = `https://raw.githubusercontent.com/${repoOwner}/WME-Cities-Overlay/master/KMLs/${countryAbbr}/${stateAbbr}_Cities.kml`;

            if (debug) console.log(`${scriptName}: KML URL`, kmlURL);

            let kml = await fetch(kmlURL);
            _kml = kml;
            updatePolygons();

            await idbKeyval.set(`${countryAbbr}_states_cities`, {
              kml: kml,
              state: stateAbbr,
              kmlsize: 0,
            });

            kmlCache[stateAbbr] = _kml; // Cache KML data locally
          } else {
            _kml = request.kml;
            kmlCache[stateAbbr] = _kml; // Cache locally if already fetched
            updatePolygons();
          }
        } else {
          _kml = kmlCache[stateAbbr];
          updatePolygons();
        }
      }

      // End loading indicator
      console.timeEnd(`${scriptName}: Loaded City Polygons for ${topState.name} in`);
    } else {
      wmeSDK.Map.redrawLayer({ layerName: layerid });
    }
  }

  /**
   * Function: updatePolygons
   * -------------------------
   * This function updates the map layer with GeoJSON features derived from a KML string,
   * replacing existing features and handling potential errors during feature addition.
   *
   * Steps:
   * 1. Convert the KML string into GeoJSON features and store them in the `_layer` variable.
   * 2. Remove all current features from the specified layer using `wmeSDK.Map`.
   * 3. Map these features with unique IDs based on their index to prepare them for loading.
   * 4. Attempt to add each feature to the target layer while tracking successes and errors.
   * 5. Populate the global `_layer` variable with successfully loaded features.
   * 6. Log the number of successfully added and skipped features and display loaded layers.
   *
   * Error Handling:
   * - Catch and log errors occurring during the feature removal and addition process.
   * - Differentiate between `InvalidStateError` for missing layers and `ValidationError`
   *   for issues with feature data, providing specific details for troubleshooting.
   *
   * Globals:
   * - `scriptName`: Name used for logging.
   * - `_kml`: The KML string serving as the data source.
   * - `_layer`: Global state to track currently loaded features.
   * - `layerid`: Identifier for the target layer.
   * - `debug`: Flag to enable detailed logging for troubleshooting.
   */
  function updatePolygons() {
    // Retrieve GeoJSON features from the KML string; conversion handled inside GetFeaturesFromKMLString
    _layer = GetFeaturesFromKMLString(_kml);
    // Remove all existing features from the specified layer
    try {
      wmeSDK.Map.removeAllFeaturesFromLayer({ layerName: layerid });
      if (debug) console.log(`${scriptName}: All features removed from layer: ${layerid}`);
    } catch (error) {
      console.error(`${scriptName}: Error removing features from layer: ${layerid}`, error);
    }

    // Map features array with unique index-based IDs
    const featuresToLoad = _layer.map((f, index) => ({
      type: f.type,
      id: `${layerid}_${index}`, // Use feature index for uniqueness
      geometry: f.geometry,
      properties: f.properties,
    }));

    wmeSDK.Map.dangerouslyAddFeaturesToLayerWithoutValidation({
          features: featuresToLoad,
          layerName: layerid,
        });

    _layer = featuresToLoad; // populates the global _layer
    if (debug) console.log(`${scriptName}: Current State is ${currState}`);

    // Log completion
    console.log(`${scriptName}: ${featuresToLoad.length} Towns added`);
    if (debug) console.log(`${scriptName}: Layers Loaded are:`, _layer);
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址