Source: core/core.js

/**
 * Core de la librairie Karteis Mapviewer. Il s'agit du point d'entrée pour
 * créer une nouvelle instance de carte Mapviewer
 * @module core
 */
import Map from 'ol/Map'
import View from 'ol/View'
import { getWidth, getHeight, buffer, containsXY } from 'ol/extent'

import { createExtent, none } from 'ol/centerconstraint'

import Overlay from 'ol/Overlay'

import Collection from 'ol/Collection'
import * as olEasing from 'ol/easing'

import ScaleLine from 'ol/control/ScaleLine'
import Attribution from 'ol/control/Attribution'

import * as olInteraction from 'ol/interaction'

import Observable from 'ol/Observable'

import { Group as LayerGroup } from 'ol/layer'

import FeatureSnapper from '../interaction/FeatureSnapper'
import CustomMouseWheelZoom from '../interaction/CustomMouseWheelZoom'
import Hover from '../interaction/Hover'
import LayersFilters from '../interaction/LayersFilters'

import ControlBar from '../control/ControlBar'
import CustomPrint from '../control/CustomPrint'

import { saveAs } from 'file-saver'

import isNil from 'lodash/isNil'
import pickBy from 'lodash/pickBy'
import mapValues from 'lodash/mapValues'
import isBoolean from 'lodash/isBoolean'
import isPlainObject from 'lodash/isPlainObject'
import upperFirst from 'lodash/upperFirst'
import isObject from 'lodash/isObject'

import { transform, transformExtent } from 'ol/proj'
import { loadProjections } from '../tools/mapviewer-services'

import { getResolutionForScale, getScaleForResolution, getZoomForScale } from '../services/scales-service'
import ControlLegendManager from '../control/ControlLegendManager'
import TokenManagerPool from '../services/token-manager-pool'

function getDefaultInteractions (interactions = {}) {
  // pour les bases interactions on vérifi juste si une entrée avec valeur false la contredit
  const baseInteractions = olInteraction.defaults(pickBy(interactions, isBoolean))
  // nouvelle interaction que l'on met par défaut, saufg si on ne la demande pas expressement
  if (interactions?.dblClickDragZoom !== false) {
    baseInteractions.push(new olInteraction.DblClickDragZoom())
  }

  const MapviewerInteractions = {
    CustomMouseWheelZoom,
    Hover,
    ...olInteraction,
  }

  // coté custom, on les charges si on un objet de paramétrage
  const customInteractions = pickBy(mapValues(
    pickBy(interactions, isPlainObject),
    (options, name) => {
      // les interactions développés pour mapviewer peuvent avoir besoin de connaitre viewer (exemple pour Hover)
      // on étend les options
      options = {
        ...options,
        viewer: this,
      }
      const interactionName = upperFirst(name)
      const InteractionConstructor = MapviewerInteractions[interactionName]
      if (!InteractionConstructor) {
        console.warn(`L'interaction nommée ${name} n'a pas été trouvée et ne sera pas ajoutée`)
        return null
      }
      return new InteractionConstructor(options)
    }), (value) => !isNil(value))

  return baseInteractions.extend(Object.values(customInteractions))
}

