UNPKG

geopf-extensions-openlayers

Version:

French Geoportal Extensions for OpenLayers libraries

685 lines (613 loc) 25 kB
import Control from "../Control"; import Geolocation from "ol/Geolocation"; import OlFeature from "ol/Feature"; import Point from "ol/geom/Point"; import SearchEngineGeocodeIGN from "./SearchEngineGeocodeIGN"; import Helper from "../../Utils/Helper"; import Select, { SelectEvent } from "ol/interaction/Select"; import Map from "ol/Map"; import Vector from "ol/layer/Vector"; import VectorSource from "ol/source/Vector"; import Overlay from "ol/Overlay.js"; import { Style, Icon, Stroke, Fill } from "ol/style"; import mapPinIcon from "./map-pin-2-fill.svg"; import Feature from "ol/Feature"; import { Layer } from "ol/layer"; import AbstractAdvancedSearch from "./AbstractAdvancedSearch"; /** Get style for features * @param {String|Array<Number>} color - Couleur du contour * @param {String|Array<Number>} [fillColor] - Couleur de remplissage * @param {Number} [offset = 0] - Décalage de la ligne. Par défaut, 0 * @returns {Style} Style OpenLayers */ function getStyle (color, fillColor, offset = 0) { return new Style({ image : new Icon({ src : mapPinIcon, color : color, anchor : [0.5, 1], }), stroke : new Stroke({ color : color, lineDash : [8, 8], width : 2, lineDashOffset : offset }), fill : new Fill({ color : fillColor || "rgba(0, 0, 0, 0.1)", }), }); } /** * @classdesc * Contrôle de recherche avancée permettant de rechercher via d'autres manières. * Gère aussi l'ajout des élements sur la carte etc. * * @extends {Control} * @module SearchEngineAdvanced */ class SearchEngineAdvanced extends Control { /** * Constructeur du contrôle de recherche avancée. * @param {SearchEngineAdvancedOptions} options - Options du constructeur. */ constructor (options) { options = options || {}; // call ol.control.Control constructor super(options); // Geolocation this.geolocation = new Geolocation({ // enableHighAccuracy must be set to true to have the heading value. trackingOptions : { enableHighAccuracy : true, }, projection : "EPSG:4326", }); this.layer = new Vector({ source : new VectorSource({}), zIndex : Infinity, style : [getStyle([255, 255, 255, 1]), getStyle([0, 0, 145, 1], null, 8)], }); this.selectInteraction = new Select({ layers : [this.layer], style : getStyle([145, 0, 0, 1], [145, 0, 0, 0.2]), }); // Initialize this.initialize(options); this._initContainer(options); this._initEvents(options); options.popupButtons = options.popupButtons ? options.popupButtons : []; this.selectInteraction.on("select", this._onSelectElement.bind(this)); this.popup = this._createPopup(options.popupButtons); } /** * Initialise les options du contrôle. * @param {SearchEngineAdvancedOptions} options - Options du constructeur. * @private */ initialize (options) { /** * Nom de la classe (heritage) * @private */ this.CLASSNAME = "SearchEngineAdvanced"; /** * @type {Array<AbstractAdvancedSearch>} */ this._searchForms; /** * Si vrai, écoute les clics sur le document pour gérer * la modale de recherche avancée * @type {Boolean} */ this.listenToClick = false; if (options.advancedSearch && options.advancedSearch instanceof Array) { this._searchForms = options.advancedSearch; } else { this._searchForms = []; } this._searchForms.forEach(search => { // Gère la recherche search.on("search", this.onAdvancedSearchResult.bind(this)); }); } /** * @override * @param {Map|null} map Carte cible */ setMap (map) { if (this.getMap() && this.baseSearchEngine) { this.getMap().removeControl(this.baseSearchEngine); } super.setMap(map); if (this.baseSearchEngine) { this.baseSearchEngine.setMap(map); } this._searchForms.forEach(search => { search.setMap(map); }); this.element.appendChild(this.advancedContainer); if (map) { // Place les couches au dessus des autres this.layer.setMap(map); map.addInteraction(this.selectInteraction); map.addOverlay(this.popup); } } /** * Retourne la couche utilisée pour afficher les résultats. * @returns {Layer} Couche des résultats */ getLayer () { return this.layer; } /** * Initialise les événements du contrôle (géolocalisation, navigation clavier, recherche). * @param {SearchEngineAdvancedOptions} options Options du constructeur. * @private */ _initEvents (options) { this.geolocation.on("change:position", () => { const pt = new Point(this.geolocation.getPosition()); pt.transform("EPSG:4326", this.getMap().getView().getProjection()); const evt = this.createEvent(pt, "Ma localisation"); this.addResultToMap(evt); this.dispatchEvent(evt); this.geolocation.setTracking(false); }); this.baseSearchEngine.input.addEventListener("keydown", function (/** @type {KeyboardEvent} */ e) { if (e.key === "Tab" && !e.shiftKey && this.locationBtn.checkVisibility()) { e.preventDefault(); this.locationBtn.focus(); } }.bind(this)); this.locationBtn.addEventListener("keydown", function (/** @type {KeyboardEvent} */ e) { if (e.key === "Tab") { e.preventDefault(); if (e.shiftKey) { // Retourne sur l'input this.baseSearchEngine.input.focus(); } else if (this.advancedBtn.checkVisibility()) { // Focus sur le bouton de recherche avancée this.advancedBtn.focus(); } else { this.eraseBtn.focus(); } } }.bind(this)); this.on("search", this.addResultToMap.bind(this)); // Gère le cas du conteneur de recherche avancée ["mousedown", "focusin"].map(eventListener => document.addEventListener(eventListener, this._onDocumentClick.bind(this))); this.advancedBtn.addEventListener("blur", function (e) { if (e.relatedTarget === this.baseSearchEngine.input) { this.listenToClick = false; this.advancedBtn.setAttribute("aria-expanded", false); } }.bind(this)); this.baseSearchEngine.input.addEventListener("blur", function (/** @type {FocusEvent} */e) { if (e.relatedTarget && e.relatedTarget === this.eraseBtn) { e.target.dispatchEvent(new Event("input")); } }.bind(this)); } /** * Crée un événement de recherche à partir d'un objet (Feature ou Point). * @param {Object|Point|OlFeature} obj Objet à afficher (Feature ou Point) * @param {String} [info] Texte affiché dans la popup * @returns {Object} Événement normalisé de type "search" */ createEvent (obj, info) { let evt = obj; if (obj instanceof OlFeature) { evt = { result : obj, extent : null }; } else if (obj instanceof Point) { evt = { result : new OlFeature(obj), extent : null }; } if (info) { evt.result.set("infoPopup", info); } evt.type = "search"; return evt; } /** * Initialise le conteneur principal du contrôle et les sous-composants. * @param {SearchEngineAdvancedOptions} options Options du constructeur * @private */ _initContainer (options) { // Gestion de l'affichage des options avancées const element = this.element = document.createElement("div"); element.className = "GPwidget gpf-widget"; element.id = Helper.getUid("GPsearchEngine-Advanced-"); // Default base search engine // const baseContainer = this.baseContainer = document.createElement("div"); // this.element.appendChild(baseContainer); options.target = this.element; options.searchButton = true; options.search = true; this.baseSearchEngine = new SearchEngineGeocodeIGN(options); this.baseSearchEngine.on(["select", "search", "autocomplete"], (e) => { this.dispatchEvent(e); }); // Geolocation this.locationBtn = this._getGeolocButton(); this.baseSearchEngine.autocompleteHeader.appendChild(this.locationBtn); // Ajout des options avancées const advancedBtn = this.advancedBtn = document.createElement("button"); advancedBtn.className = "GPSearchEngine-advanced-btn fr-btn fr-btn--sm fr-icon-arrow-up-s-line fr-btn--icon-right fr-btn--tertiary-no-outline"; advancedBtn.id = Helper.getUid("GPSearchEngine-advanced-btn-"); advancedBtn.type = "button"; advancedBtn.title = "Avancée"; advancedBtn.innerHTML = "Avancée"; advancedBtn.setAttribute("aria-label", "Afficher les options avancées"); advancedBtn.setAttribute("aria-expanded", "false"); // Gestion de l'affichage des options avancées const advancedContainer = this.advancedContainer = document.createElement("div"); advancedContainer.className = "GPAdvancedContainer"; advancedContainer.id = Helper.getUid("GPsearchEngine-AdvancedContainer-"); advancedContainer.setAttribute("aria-labelledby", advancedBtn.id); // baseContainer.appendChild(advancedContainer); // Geolocation advancedContainer.appendChild(this._getGeolocButton()); // Formulaires specifiques this._searchForms.forEach(Search => { const section = document.createElement("section"); section.className = "fr-accordion"; advancedContainer.appendChild(section); const title = document.createElement("h3"); title.className = "fr-accordion__title"; section.appendChild(title); const button = document.createElement("button"); button.type = "button"; button.className = "fr-accordion__btn"; button.setAttribute("aria-expanded", "false"); button.innerText = Search.getName(); title.appendChild(button); // Accordion const accordion = document.createElement("div"); accordion.className = "fr-collapse"; accordion.id = Helper.getUid("accordion-"); button.setAttribute("aria-controls", accordion.id); section.appendChild(accordion); // Contenu recherche avancée Search.setTarget(accordion); button.addEventListener("click", () => { const expanded = button.getAttribute("aria-expanded") === "true"; advancedContainer.querySelectorAll("section").forEach(sec => { sec.querySelector(".fr-collapse").classList.remove("fr-collapse--expanded"); sec.querySelector("button").setAttribute("aria-expanded", "false"); advancedContainer.dataset.open = !expanded; if (!expanded) { sec.classList.add("fr-hidden"); } else { sec.classList.remove("fr-hidden"); } }); if (!expanded) { button.setAttribute("aria-expanded", "true"); accordion.classList.add("fr-collapse--expanded"); section.classList.remove("fr-hidden"); } Search.dispatchEvent({ type : "expand", expanded : !expanded }); }); }); // Gestion du bouton avancé advancedBtn.setAttribute("aria-controls", advancedContainer.id); advancedBtn.addEventListener("click", function (/** @type {PointerEvent} */ e) { e.preventDefault(); const isHidden = advancedBtn.getAttribute("aria-expanded") === "false"; advancedBtn.setAttribute("aria-expanded", isHidden); this.listenToClick = isHidden; if (isHidden) { // Si la modale est ouverte, on met le focus sur le premier élément focusable const focusableSelectors = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])" ].join(","); const firstFocusable = advancedContainer.querySelector(focusableSelectors); if (firstFocusable) { firstFocusable.focus(); } } }.bind(this)); // N'ajoute pas le bouton s'il n'y a pas d'options avancées if (this._searchForms.length) { this.baseSearchEngine.optionscontainer.appendChild(advancedBtn); } // Ajout des options avancées const eraseBtn = this.eraseBtn = document.createElement("button"); eraseBtn.className = "GPSearchEngine-erase-btn fr-btn fr-btn--sm fr-icon-close-circle-fill fr-btn--tertiary-no-outline"; eraseBtn.id = Helper.getUid("GPSearchEngine-erase-btn-"); eraseBtn.type = "button"; eraseBtn.title = "Effacer la saisie"; eraseBtn.setAttribute("aria-label", "Effacer la saisie"); // Gestion du bouton avancé eraseBtn.addEventListener("click", function () { this.baseSearchEngine.input.value = ""; delete this.baseSearchEngine.input.dataset.empty; // Notifie l'input du changement this.baseSearchEngine.input.dispatchEvent(new Event("input")); // Met le focus sur l'input console.log("click"); setTimeout(() => { this.baseSearchEngine.input.focus(); }, 50); }.bind(this)); this.baseSearchEngine.optionscontainer.appendChild(eraseBtn); } /** * Fonction active si la recherche avancée est active * @param {PointerEvent} e Événement de clic sur le document */ _onDocumentClick (e) { if (this.listenToClick === true) { // Écoute des clics sur le document ==> recherche avancée active const clickOnAdvancedContainer = (this.advancedContainer === e.target || this.advancedContainer.contains(e.target)); const clickOnAdvancedBtn = this.advancedBtn === e.target; if (!(clickOnAdvancedContainer || clickOnAdvancedBtn)) { // On fait une action si un clic se produit en dehors du conteneur // Et si le bouton de recherche avancée n'est pas cliqué this.listenToClick = false; this.advancedBtn.setAttribute("aria-expanded", false); } } } /** * Ajoute les résultats (features) sur la carte et ajuste la vue. * @param {Object} e Événement de recherche contenant result/extent */ addResultToMap (e) { this._closePopup(); this.layer.getSource().clear(); let extent; if (!!e.result) { this.layer.getSource().addFeature(e.result); extent = e.result.getGeometry().getExtent(); this.selectInteraction.getFeatures().push(e.result); this._setPopupInfo(e.result); } if (!!e.extent) { this.layer.getSource().addFeature(e.extent); extent = e.extent.getGeometry().getExtent(); } if (this.getMap()) { let view = this.getMap().getView(); if (extent) { view.fit(extent); if (view.getZoom() > 15) { view.setZoom(15); } } } } /** * Ajoute les infos au popup * @param {Feature} [feature] Feature à ajouter. Si non fourni * @param {Number[]} [position] Position du popup */ _setPopupInfo (feature, position) { if (feature) { // Ferme l'ancien popup this.popup.setPosition(undefined); let offset = null; // Ajoute le popup if (feature.getGeometry()?.getType() === "Point") { // Place le popup sur le point position = feature.getGeometry()?.getCoordinates(); // TODO : AMÉLIORER L'OFFSET offset = [0, -20]; } this.popup.setPosition(position); offset && this.popup.setOffset(offset); this.setPopupContent(feature.get("infoPopup") || ""); this.popup.set("feature", feature); this.popup.set("layer", this.layer); } else { this.popup.setPosition(undefined); this.setPopupContent(""); this.popup.unset("feature"); this.popup.unset("layer"); } } /** * Callback lors de la sélection d'une feature (affiche le popup). * @param {SelectEvent} e Événement de sélection * @private */ _onSelectElement (e) { let position = e.mapBrowserEvent.coordinate; this._setPopupInfo(e.selected.length ? e.selected[0] : null, position); } /** * Crée et retourne l'overlay popup pour afficher les infos de feature. * @private * @param {PopupButton[]} popupButtons - Bouton à ajouter dans le popup (en plus de la suppression / fermeture). * @returns {Overlay} Overlay du popups */ _createPopup (popupButtons) { // Popup global let element = this._popupDiv = document.createElement("div"); // TODO : ajouter gp-feature-info-div lorsque les deux seront pareils element.className = "GPSearchPopup"; // Contenu du popup let popupContent = this._popupContent = document.createElement("div"); popupContent.className = "GPPopupContent"; // Groupe de boutons let popupBtns = this._popupBtns = document.createElement("div"); popupBtns.className = "GPButtonGroups gpf-btns-group"; popupBtns.appendChild(this._addCloseButton()); popupBtns.appendChild(this._addRemoveButton()); popupButtons.forEach(popupBtn => { popupBtns.appendChild(this._createCustomPopupButton(popupBtn)); }); element.appendChild(popupContent); element.appendChild(popupBtns); const overlay = new Overlay({ element : element, positioning : "bottom-center", }); return overlay; } /** * Définit le contenu HTML du popup. * @param {String} content Contenu HTML à afficher */ setPopupContent (content) { this._popupContent.innerHTML = content; } /** * Crée le bouton de fermeture du popup. * @returns {HTMLButtonElement} Bouton de fermeture * @private */ _addCloseButton () { let closer = document.createElement("button"); closer.title = closer.ariaLabel = "Fermer la pop-up"; closer.textContent = "Fermer"; closer.className = "GPButton gpf-btn fr-icon-close-line fr-btn fr-btn--sm gpf-btn--tertiary fr-btn--tertiary-no-outline"; // Ferme le popup closer.onclick = this._closePopup.bind(this); return closer; } /** * Ferme le popup et désélectionne la feature. * @returns {Boolean} false * @private */ _closePopup () { this.selectInteraction.getFeatures().clear(); if (this.popup !== null) { this.popup.setPosition(undefined); } return false; } /** * Crée le bouton de suppression du marqueur. * @returns {HTMLButtonElement} Bouton de suppression * @private */ _addRemoveButton () { let remove = document.createElement("button"); remove.title = remove.ariaLabel = "Supprimer le marqueur"; remove.textContent = "Supprimer"; remove.className = "GPButton gpf-btn fr-icon-delete-line fr-btn fr-btn--sm gpf-btn--tertiary fr-btn--tertiary-no-outline"; // Supprime la feature remove.onclick = this._removeFeature.bind(this); return remove; } /** * Supprime la feature sélectionnée de la couche et ferme le popup. * @private */ _removeFeature () { const f = this.popup.get("feature"); const layer = this.popup.get("layer"); // Supprime la feature if (layer && f) { layer.getSource().removeFeature(f); this.dispatchEvent({ type : this.REMOVE_FEATURE_EVENT, feature : f, layer : layer, }); // Ferme le popup this._closePopup(); } } /** * Crée un bouton personnalisé pour le popup. * @param {PopupButton} popupButton - Configuration du bouton. * @returns {HTMLButtonElement} Bouton HTML */ _createCustomPopupButton (popupButton) { const btn = document.createElement("button"); btn.title = btn.ariaLabel = popupButton.label; btn.className = "GPButton fr-btn fr-btn--sm fr-btn--tertiary-no-outline "; if (popupButton.className) { btn.className += popupButton.className; } if (popupButton.icon) { btn.classList.add(popupButton.icon); } if (popupButton.attributes) { Object.entries(popupButton.attributes).forEach(([key, value]) => { btn.setAttribute(key, value); }); } btn.onclick = () => { const feature = this.popup.get("feature"); if (feature && typeof popupButton.onClick === "function") { // New feature sans style const newFeature = feature.clone(); newFeature.setStyle(undefined); // Appel du callback if (popupButton.onClick.call(this, newFeature)) { // Feature traitée => supprimer de la sélection this._closePopup(); this.selectInteraction.getFeatures().clear(); this.layer.getSource().removeFeature(feature); }; } }; return btn; } /** * Crée le bouton de géolocalisation. * @returns {HTMLButtonElement} Bouton de géolocalisation * @private */ _getGeolocButton () { const locationBtn = document.createElement("button"); locationBtn.innerText = "Me géolocaliser"; locationBtn.className = "GPSearchEngine-locate fr-btn fr-btn--sm fr-icon-crosshair-2-line fr-btn--icon-left fr-btn--tertiary-no-outline"; locationBtn.addEventListener("click", () => { this.geolocation.setTracking(true); console.log("tracking", this.geolocation); }); return locationBtn; } /** * Callback lors d'un résultat de recherche avancée. * @param {Object} e Événement de recherche avancée * @private */ onAdvancedSearchResult (e) { // Ferme la modale de recherche avancée this.listenToClick = false; this.advancedBtn.setAttribute("aria-expanded", false); // Ferme les sections this.closeAllSections(); if (e.result instanceof Array) { // TODO : GÉRER MULTIPLE RÉSULTATS } else if (e.result instanceof Feature) { this.addResultToMap(e); } } /** * Ferme toutes les accordéons et affiche les sections */ closeAllSections () { this.advancedContainer.dataset.open = false; this.advancedContainer.querySelectorAll("section").forEach(section => { const btn = section.querySelector(".fr-accordion__title > button[aria-expanded]"); btn.setAttribute("aria-expanded", "false"); btn.ariaControlsElements[0].classList.remove("fr-collapse--expanded"); section.classList.remove("fr-hidden"); }); } } export default SearchEngineAdvanced; // Expose SearchEngine as ol.control.SearchEngine (for a build bundle) if (window.ol && window.ol.control) { window.ol.control.SearchEngineAdvanced = SearchEngineAdvanced; }