Home Reference Source

src/configurators/MapConfigurator.js

import $ from 'jquery'
import proj4 from 'proj4/dist/proj4'
import { register } from 'ol/proj/proj4'
import { getTransform, get as getProj, transform, transformExtent } from 'ol/proj'
import { applyTransform } from 'ol/extent'
import View from 'ol/View'

import { Styling } from '../Styling'
import { LayerConfigurator } from './LayerConfigurator'
import { UIConfigurator } from './UIConfigurator'
import { copyDeep } from '../utilitiesObject'
import { checkFor } from '../utilities'
import { API } from '../api/API'
import { Debug } from '../Debug'

/**
 * @typedef {Object} MapConfig
 * @property {string} [proxy] A proxy url. It should have an {url} string which will be replaced with the proxied url.
 *    Example: 'proxy.php?csurl={url}'.
 * @property {Boolean} [userActionTracking=false] if checked the map tracks certain user actions and
 *    fires 'userActionTracking' events that contain this information. Tracked actions are: move, layer activation,
 *    clicks on features, printing, measurements.
 * @property {L10NOptions} [languageSettings] settings regarding the display language.
 * @property {string} [interfaceProjection='EPSG:4326'] the projection the interface will use to interact with the
 *    user or the api.
 * @property {string} [mapProjection] the projection that is used internally. Will be infered from map data if not set.
 * @property {string} [measurementProjection] the projection measurements will calculated in.
 * @property {boolean} [enableContextMenu=false] enables the contextMenu outside of textinput fields
 * @property {g4uViewOptions} view View options.
 * @property {number} [scaleIcons] a default scaling for all used feature icons
 * @property {number} [hitTolerance=0] a default hit tolerance that will be used for all interactions
 *    (except show wms feature info).
 * @property {boolean} [manageStyles=true] set this to false to disable style managing. This disables scaleIcons,
 *    mobileScaleIcons, feature hiding and adjustable style opacity
 * @property {MobileLayoutOptions} [mobileLayout] special layout options for mobile use.
 * @property {ProjectionConfig[]} [additionalProjections] if any other projections than 'EPSG:4326' or 'EPSG:3857' are
 *    needed they can be specified here.
 * @property {APIOptions} [api] API-Options
 * @property {FeaturePopupOptions} [featurePopup] Options regarding the feature popup.
 * @property {FeatureTooltipOptions} [featureTooltip] Options regarding the feature tooltip.
 * @property {ShowWMSFeatureInfoOptions} [showWMSFeatureInfo] Options regarding WMS GetFeatureInfo.
 * @property {Object.<string, boolean>} [interactions] Specifies which map interactions should be turned on by default.
 *    Possible interactions are: 'doubleClickZoom', 'dragPan' (to which the {KineticOptions} are applied), 'dragRotate',
 *    'dragZoom', 'keyboardPan', 'keyboardZoom', 'mouseWheelZoom', 'pinchRotate', 'pinchZoom'
 * @property {KineticOptions|boolean} [kinetic] This influences the DragPan behaviour. If set to false no kinetic
 *    options are applied, if not set, the defaults are used.
 * @property {MoveOptions} [move] Options regarding the behaviour of movements on the maps.
 * @property {PositioningOptions} [positioning] Options regarding the positioning of the controls.
 * @property {string} [loadingStrategy='ALL'] Global default loading strategy. Can have the values 'BBOX' or 'ALL'.
 * @property {boolean} [ignoreLayerAvailability=false] If set all layers are added to the map, regardless of their
 *    available config option.
 * @property {string} [cssFile] a cssFile to load and insert in the head dynamically.
 * @property {Color[]} [cssTemplate] if 3 colors are given, the colors used in the text of the loaded cssFile will be
 *    replaced by this colors. The colors in the cssFile need to be pure red, blue and green.
 * @property {Object.<string, StyleObject>} [styleMap] the style objects which will be mapped to certain identifiers. It
 *    is recommended that identifiers start with a #. The {{StyleObject}} with the identifier '#defaultStyle' will be
 *    used as a default Style in the whole Software.
 * @property {ControlsConfig} controls
 */