/**
 * Créer une nouvelle carte MapViewer
 * @class
 * @param {Object} options Options
 * @param {string?} [options.mapdiv='map'] Identifiant de la div qui reçevra la carte.
 * @param {string?} [options.projection='EPSG:3857'] Système de projection de la carte.
 * @param {ol.coordinate?} [options.defaultCenter=[0,0]] Centre par défaut de la carte.
 * @param {number?} [options.defaultZoom=10] Niveau de zoom par défault de la carte.
 * @param {number?} [options.maxZoom=28] Niveau de zoom maximal.
 * @param {number?} [options.minZoom=0] Niveau de zoom minimal.
 * @param {number?} [options.moveTolerance=1] The minimum distance in pixels the cursor must move to be detected as a map move event instead of a click. Increasing this value can make it easier to click on the map
 * @param {number?} [options.hitTolerance=0] Tolérence de précision en pixel sur la sélection.
 * @param {boolean?} [options.strict=false] Effectu la vérification d'ajout sur une toolbar lors de l'ajout de controls.
 * @param {Object} options.interactions Configuration des interaction présente. Par défaut, toutes présentes.
 * @param {boolean} [options.interactions.altShiftDragRotate = true] Rotation de la carte avec Alt-Shift-Drag. Par défaut True
 * @param {boolean} [options.interactions.doubleClickZoom = true] Zoom avec un double clic. Par défaut True
 * @param {boolean} [options.interactions.keyboard = true] Interaction avec le clavier si possible (zoom avec + et -, déplacement avec flèches). Par défaut True
 * @param {boolean} [options.interactions.mouseWheelZoom = true] Zoom avec la molette de la souris. Par défaut True
 * @param {object} options.interactions.customMouseWheelZoom Zoom personnalisé avec la molette de la souris. Par défaut undefined
 * @param {string} options.interactions.customMouseWheelZoom.condition Condition de prise en charge du zoom. (altKeyOnly, altShiftKeysOnly, noModifierKeys, platformModifierKeyOnly, shiftKeyOnly)
 * @param {boolean} [options.interactions.shiftDragZoom = true] Zoom sur zone avec Shift-Drag. Par défaut True
 * @param {boolean} [options.interactions.dragPan = true] Déplacement avec le pan (appareils tactiles uniquement). Par défaut True
 * @param {boolean} [options.interactions.pinchRotate = true] Rotation avec pinch (appareils tactiles uniquement). Par défaut True
 * @param {boolean} [options.interactions.pinchZoom = true] Zoom avec pinch (appareils tactiles uniquement). Par défaut True
 * @param {boolean} [options.interactions.dblClickDragZoom = true] Zoom par double-clic + glisser. Par défaut True
 * @param {boolean} [options.interactions.hover = false] Démarre l'api de survol de feature automatiquement
 * @param {boolean} [options.zoomVisible = true] Affiche le contrôle de zoom dans la barre verticale
 * @param {boolean} [options.zoomSliderVisible = false] Affiche un slider de zoom dans la barre verticale
 * @param {boolean} [options.zoomRectangle = false] Affiche un bouton de zoom rectangle dans la barre verticale
 * @param {boolean} [options.rotateControl = true] Affiche un bouton de retour au nord dans la barre verticale
 * @param {boolean} [options.exportControl = false] affichage du bouton d'export de la carte
 * @param {boolean} [options.copyMapControl = false] affichage du bouton de copie dans le presse papier
 * @param {boolean} [options.isDesktop = false] viewer en mode desktop, impacte les outils de dessin
 * @param {ol.coordinate | function} options.zoomOrigin Défini le zoom d'origine de la carte (bouton centrer la carte). Par défaut bouton non visible
 * @param {string} options.zoomExtentLabel Contenu du bouton de zoom à l'origine de la carte
 * @param {ol.extent} options.limitExtent Permet de définir un périmètre de déplacement autorisé sur la carte.
 * @param {number} options.bufferNullExtent Permet de définir un buffer à appliquer sur les extents sans taille.
 * @param {Object[]} projections Enregistre ces projections en mémoire
 * @param {string} projections[].name Nom de la projection (EPSG:4326)
 * @param {string} projections[].projection Définition proj4 (exemple {@links https://epsg.io/27562})
 * @param {object} scaleLineOptions options pour la scaleLine {@links https://openlayers.org/en/latest/apidoc/module-ol_control_ScaleLine-ScaleLine.html}
 * @param {number?} options.minScale Echelle minimale d'affichage
 * @param {number?} options.maxScale Echelle maximale d'affichage
 * @param {(null | 'top'|'bottom'|'right'|'left'|'top-left'|'top-right'|'bottom-left'|'bottom-right')} [options.verticalBarPosition = 'top-left'] position par défaut de la barre verticale
 * @param {(null | 'top'|'bottom'|'right'|'left'|'top-left'|'top-right'|'bottom-left'|'bottom-right')} [options.horizontalBarPosition = 'bottom'] position par défaut de la barre horizontale
 */
// const Mapviewer = function (options = {}) {
class Mapviewer {
  /**
   * @type {TokenManagerPool}
   */
  tokenManagerPool

