WME E40 Geometry

A script that allows aligning, scaling, and copying POI geometry

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         WME E40 Geometry
// @name:uk      WME 🇺🇦 E40 Geometry
// @name:ru      WME 🇺🇦 E40 Geometry
// @version      0.8.3
// @description  A script that allows aligning, scaling, and copying POI geometry
// @description:uk За допомогою цього скрипта ви можете легко змінювати площу та вирівнювати POI
// @description:ru Данный скрипт позволяет изменять площадь POI, выравнивать и копировать геометрию
// @license      MIT License
// @author       Anton Shevchuk
// @namespace    https://greasyfork.org/users/227648-anton-shevchuk
// @supportURL   https://github.com/AntonShevchuk/wme-e40/issues
// @match        https://*.waze.com/editor*
// @match        https://*.waze.com/*/editor*
// @exclude      https://*.waze.com/user/editor*
// @icon         data:image/png;base64,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
// @grant        none
// @require      https://update.greasyfork.org/scripts/389765/1090053/CommonUtils.js
// @require      https://update.greasyfork.org/scripts/450160/1691572/WME-Bootstrap.js
// @require      https://update.greasyfork.org/scripts/450221/1691071/WME-Base.js
// @require      https://update.greasyfork.org/scripts/450320/1688694/WME-UI.js
//
// @require      https://cdn.jsdelivr.net/npm/@turf/[email protected]/turf.min.js
// ==/UserScript==

/* jshint esversion: 8 */
/* global require */
/* global $, jQuery */
/* global I18n */
/* global WMEBase, WMEUI, WMEUIHelper */
/* global Container, Settings, SimpleCache, Tools */
/* global Node$1, Segment, Venue, VenueAddress, WmeSDK */
/* global turf */

