Source: interaction/FeatureSnapper.js

/* eslint-disable no-unused-vars */
import EventType from 'ol/events/EventType'
import VectorEventType from 'ol/source/VectorEventType'

import Interaction from 'ol/interaction/Interaction'
import RBush from 'ol/structs/RBush'
import { boundingExtent, createEmpty } from 'ol/extent'

import {
  closestOnCircle,
  closestOnSegment,
  squaredDistance,
} from 'ol/coordinate'
import { fromCircle } from 'ol/geom/Polygon'
import {
  fromUserCoordinate,
  getUserProjection,
  toUserCoordinate,
} from 'ol/proj'

import { getUid } from 'ol/util'
import { listen, unlistenByKey } from 'ol/events'

/**
 * @param  {ol.source.Vector.VectorSourceEvent|ol.Collection.CollectionEvent} evt Event.
 * @return {ol.Feature} Feature.
 */
function getFeatureFromEvent (evt) {
  if (
    (evt).feature
  ) {
    return (evt).feature
  } else if (
    (evt).element
  ) {
    return (
      (evt).element
    )
  }
}

const tempSegment = []

class FeatureSnapper extends Interaction {
  /**
   * @param {Options} [optOptions] Options.
   */
  constructor (optOptions) {
    const options = optOptions || {}
    super(
      /** @type {ol.interaction.InteractionOptions} */ (options)
    )

    if (!options.viewer) {
      console.error('[FeatureSnapper] options.viewer non présent')
      return
    }
    this.viewer = options.viewer

    /**
     * Liste des sources utilisées
    * @type {Array<Object>}
    * @private
    */
    this.snapSources = {}

    /**
     * Index pour accéder rapidements aux infos des features
     * Extents
     * snapSourcesUids
     * snapGroups
     */
    this.indexedFeatures = {}

    /**
    * @type {Number}
    * @private
    */
    this.pixelTolerance_ =
     options.pixelTolerance !== undefined ? options.pixelTolerance : 10

    /**
    * Segment RTree for each layer
    * @type {ol.structs.RBush<SegmentData>}
    * @private
    */
    this.rBush_ = new RBush()

    /**
      * @const
      * @private
      * @type {Object<string, function(Array<Array<ol.coordinate.Coordinate>>, ol.geom.Geometry): void>}
      */
    this.GEOMETRY_SEGMENTERS_ = {
      Point: this.segmentPointGemetry_.bind(this),
      LineString: this.segmentLineStringGemetry_.bind(this),
      LinearRing: this.segmentLineStringGemetry_.bind(this),
      Polygon: this.segmentPolygonGemetry_.bind(this),
      MultiPoint: this.segmentMultiPointGemetry_.bind(this),
      MultiLineString: this.segmentMultiLineStringGemetry_.bind(this),
      MultiPolygon: this.segmentMultiPolygonGemetry_.bind(this),
      GeometryCollection: this.segmentGeometryCollectionGemetry_.bind(this),
      Circle: this.segmentCircleGemetry_.bind(this),
    }
  }

  /**
   * Remove the interaction from its current map and attach it to the new map.
   * Subclasses may set up event handlers to get notified about changes to
   * the map here.
   * @param {ol.PluggableMap} map Map.
   */
  setMap (map) {
    const currentMap = this.getMap()
    if (currentMap) {
      this.clear()
    }
    super.setMap(map)
    // TODO : recommencer l'indexation ... indexSource_
  }

