Source: annotations/markups/_MarkupManager.js

import Circle from 'ol/geom/Circle'
import DragPan from 'ol/interaction/DragPan'
import LineString from 'ol/geom/LineString'
import Overlay from 'ol/Overlay'
import VectorLayer from 'ol/layer/Vector'
import 'ol/ol.css'
import VectorSource from 'ol/source/Vector'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Collection from 'ol/Collection'
import Feature from 'ol/Feature'
import { fromCircle } from 'ol/geom/Polygon'

import Enums from '../../enums'
import { _getUnitSuffix } from '../../utils'
import { coordinateWithOffset } from '../../scoord3dUtils'
import defaultStyles from '../styles'

/**
 * Build a new LineString instance with the shortest
 * distance between a given overlay and a feature.
 *
 * @param {object} feature The feature
 * @param {object} overlay The overlay instance
 *
 * @returns {LineString} The smallest line between the overlay and feature
 *
 * @private
 */
const _getShortestLineBetweenOverlayAndFeature = (feature, overlay) => {
  let result
  let distanceSq = Infinity

  let featureGeometry = feature.getGeometry()

  if (featureGeometry instanceof Circle) {
    featureGeometry = fromCircle(featureGeometry).clone()
  }

  const geometry = featureGeometry.getLinearRing ? featureGeometry.getLinearRing(0) : featureGeometry;

  (geometry.getCoordinates() || geometry.getExtent()).forEach(coordinates => {
    const closest = overlay.getPosition()
    const distanceNew = Math.pow(closest[0] - coordinates[0], 2) + Math.pow(closest[1] - coordinates[1], 2)
    if (distanceNew < distanceSq) {
      distanceSq = distanceNew
      result = [coordinates, closest]
    }
  })

  const coordinates = overlay.getPosition()
  const closest = geometry.getClosestPoint(coordinates)
  const distanceNew = Math.pow(closest[0] - coordinates[0], 2) + Math.pow(closest[1] - coordinates[1], 2)
  if (distanceNew < distanceSq) {
    distanceSq = distanceNew
    result = [closest, coordinates]
  }

  return new LineString(result)
}

class _MarkupManager {
  constructor ({
    map,
    pyramid,
    affine,
    drawingSource,
    formatters,
    onClick,
    onStyle
  } = {}) {
    this._map = map
    this._pyramid = pyramid
    this._affine = affine
    this._formatters = formatters
    this._drawingSource = drawingSource

    this.onClick = onClick
    this.onStyle = onStyle

    this._markups = new Map()
    this._listeners = new Map()
    this._links = new Collection([], { unique: true })

    const defaultColor = defaultStyles.stroke.color
    this._styleTag = document.createElement('style')
    this._styleTag.innerHTML = this._getTooltipStyles(defaultColor)

    this._linksVector = new VectorLayer({
      source: new VectorSource({ features: this._links })
    })

    this._markupsOverlay = new Overlay({ element: this._styleTag })
    this._map.addOverlay(this._markupsOverlay)
    this._map.addLayer(this._linksVector)
  }

  /**
   * Set markups visibility.
   *
   * @param {boolean} isVisible
   * @returns {void}
   */
  setVisible (isVisible) {
    this._linksVector.setVisible(isVisible)

    if (isVisible) {
      this._markups.forEach((markup) => {
        this._map.removeOverlay(markup.overlay)
        this._map.addOverlay(markup.overlay)
      })
      return
    }
    this._markups.forEach((markup) => {
      this._map.addOverlay(markup.overlay)
      this._map.removeOverlay(markup.overlay)
    })
  }

  /**
   * Checks whether a markup exists.
   *
   * @param {string} id The markup id
   * @return {boolean}
   */
  has (id) {
    return this._markups.has(id)
  }

  /**
   * Returns a markup
   *
   * @param {string} id The markup id
   * @return {object} The markup
   */
  get (id) {
    return this._markups.get(id)
  }

  /**
   * Removes a markup and its link.
   *
   * @param {string} id The markup id
   * @return {string} The markup id
   */
  remove (id) {
    const markup = this.get(id)
    if (!markup) {
      return id
    }

    const links = this._links.getArray()
    const link = links.find((feature) => feature.getId() === id)
    if (link) {
      this._links.remove(link)
    }

    this._map.removeOverlay(markup.overlay)
    this._markups.delete(id)

    if (this._listeners.get(id)) {
      this._listeners.delete(id)
    }

    return id
  }

