Greasy Fork is available in English.

WME WazeMY

WME script for WazeMY editing moderation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        WME WazeMY
// @namespace   https://www.github.com/junyian/
// @version     2026.05.17.1
// @author      junyianl <[email protected]>
// @source      https://github.com/junyian/wme-wazemy
// @license     MIT
// @match       *://www.waze.com/editor*
// @match       *://www.waze.com/*/editor*
// @require     https://greatest.deepsurf.us/scripts/24851-wazewrap/code/WazeWrap.js
// @require     https://greatest.deepsurf.us/scripts/449165-wme-wazemy-trafcamlist/code/wme-wazemy-trafcamlist.js
// @grant       GM_xmlhttpRequest
// @grant       GM.xmlHttpRequest
// @grant       unsafeWindow
// @connect     p3.fgies.com
// @connect     p4.fgies.com
// @connect     t2.fgies.com
// @connect     jalanow.com
// @connect     llm.gov.my
// @connect     venue-image.waze.com
// @connect     generativelanguage.googleapis.com
// @run-at      document-end
// @description WME script for WazeMY editing moderation
// ==/UserScript==

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({

/***/ "./node_modules/css-loader/dist/cjs.js!./node_modules/less-loader/dist/cjs.js!./src/style/main.less"
(module, __webpack_exports__, __webpack_require__) {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   A: () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./node_modules/css-loader/dist/runtime/noSourceMaps.js");
/* harmony import */ var _node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./node_modules/css-loader/dist/runtime/api.js");
/* harmony import */ var _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1__);
// Imports


var ___CSS_LOADER_EXPORT___ = _node_modules_css_loader_dist_runtime_api_js__WEBPACK_IMPORTED_MODULE_1___default()((_node_modules_css_loader_dist_runtime_noSourceMaps_js__WEBPACK_IMPORTED_MODULE_0___default()));
// Module
___CSS_LOADER_EXPORT___.push([module.id, `.wazemySettings {
  border: 1px solid;
  padding: 8px;
  border-radius: 4px;
}
.wazemySettings legend {
  margin-bottom: 0px;
  border-bottom-style: none;
  width: auto;
}
.wazemySettings h6 {
  margin-bottom: 0px;
}
.wazemySettings input {
  margin-top: 0px;
}
#wazemyTooltip {
  height: auto;
  width: auto;
  background-color: rgba(0, 0, 0, 0.5);
  color: white;
  border-radius: 4px;
  padding: 4px;
  position: absolute;
  top: 0px;
  left: 0px;
  visibility: hidden;
  z-index: 10000;
}
#wazemyPlaces_table {
  overflow-x: scroll;
}
#wazemyPlaces_venues {
  width: 95%;
  border: 1px solid;
}
#wazemyPlaces_venues th {
  border: 1px solid;
  background-color: #ccc;
}
#wazemyPlaces_venues td {
  border: 1px solid;
}
#wazemyURs_table {
  overflow-x: scroll;
}
#wazemyURs_list {
  width: 95%;
  border: 1px solid;
}
#wazemyURs_list th {
  border: 1px solid;
  background-color: #ccc;
}
#wazemyURs_list td {
  border: 1px solid;
}
.wazemyURs_row {
  cursor: pointer;
}
.wazemyURs_row:hover {
  background-color: #e8f4fc;
}
.wazemyURs_severity_low {
  background-color: #90EE90;
  text-align: center;
}
.wazemyURs_severity_medium {
  background-color: #FFD700;
  text-align: center;
}
.wazemyURs_severity_high {
  background-color: #FF6B6B;
  text-align: center;
}
#gemini {
  margin-top: 10px;
  padding: 8px;
  border-radius: 4px;
  background-color: #f5f5f5;
}
#gemini ul {
  margin: 5px 0;
  padding-left: 20px;
}
.wazemyPlaces_ai_approve {
  background-color: #90EE90;
  text-align: center;
  color: #2e7d32;
  font-weight: bold;
}
.wazemyPlaces_ai_reject {
  background-color: #FFCDD2;
  text-align: center;
  color: #c62828;
}
.wazemyPlaces_ai_reject span {
  font-weight: bold;
  margin-right: 4px;
}
.wazemyPlaces_quickReject {
  font-size: 10px;
  padding: 2px 6px;
  cursor: pointer;
  background-color: #f44336;
  color: white;
  border: none;
  border-radius: 3px;
}
.wazemyPlaces_quickReject:hover {
  background-color: #d32f2f;
}
.wazemyPlaces_quickReject:disabled {
  background-color: #999;
  cursor: not-allowed;
}
.wazemyPlaces_rejected {
  background-color: #4CAF50 !important;
}
.wazemyPlaces_ai_error {
  background-color: #FFE0B2;
  text-align: center;
  color: #e65100;
  font-weight: bold;
  cursor: help;
}
.wazemyPlaces_ai_none {
  text-align: center;
  color: #9e9e9e;
  cursor: help;
}
`, ""]);
// Exports
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);


/***/ },

/***/ "./node_modules/css-loader/dist/runtime/api.js"
(module) {



/*
  MIT License http://www.opensource.org/licenses/mit-license.php
  Author Tobias Koppers @sokra
*/
module.exports = function (cssWithMappingToString) {
  var list = [];

  // return the list of modules as css string
  list.toString = function toString() {
    return this.map(function (item) {
      var content = "";
      var needLayer = typeof item[5] !== "undefined";
      if (item[4]) {
        content += "@supports (".concat(item[4], ") {");
      }
      if (item[2]) {
        content += "@media ".concat(item[2], " {");
      }
      if (needLayer) {
        content += "@layer".concat(item[5].length > 0 ? " ".concat(item[5]) : "", " {");
      }
      content += cssWithMappingToString(item);
      if (needLayer) {
        content += "}";
      }
      if (item[2]) {
        content += "}";
      }
      if (item[4]) {
        content += "}";
      }
      return content;
    }).join("");
  };

  // import a list of modules into the list
  list.i = function i(modules, media, dedupe, supports, layer) {
    if (typeof modules === "string") {
      modules = [[null, modules, undefined]];
    }
    var alreadyImportedModules = {};
    if (dedupe) {
      for (var k = 0; k < this.length; k++) {
        var id = this[k][0];
        if (id != null) {
          alreadyImportedModules[id] = true;
        }
      }
    }
    for (var _k = 0; _k < modules.length; _k++) {
      var item = [].concat(modules[_k]);
      if (dedupe && alreadyImportedModules[item[0]]) {
        continue;
      }
      if (typeof layer !== "undefined") {
        if (typeof item[5] === "undefined") {
          item[5] = layer;
        } else {
          item[1] = "@layer".concat(item[5].length > 0 ? " ".concat(item[5]) : "", " {").concat(item[1], "}");
          item[5] = layer;
        }
      }
      if (media) {
        if (!item[2]) {
          item[2] = media;
        } else {
          item[1] = "@media ".concat(item[2], " {").concat(item[1], "}");
          item[2] = media;
        }
      }
      if (supports) {
        if (!item[4]) {
          item[4] = "".concat(supports);
        } else {
          item[1] = "@supports (".concat(item[4], ") {").concat(item[1], "}");
          item[4] = supports;
        }
      }
      list.push(item);
    }
  };
  return list;
};

/***/ },

/***/ "./node_modules/css-loader/dist/runtime/noSourceMaps.js"
(module) {



module.exports = function (i) {
  return i[1];
};

/***/ },

/***/ "./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js"
(module) {



var stylesInDOM = [];
function getIndexByIdentifier(identifier) {
  var result = -1;
  for (var i = 0; i < stylesInDOM.length; i++) {
    if (stylesInDOM[i].identifier === identifier) {
      result = i;
      break;
    }
  }
  return result;
}
function modulesToDom(list, options) {
  var idCountMap = {};
  var identifiers = [];
  for (var i = 0; i < list.length; i++) {
    var item = list[i];
    var id = options.base ? item[0] + options.base : item[0];
    var count = idCountMap[id] || 0;
    var identifier = "".concat(id, " ").concat(count);
    idCountMap[id] = count + 1;
    var indexByIdentifier = getIndexByIdentifier(identifier);
    var obj = {
      css: item[1],
      media: item[2],
      sourceMap: item[3],
      supports: item[4],
      layer: item[5]
    };
    if (indexByIdentifier !== -1) {
      stylesInDOM[indexByIdentifier].references++;
      stylesInDOM[indexByIdentifier].updater(obj);
    } else {
      var updater = addElementStyle(obj, options);
      options.byIndex = i;
      stylesInDOM.splice(i, 0, {
        identifier: identifier,
        updater: updater,
        references: 1
      });
    }
    identifiers.push(identifier);
  }
  return identifiers;
}
function addElementStyle(obj, options) {
  var api = options.domAPI(options);
  api.update(obj);
  var updater = function updater(newObj) {
    if (newObj) {
      if (newObj.css === obj.css && newObj.media === obj.media && newObj.sourceMap === obj.sourceMap && newObj.supports === obj.supports && newObj.layer === obj.layer) {
        return;
      }
      api.update(obj = newObj);
    } else {
      api.remove();
    }
  };
  return updater;
}
module.exports = function (list, options) {
  options = options || {};
  list = list || [];
  var lastIdentifiers = modulesToDom(list, options);
  return function update(newList) {
    newList = newList || [];
    for (var i = 0; i < lastIdentifiers.length; i++) {
      var identifier = lastIdentifiers[i];
      var index = getIndexByIdentifier(identifier);
      stylesInDOM[index].references--;
    }
    var newLastIdentifiers = modulesToDom(newList, options);
    for (var _i = 0; _i < lastIdentifiers.length; _i++) {
      var _identifier = lastIdentifiers[_i];
      var _index = getIndexByIdentifier(_identifier);
      if (stylesInDOM[_index].references === 0) {
        stylesInDOM[_index].updater();
        stylesInDOM.splice(_index, 1);
      }
    }
    lastIdentifiers = newLastIdentifiers;
  };
};

/***/ },

/***/ "./node_modules/style-loader/dist/runtime/insertBySelector.js"
(module) {



var memo = {};

/* istanbul ignore next  */
function getTarget(target) {
  if (typeof memo[target] === "undefined") {
    var styleTarget = document.querySelector(target);

    // Special case to return head of iframe instead of iframe itself
    if (window.HTMLIFrameElement && styleTarget instanceof window.HTMLIFrameElement) {
      try {
        // This will throw an exception if access to iframe is blocked
        // due to cross-origin restrictions
        styleTarget = styleTarget.contentDocument.head;
      } catch (e) {
        // istanbul ignore next
        styleTarget = null;
      }
    }
    memo[target] = styleTarget;
  }
  return memo[target];
}

/* istanbul ignore next  */
function insertBySelector(insert, style) {
  var target = getTarget(insert);
  if (!target) {
    throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");
  }
  target.appendChild(style);
}
module.exports = insertBySelector;

/***/ },

/***/ "./node_modules/style-loader/dist/runtime/insertStyleElement.js"
(module) {



/* istanbul ignore next  */
function insertStyleElement(options) {
  var element = document.createElement("style");
  options.setAttributes(element, options.attributes);
  options.insert(element, options.options);
  return element;
}
module.exports = insertStyleElement;

/***/ },

/***/ "./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js"
(module, __unused_webpack_exports, __webpack_require__) {



/* istanbul ignore next  */
function setAttributesWithoutAttributes(styleElement) {
  var nonce =  true ? __webpack_require__.nc : 0;
  if (nonce) {
    styleElement.setAttribute("nonce", nonce);
  }
}
module.exports = setAttributesWithoutAttributes;

/***/ },

/***/ "./node_modules/style-loader/dist/runtime/styleDomAPI.js"
(module) {



/* istanbul ignore next  */
function apply(styleElement, options, obj) {
  var css = "";
  if (obj.supports) {
    css += "@supports (".concat(obj.supports, ") {");
  }
  if (obj.media) {
    css += "@media ".concat(obj.media, " {");
  }
  var needLayer = typeof obj.layer !== "undefined";
  if (needLayer) {
    css += "@layer".concat(obj.layer.length > 0 ? " ".concat(obj.layer) : "", " {");
  }
  css += obj.css;
  if (needLayer) {
    css += "}";
  }
  if (obj.media) {
    css += "}";
  }
  if (obj.supports) {
    css += "}";
  }
  var sourceMap = obj.sourceMap;
  if (sourceMap && typeof btoa !== "undefined") {
    css += "\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap)))), " */");
  }

  // For old IE
  /* istanbul ignore if  */
  options.styleTagTransform(css, styleElement, options.options);
}
function removeStyleElement(styleElement) {
  // istanbul ignore if
  if (styleElement.parentNode === null) {
    return false;
  }
  styleElement.parentNode.removeChild(styleElement);
}

/* istanbul ignore next  */
function domAPI(options) {
  if (typeof document === "undefined") {
    return {
      update: function update() {},
      remove: function remove() {}
    };
  }
  var styleElement = options.insertStyleElement(options);
  return {
    update: function update(obj) {
      apply(styleElement, options, obj);
    },
    remove: function remove() {
      removeStyleElement(styleElement);
    }
  };
}
module.exports = domAPI;

/***/ },