  /**
   * @typdef {Object} snapOptions
   *  @property {Array<String>} snapGroups Noms des groupes (paramétrés via addSource) auquel s'accrocher
   *  @property {Number} [pixelTolerance] tolérance pixel d'accroche objet
   *  @property {Function} [filterFn] fonction pour exclure des features du snap
   *
   * @param {ol.pixel.Pixel} pixel Pixel
   * @param {ol.pixel.Pixel} pixel Pixel
   * @param {ol.coordinate.Coordinate} pixelCoordinate Coordinate
   * @param {snapOptions} snapOptions options for snap operation
   * @return {Result|null} Snap result
   */
  snapTo (pixel, pixelCoordinate, snapOptions) {
    const snapGroups = snapOptions?.snapGroups || []
    const pixelTolerance = snapOptions?.pixelTolerance || this.pixelTolerance_
    if (snapGroups.length === 0) {
      return null
    }

    const map = this.getMap()

    // récupère la zone d'intersection en fonction de la tolerance en poxel
    const lowerLeft = map.getCoordinateFromPixel([
      pixel[0] - pixelTolerance,
      pixel[1] + pixelTolerance,
    ])
    const upperRight = map.getCoordinateFromPixel([
      pixel[0] + pixelTolerance,
      pixel[1] - pixelTolerance,
    ])
    const box = boundingExtent([lowerLeft, upperRight])

    // recupère les segments dans la zone d'intersection pour les groups demandés dans snapOptions
    const segments = this.rBush_.getInExtent(box).filter((segmentData) => {
      if (!this.viewer.dataLayer.isVisibleFeature({ feature: segmentData.feature, evaluateStyles: false })) { return false }
      if (snapOptions.filterFn && !snapOptions.filterFn(segmentData.feature)) { return false }
      const featureInfos = this.indexedFeatures[getUid(segmentData.feature)]
      return snapGroups.some((group) => featureInfos.groups[group] !== undefined)
    })

    const segmentsLength = segments.length
    if (segmentsLength === 0) {
      return null
    }

    const projection = map.getView().getProjection()
    const projectedCoordinate = fromUserCoordinate(pixelCoordinate, projection)

    // les fonctions de recherche par vertex ou edge vont set ces variable puis appeler getResult
    let closestVertex
    let minSquaredDistance = Infinity

    const squaredPixelTolerance = pixelTolerance * pixelTolerance
    const getResult = () => {
      if (closestVertex) {
        const vertexPixel = map.getPixelFromCoordinate(closestVertex)
        const squaredPixelDistance = squaredDistance(pixel, vertexPixel)
        if (squaredPixelDistance <= squaredPixelTolerance) {
          return {
            vertex: closestVertex,
            vertexPixel: [
              Math.round(vertexPixel[0]),
              Math.round(vertexPixel[1]),
            ],
          }
        }
      }
      return null
    }

    // regroupe les features utilisable en mode vertex en fonctions des paramètres du groupe
    const segmentWithVertexOption = segments.filter((segmentData) => {
      const featureInfos = this.indexedFeatures[getUid(segmentData.feature)]
      return snapGroups.some((group) => featureInfos.groups[group] !== undefined && featureInfos.groups[group].vertex)
    })

    const segmentWithVertexOptionLength = segmentWithVertexOption.length
    if (segmentWithVertexOptionLength > 0) {
      for (let i = 0; i < segmentWithVertexOptionLength; ++i) {
        const segmentData = segmentWithVertexOption[i]
        if (segmentData.feature.getGeometry().getType() !== 'Circle') {
          segmentData.segment.forEach((vertex) => {
            const tempVertexCoord = fromUserCoordinate(vertex, projection)
            const delta = squaredDistance(projectedCoordinate, tempVertexCoord)
            if (delta < minSquaredDistance) {
              closestVertex = vertex
              minSquaredDistance = delta
            }
          })
        }
      }
      const result = getResult()
      if (result) {
        return result
      }
    }

    // regroupe les features utilisable en mode edge en fonctions des paramètres du groupe
    const segmentWithEdgeOption = segments.filter((segmentData) => {
      const featureInfos = this.indexedFeatures[getUid(segmentData.feature)]
      return snapGroups.some((group) => featureInfos.groups[group] !== undefined && featureInfos.groups[group].edge)
    })

    const segmentWithEdgeOptionLength = segmentWithEdgeOption.length

    if (segmentWithEdgeOptionLength > 0) {
      for (let i = 0; i < segmentWithEdgeOptionLength; ++i) {
        let vertex = null
        const segmentData = segmentWithEdgeOption[i]
        if (segmentData.feature.getGeometry().getType() === 'Circle') {
          let circleGeometry = segmentData.feature.getGeometry()
          const userProjection = getUserProjection()
          if (userProjection) {
            circleGeometry = circleGeometry
              .clone()
              .transform(userProjection, projection)
          }
          vertex = toUserCoordinate(
            closestOnCircle(
              projectedCoordinate,
              /** @type {ol.Geom.Circle} */ (circleGeometry)
            ),
            projection
          )
        } else {
          const [segmentStart, segmentEnd] = segmentData.segment
          // points have only one coordinate
          if (segmentEnd) {
            tempSegment[0] = fromUserCoordinate(segmentStart, projection)
            tempSegment[1] = fromUserCoordinate(segmentEnd, projection)
            vertex = closestOnSegment(projectedCoordinate, tempSegment)
          }
        }
        if (vertex) {
          const delta = squaredDistance(projectedCoordinate, vertex)
          if (delta < minSquaredDistance) {
            closestVertex = vertex
            minSquaredDistance = delta
          }
        }
      }

      const result = getResult()
      if (result) {
        return result
      }
    }

    return null
  }