  /**
   * Set markup visibility.
   *
   * @param {string} id The markup id
   * @param {boolean} isVisible The markup visibility
   */
  setVisibility (id, isVisible) {
    const markup = this.get(id)
    if (!markup) {
      return
    }

    const links = this._links.getArray()
    const link = links.find((feature) => feature.getId() === id)

    const feature = this._drawingSource.getFeatureById(id)

    if (!isVisible) {
      this._map.removeOverlay(markup.overlay)
      this._links.remove(link)
    } else {
      this._map.addOverlay(markup.overlay)
      this._drawLink(feature)
    }

    return isVisible
  }

  /**
   * Creates a new markup
   *
   * @param {object} options The options
   * @param {Feature} options.feature The feature to plug the measure markup
   * @param {string} options.value The inner content of element
   * @param {boolean} options.isLinkable Create a link between feature and markup
   * @param {boolean} options.isDraggable Allow markup to be dragged
   * @param {array} offset Markup offset
   * @return {object} The markup object
   */
  create ({ feature, value = '', isLinkable = true, isDraggable = true }) {
    const id = feature.getId()
    if (!id) {
      console.warn('Failed to create markup, feature id not found')
      return
    }

    if (this.has(id)) {
      console.warn('Markup for feature already exists', id)
      return this.get(id)
    }

    const markup = { id, isLinkable, isDraggable }

    const element = document.createElement('div')
    element.id = markup.isDraggable ? Enums.InternalProperties.Markup : ''
    element.className = 'ol-tooltip ol-tooltip-measure'
    element.innerText = value

    const spacedCoordinate = coordinateWithOffset(feature)

    markup.element = element
    markup.overlay = new Overlay({
      className: 'markup-container',
      positioning: 'center-center',
      stopEvent: false,
      dragging: false,
      position: spacedCoordinate,
      element: markup.element
    })

    this._map.addOverlay(markup.overlay)
    this._markups.set(id, markup)

    this._drawLink(feature)
    this._wireInternalEvents(feature)

    return markup
  }

  /**
   * Wire internal events to markup feature.
   *
   * @param {object} feature
   * @returns {void}
   */
  _wireInternalEvents (feature) {
    const id = feature.getId()
    const markup = this.get(id)
    const listener = feature.on(
      Enums.FeatureEvents.CHANGE,
      (event) => {
        if (this.has(id)) {
          const view = this._map.getView()
          const unitSuffix = _getUnitSuffix(view)
          const format = this._getFormatter(event.target)
          const output = format(
            event.target,
            unitSuffix,
            this._pyramid,
            this._affine
          )
          this.update({
            feature,
            value: output,
            coordinate: event.target.getGeometry().getLastCoordinate()
          })
          this._drawLink(feature)
        }
      }
    )
    this._listeners.set(id, listener)

    this._styleTooltip(feature)

    /** Keep markup style after external style changes */
    feature.on(
      Enums.FeatureEvents.PROPERTY_CHANGE,
      ({ key: property, target: feature }) => {
        if (property === Enums.InternalProperties.StyleOptions) {
          this._styleTooltip(feature)
        }
      }
    )

    /** Update markup style on feature geometry change */
    feature.getGeometry().on(Enums.FeatureGeometryEvents.CHANGE, () => {
      this._styleTooltip(feature)
    })

    let dragPan
    const dragProperty = 'dragging'
    this._map.getInteractions().forEach((interaction) => {
      if (interaction instanceof DragPan) {
        dragPan = interaction
      }
    })

    markup.element.addEventListener(Enums.HTMLElementEvents.MOUSE_DOWN, () => {
      const markup = this.get(id)
      if (markup) {
        dragPan.setActive(false)
        markup.overlay.set(dragProperty, true)
      }
    })

    this._map.on(Enums.MapEvents.POINTER_MOVE, (event) => {
      const markup = this.get(id)
      if (
        markup &&
        markup.overlay.get(dragProperty) === true &&
        markup.isDraggable
      ) {
        /** Doesn't need to have the offset */
        markup.overlay.setPosition(event.coordinate)
        this._drawLink(feature)
      }
    })

    this._map.on(Enums.MapEvents.POINTER_UP, () => {
      const markup = this.get(id)
      if (
        markup &&
        markup.overlay.get(dragProperty) === true &&
        markup.isDraggable
      ) {
        dragPan.setActive(true)
        markup.overlay.set(dragProperty, false)
      }
    })
  }

