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