  /**
   * @typdef {Object} snapGroup
   * @property {String} name nom du groupe auquel appartient la source
   * @property {Boolean} edge accrochage aux ligne
   * @property {Boolean} vertex accrochage au extremités
   *
   * Ajoute une source vecteur a surveiller
   * @param {Object} snapSource paramétrage d'une snapSource
   *  @param {Array<snapGroup>} [snapSource.groups] groupes auquels appartientla source
   *  @param {ol.source.Vector} [snapSource.source] Source openlayer, attention a ne pas ajouter des sources composée des même features...
  */
  addSource (snapSource) {
    const newSource = {
      ...{
        groups: [],
        featuresListenerKeys: [],
        source: /** @type {ol/source/Vector} */ null,
      },
      ...snapSource,
    }
    const sourceUid = getUid(newSource)
    this.snapSources[sourceUid] = newSource
    this.indexSource_(sourceUid)
    return sourceUid
  }

  /**
   * retire une source de l'indexation
   * @param {ol.source.Vector} source source openlayers
   */
  removeSource (source) {
    Object.entries(this.snapSources).forEach(([sourceId, snapSource]) => {
      if (snapSource.source === source) {
        this.unIndexSource_(sourceId)
        delete this.snapSources[sourceId]
      }
    })
  }

  /** indexe la source de données */
  indexSource_ (sourceUid) {
    const snapSource = this.snapSources[sourceUid]
    const features = /** @type {Array<ol.Feature.default>} */ (
      snapSource.source.getFeatures()
    )

    snapSource.featuresListenerKeys.push(
      listen(
        snapSource.source,
        VectorEventType.ADDFEATURE,
        (evt) => { this.handleFeatureAdd_(evt, sourceUid) },
        this
      ),
      listen(
        snapSource.source,
        VectorEventType.REMOVEFEATURE,
        (evt) => { this.handleFeatureRemove_(evt, sourceUid) },
        this
      )
    )
    features.forEach((feature) => this.forEachFeatureAdd_(feature, sourceUid))
    // console.log('indexed')
  }

  unIndexSource_ (sourceUid) {
    const snapSource = this.snapSources[sourceUid]

    const features = /** @type {Array<ol.Feature>} */ (
      snapSource.source.getFeatures()
    )

    snapSource.featuresListenerKeys.forEach(unlistenByKey)
    snapSource.featuresListenerKeys.length = 0

    features.forEach(this.forEachFeatureRemove_.bind(this))
  }

  /** Nettoit les sources ecoutés
   * @param {boolean} [clearSystemSnap=false] doit-on effacer les sources systeme?
   */
  clear (clearSystemSnap = false) {
    Object.entries(this.snapSources).forEach(([sourceId, snapSource]) => {
      if (!snapSource.groups.some((group) => group.name === 'KMAPV') || clearSystemSnap) {
        this.unIndexSource_(sourceId)
        delete this.snapSources[sourceId]
      }
    })
  }

