Source: control/ControlTriangulation.js

import { unByKey } from 'ol/Observable'

import OlControl from 'ol/control/Control'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import { RegularShape, Fill, Stroke, Style, Text } from 'ol/style'
import Point from 'ol/geom/Point'
import Feature from 'ol/Feature'

import OlExtElement from 'ol-ext/util/element'

import { createButton, createInteractiveHelp } from './ControlUtils'
import ControlCreateTools from './ControlCreateTools'

const layerName = '__kmapv_control_triangulation'
const stroke = new Stroke({ color: 'red', width: 2 })

const fill = new Fill({ color: 'red' })

const texts = {
  errorMinDist: 'La distance entre les points est trop courte',
  errorMaxDist: 'La distance entre les points est trop éloignée',
  errorPointManquant: 'Veuillez choisir les points de relevé',
  errorRayonNaN: 'La distance saisie n\'est pas valide',
  helpTitle: 'Outil de triangulation',
  helptooltip: 'Afficher / Masquer l\'aide',
  help1: 'Cet outil calcule les points distants de deux positions choisi',
  help2: 'Pour chaque points de relevé :',
  help3: 'Utiliser <i class="kmapv-icon kmapv-icon-map-marker"></i> pour choisir la position',
  help4: 'Saisir la distance du point à retrouver',
  help5: 'Lancer le calcul',
  help6: 'Effacer tous les résultats',
  help7: 'Arrêter d\'utiliser l\'outil',
  point: 'Repère',
  distance: 'Distance',
  valid: 'Valider',
  clear: 'Effacer',
  cancel: 'Annuler',

}

const styles = {
  cross: new Style({
    image: new RegularShape({
      fill,
      stroke,
      points: 4,
      radius: 10,
      radius2: 0,
      angle: 0,
    }),
  }),
  label: new Style({
    text: new Text({
      textAlign: 'start',
      textBaseline: 'top',
      font: '14px sans-serif',
      text: 'gg',
      offsetX: 5,
      offsetY: 5,
      fill,
    }),
  }),
  x: new Style({
    image: new RegularShape({
      fill,
      stroke,
      points: 4,
      radius: 10,
      radius2: 0,
      angle: Math.PI / 4,
    }),
  }),

}

/** Control de création de deux point par triangulation
 * L'utilisateur choisi deux point et deux distance
 * L'outil pose deux nouveaux points par calcul de l'intersection de deux cercles
 *
 * formules:
 *  http://math.15873.pagesperso-orange.fr/IntCercl.html
 *  https://www.geogebra.org/m/sfasuhk7
 *
 *  code fonctionnel:
 *  https://codepen.io/BenjaminS/pen/ZWqVRy?editors=1111
 *
*/
class ControlTriangulation extends OlControl {
  constructor (options) {
    const className = options.className !== undefined ? options.className : ''

    const classNames = (className || '') + ' kmapv-bottom-tools kmap-triangulation-tools' + (options.target ? '' : ' ol-unselectable ol-control')

    const element = OlExtElement.create('DIV', {
      className: classNames,
    })

    super({
      element,
      target: options.target,
    })
    if (!options.viewer) {
      console.error('[ControlTriangulation] options.viewer non présent')
      return
    }
    this.viewer = options.viewer
    this.snapOptions = options.snapOptions
    this.toolId = options.toolId || 'triangulation-tool'

    // #region build de l'ihm

    this.buildIhm(element)

    // #endregion

    // créer un calque spécifique triangulation si il n'existe pas (ou utiliser celui recu dans les options)
    // #region Calque et interraction nécessaire a cet outil
    if (options.layer) {
    // on a fourni un layer sur lequel trianguler
      this.layer = options.layer
    } else {
    // sinon on le créer une seule fois dans l'appli
      if (this.viewer.commonLayer.layerExist(layerName, 'SYSTEM')) {
      // remet tjs le calque au dessus
        this.viewer.commonLayer.upLayer(layerName, 'SYSTEM')
        this.layer = this.viewer.commonLayer.getLayer(layerName, 'SYSTEM')
      } else {
      /**
      * @type {VectorLayer}
      */
        this.layer = new VectorLayer({
          [this.viewer.commonLayer.propertiesName.ID_LAYER]: layerName,
          source: new VectorSource(),
          style: styles.x,
          displayInLayerSwitcher: false,
        })
        this.viewer.commonLayer.addLayer(this.layer, 'SYSTEM')
        this.viewer.getFeatureSnapper().addSource({ groups: [{ name: 'KMAPV', edge: true, vertex: true }], source: this.layer.getSource() })
      }
    }

    // #endregion

    // garde l'état d'origine de la barre horizontal
    this.horizontalToolbarVisility = this.viewer.horizontalToolbar.getVisible()
    this.viewer.horizontalToolbar.setVisible(false)

    // etant donné qu'on va se placer en bas on masque la barre horizontal du viewer puis on le remttra quand le control sera retiré

    // lorsque ce controle est retiré de la carte, on arrête d'écouter les event
    const listenerRemoveControl = this.viewer.Map.getControls().on('remove', (ev) => {
      if (ev.element === this) {
        // remettre la barre horizontal dans son état d'origine
        this.viewer.horizontalToolbar.setVisible(this.horizontalToolbarVisility)
        unByKey(listenerRemoveControl)
      }
    })

  /* OlControl.call(this, {
    element: element,
    target: options.target,
  }) */
  }
}