/**
 * @typedef {object} KineticOptions
 * @property {number} [decay]
 * @property {number} [minVelocity]
 * @property {number} [delay]
 */

/**
 * @typedef {Object} ProjectionConfig
 * @property {string} code
 * @property {string} definition
 */

/**
 * @typedef {Object} g4uViewOptions
 * @property {ol.Coordinate} center the initial center of the map
 * @property {ol.Extent} [extent] the max extent the center can lay in
 * @property {number} [resolution]
 * @property {number} [zoom]
 * @property {number} [rotation]
 * @property {number} [fit] an extent to fit the map initialy to, overwrites center settings
 * @property {ol.ProjectionLike} [projection]
 */

/**
 * @typedef {object} MobileLayoutOptions
 * @property {string[]} mediaQueries these will enable the mobile layout including removing the g4u-desktop class from
 *    the ol-viewport and adding the g4u-mobile class
 * @property {number} [scaleIcons=1] a value to scale all icons by
 * @property {boolean} [animations=true] if animations should be disabled
 * @property {number} [hitTolerance]
 */

/**
 * This class configures a map once the configureMap method is called.
 * configureMap initializes the map and can only be called once.
 * it delegates the configureUI and configureLayers to the {{UIConfigurator}} and {{LayerConfigurator}} classes.
 */
