WME E40 Geometry

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         WME E40 Geometry
// @name:uk      WME 🇺🇦 E40 Geometry
// @name:ru      WME 🇺🇦 E40 Geometry
// @version      0.8.1
// @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',
      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: 'Повернути',
      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: 'Повернуть',
      copy: 'Копировать',
      about: '<a href="https://greasyfork.org/uk/scripts/388271-wme-e40-geometry">WME E40 Geometry</a>',
    }
  }

  WMEUI.addTranslation(NAME, TRANSLATION)

  const STYLE =
    'button.waze-btn.e40 { margin: 0 4px 4px 0; padding: 2px; width: 45px; border: 1px solid #ddd; } ' +
    'p.e40-info { border-top: 1px solid #ccc; color: #777; font-size: x-small; margin-top: 15px; padding-top: 10px; text-align: center; }' +
    '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 panelButtons = {
    A: {
      title: '<i class="fa fa-circle-o" 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: 'S+51',
      callback: () => simplify(0.00001)
    },
    D: {
      title: '3️⃣ 📐',
      description: I18n.t(NAME).simplify + ' (tolerance = 0.00003)',
      shortcut: 'S+52',
      callback: () => simplify(0.00003)
    },
    E: {
      title: '5️⃣ 📐',
      description: I18n.t(NAME).simplify + ' (tolerance = 0.00005)',
      shortcut: 'S+53',
      callback: () => simplify(0.00005)
    },
    F: {
      title: '<i class="fa fa-clone" aria-hidden="true"></i>',
      description: I18n.t(NAME).copy,
      shortcut: 'S+54',
      callback: () => copyPlaces()
    },
    G: {
      title: '<i class="fa fa-repeat" aria-hidden="true"></i>',
      description: I18n.t(NAME).rotate,
      shortcut: 'S+55',
      callback: () => enablePolygonRotation()
    },
    H: {
      title: '<i class="fa fa-expand" aria-hidden="true"></i>',
      description: I18n.t(NAME).scale,
      shortcut: 'S+56',
      callback: () => enablePolygonResize()
    },
    I: {
      title: '500m²',
      description: I18n.t(NAME).scale + ' 500m²',
      shortcut: 'S+57',
      callback: () => scaleSelected(500)
    },
    J: {
      title: '650m²',
      description: I18n.t(NAME).scale + ' 650m²',
      shortcut: 'S+58',
      callback: () => scaleSelected(650)
    },
    K: {
      title: '650+',
      description: I18n.t(NAME).scale + ' 650+',
      shortcut: 'S+59',
      callback: () => scaleSelected(650, true)
    },

  }

  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, panelButtons) {
      super(name)

      this.initHelper()

      this.initTab(tabButtons)

      this.initPanel(panelButtons)

      this.initShortcuts(panelButtons)
    }

    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()
    }

    initPanel (buttons) {
      this.panel = this.helper.createPanel(
        I18n.t(this.name).title
      )
      this.panel.addButtons(buttons)
    }

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

            if (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.createPanel(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) {
        this.createPanel(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
     */
    createPanel (event, element) {
      if (element.querySelector('div.form-group.e40')) {
        return
      }

      element.prepend(this.panel.html())
      this.updateLabel()
    }

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

      if (places.length === 0) {
        return
      }
      let info = []
      for (let i = 0; i < places.length; i++) {
        info.push(Math.round(turf.area(places[i].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
    }
  }

  let E40Instance

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

    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.updateLabel()
      }
    });
  })

  /**
   * Scale selected place(s) to X m²
   * @param {Number} x square meters
   * @param {Boolean} orMore flag
   * @return {boolean}
   */
  function scaleSelected (x, orMore = false) {
    scaleArray(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) {
    scaleArray(E40Instance.getAllPlaces(), x, orMore)
    return false
  }

  /**
   * Scale places to X m²
   * @param {Venue[]} elements
   * @param {Number} x square meters
   * @param {Boolean} orMore flag
   */
  function scaleArray (elements, x, orMore = false) {
    console.groupCollapsed(
      '%c' + NAME + ': 📏 %c try to scale ' + (elements.length) + ' element(s) to ' + x + 'm²',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )
    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)

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

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

  /**
   * Orthogonalize selected place(s)
   * @return {boolean}
   */
  function orthogonalize () {
    orthogonalizeArray(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
    orthogonalizeArray(
      E40Instance.getAllPlaces(['OUTDOORS', 'PARKING_LOT', 'NATURAL_FEATURES'])
    )
    return false
  }

  /**
   * Orthogonalize place(s)
   * @param {Venue[]} elements
   */
  function orthogonalizeArray (elements) {
    console.groupCollapsed(
      '%c' + NAME + ': ⬛️ %c try to orthogonalize ' + (elements.length) + ' element(s)',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )
    let total = 0
    // skip points
    for (let i = 0; i < elements.length; i++) {
      try {
        let geometry = orthogonalizeGeometry(elements[i].geometry)

        // console.log(elements[i].geometry.coordinates[0], geometry.coordinates[0])

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

  /**
   * Orthogonalizes a polygon's geometry by iteratively snapping angles
   * to be closer to 90 or 180 degrees.
   *
   * This is a refactor of your original algorithm to use Turf.js for
   * projections and data handling.
   *
   * @param {Feature<Polygon>|Polygon} geojsonPolygon The polygon to modify.
   * @param {number} [threshold=12] Degrees within 90 or 180 to "snap".
   * @returns {Feature<Polygon>} A new polygon with snapped vertices.
   */
  function orthogonalizeGeometry(geojsonPolygon, threshold = 12) {

    // --- Threshold setup (from original) ---
    const nomThreshold = threshold; // degrees within right or straight to alter
    const lowerThreshold = Math.cos((90 - nomThreshold) * Math.PI / 180);
    const upperThreshold = Math.cos(nomThreshold * Math.PI / 180);

    // --- Vector Math Helpers (unchanged from original) ---
    function subtractPoints(a, b) {
      return { x: a.x - b.x, y: a.y - b.y };
    }
    function addPoints(a, b) {
      return { x: a.x + b.x, y: a.y + b.y };
    }
    function euclideanDistance(a, b) {
      let x = a.x - b.x, y = a.y - b.y;
      return Math.sqrt((x * x) + (y * y));
    }
    function normalizePoint(point, scale) {
      let vector = { x: 0, y: 0 };
      let length = Math.sqrt(point.x * point.x + point.y * point.y);
      if (length !== 0) {
        vector.x = point.x / length;
        vector.y = point.y / length;
      }
      vector.x *= scale;
      vector.y *= scale;
      return vector;
    }

    // --- Algorithm Helpers (unchanged from original) ---
    function filterDotProduct(dotp) {
      if (lowerThreshold > Math.abs(dotp) || Math.abs(dotp) > upperThreshold) {
        return dotp;
      }
      return 0;
    }

    function normalizedDotProduct(i, points) {
      let a = points[(i - 1 + points.length) % points.length],
        b = points[i],
        c = points[(i + 1) % points.length],
        p = subtractPoints(a, b),
        q = subtractPoints(c, b);

      p = normalizePoint(p, 1.0);
      q = normalizePoint(q, 1.0);

      return p.x * q.x + p.y * q.y;
    }

    function squareness(points) {
      return points.reduce(function (sum, val, i, array) {
        let dotp = normalizedDotProduct(i, array);
        dotp = filterDotProduct(dotp);
        return sum + 2.0 * Math.min(Math.abs(dotp - 1.0), Math.min(Math.abs(dotp), Math.abs(dotp + 1)));
      }, 0);
    }

    // --- Core iterative function, rewritten to use Turf ---
    function Orthogonalize(polygon) {
      // 1. Project to Mercator (EPSG:3857) to work with planar {x, y} coordinates
      const projectedPoly = turf.toMercator(polygon);
      const coords = turf.getCoords(projectedPoly)[0];

      // 2. Convert to algorithm's {x, y} format, remove closing point
      let points = coords.slice(0, -1).map(c => ({ x: c[0], y: c[1] }));

      let corner = { i: 0, dotp: 1 };
      const epsilon = 1e-4;
      let i, j, score, motions;

      // This helper must be in this scope to access `corner`
      function calcMotion(b, i, array) {
        let a = array[(i - 1 + array.length) % array.length],
          c = array[(i + 1) % array.length],
          p = subtractPoints(a, b),
          q = subtractPoints(c, b),
          scale, dotp;

        scale = 2 * Math.min(euclideanDistance(p, { x: 0, y: 0 }), euclideanDistance(q, { x: 0, y: 0 }));
        p = normalizePoint(p, 1.0);
        q = normalizePoint(q, 1.0);

        dotp = filterDotProduct(p.x * q.x + p.y * q.y);

        // Nasty hack from original
        if (array.length > 3) {
          if (dotp < -0.707106781186547) {
            dotp += 1.0;
          }
        } else if (dotp && Math.abs(dotp) < corner.dotp) {
          corner.i = i;
          corner.dotp = Math.abs(dotp);
        }
        return normalizePoint(addPoints(p, q), 0.1 * dotp * scale);
      }

      // 3. Run the iterative algorithm

      // --- Handle 3-point case (Triangle) ---
      // (Original checks nodes.length === 4, which is 3 unique points)
      if (points.length === 3) {
        for (i = 0; i < 1000; i++) {
          motions = points.map(calcMotion);

          // Only move the "sharpest" corner
          let tmp = addPoints(points[corner.i], motions[corner.i]);
          points[corner.i].x = tmp.x;
          points[corner.i].y = tmp.y;

          score = corner.dotp;
          if (score < epsilon) {
            break;
          }
        }
      }
      // --- Handle N-point case ---
      else {
        let best;
        score = Infinity;

        for (i = 0; i < 1000; i++) {
          motions = points.map(calcMotion);
          for (j = 0; j < motions.length; j++) {
            let tmp = addPoints(points[j], motions[j]);
            points[j].x = tmp.x;
            points[j].y = tmp.y;
          }

          let newScore = squareness(points);
          if (newScore < score) {
            best = points.map(p => ({ ...p })); // Store a copy of the best points
            score = newScore;
          }
          if (score < epsilon) {
            break;
          }
        }
        points = best;
      }

      // 4. Remove collinear points (original's final loop)
      let finalCoords = [];
      if (points) {
        for (i = 0; i < points.length; i++) {
          let dotp = normalizedDotProduct(i, points);
          // if angle is not ~180 degrees, keep the point
          if (dotp > -1 + epsilon) {
            finalCoords.push([points[i].x, points[i].y]);
          }
        }
      } else {
        // Algorithm failed or points was undefined
        return polygon;
      }

      // 5. Convert back to GeoJSON
      if (finalCoords.length < 3) {
        console.warn("Orthogonalization failed, returning original polygon.");
        return polygon; // Algorithm failed
      }

      finalCoords.push(finalCoords[0]); // Close the polygon ring

      // Create a new polygon from the modified (and still projected) coords
      const newProjectedPoly = turf.polygon([finalCoords]);

      // Project back to WGS84 (lat/lon)
      const newGeoJsonPoly = turf.toWgs84(newProjectedPoly);

      // Preserve properties from the original
      newGeoJsonPoly.properties = turf.getType(geojsonPolygon) === 'Feature' ?
        geojsonPolygon.properties : {};

      return newGeoJsonPoly;
    }

    // --- Entry point of the main function ---
    let polygon = Orthogonalize(geojsonPolygon);
    return polygon.geometry
  }

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

  /**
   * Smooth place(s)
   * @param {Venue[]} elements
   */
  function smoothArray (elements) {
    console.groupCollapsed(
      '%c' + NAME + ': ⚫️ %c try to smooth ' + (elements.length) + ' element(s)',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )
    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) {
          E40Instance.wmeSDK.DataModel.Venues.updateVenue({
            venueId: elements[i].id, geometry
          })
          total++
        }
      } catch (e) {
        console.log('skipped', e)
      }
    }
    console.log(total + ' element(s) was smoothed')
    console.groupEnd()
  }

  /**
   * Simplify selected place(s)
   * @param {Number} tolerance
   * @return {boolean}
   */
  function simplify (tolerance = 0.00001) {
    simplifyArray(
      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
    simplifyArray(
      E40Instance.getAllPlaces(['OUTDOORS', 'PARKING_LOT', 'NATURAL_FEATURES']),
      tolerance
    )
    return false
  }

  /**
   * Simplify place(s)
   * @param {Venue[]} elements
   * @param {Number} tolerance
   */
  function simplifyArray (elements, tolerance = 0.00001) {
    console.groupCollapsed(
      '%c' + NAME + ': < tolerance=' + tolerance + ' > %c try to simplify ' + (elements.length) + ' element(s)',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )
    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) {
          E40Instance.wmeSDK.DataModel.Venues.updateVenue({
            venueId: elements[i].id, geometry
          })
          total++
        }
      } catch (e) {
        console.log('skipped', e)
      }
    }
    console.log(total + ' element(s) was simplified')
    console.groupEnd()
  }
  /**
   * Copy selected places
   * Last of them will be chosen
   */
  function copyPlaces () {
    let venues = E40Instance.getSelectedPlaces()

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

  /**
   * Compare two polygons point-by-point
   *
   * @param {Array} coordinates1
   * @param {Array} coordinates2
   * @return boolean
   */
  function 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
  }

  /**
   * wmeSDK.Map.enablePolygonResize()
   */
  function enablePolygonResize () {
    console.log('%c' + NAME + ': %c enable resize')
    E40Instance.wmeSDK.Map.enablePolygonResize()
  }

  /**
   * wmeSDK.Map.enablePolygonRotation()
   */
  function enablePolygonRotation() {
    console.log('%c' + NAME + ': %c enable rotation')
    E40Instance.wmeSDK.Map.enablePolygonRotation()
  }

  /**
   * Create copy for place
   * @param {Venue} venue
   */
  function copyPlace (venue) {
    console.log(
      '%c' + NAME + ': %c created a copy of the POI ' + venue.name,
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )

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

    let venueId = E40Instance.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),
    }

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

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

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

})()