/** Set the control visibility
 * @param {boolean} visibility
 */
ControlTriangulation.prototype.setVisible = function (visibility) {
  if (visibility) this.element.style.display = ''
  else this.element.style.display = 'none'
}

/** construction de la toolbar */
ControlTriangulation.prototype.buildIhm = function (element) {
  const validHtml = `<i class="kmapv-icon kmapv-icon-valid"></i><span>${texts.valid}</span>`
  const clearHtml = `<i class="kmapv-icon kmapv-icon-clear"></i><span>${texts.clear}</span>`
  const cancelHtml = `<i class="kmapv-icon kmapv-icon-cancel"></i><span>${texts.cancel}</span>`
  this.menuMain = OlExtElement.create('DIV', {
    className: 'tools',
  })

  // #region aide interractive
  const helpTexts = [
    texts.help1,
    `<b>${texts.help2}</b>`,
    texts.help3,
    texts.help4,
    `${validHtml} ${texts.help5}`,
    `${clearHtml} ${texts.help6}`,
    `${cancelHtml} ${texts.help7}`]

  const { helpTitle, helpDiv } = createInteractiveHelp({
    title: texts.helpTitle,
    buttonTitle: texts.helptooltip,
    helpTexts,
    joinText: '<br/>',
  })

  element.appendChild(helpTitle)
  element.appendChild(helpDiv)
  // #endregion

  this.errorPlace = OlExtElement.create('label', { style: { color: 'red' } })
  element.appendChild(this.errorPlace)

  const inputsContainer = OlExtElement.create('div', {
    className: 'input-container',
  })

  const input1Container = OlExtElement.create('span', { className: 'input-wrapper' })
  input1Container.appendChild(OlExtElement.create('span', { text: texts.distance }))

  this.input1 = document.createElement('input')
  this.input1.setAttribute('type', 'number')
  this.input1.setAttribute('value', 1)
  this.input1.setAttribute('min', '0')
  input1Container.appendChild(this.input1)

  const ligne1Container = OlExtElement.create('div', { })

  ligne1Container.appendChild(OlExtElement.create('label', { text: `${texts.point} 1 : ` }))
  ligne1Container.appendChild(input1Container)
  ligne1Container.appendChild(createButton('<i class="kmapv-icon kmapv-icon-plus"></i>', () => this.input1.stepUp()))
  ligne1Container.appendChild(createButton('<i class="kmapv-icon kmapv-icon-minus"></i>', () => this.input1.stepDown()))
  ligne1Container.appendChild(createButton('<i class="kmapv-icon kmapv-icon-map-marker"></i>', onClickAddPoint.bind(this, '1')))

  const input2Container = OlExtElement.create('span', { className: 'input-wrapper' })
  input2Container.appendChild(OlExtElement.create('span', { text: texts.distance }))

  this.input2 = document.createElement('input')
  this.input2.setAttribute('type', 'number')
  this.input2.setAttribute('value', 1)
  this.input2.setAttribute('min', '0')
  input2Container.appendChild(this.input2)

  const ligne2Container = OlExtElement.create('div', { })
  ligne2Container.appendChild(OlExtElement.create('label', { text: `${texts.point} 2 : ` }))
  ligne2Container.appendChild(input2Container)
  ligne2Container.appendChild(createButton('<i class="kmapv-icon kmapv-icon-plus"></i>', () => this.input2.stepUp()))
  ligne2Container.appendChild(createButton('<i class="kmapv-icon kmapv-icon-minus"></i>', () => this.input2.stepDown()))
  ligne2Container.appendChild(createButton('<i class="kmapv-icon kmapv-icon-map-marker"></i>', onClickAddPoint.bind(this, '2')))

  inputsContainer.appendChild(ligne1Container)
  inputsContainer.appendChild(ligne2Container)

  const validBtn = createButton(validHtml, onClickValid.bind(this))
  const clearBtn = createButton(clearHtml, onClickClear.bind(this))
  const cancelBtn = createButton(cancelHtml, onClickCancel.bind(this))

  this.menuMain.appendChild(inputsContainer)
  this.menuMain.appendChild(validBtn)
  this.menuMain.appendChild(clearBtn)
  this.menuMain.appendChild(cancelBtn)

  element.appendChild(this.menuMain)
}

// gestion des interaction
let onDrawEndListener = null
let onDrawAbortListener = null
let createTool = null
const removeInterraction = function () {
  unByKey(onDrawAbortListener)
  unByKey(onDrawEndListener)
  this.setVisible(true)
  if (createTool) {
    this.viewer.Map.removeControl(createTool)
  }
  this.viewer.dataLayer.changeMapMode('select', this.toolId)
  onDrawEndListener = null
  onDrawAbortListener = null
  createTool = null
}