  /**
   * Add a feature to the collection of features that we may snap to.
   * @param {ol.Feature} feature Feature.
   * @param {boolean} [optListen] Whether to listen to the feature change or not
   *     Defaults to `true`.
   * @api
   */
  addFeature (feature, sourceUid, optListen) {
    const register = optListen !== undefined ? optListen : true
    const featureUid = getUid(feature)
    const geometry = feature.getGeometry()

    if (geometry) {
      const segmenter = this.GEOMETRY_SEGMENTERS_[geometry.getType()]
      if (segmenter) {
        const groups = {}
        this.snapSources[sourceUid].groups.forEach((group) => {
          groups[group.name] = {
            edge: group.edge,
            vertex: group.vertex,
          }
        })
        // indexe les étendues et l'id de la source pour chaque feature (d'ou le fait qu'il ne faut pas qu'une feature soit sur deux sources indexées)
        this.indexedFeatures[featureUid] = {
          extent: geometry.getExtent(
            createEmpty()
          ),
          sourceUid,
          groups,
        }
        const segments = /** @type {Array<Array<ol.coordinate.Coordinate>>} */ ([])
        segmenter(segments, geometry)
        if (segments.length === 1) {
          this.rBush_.insert(boundingExtent(segments[0]), {
            feature,
            segment: segments[0],
          })
        } else if (segments.length > 1) {
          const extents = segments.map((s) => boundingExtent(s))
          const segmentsData = segments.map((segment) => ({
            feature,
            segment,
          }))
          this.rBush_.load(extents, segmentsData)
        }
      }
    }

    if (register) {
      this.snapSources[sourceUid].featuresListenerKeys[featureUid] = listen(
        feature,
        EventType.CHANGE,
        (evt) => this.handleFeatureChange_(evt, sourceUid),
        this
      )
    }
  }

  /**
   * @param {ol.Feature} feature Feature.
   * @private
   */
  forEachFeatureAdd_ (feature, sourceUid) {
    this.addFeature(feature, sourceUid)
  }

  /**
   * @param {ol.source.Vector.VectorSourceEvent|ol.Collection.CollectionEvent} evt Event.
   * @private
   */
  handleFeatureAdd_ (evt, sourceUid) {
    const feature = getFeatureFromEvent(evt)
    this.addFeature(feature, sourceUid)
  }

  /**
   * Remove a feature from the collection of features that we may snap to.
   * @param {ol.Feature} feature Feature
   * @param {boolean} [optUnlisten] Whether to unlisten to the feature change
   *     or not. Defaults to `true`.
   * @api
   */
  removeFeature (feature, optUnlisten) {
    const unregister = optUnlisten !== undefined ? optUnlisten : true
    const featureUid = getUid(feature)
    if (!this.indexedFeatures[featureUid]) { return }

    const { extent, sourceUid } = this.indexedFeatures[featureUid]
    if (this.indexedFeatures[featureUid]) {
      const rBush = this.rBush_
      const nodesToRemove = []
      rBush.forEachInExtent(extent, function (node) {
        if (feature === node.feature) {
          nodesToRemove.push(node)
        }
      })
      for (let i = nodesToRemove.length - 1; i >= 0; --i) {
        rBush.remove(nodesToRemove[i])
      }
    }

    if (unregister) {
      unlistenByKey(this.snapSources[sourceUid].featuresListenerKeys)
      delete this.snapSources[sourceUid].featuresListenerKeys[featureUid]
      delete this.indexedFeatures[featureUid]
    }
  }

  /**
   * @param {ol.Feature} feature Feature.
   * @private
   */
  forEachFeatureRemove_ (feature) {
    this.removeFeature(feature)
  }

  /**
 * @param {ol.source.Vector.VectorSourceEvent|ol.Collection.CollectionEvent} evt Event.
 * @private
 */
  handleFeatureRemove_ (evt) {
    const feature = getFeatureFromEvent(evt)
    this.removeFeature(feature)
  }

  /**
   * @param {ol.Feature} feature Feature
   * @private
   */
  updateFeature_ (feature, sourceUid) {
    const featureUid = getUid(feature)
    // const { sourceUid } = this.indexedFeatures[featureUid]
    if (this.indexedFeatures[featureUid]) { this.removeFeature(feature, false) }
    this.addFeature(feature, sourceUid, false)
  }

  /**
 * @param {ol.events.Event} evt Event.
 * @private
 */
  handleFeatureChange_ (evt, sourceUid) {
    const feature = /** @type {ol.Feature} */ (evt.target)
    this.updateFeature_(feature, sourceUid)
  }

