WME Address Point Helper

Creates point with same address

// ==UserScript==
// @name         WME Address Point Helper
// @description  Creates point with same address
// @version      2.5.3
// @license      MIT License
// @author       Andrei Pavlenko, Anton Shevchuk
// @namespace    https://greasyfork.org/ru/users/160654-waze-ukraine
// @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/1218867/WME-Bootstrap.js
// @require      https://update.greasyfork.org/scripts/452563/1218878/WME.js
// @require      https://update.greasyfork.org/scripts/450221/1137043/WME-Base.js
// @require      https://update.greasyfork.org/scripts/450320/1281847/WME-UI.js
// @require      https://update.greasyfork.org/scripts/480123/1281900/WME-EntryPoint.js

// ==/UserScript==

/* jshint esversion: 8 */
/* global require */
/* global $, jQuery */
/* global W, W.model */
/* global I18n */
/* global OpenLayers */
/* global NavigationPoint */
/* global WME, WMEBase, WMEUI, WMEUIHelper */
/* global Container, Settings, SimpleCache, Tools  */

(function () {
  'use strict'

  // Script name, uses as unique index
  const NAME = 'ADDRESS-POINT-HELPER'

  const TRANSLATION = {
    'en': {
      title: 'APH📍',
      description: 'Address Point Helper 📍',
      buttons: {
        createPoint: 'Clone to POI',
        createResidential: 'Clone to AT',
        newPoint: 'Create new point'
      },
      settings: {
        title: 'Options',
        addNavigationPoint: 'Add entry point',
        inheritNavigationPoint: 'Inherit parent\'s landmark entry point',
        autoSetHNToName: 'Copy house number into name',
        noDuplicates: 'Do not create duplicates'
      }
    },
    'uk': {
      title: 'APH📍',
      description: 'Address Point Helper 📍',
      buttons: {
        createPoint: 'Клон до POI',
        createResidential: 'Клон до АТ',
        newPoint: 'Створити нову точку POI'
      },
      settings: {
        title: 'Налаштування',
        addNavigationPoint: 'Додавати точку в\'їзду',
        inheritNavigationPoint: 'Наслідувати точку в\'їзду від POI',
        autoSetHNToName: 'Копіювати номер будинку в назву',
        noDuplicates: 'Не створювати дублікатів'
      }
    },
    'ru': {
      title: 'APH📍',
      description: 'Address Point Helper 📍',
      buttons: {
        createPoint: 'Клон в POI',
        createResidential: 'Клон в АТ',
        newPoint: 'Создать новую точку POI'
      },
      settings: {
        title: 'Настройки',
        addNavigationPoint: 'Создавать точку въезда',
        inheritNavigationPoint: 'Наследовать точку въезда от POI',
        autoSetHNToName: 'Копировать номер дома в название',
        noDuplicates: 'Не создавать дубликатов'
      }
    }
  }

  const STYLE = '.address-point-helper legend { cursor:pointer; font-size: 12px; font-weight: bold; width: auto; text-align: right; border: 0; margin: 0; padding: 0 8px; }' +
    '.address-point-helper fieldset { border: 1px solid #ddd; padding: 4px; }' +
    '.address-point-helper fieldset div.controls label { white-space: normal; }' +
    'button.address-point-helper { border: 1px solid #ddd; margin-right: 2px; }' +
    'p.address-point-helper-info { border-top: 1px solid #ccc; color: #777; font-size: x-small; margin-top: 15px; padding-top: 10px; text-align: center; }'

  WMEUI.addTranslation(NAME, TRANSLATION)
  WMEUI.addStyle(STYLE)

  // Set shortcuts title
  WMEUIShortcut.setGroupTitle(NAME, I18n.t(NAME).description)

  // default settings
  const SETTINGS = {
    addNavigationPoint: false,
    inheritNavigationPoint: false,
    autoSetHNToName: false,
    noDuplicates: false
  }

  const BUTTONS = {
    A: {
      title: '<i class="w-icon w-icon-node"></i> ' + I18n.t(NAME).buttons.createPoint,
      description: I18n.t(NAME).buttons.createPoint,
      shortcut: 'A+G',
      callback: () => createPoint()
    },
    B: {
      title: '<i class="fa fa-map-marker"></i> ' + I18n.t(NAME).buttons.createResidential,
      description: I18n.t(NAME).buttons.createResidential,
      shortcut: 'A+H',
      callback: () => createResidential()
    },
  }

  let scriptSettings = new Settings(NAME, SETTINGS)

  class APH extends WMEBase {
    constructor (name, settings) {
      super(name, settings)

      this.helper = new WMEUIHelper(NAME)

      // Create tab for settings
      this.tab = this.helper.createTab(
        I18n.t(NAME).title,
        {
          icon: 'home'
        }
      )

      // Setup options
      let fieldsetSettings = this.helper.createFieldset(I18n.t(NAME).settings.title)

      for (let item in settings.container) {
        if (settings.container.hasOwnProperty(item)) {
          fieldsetSettings.addCheckbox(
            item,
            I18n.t(NAME).settings[item],
            event => settings.set([item], event.target.checked),
            settings.get(item)
          )
        }
      }
      this.tab.addElement(fieldsetSettings)

      this.tab.addText(
        'info',
        '<a href="' + GM_info.scriptUpdateURL + '">' + GM_info.script.name + '</a> ' + GM_info.script.version
      )

      this.tab.inject()

      // Create panel for POI
      this.panel = this.helper.createPanel(I18n.t(NAME).title)
      this.panel.addButtons(BUTTONS)

      /* name, desc, group, title, shortcut, callback, scope */
      new WMEUIShortcut(
        this.name + '_new_point',
        I18n.t(NAME).buttons.newPoint,
        this.name,
        I18n.t(NAME).buttons.newPoint,
        '80', // P
        () => $('.toolbar-group-item.other').find('wz-button.point').click()
      ).register()
    }

    /**
     * Handler for `venue.wme` event
     * @param {jQuery.Event} event
     * @param {HTMLElement} element
     * @param {W.model} model
     * @return {null|void}
     */
    onVenue (event, element, model) {
      if (!model.isGeometryEditable()) {
        return
      }
      if (element.querySelector('div.form-group.address-point-helper')) {
        return
      }
      element.prepend(this.panel.html())

      $('button.address-point-helper-A').prop('disabled', !validateForPoint())
      $('button.address-point-helper-B').prop('disabled', !validateForResidential())
    }

    /**
     * Handler for window `beforeunload` event
     * @param {jQuery.Event} event
     * @return {Null}
     */
    onBeforeUnload (event) {
      this.settings.save()
    }
  }

  $(document).on('bootstrap.wme', () => {
    new APH(NAME, scriptSettings)

    // Register handler for changes
    registerEventListeners()
  })

  function createPoint (isResidential = false) {
    console.groupCollapsed(
      '%c' + NAME + ': 📍%c try to create ' + (isResidential ? 'residential ' : '') + 'point',
      'color: #0DAD8D; font-weight: bold',
      'color: dimgray; font-weight: normal'
    )

    if ((!validateForPoint() && !isResidential)
      || (!validateForResidential() && isResidential)) {
      console.log('Invalid point')
      console.groupEnd()
      return
    }

    let WazeFeatureVectorLandmark = require('Waze/Feature/Vector/Landmark')
    let WazeActionAddLandmark = require('Waze/Action/AddLandmark')
    let WazeActionUpdateObject = require('Waze/Action/UpdateObject')
    let WazeActionUpdateFeatureAddress = require('Waze/Action/UpdateFeatureAddress')

    let { lat, lon } = getPointCoordinates()
    let address = getSelectedLandmarkAddress()
    let lockRank = getPointLockRank()

    let pointGeometry = new OpenLayers.Geometry.Point(lon, lat)

    let NewPoint = new WazeFeatureVectorLandmark({
      geoJSONGeometry: W.userscripts.toGeoJSONGeometry(pointGeometry)
    })
    NewPoint.attributes.categories.push('OTHER')
    NewPoint.attributes.lockRank = lockRank
    NewPoint.attributes.residential = isResidential

    if (scriptSettings.get('addNavigationPoint')) {
      let newEntryPoint, parentEntryPoint = WME.getSelectedVenue().getAttributes().entryExitPoints[0]
      if (scriptSettings.get('inheritNavigationPoint') && parentEntryPoint !== undefined) {
        newEntryPoint = new entryPoint().with({primary: true, point: parentEntryPoint.getPoint()})
      } else {
        newEntryPoint = new entryPoint({primary: true, point: W.userscripts.toGeoJSONGeometry(pointGeometry.clone())})
      }
      NewPoint.attributes.entryExitPoints.push(newEntryPoint)
    }

    if (!!address.attributes.houseNumber) {
      NewPoint.attributes.name = address.attributes.houseNumber
      NewPoint.attributes.houseNumber = address.attributes.houseNumber
    }

    let newAddressAttributes = {
      streetName: address.getStreetName(),
      emptyStreet: false,
      stateID: address.getState().getID(),
      countryID: address.getCountry().getID(),
    }

    if (address.getCity().getID() === 55344
    || address.getCityName() === 'поза НП') {
      newAddressAttributes.cityName = ''
      newAddressAttributes.emptyCity = true
    } else {
      newAddressAttributes.cityName = address.getCityName()
      newAddressAttributes.emptyCity = false
    }

    if (scriptSettings.get('noDuplicates') && hasDuplicate(NewPoint, newAddressAttributes, isResidential)) {
      console.log('This point already exists.')
      console.groupEnd()
      return
    }

    W.selectionManager.unselectAll()
    let addedLandmark = new WazeActionAddLandmark(NewPoint)
    W.model.actionManager.add(addedLandmark)
    W.model.actionManager.add(new WazeActionUpdateFeatureAddress(NewPoint, newAddressAttributes))
    if (!!address.attributes.houseNumber) {
      W.model.actionManager.add(new WazeActionUpdateObject(NewPoint, { houseNumber: address.attributes.houseNumber }))
    }
    W.selectionManager.setSelectedModels([addedLandmark.venue])
    console.log('The point was created.')
    console.groupEnd()
  }

  function createResidential () {
    createPoint(true)
  }

  function validateForPoint () {
    if (!WME.getSelectedVenue()) return false
    let selectedPoiHN = getSelectedLandmarkAddress().attributes.houseNumber
    return /\d+/.test(selectedPoiHN)
  }

  function validateForResidential () {
    if (!WME.getSelectedVenue()) return false
    let selectedPoiHN = getSelectedLandmarkAddress().attributes.houseNumber
    return /^\d+[А-ЯЇІЄ]{0,3}$/i.test(selectedPoiHN)
  }

  function getSelectedLandmarkAddress () {
    return WME.getSelectedVenue().getAddress()
  }

  function getPointLockRank () {
    let selectedLandmark = WME.getSelectedVenue()
    let userRank = W.loginManager.user.attributes.rank
    let parentFeatureLockRank = selectedLandmark.getLockRank()

    if (userRank >= parentFeatureLockRank) {
      return parentFeatureLockRank
    } else if (userRank >= 1) {
      return 1
    } else {
      return 0
    }
  }

  function getPointCoordinates () {
    let selectedLandmark = WME.getSelectedVenue()
    let selectedLandmarkGeometry = selectedLandmark.getOLGeometry()

    let coords
    if (/polygon/i.test(selectedLandmarkGeometry.id)) {
      let polygonCenteroid = selectedLandmarkGeometry.components[0].getCentroid()
      let geometryComponents = selectedLandmarkGeometry.components[0].components
      let flatComponentsCoords = []
      geometryComponents.forEach(c => flatComponentsCoords.push(c.x, c.y))
      let interiorPoint = getInteriorPointOfArray(
        flatComponentsCoords,
        2, [polygonCenteroid.x, polygonCenteroid.y]
      )
      coords = {
        lon: interiorPoint[0],
        lat: interiorPoint[1]
      }
    } else {
      coords = {
        lon: selectedLandmarkGeometry.x,
        lat: selectedLandmarkGeometry.y
      }
    }

    coords.lon += 4 // shift by X
    coords.lat += 5 // shift by Y
    return coords
  }

  function hasDuplicate (poi, addr, isResidential) {
    const venues = W.model.venues.getObjectArray()

    for (let key in venues) {
      if (!venues.hasOwnProperty(key)) continue
      const currentVenue = venues[key]
      const currentAddress = currentVenue.getAddress()

      let equalNames = true // or empty for residential
      if (!isResidential && !!currentVenue.attributes.name && !!poi.attributes.name) {
        if (currentVenue.attributes.name !== poi.attributes.name) {
          equalNames = false
        }
      }
      if (
        equalNames
        && poi.attributes.houseNumber === currentVenue.attributes.houseNumber
        && poi.attributes.residential === currentVenue.attributes.residential
        && addr.streetName === currentAddress.getStreetName()
        && addr.cityName === currentAddress.getCityName()
        && addr.countryID === currentAddress.getCountry().getID()
      ) {
        return true
      }
    }
    return false
  }

  function registerEventListeners () {
    let WazeActionUpdateObject = require('Waze/Action/UpdateObject')

    W.model.actionManager.events.register('afteraction', null, action => {
      // Задаем номер дома в название, если нужно. Пока не нашел более лаконичного способа определить что
      // произошло именно изменение адреса. Можно тестить регуляркой поле _description, но будут проблемы с
      // нюансами содержания этого поля на разных языках
      if (scriptSettings.get('autoSetHNToName')) {
        try {
          let subAction = action.action.subActions[0]
          let houseNumber = subAction.attributes.houseNumber
          let feature = subAction.feature
          if (feature.attributes.categories.includes('OTHER') && feature.attributes.name === '') {
            W.model.actionManager.add(new WazeActionUpdateObject(feature, { name: houseNumber }))
          }

          $('button.address-point-helper-A').prop('disabled', !validateForPoint())
          $('button.address-point-helper-B').prop('disabled', !validateForResidential())

        } catch (e) { /* Do nothing */ }
      }
    })
  }

  /**
   * @link https://github.com/openlayers/openlayers
   */
  function getInteriorPointOfArray (flatCoordinates, stride, flatCenters) {
    let offset = 0
    let flatCentersOffset = 0
    let ends = [flatCoordinates.length]
    let i, ii, x, x1, x2, y1, y2
    const y = flatCenters[flatCentersOffset + 1]
    const intersections = []
    // Calculate intersections with the horizontal line
    for (let r = 0, rr = ends.length; r < rr; ++r) {
      const end = ends[r]
      x1 = flatCoordinates[end - stride]
      y1 = flatCoordinates[end - stride + 1]
      for (i = offset; i < end; i += stride) {
        x2 = flatCoordinates[i]
        y2 = flatCoordinates[i + 1]
        if ((y <= y1 && y2 <= y) || (y1 <= y && y <= y2)) {
          x = (y - y1) / (y2 - y1) * (x2 - x1) + x1
          intersections.push(x)
        }
        x1 = x2
        y1 = y2
      }
    }
    // Find the longest segment of the horizontal line that has its center point
    // inside the linear ring.
    let pointX = NaN
    let maxSegmentLength = -Infinity
    intersections.sort(numberSafeCompareFunction)
    x1 = intersections[0]
    for (i = 1, ii = intersections.length; i < ii; ++i) {
      x2 = intersections[i]
      const segmentLength = Math.abs(x2 - x1)
      if (segmentLength > maxSegmentLength) {
        x = (x1 + x2) / 2
        if (linearRingsContainsXY(flatCoordinates, offset, ends, stride, x, y)) {
          pointX = x
          maxSegmentLength = segmentLength
        }
      }
      x1 = x2
    }
    if (isNaN(pointX)) {
      // There is no horizontal line that has its center point inside the linear
      // ring.  Use the center of the the linear ring's extent.
      pointX = flatCenters[flatCentersOffset]
    }

    return [pointX, y, maxSegmentLength]
  }

  function numberSafeCompareFunction (a, b) {
    return a > b ? 1 : a < b ? -1 : 0
  }

  function linearRingContainsXY (flatCoordinates, offset, end, stride, x, y) {
    // http://geomalgorithms.com/a03-_inclusion.html
    // Copyright 2000 softSurfer, 2012 Dan Sunday
    // This code may be freely used and modified for any purpose
    // providing that this copyright notice is included with it.
    // SoftSurfer makes no warranty for this code, and cannot be held
    // liable for any real or imagined damage resulting from its use.
    // Users of this code must verify correctness for their application.
    let wn = 0
    let x1 = flatCoordinates[end - stride]
    let y1 = flatCoordinates[end - stride + 1]
    for (; offset < end; offset += stride) {
      const x2 = flatCoordinates[offset]
      const y2 = flatCoordinates[offset + 1]
      if (y1 <= y) {
        if (y2 > y && ((x2 - x1) * (y - y1)) - ((x - x1) * (y2 - y1)) > 0) {
          wn++
        }
      } else if (y2 <= y && ((x2 - x1) * (y - y1)) - ((x - x1) * (y2 - y1)) < 0) {
        wn--
      }
      x1 = x2
      y1 = y2
    }
    return wn !== 0
  }

  function linearRingsContainsXY (flatCoordinates, offset, ends, stride, x, y) {
    if (ends.length === 0) {
      return false
    }
    if (!linearRingContainsXY(flatCoordinates, offset, ends[0], stride, x, y)) {
      return false
    }
    for (let i = 1, ii = ends.length; i < ii; ++i) {
      if (linearRingContainsXY(flatCoordinates, ends[i - 1], ends[i], stride, x, y)) {
        return false
      }
    }
    return true
  }
})()