Home Reference Source

src/configurators/UIConfigurator.js

import $ from 'jquery'

import DoubleClickZoom from 'ol/interaction/DoubleClickZoom'
import DragPan from 'ol/interaction/DragPan'
import DragRotate from 'ol/interaction/DragRotate'
import Kinetic from 'ol/Kinetic'
import DragZoom from 'ol/interaction/DragZoom'
import KeyboardPan from 'ol/interaction/KeyboardPan'
import KeyboardZoom from 'ol/interaction/KeyboardZoom'
import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'
import PinchRotate from 'ol/interaction/PinchRotate'
import PinchZoom from 'ol/interaction/PinchZoom'
import { ClickableInteraction } from '../interactions/ClickableInteraction'

import { Positioning } from './Positioning'

import { Move } from '../Move'
import { FeaturePopup } from '../FeaturePopup'
import { FeatureTooltip } from '../FeatureTooltip'

import { Shield } from '../html/Shield'

import { ControlFactory } from './ControlFactory'

import { Debug } from '../Debug'

import { copyDeep } from '../utilitiesObject'
import { checkFor, getConfig, urlDirname, urlJoin } from '../utilities'

import { MeasurementButton } from '../controls/MeasurementButton'
import { PrintButton } from '../controls/PrintButton'

import { cssClasses } from '../globals'

import { FeatureInteraction } from '../interactions/FeatureInteraction'

import { parseCSSColor } from 'csscolorparser'
import { ShowWMSFeatureInfo } from '../ShowWMSFeatureInfo'

import { History } from '../html/History'
import { debounce } from 'lodash/function'

/**
 * This class configures the UI of a map according to its mapconfig
 */
export class UIConfigurator {
  /**
   * @param {G4UMap} map
   */
  constructor (map) {
    /**
     * @type {G4UMap}
     * @private
     */
    this.map_ = map
    this.map_.on('change:mapConfig', this.configureUI.bind(this))

    /**
     * @type {boolean}
     * @private
     */
    this.initialized_ = false

    /**
     * Counts how many cssFiles are getting loaded
     * @type {number}
     * @private
     */
    this.pending_ = 0
  }

  /**
   * Fill the given css String with the rigth colors
   * @param {string} css
   * @param {Color[]} colors
   * @returns {string}
   */
  static templateCSS (css, colors) {
    // find the used index of the color template
    const colorTemplateIndex = (c) => {
      let channels = 0
      let index = -1
      for (let i = 0; i < 3; i++) {
        if (c[i] !== 0) {
          channels++
          index = i
        }
      }
      if (channels > 1) {
        return -1
      } else {
        return index
      }
    }

    const regExp = /(rgba?\([^)]*\))|(#[0-9a-f]{6})/g

    colors = colors.map(parseCSSColor)

    return css.replace(regExp, match => {
      const templateColor = parseCSSColor(match)
      const index = colorTemplateIndex(templateColor)
      if (index > -1) {
        const aFac = templateColor[3]
        const cFac = templateColor[index] / 120
        const newColor = [
          colors[index][0] * cFac,
          colors[index][1] * cFac,
          colors[index][2] * cFac,
          colors[index][3] * aFac
        ]
        return `rgba(${newColor[0]}, ${newColor[1]}, ${newColor[2]}, ${newColor[3]})`
      }
      return match
    })
  }