/** Ajout d'un un point temporaire pour la triangulation */
const onClickAddPoint = function (numPoint) {
  // Ajout d'une geométrie a la multi-géométrie: on utilise nos contrôle habituel afin de positionner le point puis on va
  // afin d'ajouter des morceau de geom a notre calque d'édition

  this.viewer.dataLayer.changeMapMode('_drawPointTouch', this.toolId)
  const mapMode = this.viewer.commonLayer.getCurrentMapMode()
  const [createInteraction] = mapMode.interactions

  // on utilise l'event OL plutot que viewer car meilleure gestion du scope
  onDrawEndListener = createInteraction.on('drawend', (ev) => {
    const id = 'point_depart_' + numPoint

    if (this.layer.getSource().getFeatureById(id)) {
      this.layer.getSource().getFeatureById(id).setGeometry(ev.feature.getGeometry())
    } else {
      ev.feature.setId(id)
      ev.feature.set('numPoint', numPoint)
      // ev.feature.set('type', 'point_construction')
      ev.feature.setStyle((feature) => {
        styles.label.getText().setText(numPoint)
        return [
          styles.cross,
          styles.label,
        ]
      })
      this.layer.getSource().addFeature(ev.feature)
    }
    // on remet en mapmode normal
    removeInterraction.call(this)
  })
  onDrawAbortListener = createInteraction.on('drawabort', (ev) => {
    // on remet en mapmode normal
    removeInterraction.call(this)
  })

  const createToolOptions = {
    // la boite d'outils de création ne fait qu'utiliser l'interaction drawtouch venant de datalayer, on fourni la bonne
    viewer: this.viewer,
    geometryType: 'Point',
    interaction: createInteraction,
    title: 'Point d\'insertion ' + numPoint,
    digitalizeOptions: {
      snapOptions: this.snapOptions,
    },
  }

  this.setVisible(false)

  createTool = new ControlCreateTools(createToolOptions)
  this.viewer.Map.addControl(createTool)
}

const onClickCancel = function () {
  this.viewer.Map.removeControl(this)
}

const onClickClear = function () {
  this.layer.getSource().clear()
}

const onClickValid = function () {
  this.errorPlace.textContent = null
  const pt0 = this.layer.getSource().getFeatures().find((feature) => feature.get('numPoint') === '1')
  const pt1 = this.layer.getSource().getFeatures().find((feature) => feature.get('numPoint') === '2')
  const r0 = this.input1.value
  const r1 = this.input2.value

  if (!pt0 || !pt1) {
    this.errorPlace.textContent = texts.errorPointManquant
    return
  }

  if (r0 === null || r1 === null || r0 === '' || r1 === '' || isNaN(r0) || isNaN(r1)) {
    this.errorPlace.textContent = texts.errorRayonNaN
    return
  }

  const pt0Coords = pt0.getGeometry().getCoordinates()
  const pt1Coords = pt1.getGeometry().getCoordinates()
  const rtn = intersection(pt0Coords[0], pt0Coords[1], parseFloat(r0), pt1Coords[0], pt1Coords[1], parseFloat(r1))
  if (Array.isArray(rtn)) {
    this.layer.getSource().addFeature(new Feature(new Point([rtn[0], rtn[1]])))
    this.layer.getSource().addFeature(new Feature(new Point([rtn[2], rtn[3]])))
  } else {
    this.errorPlace.textContent = rtn
  }
}

function intersection (x0, y0, r0, x1, y1, r1) {
  /* dx and dy are the vertical and horizontal distances between
   * the circle centers.
   */
  const dx = x1 - x0
  const dy = y1 - y0

  /* Determine the straight-line distance between the centers. */
  const d = Math.sqrt((dy * dy) + (dx * dx))

  /* Check for solvability. */
  if (d > (r0 + r1)) {
    /* no solution. circles do not intersect. */
    return texts.errorMinDist
  }
  if (d < Math.abs(r0 - r1)) {
    /* no solution. one circle is contained in the other */
    return texts.errorMaxDist
  }

  /* 'point 2' is the point where the line through the circle
   * intersection points crosses the line between the circle
   * centers.
   */

  /* Determine the distance from point 0 to point 2. */
  const a = ((r0 * r0) - (r1 * r1) + (d * d)) / (2.0 * d)

  /* Determine the coordinates of point 2. */
  const x2 = x0 + (dx * a / d)
  const y2 = y0 + (dy * a / d)

  /* Determine the distance from point 2 to either of the
   * intersection points.
   */
  const h = Math.sqrt((r0 * r0) - (a * a))

  /* Now determine the offsets of the intersection points from
   * point 2.
   */
  const rx = -dy * (h / d)
  const ry = dx * (h / d)

  /* Determine the absolute intersection points. */
  const xi = x2 + rx
  const xiPrime = x2 - rx
  const yi = y2 + ry
  const yiPrime = y2 - ry

  return [xi, yi, xiPrime, yiPrime]
}

export default ControlTriangulation