  /**
   * tolerance au clic pour les outils de sélection
   * @type {number}
   */
  hitTolerance = 0
  /** gestion du padding au niveau applicatif entier (permet a l'application de se "décentrer")
   * @type {Array<number>}
  */
  padding = null
  /** Extent de l'origine de la carte (peut-être une fonction), utilisé dans ControlBar
   * @type {function | ol.Coordinate}
  */
  zoomOrigin = null
  /** Gestion d'un buffer sur le zoom d'une extent sans taille
   * @type {number}
  */
  bufferNullExtent = 20
  /** mode desktop du viewer
   *  @type {boolean}
  */
  isDesktop = false
  /**
   * @type {ol.Map}
   */
  Map = null
  constructor (options = {}) {
  /** options par défaut */
    options = {
      mapdiv: 'map',
      projection: 'EPSG:3857',
      defaultCenter: [0, 0],
      defaultZoom: 10,
      hitTolerance: 0,
      minZoom: 0,
      maxZoom: 28,
      padding: null,
      strict: false,
      zoomVisible: true,
      rotateControl: true,
      zoomSliderVisible: false,
      zoomRectangle: false,
      hasExportControl: false,
      hasCopyMapControl: false,
      bufferNullExtent: 20,
      isDesktop: false,
      ...options,
    }

    // si on a défini des projections, on les charge en tout premier
    const projections = options.projections || null
    if (projections) {
      loadProjections(projections)
    }

    /** Paramètres a garder en mémoire */
    // tolerance au clic pour les outils de sélection
    this.hitTolerance = options.hitTolerance
    // gestion du padding au niveau applicatif entier (permet a l'application de se "décentrer")
    this.padding = options.padding
    // Extent de l'origine de la carte (peut-être une fonction), utilisé dans ControlBar
    this.zoomOrigin = options.zoomOrigin
    // Gestion d'un buffer sur le zoom d'une extent sans taille
    this.bufferNullExtent = options.bufferNullExtent
    // mode desktop du viewer
    this.isDesktop = options.isDesktop

    // controles et interractions a ajouter a l'initilisation
    const interactions = getDefaultInteractions.call(this, options.interactions)
    const controls = new Collection()

    /**
   * @type {ControlBar}
   */
    this.verticalToolbar = new ControlBar({
      className: 'kmapv-vertical-bar',
      viewer: this,
      position: options?.verticalBarPosition || 'top-right',
    })
    controls.push(this.verticalToolbar)

    /**
   * @type {ControlBar}
   */
    this.horizontalToolbar = new ControlBar({
      className: 'kmapv-horizontal-bar',
      viewer: this,
      position: options?.horizontalBarPosition || 'bottom',
    })
    controls.push(this.horizontalToolbar)
    /**
   * @type {CustomPrint}
   */
    this.printControl = new CustomPrint({
      imageType: 'image/png',
    })
    controls.push(this.printControl)

    // pour placer plus tard le layer switcher
    this.layerManager = null

    // gestion de la légende
    this.legendManager = new ControlLegendManager({ viewer: this })
    controls.push(this.legendManager)
    // this.legendManager = new MapViewerLegendManager({ viewer: this })

    // controle de zoom rectangle
    if (options.zoomRectangle) {
      this.verticalToolbar.addZoomBox()
    }

    // Contrôle de niveau de zoom
    if (options.zoomVisible) {
      this.verticalToolbar.addZoom({ zoomExtentLabel: options.zoomExtentLabel })
    }

    // Contrôle de slider de zoom
    if (options.zoomSliderVisible) {
      this.verticalToolbar.addZoomSlider()
    }

    // Controle de gestion de la rotation
    if (options.rotateControl) {
      this.verticalToolbar.addRotate()
    }

    if (options.hasExportControl) {
      this.verticalToolbar.addExportMapButton()
    }

    if (options.hasCopyMapControl) {
      this.verticalToolbar.addCopyMapButton()
    }

    // Controle pour afficher l'echelle
    controls.push(new ScaleLine(options?.scaleLineOptions))

    // Controle pour afficher les attribution de la carte
    controls.push(new Attribution({ collapsible: false }))

    // outil de Snap, on l'initialise et ajoute a core
    /** @type {FeatureSnapper} */
    this.featureSnapper = new FeatureSnapper({ viewer: this })
    interactions.push(this.featureSnapper)

    // outil de gestion des filtrages de données, on l'initialiser et ajout a core
    /** @type{LayersFilters}  */
    this.layerFilterInteraction = new LayersFilters({ viewer: this })
    interactions.push(this.layerFilterInteraction)

    // Création de la carte Openlayers
    this.Map = new Map({
      controls,
      interactions,
      target: options.mapdiv,
      moveTolerance: options.moveTolerance,
      view: new View({
        projection: options.projection,
        center: options.defaultCenter,
        zoom: options.defaultZoom,
        minZoom: options.minZoom || 0,
        maxZoom: options.maxZoom || 28,
        extent: options.limitExtent,
      }),
    })

    this.projection = options.projection
    // les notions de zoom & scale/résolutions sont inversées (max zoom = proche du sol)
    // options minScale? on la convertie en zoom
    if (options?.minScale) {
      this.Map.getView().setMaxZoom(getZoomForScale(this.Map.getView(), options.minScale))
    }

    // options maxScale? on la convertie en zoom
    if (options?.maxScale) {
      this.Map.getView().setMinZoom(getZoomForScale(this.Map.getView(), options.maxScale))
    }

    const originalAddControl = this.Map.addControl.bind(this.Map)
    this.Map.addControl = function (control) {
      if (options.strict) {
        console.warn('Pour ajouter le control a une toolbar utiliser [instanceMapviewer].verticalToolbar.addControl(control) ou [instanceMapviewer].horizontalToolbar.addControl(control)', control)
      }
      originalAddControl(control)
    }

    // Diffusion de l'event click
    this.Map.on('click', ev => {
      this.dispatchEvent('click', {
        coordinate: ev.coordinate,
        pixel: ev.pixel,
        resolution: this.Map.getView().getResolution(),
      })
    })

    // Diffusion de l'event singleclick
    this.Map.on('singleclick', ev => {
      this.dispatchEvent('singleclick', {
        coordinate: ev.coordinate,
        pixel: ev.pixel,
        resolution: this.Map.getView().getResolution(),
      })
    })

    // Diffusion de l'event de changement de rotation
    this.Map.getView().on('change:rotation', () => {
      this.dispatchEvent('change:rotation', {
        rotation: this.getRotation(),
      })
    })

    this.tokenManagerPool = new TokenManagerPool()
  }

