WME E40 Geometry

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

// ==UserScript==
// @name         WME E40 Geometry
// @name:uk      WME 🇺🇦 E40 Geometry
// @name:ru      WME 🇺🇦 E40 Geometry
// @version      0.8.0
// @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: button.shortcut })) {
              this.wmeSDK.Shortcuts.createShortcut(shortcut);
            } else {
              this.log('Shortcut already in use')
            }
          }
        }
      }
    }

    /**
     * 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 address = E40Instance.wmeSDK.DataModel.Venues.getAddress( { venueId: venue.id } )
    let geometry = venue.geometry

    // little move for new POI, uses geoJSON
    for (let i = 0; i < geometry.coordinates[0].length; i++) {
      geometry.coordinates[0][i][0] += 0.0001
      geometry.coordinates[0][i][1] += 0.00005
    }

    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)

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

})()