Repository: IGNF/geopf-extensions-openlayers
Pull request #367: feat(ls): add greyscale btn. only works for raster pour l'instant
Author: login: azarz
Labels: name: new feature
Created: 2025-03-14T10:31:54Z | Merged: 2025-03-20T16:52:31Z

Importation du code SDK présent ici : https://github.com/IGNF/geoportal-sdk/blob/316168e8de142627da59dff008cc4c4b308bf2c2/src/OpenLayers/OlMapLayers.js#L965

- Pour l'instant, le bouton ne marche que pour les raster (WMTS et WMS)
- Pour l'instant, ne marche que sur le DSFR car le css du mode classic pour le layer switcher ne laisse pas la place pour le nouveau bouton.


Diff:
diff --git a/package.json b/package.json
index ae466ac91..d8a548047 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
 {
   "name": "geopf-extensions-openlayers",
   "description": "French Geoportal Extensions for OpenLayers libraries",
-  "version": "1.0.0-beta.3-369",
-  "date": "11/03/2025",
+  "version": "1.0.0-beta.3-367",
+  "date": "14/03/2025",
   "module": "src/index.js",
   "directories": {},
   "engines": {
diff --git a/src/packages/CSS/Controls/LayerSwitcher/DSFRlayerSwitcherStyle.css b/src/packages/CSS/Controls/LayerSwitcher/DSFRlayerSwitcherStyle.css
index ce7ee7751..68de4df74 100644
--- a/src/packages/CSS/Controls/LayerSwitcher/DSFRlayerSwitcherStyle.css
+++ b/src/packages/CSS/Controls/LayerSwitcher/DSFRlayerSwitcherStyle.css
@@ -103,7 +103,7 @@ button[id^=GPshowAdvancedTools_ID_][aria-pressed="false"] {
     box-shadow: none;
 }
 
-button[id^=GPshowAdvancedTools_ID_][aria-pressed="false "] + .GPlayerAdvancedTools > .GPlayerInfo {
+button[id^=GPshowAdvancedTools_ID_][aria-pressed="false"] + .GPlayerAdvancedTools > .GPlayerInfo {
     visibility: hidden;
 }
 
@@ -177,6 +177,15 @@ div[id^=GPadvancedTools_ID_] {
     mask: url("img/dsfr/info.svg") center no-repeat;
 }
 
+.gpf-btn-icon-ls-greyscale {
+    box-shadow: none;
+}
+
+.gpf-btn-icon-ls-greyscale::after {
+    -webkit-mask: url("img/dsfr/greyscale.svg") center no-repeat;
+    mask: url("img/dsfr/greyscale.svg") center no-repeat;
+}
+
 .gpf-btn-icon-ls-extent {
     box-shadow: none;
 }
@@ -290,7 +299,7 @@ div[id^=GPlayerInfoContent] {
         width: calc(100% + 40px);
     }
 
-    .GPlayerAdvancedTools > .GPlayerRemove, .GPlayerAdvancedTools > .GPlayerInfo, .GPlayerAdvancedTools > .GPlayerExtent {
+    .GPlayerAdvancedTools > .GPlayerRemove, .GPlayerAdvancedTools > .GPlayerGreyscale, .GPlayerAdvancedTools > .GPlayerInfo, .GPlayerAdvancedTools > .GPlayerExtent {
         display: none;
     }
 
diff --git a/src/packages/CSS/Controls/LayerSwitcher/GPFlayerSwitcherStyle.css b/src/packages/CSS/Controls/LayerSwitcher/GPFlayerSwitcherStyle.css
index 115f207ac..3789b7d1d 100644
--- a/src/packages/CSS/Controls/LayerSwitcher/GPFlayerSwitcherStyle.css
+++ b/src/packages/CSS/Controls/LayerSwitcher/GPFlayerSwitcherStyle.css
@@ -72,6 +72,7 @@ div[id^=GPlayerInfoTitle] {
 .GPlayerExtent,
 .GPlayerVisibility,
 .GPlayerInfo,
+.GPlayerGreyscale,
 .GPlayerInfoOpened,
 .GPlayerRemove,
 .GPlayerEdit {
@@ -84,6 +85,7 @@ div[id^=GPlayerInfoTitle] {
 .GPlayerVisibility,
 .GPlayerName,
 .GPlayerInfo,
+.GPlayerGreyscale,
 .GPlayerInfoOpened,
 .GPlayerRemove,
 .GPlayerEdit,
@@ -110,6 +112,7 @@ div[id^=GPlayerInfoTitle] {
 .GPlayerVisibility,
 .GPshowLayerAdvancedTools,
 .GPlayerInfo,
+.GPlayerGreyscale,
 .GPlayerInfoOpened,
 .GPlayerRemove,
 .GPlayerEdit {
@@ -179,6 +182,11 @@ button[id^=GPshowAdvancedTools_ID_][aria-pressed="true"] + .GPlayerAdvancedTools
   background-position: -55px 0;
 }
 
+.GPlayerGreyscale {
+  left: 0;
+  background-position: -55px 0;
+}
+
 .GPlayerInfoOpened {
   left: 0;
   background-position: -83px 0;
diff --git a/src/packages/CSS/Controls/LayerSwitcher/img/dsfr/greyscale.svg b/src/packages/CSS/Controls/LayerSwitcher/img/dsfr/greyscale.svg
new file mode 100644
index 000000000..435d8fe46
--- /dev/null
+++ b/src/packages/CSS/Controls/LayerSwitcher/img/dsfr/greyscale.svg
@@ -0,0 +1,3 @@
+<svg width="20" height="20" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path id="Ic&#195;&#180;ne" fill-rule="evenodd" clip-rule="evenodd" d="M6.99992 0.333496C10.6819 0.333496 13.6666 3.31816 13.6666 7.00016C13.6666 10.6822 10.6819 13.6668 6.99992 13.6668C3.31792 13.6668 0.333252 10.6822 0.333252 7.00016C0.333252 3.31816 3.31792 0.333496 6.99992 0.333496ZM6.99992 1.66683C4.0544 1.66683 1.66659 4.05464 1.66659 7.00016C1.66659 9.94568 4.0544 12.3335 6.99992 12.3335C9.94544 12.3335 12.3333 9.94568 12.3333 7.00016C12.3333 4.05464 9.94544 1.66683 6.99992 1.66683ZM6.99992 3.00016C9.20906 3.00016 10.9999 4.79102 10.9999 7.00016C10.9999 9.2093 9.20906 11.0002 6.99992 11.0002V3.00016Z" fill="#000091"/>
+</svg>
diff --git a/src/packages/Controls/LayerSwitcher/LayerSwitcher.js b/src/packages/Controls/LayerSwitcher/LayerSwitcher.js
index 37b063535..f99ad1215 100644
--- a/src/packages/Controls/LayerSwitcher/LayerSwitcher.js
+++ b/src/packages/Controls/LayerSwitcher/LayerSwitcher.js
@@ -5,6 +5,9 @@ import "../../CSS/Controls/LayerSwitcher/GPFlayerSwitcher.css";
 // import Control from "ol/control/Control";
 import Widget from "../Widget";
 import Control from "../Control";
+import ImageWMSSource from "ol/source/ImageWMS";
+import WMTSSource from "ol/source/WMTS";
+import TileWMSSource from "ol/source/TileWMS";
 import { unByKey as olObservableUnByKey } from "ol/Observable";
 import { intersects as olIntersects } from "ol/extent";
 import {
@@ -95,7 +98,7 @@ var logger = Logger.getLogger("layerswitcher");
  * LayerSwitcher.on("layerswitcher:change:position", function (e) {
  *    console.warn("layer", e.layer, e.position);
  * });
- * 
+ *
  */
 var LayerSwitcher = class LayerSwitcher extends Control {
 
@@ -559,12 +562,12 @@ var LayerSwitcher = class LayerSwitcher extends Control {
             gutter : false,
             allowEdit : false
         };
-        
+
         // merge with user options
         Utils.assign(this.options, options);
-        
+
         this.options.layers = layers;
-        
+
         // identifiant du contrôle : utile pour suffixer les identifiants CSS (pour gérer le cas où il y en a plusieurs dans la même page)
         this._uid = this.options.id || SelectorID.generate();
         // {Object} control layers list. Each key is a layer id, and its value is an object of layers options (layer, id, opacity, visibility, title, description...)
@@ -828,7 +831,7 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         var isLegends = layerOptions.legends && layerOptions.legends.length !== 0;
         var isMetadata = layerOptions.metadata && layerOptions.metadata.length !== 0;
         var isQuicklookUrl = layerOptions.quicklookUrl;
-        // on n'affiche les informations que si elles sont renseignées 
+        // on n'affiche les informations que si elles sont renseignées
         // (pour ne pas avoir un panneau vide)
         if (isLegends || isMetadata || isQuicklookUrl) {
             layerOptions.displayInformationElement = true;
@@ -1273,6 +1276,118 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         logger.debug(e);
     }
 
+    /**
+     * togglegreyscale
+     * @param {Event} e - Event
+     * @private
+     */
+    _onToggleLayerGreyscaleClick (e) {
+        // fonction de conversion d'une image en n/b SEE: https://github.com/IGNF/geoportal-sdk/blob/316168e8de142627da59dff008cc4c4b308bf2c2/src/OpenLayers/OlMapLayers.js#L965
+        function getGreyScaledDataUrl (img) {
+            // FIXME : patch pour safari !?
+            // ce patch cause des problemes sur Chrome v83+
+            // img.crossOrigin = null;
+            img.crossOrigin = "anonymous";
+
+            var canvas = document.createElement("canvas");
+            var ctx = canvas.getContext("2d");
+
+            // si la taille est nulle, on force à une taille de tuile par defaut
+            // afin d'eviter une exception !
+            img.width = img.width || 256;
+            img.height = img.height || 256;
+
+            canvas.width = img.width;
+            canvas.height = img.height;
+            ctx.drawImage(img, 0, 0);
+
+            var imageData = ctx.getImageData(0, 0, img.width, img.height);
+            var data = imageData.data;
+
+            for (var i = 0; i < data.length; i += 4) {
+                var avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
+                data[i] = avg; // red
+                data[i + 1] = avg; // green
+                data[i + 2] = avg; // blue
+            }
+
+            ctx.putImageData(imageData, 0, 0);
+            return canvas.toDataURL();
+        };
+
+        // fonction de conversion et de chargement d'une image en n/b
+        function convertImagetoGreyScale (image, context) {
+            // conversion en n/b
+            var dataUrl = getGreyScaledDataUrl(image);
+
+            // chargement d'une image vide intermediaire pour eviter
+            // l'affichage d'images couleurs (pour certains navigateurs
+            // le chargement de l'image n/b et plus long et l'image originale
+            // apparait de manière transitoire)
+            image.src = "";
+            image.src = dataUrl;
+        }
+
+        // handler for event 'imageloadstart'
+        function imageloadstartHandler (evt) {
+            evt.image.getImage().crossOrigin = "Anonymous";
+        };
+
+        // handler for event 'tileloadstart'
+        function tileloadstartHandler (evt) {
+            evt.tile.getImage().crossOrigin = "Anonymous";
+        };
+
+        // handler for event 'imageloadend'
+        function imageloadendHandler (evt) {
+            convertImagetoGreyScale(evt.image.getImage(), evt.target);
+        };
+
+        // handler for event 'tileloadend'
+        function tileloadendHandler (evt) {
+            convertImagetoGreyScale(evt.tile.getImage(), evt.target);
+        };
+
+        // abonnement/desabonnement aux evenements permettant la conversion en n/b
+        var divId = e.target.id; // ex GPvisibilityPicto_ID_26
+        var layerID = SelectorID.index(divId); // ex. 26
+
+        var layer = this._layers[layerID].layer;
+        var source = layer.getSource();
+
+        if (!(source instanceof TileWMSSource || source instanceof WMTSSource)) {
+            console.warn("greyscale only implemented of raster pour l'instant");
+            return;
+        }
+        var toGreyScale = true;
+        if (e.target.classList.contains("GPlayerGreyscaleOff")) {
+            e.target.classList.remove("GPlayerGreyscaleOff");
+            e.target.classList.add("GPlayerGreyscaleOn");
+        } else {
+            e.target.classList.add("GPlayerGreyscaleOff");
+            e.target.classList.remove("GPlayerGreyscaleOn");
+            toGreyScale = false;
+        }
+
+        if (toGreyScale) {
+            if (source instanceof TileWMSSource) {
+                source.loadstartListenerKey = source.on("imageloadstart", imageloadstartHandler);
+                source.loadendListenerKey = source.on("imageloadend", imageloadendHandler);
+            } else {
+                source.loadstartListenerKey = source.on("tileloadstart", tileloadstartHandler);
+                source.loadendListenerKey = source.on("tileloadend", tileloadendHandler);
+            }
+        } else {
+            olObservableUnByKey(source.loadstartListenerKey);
+            olObservableUnByKey(source.loadendListenerKey);
+            source.loadstartListenerKey = null;
+            source.loadendListenerKey = null;
+        }
+
+        // maj du cache
+        source.refresh();
+    }
+
     /**
      * zoom to extent
      * @fixme dot it for other user data
@@ -1296,14 +1411,14 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         var error = null;
 
         var map = this.getMap();
-        // cas d'un layer vecteur 
-        // - importé, 
-        // - d'un croquis, 
+        // cas d'un layer vecteur
+        // - importé,
+        // - d'un croquis,
         // - d'une couche de calcul
         // - enregistré sur l'espace personnel
-        if (data.layer.hasOwnProperty("gpResultLayerId") && 
-            (data.layer.gpResultLayerId.split(":")[0] === "layerimport" || 
-            data.layer.gpResultLayerId.split(":")[0] === "drawing" || 
+        if (data.layer.hasOwnProperty("gpResultLayerId") &&
+            (data.layer.gpResultLayerId.split(":")[0] === "layerimport" ||
+            data.layer.gpResultLayerId.split(":")[0] === "drawing" ||
             data.layer.gpResultLayerId.split(":")[0] === "compute" ||
             data.layer.gpResultLayerId.split(":")[0] === "bookmark"
             )) {
@@ -1324,7 +1439,7 @@ var LayerSwitcher = class LayerSwitcher extends Control {
                 }
             }
         } else {
-            try {   
+            try {
                 // Check if configuration is loaded
                 if (!Config.isConfigLoaded()) {
                     throw "ERROR : contract key configuration has to be loaded to load Geoportal layers.";
diff --git a/src/packages/Controls/LayerSwitcher/LayerSwitcherDOM.js b/src/packages/Controls/LayerSwitcher/LayerSwitcherDOM.js
index a34f91ae4..1b74466a7 100644
--- a/src/packages/Controls/LayerSwitcher/LayerSwitcherDOM.js
+++ b/src/packages/Controls/LayerSwitcher/LayerSwitcherDOM.js
@@ -462,8 +462,9 @@ var LayerSwitcherDOM = {
         for (var i = 0; i < array.length; i++) {
             container.appendChild(array[i]);
         }
-        
+
         container.appendChild(this._createAdvancedToolExtentElement(obj));
+        container.appendChild(this._createAdvancedToolGreyscaleElement(obj));
 
         if (checkDsfr()) {
             var btn = document.createElement("button");
@@ -485,6 +486,7 @@ var LayerSwitcherDOM = {
             contextual.appendChild(this._createAdvancedToolDeleteElement(obj, true));
             contextual.appendChild(this._createAdvancedToolInformationElement(obj, true));
             contextual.appendChild(this._createAdvancedToolExtentElement(obj, true));
+            contextual.appendChild(this._createAdvancedToolGreyscaleElement(obj, true));
 
             container.appendChild(btn);
             container.appendChild(contextual);
@@ -529,7 +531,7 @@ var LayerSwitcherDOM = {
 
         return button;
     },
- 
+
     /**
      * Creation de l'icone d'edition du layer (DOM)
      *
@@ -622,6 +624,55 @@ var LayerSwitcherDOM = {
         return btnInfo;
     },
 
+    /**
+     * Creation de l'icone de n&b du layer (DOM)
+     *
+     * @param {Object} obj - options de la couche à ajouter dans le layer switcher
+     * @param {Boolean} contextual - est-ce que le bouton est dans le menu contextuel ? Default false
+     *
+     * @returns {DOMElement} container
+     */
+    _createAdvancedToolGreyscaleElement : function (obj, contextual = false) {
+        // exemple :
+        // <div id="GPgreyscale_ID_Layer1" class="GPlayerBreyscale" title="Noir & blanc" onclick="GPtoggleGreyscale(this);"></div>
+
+        var btnGreyscale = document.createElement("button");
+        if (!contextual) {
+            btnGreyscale.id = this._addUID("GPgreyscale_ID_" + obj.id);
+        } else {
+            btnGreyscale.id = this._addUID("GPgreyscaleContextual_ID_" + obj.id);
+        }
+        btnGreyscale.className = "GPelementHidden GPlayerGreyscale GPlayerGreyscaleOff gpf-btn gpf-btn-icon gpf-btn-icon-ls-greyscale fr-btn fr-btn--tertiary gpf-btn--tertiary";
+        btnGreyscale.title = "Noir et blanc";
+        btnGreyscale.layerId = obj.id;
+        if (contextual) {
+            btnGreyscale.innerText = "N&B";
+        }
+        btnGreyscale.setAttribute("tabindex", "0");
+        btnGreyscale.setAttribute("type", "button");
+
+        // add event on click
+        var context = this;
+        if (btnGreyscale.addEventListener) {
+            btnGreyscale.addEventListener(
+                "click",
+                function (e) {
+                    context._onToggleLayerGreyscaleClick(e);
+                }
+            );
+        } else if (btnGreyscale.attachEvent) {
+            // internet explorer
+            btnGreyscale.attachEvent(
+                "onclick",
+                function (e) {
+                    context._onToggleLayerGreyscaleClick(e);
+                }
+            );
+        }
+
+        return btnGreyscale;
+    },
+
     /**
      * Creation de l'icone de gestion de l'opacité du layer (DOM)
      *


Reviews:
user: login: elias75015
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:17:02Z
user: login: elias75015
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T15:17:26Z
user: login: elias75015
body: Etrange, chez moi le clic bouton change juste la visibilité. 

a voir ensemble pourquoi j'ai ce comportement
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:20:14Z
user: login: azarz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T18:10:30Z
user: login: azarz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T18:10:34Z
user: login: elias75015
body: Cool ! 

Faudra le faire pour le mode classique. On n'aura a priori pas de maquette....je vais y réfléchir
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T10:19:09Z

Review comments:
diff_hunk: @@ -1273,6 +1276,126 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         logger.debug(e);
     }
 
+    /**
+     * togglegreyscale
+     * @param {Event} e - Event
+     * @private
+     */
+    _onToggleLayerGreyscaleClick (e) {
path: src/packages/Controls/LayerSwitcher/LayerSwitcher.js
user: login: elias75015
body: US or English, you have to make a choice !
created_at: 2025-03-18T15:17:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1273,6 +1276,126 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         logger.debug(e);
     }
 
+    /**
+     * togglegreyscale
+     * @param {Event} e - Event
+     * @private
+     */
+    _onToggleLayerGreyscaleClick (e) {
+        // fonction de conversion d'une image en n/b SEE: https://github.com/IGNF/geoportal-sdk/blob/316168e8de142627da59dff008cc4c4b308bf2c2/src/OpenLayers/OlMapLayers.js#L965
+        function getGrayScaledDataUrl (img) {
path: src/packages/Controls/LayerSwitcher/LayerSwitcher.js
user: login: elias75015
body: US or English, you have to make a choice !
created_at: 2025-03-18T15:17:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1273,6 +1276,126 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         logger.debug(e);
     }
 
+    /**
+     * togglegreyscale
+     * @param {Event} e - Event
+     * @private
+     */
+    _onToggleLayerGreyscaleClick (e) {
path: src/packages/Controls/LayerSwitcher/LayerSwitcher.js
user: login: azarz
body: Réparé
created_at: 2025-03-19T18:10:30Z
author_association: MEMBER
diff_hunk: @@ -1273,6 +1276,126 @@ var LayerSwitcher = class LayerSwitcher extends Control {
         logger.debug(e);
     }
 
+    /**
+     * togglegreyscale
+     * @param {Event} e - Event
+     * @private
+     */
+    _onToggleLayerGreyscaleClick (e) {
+        // fonction de conversion d'une image en n/b SEE: https://github.com/IGNF/geoportal-sdk/blob/316168e8de142627da59dff008cc4c4b308bf2c2/src/OpenLayers/OlMapLayers.js#L965
+        function getGrayScaledDataUrl (img) {
path: src/packages/Controls/LayerSwitcher/LayerSwitcher.js
user: login: azarz
body: Réparé
created_at: 2025-03-19T18:10:34Z
author_association: MEMBER

PR comments:
user: login: azarz
created_at: 2025-03-19T18:11:11Z
body: > Etrange, chez moi le clic bouton change juste la visibilité.
> 
> a voir ensemble pourquoi j'ai ce comportement

Réparé via https://github.com/IGNF/geopf-extensions-openlayers/pull/367/commits/0bec2aafebbae140993ef94b24ecbbea2d31e334
@elias75015 
author_association: MEMBER
user: login: azarz
created_at: 2025-03-20T11:21:13Z
body: Je peux attendre le mode classic pour merger !
Le souci étant qu'il faut trouver un endroit o^ù positionner le bouton sur l'interface actuelle, en plus là le postionnement est fait en absolute en classic ce qui ne permet pas trop d'ajouter des trucs facilement

Pour rappel, l'interface en classic : 
![image](https://github.com/user-attachments/assets/19fa074d-cfef-46eb-a8c4-f1aacb81ebe3)

author_association: MEMBER
user: login: azarz
created_at: 2025-03-20T16:52:26Z
body: finalement je ferai en classic une autre fois
author_association: MEMBER