(function () {
  'use strict'

  // Script name, uses as unique index
  const NAME = 'E40'

  // User level required for apply geometry for all entities in the view area
  const REQUIRED_LEVEL = 2

  // Translations
  const TRANSLATION = {
    'en': {
      title: 'POI Geometry',
      description: 'Change geometry in the current view area',
      warning: '⚠️ This option is available for editors with a rank higher than ' + REQUIRED_LEVEL,
      help: 'You can use the <strong>Keyboard shortcuts</strong> to apply the settings. It\'s more convenient than clicking on the buttons.',
      orthogonalize: 'Orthogonalize',
      smooth: 'Smooth',
      simplify: 'Simplify',
      scale: 'Scale',
      rotate: 'Rotate',
      circle: 'Circle',
      square: 'Square',
      copy: 'Copy',
      about: '<a href="https://greasyfork.org/uk/scripts/388271-wme-e40-geometry">WME E40 Geometry</a>',
    },
    'uk': {
      title: 'Геометрія POI',
      description: 'Змінити геометрію об’єктів у поточному розташуванні',
      warning: '⚠️ Ця опція доступна лише для редакторів з рангом вищім ніж ' + REQUIRED_LEVEL,
      help: 'Використовуйте <strong>гарячі клавіши</strong>, це значно швидше ніж використовувати кнопки',
      orthogonalize: 'Вирівняти',
      smooth: 'Згладити',
      simplify: 'Спростити',
      scale: 'Масштабувати',
      rotate: 'Повернути',
      circle: 'Круг',
      square: 'Квадрат',
      copy: 'Копіювати',
      about: '<a href="https://greasyfork.org/uk/scripts/388271-wme-e40-geometry">WME E40 Geometry</a>',
    },
    'ru': {
      title: 'Геометрия POI',
      description: 'Изменить геометрию объектов в текущем расположении',
      warning: '⚠️ Эта опция доступна для редакторов с рангов выше ' + REQUIRED_LEVEL,
      help: 'Используйте <strong>комбинации клавиш</strong>, и не надо будет клацать кнопки',
      orthogonalize: 'Выровнять',
      smooth: 'Сгладить',
      simplify: 'Упростить',
      scale: 'Масштабировать',
      rotate: 'Повернуть',
      circle: 'Круг',
      square: 'Квадрат',
      copy: 'Копировать',
      about: '<a href="https://greasyfork.org/uk/scripts/388271-wme-e40-geometry">WME E40 Geometry</a>',
    }
  }

  WMEUI.addTranslation(NAME, TRANSLATION)

  const STYLE =
    '.e40 .controls { display: grid; grid-template-columns: repeat(6, 44px); gap: 6px; padding: 0; }' +
    '.e40 .button-toolbar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }' +
    '.e40 .button-toolbar button.e40 { min-height: 30px; line-height: 25px; margin-bottom: 16px; }' +
    '.e40 button.e40 { width:44px;margin:0;padding:2px;display:flex;justify-content:center;border:1px solid #eee;cursor:pointer;box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:nowrap;color:#333; flex-wrap: wrap; align-content: center;} ' +
    '.e40 button.e40:hover { box-shadow:0 2px 8px 0 rgba(0,0,0,.1),inset 0 0 100px 100px rgba(255,255,255,.3) } ' +
    '.e40 button.e40-M, .e40 button.e40-N, .e40 button.e40-O, .e40 button.e40-P, .e40 button.e40-R, .e40 button.e40-S { min-height: 50px; } ' +
    '#sidebar p.e40 { width: 100%; }' +
    '#sidebar p.e40-info { border-top: 1px solid #ccc; color: #777; font-size: x-small; margin-top: 15px; padding-top: 10px; text-align: center; }' +
    '#sidebar p.e40-warning { color: #f77 }' +
    '#sidebar p.e40-blue { background-color:#0057B8;color:white;height:32px;text-align:center;line-height:32px;font-size:24px;margin:0; }' +
    '#sidebar p.e40-yellow { background-color:#FFDD00;color:black;height:32px;text-align:center;line-height:32px;font-size:24px;margin:0; }'

  WMEUI.addStyle(STYLE)

  // https://fontawesome.com/v4/icons/
  const placeButtons = {
    A: {
      title: '<i class="fa fa-circle-thin" aria-hidden="true"></i>',
      description: I18n.t(NAME).smooth,
      shortcut: 'S+49',
      callback: () => smooth()
    },
    B: {
      title: '<i class="fa fa-square-o" aria-hidden="true"></i>',
      description: I18n.t(NAME).orthogonalize,
      shortcut: 'S+50',
      callback: () => orthogonalize()
    },
    C: {
      title: '1️⃣ 📐',
      description: I18n.t(NAME).simplify + ' (tolerance = 0.00001)',
      shortcut: null,
      callback: () => simplify(0.00001)
    },
    D: {
      title: '3️⃣ 📐',
      description: I18n.t(NAME).simplify + ' (tolerance = 0.00003)',
      shortcut: null,
      callback: () => simplify(0.00003)
    },
    E: {
      title: '5️⃣ 📐',
      description: I18n.t(NAME).simplify + ' (tolerance = 0.00005)',
      shortcut: null,
      callback: () => simplify(0.00005)
    },
    F: {
      title: '<i class="fa fa-clone" aria-hidden="true"></i>',
      description: I18n.t(NAME).copy,
      shortcut: null,
      callback: () => copyPlaces()
    },
    G: {
      title: '<i class="fa fa-repeat" aria-hidden="true"></i>',
      description: I18n.t(NAME).rotate,
      shortcut: 'S+51',
      callback: () => enablePolygonRotation()
    },
    H: {
      title: '<i class="fa fa-expand" aria-hidden="true"></i>',
      description: I18n.t(NAME).scale,
      shortcut: 'S+52',
      callback: () => enablePolygonResize()
    },
    I: {
      title: '500m²',
      description: I18n.t(NAME).scale + ' 500m²',
      shortcut: 'S+53',
      callback: () => scale(500)
    },
    J: {
      title: '650m²',
      description: I18n.t(NAME).scale + ' 650m²',
      shortcut: 'S+54',
      callback: () => scale(650)
    },
    K: {
      title: '650+',
      description: I18n.t(NAME).scale + ' 650+',
      shortcut: 'S+55',
      callback: () => scale(650, true)
    },
  }

  const pointButtons = {
    M: {
      title: '<i class="fa fa-circle-thin fa-2x" aria-hidden="true"></i> 500m²',
      description: I18n.t(NAME).circle,
      shortcut: null,
      callback: () => circle(503, 32)
    },
    N: {
      title: '<i class="fa fa-circle-thin fa-2x" aria-hidden="true"></i> 650m²',
      description: I18n.t(NAME).circle,
      shortcut: null,
      callback: () => circle(651, 64)
    },
    O: {
      title: '<i class="fa fa-circle-thin fa-2x" aria-hidden="true"></i> R=20m',
      description: I18n.t(NAME).circle,
      shortcut: null,
      callback: () => circle(1256.64, 64)
    },
    P: {
      title: '<i class="fa fa-square-o fa-2x" aria-hidden="true"></i> 500m²',
      description: I18n.t(NAME).square,
      shortcut: null,
      callback: () => square(500)
    },
    R: {
      title: '<i class="fa fa-square-o fa-2x" aria-hidden="true"></i> 650m²',
      description: I18n.t(NAME).square,
      shortcut: null,
      callback: () => square(650)
    },
    S: {
      title: '<i class="fa fa-square-o fa-2x" aria-hidden="true"></i> 1000m²',
      description: I18n.t(NAME).square,
      shortcut: null,
      callback: () => square(1000)
    },
  }

  const tabButtons = {
    A: {
      title: '<i class="fa fa-square-o" aria-hidden="true"></i>',
      description: I18n.t(NAME).orthogonalize,
      callback: () => orthogonalizeAll()
    },
    B: {
      title: '1️⃣ 📐',
      description: I18n.t(NAME).simplify,
      callback: () => simplifyAll(0.00001)
    },
    C: {
      title: '3️⃣ 📐',
      description: I18n.t(NAME).simplify,
      callback: () => simplifyAll(0.00003)
    },
    D: {
      title: '5️⃣ 📐',
      description: I18n.t(NAME).simplify,
      callback: () => simplifyAll(0.00005)
    },
    E: {
      title: '500+',
      description: I18n.t(NAME).scale + ' 500m²+',
      callback: () => scaleAll(500, true)
    }
  }

  class E40 extends WMEBase {
    constructor (name, tabButtons, placeButtons, pointButtons) {
      super(name)

      this.initHelper()

      this.initTab(tabButtons)

      this.initPlacePanel(placeButtons)

      this.initShortcuts(placeButtons)

      this.initPointPanel(pointButtons)
    }

    initHelper() {
      this.helper = new WMEUIHelper(this.name)
    }

    initTab (buttons) {
      let tab = this.helper.createTab(
        I18n.t(this.name).title,
        {
          sidebar: this.wmeSDK.Sidebar,
          image: GM_info.script.icon
        }
      )
      tab.addText('description', I18n.t(this.name).description)
      if (this.wmeSDK.State.getUserInfo().rank >= REQUIRED_LEVEL) {
        tab.addButtons(buttons)
      } else {
        tab.addText('warning', I18n.t(this.name).warning)
      }
      tab.addDiv('text', I18n.t(this.name).help)
      tab.addText(
        'info',
        '<a href="' + GM_info.scriptUpdateURL + '">' + GM_info.script.name + '</a> ' + GM_info.script.version
      )
      tab.addText('blue', 'made in')
      tab.addText('yellow', 'Ukraine')
      tab.inject()
    }

    initPlacePanel (buttons) {
      this.placePanel = this.helper.createPanel(
        I18n.t(this.name).title
      )
      this.placePanel.addButtons(buttons)
    }

    initPointPanel (buttons) {
      this.pointPanel = this.helper.createPanel(
        I18n.t(this.name).title
      )
      this.pointPanel.addButtons(buttons)
    }

    initShortcuts (buttons) {
      for (let btn in buttons) {
        if (buttons.hasOwnProperty(btn)) {
          let button = buttons[btn]
          if (button.hasOwnProperty('shortcut')) {
            let shortcut = {
              callback: button.callback,
              description: button.description,
              shortcutId: this.id + '-' + btn,
              shortcutKeys: button.shortcut,
            };

            if (shortcut.shortcutKeys && this.wmeSDK.Shortcuts.areShortcutKeysInUse({ shortcutKeys: shortcut.shortcutKeys })) {
              this.log('Shortcut already in use')
              shortcut.shortcutKeys = null
            }
            this.wmeSDK.Shortcuts.createShortcut(shortcut);
          }
        }
      }
    }

    /**
     * Handler for `place.wme` event
     * @param {jQuery.Event} event
     * @param {HTMLElement} element
     * @param {Venue} model
     */
    onPlace (event, element, model) {
      if (this.wmeSDK.DataModel.Venues.hasPermissions({ venueId: model.id })) {
        this.createPlacePanel(event, element)
      }
    }

    /**
     * Handler for `point.wme` event
     * @param {jQuery.Event} event
     * @param {HTMLElement} element
     * @param {Venue} model
     */
    onPoint (event, element, model) {
      if (this.wmeSDK.DataModel.Venues.hasPermissions({ venueId: model.id })) {
        this.createPointPanel(event, element)
      }
    }

    /**
     * Handler for `venues.wme` event
     * @param {jQuery.Event} event
     * @param {HTMLElement} element
     * @param {Venue[]} models
     * @return {Null}
     */
    onVenues (event, element, models) {
      models = models.filter(model => !model.isResidential
        && this.wmeSDK.DataModel.Venues.hasPermissions({ venueId: model.id }))

      if (models.length > 0) {
        if (models[0].geometry.type === 'Polygon') {
          this.createPlacePanel(event, element)
        } else {
          this.createPointPanel(event, element)
        }
      }
    }

    /**
     * @param {String[]} except
     * @return {Venue[]} models
     */
    getAllPlaces(except = []) {
      let venues = this.getAllVenues(except)
      return venues.filter(venue => venue.geometry.type === 'Polygon')
    }

    /**
     * @return {Venue[]} models
     */
    getSelectedPlaces() {
      let venues = this.getSelectedVenues()
      return venues.filter(venue => venue.geometry.type === 'Polygon')
    }

    /**
     * Create panel with buttons
     * @param event
     * @param {HTMLElement} element
     */
    createPlacePanel (event, element) {
      if (element?.querySelector('div.form-group.e40')) {
        return
      }

      element?.prepend(this.placePanel.html())
      this.updateLabel()
    }

    /**
     * Create panel with buttons
     * @param event
     * @param {HTMLElement} element
     */
    createPointPanel (event, element) {
      if (element?.querySelector('div.form-group.e40')) {
        return
      }

      element?.prepend(this.pointPanel.html())
      this.updateLabel()
    }

    /**
     * Refresh the panel if something was changed
     */
    refreshPanel () {
      let venue = this.getSelectedVenue()

      let element = document.getElementById('venue-edit-general')
          element?.querySelector('div.form-group.e40')?.remove()

      if (venue) {
        if (venue.geometry.type === 'Polygon') {
          this.createPlacePanel(null, element)
        } else {
          this.createPointPanel(null, element)
        }
      }
    }

    /**
     * Updated label
     */
    updateLabel () {
      let places = this.getSelectedVenues()

      if (places.length === 0) {
        return
      }
      let info = []
      for (let i = 0; i < places.length; i++) {
        let place = places[i]
        if (place.geometry.type === 'Polygon') {
          info.push(Math.round(turf.area(place.geometry)) + 'm²')
        }
      }
      let label = I18n.t(NAME).title
      if (info.length) {
        label += ' (' + info.join(', ') + ')'
      }

      let elm = document.querySelector('div.form-group.e40 wz-label')
      if (elm) elm.innerText = label
    }

    /**
     * Scale places to X m²
     * @param {Venue[]} elements
     * @param {Number} x square meters
     * @param {Boolean} orMore flag
     */
    scale (elements, x, orMore = false) {
      this.group('scale ' + (elements.length) + ' element(s) to ' + x + 'm²')

      let total = 0
      for (let i = 0; i < elements.length; i++) {
        try {
          let scale = Math.sqrt((x + 5) / turf.area(elements[i].geometry))
          if (scale < 1 && orMore) {
            continue
          }

          let geometry = turf.transformScale(elements[i].geometry, scale)

          this.wmeSDK.DataModel.Venues.updateVenue({
            venueId: elements[i].id, geometry
          })

          total++
        } catch (e) {
          this.log('skipped', e)
        }
      }
      this.log(total + ' element(s) was scaled')
      this.groupEnd()
    }

    /**
     * Orthogonalize place(s)
     * @param {Venue[]} elements
     */
    orthogonalize (elements) {
      this.group('orthogonalize ' + (elements.length) + ' element(s)')
      let total = 0
      // skip points
      for (let i = 0; i < elements.length; i++) {
        try {
          let geometry = simplifyPolygon(elements[i].geometry)
              geometry = normalizeRightAngles(geometry)

          if (!this.compare(elements[i].geometry.coordinates[0], geometry.coordinates[0])) {
            this.wmeSDK.DataModel.Venues.updateVenue({
              venueId: elements[i].id, geometry
            })
            total++
          }
        } catch (e) {
          this.log('skipped', e)
        }
      }
      this.log(total + ' element(s) was orthogonalized')
      this.groupEnd()
    }


    /**
     * Smooth place(s)
     * @param {Venue[]} elements
     */
    smooth (elements) {
      this.group('smooth ' + (elements.length) + ' element(s)')

      let total = 0
      for (let i = 0; i < elements.length; i++) {
        try {
          let geometry = turf.polygonSmooth(elements[i].geometry).features[0].geometry;

          if (geometry.coordinates[0].length !== elements[i].geometry.coordinates[0].length) {
            this.wmeSDK.DataModel.Venues.updateVenue({
              venueId: elements[i].id, geometry
            })
            total++
          }
        } catch (e) {
          this.log('skipped', e)
        }
      }
      this.log(total + ' element(s) was smoothed')
      this.groupEnd()
    }

    /**
     * Simplify place(s)
     * @param {Venue[]} elements
     * @param {Number} tolerance
     */
    simplify (elements, tolerance = 0.00001) {
      this.group('simplify ' + (elements.length) + ' element(s) with < tolerance=' + tolerance + ' >')

      let total = 0
      for (let i = 0; i < elements.length; i++) {
        try {
          let geometry = turf.simplify(elements[i].geometry, { tolerance })

          if (geometry.coordinates[0].length !== elements[i].geometry.coordinates[0].length) {
            this.wmeSDK.DataModel.Venues.updateVenue({
              venueId: elements[i].id, geometry
            })
            total++
          }
        } catch (e) {
          this.log('skipped', e)
        }
      }
      this.log(total + ' element(s) was simplified')
      this.groupEnd()
    }

    /**
     * Transform the Point to circle place
     *
     * @param {Venue[]} elements
     * @param {Number} area in square meters
     * @param {Number} steps
     */
    circle (elements, area, steps = 64) {
      this.group('transform ' + (elements.length) + ' element(s) to circle')

      let total = 0
      for (let i = 0; i < elements.length; i++) {
        try {
          let place = elements[i]
          let geometry = place.geometry

          if (geometry.type !== 'Point') {
            geometry = turf.centroid(geometry).geometry
          }

          let circle = createCirclePolygon(geometry, area, steps)

          this.wmeSDK.DataModel.Venues.updateVenue({
            venueId: place.id, geometry: circle.geometry
          })
          total++
        } catch (e) {
          this.log('skipped', e)
        }
      }

      this.log(total + ' element(s) was transformed')
      this.groupEnd()

      this.wmeSDK.Editing.clearSelection()

      // select changed elements
      setTimeout(() =>
        this.wmeSDK.Editing.setSelection({ selection: {
            ids: elements.map(e => String(e.id)),
            objectType: 'venue'
          }}), 100)
    }


    /**
     * Transform the Point(s) to square place
     *
     * @param {Venue[]} elements
     * @param {Number} area in square meters
     */
    square (elements, area) {
      this.group('transform ' + (elements.length) + ' element(s) to square')

      let total = 0
      for (let i = 0; i < elements.length; i++) {
        try {
          let place = elements[i]

          let geometry = place.geometry

          if (geometry.type !== 'Point') {
            geometry = turf.centroid(geometry).geometry
          }

          let square = createSquarePolygon(geometry, area)

          this.wmeSDK.DataModel.Venues.updateVenue({
            venueId: place.id, geometry: square.geometry
          })

          total++
        } catch (e) {
          this.log('skipped', e)
        }
      }

      this.log(total + ' element(s) was transformed')
      this.groupEnd()

      this.wmeSDK.Editing.clearSelection()

      // select changed elements
      setTimeout(() =>
        this.wmeSDK.Editing.setSelection({ selection: {
            ids: elements.map(e => String(e.id)),
            objectType: 'venue'
          }}), 100)
    }

    /**
     * Create copy for place
     * @param {Venue} venue
     */
    copyPlace (venue) {
      this.log('created a copy of the POI ' + venue.name)

      let geometry = turf.transformTranslate(venue.geometry, 0.01, 0.005)

      let venueId = this.wmeSDK.DataModel.Venues.addVenue(
        {
          category: venue.categories[0],
          geometry: geometry
        }
      )

      let newVenue = {
        // isAdLocked: venue.isAdLocked,
        // isResidential: venue.isResidential,
        name: venue.name + ' (copy)',
        venueId: String(venueId),
      }

      this.wmeSDK.DataModel.Venues.updateVenue(newVenue)

      let address = E40Instance.wmeSDK.DataModel.Venues.getAddress( { venueId: venue.id } )

      if (address?.street?.id) {
        this.wmeSDK.DataModel.Venues.updateAddress(
          {
            venueId: String(venueId),
            streetId: address.street.id,
          }
        )
      }
    }

    /**
     * Compare two polygons point-by-point
     *
     * @param {Array} coordinates1
     * @param {Array} coordinates2
     * @return boolean
     */
    compare (coordinates1, coordinates2) {
      if (coordinates1.length !== coordinates2.length) {
        return false
      }
      for (let i = 0; i < coordinates1.length; i++) {
        if (Math.abs(coordinates1[i][0] - coordinates2[i][0]) > .00001
          || Math.abs(coordinates1[i][1] - coordinates2[i][1]) > .00001) {
          return false
        }
      }
      return true
    }
  }

  let E40Instance

  $(document).on('bootstrap.wme', () => {
    E40Instance = new E40(NAME, tabButtons, placeButtons, pointButtons)

    E40Instance.wmeSDK.Events.trackDataModelEvents({ dataModelName: "venues" })
    E40Instance.wmeSDK.Events.on({
      eventName: "wme-data-model-objects-changed",
      eventHandler: ({dataModelName, objectIds}) => {
        // console.log(dataModelName)
        // console.log(objectIds)
        E40Instance.refreshPanel()
      }
    });
  })

  /**
   * Scale selected place(s) to X m²
   * @param {Number} x square meters
   * @param {Boolean} orMore flag
   * @return {boolean}
   */
  function scale (x, orMore = false) {
    E40Instance.scale(E40Instance.getSelectedPlaces(), x, orMore)
    return false
  }

  /**
   * Scale all places in the editor area to X m²
   * @param {Number} x square meters
   * @param {Boolean} orMore flag
   * @return {boolean}
   */
  function scaleAll (x = 650, orMore = true) {
    E40Instance.scale(E40Instance.getAllPlaces(), x, orMore)
    return false
  }

  /**
   * Orthogonalize selected place(s)
   * @return {boolean}
   */
  function orthogonalize () {
    E40Instance.orthogonalize(
      E40Instance.getSelectedPlaces()
    )
    return false
  }

  /**
   * Orthogonalize all places in the editor area
   * @return {boolean}
   */
  function orthogonalizeAll () {
    // skip parking, natural and outdoors
    // TODO: make options for filters
    E40Instance.orthogonalize(
      E40Instance.getAllPlaces(['OUTDOORS', 'PARKING_LOT', 'NATURAL_FEATURES'])
    )
    return false
  }

  /**
   * Smooth selected place(s)
   * @return {boolean}
   */
  function smooth () {
    E40Instance.smooth(
      E40Instance.getSelectedPlaces()
    )
    return false
  }

  /**
   * Simplify selected place(s)
   * @param {Number} tolerance
   * @return {boolean}
   */
  function simplify (tolerance = 0.00001) {
    E40Instance.simplify(
      E40Instance.getSelectedPlaces(),
      tolerance
    )
    return false
  }

  /**
   * Simplify all places in the editor area
   * @param {Number} tolerance
   * @return {boolean}
   */
  function simplifyAll (tolerance = 0.00001) {
    // skip parking, natural and outdoors
    E40Instance.simplify(
      E40Instance.getAllPlaces(['OUTDOORS', 'PARKING_LOT', 'NATURAL_FEATURES']),
      tolerance
    )
    return false
  }

  /**
   * Transform the Point to circle place
   * @param {Number} area in square meters
   * @param {Number} steps
   */
  function circle (area, steps = 64) {
    E40Instance.circle(
      E40Instance.getSelectedVenues(),
      area,
      steps
    )
    return false
  }

  /**
   * Transform the Point to square place
   * @param {Number} area in square meters
   */
  function square (area) {
    E40Instance.square(
      E40Instance.getSelectedVenues(),
      area
    )
    return false
  }

  /**
   * Copy selected places
   * Last of them will be chosen
   */
  function copyPlaces () {
    let venues = E40Instance.getSelectedPlaces()

    for (let i = 0; i < venues.length; i++) {
      E40Instance.copyPlace(venues[i])
    }
  }

  /**
   * wmeSDK.Map.enablePolygonResize()
   */
  function enablePolygonResize () {
    console.log(
      '%c' + NAME + ': %cenable resize for Polygon',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )
    let places = E40Instance.getSelectedPlaces()
    if (places.length) {
      E40Instance.wmeSDK.Map.enablePolygonResize()
    }
  }

  /**
   * wmeSDK.Map.enablePolygonRotation()
   */
  function enablePolygonRotation() {
    console.log(
      '%c' + NAME + ': %cenable rotation for Polygon',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )
    let places = E40Instance.getSelectedPlaces()
    if (places.length) {
      E40Instance.wmeSDK.Map.enablePolygonRotation()
    }
  }

  /**
   * Creates a GeoJSON Polygon representing a circle centered at a given point
   * with a radius calculated from a desired area in square meters.
   *
   * @param {object} centerPoint - A GeoJSON Point feature (e.g., turf.point([lon, lat])).
   * @param {number} areaSqMeters - The desired area of the circle in square meters (m²).
   * @param {number} [steps=64] - The number of steps/segments to create the circle (higher = smoother).
   * @returns {object} A GeoJSON Polygon Feature representing the circle.
   */
  function createCirclePolygon(centerPoint, areaSqMeters, steps = 64) {
    if (centerPoint.type !== 'Point') {
      throw new Error('Invalid centerPoint: Must be a GeoJSON Point feature.');
    }
    if (typeof areaSqMeters !== 'number' || areaSqMeters <= 0) {
      throw new Error('Invalid areaSqMeters: Must be a positive number.');
    }

    // 1. Calculate the required radius (R) from the Area (A)
    // The formula for the area of a circle is: A = π * R²
    // Rearranging for the radius: R = sqrt(A / π)
    const radiusMeters = Math.sqrt(areaSqMeters / Math.PI);

    // 2. Convert the radius from meters to kilometers (Turf.js default unit)
    const radiusKilometers = radiusMeters / 1000;

    // 3. Use turf.circle to create the polygon
    return turf.circle(centerPoint, radiusKilometers, {
      steps: steps,
      units: 'kilometers' // Explicitly set units, though it's the default
    });
  }

  /**
   * Creates a GeoJSON Polygon representing a square centered at a given point
   * with a side length calculated from a desired area in square meters.
   *
   * @param {object} centerPoint - A GeoJSON Point feature (e.g., turf.point([lon, lat])).
   * @param {number} areaSqMeters - The desired area of the square in square meters (m²).
   * @returns {object} A GeoJSON Polygon Feature representing the square.
   */
  function createSquarePolygon(centerPoint, areaSqMeters) {
    if (centerPoint.type !== 'Point') {
      throw new Error('Invalid centerPoint: Must be a GeoJSON Point feature.');
    }
    if (typeof areaSqMeters !== 'number' || areaSqMeters <= 0) {
      throw new Error('Invalid areaSqMeters: Must be a positive number.');
    }

    // 1. Calculate the required Side Length (S) from the Area (A)
    // The formula for the area of a square is: A = S²
    // Rearranging for the side length: S = sqrt(A)
    const sideLengthMeters = Math.sqrt(areaSqMeters);

    // 2. Calculate the distance from the center to any edge of the square
    // This is half the side length: HalfSide = S / 2
    const halfSideMeters = sideLengthMeters / 2;

    // 3. Since Turf.js typically handles distances in kilometers, convert the half-side.
    const halfSideKilometers = halfSideMeters / 1000;

    // 4. Calculate the bounding box (bbox) coordinates
    // We can use a combination of `turf/destination` or, more simply for a centered square,
    // manually calculate the offsets using Turf's distance handling for min/max coordinates.
    // However, a simpler approach is to calculate the bounding box for the square's corners.

    // A centered square's extent is defined by its center coordinates +/- (half-side in distance units).
    // The `turf/bbox` function is often used to get the extent of a feature, but here we need
    // to calculate the BBOX based on a distance from the center point.

    // Calculate the geographic bounding box [west, south, east, north]
    // Due to the complexities of Earth's curvature, calculating precise coordinates
    // by simply adding/subtracting distances (especially for large squares) is difficult.
    // A robust, though slightly over-engineered, way is to use the `turf/buffer` function
    // to approximate the square's corners.

    // A simpler approach for small, localized areas is to calculate the min/max coordinates
    // by using the `turf/transformScale` on a unit square. However, this is more complex.

    // A common and practical approximation for *small* areas:
    const [lon, lat] = centerPoint.coordinates;

    // For simplicity, we'll use an approximation based on latitude/longitude differences.
    // WARNING: This approximation is only accurate for very small areas or near the equator.
    // For a highly accurate square, you would use geodesic distance functions (like turf/destination)
    // to find the four corners based on the center point and the half-side distance.

    // --- Robust Geodesic Calculation for the Four Corners ---
    const options = { units: 'kilometers' };

    // 45 degrees: Northeast, 135 degrees: Northwest, 225 degrees: Southwest, 315 degrees: Southeast
    const cornerNE = turf.destination(centerPoint, halfSideKilometers * Math.SQRT2, 45, options);
    const cornerSW = turf.destination(centerPoint, halfSideKilometers * Math.SQRT2, 225, options);

    const minLon = cornerSW.geometry.coordinates[0];
    const minLat = cornerSW.geometry.coordinates[1];
    const maxLon = cornerNE.geometry.coordinates[0];
    const maxLat = cornerNE.geometry.coordinates[1];

    // The BBOX format is [minX, minY, maxX, maxY] => [west, south, east, north]
    const calculatedBbox = [minLon, minLat, maxLon, maxLat];

    // 5. Use turf.bboxPolygon to create the square polygon from the bounding box
    return turf.bboxPolygon(calculatedBbox);
  }

  /**
   * Extracts the exterior coordinate ring from a Feature<Polygon> or Polygon geometry object.
   * @param {object} geojsonObject A GeoJSON Feature<Polygon> or Polygon geometry object.
   * @returns {Array<Array<number>> | null} The exterior ring, or null on error.
   */
  function getExteriorRing(geojsonObject) {
    if (geojsonObject.type === "Feature" && geojsonObject.geometry?.type === "Polygon") {
      return geojsonObject.geometry.coordinates[0];
    } else if (geojsonObject.type === "Polygon") {
      return geojsonObject.coordinates[0];
    }
    console.error("Invalid GeoJSON input: method accepts Feature<Polygon> or Polygon geometry object only.");
    return null;
  }

  /**
   * Iteratively simplifies a GeoJSON Polygon ring by removing points that form
   * an angle between 175° and 180° with their neighbors.
   * @param {object} geojsonObject A GeoJSON Feature<Polygon> or Polygon geometry object.
   * @returns {object} The simplified GeoJSON Polygon geometry object (type: "Polygon").
   */
  function simplifyPolygon(geojsonObject) {
    let ring = getExteriorRing(geojsonObject);
    if (!ring) return { type: "Polygon", coordinates: [[]] };

    let points = [...ring];
    const MIN_UNIQUE_POINTS = 4; // A, B, C, A (length 4) means 3 unique points (a triangle)
    const MIN_ANGLE = 175.0;
    const MAX_ANGLE = 185.0;
    let pointsRemoved = 0;
    let iteration = 0;

    console.log("--- Starting Polygon Simplification (175° to 185° removal) ---");

    while (points.length > MIN_UNIQUE_POINTS) {
      iteration++;
      let pointIndexToRemove = -1;

      // Check points from index 1 up to length - 2.
      for (let i = 1; i < points.length - 1; i++) {
        const angle = GeoUtils.findAngle(points[i - 1], points[i], points[i + 1]);

        if (angle >= MIN_ANGLE && angle <= MAX_ANGLE) {
          pointIndexToRemove = i;
          console.log(`[Iter ${iteration}] Found point to remove at index ${i} (${points[i].map(c => c.toFixed(2)).join(', ')}). Angle: ${angle.toFixed(4)}°`);
          break; // Remove only one point per iteration
        }
      }

      if (pointIndexToRemove !== -1) {
        points.splice(pointIndexToRemove, 1);
        pointsRemoved++;

        // Update the closure point
        points[points.length - 1] = points[0];
        console.log(`[Iter ${iteration}] Point removed. New length: ${points.length}. Unique points remaining: ${points.length - 1}.`);
      } else {
        console.log(`[Iter ${iteration}] No point found in the angle range [${MIN_ANGLE}°, ${MAX_ANGLE}°]. Stopping.`);
        break;
      }
    }

    if (points.length <= MIN_UNIQUE_POINTS) {
      console.log(`Reached minimum size of 3 unique points (array length ${points.length}). Stopping.`);
    }

    console.log(`--- Simplification Finished. Total points removed: ${pointsRemoved} ---`);

    return {
      type: "Polygon",
      coordinates: [points]
    };
  }

  /**
   * Moves vertices (P_curr) that form a near-90° angle (85-89.9 or 90.1-95)
   * to a new position (P'_curr) that forms exactly 90°.
   * @param {object} geojsonObject A GeoJSON Feature<Polygon> or Polygon geometry object.
   * @returns {object} The angle-normalized GeoJSON Polygon geometry object.
   */
  function normalizeRightAngles(geojsonObject) {
    let ring = getExteriorRing(geojsonObject);
    if (!ring) return { type: "Polygon", coordinates: [[]] };

    let points = JSON.parse(JSON.stringify(ring)); // Deep copy to modify
    let pointsAdjusted = 0;
    let totalIterations = 0;
    let changedInPass = true;

    console.log("--- Starting Angle Normalization (Near 90° adjustment) ---");

    // Iterate until no points are adjusted in a full pass
    while (changedInPass && totalIterations < 10) { // Safety limit for iterations
      changedInPass = false;
      totalIterations++;

      console.log(`[Iter ${totalIterations}] Start`)

      // Check points from index 1 up to length - 2.
      for (let i = 1; i < points.length - 1; i++) {
        const pPrev = points[i - 1];
        const pCurr = points[i];
        const pNext = points[i + 1];

        const angle = GeoUtils.findAngle(pPrev, pCurr, pNext);

        console.log(`[Point ${i}] Angle:`, angle.toFixed(4))

        // Check if the angle is in the target normalization ranges
        const inRange1 = angle >= 75.0 && angle <= 89.5;
        const inRange2 = angle >= 90.5 && angle <= 105.0;


        if (inRange1 || inRange2) {

          // Round coordinates to 6 decimal places for GeoJSON compatibility
          points[i] = GeoUtils.findRightAngleIntersection(pPrev, pCurr, pNext)

          let new_angle = GeoUtils.findAngle(pPrev, points[i], pNext);

          pointsAdjusted++;
          changedInPass = true;
          console.log(`[Point ${i}] Angle ${angle.toFixed(4)}° adjusted to ${new_angle.toFixed(4)}°.`);

          // The loop continues in the same pass. If points[i] is adjusted,
          // it affects the angle calculations for P_{i-1} and P_{i+1} in the next passes.
        }
      }
    }

    // Ensure the closure point is updated after all adjustments
    points[points.length - 1] = points[0];

    console.log(`--- Normalization Finished. Total points adjusted: ${pointsAdjusted} in ${totalIterations} passes. ---`);

    return {
      type: "Polygon",
      coordinates: [points]
    };
  }


  /**
   * A utility class for spherical geometry (geodesy).
   * Assumes points are [longitude, latitude] in degrees.
   */
  class GeoUtils {
    /**
     * @param {number} degrees
     * @return {number} radians
     * @private
     */
    static _toRadians(degrees) {
      return degrees * (Math.PI / 180);
    }

    /**
     * @param {number} radians
     * @return {number} degrees
     * @private
     */
    static _toDegrees(radians) {
      return radians * (180 / Math.PI);
    }

    /**
     * Normalizes an angle to the range -180 to +180 degrees.
     *
     * @param {number} degrees
     * @return {number} degrees
     */
    static _normalizeAngle(degrees) {
      return (degrees + 540) % 360 - 180;
    }

    /**
     * Calculates the initial bearing from pA to pB.
     *
     * @param {[number,number]} pA - [lon, lat] of start point.
     * @param {[number,number]} pB - [lon, lat] of end point.
     * @returns {number} Initial bearing in degrees (0-360).
     */
    static getBearing(pA, pB) {
      const latA = GeoUtils._toRadians(pA[1]);
      const lonA = GeoUtils._toRadians(pA[0]);
      const latB = GeoUtils._toRadians(pB[1]);
      const lonB = GeoUtils._toRadians(pB[0]);

      const deltaLon = lonB - lonA;

      const y = Math.sin(deltaLon) * Math.cos(latB);
      const x = Math.cos(latA) * Math.sin(latB) -
        Math.sin(latA) * Math.cos(latB) * Math.cos(deltaLon);

      const bearingRad = Math.atan2(y, x);

      // Convert from -180/+180 to 0-360
      return (GeoUtils._toDegrees(bearingRad) + 360) % 360;
    }

    /**
     * Calculates the interior angle at vertex p2.
     *
     * @param {[number,number]} p1
     * @param {[number,number]} p2
     * @param {[number,number]} p3
     */
    static findAngle(p1, p2, p3) {
      const bearing21 = GeoUtils.getBearing(p2, p1);
      const bearing23 = GeoUtils.getBearing(p2, p3);
      let angle = Math.abs(bearing21 - bearing23);

      if (angle > 180) {
        angle = 360 - angle;
      }
      return angle;
    }

    /**
     * Calculate the approximate distance between two coordinates (lat/lon)
     *
     * @param {[number,number]} pA - [lon, lat] of start point.
     * @param {[number,number]} pB - [lon, lat] of end point.
     * @return {number} The distance in meters.
     */
    static getDistance (pA, pB) {
      return GeoUtils.getAngularDistance(pA, pB) * 6371000
    }

    /**
     * Calculates the angular distance between two points using the Haversine formula.
     *
     * @param {[number,number]} pA - [lon, lat] of start point.
     * @param {[number,number]} pB - [lon, lat] of end point.
     * @returns {number} The angular distance in radians.
     */
    static getAngularDistance(pA, pB) {
      const latA = GeoUtils._toRadians(pA[1]);
      const lonA = GeoUtils._toRadians(pA[0]);
      const latB = GeoUtils._toRadians(pB[1]);
      const lonB = GeoUtils._toRadians(pB[0]);

      const deltaLat = latB - latA;
      const deltaLon = lonB - lonA;

      const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
        Math.cos(latA) * Math.cos(latB) *
        Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);

      // c is the angular distance in radians
      return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    }

    /**
     * Calculates the destination point given a start point, bearing, and distance.
     * @param {[number,number]} startPoint - [lon, lat] of start point.
     * @param {number} bearing - Bearing in degrees (0-360).
     * @param {number} distanceRad - Angular distance in radians.
     * @returns {number[]} The destination point [lon, lat] in degrees.
     */
    static getDestination(startPoint, bearing, distanceRad) {
      const lat1 = GeoUtils._toRadians(startPoint[1]);
      const lon1 = GeoUtils._toRadians(startPoint[0]);
      const brng = GeoUtils._toRadians(bearing);
      const d = distanceRad;

      const lat2 = Math.asin(
        Math.sin(lat1) * Math.cos(d) +
        Math.cos(lat1) * Math.sin(d) * Math.cos(brng)
      );

      const lon2 = lon1 + Math.atan2(
        Math.sin(brng) * Math.sin(d) * Math.cos(lat1),
        Math.cos(d) - Math.sin(lat1) * Math.sin(lat2)
      );

      // Normalize longitude to -180 to +180
      const lon2Deg = GeoUtils._toDegrees(lon2);
      const lat2Deg = GeoUtils._toDegrees(lat2);

      return [(lon2Deg + 540) % 360 - 180, lat2Deg];
    }

    /**
     * Finds the intersection of two great-circle paths.
     * Path 1: Defined by p1 and p2.
     * Path 2: Defined by p3 and an internal angle at p3.
     *
     * @param {[number,number]} p1 - First point of Line 1 [lon, lat].
     * @param {[number,number]} p2 - Second point of Line 1 [lon, lat].
     * @param {[number,number]} p3 - Start point of Line 2 [lon, lat].
     * @param {number} angle - The SIGNED internal angle at p2 (in degrees).
     * @returns {[number,number] | null} The intersection point [lon, lat], or null if lines are parallel.
     */
    static findIntersection(p1, p2, p3, angle) {
      // 1. Define the triangle P1-P3-X (A-C-B)
      //    A = p1, C = p3, B = X (intersection)

      // 2. Find known bearings
      const brng1_2 = GeoUtils.getBearing(p1, p2); // Bearing of Line 1
      const brng1_3 = GeoUtils.getBearing(p1, p3); // Bearing from p1 to p3

      // 3. Calculate internal angles A (at p1) and C (at p3)
      const angleA = GeoUtils._normalizeAngle(brng1_2 - brng1_3);
      const angleB = angle
      const angleC = GeoUtils._normalizeAngle(180 - angleA - angleB)

      // 4. Calculate known side b (angular distance p1-p3)
      const dist_b = GeoUtils.getAngularDistance(p1, p3); // in radians

      // Check for parallel lines
      if (Math.sin(GeoUtils._toRadians(angleA)) === 0 && Math.sin(GeoUtils._toRadians(angleC)) === 0) {
        return null; // Collinear
      }

      // 5. Find internal angle B (at intersection X) using Law of Cosines for angles
      const angleA_rad = GeoUtils._toRadians(angleA);
      const angleB_rad = GeoUtils._toRadians(angleB);
      const angleC_rad = GeoUtils._toRadians(angleC);

      // 5a. Find internal angle B (at intersection X) using Law of Cosines for angles
      let cos_B = -Math.cos(angleA_rad) * Math.cos(angleC_rad) +
        Math.sin(angleA_rad) * Math.sin(angleC_rad) * Math.cos(dist_b);
      cos_B = Math.max(-1, Math.min(1, cos_B)); // Clamp
      // angleB_rad = Math.acos(cos_B);

      // Check for parallel/collinear lines (angleB is 0 or 180)
      if (Math.abs(Math.sin(angleB_rad)) < 1e-9) {
        return null;
      }

      // 5b. Find side c (distance p1-X) using Law of Cosines (NOT Sines)
      //     cos(c) = (cos(C) + cos(A)cos(B)) / (sin(A)sin(B))
      let cos_c = (Math.cos(angleC_rad) + Math.cos(angleA_rad) * cos_B) /
        (Math.sin(angleA_rad) * Math.sin(angleB_rad));

      cos_c = Math.max(-1, Math.min(1, cos_c)); // Clamp
      const dist_c = Math.acos(cos_c); // This is dist_p1_X in radians

      // 6. Calculate the final intersection point X
      //    We have start (p1), bearing (brng1_X), and distance (dist_c)
      return GeoUtils.getDestination(p1, brng1_2, dist_c);
    }

    /**
     * Calculates the coordinates of point D in a right-angled spherical triangle ADC,
     * using Angle A and the hypotenuse AC.
     * Triangle ADC has a right angle at D (angle D = 90 deg),
     * and angle A and side AC are preserved from the original triangle ABC.
     *
     * @param {[number,number]} pA - [lon, lat] of point A.
     * @param {[number,number]} pB - [lon, lat] of point B (used to calculate angle A).
     * @param {[number,number]} pC - [lon, lat] of point C.
     * @returns {[number,number]} The coordinates [lon, lat] of point D.
     */
    static findRightAngleIntersection(pA, pB, pC) {
      // 1. Calculate the required angle at A (angle A_ABC)
      // The angle at A in triangle ABC is the interior angle at pA.
      const angleA_deg = GeoUtils.findAngle(pB, pA, pC);
      const angleA_rad = GeoUtils._toRadians(angleA_deg);

      // 2. Calculate the common side AC (side 'b' in spherical triangle ADC)
      // This is the hypotenuse of the right triangle ADC.
      const distAC_rad = GeoUtils.getAngularDistance(pA, pC);

      // 3. Use Napier's Rules to find side 'c' (distance AD)
      // In right triangle ADC: D = 90 deg, angle A is known, hypotenuse b (AC) is known.
      // We want to find side 'c' (distance AD), which is adjacent to angle A.
      // The correct spherical formula relating adjacent side 'c', hypotenuse 'b', and angle 'A' is:
      // cos(A) = tan(c) / tan(b)

      // Therefore, tan(c) = cos(A) * tan(b)
      // Where:
      // c = distAD_rad (unknown side)
      // b = distAC_rad (hypotenuse)
      // A = angleA_rad (known angle)

      const tan_c = Math.cos(angleA_rad) * Math.tan(distAC_rad);
      const distAD_rad = Math.atan(tan_c);

      // 4. Determine the bearing from A to D
      // The bearing from A to D is the bearing from A to C, adjusted by the angle A.
      const bearingAC_deg = GeoUtils.getBearing(pA, pC);

      // The bearing A->D must be rotated from A->C such that D forms a right angle with CD.
      // This requires D to be along the great circle arc that is perpendicular to C->D.

      // Bearing from A to B
      const bearingAB_deg = GeoUtils.getBearing(pA, pB);

      // Calculate the signed difference: bearingAC - bearingAB
      const angleCAB_raw_diff = GeoUtils._normalizeAngle(bearingAC_deg - bearingAB_deg);

      let bearingAD_deg;

      // The point D is found by rotating the bearing A->C away from B, by the interior angle A.
      if (angleCAB_raw_diff >= 0) {
        // B is counter-clockwise from AC (left side)
        // D needs to be on the other side of AC
        bearingAD_deg = GeoUtils._normalizeAngle(bearingAC_deg - angleA_deg);
      } else {
        // B is clockwise from AC (right side)
        // D needs to be on the other side of AC
        bearingAD_deg = GeoUtils._normalizeAngle(bearingAC_deg + angleA_deg);
      }

      // 5. Calculate the destination point D
      // Start point: pA
      // Bearing: bearingAD_deg
      // Distance: distAD_rad
      return GeoUtils.getDestination(pA, bearingAD_deg, distAD_rad);
    }
  }

})()