export class MapConfigurator {
  /**
   * @param {G4UMap} map
   * @public
   */
  constructor (map) {
    /**
     * @type {G4UMap}
     * @private
     */
    this.map_ = map

    /**
     * @type {LayerConfigurator}
     * @private
     */
    this.layerConfigurator_ = new LayerConfigurator(map)

    /**
     * @type {UIConfigurator}
     * @private
     */
    this.UIConfigurator_ = new UIConfigurator(map)

    map.set('layerConfigurator', this.layerConfigurator_)
    map.set('UIConfigurator', this.UIConfigurator_)

    this.configureMap()

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

  /**
   * Delegate call to {{LayerConfigurator}}
   * @public
   */
  configureLayers () {
    this.layerConfigurator_.configureLayers()
  }

  /**
   * Delegate call to {{UIConfigurator}}
   * @public
   */
  configureUI () {
    this.UIConfigurator_.configureUI()
  }

  /**
   * @public
   */
  configureMap () {
    if (this.firstRun_) {
      Debug.error('configureMap is supposed to be called only once')
      Debug.warn('If you would like to change that, please think about something because of the asynchronous nature' +
        ' of "ready", "ready:ui" and "ready:layers"')
      throw new Error()
    }

    /**
     * @type {MapConfig}
     */
    const mapConfigCopy = copyDeep(this.map_.get('mapConfig'))

    if (!mapConfigCopy.enableContextMenu) {
      $(this.map_.getTarget()).on('contextmenu', e => {
        if (!$(e.target).is('input[type=text]') && !$(e.target).is('textarea')) {
          e.preventDefault()
        }
      })
    }

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

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                                       Styling                                            //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    // has to be done before configureLayers ... -> promise?

    const stylingOptions = {}

    if (mapConfigCopy.hasOwnProperty('manageStyles')) {
      stylingOptions.manageStyles = mapConfigCopy.manageStyles
    }

    if (this.map_.get('styleMap')) {
      stylingOptions.styleConfigMap = this.map_.get('styleMap')
    } else if (mapConfigCopy.hasOwnProperty('styleMap')) {
      stylingOptions.styleConfigMap = mapConfigCopy.styleMap
    }

    const scaleIcons = mapConfigCopy.hasOwnProperty('scaleIcons') ? mapConfigCopy.scaleIcons : 1
    stylingOptions.scaleIcons = scaleIcons

    this.map_.set('scaleIcons', scaleIcons)
    this.map_.set('styling', new Styling(stylingOptions))

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                                      Projections                                         //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    const additionalProjectionsConf = mapConfigCopy.hasOwnProperty('additionalProjections')
      ? mapConfigCopy.additionalProjections
      : []

    for (let i = 0, ii = additionalProjectionsConf.length; i < ii; i++) {
      proj4.defs(additionalProjectionsConf[i].code, additionalProjectionsConf[i].definition)
    }

    if (additionalProjectionsConf.length > 0) {
      register(proj4)
    }

    let mapProjection
    if (!mapConfigCopy.hasOwnProperty('mapProjection')) {
      Debug.warn('map should have set a `mapProjection`. Assuming default `EPSG:3857`.')
      mapProjection = getProj('EPSG:3857')
    } else {
      mapProjection = getProj(mapConfigCopy.mapProjection)
    }
    this.map_.set('mapProjection', mapProjection)

    const interfaceProjection = mapConfigCopy.hasOwnProperty('interfaceProjection')
      ? mapConfigCopy.interfaceProjection
      : 'EPSG:4326'

    this.map_.set('interfaceProjection', interfaceProjection)

    if (checkFor(mapConfigCopy, 'measurementProjection')) {
      try {
        this.map_.set('measurementProjection', getProj(mapConfigCopy.measurementProjection))
      } catch (e) {
        throw new Error('measurementProjection is not available, check for spelling or try to add it to' +
          ' additionalProjections with a proj4 definition.')
      }
    }

    this.configureLayers()

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                                    Creating View                                         //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    /**
     * @type {g4uViewOptions}
     */
    const viewOptions = mapConfigCopy.view || {}

    viewOptions.projection = mapProjection

    if (checkFor(mapConfigCopy.view, 'center')) {
      viewOptions.center = transform(mapConfigCopy.view.center, interfaceProjection, mapProjection)
    }

    if (checkFor(mapConfigCopy.view, 'extent')) {
      viewOptions.extent = applyTransform(
        mapConfigCopy.view.extent,
        getTransform(interfaceProjection, mapProjection)
      )
      viewOptions.constrainCenterOnly = true
    }

    const oldView = this.map_.getView()
    if (oldView) {
      viewOptions.center = oldView.getCenter() || mapConfigCopy.view.center
      viewOptions.resolution = oldView.getResolution() || mapConfigCopy.view.resolution
      viewOptions.rotation = oldView.getRotation() || mapConfigCopy.view.rotation
    }

    // creating the view
    const view = new View(viewOptions)

    // setting the extent overwrites any settings about zoom and start coordinates
    if (!oldView && checkFor(mapConfigCopy.view, 'fit')) {
      view.fit(transformExtent(mapConfigCopy.view.fit, interfaceProjection, mapProjection), {
        size: this.map_.getSize()
      })
    }

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                                   Setting View                                           //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    this.map_.setView(view)

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                                       Resize                                             //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    $(window).on('resize', () => { // NOTE: could get depricated with 'change:size'
      this.map_.dispatchEvent('resize')
    })

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                              Generic global handlers                                     //
    // //////////////////////////////////////////////////////////////////////////////////////// //

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

    // applying a mousedown class to the viewport if the mouse is down
    const mousedownClass = 'mousedown'
    $viewport.on('mousedown', () => {
      $viewport.addClass(mousedownClass)
    })
    $viewport.on('mouseup', () => {
      $viewport.removeClass(mousedownClass)
    })

    // Sets the Keyboardfocus on the Map
    $viewport.focus()

    // Let the keyboardfocus stay with the Map
    $viewport.on('click', () => {
      $viewport.focus()
    })

    // //////////////////////////////////////////////////////////////////////////////////////// //
    //                             UI (Interactions and Controls)                               //
    // //////////////////////////////////////////////////////////////////////////////////////// //

    Debug.tryOrThrow(() => {
      this.configureUI()
    })

    this.map_.set('api', new API(this.map_, mapConfigCopy.api))

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