  onDrawAbort ({ feature }) {
    this.remove(feature.getId())
  }

  /**
   * Updates the feature's markup tooltip style.
   *
   * @param {object} feature
   * @returns {void}
   */
  _styleTooltip (feature) {
    const styleOptions = feature.get(Enums.InternalProperties.StyleOptions)
    if (styleOptions && styleOptions.stroke) {
      const { color } = styleOptions.stroke
      const tooltipColor = color || defaultStyles.stroke.color
      const links = this._links.getArray()
      const link = links.find((link) => link.getId() === feature.getId())
      if (link) {
        const styles = link.getStyle()
        const stroke = styles.getStroke()
        stroke.setColor(tooltipColor)
        styles.setStroke(stroke)
        link.setStyle(styles)
      }
      const marker = this.get(feature.getId())
      if (marker) {
        marker.element.style.color = tooltipColor
      }
    }
  }

  /**
   * Returns tooltip styles.
   *
   * @param {string} color
   */
  _getTooltipStyles (color) {
    return `
      .ol-tooltip {
        color: ${color};
        white-space: nowrap;
        font-size: 17px;
        font-weight: bold;
      }
      .ol-tooltip-measure { opacity: 1; }
      .ol-tooltip-static { color: ${color}; }
      .ol-tooltip-measure:before,
      .ol-tooltip-static:before {
        content: '',
      }

      #markup { cursor: move; }
      .markup-container { display: block !important; }
    `
  }

  /**
   * Checks if feature has the correct markup.
   *
   * @param {Feature} feature The feature
   */
  _isValidFeature (feature) {
    return Object.values(Enums.Markup).includes(
      feature.get(Enums.InternalProperties.Markup)
    )
  }

  /**
   * Update markup content.
   *
   * @param {object} markup The markup properties
   * @param {Feature} markup.feature The markup feature
   * @param {string} markup.value The markup content
   * @param {string} markup.coordinate The markup coordinate
   */
  update ({ feature, value, coordinate }) {
    const id = feature.getId()

    if (!id) {
      console.warn('Failed attempt to update markup, feature with empty id')
      return
    }

    const markup = this.get(id)
    if (!markup) {
      console.warn('No markup found for given feature')
      return
    }

    if (value) {
      markup.element.innerText = value
    }

    if (coordinate) {
      markup.overlay.setPosition(coordinate)
    }

    this._markups.set(id, markup)
  }

  /**
   * This event is responsible assign markup classes on drawEnd event
   *
   * @param {object} event The event
   */
  onDrawEnd (event) {
    const feature = event.feature
    if (this._isValidFeature(feature)) {
      const featureId = feature.getId()
      const markup = this.get(featureId)
      if (markup) {
        markup.element.className = 'ol-tooltip ol-tooltip-static'
        this._markups.set(featureId, markup)
      }
    }
  }

  /**
   * This event is responsible assign markup classes on update event
   *
   * @param {object} event The event
   */
  onUpdate (feature) {
    if (this._isValidFeature(feature)) {
      const featureId = feature.getId()
      const markup = this.get(featureId)
      if (markup) {
        markup.element.className = 'ol-tooltip ol-tooltip-static'
        this._markups.set(featureId, markup)
      }
    }
  }

  /**
   * Returns the string format function for a given markup.
   *
   * @param {object} feature The feature
   * @returns {function} format function
   */
  _getFormatter (feature) {
    const markup = feature.get(Enums.InternalProperties.Markup)
    const formatter = this._formatters[markup]
    if (!formatter) return () => ''
    return formatter
  }

  /**
   * Draws a link between the feature and the markup.
   *
   * @param {object} feature The feature
   */
  _drawLink (feature) {
    const markup = this.get(feature.getId())
    if (!markup || !markup.isLinkable) {
      return
    }

    const line = _getShortestLineBetweenOverlayAndFeature(
      feature,
      markup.overlay
    )

    const updated = this._links.getArray().some((feature) => {// eslint-disable-line
      if (feature.getId() === markup.id) {
        feature.setGeometry(line)
        return true
      }
    })

    if (!updated) {
      const feature = new Feature({ geometry: line, name: 'Line' })
      feature.setId(markup.id)
      feature.setStyle(
        new Style({
          stroke: new Stroke({
            color: defaultStyles.stroke.color,
            lineDash: [0.3, 7],
            width: 3
          })
        })
      )
      this._links.push(feature)
    }
  }
}

export default _MarkupManager