  /**
   * Realise un zoom avec un delta
   *
   * @param {number} delta Delta de zoom sur le zoom courrant
   */
  #zoomByDelta_ = delta => {
    const view = this.Map.getView()
    if (view) {
      const resolution = view.getResolution()
      if (resolution) {
        view.animate({
          resolution: view.constrainResolution(resolution, delta),
          duration: 250,
          easing: olEasing.easeOut,
        })
      }
    }
  }

  setPadding (padding) {
    this.padding = padding
    // reflete le padding aux interractions en controles
    this.dispatchEvent('change:padding', { padding: this.padding })
    this.refreshMap()
  }

  /**
   * Zoom d'un niveau
   */
  zoomIn = () => this.#zoomByDelta_(1)

  /**
   * Dezoom d'un niveau
   */
  zoomOut = () => this.#zoomByDelta_(-1)

  /**
   * Récupère le niveau de zoom actuel
   */
  getZoom () {
    return this.Map.getView().getZoom()
  }

  /**
   * set le niveau de zoom actuel
   * @param  {Number} zoom niveau de zoom, plus c'est élevè, plus on se rapproche
   */
  setZoom (zoom) {
    return this.Map.getView().setZoom(zoom)
  }

  /**
   * Permet de zoomer sur une extent
   *
   * @param {ol.Extent} extent Extent sur laquelle zoomer
   * @param {Object} options Voir ol.View.fit
   */
  zoomToExtent (extent, options) {
    if (getWidth(extent) === 0 || getHeight(extent) === 0) {
      extent = buffer(extent, this.bufferNullExtent)
    }
    options = options || {}
    if (this.padding && !options.padding) {
      options.padding = this.padding
    }
    this.Map.getView().fit(extent, options)
  }

  /**
   * Permet de zoomer sur une extent avec une animaion (1 seconde)
   * @param  {ol.Extent} extent  The destination extent
   * @param  {Object} options Voir ol.View.fit
   */
  flyToExtent (extent, options) {
    this.zoomToExtent(extent, { duration: 1000, ...options })
  }

  /**
   * Permet de recalculer l'ensemble des layers
   */
  refreshMap () {
    // Traitement pour les layers
    this.getFlatLayers().forEach(layer => {
      // revert de l'ancien code: layer.getSource().refresh() vide les calques vectoriels.
      // il faudrait voir ce que l'on veut faire, car l'appeler trop souvent pose des soucis de perf (clignotement des couches images)
      // utiliser une option??
      // if (typeof layer.getSource().refresh === 'function') {
      //   layer.getSource().refresh()
      if (typeof layer.refresh === 'function') {
        layer.refresh()
      } else if (typeof layer.changed === 'function') {
        layer.changed()
      }
    })

    // Le seule moyen d'actualiser le style des interactions de selection
    // est d'appeler changed sur les features de l'interaction
    this.Map.getInteractions().getArray()
      .filter(interaction => interaction instanceof olInteraction.Select)
      .map(interaction => interaction.getFeatures().getArray())
      .flat()
      .forEach(feature => feature.changed())
  }

  /**
   * renvoi les calques et ceux dans les groupe (un groupe est considéré par un layer par Map.getLayers)
   * @param {Array} layers mets a place cette liste, part de map.getLayers() si vide
   * @returns la liste des calques à plat
   */
  getFlatLayers (layers) {
    layers = layers || this.Map.getLayers().getArray()

    return layers.reduce((acc, groupOrLayer) => {
      if (groupOrLayer instanceof LayerGroup) {
        acc = [...acc, ...this.getFlatLayers(groupOrLayer.getLayers().getArray())]
      } else {
        acc.push(groupOrLayer)
      }
      return acc
    }, [])
  }

  /**
   * Permet de récupérer l'extent de la vue courrante
   */
  getExtent () {
    return this.Map.getView().calculateExtent(this.Map.getSize())
  }

  /**
   * Permet de récupérer le centre de la vue.
   * Possibilité de prendre en compte un padding.
   *
   * @param {Object} options Options pour récupérer le centre de la carte
   * @param {Array<number>} options.padding [right, top, bottom, left] Padding à appliquer si l'ensemble de la vue n'est pas visible par l'utilisateur
   * @returns {Array<Number>} Coordonnées du centre de la carte
   */
  getCenter ({ padding } = {}) {
    padding = padding || this.padding
    if (padding) {
      const [sizex, sizey] = this.Map.getSize()
      const pixelX = (sizex / 2) + (padding[3] / 2) - (padding[1] / 2)
      const pixelY = (sizey / 2) + (padding[2] / 2) - (padding[0] / 2)
      return this.Map.getCoordinateFromPixel([pixelX, pixelY])
    }
    return this.Map.getView().getCenter()
  }

  /**
   * Permet de changer le centre de la vue.
   * Possibilité de prendre en compte un padding.
   *
   * @param {Array<Number>} center Nouveau centre de la carte à appliquer
   * @param {Object} options Options pour modifier le centre de la carte
   * @param {Array<number>} options.padding [top, right, bottom, left] Padding à appliquer si l'ensemble de la vue n'est pas visible par l'utilisateur
   */
  setCenter (center, { padding } = {}) {
    padding = padding || this.padding
    if (padding) {
      const [pixelX, pixelY] = this.Map.getPixelFromCoordinate(center)
      const pixelWithPadding = [
        pixelX + (padding[1] / 2) - (padding[3] / 2),
        pixelY + (padding[0] / 2) - (padding[2] / 2),
      ]
      const realCenter = this.Map.getCoordinateFromPixel(pixelWithPadding)
      return this.Map.getView().setCenter(realCenter)
    }
    return this.Map.getView().setCenter(center)
  }

  /**
   * Permet de récupérer la rotation en radian de la carte
   *
   * @returns {Number} Rotation de la carte en radian
   */
  getRotation () {
    return this.Map.getView().getRotation()
  }

  /**
   * Permet de modifier la rotation de la carte
   *
   * @param {Number} rotation Rotation de la carte en radian
   * @param {Object} options Options pour la rotation de la carte
   * @param {Array<Number>} options.padding Padding à prendre en compte pour la rotation [top, right, bottom, left]
   */
  setRotation (rotation, { padding } = {}) {
    padding = padding || this.padding
    // Récupère le centre réel avec le padding
    const realCenter = this.getCenter({ padding })
    const currentRotation = this.getRotation()

    // Change la rotation autour de ce centre
    this.Map.getView().adjustRotation(rotation - currentRotation, realCenter)
  }

  /**
   * Permet de récupérer la résolution actuelle de la carte
   *
   * @returns {Number} Résolution de la carte
   */
  getResolution () {
    return this.Map.getView().getResolution()
  }

  /**
   * Permet de modifier la résolution actuelle de la carte
   *
   * @param {Number} resolution Résolution de la carte
   */
  setResolution (resolution) {
    this.Map.getView().setResolution(resolution)
  }

  /**
   * Permet de savoir si une feature est visible dans la vue courrante
   * @param {ol.Feature} feature Feature à tester
   */
  isFeatureInMapExtent (feature) {
    if (feature) {
      const featExtent = feature.getGeometry().getExtent()
      const extent = this.Map.getView().calculateExtent(this.Map.getSize())

      return containsXY(extent, featExtent[0], featExtent[1]) || containsXY(extent, featExtent[2], featExtent[3])
    }
  }

  /**
   * @typdef {Object} PostFilter
   * @property {'citycode'|'postalcode'|'city'|'context'} type type de filtre (seul les types 'citycode' et 'postalcode' sont pris en charge)
   * @property {Array<string|number>} values liste des valeurs que l'on accepte
   *
   *
   * Paramètre de recherche WfsSource
   * @typdef {Object} WfsSource
   * @property {String} name type de filtre
   * @property {Object} wfs Paramère générale de la source
   * @property {string} wfs.url Url du service
   * @property {string?} [wfs.version=1.0.0] version du service
   * @property {string} featureNS Namespace (a retrouver avec getCapabilities)
   * @property {string} featurePrefix prefix de la source a interroger
   * @property {Array<string>} featureTypes // liste des couches a interroger
   * @property {Array<string>} searchIn // recherche ce que l'utilisateur va taper dans ces champs
   * @property {Object} prefilter préfiltrage sur une des propriétée
   * @property {string | Array} prefilter.key Nom du champ que l'on préfiltre, on peut utiliser une valeur, une liste de valeur ou un mot clé pour utiliser un résultat précédent (code_dep: "94" ou  code_insee: ['94041','94081','94046','94002'] ou code_dep: '__PREVIOUS_RESULT__.commune.code_dep' ou code_com: '__PREVIOUS_RESULT__.commune.<%= code_insee.slice(2) %>')
   * @property {string | Array} value nom du champ ou des champs a retourner (pour utiliser dans la cascade)
   * @property {string} display libellé a afficher dans la liste
   * @property {string?} placeholder surcharge du placeholder de la zone de recherche pour cette étape
   * @property {string?} geometryName  nom de la propriété géométrie (utilisé pour filtre bbox ou lors du zoom vers une feature)
   * @property {boolean?} useBbox tente d'utiliser la bbox du résultat fin le plus proche comme filtre, geometryName est nécessaire
   * @property {number?} minLength surcharge du nombre de caractère minimum a taper pour cette étape
   *
   * Permet d'ajouter un contrôle de recherche
   * @param {Object} options Options de création du contrôle de recherche
   *  @param {'ban'|'photon'|'wfs'} [options.provider=ban] Nom du fournisseur pour la recherche
   *  @param {boolean} [options.reverse=false] Affiche un outil de géocodage inverse d'adresse
   *  @param {string} [options.reverseTitle=Cliquer sur la carte...] Titre à afficher sur le tooltip du bouton de géocodage inverse
   *  @param {boolean} [options.position=true] Priorise les résultats près du centre de la carte affichée
   *  @param {string} [options.label=Rechercher] Libellé affiché pour la zone de recherche
   *  @param {string} [options.placeholder=Rechercher une adresse] Placeholder de la zone de recherche
   *  @param {integer} [options.maxItems=10] Nombre de résultats affichés classés par score
   *  @param {integer} [options.limit=10] Nombre de résultats recherchés (utile lorsque l'on va appliquer un filtre)
   *  @param {number} [options.typing=500] le délais en ms pour lancer la recherche après une saisie utilisateur
   *  @param {integer} [options.minLength=3] la longueur de la chaine de recherche à partir de laquelle lancer la recherche
   *  @param {integer} [options.resultZoom=16] Zoom minimal à appliquer lors de la localisation sur la carte d'un résultat
   *  @param {Array<string>} [options.citycodes=[]] Liste de code insee sur lesquels on va lancer la recherche (attention, une requête sera réalisée par code insee)
   *  @param {Array<PostFilter>} [options.postfilters=[]] Liste des filtres à appliquer sur les résultats (attention, il est possible qu'aucun résultat ne s'affiche)
   *  @param {Array<WfsSource>} [options.wfsSources=[]] Options de recherche pour le type wfs en cascade (dans l'ordre du tableau)
   */
  addSearchControl (options = {}) {
    this.verticalToolbar.addSearchControl(options)
  }

  /**
   * Permet de définir un extent comme limite de déplacement sur la carte
   *
   * @param {ol.extent} extent Extent à appliquer (dans l'epsg de la carte)
   * @param {Boolean} center Place la carte au centre de l'extent. Par défaut false
   * @param {number} buffer Ratio du buffer à appliquer à l'extent. Par défaut 1 (extent original)
   */
  setLimitExtent (extent, forceCenter, buffer = 1) {
    const view = this.Map.getView()

    if (extent) {
      if (buffer !== 1) {
        // Calcule le buffer à appliquer par rapport à l'extent
        const widthExtent = extent[2] - extent[0]
        const heightExtent = extent[3] - extent[1]
        const widthBuffer = widthExtent * (buffer - 1) / 2
        const heightBuffer = heightExtent * (buffer - 1) / 2

        extent[0] -= widthBuffer
        extent[1] -= heightBuffer
        extent[2] += widthBuffer
        extent[3] += heightBuffer
      }
      // Applique la nouvelle contrainte de centre sur la vue
      view.constrainCenter = createExtent(extent)

      // Si forceCenter, ce place au centre de l'extent sinon, on
      // se place dans l'extent par rapport au centre actuel
      const center = forceCenter
        ? [
            extent[0] + (extent[2] - extent[0]) / 2,
            extent[1] + (extent[3] - extent[1]) / 2,
          ]
        : view.constrainCenter(view.getCenter())

      view.setCenter(center)
    } else {
      // Si pas d'extent, on supprime la contrainte
      view.constrainCenter = none
    }
  }

  createOverlay (options) {
    return new Overlay(options)
  }

  /** Supprime toutes les sources du featureSnapper */
  clearFeatureSnapper () {
    this.featureSnapper.clear()
  }

  /**
   * @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...
  */

  addSourceToFeatureSnapper (snapSource) {
    this.featureSnapper.addSource(snapSource)
  }

  /** Renvoit le featureSnapper
   * @returns {FeatureSnapper}
  */
  getFeatureSnapper () {
    return this.featureSnapper
  }

  /**
 * Renvoit une promesse avec le screenshot de la carte
 * @param {Object} options
 * @param {string} options.imageType Format d'image, default image/png
 * @param {number} options.quality Number between 0 and 1 indicating the image quality to use for image formats that use lossy compression such as image/jpeg and image/webp
 * @param {string} options.orientation Orientation (paysage/portrait), default "devine le meilleur"
 * @param {boolean} options.immediate=false force l'impression même si la carte n'a pas finie de charger, default false
 * @param {boolean} options.toDataUrl=false retour sous forme de "data url", default false
 * @returns {Promise<Blob> | Promise<string>} Blob ou dataUrl de l'image de la carte
 */
  async getMapSnapshot (options) {
    const rtn = new Promise((resolve, reject) => {
      this.printControl.once(['print', 'error'], async function (e) {
      // Print success
        if (e.image) {
          if (options?.toDataUrl) {
            resolve(e.canvas.toDataURL(e.imageType))
          } else {
            e.canvas.toBlob((blob) => {
              resolve(blob)
            }, e.imageType)
          }
        } else {
          console.warn('No canvas to export')
          reject(new Error('No canvas to export'))
        }
      })
    })
    this.printControl.print(options)
    return rtn
  }

  /**
 * télécharge une copie de la carte
 * @param {Object} options
 * @param {string} options.imageType Format d'image, default image/png
 * @param {number} options.quality Number between 0 and 1 indicating the image quality to use for image formats that use lossy compression such as image/jpeg and image/webp
 * @param {string} options.orientation Orientation (paysage/portrait), default "devine le meilleur"
 * @param {boolean} options.immediate=false force l'impression même si la carte n'a pas finie de charger, default false
 * @param {string} options.filename Nom de fichier
 * @returns {Promise<Blob>} Blob de l'image de la carte
 */
  async print (options) {
    this.printControl.print(options)
    this.printControl.once(['print', 'error'], async function (e) {
    // Print success
      if (e.image) {
        e.canvas.toBlob((blob) => {
          const now = new Date()
          const defaultFilename = now.getFullYear() + '-' + now.getMonth() + '-' + now.getDate() + ' ' + now.getHours() + '-' + now.getMinutes() + '-' + now.getSeconds()

          const filename = options?.filename ? options.filename : 'map.' + defaultFilename + '.' + e.imageType.replace('image/', '')
          saveAs(blob, filename)
        }, e.imageType)
      } else {
        console.warn('No canvas to export')
      }
    })
  }

  /**
 * Copie la carte dans le presse papier
 * @param {Object} options
 * @param {string} options.imageType Format d'image, default image/png
 * @param {number} options.quality Number between 0 and 1 indicating the image quality to use for image formats that use lossy compression such as image/jpeg and image/webp
 * @param {string} options.orientation Orientation (paysage/portrait), default "devine le meilleur"
 * @param {boolean} options.immediate=false force l'impression même si la carte n'a pas finie de charger, default false
 */
  async copyMap (options) {
    this.printControl.copyMap(options)
  }

  /**
 * Lance le mode mesure
 * @param {('distance'|'area')} measureType type de mesure a effectuer
 * @return MapMeasureEvent
 */
  async startMeasure (measureType) {
    if (!this.DATALAYER_LOADED) {
      return
    }

    const rtn = new Promise((resolve, reject) => {
      this.once(['measureend', 'measureabort'], async function (evt) {
      //  success
      // if (evt.type === 'measureend') {
        resolve(evt)
        // note dhe: on a peut être pas besoin de lever une erreur, juste fournir un resolve
      /* } else {
        reject(new Error('Annulé'))
      } */
      })
    })

    this.dataLayer.changeMapMode(measureType === 'distance' ? 'measureLine' : 'measureArea')

    return rtn
  }

  /**
 * Démarre l'enregistrement
 * @param {Object} options
 * @param {Boolean} options.interval interval d'enregistrement des points
 * @param {Number} options.precision Nombre de chiffres après la virgule
 * @param {Number} options.horodatage interval d'enregistrement des points
 */
  startRecordingTrace (options) {
    if (this.horizontalToolbar.getControlByName('kmapv-control-record-trace')) { return }

    const traceControl = this.horizontalToolbar.addRecordTraceButton(options)
    traceControl.startRecording(options)
  // démarre le service de recording, ajoute un contrôle "enregistrement en cours" qui affiche les bouton d'inteaction
  }

  /**
 * Validation, lance l'event, renvoit le résultat et retirer le controle
 * @param {Object} options
 * @param {Boolean} options.silent ne lance pas l'event
 * @returns {object} {positions, feature}
 */
  validateRecordingTrace (options) {
    const traceControl = this.horizontalToolbar.getControlByName('kmapv-control-record-trace')
    if (traceControl) {
      return traceControl.validateRecording(options)
    }

    return null
  }

  /** Annule l'enregistrement de trace */
  stopRecordingTrace () {
    const traceControl = this.horizontalToolbar.getControlByName('kmapv-control-record-trace')
    if (traceControl) {
      traceControl.cancelRecording()
    }
  }

  /**
 * Démarre ou remplace le service de survol des features
 * @param {Object} options
 */
  setFeatureHover (options) {
  // On assume que le dev n'a pas chargé DEUX instance de Hover..
    let featureHoverInteraction = this.Map.getInteractions().getArray().find(interaction => interaction instanceof Hover)

    if (featureHoverInteraction) {
      this.Map.removeInteraction(featureHoverInteraction)
    }

    featureHoverInteraction = new Hover({
      viewer: this,
      ...options,
    })

    this.Map.addInteraction(featureHoverInteraction)
  }

  /**
 * Met en pause ou active le service survol des features
 * @param {Boolean} active
 */
  setFeatureHoverActive (active) {
  // On assume que le dev n'a pas chargé DEUX instance de Hover..
    const featureHoverInteraction = this.Map.getInteractions().getArray().find(interaction => interaction instanceof Hover)

    if (!featureHoverInteraction) {
      console.log('[Core] : action impossible: outil de survol non chargé, chargement via la méthode setFeatureHover')
      return
    }
    featureHoverInteraction.setActive(active)
  }

  /**
 * Renvoi la carte
 * @returns {ol.Map}
 */
  getMap () {
    return this.Map
  }

  /**
 * Verifi que la vue est dans la même projection que celle demandée et switch la projection
 * @param {String} toProjection
 * @returns {Boolean} projection changed
 */
  setViewProjection (toProjection) {
  // recréer la vue si nécessaire
    const currentView = this.Map.getView()
    const currentProjection = currentView.getProjection().getCode()
    toProjection = isObject(toProjection) ? toProjection.getCode() : toProjection
    if (toProjection && currentProjection !== toProjection) {
      const minZoom = currentView.getMinZoom()
      const maxZoom = currentView.getMaxZoom()
      const minResolution = currentView.getMinResolution()
      const maxResolution = currentView.getMaxResolution()
      const rotation = currentView.getRotation()
      // récupère l'étendue actuelle de la carte pour zoomer dessus une fois le changement de vue effectué
      const currentExtent = currentView.calculateExtent(this.Map.getSize())

      const view = new View({
        projection: toProjection,
        center: transform(currentView.getCenter(), currentProjection, toProjection),
        maxZoom,
        minZoom,
        minResolution,
        maxResolution,
        rotation,
      })
      const newExtent = transformExtent(currentExtent, currentProjection, toProjection)

      this.Map.setView(view)

      this.Map.getView().on('change:rotation', () => {
        this.dispatchEvent('change:rotation', {
          rotation: this.getRotation(),
        })
      })

      view.fit(newExtent, { nearest: true })
      this.projection = toProjection
      this.dispatchEvent('change:projection', {
        projection: toProjection,
      })
      return true
    }
    return false
  }

  /**
 * Renvoi le code de projection actuel de la carte
 * @returns {string} Code de projection
 */
  getViewProjection () {
    return this.Map.getView().getProjection().getCode()
  }

  /**
 * renvoi l'échelle d'affichage courante
 * @param {boolean?} [round=false] arrondir?
 * @returns Number
 */
  getScale (round = false) {
    return getScaleForResolution(this.Map.getView(), null, null, round)
  }

  /**
 * Met la carte a l'echelle demandée
 * @param {Number} scale echelle a atteindre
 * @param {boolean | object | null} animate Animer la mise a l'echelle (true: animation prédéfinie, object: options compatible {@link https://openlayers.org/en/latest/apidoc/module-ol_View-View.html#animate})
 */
  setScale (scale, animate) {
    const view = this.Map.getView()
    const resolution = getResolutionForScale(view, scale)

    if (!animate) {
      view.setResolution(resolution)
    } else {
      animate = isObject(animate) ? animate : {}
      view.animate({ ...animate, resolution })
    }
  }

  /**
   * Ajoute une source de token
   * @param {import("./token-manager").tokenOptions | String} tokenOptions options de gestion du token ou token en clair (historique)
  * @returns {import("./token-manager").TokenManager}
  */
  async addTokenSource (tokenOptions) {
    return this.tokenManagerPool.addTokenSource('', '', tokenOptions)
  }

  /**
   * Supprime une source de token
   * @param {import("./token-manager").tokenOptions | String} tokenOptions options de gestion du token ou token en clair (historique)
  * @returns {Boolean} true si le token a été supprimé
  */
  removeSourceToken (tokenOptions) {
    return this.tokenManagerPool.removeTokenSource(tokenOptions)
  }

  getTokenManager (tokenOptions) {
    return this.tokenManagerPool.getTokenManager(tokenOptions)
  }

  // #region Gestion du layerFilter
  // on l'initilise comme si c'était un module
  layerFilter = {
    // layerFilterInteraction: this.layerFilterInteraction,
    layerFilterControl: null,
    addGroup: (...args) => this.layerFilterInteraction.addGroup(...args),
    addFilter: (...args) => this.layerFilterInteraction.addFilter(...args),
    addLayers: (...args) => this.layerFilterInteraction.addLayers(...args),
    removeAllGroupsAndFilters: (...args) => this.layerFilterInteraction.removeAllGroupsAndFilters(...args),
    applyFilter: (...args) => this.layerFilterInteraction.applyFilter(...args),
    clear: (...args) => this.layerFilterInteraction.clear(...args),
    getCurrentFilter: (...args) => this.layerFilterInteraction.getCurrentFilter(...args),
  }
  // #endregion

  // #region Gestion des events
  #event = new Observable()
  /**
 * Permet de lever un event
 * @param {String} type Type d'event à lever
 * @param {*} data Données à transmettre
 */
  dispatchEvent (type, data) {
    this.#event.dispatchEvent({ type, ...data })
  }

  /**
 * Permet d'ecouter un event
 * @param {String} type Type d'event à écouter
 * @param {Function} listener Callback losque l'event survient
 * @param {Object} context Context d'appel du callback
 */
  on (type, listener, context) {
    if (context) {
      console.warn("Appel de viewer.on avec l'option context obsolète", type, listener, context)
    }
    return this.#event.on(type, listener)
  }

  /**
 * Permet d'ecouter un event une seule fois
 * @param {String} type Type d'event à écouter
 * @param {Function} listener Callback losque l'event survient
 * @param {Object} context Context d'appel du callback
 */
  once (type, listener, context) {
    if (context) {
      console.warn("Appel de viewer.once avec l'option context obsolète", type, listener, context)
    }
    return this.#event.once(type, listener)
  }

  /**
 * Permet de ne plus ecouter un event
 * @param {String} type Type d'event à écouter
 * @param {Function} listener Callback losque l'event survient
 * @param {Object} context Context d'appel du callback
 */
  un (type, listener, context) {
    if (context) {
      console.warn("Appel de viewer.un avec l'option context obsolète", type, listener, context)
    }
    this.#event.un(type, listener)
  }

  // #endregion

  use (patch) {
    const patched = patch(this)
    if (patched !== this) {
      throw new Error('The return value is not this')
    }
    return patched
  }
}

export default Mapviewer