/***/ "./node_modules/style-loader/dist/runtime/styleTagTransform.js"
(module) {



/* istanbul ignore next  */
function styleTagTransform(css, styleElement) {
  if (styleElement.styleSheet) {
    styleElement.styleSheet.cssText = css;
  } else {
    while (styleElement.firstChild) {
      styleElement.removeChild(styleElement.firstChild);
    }
    styleElement.appendChild(document.createTextNode(css));
  }
}
module.exports = styleTagTransform;

/***/ }

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			id: moduleId,
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	/* webpack/runtime/compat get default export */
/******/ 	(() => {
/******/ 		// getDefaultExport function for compatibility with non-harmony modules
/******/ 		__webpack_require__.n = (module) => {
/******/ 			var getter = module && module.__esModule ?
/******/ 				() => (module['default']) :
/******/ 				() => (module);
/******/ 			__webpack_require__.d(getter, { a: getter });
/******/ 			return getter;
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/nonce */
/******/ 	(() => {
/******/ 		__webpack_require__.nc = undefined;
/******/ 	})();
/******/ 	
/************************************************************************/
var __webpack_exports__ = {};

// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js
var injectStylesIntoStyleTag = __webpack_require__("./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var injectStylesIntoStyleTag_default = /*#__PURE__*/__webpack_require__.n(injectStylesIntoStyleTag);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleDomAPI.js
var styleDomAPI = __webpack_require__("./node_modules/style-loader/dist/runtime/styleDomAPI.js");
var styleDomAPI_default = /*#__PURE__*/__webpack_require__.n(styleDomAPI);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertBySelector.js
var insertBySelector = __webpack_require__("./node_modules/style-loader/dist/runtime/insertBySelector.js");
var insertBySelector_default = /*#__PURE__*/__webpack_require__.n(insertBySelector);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js
var setAttributesWithoutAttributes = __webpack_require__("./node_modules/style-loader/dist/runtime/setAttributesWithoutAttributes.js");
var setAttributesWithoutAttributes_default = /*#__PURE__*/__webpack_require__.n(setAttributesWithoutAttributes);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/insertStyleElement.js
var insertStyleElement = __webpack_require__("./node_modules/style-loader/dist/runtime/insertStyleElement.js");
var insertStyleElement_default = /*#__PURE__*/__webpack_require__.n(insertStyleElement);
// EXTERNAL MODULE: ./node_modules/style-loader/dist/runtime/styleTagTransform.js
var styleTagTransform = __webpack_require__("./node_modules/style-loader/dist/runtime/styleTagTransform.js");
var styleTagTransform_default = /*#__PURE__*/__webpack_require__.n(styleTagTransform);
// EXTERNAL MODULE: ./node_modules/css-loader/dist/cjs.js!./node_modules/less-loader/dist/cjs.js!./src/style/main.less
var main = __webpack_require__("./node_modules/css-loader/dist/cjs.js!./node_modules/less-loader/dist/cjs.js!./src/style/main.less");
;// ./src/style/main.less

      
      
      
      
      
      
      
      
      

var options = {};

options.styleTagTransform = (styleTagTransform_default());
options.setAttributes = (setAttributesWithoutAttributes_default());
options.insert = insertBySelector_default().bind(null, "head");
options.domAPI = (styleDomAPI_default());
options.insertStyleElement = (insertStyleElement_default());

var update = injectStylesIntoStyleTag_default()(main/* default */.A, options);




       /* harmony default export */ const style_main = (main/* default */.A && main/* default */.A.locals ? main/* default */.A.locals : undefined);

;// ./src/SettingsStorage.ts
class SettingsStorage {
    /**
     * Initializes a new instance of the SettingsStorage class with the specified storage key.
     *
     * @param {string} storageKey - The key used to store the settings in the local storage.
     */
    constructor(storageKey) {
        this.storageKey = storageKey;
    }
    /**
     * Saves the given settings to the local storage.
     *
     * @param {any} settings - The settings to be saved.
     * @return {void} This function does not return anything.
     */
    saveSettings(settings) {
        localStorage.setItem(this.storageKey, JSON.stringify(settings));
    }
    /**
     * Loads the settings from the local storage.
     *
     * @return {any} The loaded settings, or null if no settings are found.
     */
    loadSettings() {
        const settings = localStorage.getItem(this.storageKey);
        return settings ? JSON.parse(settings) : null;
    }
    /**
     * Updates a specific setting in the local storage.
     *
     * @param {string} key - The key of the setting to update.
     * @param {any} value - The new value for the setting.
     * @return {void} This function does not return anything.
     */
    updateSetting(key, value) {
        const settings = this.loadSettings() || {};
        settings[key] = { ...(settings[key] || {}), ...value };
        this.saveSettings(settings);
    }
    /**
     * Retrieves a specific setting from the local storage.
     *
     * @param {string} key - The key of the setting to retrieve.
     * @return {any} The value of the setting, or null if the setting is not found.
     */
    getSetting(key) {
        const settings = this.loadSettings();
        return settings ? settings[key] : null;
    }
    /**
     * Removes a specific setting from the local storage.
     *
     * @param {string} key - The key of the setting to remove.
     * @return {void} This function does not return anything.
     */
    removeSetting(key) {
        const settings = this.loadSettings();
        if (settings && key in settings) {
            delete settings[key];
            this.saveSettings(settings);
        }
    }
}
SettingsStorage.instance = new SettingsStorage("WME_wazemySettings");

;// ./src/plugins/PluginTooltip.ts


class PluginTooltip {
    constructor() {
        this.currentFeatureId = null;
        this.currentLayerName = null;
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-tooltip",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    /**
     * Initializes the plugin by adding settings into the tab pane, setting the initial state of the settings based on the last stored value, and adding a hidden tooltip window.
     *
     * @return {void} This function does not return anything.
     */
    initialize() {
        // Add settings into tab pane.
        const settingsHTML = `<input type="checkbox" id="wazemySettings_tooltip_enable"/>
<label for="wazemySettings_tooltip_enable">Enable map tooltip</label>`;
        $("#wazemySettings_settings").append(settingsHTML);
        $("#wazemySettings_tooltip_enable").on("change", () => {
            PluginManager.instance.updatePluginSettings("tooltip", {
                enable: $("#wazemySettings_tooltip_enable").prop("checked"),
            });
        });
        // Set settings according to last stored value.
        const savedSettings = SettingsStorage.instance.getSetting("tooltip");
        if (savedSettings?.enable === true) {
            $("#wazemySettings_tooltip_enable").prop("checked", true);
        }
        else {
            $("#wazemySettings_tooltip_enable").prop("checked", false);
        }
        // Add hidden tooltip window.
        const tooltipHTML = `<div id="wazemyTooltip"></div>`;
        $(document.body).append(tooltipHTML);
        console.log("[WazeMY] PluginTooltip initialized.");
    }
    /**
     * Enables the PluginTooltip by registering the "mousemove" event, showing the tooltip, and logging a message.
     *
     * @return {void} This function does not return anything.
     */
    enable() {
        // Track layer events for segments and venues
        this.sdk.Events.trackLayerEvents({
            layerName: "segments",
        });
        this.sdk.Events.trackLayerEvents({
            layerName: "venues",
        });
        // Register handlers for mouse enter/leave events
        this.sdk.Events.on({
            eventName: "wme-layer-feature-mouse-enter",
            eventHandler: this.onFeatureMouseEnter.bind(this),
        });
        this.sdk.Events.on({
            eventName: "wme-layer-feature-mouse-leave",
            eventHandler: this.onFeatureMouseLeave.bind(this),
        });
        // Keep mouse move for positioning updates
        this.sdk.Events.on({
            eventName: "wme-map-mouse-move",
            eventHandler: this.updateTooltipPosition.bind(this),
        });
        $("#wazemyTooltip").show();
        console.log("[WazeMY] PluginTooltip enabled.");
    }
    /**
     * Disables the PluginTooltip by unregistering the "mousemove" event, hiding the tooltip, and logging a message.
     *
     * @return {void} This function does not return anything.
     */
    disable() {
        // Stop tracking layer events
        this.sdk.Events.stopLayerEventsTracking({
            layerName: "segments",
        });
        this.sdk.Events.stopLayerEventsTracking({
            layerName: "venues",
        });
        // Unregister event handlers
        this.sdk.Events.off({
            eventName: "wme-layer-feature-mouse-enter",
            eventHandler: this.onFeatureMouseEnter.bind(this),
        });
        this.sdk.Events.off({
            eventName: "wme-layer-feature-mouse-leave",
            eventHandler: this.onFeatureMouseLeave.bind(this),
        });
        this.sdk.Events.off({
            eventName: "wme-map-mouse-move",
            eventHandler: this.updateTooltipPosition.bind(this),
        });
        // Clear current feature state
        this.currentFeatureId = null;
        this.currentLayerName = null;
        $("#wazemyTooltip").hide();
        console.log("[WazeMY] PluginTooltip disabled.");
    }
    /**
     * Updates the settings of the PluginTooltip based on the provided settings object.
     *
     * @param {any} settings - The new settings object.
     * @return {void} This function does not return anything.
     */
    updateSettings(settings) {
        if (settings.enable === true) {
            this.enable();
        }
        else {
            this.disable();
        }
        console.log("[WazeMY] PluginTooltip settings updated.", settings);
    }
    /**
     * Handles mouse enter event on a feature.
     * Stores the feature ID and layer name for tooltip display.
     *
     * @param {Object} event - The event object containing featureId and layerName.
     * @return {void}
     */
    onFeatureMouseEnter(event) {
        this.currentFeatureId = event.featureId;
        this.currentLayerName = event.layerName;
    }
    /**
     * Handles mouse leave event on a feature.
     * Clears the stored feature information and hides the tooltip.
     *
     * @return {void}
     */
    onFeatureMouseLeave() {
        this.currentFeatureId = null;
        this.currentLayerName = null;
        $("#wazemyTooltip").css("visibility", "hidden");
    }
    /**
     * Updates the tooltip position and content based on the current hovered feature.
     *
     * @return {void} This function does not return anything.
     */
    updateTooltipPosition() {
        // If no feature is currently hovered, hide tooltip
        if (!this.currentFeatureId || !this.currentLayerName) {
            $("#wazemyTooltip").css("visibility", "hidden");
            return;
        }
        let output = "";
        // Build tooltip content based on layer type
        if (this.currentLayerName === "venues") {
            const venue = this.sdk.DataModel.Venues.getById({
                venueId: String(this.currentFeatureId),
            });
            output = venue.name ? `<b>${venue.name}</b><br>` : "";
            output += `<i>[${venue.categories.join(", ")}]</i><br>`;
            const venueAddress = this.sdk.DataModel.Venues.getAddress({
                venueId: String(this.currentFeatureId),
            });
            output += venueAddress.houseNumber ? `${venueAddress.houseNumber}, ` : "";
            output += venueAddress.street?.name
                ? `${venueAddress.street.name}<br>`
                : "";
            if (venueAddress.city?.name && venueAddress.state?.name) {
                output += `${venueAddress.city.name}, ${venueAddress.state.name}<br>`;
            }
            output += `<b>Lock:</b> ${venue.lockRank + 1}`;
        }
        else if (this.currentLayerName === "segments") {
            const segmentData = this.sdk.DataModel.Segments.getById({
                segmentId: Number(this.currentFeatureId),
            });
            const address = this.sdk.DataModel.Segments.getAddress({
                segmentId: Number(this.currentFeatureId),
            });
            output = address.street?.name ? `<b>${address.street.name}</b><br>` : "";
            const altStreets = address.altStreets;
            for (let i = 0; i < altStreets.length; i++) {
                const altStreetName = altStreets[i].street?.name;
                if (altStreetName) {
                    output += `Alt: ${altStreetName}<br>`;
                }
            }
            if (address.city?.name && address.state?.name) {
                output += `${address.city.name}, ${address.state.name}<br>`;
            }
            output += `<b>ID:</b> ${this.currentFeatureId}<br>`;
            if (segmentData.isTwoWay) {
                output += `<b>Direction:</b> Two way<br>`;
            }
            else if (segmentData.isAtoB) {
                output += `<b>Direction:</b> A -> B<br>`;
            }
            else if (segmentData.isBtoA) {
                output += `<b>Direction:</b> B -> A<br>`;
            }
            output += `<b>Lock:</b> ${segmentData.lockRank + 1}`;
        }
        // Update tooltip position based on mouse coordinates
        const tooltipDiv = $("#wazemyTooltip");
        const positions = document
            .querySelector(".wz-map-ol-control-span-mouse-position")
            .innerHTML.split(" ");
        const lat = parseFloat(positions[0]);
        const lon = parseFloat(positions[1]);
        if (lat >= 0 && lon >= 0) {
            const pixel = this.sdk.Map.getPixelFromLonLat({
                lonLat: {
                    lat: parseFloat(positions[0]),
                    lon: parseFloat(positions[1]),
                },
            });
            const tw = tooltipDiv.innerWidth();
            const th = tooltipDiv.innerHeight();
            let tooltipX = pixel.x + window.scrollX + 15;
            let tooltipY = pixel.y + window.scrollY + 15;
            // Handle cases where tooltip is too near the edge
            const mapElement = this.sdk.Map.getMapViewportElement();
            if (tooltipX + tw > mapElement.offsetWidth) {
                tooltipX -= tw + 20; // 20 = scroll bar size
                if (tooltipX < 0) {
                    tooltipX = 0;
                }
            }
            if (tooltipY + th > mapElement.offsetHeight) {
                tooltipY -= th + 20;
                if (tooltipY < 0) {
                    tooltipY = 0;
                }
            }
            tooltipDiv.html(output);
            tooltipDiv.css("top", `${tooltipY}px`);
            tooltipDiv.css("left", `${tooltipX}px`);
            tooltipDiv.css("visibility", "visible");
        }
    }
}

;// ./src/plugins/PluginCopyLatLon.ts
class PluginCopyLatLon {
    constructor() {
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-copylatlon",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    /**
     * Initialize plugin.
     *
     * @return {void} This function does not return anything.
     */
    initialize() {
        const settingsHTML = `<div>Ctrl+Alt+C: <i>Copy lat/lon of mouse position to clipboard.</i></div>`;
        $("#wazemySettings_shortcuts").append(settingsHTML);
        this.enable(); // Manually enable plugin since there is no settings to trigger this.
        console.log("[WazeMY] PluginCopyLatLon initialized.");
    }
    /**
     * Enable plugin.
     *
     * @return {void} This function does not return anything.
     */
    enable() {
        const shortcut = {
            callback: this.copyLatLon,
            description: "Copy lat/lon of mouse position to clipboard.",
            shortcutId: "WazeMY_latloncopy",
            shortcutKeys: "CA+c",
        };
        this.sdk.Shortcuts.createShortcut(shortcut);
        console.log("[WazeMY] PluginCopyLatLon enabled.");
    }
    /**
     * Disable plugin.
     *
     * @return {void} This function does not return anything.
     */
    disable() {
        console.log("[WazeMY] PluginCopyLatLon disabled.");
    }
    /**
     * Updates the settings of the PluginCopyLatLon based on the provided settings object.
     *
     * @return {void} This function does not return anything.
     */
    updateSettings(settings) {
        console.log("[WazeMY] PluginCopyLatLon settings updated.", settings);
    }
    /**
     * Copies lat/lon of mouse position to clipboard.
     *
     * @return {void} This function does not return anything.
     */
    copyLatLon() {
        console.log("[WazeMY] Copy lat/lon shortcut triggered.");
        const latlon = $(".wz-map-ol-control-span-mouse-position").text();
        navigator.clipboard.writeText(latlon);
    }
}

;// ./src/plugins/PluginTrafficCameras.ts


class PluginTrafficCameras {
    constructor() {
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-trafcam",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    initialize() {
        // Add settings into view.
        const settingsHTML = `<div>
      <input type="checkbox" id="wazemySettings_trafcam_enable" style="margin-top:0px"/>
      <label for="wazemySettings_trafcam_enable">Traffic cameras</label><br></div>`;
        const wazemySettings = document.getElementById("wazemySettings_settings");
        $("#wazemySettings_settings").append(settingsHTML);
        // wazemySettings.insertAdjacentHTML("afterbegin", settingsHTML);
        const settingsEl = document.getElementById(`wazemySettings_trafcam_enable`);
        const savedSettings = SettingsStorage.instance.getSetting("trafcam");
        if (savedSettings?.enable === true) {
            settingsEl.checked = true;
        }
        else {
            settingsEl.checked = false;
        }
        settingsEl.onchange = (e) => {
            const target = e.target;
            PluginManager.instance.updatePluginSettings("trafcam", {
                enable: target.checked,
            });
        };
        // Install camera icon
        if (!OpenLayers.Icon) {
            this.installIconClass();
        }
        this.trafcamLayer = new OpenLayers.Layer.Markers("wazemyTrafcamLayer");
        W.map.addLayer(this.trafcamLayer);
        this.showIcons();
        console.log("PluginTrafficCameras initialized.");
    }
    enable() {
        console.log("PluginTrafficCameras enabled.");
        this.trafcamLayer.setVisibility(true);
    }
    disable() {
        console.log("PluginTrafficCameras disabled.");
        this.trafcamLayer.setVisibility(false);
    }
    updateSettings(settings) {
        if (settings.enable === true) {
            this.enable();
        }
        else {
            this.disable();
        }
        console.log("PluginTrafficCameras settings updated", settings);
    }
    installIconClass() {
        OpenLayers.Icon = OpenLayers.Class({
            url: null,
            size: null,
            offset: null,
            calculateOffset: null,
            imageDiv: null,
            px: null,
            initialize: function (url, size, offset, calculateOffset) {
                this.url = url;
                this.size = size || { w: 20, h: 20 };
                this.offset = offset || {
                    x: -(this.size.w / 2),
                    y: -(this.size.h / 2),
                };
                this.calculateOffset = calculateOffset;
                url = OpenLayers.Util.createUniqueID("OL_Icon_");
                const div = (this.imageDiv = OpenLayers.Util.createAlphaImageDiv(url));
                $(div.firstChild).removeClass("olAlphaImg"); // LEAVE THIS LINE TO PREVENT WME-HARDHATS SCRIPT FROM TURNING ALL ICONS INTO HARDHAT WAZERS --MAPOPMATIC
            },
            destroy: function () {
                this.erase();
                OpenLayers.Event.stopObservingElement(this.imageDiv.firstChild);
                this.imageDiv.innerHTML = "";
                this.imageDiv = null;
            },
            clone: function () {
                return new OpenLayers.Icon(this.url, this.size, this.offset, this.calculateOffset);
            },
            setSize: function (size) {
                null !== size && (this.size = size);
                this.draw();
            },
            setUrl: function (url) {
                null !== url && (this.url = url);
                this.draw();
            },
            draw: function (a) {
                OpenLayers.Util.modifyAlphaImageDiv(this.imageDiv, null, null, this.size, this.url, "absolute");
                this.moveTo(a);
                return this.imageDiv;
            },
            erase: function () {
                null !== this.imageDiv &&
                    null !== this.imageDiv.parentNode &&
                    OpenLayers.Element.remove(this.imageDiv);
            },
            setOpacity: function (a) {
                OpenLayers.Util.modifyAlphaImageDiv(this.imageDiv, null, null, null, null, null, null, null, a);
            },
            moveTo: function (a) {
                null !== a && (this.px = a);
                null !== this.imageDiv &&
                    (null === this.px
                        ? this.display(!1)
                        : (this.calculateOffset &&
                            (this.offset = this.calculateOffset(this.size)),
                            OpenLayers.Util.modifyAlphaImageDiv(this.imageDiv, null, {
                                x: this.px.x + this.offset.x,
                                y: this.px.y + this.offset.y,
                            })));
            },
            display: function (a) {
                this.imageDiv.style.display = a ? "" : "none";
            },
            isDrawn: function () {
                return (this.imageDiv &&
                    this.imageDiv.parentNode &&
                    11 != this.imageDiv.parentNode.nodeType);
            },
            CLASS_NAME: "OpenLayers.Icon",
        });
    }
    showIcons() {
        trafficCamsData.forEach((e, idx) => {
            this.drawCamIcon({
                idx: idx,
                desc: e.desc,
                src: e.url,
                width: 20,
                height: 20,
                lat: e.lat,
                lon: e.lon,
            });
        });
    }
    drawCamIcon(spec) {
        const camIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAAGXcA1uAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYxIDY0LjE0MDk0OSwgMjAxMC8xMi8wNy0xMDo1NzowMSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpBRDNGNTkwRTYzQThFMzExQTc4MDhDNjAwODdEMzdEQSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo2OUI0RUEyN0IwRjcxMUUzOERFM0E1OTJCRUY3NTFBOCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo2OUI0RUEyNkIwRjcxMUUzOERFM0E1OTJCRUY3NTFBOCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1LjEgV2luZG93cyI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjZGOEJBMzExNkZCMEUzMTFCOEY5QTU3QUQxM0M2MjI5IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOkFEM0Y1OTBFNjNBOEUzMTFBNzgwOEM2MDA4N0QzN0RBIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+TV0cjwAABbhJREFUeNpiYIAAXiZGCIMPiP//f8DwnwnI+PT/HZD8y8AAEEBQVQzTwOSfuwz/u6qAyh4y/Gf8f4/hPwMnUJSZgQEggMCyzpZAmbsILMTHcAokPhPEWT8dqJoBgvetAtMMDBIiDCf/P4bqeMXwf+t8BiOAAGJAAqVA7A1iPH/+goFBT4OB8f9LJDueAzFQgOHGLoTZYEGgpJgww0+G/68ZQArAEsryEHpRN5BuK4FIcHMhjJORVTvBYG3MwPD2CkKwKAVI/3nBABBAYOcY6zKAjGSQEmP4cGkXxMlgK4BeaCll+B/lzzDh/yegmr8vIO4HGv/l/0eG/w4WDP9fnUM4Dhl/uMzwf/6CFQ4g9RUgE3ctRvgGGSvJMfw/tw3i7sY8hv8sQMGrQE8yuFoBZe8CeRwMDIKaDAyWRgwM2xYD+exA/AuIvwAV3kaE6sSPQCuBHv2vqczwf0YL0MR7SE4CsgsTGP5///aSCZQSGDRVGPJfv2dgNDNkcP30heHbB6BpyzYyMCRVMjCwqDIcOX+LgTEtioGRhfn/P4AAbJTfK0NhGMe/Z+ecpVkrScnIlCiWkoulpFyhxgXKXCGK3XGBG/4BJcoNN665tqsxo6XshiKtyQybn82vMZPF63nPOXKYi+fieU7P8z59P9/n6NlBVNrRRDFPMUlRIGhmM0gWzk5NSCFMuDE2MqAaUJGUhDgOgNmKkXmLwMRudA11diynI9lSKhEHG+oBu9q1mHkDf9AWWkM0dgHEb4H+PqqkKD51uxpJuSoDHpIfAowyohyUveJH+9pqUjigav/9UnIfzO/frkRvBxUuwcpK/gc3PkzfzynuwRoanYuStZDKaeAkwA8QEPJ/CYfpBUCmqxp1E7uXJ6u0tUNVQbvIR+DIBzgHgQMvrZ5LZWIiSuowh6N+nQ9ZYgnNcLTzdRBsz5Ot1socWCr1KipYulrJVDIQjqjwgqsESvcPQB5QWmP2nsWem5X80IeizhaadPfHQwTxnXJTDk5ZQgeOOCC0ScY0wtPdRrc4AzY7BVZuQ8bVDhcXJLyhNnwJUFj5hTQVhmH8mQ7H5nYYkRxJw8hqBWYsLIr+gisKYobdjKguClOKLiQvgrrwqogiIr1wECHdRCCjIopNiCKZIGkthysrrWSklg36M6O5fT3fzmk7C8kDL4zt2/neP8/ze01/b6XuceWsVmAJJR56gurObjSn0/DWrEY152SmIyFBNk1sxZhBZAQTyVmEDjeiy9eAQdm4FK1gLlHg8Y3CYlXzZW12A1+GYd1Yi1u1LogXQSYgmxdnjM8jsTFNZvLMxADE3h0QS8vxNNqLJWKa2ZMXBRXwaaNmL/XdoUct+hhtjF+MFBZ+zJq5Gw6ywoRyI/xs/JjJtCj38+XWo3rGdM6pI3nlqYshmmiGx7fJpLE8rAqGZQy+49o5CNeauoeplJZZPbWfztqSWurvmV/ixrCXQjRSFQE/xI8R/dK44VJae99OorWt/Xh2tZxp0Q+903zynX/q6YLwevgy28IXyiBYxCY3cXYeIvGGRL0Isc697Z5k0NRMQj8Grd92zuDALuAuIRm8CVSohe1uYZ+T74FwADjdBFBh6GgH+mm3ZuLDSb9Ocg9YLNYZOeSyUitevupFeWWVTlzjQ0dNfQJW1fOybulPWlmyZ85wpshQC43/m+9YsR2ZTn9wg5z955+z2FO3H0PRIIqcHHzgPpAgNukwOJzAwCB3NiFQxsyyjJ37J4lMXklpfnaRyidaO3xe7+6h3JmVy2CjkcInD3EO3/T1xsFn3mobX0z+RzkfNAxcpXrIjo+RkFKp0VLkk1hfwwqJr69RODxbcH05gem/wIEN62TV13XuMxNIvqYYqCT6R6x14UHsEarkwhBxJbtnC4wmS9fXDuT2stFkxIDsp4tfbZU5MCr0jnMbIMLoKy7Gc8WunU3rxHMoCmKxUaiqij/5alOWhMPoGAAAAABJRU5ErkJggg==";
        const size = new OpenLayers.Size(20, 20);
        const icon = new OpenLayers.Icon(camIcon, size);
        const epsg4326 = new OpenLayers.Projection("EPSG:4326"); // WGS 1984 projection
        const webMercator = new OpenLayers.Projection("EPSG:900913"); // Web Mercator projection used by WME
        const lonLat = new OpenLayers.LonLat(spec.lon, spec.lat).transform(epsg4326, webMercator);
        const newMarker = new OpenLayers.Marker(lonLat, icon);
        newMarker.idx = spec.idx;
        newMarker.title = spec.desc;
        newMarker.url = spec.src;
        newMarker.width = spec.width;
        newMarker.height = spec.height;
        newMarker.location = lonLat;
        newMarker.events.register("click", newMarker, this.popupCam);
        this.trafcamLayer.addMarker(newMarker);
    }
    popupCam(e) {
        popupCam_close(); // Close existing popup if already opened.
        var popupHTML = `<div id="gmPopupContainerCam" style="margin:1;text-align:center;padding:5px;z-index:1100;position:absolute;color:white;background:rgba(0,0,0,0.5)">
            <table border=0>
                <tr>
                    <td><div id="mycamdivheader" style="min-height:20px;white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word;width:380px">${e.object.title}</div></td>
                    <td align="right"><a href="#close" id="gmCloseCamDlgBtn" title="Close" style="color:red">X</a></td>
                </tr>
                <tr><td colspan=2>Select source:
                    <select id="wazemy_camSource">
                    </select>
                    <div hidden id="mycamid">${e.object.idx}</div>
                </td></tr>
                <tr><td colspan=2><img style="width:400px" id="staticimage"></td></tr>
                <tr><td colspan=2><div id="mycamstatus"></div></td></tr>
            </table></div>`;
        document.body.insertAdjacentHTML("afterbegin", popupHTML);
        // Get SDK instance for map viewport
        const sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-trafcam",
            scriptName: "WazeMY",
        });
        // Handle cases where popup is too near the edge.
        let tw = $("#gmPopupContainerCam").width();
        let th = $("#gmPopupContainerCam").height() + 200;
        var tooltipX = e.clientX + window.scrollX + 15;
        var tooltipY = e.clientY + window.scrollY + 15;
        const mapElement = sdk.Map.getMapViewportElement();
        if (tooltipX + tw > mapElement.offsetWidth) {
            tooltipX -= tw + 20; // 20 = scroll bar size
            if (tooltipX < 0)
                tooltipX = 0;
        }
        if (tooltipY + th > mapElement.offsetHeight) {
            tooltipY -= th + 20;
            if (tooltipY < 0)
                tooltipY = 0;
        }
        $("#gmPopupContainerCam").css({ left: tooltipX });
        $("#gmPopupContainerCam").css({ top: tooltipY });
        //Add listener for popup's "Close" button
        const closeBtn = document.getElementById("gmCloseCamDlgBtn");
        closeBtn.onclick = popupCam_close;
        // Allow popup to be draggable.
        const popupContainerEl = document.getElementById("gmPopupContainerCam");
        popup_dragElement(popupContainerEl);
        const camSourceEl = document.getElementById("wazemy_camSource");
        for (let urlsrc in e.object.url) {
            if (urlsrc === "LLM" && e.object.url["LLM"].split("|").length == 2) {
                popup_appendOption(urlsrc);
            }
            else if (urlsrc === "Jalanow") {
                popup_appendOption(urlsrc);
            }
        }
        camSourceEl.onchange = (e) => {
            console.log("PluginTrafficCameras: Camera source selection changed.");
            const camId = document.getElementById("mycamid");
            const target = e.target;
            switch (target.selectedOptions[0].innerText) {
                case "Jalanow":
                    popup_getJalanowImage(trafficCamsData[camId.innerText]["url"]["Jalanow"]);
                    break;
                case "LLM":
                    popup_getLLMImage(trafficCamsData[camId.innerText]["url"]["LLM"]);
                    break;
            }
        };
        // Get image for the first time when popup is displayed.
        switch (Object.keys(e.object.url)[0]) {
            case "Jalanow":
                popup_getJalanowImage(e.object.url["Jalanow"]);
                break;
            case "LLM":
                popup_getLLMImage(e.object.url["LLM"]);
                break;
        }
        function popupCam_close() {
            const popupContainerEl = document.getElementById("gmPopupContainerCam");
            if (popupContainerEl) {
                popupContainerEl.remove();
                popupContainerEl.hidden = true;
            }
        }
        function popup_dragElement(elmnt) {
            var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
            if (document.getElementById("mycamdivheader")) {
                // if present, the header is where you move the DIV from:
                document.getElementById("mycamdivheader").onmousedown = dragMouseDown;
            }
            else {
                // otherwise, move the DIV from anywhere inside the DIV:
                elmnt.onmousedown = dragMouseDown;
            }
            function dragMouseDown(e) {
                e.preventDefault();
                // get the mouse cursor position at startup:
                pos3 = e.clientX;
                pos4 = e.clientY;
                document.onmouseup = closeDragElement;
                // call a function whenever the cursor moves:
                document.onmousemove = elementDrag;
            }
            function elementDrag(e) {
                e.preventDefault();
                // calculate the new cursor position:
                pos1 = pos3 - e.clientX;
                pos2 = pos4 - e.clientY;
                pos3 = e.clientX;
                pos4 = e.clientY;
                // set the element's new position:
                const popupContainerEl = document.getElementById("gmPopupContainerCam");
                popupContainerEl.style.top = popupContainerEl.offsetTop - pos2 + "px";
                popupContainerEl.style.left = popupContainerEl.offsetLeft - pos1 + "px";
            }
            function closeDragElement() {
                // stop moving when mouse button is released:
                document.onmouseup = null;
                document.onmousemove = null;
            }
        }
        function popup_appendOption(urlsrc) {
            const option = document.createElement("option");
            option.value = urlsrc;
            option.text = urlsrc;
            camSourceEl.append(option);
        }
        function popup_getJalanowImage(url) {
            GM_xmlhttpRequest({
                method: "GET",
                responseType: "blob",
                headers: {
                    authority: "p4.fgies.com",
                    referer: "https://www.jalanow.com/",
                    accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
                },
                url: url,
                onload: function (response) {
                    const staticImageEl = document.getElementById("staticimage");
                    staticImageEl.src = URL.createObjectURL(response.response);
                    document.getElementById("mycamstatus").innerHTML = "";
                },
                onerror: function (response) {
                    document.getElementById("mycamstatus").innerHTML =
                        "Error loading image.";
                },
                onprogress: function (response) {
                    document.getElementById("mycamstatus").innerHTML = "Loading image...";
                },
            });
        }
        function popup_getLLMImage(url) {
            let camImg = url.split("|");
            GM_xmlhttpRequest({
                method: "GET",
                responseType: "blob",
                url: camImg[0],
                onload: function (response) {
                    const re = new RegExp('src="data:image/png;base64, ([A-Za-z0-9/+=]*)" title="' +
                        camImg[1] +
                        '"');
                    const m = response.responseText.match(re);
                    const staticImageEl = document.getElementById("staticimage");
                    staticImageEl.src = "data:image/png;base64," + m[1];
                    document.getElementById("mycamstatus").innerHTML = "";
                },
                onerror: function (response) {
                    document.getElementById("mycamstatus").innerHTML =
                        "Error loading image.";
                },
                onprogress: function (response) {
                    document.getElementById("mycamstatus").innerHTML = "Loading image...";
                },
            });
        }
    }
}

;// ./src/plugins/PluginKVMR.ts


class PluginKVMR {
    constructor() {
        this.areas = [
            {
                name: "Area 1",
                geometry: "POLYGON ((101.296968 3.210365, 101.30376 3.118812, 101.327352 3.100621, 101.332475 3.083048, 101.3447571 3.0671528, 101.3475037 2.9992702, 101.4546204 2.9862417, 101.443634 3.2097608, 101.296968 3.210365))",
                color: "#ffffff",
            },
            {
                name: "Area 2a",
                geometry: "POLYGON ((101.443634 3.2097608, 101.4546204 2.9862417, 101.542797963266 2.999451622765, 101.546214771469 3.20939581928522, 101.443634 3.2097608))",
                color: "#ff0000",
            },
            {
                name: "Area 2b",
                geometry: "POLYGON ((101.546208402411 3.20940523257651, 101.545212203573 3.14955753765693, 101.640751782444 3.14826179285013, 101.6379547 3.2090753, 101.546208402411 3.20940523257651))",
                color: "#00ff00",
            },
            {
                name: "Area 2c",
                geometry: "POLYGON ((101.545226515908 3.14956187716133, 101.544049591624 3.0763653393285, 101.643784992874 3.08223941174546, 101.640750558754 3.14825692296985, 101.545226515908 3.14956187716133))",
                color: "#0000ff",
            },
            {
                name: "Area 2d",
                geometry: "POLYGON ((101.544050641663 3.07636531606873, 101.542795175242 2.99943779058738, 101.6468811 3.0150413, 101.643784373198 3.08223705403019, 101.544050641663 3.07636531606873))",
                color: "#ffff00",
            },
            {
                name: "Area 3",
                geometry: "POLYGON ((101.6379547 3.2090753, 101.6412146 3.145488, 101.7443848 3.1501147, 101.7309414 3.2090495, 101.6379547 3.2090753))",
                color: "#ff00ff",
            },
            {
                name: "Area 4",
                geometry: "POLYGON ((101.6412146 3.145488, 101.6439612 3.0796673, 101.7615509 3.0791519, 101.7443848 3.1501147, 101.6412146 3.145488))",
                color: "#00ffff",
            },
            {
                name: "Area 5",
                geometry: "POLYGON ((101.788224 3.210399, 101.7309414 3.2090495, 101.7615509 3.0791519, 101.6439612 3.0796673, 101.6468811 3.0150413, 101.8391418 3.0253266, 101.788224 3.210399))",
                color: "#40ff00",
            },
            {
                name: "Area 6",
                geometry: "POLYGON ((101.332475 3.083048, 101.262098 3.074553, 101.2335205 3.0815516, 101.208976 3.060844, 101.162394 2.989639, 101.223721 2.903504, 101.268598 2.871386, 101.284902 2.830662, 101.448138 2.72835, 101.4546204 2.9862417, 101.3475037 2.9992702, 101.3447571 3.0671528, 101.332475 3.083048))",
                color: "#ff4000",
            },
            {
                name: "Area 7",
                geometry: "POLYGON ((101.4546204 2.9862417, 101.448138 2.72835, 101.477474 2.766274, 101.559411 2.807463, 101.6578674 2.8223442, 101.6468811 3.0150413, 101.4546204 2.9862417))",
                color: "#33ff00",
            },
            {
                name: "Area 8",
                geometry: "POLYGON ((101.6578674 2.8223442, 101.725067 2.83344, 101.756459 2.866068, 101.882828 2.870563, 101.8391418 3.0253266, 101.6468811 3.0150413, 101.6578674 2.8223442))",
                color: "#ff0033",
            },
        ];
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-kvmr",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    initialize() {
        // Add settings into view.
        const settingsHTML = `<div>
      <input type="checkbox" id="wazemySettings_kvmr_enable" style="margin-top:0px"/>
      <label for="wazemySettings_kvmr_enable">Klang Valley Map Raid</label><br></div>`;
        $("#wazemySettings_settings").append(settingsHTML);
        const settingsEl = document.getElementById(`wazemySettings_kvmr_enable`);
        const savedSettings = SettingsStorage.instance.getSetting("kvmr");
        if (savedSettings?.enable === true) {
            settingsEl.checked = true;
        }
        else {
            settingsEl.checked = false;
        }
        settingsEl.onchange = (e) => {
            const target = e.target;
            PluginManager.instance.updatePluginSettings("kvmr", {
                enable: target.checked,
            });
        };
        // Add MR polygon overlay.
        this.raid_mapLayer = new OpenLayers.Layer.Vector("KlangValley", {
            displayInLayerSwitcher: true,
            uniqueName: "__KlangValley",
        });
        // W. object: Complex polygon vector layer with custom styling
        // SDK layer system is designed for GeoJSON; refactoring would have low ROI
        W.map.addLayer(this.raid_mapLayer);
        // Register layer for cross-plugin access
        PluginManager.instance.registerLayer("__KlangValley", this.raid_mapLayer);
        this.areas.forEach((area) => {
            const geometry = parseWKT(area.geometry);
            this.addRaidPolygon(this.raid_mapLayer, geometry, area.color, area.name);
        });
        // Create event handler reference for cleanup
        this.handleMapUpdate = () => this.currentRaidLocation();
        // Register SDK events for map updates
        // Note: wme-map-move-end fires after both panning and zooming
        this.sdk.Events.on({
            eventName: "wme-map-move-end",
            eventHandler: this.handleMapUpdate,
        });
        console.log("PluginKVMR initialized.");
        function parseWKT(wkt) {
            let trimmed;
            if (wkt.startsWith("POLYGON")) {
                trimmed = wkt.replace("POLYGON ((", "").replace("))", "");
            }
            const coordinatePairs = trimmed.split(", ");
            const coordinates = coordinatePairs.map((pair) => {
                const [lon, lat] = pair.split(" ");
                return { lon, lat };
            });
            return coordinates;
        }
    }
    /**
     * Updates the current raid location on the map based on the user's current location.
     *
     * @return {void} This function does not return anything.
     */
    currentRaidLocation() {
        // Only run if the plugin is enabled. Workaround because unregistering events doesn't work.
        if ($("#wazemySettings_kvmr_enable").is(":checked") === false) {
            return;
        }
        for (let i = 0; i < this.raid_mapLayer.features?.length; i++) {
            // W. object: Using W.map.getCenter() for map center in Web Mercator coordinates
            // Used for polygon containment check with OpenLayers geometry
            var raidMapCenter = W.map.getCenter();
            var raidCenterPoint = new OpenLayers.Geometry.Point(raidMapCenter.lon, raidMapCenter.lat);
            var raidCenterCheck = this.raid_mapLayer.features[i].geometry.components[0].containsPoint(raidCenterPoint);
            var holes = this.raid_mapLayer.features[i].attributes.holes;
            if (raidCenterCheck === true) {
                var str = $("#topbar-container > div > div.location-info-region > div").text();
                const location = str.split(" - ");
                if (location.length > 1) {
                    location[1] =
                        "Klang Valley MapRaid " +
                            this.raid_mapLayer.features[i].attributes.number;
                }
                else {
                    location.push("Klang Valley MapRaid " +
                        this.raid_mapLayer.features[i].attributes.number);
                }
                const raidLocationLabel = location.join(" - ");
                setTimeout(function () {
                    $("#topbar-container > div > div.location-info-region > div").text(raidLocationLabel);
                }, 200);
                if (holes === "false") {
                    break;
                }
            }
        }
    }
    enable() {
        this.raid_mapLayer.setVisibility(true);
        console.log("PluginKVMR enabled.");
    }
    disable() {
        this.raid_mapLayer.setVisibility(false);
        // Unregister SDK events
        this.sdk.Events.off({
            eventName: "wme-map-move-end",
            eventHandler: this.handleMapUpdate,
        });
        console.log("PluginKVMR disabled.");
    }
    updateSettings(settings) {
        if (settings.enable === true) {
            this.enable();
        }
        else {
            this.disable();
        }
        console.log("PluginKVMR settings updated", settings);
    }
    addRaidPolygon(raidLayer, groupPoints, groupColor, groupNumber) {
        var raidGroupLabel = "KlangValley " + groupNumber;
        var groupName = "RaidGroup " + groupNumber;
        var style = {
            strokeColor: groupColor,
            strokeOpacity: 0.8,
            strokeWidth: 3,
            fillColor: groupColor,
            fillOpacity: 0.15,
            label: raidGroupLabel,
            labelOutlineColor: "black",
            labelOutlineWidth: 3,
            fontSize: 14,
            fontColor: groupColor,
            fontOpacity: 0.85,
            fontWeight: "bold",
        };
        var attributes = {
            name: groupName,
            number: groupNumber,
        };
        var pnt = [];
        const wgs84 = new OpenLayers.Projection("EPSG:4326");
        const webMercator = new OpenLayers.Projection("EPSG:900913");
        for (let i = 0; i < groupPoints.length; i++) {
            const convPoint = new OpenLayers.Geometry.Point(groupPoints[i].lon, groupPoints[i].lat).transform(wgs84, webMercator);
            //console.log('MapRaid: ' + JSON.stringify(groupPoints[i]) + ', ' + groupPoints[i].lon + ', ' + groupPoints[i].lat);
            pnt.push(convPoint);
        }
        var ring = new OpenLayers.Geometry.LinearRing(pnt);
        var polygon = new OpenLayers.Geometry.Polygon([ring]);
        var feature = new OpenLayers.Feature.Vector(polygon, attributes, style);
        raidLayer.addFeatures([feature]);
    }
}

;// ./src/plugins/PluginZoomPic.ts
class PluginZoomPic {
    constructor() {
        this.currentBlobUrl = null;
        this.initialize();
    }
    /**
     * Initialize plugin.
     *
     * @return {void} This function does not return anything.
     */
    initialize() {
        this.createPopupContainer();
        this.setupPopupHandlers();
        $(document.body).on("click", () => {
            const img = $(".venue-image-dialog > wz-dialog-content > img");
            if (img.length > 0) {
                const newImg = img[0];
                // Remove existing zoom links
                const links = $(".venue-image-dialog > wz-dialog-header > #zoomPicLink");
                for (let i = 0; i < links.length; i++) {
                    links[i].remove();
                }
                // Add clickable span that fetches and displays image in popup
                const newImgHTML = `<span id="zoomPicLink" style="cursor:pointer; color:blue; text-decoration:underline;">(+)</span>`;
                $(".venue-image-dialog > wz-dialog-header").append(newImgHTML);
                $("#zoomPicLink").on("click", (e) => {
                    e.stopPropagation();
                    const fullSizeUrl = newImg.src.replace("thumbs/thumb700_", "");
                    this.fetchAndDisplayImage(fullSizeUrl);
                });
            }
        });
        console.log("[WazeMY] PluginZoomPic initialized.");
    }
    /**
     * Create the popup container for displaying full-size images.
     */
    createPopupContainer() {
        const popupHTML = `<div id="gmPopupContainerZoomPic" style="display:none; position:fixed; z-index:10001; background:#fff; border:2px solid #333; border-radius:5px; padding:5px; cursor:move; box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
      <div style="text-align:right; margin-bottom:5px;">
        <span id="zoomPicClose" style="cursor:pointer; font-weight:bold; padding:0 5px;">[X]</span>
      </div>
      <div id="zoomPicLoading" style="display:none; padding:20px; text-align:center;">Loading...</div>
      <img id="zoomPicImage" style="max-width:90vw; max-height:85vh; display:block;">
    </div>`;
        document.body.insertAdjacentHTML("afterbegin", popupHTML);
    }
    /**
     * Setup event handlers for popup close and drag functionality.
     */
    setupPopupHandlers() {
        const popup = document.getElementById("gmPopupContainerZoomPic");
        const closeBtn = document.getElementById("zoomPicClose");
        // Close button handler
        closeBtn?.addEventListener("click", () => {
            this.closePopup();
        });
        // Close on Escape key
        document.addEventListener("keydown", (e) => {
            if (e.key === "Escape" && popup?.style.display === "block") {
                this.closePopup();
            }
        });
        // Drag functionality
        let isDragging = false;
        let offsetX = 0;
        let offsetY = 0;
        popup?.addEventListener("mousedown", (e) => {
            const target = e.target;
            // Don't drag if clicking on close button or image
            if (target.id === "zoomPicClose" || target.id === "zoomPicImage")
                return;
            isDragging = true;
            offsetX = e.clientX - popup.offsetLeft;
            offsetY = e.clientY - popup.offsetTop;
        });
        document.addEventListener("mousemove", (e) => {
            if (!isDragging || !popup)
                return;
            popup.style.left = e.clientX - offsetX + "px";
            popup.style.top = e.clientY - offsetY + "px";
        });
        document.addEventListener("mouseup", () => {
            isDragging = false;
        });
    }
    /**
     * Close the popup and cleanup blob URL.
     */
    closePopup() {
        const popup = document.getElementById("gmPopupContainerZoomPic");
        const imgEl = document.getElementById("zoomPicImage");
        if (popup) {
            popup.style.display = "none";
        }
        // Revoke blob URL to free memory
        if (this.currentBlobUrl) {
            URL.revokeObjectURL(this.currentBlobUrl);
            this.currentBlobUrl = null;
        }
        if (imgEl) {
            imgEl.src = "";
        }
    }
    /**
     * Fetch image via GM_xmlhttpRequest and display in popup.
     */
    fetchAndDisplayImage(url) {
        const popup = document.getElementById("gmPopupContainerZoomPic");
        const imgEl = document.getElementById("zoomPicImage");
        const loadingEl = document.getElementById("zoomPicLoading");
        if (!popup || !imgEl)
            return;
        // Show popup with loading indicator
        popup.style.display = "block";
        popup.style.left = "50px";
        popup.style.top = "50px";
        if (loadingEl)
            loadingEl.style.display = "block";
        imgEl.style.display = "none";
        // Cleanup previous blob URL if exists
        if (this.currentBlobUrl) {
            URL.revokeObjectURL(this.currentBlobUrl);
            this.currentBlobUrl = null;
        }
        GM_xmlhttpRequest({
            method: "GET",
            responseType: "blob",
            url: url,
            onload: (response) => {
                if (loadingEl)
                    loadingEl.style.display = "none";
                imgEl.style.display = "block";
                // Create blob URL - this works regardless of server MIME type
                this.currentBlobUrl = URL.createObjectURL(response.response);
                imgEl.src = this.currentBlobUrl;
            },
            onerror: () => {
                if (loadingEl)
                    loadingEl.style.display = "none";
                imgEl.style.display = "block";
                imgEl.alt = "Error loading image";
                console.error("[WazeMY] PluginZoomPic: Error loading image from", url);
            },
        });
    }
    /**
     * Enable plugin.
     *
     * @return {void} This function does not return anything.
     */
    enable() {
        console.log("[WazeMY] PluginZoomPic enabled.");
    }
    /**
     * Disable plugin.
     *
     * @return {void} This function does not return anything.
     */
    disable() {
        console.log("[WazeMY] PluginZoomPic disabled.");
    }
    /**
     * Updates the settings of the PluginZoomPic based on the provided settings object.
     *
     * @return {void} This function does not return anything.
     */
    updateSettings(settings) {
        console.log("[WazeMY] PluginZoomPic settings updated.", settings);
    }
}

;// ./src/utils/dateUtils.ts
/**
 * Formats a timestamp as a relative time string (e.g., "2 hours ago", "3 days ago").
 *
 * @param timestamp - Unix timestamp in milliseconds
 * @returns A human-readable relative time string
 */
function formatRelativeTime(timestamp) {
    const now = Date.now();
    const diff = now - timestamp;
    const seconds = Math.floor(diff / 1000);
    const minutes = Math.floor(seconds / 60);
    const hours = Math.floor(minutes / 60);
    const days = Math.floor(hours / 24);
    const weeks = Math.floor(days / 7);
    const months = Math.floor(days / 30);
    const years = Math.floor(days / 365);
    if (years > 0) {
        return years === 1 ? "1 year ago" : `${years} years ago`;
    }
    if (months > 0) {
        return months === 1 ? "1 month ago" : `${months} months ago`;
    }
    if (weeks > 0) {
        return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
    }
    if (days > 0) {
        return days === 1 ? "1 day ago" : `${days} days ago`;
    }
    if (hours > 0) {
        return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
    }
    if (minutes > 0) {
        return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
    }
    return "Just now";
}
/**
 * Formats a timestamp as a full date string for tooltips.
 *
 * @param timestamp - Unix timestamp in milliseconds
 * @returns A formatted date string (e.g., "Jan 15, 2025 3:45 PM")
 */
function formatFullDate(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString("en-US", {
        month: "short",
        day: "numeric",
        year: "numeric",
        hour: "numeric",
        minute: "2-digit",
        hour12: true,
    });
}

;// ./src/plugins/PluginGemini.ts


// Mapping from Gemini violation codes to WME rejection reason values
const VIOLATION_TO_WME_REASON = {
    IRRELEVANT_IMAGE: "7", // Not relevant / wrong place
    LOW_QUALITY: "3", // Low quality
    INAPPROPRIATE_CONTENT: "4", // Offensive
    PERSONAL_INFORMATION: "6", // Private / personal info
    SCREENSHOT_OF_MAP: "5", // Screenshot
    EXCESSIVE_TEXT_OR_OVERLAYS: "3", // Low quality (closest match)
    COPYRIGHTED_MATERIAL: "1", // Copyrighted
    ORIENTATION_OR_CROPPING_ISSUE: "3", // Low quality
    DUPLICATE_IMAGE: "2", // Duplicate
    OTHER_GENERAL_ISSUE: "8", // Other
};
class GeminiError extends Error {
    constructor(message, type) {
        super(message);
        this.type = type;
        this.name = "GeminiError";
    }
}
class PluginGemini {
    /**
     * Constructs a new instance of the PluginGemini class.
     * Initializes the WME SDK with the specified script ID and name,
     * and calls the initialize method to set up the plugin.
     */
    constructor() {
        this.lastEvaluatedImageSrc = null;
        this.evaluateDebounceTimer = null;
        this.lastEvaluationResult = null;
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-gemini",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    /**
     * Initialize plugin.
     *
     * @return {void} This function does not return anything.
     */
    initialize() {
        const settingsHTML = `
      <div>
        <input type="checkbox" id="wazemySettings_gemini_enable"/>
        <label for="wazemySettings_gemini_enable">Enable Gemini integration</label>
      </div>
    `;
        $("#wazemySettings_settings").append(settingsHTML);
        $("#wazemySettings_gemini_enable").on("change", () => {
            PluginManager.instance.updatePluginSettings("gemini", {
                enable: $("#wazemySettings_gemini_enable").prop("checked"),
            });
        });
        const geminiAPIKeySettings = `
      <div>
        <label for="wazemySettings_gemini_apiKey">API Key</label>
        <input type="password" id="wazemySettings_gemini_apiKey" placeholder="Enter Gemini API Key"/>
        <wz-button id="wazemySettings_gemini_saveApiKey" class="wazemySettingsButton" style="padding:3px">
          Save
        </wz-button>
        <div style="font-size: 10px; margin-top: 5px;">
          Get your API key from <a href="https://aistudio.google.com/" target="_blank">Google AI Studio</a>.
        </div>
      </div>
    `;
        $("#wazemySettings_gemini").append(geminiAPIKeySettings);
        $("#wazemySettings_gemini_saveApiKey").on("click", () => {
            PluginManager.instance.updatePluginSettings("gemini", {
                geminiApiKey: $("#wazemySettings_gemini_apiKey").val(),
            });
        });
        // Set settings according to last stored value.
        const savedSettings = SettingsStorage.instance.getSetting("gemini");
        if (savedSettings?.enable === true) {
            $("#wazemySettings_gemini_enable").prop("checked", true);
        }
        else {
            $("#wazemySettings_gemini_enable").prop("checked", false);
        }
        if (savedSettings?.geminiApiKey) {
            $("#wazemySettings_gemini_apiKey").val(savedSettings.geminiApiKey);
            this.geminiApiKey = savedSettings.geminiApiKey;
        }
        // let aiAnswer = this.getGeminiTextResponse("How AI does work?");
        // console.log("[WazeMY] Gemini AI Answer:", aiAnswer);
        this.initializeVenueUpdateRequestImageHelper();
        console.log("[WazeMY] PluginGemini initialized.");
    }
    /**
     * Enable plugin.
     *
     * @return {void} This function does not return anything.
     */
    enable() {
        console.log("[WazeMY] PluginGemini enabled.");
    }
    /**
     * Disable plugin.
     *
     * @return {void} This function does not return anything.
     */
    disable() {
        console.log("[WazeMY] PluginGemini disabled.");
    }
    /**
     * Updates the settings of the PluginGemini based on the provided settings object.
     *
     * @return {void} This function does not return anything.
     */
    updateSettings(settings) {
        if (settings.enable === true) {
            this.enable();
        }
        else if (settings.enable === false) {
            this.disable();
        }
        if (settings.geminiApiKey !== undefined) {
            this.geminiApiKey = settings.geminiApiKey;
        }
        console.log("[WazeMY] PluginGemini settings updated.");
    }
    /**
     * Converts an ArrayBuffer to a base64 encoded string.
     */
    arrayBufferToBase64(buffer) {
        const bytes = new Uint8Array(buffer);
        let binary = "";
        for (let i = 0; i < bytes.byteLength; i++) {
            binary += String.fromCharCode(bytes[i]);
        }
        return btoa(binary);
    }
    /**
     * Initialize the helper for venue update request image evaluation.
     */
    initializeVenueUpdateRequestImageHelper() {
        const onload_base64image = (response) => {
            const base64data = this.arrayBufferToBase64(response.response);
            this.getGeminiPictureEvaluation(base64data).then((evaluation) => {
                const evaluationText = JSON.parse(evaluation);
                this.lastEvaluationResult = evaluationText;
                $("#gemini").replaceWith(`<div id='gemini' class="changes"><b>Gemini image evaluation: ${evaluationText.suggestion}</b><br><i>${evaluationText.reason}</i><br></div>`);
                if (evaluationText.suggestion === "Reject") {
                    $("#gemini").append(`<b>Violations:</b><ul>${evaluationText.violations.map((v) => `<li>${v}</li>`).join("")}</ul>`);
                }
            });
        };
        const evaluateImage = (displayAfterElement) => {
            const imagePreview = $(".image-preview");
            const geminiElement = $("#gemini");
            if (imagePreview.length > 0 && geminiElement.length === 0) {
                const src = imagePreview.attr("src");
                if (src === this.lastEvaluatedImageSrc) {
                    return;
                }
                this.lastEvaluatedImageSrc = src;
                $(displayAfterElement).after("<div id='gemini' class='changes'><i>Gemini is evaluating...</i><br></div>");
                GM_xmlhttpRequest({
                    method: "GET",
                    url: src,
                    responseType: "arraybuffer",
                    onload: onload_base64image.bind(this),
                });
            }
        };
        $(document.body).on("click", (_e) => {
            if (this.evaluateDebounceTimer) {
                clearTimeout(this.evaluateDebounceTimer);
            }
            this.evaluateDebounceTimer = setTimeout(() => {
                evaluateImage("div.changes");
            }, 300);
        });
    }
    getGeminiTextResponse(context) {
        return new Promise((resolve, reject) => {
            if (!this.geminiApiKey) {
                reject(new Error("Gemini API key is not set."));
                return;
            }
            const model = "gemini-2.5-flash";
            const message = {
                contents: [{ parts: [{ text: context }] }],
                generationConfig: { thinkingConfig: { thinkingBudget: 0 } },
            };
            GM_xmlhttpRequest({
                method: "POST",
                url: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.geminiApiKey}`,
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(message),
                onload: function (response) {
                    try {
                        const data = JSON.parse(response.responseText);
                        // console.log(data["candidates"][0]["content"]["parts"][0]["text"]);
                        resolve(data["candidates"][0]["content"]["parts"][0]["text"]);
                    }
                    catch (e) {
                        console.error(new Error("Failed to parse response: " + e.message));
                        reject(new Error(`Failed to parse Gemini response: ${e.message}`));
                    }
                },
            });
        });
    }
    getGeminiPictureEvaluation(base64ImageData) {
        return new Promise((resolve, reject) => {
            if (!this.geminiApiKey) {
                reject(new Error("Gemini API key is not set."));
                return;
            }
            const model = "gemini-2.5-flash";
            const prompt = `You are an AI assistant specialized in Waze Map Editor (WME) venue image moderation.

Your task is to evaluate an uploaded image of a Waze venue against Waze Map Editor's official guidelines for venue images. You will determine if the image should be 'Approved' or 'Rejected' and provide a clear, concise justification, along with specific guideline violations if rejected.

I will provide you with an image for evaluation.

Respond with a JSON object with the following fields:
- "suggestion": "Approve" or "Reject"
- "reason": Concise explanation for the decision
- "violations": Array of violation codes if rejected. Valid codes: "IRRELEVANT_IMAGE", "LOW_QUALITY", "INAPPROPRIATE_CONTENT", "PERSONAL_INFORMATION", "SCREENSHOT_OF_MAP", "EXCESSIVE_TEXT_OR_OVERLAYS", "COPYRIGHTED_MATERIAL", "ORIENTATION_OR_CROPPING_ISSUE", "DUPLICATE_IMAGE", "OTHER_GENERAL_ISSUE"

Waze Venue Image Guidelines to Consider:

1.  **Relevance:** The image *must* clearly depict the venue/business itself (e.g., its entrance, sign, storefront). No random objects, people (unless part of a large crowd at an event, but generally avoided), or irrelevant scenery.
2.  **Quality:** Images must be clear, well-lit, in focus, and not blurry, pixelated, overexposed, or excessively dark.
3.  **Appropriateness:** No offensive, violent, sexually explicit, or hateful content.
4.  **No Personal Information:** Avoid identifiable faces (especially children), license plates, or other sensitive personal data unless it's an unchangeable part of the venue's permanent signage.
5.  **No Map Screenshots:** Do not approve images that are screenshots of Waze, Google Maps, or any other navigation/map application.
6.  **Minimal Text/Overlays:** Avoid images with excessive text, watermarks, promotional overlays, or graphic elements that are not part of the venue's physical branding/signage. A clear logo on a sign is generally fine; a flyer overlay is not.
7.  **Copyright:** Avoid copyrighted images without explicit permission (AI should err on the side of caution).
8.  **Focus:** The primary subject of the image should be the venue.
9.  **Orientation:** Landscape orientation is generally preferred for display, but a good quality portrait image of a tall building is acceptable if it clearly shows the venue. Poor rotation is a rejection reason.

Decision Logic:

* If the image adheres to all the above guidelines, set 'decision' to "Approved".
* If the image violates one or more guidelines, set 'decision' to "Rejected" and list *all* applicable violation codes in the 'violations' array.`;
            const message = {
                contents: [
                    {
                        parts: [
                            {
                                inlineData: {
                                    mimeType: "image/jpeg",
                                    data: base64ImageData,
                                },
                            },
                            {
                                text: prompt,
                            },
                        ],
                    },
                ],
                generationConfig: {
                    thinkingConfig: { thinkingBudget: 0 },
                    responseMimeType: "application/json",
                },
            };
            GM_xmlhttpRequest({
                method: "POST",
                url: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${this.geminiApiKey}`,
                headers: { "Content-Type": "application/json" },
                data: JSON.stringify(message),
                onload: function (response) {
                    try {
                        const data = JSON.parse(response.responseText);
                        // Check for API error response
                        if (data.error) {
                            console.error("[WazeMY] Gemini API error:", data.error);
                            const errorMsg = data.error.message || "";
                            const errorStatus = data.error.status || "";
                            // Detect quota/rate limit errors
                            if (errorStatus === "RESOURCE_EXHAUSTED" ||
                                errorMsg.toLowerCase().includes("quota") ||
                                errorMsg.toLowerCase().includes("rate limit") ||
                                response.status === 429) {
                                reject(new GeminiError("API quota exceeded", "quota"));
                                return;
                            }
                            // Detect API key errors (be more specific to avoid false positives)
                            if (errorStatus === "UNAUTHENTICATED" ||
                                errorMsg.toLowerCase().includes("api key")) {
                                reject(new GeminiError("Invalid API key", "api_key"));
                                return;
                            }
                            // Detect image processing errors
                            if (errorStatus === "INVALID_ARGUMENT" &&
                                errorMsg.toLowerCase().includes("image")) {
                                reject(new GeminiError("Invalid image: " + errorMsg, "unknown"));
                                return;
                            }
                            reject(new GeminiError(`Gemini API error: ${data.error.message || JSON.stringify(data.error)}`, "unknown"));
                            return;
                        }
                        if (!data.candidates?.[0]?.content?.parts?.[0]?.text) {
                            console.error("[WazeMY] Unexpected Gemini response:", data);
                            reject(new GeminiError("Unexpected Gemini response structure", "unknown"));
                            return;
                        }
                        resolve(data["candidates"][0]["content"]["parts"][0]["text"]);
                    }
                    catch (e) {
                        console.error("[WazeMY] Failed to parse Gemini response:", response.responseText);
                        reject(new GeminiError(`Failed to parse Gemini response: ${e.message}`, "unknown"));
                    }
                },
                onerror: function (error) {
                    console.error("[WazeMY] Gemini request failed:", error);
                    reject(new GeminiError(`Network error: ${error}`, "network"));
                },
            });
        });
    }
    /**
     * Evaluates an image from a URL using Gemini AI.
     * This method can be called by other plugins (e.g., PluginPlaces).
     *
     * @param imageUrl - The URL of the image to evaluate
     * @returns Promise resolving to the evaluation result
     */
    evaluateImageFromUrl(imageUrl) {
        return new Promise((resolve, reject) => {
            if (!this.geminiApiKey) {
                reject(new Error("Gemini API key is not set."));
                return;
            }
            GM_xmlhttpRequest({
                method: "GET",
                url: imageUrl,
                responseType: "arraybuffer",
                onload: (response) => {
                    if (response.status >= 400) {
                        reject(new GeminiError(`Image fetch failed: HTTP ${response.status}`, "network"));
                        return;
                    }
                    const base64data = this.arrayBufferToBase64(response.response);
                    this.getGeminiPictureEvaluation(base64data)
                        .then((evaluation) => {
                        const result = JSON.parse(evaluation);
                        resolve(result);
                    })
                        .catch(reject);
                },
                onerror: (error) => {
                    reject(new GeminiError(`Failed to fetch image: ${error}`, "network"));
                },
            });
        });
    }
    /**
     * Checks if the Gemini API key is configured.
     */
    isConfigured() {
        return !!this.geminiApiKey;
    }
}

;// ./src/plugins/PluginPlaces.ts




const VENUE_IMAGE_BASE_URL = "https://venue-image.waze.com";
const GEMINI_ERROR_INFO = {
    quota: { icon: "Q", tooltip: "Gemini API quota exceeded - try again later" },
    api_key: { icon: "K", tooltip: "Invalid Gemini API key - check settings" },
    network: { icon: "N", tooltip: "Network error - check your connection" },
    unknown: {
        icon: "!",
        tooltip: "Evaluation failed - check console for details",
    },
};
class PluginPlaces {
    constructor() {
        this.sidebarElements = null;
        this.tabHTML = `
    <div><h4>WazeMY Places</h4></div>
    <div id="wazemyPlaces">
      <select name="wazemyPlaces_polygons" id="wazemyPlaces_polygons"></select>
      <button id="wazemyPlaces_scan">Scan</button>
      <div id="wazemyPlaces_scanStatus"></div>
      <div id="wazemyPlaces_purCount"></div>
      <div id="wazemyPlaces_totalCount"></div>
      <div id="wazemyPlaces_table">
      <table id="wazemyPlaces_venues">
        <thead>
          <tr>
            <th title="I=Image\nN=New Place\nU=Update\nF=Flag\nD=Delete">PUR</th>
            <th>Date</th>
            <th>L</th>
            <th>Name</th>
            <th>Errors</th>
            <th title="Gemini AI evaluation for image PURs">AI</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
      </div>
    </div>
  `;
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-places",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    /**
     * Initialize plugin.
     *
     * @return {void} This function does not return anything.
     */
    initialize() {
        const settingsHTML = `<div><input type="checkbox" id="wazemySettings_places_enable"/>
      <label for="wazemySettings_places_enable">Enable Places</label></div>`;
        $("#wazemySettings_settings").append(settingsHTML);
        $("#wazemySettings_places_enable").on("change", () => {
            PluginManager.instance.updatePluginSettings("places", {
                enable: $("#wazemySettings_places_enable").prop("checked"),
            });
        });
        // Set settings according to last stored value.
        const savedSettings = SettingsStorage.instance.getSetting("places");
        if (savedSettings?.enable === true) {
            $("#wazemySettings_places_enable").prop("checked", true);
        }
        else {
            $("#wazemySettings_places_enable").prop("checked", false);
        }
        console.log("[WazeMY] PluginPlaces initialized.");
    }
    /**
     * Enable plugin.
     *
     * @return {void} This function does not return anything.
     */
    enable() {
        this.sdk.Sidebar.registerScriptTab().then((sidebarResult) => {
            this.sidebarElements = sidebarResult;
            sidebarResult.tabLabel.innerHTML = "WazeMY Places";
            sidebarResult.tabLabel.title = "WazeMY Places";
            sidebarResult.tabPane.innerHTML = this.tabHTML;
            // Populate select options with polygons from KVMR.
            const kvmrLayer = PluginManager.instance.getLayer("__KlangValley");
            if (kvmrLayer) {
                kvmrLayer.features.forEach((feature) => {
                    $("#wazemyPlaces_polygons").append($("<option>", {
                        value: feature.data.number,
                        text: feature.data.number,
                    }));
                });
            }
            // Handle Scan button.
            $("#wazemyPlaces_scan").on("click", async () => {
                const pluginSdk = this.sdk;
                $("#wazemyPlaces_scanStatus").text("Scanning tiles.");
                $("#wazemyPlaces_venues > tbody").empty();
                const kvmrLayer = PluginManager.instance.getLayer("__KlangValley");
                if (!kvmrLayer) {
                    console.log("[PluginPlaces] No KVMR layer found. Aborting scan.");
                    return false;
                }
                const mr = kvmrLayer.getFeaturesByAttribute("number", $("#wazemyPlaces_polygons option:selected")[0].innerText);
                if (mr.length === 0) {
                    console.log("[PluginPlaces] No polygon found. Aborting scan.");
                    return false;
                }
                const feature = mr[0];
                let bounds = feature.geometry.getBounds().clone();
                const webMercator = new OpenLayers.Projection("EPSG:900913");
                const wgs84 = new OpenLayers.Projection("EPSG:4326");
                bounds = bounds.transform(webMercator, wgs84);
                const venues = await getAllVenues(bounds);
                // Helper functions defined once
                function evaluateVenue(venue) {
                    let status = {
                        priority: 0,
                        errors: [],
                    };
                    // Rule #1
                    if (typeof venue.name == "undefined") {
                        if (!venue.categories.includes("RESIDENCE_HOME")) {
                            status.priority = 3;
                            status.errors.push("Missing name.");
                        }
                    }
                    else {
                        // Rule: Check name for all uppercase.
                        if (venue.name === venue.name.toUpperCase()) {
                            status.priority = 3;
                            status.errors.push("Name is uppercase.");
                        }
                        // Rule: Check name for all lowercase.
                        if (venue.name === venue.name.toLowerCase()) {
                            status.priority = 3;
                            status.errors.push("Name is lowercase.");
                        }
                    }
                    // Rule: Min lock is not set.
                    if (venue.lockRank === 0) {
                        status.priority = 3;
                        status.errors.push("Min lock not set.");
                    }
                    // Rule: Phone number format.
                    if (venue.phone) {
                        if (/^[\d]{3}-[\d]{3} [\d]{4}$/.test(venue.phone) === false &&
                            /^[\d]{3}-[\d]{4} [\d]{4}$/.test(venue.phone) === false &&
                            /^[\d]{2}-[\d]{4} [\d]{4}$/.test(venue.phone) === false &&
                            /^[\d]{2}-[\d]{3} [\d]{4}$/.test(venue.phone) === false &&
                            /^[\d]{3}-[\d]{3} [\d]{3}$/.test(venue.phone) === false &&
                            /^[\d]{1}-[\d]{3}-[\d]{2}-[\d]{4}$/.test(venue.phone) === false) {
                            status.priority = 2;
                            status.errors.push("Phone number format incorrect.");
                        }
                    }
                    // Rule: Category specific rank locks.
                    if ((venue.categories.includes("CHARGING_STATION") &&
                        venue.lockRank < 3) ||
                        (venue.categories.includes("GAS_STATION") && venue.lockRank < 3) ||
                        (venue.categories.includes("AIRPORT") && venue.lockRank < 4) ||
                        (venue.categories.includes("BUS_STATION") && venue.lockRank < 2) ||
                        (venue.categories.includes("FERRY_PIER") && venue.lockRank < 2) ||
                        (venue.categories.includes("JUNCTION_INTERCHANGE") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("REST_AREAS") && venue.lockRank < 2) ||
                        (venue.categories.includes("SEAPORT_MARINA_HARBOR") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("TRAIN_STATION") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("TUNNEL") && venue.lockRank < 2) ||
                        (venue.categories.includes("CITY_HALL") && venue.lockRank < 2) ||
                        (venue.categories.includes("COLLEGE_UNIVERSITY") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("COURTHOUSE") && venue.lockRank < 2) ||
                        (venue.categories.includes("DOCTOR_CLINIC") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("EMBASSY_CONSULATE") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("FIRE_DEPARTMENT") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("HOSPITAL_URGENT_CARE") &&
                            venue.lockRank < 3) ||
                        (venue.categories.includes("LIBRARY") && venue.lockRank < 2) ||
                        (venue.categories.includes("MILITARY") && venue.lockRank < 3) ||
                        (venue.categories.includes("POLICE_STATION") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("PRISON_CORRECTIONAL_FACILITY") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("RELIGIOUS_CENTER") &&
                            venue.lockRank < 3) ||
                        (venue.categories.includes("SCHOOL") && venue.lockRank < 2) ||
                        (venue.categories.includes("BANK_FINANCIAL") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("SHOPPING_CENTER") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("MUSEUM") && venue.lockRank < 2) ||
                        (venue.categories.includes("RACING_TRACK") && venue.lockRank < 2) ||
                        (venue.categories.includes("STADIUM_ARENA") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("THEME_PARK") && venue.lockRank < 2) ||
                        (venue.categories.includes("TOURIST_ATTRACTION_HISTORIC_SITE") &&
                            venue.lockRank < 2) ||
                        (venue.categories.includes("ZOO_AQUARIUM") && venue.lockRank < 2) ||
                        (venue.categories.includes("BEACH") && venue.lockRank < 2) ||
                        (venue.categories.includes("GOLF_COURSE") && venue.lockRank < 2) ||
                        (venue.categories.includes("PARK") && venue.lockRank < 2) ||
                        (venue.categories.includes("FOREST_GROVE") && venue.lockRank < 2) ||
                        (venue.categories.includes("ISLAND") && venue.lockRank < 4) ||
                        (venue.categories.includes("RIVER_STREAM") && venue.lockRank < 3) ||
                        (venue.categories.includes("SEA_LAKE_POOL") &&
                            venue.lockRank < 5) ||
                        (venue.categories.includes("CANAL") && venue.lockRank < 2) ||
                        (venue.categories.includes("SWAMP_MARSH") && venue.lockRank < 2)) {
                        status.priority = 2;
                        status.errors.push("Min lock incorrect.");
                    }
                    return status;
                }
                function checkPURstatus(venue) {
                    return venue.venueUpdateRequests?.length > 0;
                }
                function getPURDate(venue) {
                    if (venue.venueUpdateRequests?.length > 0) {
                        // Try dateAdded first, fallback to createdOn
                        return (venue.venueUpdateRequests[0].dateAdded ||
                            venue.venueUpdateRequests[0].createdOn ||
                            null);
                    }
                    return null;
                }
                const processedVenues = [];
                venues.forEach((venue) => {
                    const status = evaluateVenue(venue);
                    const isPUR = checkPURstatus(venue);
                    const isImagePUR = isPUR && venue.venueUpdateRequests?.[0]?.type === "IMAGE";
                    if (status.priority > 0 || isPUR) {
                        let lon = 0;
                        let lat = 0;
                        if (venue.geometry.type === "Polygon") {
                            lon = venue.geometry.coordinates[0][0][0];
                            lat = venue.geometry.coordinates[0][0][1];
                        }
                        else {
                            lon = venue.geometry.coordinates[0];
                            lat = venue.geometry.coordinates[1];
                        }
                        // Get image URL for IMAGE PURs
                        let imageUrl;
                        if (isImagePUR) {
                            const pur = venue.venueUpdateRequests?.[0];
                            // Find the unapproved image in venue.images that matches the PUR
                            const pendingImage = venue.images?.find((img) => img.id === pur?.id || img.approved === false);
                            if (pendingImage?.id) {
                                imageUrl = `${VENUE_IMAGE_BASE_URL}/${pendingImage.id}`;
                            }
                        }
                        processedVenues.push({
                            venue,
                            status,
                            isPUR,
                            isImagePUR,
                            purDate: getPURDate(venue),
                            lon,
                            lat,
                            imageUrl,
                        });
                    }
                });
                // Sort: PURs first (newest to oldest), then non-PURs
                processedVenues.sort((a, b) => {
                    // PURs come first
                    if (a.isPUR && !b.isPUR)
                        return -1;
                    if (!a.isPUR && b.isPUR)
                        return 1;
                    // Both are PURs: sort by date descending (newest first)
                    if (a.isPUR && b.isPUR) {
                        const dateA = a.purDate || 0;
                        const dateB = b.purDate || 0;
                        return dateB - dateA;
                    }
                    // Both are non-PURs: keep original order
                    return 0;
                });
                // Evaluate IMAGE PURs with Gemini AI
                const geminiPlugin = PluginManager.instance.getPlugin("gemini");
                const imagePURs = processedVenues.filter((pv) => pv.isImagePUR && pv.imageUrl);
                if (geminiPlugin?.isConfigured() && imagePURs.length > 0) {
                    let quotaExceeded = false;
                    let evaluated = 0;
                    // Evaluate images sequentially to detect quota errors early
                    for (const pv of imagePURs) {
                        if (quotaExceeded) {
                            // Mark remaining venues as quota-limited
                            pv.geminiError = "quota";
                            continue;
                        }
                        evaluated++;
                        $("#wazemyPlaces_scanStatus").text(`Evaluating image ${evaluated}/${imagePURs.length} with Gemini...`);
                        // Small delay between requests to avoid rate limiting
                        if (evaluated > 1) {
                            await new Promise((resolve) => setTimeout(resolve, 500));
                        }
                        try {
                            const result = await geminiPlugin.evaluateImageFromUrl(pv.imageUrl);
                            pv.geminiResult = result;
                        }
                        catch (error) {
                            console.error(`[WazeMY] Gemini evaluation failed for ${pv.venue.name}:`, error);
                            // Check if this is a quota error
                            if (error instanceof GeminiError) {
                                pv.geminiError = error.type;
                                if (error.type === "quota") {
                                    quotaExceeded = true;
                                    console.warn("[WazeMY] Gemini quota exceeded, skipping remaining evaluations");
                                }
                            }
                            else {
                                pv.geminiError = "unknown";
                            }
                        }
                    }
                    if (quotaExceeded) {
                        $("#wazemyPlaces_scanStatus").text(`Gemini quota exceeded. Evaluated ${evaluated - 1}/${imagePURs.length} images.`);
                    }
                }
                // Render sorted venues
                let purCount = 0;
                let totalCount = 0;
                processedVenues.forEach((pv) => {
                    const { venue, status, isPUR, purDate, lon, lat } = pv;
                    const row = $("<tr>");
                    row.attr("id", `${lon}:${lat}:${venue.id}`);
                    row.on("click", (e) => {
                        const target = e.currentTarget.id.split(":"); // split to lon:lat:id
                        this.sdk.Map.setMapCenter({
                            lonLat: {
                                lon: parseFloat(target[0]),
                                lat: parseFloat(target[1]),
                            },
                        });
                    });
                    // PUR type column
                    let purHTML = ``;
                    if (isPUR) {
                        purCount++;
                        if (venue.approved === false) {
                            purHTML = `<td align="center">N</td>`;
                        }
                        else if (venue.venueUpdateRequests[0].type === "REQUEST") {
                            if (venue.venueUpdateRequests[0].subType === "FLAG") {
                                purHTML = `<td align="center">F</td>`;
                            }
                            else if (venue.venueUpdateRequests[0].subType === "UPDATE") {
                                purHTML = `<td align="center">U</td>`;
                            }
                            else if (venue.venueUpdateRequests[0].subType === "DELETE") {
                                purHTML = `<td align="center">D</td>`;
                            }
                            else {
                                purHTML = `<td align="center">+</td>`;
                            }
                        }
                        else if (venue.venueUpdateRequests[0].type === "IMAGE") {
                            purHTML = `<td align="center">I</td>`;
                        }
                        else {
                            purHTML = `<td align="center">+</td>`;
                        }
                    }
                    else {
                        purHTML = `<td></td>`;
                    }
                    row.append(purHTML);
                    // Date column
                    let dateHTML = `<td></td>`;
                    if (isPUR && purDate) {
                        const relativeTime = formatRelativeTime(purDate);
                        const fullDate = formatFullDate(purDate);
                        dateHTML = `<td title="${fullDate}">${relativeTime}</td>`;
                    }
                    row.append(dateHTML);
                    const levelHTML = `<td>${venue.lockRank ? venue.lockRank + 1 : 1}</td>`;
                    row.append(levelHTML);
                    const colHTML = `<td>${venue.name}</td>`;
                    row.append(colHTML);
                    const errorsHTML = `<td>${status.errors.join("\r\n")}</td>`;
                    row.append(errorsHTML);
                    // AI column for Gemini evaluation
                    let aiHTML = `<td></td>`;
                    if (pv.isImagePUR && pv.geminiResult) {
                        const suggestion = pv.geminiResult.suggestion;
                        const reason = pv.geminiResult.reason;
                        if (suggestion === "Reject") {
                            const violations = pv.geminiResult.violations || [];
                            const primaryViolation = violations[0] || "OTHER_GENERAL_ISSUE";
                            aiHTML = `<td class="wazemyPlaces_ai_reject" title="${reason}">
                <span>✗</span>
                <button class="wazemyPlaces_quickReject"
                  data-venue-id="${venue.id}"
                  data-violation="${primaryViolation}"
                  title="Quick Reject: ${violations.join(", ")}">
                  Reject
                </button>
              </td>`;
                        }
                        else {
                            aiHTML = `<td class="wazemyPlaces_ai_approve" title="${reason}">✓</td>`;
                        }
                    }
                    else if (pv.isImagePUR && pv.geminiError) {
                        // Show specific error indicators
                        const info = GEMINI_ERROR_INFO[pv.geminiError] || GEMINI_ERROR_INFO.unknown;
                        aiHTML = `<td class="wazemyPlaces_ai_error" title="${info.tooltip}">${info.icon}</td>`;
                    }
                    else if (pv.isImagePUR && !pv.geminiResult) {
                        // No evaluation attempted
                        let tooltip = "Gemini evaluation not available";
                        if (!geminiPlugin) {
                            tooltip = "Gemini plugin not loaded";
                        }
                        else if (!geminiPlugin.isConfigured()) {
                            tooltip = "Gemini API key not configured - add key in settings";
                        }
                        else if (!pv.imageUrl) {
                            tooltip = "No image URL found in PUR data";
                        }
                        aiHTML = `<td class="wazemyPlaces_ai_none" title="${tooltip}">-</td>`;
                    }
                    row.append(aiHTML);
                    $("#wazemyPlaces_venues > tbody").append(row);
                    totalCount++;
                });
                // Attach Quick Reject button handlers
                $(".wazemyPlaces_quickReject").on("click", function (e) {
                    e.stopPropagation(); // Prevent row click from triggering
                    const button = $(this);
                    const venueId = button.data("venue-id");
                    const violation = button.data("violation");
                    performQuickReject(venueId, violation, button);
                });
                // Quick reject function
                function performQuickReject(venueId, violation, button) {
                    const wmeReasonValue = VIOLATION_TO_WME_REASON[violation] || "8";
                    // Find and select the venue in WME to open its panel
                    const venue = processedVenues.find((pv) => pv.venue.id === venueId);
                    if (!venue) {
                        console.log("[WazeMY] Could not find venue for quick reject.");
                        return;
                    }
                    // Center map on venue first
                    pluginSdk.Map.setMapCenter({
                        lonLat: { lon: venue.lon, lat: venue.lat },
                    });
                    // Disable button and show progress
                    button.prop("disabled", true).text("...");
                    // Wait for map to center, then try to click the PUR and reject
                    setTimeout(() => {
                        // Try to find and click the reject button in WME's PUR panel
                        const rejectButton = $('wz-button[color="secondary"]:contains("Reject"), ' +
                            "wz-button.reject-button, " +
                            'button:contains("Reject")').first();
                        if (rejectButton.length > 0) {
                            rejectButton[0].click();
                            // Wait for dialog, then select reason and submit
                            setTimeout(() => {
                                const reasonSelect = $('wz-select[name="annotationType"], ' +
                                    'select[name="annotationType"], ' +
                                    ".rejection-reason select").first();
                                if (reasonSelect.length > 0) {
                                    const selectElement = reasonSelect[0];
                                    if (selectElement.tagName.toLowerCase() === "wz-select") {
                                        selectElement.value = wmeReasonValue;
                                        selectElement.dispatchEvent(new Event("change", { bubbles: true }));
                                    }
                                    else {
                                        selectElement.value = wmeReasonValue;
                                        $(selectElement).trigger("change");
                                    }
                                }
                                // Click submit
                                setTimeout(() => {
                                    const submitButton = $('wz-button:contains("Submit"), ' +
                                        'wz-button:contains("Confirm"), ' +
                                        'wz-button[color="primary"]:visible').first();
                                    if (submitButton.length > 0) {
                                        submitButton[0].click();
                                        button.text("Done").addClass("wazemyPlaces_rejected");
                                    }
                                    else {
                                        button.prop("disabled", false).text("Retry");
                                    }
                                }, 200);
                            }, 300);
                        }
                        else {
                            console.log("[WazeMY] Could not find WME reject button.");
                            button.prop("disabled", false).text("Retry");
                        }
                    }, 500);
                }
                $("#wazemyPlaces_purCount").text(`# PUR = ${purCount}`);
                $("#wazemyPlaces_totalCount").text(`# total = ${totalCount}`);
                $("#wazemyPlaces_scanStatus").text("");
                async function getAllVenues(bounds) {
                    let venues = [];
                    // console.log(bounds);
                    const baseURL = "https://www.waze.com/row-Descartes/app/Features?language=en&v=2&cameras=true&mapComments=true&roadClosures=true&roadTypes=1%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C15%2C16%2C17%2C18%2C19%2C20%2C22&venueLevel=4&venueFilter=1%2C1%2C1%2C1&";
                    let urls = [];
                    const stepSize = 0.1;
                    for (let left = bounds.left; left <= bounds.right; left += stepSize) {
                        for (let bottom = bounds.bottom; bottom <= bounds.top; bottom += stepSize) {
                            urls.push(`bbox=${left}%2C${bottom}%2C${left + stepSize > bounds.right ? bounds.right : left + stepSize}%2C${bottom + stepSize > bounds.top ? bounds.top : bottom + stepSize}`);
                        }
                    }
                    for (let i = 0; i < urls.length; i++) {
                        // console.log(baseURL + urls[i]);
                        $("#wazemyPlaces_scanStatus").text(`Scanning tile ${i + 1} of ${urls.length}.`);
                        const result = await GM.xmlHttpRequest({
                            method: "GET",
                            responseType: "json",
                            url: baseURL + urls[i],
                        }).catch((e) => console.error(e));
                        venues = venues.concat(result.response.venues.objects);
                    }
                    return venues;
                }
            });
            console.log("[WazeMY] PluginPlaces enabled.");
        });
    }
    /**
     * Disable plugin.
     *
     * @return {void} This function does not return anything.
     */
    disable() {
        if (this.sidebarElements) {
            this.sidebarElements.tabLabel.remove();
            this.sidebarElements.tabPane.remove();
            this.sidebarElements = null;
        }
        console.log("[WazeMY] PluginPlaces disabled.");
    }
    /**
     * Updates the settings of the PluginPlaces based on the provided settings object.
     *
     * @return {void} This function does not return anything.
     */
    updateSettings(settings) {
        if (settings.enable === true) {
            this.enable();
        }
        else if (settings.enable === false) {
            this.disable();
        }
        console.log("[WazeMY] PluginPlaces settings updated.", settings);
    }
}

;// ./src/plugins/PluginURs.ts



class PluginURs {
    constructor() {
        this.sidebarElements = null;
        this.mapDataLoadedHandler = null;
        this.tabHTML = `
    <div><h4>WazeMY URs</h4></div>
    <div id="wazemyURs">
      <button id="wazemyURs_refresh">Refresh</button>
      <div id="wazemyURs_status"></div>
      <div id="wazemyURs_count"></div>
      <div id="wazemyURs_table">
      <table id="wazemyURs_list">
        <thead>
          <tr>
            <th>Type</th>
            <th>Sev</th>
            <th>Reported</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
      </div>
    </div>
  `;
        this.sdk = unsafeWindow.getWmeSdk({
            scriptId: "wme-wazemy-urs",
            scriptName: "WazeMY",
        });
        this.initialize();
    }
    /**
     * Initialize plugin.
     *
     * @return {void} This function does not return anything.
     */
    initialize() {
        const settingsHTML = `<div><input type="checkbox" id="wazemySettings_urs_enable"/>
      <label for="wazemySettings_urs_enable">Enable URs Panel</label></div>`;
        $("#wazemySettings_settings").append(settingsHTML);
        $("#wazemySettings_urs_enable").on("change", () => {
            PluginManager.instance.updatePluginSettings("urs", {
                enable: $("#wazemySettings_urs_enable").prop("checked"),
            });
        });
        // Set settings according to last stored value.
        const savedSettings = SettingsStorage.instance.getSetting("urs");
        if (savedSettings?.enable === true) {
            $("#wazemySettings_urs_enable").prop("checked", true);
        }
        else {
            $("#wazemySettings_urs_enable").prop("checked", false);
        }
        console.log("[WazeMY] PluginURs initialized.");
    }
    /**
     * Enable plugin.
     *
     * @return {void} This function does not return anything.
     */
    enable() {
        this.sdk.Sidebar.registerScriptTab().then((sidebarResult) => {
            this.sidebarElements = sidebarResult;
            sidebarResult.tabLabel.innerHTML = "WazeMY URs";
            sidebarResult.tabLabel.title = "WazeMY URs";
            sidebarResult.tabPane.innerHTML = this.tabHTML;
            // Handle Refresh button.
            $("#wazemyURs_refresh").on("click", () => {
                this.refreshURList();
            });
            // Auto-refresh on map data loaded.
            this.mapDataLoadedHandler = () => {
                this.refreshURList();
            };
            this.sdk.Events.on({
                eventName: "wme-map-data-loaded",
                eventHandler: this.mapDataLoadedHandler,
            });
            // Initial load
            this.refreshURList();
            console.log("[WazeMY] PluginURs enabled.");
        });
    }
    /**
     * Refresh the UR list with current data.
     */
    refreshURList() {
        $("#wazemyURs_status").text("Loading URs...");
        $("#wazemyURs_list > tbody").empty();
        const allURs = this.sdk.DataModel.MapUpdateRequests.getAll();
        // Filter to only editable URs
        const editableURs = allURs.filter((ur) => ur.isEditable);
        // Sort by reportedOn descending (newest first)
        editableURs.sort((a, b) => b.reportedOn - a.reportedOn);
        let count = 0;
        editableURs.forEach((ur) => {
            const row = $("<tr>");
            row.attr("data-ur-id", ur.id.toString());
            row.addClass("wazemyURs_row");
            // Store geometry for click handler
            const lon = ur.geometry.coordinates[0];
            const lat = ur.geometry.coordinates[1];
            row.attr("data-lon", lon.toString());
            row.attr("data-lat", lat.toString());
            row.on("click", () => {
                this.sdk.Map.setMapCenter({
                    lonLat: { lon, lat },
                });
            });
            // Type column - format the type name
            const typeFormatted = this.formatURType(ur.updateRequestType);
            const typeHTML = `<td title="${ur.updateRequestType}">${typeFormatted}</td>`;
            row.append(typeHTML);
            // Severity column with color coding
            const severityClass = `wazemyURs_severity_${ur.severity}`;
            const severityHTML = `<td class="${severityClass}">${ur.severity.charAt(0).toUpperCase()}</td>`;
            row.append(severityHTML);
            // Reported date column
            const relativeTime = formatRelativeTime(ur.reportedOn);
            const fullDate = formatFullDate(ur.reportedOn);
            const reportedHTML = `<td title="${fullDate}">${relativeTime}</td>`;
            row.append(reportedHTML);
            // Status column
            const status = ur.isOpen ? "Open" : "Closed";
            const statusHTML = `<td>${status}</td>`;
            row.append(statusHTML);
            $("#wazemyURs_list > tbody").append(row);
            count++;
        });
        $("#wazemyURs_count").text(`Editable URs: ${count}`);
        $("#wazemyURs_status").text("");
    }
    /**
     * Format the UR type for display.
     */
    formatURType(type) {
        const typeMap = {
            BLOCKED_ROAD: "Blocked",
            INCORRECT_ADDRESS: "Address",
            INCORRECT_GENERAL_ERROR: "General",
            INCORRECT_JUNCTION: "Junction",
            INCORRECT_MISSING_ROUNDABOUT: "Roundabout",
            INCORRECT_ROUTE: "Route",
            INCORRECT_TURN: "Turn",
            MISSING_BRIDGE_OVERPASS: "Bridge",
            MISSING_EXIT: "Exit",
            MISSING_ROAD: "Missing Rd",
            TURN_NOT_ALLOWED: "No Turn",
            WRONG_DRIVING_DIRECTIONS: "Directions",
        };
        return typeMap[type] || type;
    }
    /**
     * Disable plugin.
     *
     * @return {void} This function does not return anything.
     */
    disable() {
        // Remove event handler
        if (this.mapDataLoadedHandler) {
            this.sdk.Events.off({
                eventName: "wme-map-data-loaded",
                eventHandler: this.mapDataLoadedHandler,
            });
            this.mapDataLoadedHandler = null;
        }
        if (this.sidebarElements) {
            this.sidebarElements.tabLabel.remove();
            this.sidebarElements.tabPane.remove();
            this.sidebarElements = null;
        }
        console.log("[WazeMY] PluginURs disabled.");
    }
    /**
     * Updates the settings of the PluginURs based on the provided settings object.
     *
     * @return {void} This function does not return anything.
     */
    updateSettings(settings) {
        if (settings.enable === true) {
            this.enable();
        }
        else {
            this.disable();
        }
        console.log("[WazeMY] PluginURs settings updated.", settings);
    }
}

;// ./src/PluginFactory.ts








class PluginFactory {
    static createPlugin(pluginName) {
        switch (pluginName) {
            case "PluginTooltip":
                return new PluginTooltip();
            case "PluginCopyLatLon":
                return new PluginCopyLatLon();
            case "PluginTrafficCameras":
                return new PluginTrafficCameras();
            case "PluginKVMR":
                return new PluginKVMR();
            case "PluginZoomPic":
                return new PluginZoomPic();
            case "PluginPlaces":
                return new PluginPlaces();
            case "PluginGemini":
                return new PluginGemini();
            case "PluginURs":
                return new PluginURs();
            default:
                throw new Error(`Unknown plugin: ${pluginName}`);
        }
    }
}

;// ./src/PluginManager.ts


class PluginManager {
    constructor(settings) {
        this.plugins = {};
        this.layerRegistry = new Map();
        this.settingsStorage = settings;
    }
    /**
     * Adds a plugin to the PluginManager.
     *
     * @param {string} key - The key to associate the plugin with.
     * @param {string} type - The type of plugin to create.
     * @return {void} This function does not return anything.
     */
    addPlugin(key, type) {
        const plugin = PluginFactory.createPlugin(type);
        this.plugins[key] = plugin;
        const pluginSettings = this.settingsStorage.getSetting(key);
        if (pluginSettings) {
            plugin.updateSettings(pluginSettings);
        }
    }
    /**
     * Removes a plugin from the PluginManager.
     *
     * @param {string} key - The key associated with the plugin to remove.
     * @return {void} This function does not return anything.
     */
    removePlugin(key) {
        if (this.plugins[key]) {
            this.settingsStorage.removeSetting(key);
            delete this.plugins[key];
        }
    }
    /**
     * Enables a plugin with the given key if it exists.
     *
     * @param {string} key - The key of the plugin to enable.
     * @return {void} This function does not return anything.
     */
    enablePlugin(key) {
        if (this.plugins[key]) {
            this.plugins[key].enable();
        }
    }
    /**
     * Disables a plugin with the given key if it exists.
     *
     * @param {string} key - The key of the plugin to disable.
     * @return {void} This function does not return anything.
     */
    disablePlugin(key) {
        if (this.plugins[key]) {
            this.plugins[key].disable();
        }
    }
    /**
     * Updates the settings of a plugin associated with the given key.
     *
     * @param {string} key - The key associated with the plugin.
     * @param {any} settings - The new settings to be applied to the plugin.
     * @return {void} This function does not return anything.
     */
    updatePluginSettings(key, settings) {
        if (this.plugins[key]) {
            this.plugins[key].updateSettings(settings);
            this.settingsStorage.updateSetting(key, settings);
        }
    }
    /**
     * Registers a layer in the layer registry for cross-plugin access.
     *
     * @param {string} name - The unique name of the layer.
     * @param {any} layer - The layer object to register.
     * @return {void} This function does not return anything.
     */
    registerLayer(name, layer) {
        this.layerRegistry.set(name, layer);
    }
    /**
     * Retrieves a layer from the layer registry.
     *
     * @param {string} name - The unique name of the layer.
     * @return {any} The layer object, or undefined if not found.
     */
    getLayer(name) {
        return this.layerRegistry.get(name);
    }
    /**
     * Retrieves a plugin by its key.
     *
     * @param {string} key - The key associated with the plugin.
     * @return {IPlugin | undefined} The plugin instance, or undefined if not found.
     */
    getPlugin(key) {
        return this.plugins[key];
    }
}
PluginManager.instance = new PluginManager(SettingsStorage.instance);

;// ./src/index.ts


const updateMessage = `Version 2026.05.17.1: Restored WazeWrap.`;
var sdk;
console.log("[WazeMY] Script started");
unsafeWindow.SDK_INITIALIZED.then(initScript);
function initScript() {
    if (!unsafeWindow.getWmeSdk) {
        throw new Error("WME SDK not available");
    }
    sdk = unsafeWindow.getWmeSdk({
        scriptId: "wme-wazemy",
        scriptName: "WazeMY",
    });
    sdk.Events.once({ eventName: "wme-ready" }).then(initializeWazeMY);
}
function initializeWazeMY() {
    console.log("[WazeMY] WME ready");
    if (WazeWrap && WazeWrap.Ready) {
        WazeWrap.Alerts.success("wme-wazemy", "Script initialized");
    }
    sdk.Sidebar.registerScriptTab().then((sidebarResult) => {
        sidebarResult.tabLabel.innerHTML = "WazeMY";
        sidebarResult.tabLabel.title = "WazeMY";
        sidebarResult.tabPane.innerHTML = `
        <wz-section-header headline="WazeMY" size="section-header2" class="settings-header">
          <wz-overline class="headline">WazeMY</wz-overline>
        </wz-section-header>
        <wz-overline class="headline">${GM_info.script.version}</wz-overline>
        <div class="settings">
          <div class="settings__form-group">
            <fieldset class="wazemySettings">
              <legend class="wazemySettingsLegend">
                <wz-label>Settings</wz-label>
              </legend>
              <div id="wazemySettings_settings"></div>
            </fieldset>
          </div>
          <div class="settings__form-group">
            <fieldset class="wazemySettings">
              <legend class="wazemySettingsLegend">
                <wz-label>Shortcuts</wz-label>
              </legend>
              <div id="wazemySettings_shortcuts"></div>
            </fieldset>
          </div>
          <div class="settings__form-group">
            <fieldset class="wazemySettings">
              <legend class="wazemySettingsLegend">
                <wz-label>Gemini</wz-label>
              </legend>
              <div id="wazemySettings_gemini"></div>
            </fieldset>
          </div>
        </div>
      `;
        WazeWrap.Interface.ShowScriptUpdate("WME WazeMY", GM_info.script.version, updateMessage, "https://greatest.deepsurf.us/en/scripts/404584-wazemy", "javascript:alert('No forum available');");
        console.info(["wme-wazemy", updateMessage]);
        const pluginManager = PluginManager.instance;
        pluginManager.addPlugin("copylatlon", "PluginCopyLatLon");
        pluginManager.addPlugin("tooltip", "PluginTooltip");
        pluginManager.addPlugin("trafcam", "PluginTrafficCameras");
        pluginManager.addPlugin("kvmr", "PluginKVMR");
        pluginManager.addPlugin("zoompic", "PluginZoomPic");
        pluginManager.addPlugin("places", "PluginPlaces");
        pluginManager.addPlugin("urs", "PluginURs");
        pluginManager.addPlugin("gemini", "PluginGemini");
    });
}

/******/ })()
;