  /**
   * Fixes all urls in the css string to the right path
   * @param css
   * @param cssPath
   * @returns {string}
   */
  static fixUrls (css, cssPath) {
    const dirname = urlDirname(cssPath)
    return css.replace(/url\("([^"]*)"\)/g, (match, url) => {
      return `url("${urlJoin(dirname, url)}")`
    })
  }

  /**
   * Loads the from cssFile the given conf (if any is given) and inserts into a style tag in the head of the document
   * @param {MapConfig} conf
   * @returns {Promise}
   */
  loadCSS (conf) {
    return new Promise(resolve => {
      if (conf.hasOwnProperty('cssFile')) {
        const id = 'g4u-css'
        let $style = $('#' + id)

        if ($style.length === 0) {
          $style = $('<style>', {
            id: id
          })
          $('head').append($style)
        }

        const same = this.cssFile_ === conf.cssFile
        if (!same) {
          $.ajax(conf.cssFile).done(data => {
            $style.empty()
            data = UIConfigurator.fixUrls(data, conf.cssFile)
            if (conf.hasOwnProperty('cssTemplate')) {
              this.map_.set('cssTemplateFile', data)
              $style.html(UIConfigurator.templateCSS(data, conf.cssTemplate))
            } else {
              $style.html(data)
            }
            resolve()
          }).fail(() => {
            Debug.error('Failed to load css file.')
            resolve()
          })
          this.cssFile_ = conf.cssFile
        } else {
          if (conf.hasOwnProperty('cssTemplate')) {
            $style.html(UIConfigurator.templateCSS(this.map_.get('cssTemplateFile'), conf.cssTemplate))
          }
          resolve()
        }
      } else {
        resolve()
      }
    })
  }

  /**
   * This functions is called the first time the configureUI function is called
   * @param {MapConfig} mapConfigCopy
   * @private
   */
  initialize_ (mapConfigCopy) {
    //
    // Control positioning
    //

    /**
     * @type {PositioningOptions}
     */
    const positioningOptions = mapConfigCopy.positioning || {}
    positioningOptions.viewport = this.map_.getViewport()

    this.map_.set('controlPositioning', new Positioning(positioningOptions))

    const debouncedPosition = debounce(() => this.map_.get('controlPositioning').positionElements())

    this.map_.on('ready', debouncedPosition)

    this.map_.asSoonAs('ready', true, () => {
      this.map_.on('resize', debouncedPosition)
      this.map_.on('ready:ui', () => {
        if (this.map_.get('ready:ui')) {
          debouncedPosition()
        }
      })
      this.map_.on('change:mobile', debouncedPosition)

      this.map_.on('ready:layers', () => {
        debouncedPosition()
        this.map_.getLayerGroup().forEachOn('change:visible', () => setTimeout(debouncedPosition, 200))
      })

      this.map_.getLayerGroup().forEachOn('change:visible', () => setTimeout(debouncedPosition, 200))
    })

    //
    //  Responsiveness / Mobile Layout
    //

    this.map_.set('mobile', false)
    $(this.map_.getTarget()).children().addClass(cssClasses.desktop)

    this.map_.set('scaleIcons', mapConfigCopy.scaleIcons)

    const mobileChangeHandler = this.getHandleMobileChange_()
    this.map_.on('change:mobile', mobileChangeHandler)

    this.map_.once('ready', () => {
      this.map_.set('desktopLayout', {
        animations: this.map_.get('move').getAnimations(),
        scaleIcons: this.map_.get('scaleIcons'),
        hitTolerance: this.map_.get('hitTolerance')
      })
      mobileChangeHandler()
    })

    const checkMobileLayoutQuery = () => {
      /**
       * @type {MobileLayoutOptions}
       */
      const mobileLayout = this.map_.get('mobileLayout')

      if (mobileLayout && mobileLayout.mediaQueries && window.matchMedia) {
        this.map_.set('mobile', mobileLayout.mediaQueries.some(query => {
          return window.matchMedia(query).matches
        }))
      }
    }

    //
    // Enabling/Disabling responsiveness
    //

    let oldResponsive

    const onChangeResponsive = () => {
      if (this.map_.get('responsive') !== oldResponsive) {
        if (this.map_.get('responsive')) {
          checkMobileLayoutQuery()
          this.map_.on('resize', checkMobileLayoutQuery)
        } else {
          this.map_.un('resize', checkMobileLayoutQuery)
        }
        oldResponsive = this.map_.get('responsive')
      }
    }

    this.map_.once('ready', onChangeResponsive)

    this.map_.on('change:responsive', onChangeResponsive)

    this.map_.on('ready:ui', onChangeResponsive)

    this.initialized_ = true
  }

  applyLayout (layout) {
    if (layout.hasOwnProperty('animations')) {
      this.map_.get('move').setAnimations(layout.animations)
    }

    if (layout.hasOwnProperty('scaleIcons')) {
      this.map_.set('scaleIcons', layout.scaleIcons)
      this.map_.get('styling').setGlobalIconScale(layout.scaleIcons)
      this.map_.getLayerGroup().recursiveForEach(l => {
        if (l.getVisible()) {
          l.changed()
        }
      })
    }

    if (layout.hasOwnProperty('hitTolerance')) {
      this.map_.set('hitTolerance', layout.hitTolerance)
    }

    const featurePopup = this.map_.get('featurePopup')
    const wmsFeatureInfo = this.map_.get('showWMSFeatureInfo')

    const restoreWmsFeatureInfoPoint = wmsFeatureInfo && wmsFeatureInfo.getPointVisible()

    if (featurePopup && featurePopup.getVisible()) {
      const feature = featurePopup.getFeature()
      featurePopup.setVisible(false)
      setTimeout(() => {
        featurePopup.setFeature(feature)
        featurePopup.setVisible(true)
        if (restoreWmsFeatureInfoPoint) {
          wmsFeatureInfo.setPointVisible(true)
        }
      }, 0)
    }
  }

  getHandleMobileChange_ () {
    if (!this.handleMobileChange__) {
      this.handleMobileChange__ = () => {
        if (this.map_.get('mobile')) {
          $(this.map_.getTarget()).children().addClass(cssClasses.mobile)
          $(this.map_.getTarget()).children().removeClass(cssClasses.desktop)

          this.applyLayout(this.map_.get('mobileLayout'))
        } else {
          $(this.map_.getTarget()).children().addClass(cssClasses.desktop)
          $(this.map_.getTarget()).children().removeClass(cssClasses.mobile)

          this.applyLayout(this.map_.get('desktopLayout'))
        }
      }
    }
    return this.handleMobileChange__
  }

  /**
   * @public
   */
  configureUI () {
    this.map_.set('ready:ui', false)
    this.pending_++

    const mapConfigCopy = copyDeep(this.map_.get('mapConfig'))

    if (!this.initialized_) {
      this.initialize_(mapConfigCopy)
    }

    if (this.map_.get('history')) {
      this.map_.get('history').flush()
    }

    let curConfig

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                              Load CSS if neccessary                                      //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    this.loadCSS(mapConfigCopy).then(() => {
      this.pending_--
      if (this.pending_ === 0) {
        // clear
        this.map_.removeControls()
        this.map_.controlsByName = {}
        this.map_.removeInteractions()

        this.map_.get('controlPositioning').init()

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                           Move Class (before mobileLayout)                               //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        const moveOptions = copyDeep(getConfig(mapConfigCopy, 'move')) || {}
        moveOptions.map = this.map_

        this.map_.set('move', new Move(moveOptions))

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                     HTML-Shield                                          //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        if (!this.map_.get('shield')) {
          this.map_.set('shield', new Shield({ map: this.map_ }))
        }

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                MobileLayoutMediaQuery                                    //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        this.map_.set('mobileLayout', mapConfigCopy.mobileLayout)

        this.map_.set('responsive', this.map_.get('responsive') || !!this.map_.get('mobileLayout'))

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                       MODULES                                            //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        // should be before adding controls so the controls don't have to wait for any other ui elements

        for (const module of this.map_.getModules()) {
          module.configureUI(mapConfigCopy)
        }

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                       Controls                                           //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        if (!this.controlFactory) {
          this.controlFactory = new ControlFactory({
            map: this.map_,
            localiser: this.map_.get('localiser')
          })
        } else {
          this.controlFactory.setLocaliser(this.map_.get('localiser'))
        }

        this.controlFactory.addControls()

        const deactivate = control => {
          if (control.setActive) {
            control.setActive(false)
          }
          if (control.getControls) {
            control.getControls().forEach(deactivate)
          }
        }

        this.map_.on('change:mobile', () => this.map_.getControls().forEach(deactivate))

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                     Interactions                                         //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        if (checkFor(mapConfigCopy, 'interactions')) {
          if (checkFor(mapConfigCopy.interactions, 'doubleClickZoom')) {
            this.map_.addDefaultInteraction('dblclick', new DoubleClickZoom())
          }

          if (checkFor(mapConfigCopy.interactions, 'dragPan')) {
            if (mapConfigCopy.kinetic !== false) {
              let decay = -0.002
              let minVelocity = 0.02
              let delay = 100
              if (typeof mapConfigCopy.kinetic === 'object') {
                decay = mapConfigCopy.kinetic.decay || decay
                minVelocity = mapConfigCopy.kinetic.minVelocity || minVelocity
                delay = mapConfigCopy.kinetic.delay || delay
              }
              this.map_.addDefaultInteraction('drag', new DragPan({
                kinetic: new Kinetic(decay, minVelocity, delay)
              }))
            } else {
              this.map_.addDefaultInteraction('drag', new DragPan())
            }
          }

          if (checkFor(mapConfigCopy.interactions, 'dragRotate')) {
            // using default condition: ol.events.condition.altShiftKeysOnly
            this.map_.addDefaultInteraction('altShiftDrag', new DragRotate())
          }

          if (checkFor(mapConfigCopy.interactions, 'dragZoom')) {
            // using default condition: ol.events.condition.shiftKeyOnly
            this.map_.addDefaultInteraction('shiftDrag', new DragZoom())
          }

          if (checkFor(mapConfigCopy.interactions, 'keyboardPan')) {
            this.map_.addDefaultInteraction('keyboard', new KeyboardPan())
          }

          if (checkFor(mapConfigCopy.interactions, 'keyboardZoom')) {
            this.map_.addDefaultInteraction('keyboard', new KeyboardZoom())
          }

          if (checkFor(mapConfigCopy.interactions, 'mouseWheelZoom')) {
            this.map_.addDefaultInteraction('mouseWheel', new MouseWheelZoom())
          }

          if (checkFor(mapConfigCopy.interactions, 'pinchRotate')) {
            this.map_.addDefaultInteraction('pinchRotate', new PinchRotate())
          }

          if (checkFor(mapConfigCopy.interactions, 'pinchZoom')) {
            this.map_.addDefaultInteraction('pinchZoom', new PinchZoom())
          }
        }

        const clickInteraction = new FeatureInteraction({
          type: 'singleclick',
          style: null
        })
        this.map_.addDefaultInteraction('singleclick', clickInteraction)
        this.map_.set('clickInteraction', clickInteraction)

        const moveInteraction = new FeatureInteraction({
          type: 'pointermove',
          style: null
        })
        this.map_.addDefaultInteraction('pointermove', moveInteraction)
        this.map_.set('moveInteraction', moveInteraction)

        const $viewport = $(this.map_.getViewport())

        // if the map is left the features should get deselected
        $viewport.find('canvas').on('mouseleave', e => {
          if (!$(e.relatedTarget).is('canvas')) {
            moveInteraction.triggerEmptyMapEvent()
          }
        })

        const clickableInteraction = new ClickableInteraction({ style: null })
        this.map_.addDefaultInteraction('pointermove', clickableInteraction)
        this.map_.set('clickableInteraction', clickableInteraction)

        // hitTolerance
        const updateHitTolerance = () => {
          const hitTolerance = this.map_.get('hitTolerance')
          for (const interaction of this.map_.getInteractions().getArray()) {
            if (interaction.setHitTolerance) {
              interaction.setHitTolerance(hitTolerance)
            }
          }
        }

        this.map_.set('hitTolerance', mapConfigCopy.hitTolerance || 0)

        updateHitTolerance()
        this.map_.on('change:hitTolerance', updateHitTolerance)

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                     Feature Popups                                       //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        let featurePopup = this.map_.get('featurePopup')
        if (featurePopup) {
          featurePopup.setMap(null)
        }
        curConfig = getConfig(mapConfigCopy, 'featurePopup')
        if (curConfig) {
          featurePopup = new FeaturePopup(curConfig)
          featurePopup.setMap(this.map_)
          this.map_.set('featurePopup', featurePopup)
        } else {
          this.map_.set('featurePopup', undefined)
        }

        let featureTooltip = this.map_.get('featureTooltip')
        if (featureTooltip) {
          featureTooltip.setMap(null)
        }
        curConfig = getConfig(mapConfigCopy, 'featureTooltip')
        if (curConfig) {
          featureTooltip = new FeatureTooltip(curConfig)
          featureTooltip.setMap(this.map_)
          this.map_.set('featureTooltip', featureTooltip)
        } else {
          this.map_.set('featureTooltip', undefined)
        }

        let showWMSFeatureInfo = this.map_.get('showWMSFeatureInfo')
        if (showWMSFeatureInfo) {
          showWMSFeatureInfo.setMap(null)
        }
        curConfig = getConfig(mapConfigCopy, 'showWMSFeatureInfo')
        if (curConfig) {
          showWMSFeatureInfo = new ShowWMSFeatureInfo(curConfig)
          showWMSFeatureInfo.setMap(this.map_)
          this.map_.set('showWMSFeatureInfo', showWMSFeatureInfo)
        } else {
          this.map_.set('showWMSFeatureInfo', undefined)
        }

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                    Custom History                                        //
        // //////////////////////////////////////////////////////////////////////////////////////// //

        this.map_.set('history', new History())

        // //////////////////////////////////////////////////////////////////////////////////////// //
        //                                     UserExit (2/2)                                       //
        // //////////////////////////////////////////////////////////////////////////////////////// //
        if (this.map_.get('userActionTracking')) {
          const map = this.map_

          this.map_.on('moveend', function () {
            map.dispatchEvent({
              type: 'userActionTracking',
              action: 'move'
            })
          })

          this.map_.getLayers().getLayers().forEach(function (layer) {
            layer.on('change:visible', function () {
              if (layer.getVisible()) { // only if changed to visible
                map.dispatchEvent({
                  type: 'userActionTracking',
                  action: 'layerChange',
                  value: layer.get('title')
                })
              }
            })
          })

          if (this.map_.get('featurePopup')) {
            this.map_.get('featurePopup').on('change:visible', function () {
              if (this.getVisible()) { // only if changed to visible
                map.dispatchEvent({
                  type: 'userActionTracking',
                  action: 'popupOpen',
                  value: this.getFeature().get('name')
                })
              }
            })
          }

          for (const module of this.map_.getModules()) {
            module.enableUserActionTracking()
          }

          this.map_.getControls().forEach(function (control) {
            if (control instanceof PrintButton) {
              control.on('click', function () {
                map.dispatchEvent({
                  type: 'userActionTracking',
                  action: 'print'
                })
              })
            }

            if (control instanceof MeasurementButton) {
              if (control.getType() === 'LineString') {
                control.on('measurement', function () {
                  map.dispatchEvent({
                    type: 'userActionTracking',
                    action: 'measureLine'
                  })
                })
              } else if (control.getType() === 'Polygon') {
                control.on('measurement', function () {
                  map.dispatchEvent({
                    type: 'userActionTracking',
                    action: 'measureArea'
                  })
                })
              }
            }
          })
        }
        this.map_.set('ready:ui', true)
      }
    }).catch(function (reason) {
      Debug.error(reason)
    })
  }
}