  /**
   * @param {Array<Array<ol.coordinate.Coordinate>>} segments Segments
   * @param {ol.geom.Circle} geometry Geometry.
   * @private
   */
  segmentCircleGemetry_ (segments, geometry) {
    const projection = this.getMap().getView().getProjection()
    let circleGeometry = geometry
    const userProjection = getUserProjection()
    if (userProjection) {
      circleGeometry = /** @type {ol.geom.Circle} */ (
        circleGeometry.clone().transform(userProjection, projection)
      )
    }
    const polygon = fromCircle(circleGeometry)
    if (userProjection) {
      polygon.transform(projection, userProjection)
    }
    const coordinates = polygon.getCoordinates()[0]
    for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
      segments.push(coordinates.slice(i, i + 2))
    }
  }

  /**
   * @param {Array<Array<ol.coordinate.Coordinate>>} segments Segments
   * @param {ol.geom.GeometryCollection} geometry Geometry.
   * @private
   */
  segmentGeometryCollectionGemetry_ (segments, geometry) {
    const geometries = geometry.getGeometriesArray()
    for (let i = 0; i < geometries.length; ++i) {
      const segmenter = this.GEOMETRY_SEGMENTERS_[geometries[i].getType()]
      if (segmenter) {
        segmenter(segments, geometries[i])
      }
    }
  }

  /**
   * @param {Array<Array<ol.coordinate.Coordinate>>} segments Segments
   * @param {ol.geom.LineString} geometry Geometry.
   * @private
   */
  segmentLineStringGemetry_ (segments, geometry) {
    const coordinates = geometry.getCoordinates()
    for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
      segments.push(coordinates.slice(i, i + 2))
    }
  }

  /**
   * @param {Array<Array<ol.coordinateCoordinate>>} segments Segments
   * @param {ol.geom.MultiLineString} geometry Geometry.
   * @private
   */
  segmentMultiLineStringGemetry_ (segments, geometry) {
    const lines = geometry.getCoordinates()
    for (let j = 0, jj = lines.length; j < jj; ++j) {
      const coordinates = lines[j]
      for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
        segments.push(coordinates.slice(i, i + 2))
      }
    }
  }

  /**
   * @param {Array<Array<ol.coordinateCoordinate>>} segments Segments
   * @param {ol.geom.MultiPoint} geometry Geometry.
   * @private
   */
  segmentMultiPointGemetry_ (segments, geometry) {
    geometry.getCoordinates().forEach((point) => {
      segments.push([point])
    })
  }

  /**
   * @param {Array<Array<ol.coordinate.Coordinate>>} segments Segments
   * @param {ol.geom.MultiPolygon} geometry Geometry.
   * @private
   */
  segmentMultiPolygonGemetry_ (segments, geometry) {
    const polygons = geometry.getCoordinates()
    for (let k = 0, kk = polygons.length; k < kk; ++k) {
      const rings = polygons[k]
      for (let j = 0, jj = rings.length; j < jj; ++j) {
        const coordinates = rings[j]
        for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
          segments.push(coordinates.slice(i, i + 2))
        }
      }
    }
  }

  /**
   * @param {Array<Array<ol.coordinateCoordinate>>} segments Segments
   * @param {ol.geom.Point} geometry Geometry.
   * @private
   */
  segmentPointGemetry_ (segments, geometry) {
    segments.push([geometry.getCoordinates()])
  }

  /**
   * @param {Array<Array<olcoordinateCoordinate>>} segments Segments
   * @param {ol.geom.Polygon} geometry Geometry.
   * @privateimport { Interaction } from 'ol/interaction/Interaction';

   */
  segmentPolygonGemetry_ (segments, geometry) {
    const rings = geometry.getCoordinates()
    for (let j = 0, jj = rings.length; j < jj; ++j) {
      const coordinates = rings[j]
      for (let i = 0, ii = coordinates.length - 1; i < ii; ++i) {
        segments.push(coordinates.slice(i, i + 2))
      }
    }
  }
}

export default FeatureSnapper