diff --git a/package-lock.json b/package-lock.json
index 53bf9fa3..e2781183 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,7 +44,7 @@
"chart.js": "^4.4.1",
"install": "^0.13.0",
"lodash": "^4.17.21",
- "maplibre-gl": "^4.3.2",
+ "maplibre-gl": "^4.5.0",
"npm": "^10.5.0",
"proj4": "^2.10.0"
},
@@ -8440,10 +8440,9 @@
}
},
"node_modules/geojson-vt": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz",
- "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==",
- "license": "ISC"
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
+ "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
},
"node_modules/get-caller-file": {
"version": "2.0.5",
@@ -10796,9 +10795,9 @@
}
},
"node_modules/maplibre-gl": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.3.2.tgz",
- "integrity": "sha512-/oXDsb9I+LkjweL/28aFMLDZoIcXKNEhYNAZDLA4xgTNkfvKQmV/r0KZdxEMcVthincJzdyc6Y4N8YwZtHKNnQ==",
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.5.0.tgz",
+ "integrity": "sha512-qOS1hn4d/pn2i0uva4S5Oz+fACzTkgBKq+NpwT/Tqzi4MSyzcWNtDELzLUSgWqHfNIkGCl5CZ/w7dtis+t4RCw==",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -10807,7 +10806,7 @@
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
- "@maplibre/maplibre-gl-style-spec": "^20.2.0",
+ "@maplibre/maplibre-gl-style-spec": "^20.3.0",
"@types/geojson": "^7946.0.14",
"@types/geojson-vt": "3.2.5",
"@types/junit-report-builder": "^3.0.2",
@@ -10816,7 +10815,7 @@
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"earcut": "^2.2.4",
- "geojson-vt": "^3.2.1",
+ "geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.3",
"global-prefix": "^3.0.0",
"kdbush": "^4.0.2",
diff --git a/package.json b/package.json
index 83470966..d20bcadc 100644
--- a/package.json
+++ b/package.json
@@ -64,7 +64,7 @@
"chart.js": "^4.4.1",
"install": "^0.13.0",
"lodash": "^4.17.21",
- "maplibre-gl": "^4.3.2",
+ "maplibre-gl": "^4.5.0",
"npm": "^10.5.0",
"proj4": "^2.10.0"
},
diff --git a/src/html/img/layers/3D.BUILDINGS.jpg b/src/html/img/layers/3D.BUILDINGS.jpg
new file mode 100644
index 00000000..5cf8e775
Binary files /dev/null and b/src/html/img/layers/3D.BUILDINGS.jpg differ
diff --git a/src/html/img/layers/3D.TERRAIN.jpg b/src/html/img/layers/3D.TERRAIN.jpg
new file mode 100644
index 00000000..b14641b8
Binary files /dev/null and b/src/html/img/layers/3D.TERRAIN.jpg differ
diff --git a/src/js/controls.js b/src/js/controls.js
index 01e24ff6..b793d91e 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -22,6 +22,7 @@ import SignalementOSM from "./signalement-osm";
import Landmark from "./landmark";
import MapboxAccessibility from "./poi-accessibility";
import DOM from "./dom";
+import ThreeD from "./three-d";
import LocationLayers from "./services/location-styles";
import compareLandmark from "./compare-landmark";
@@ -133,6 +134,10 @@ const addControls = () => {
});
Globals.compareLandmark = new compareLandmark(Globals.mapRLT1, Globals.mapRLT2, {});
+ // 3d
+ Globals.threeD = new ThreeD(map, {});
+ Globals.manager.layerCatalogue.add3DThematicLayers();
+
// contrôle filtres POI
Globals.poi = new POI(map, {});
Globals.poi.load() // promise !
diff --git a/src/js/dom.js b/src/js/dom.js
index b1c88824..e590bdc8 100644
--- a/src/js/dom.js
+++ b/src/js/dom.js
@@ -21,6 +21,7 @@ const $selectOnMap = document.getElementById("selectOnMap");
const $geolocateBtn = document.getElementById("geolocateBtn");
const $backTopLeftBtn = document.getElementById("backTopLeftBtn");
const $compassBtn = document.getElementById("compassBtn");
+const $threeDBtn = document.getElementById("threeDBtn");
const $layerManagerBtn = document.getElementById("layerManagerBtn");
const $sideBySideBtn = document.getElementById("sideBySideBtn");
const $compareMode = document.getElementById("compareMode");
@@ -136,4 +137,5 @@ export default {
$map,
$createCompareLandmarkBtn,
$compareLandmarkWindow,
+ $threeDBtn,
};
diff --git a/src/js/globals.js b/src/js/globals.js
index 8c3c27ed..439bfe65 100644
--- a/src/js/globals.js
+++ b/src/js/globals.js
@@ -99,6 +99,9 @@ let signalementOSM = null;
let landmark = null;
let compareLandmark = null;
+// Global control 3d
+let threeD = null;
+
// Global flag: is the device connected to the internet?
let online = (await Network.getStatus()).connected;
@@ -156,4 +159,5 @@ export default {
osmPoiAccessibility,
landmark,
compareLandmark,
+ threeD,
};
diff --git a/src/js/index.js b/src/js/index.js
index a86b926f..505d874b 100644
--- a/src/js/index.js
+++ b/src/js/index.js
@@ -194,7 +194,7 @@ function app() {
attributionControl: false,
maxZoom: 21,
locale: "fr",
- maxPitch: 45,
+ maxPitch: 60,
crossSourceCollisions: false,
});
diff --git a/src/js/layer-manager/layer-catalogue.js b/src/js/layer-manager/layer-catalogue.js
index 8314b300..4c42ce02 100644
--- a/src/js/layer-manager/layer-catalogue.js
+++ b/src/js/layer-manager/layer-catalogue.js
@@ -9,6 +9,8 @@ import LayersConfig from "./layer-config";
import LayersAdditional from "./layer-additional";
import ImageNotFound from "../../html/img/image-not-found.png";
+import ReliefBuildingsImage from "../../html/img/layers/3D.BUILDINGS.jpg";
+import ReliefTerrainImage from "../../html/img/layers/3D.TERRAIN.jpg";
import DomUtils from "../utils/dom-utils";
import { Toast } from "@capacitor/toast";
@@ -153,6 +155,105 @@ class LayerCatalogue extends EventTarget {
target.appendChild(container);
}
+ /**
+ * Ajout de 2 faux "Layers" 3D qui n'apparaissent pas dans le Layer Switcher et du bouton 3D pour les filtrer
+ * - Bâtiments 3D
+ * - Relief
+ */
+ add3DThematicLayers() {
+ var target = this.options.target || document.getElementById("layer-thematics");
+ if (!target) {
+ console.warn();
+ return;
+ }
+ var container = target.querySelector("#thematicLayers");
+
+ var buildings3DLayerHtml = `
+
+
+
![Bâtiments 3D](${ReliefBuildingsImage})
+
+
+
+
3D
+
Bâtiments 3D
+
+ `;
+ var buildings3DLayerElement = DomUtils.stringToHTML(buildings3DLayerHtml.trim());
+ buildings3DLayerElement.addEventListener("click", () => {
+ if (buildings3DLayerElement.classList.contains("selectedLayer")) {
+ Globals.threeD.remove3dBuildings();
+ buildings3DLayerElement.classList.remove("selectedLayer");
+ } else {
+ Globals.threeD.add3dBuildings();
+ buildings3DLayerElement.classList.add("selectedLayer");
+ }
+ });
+ container.appendChild(buildings3DLayerElement);
+
+ var terrainLayerHtml = `
+
+
+
![Relief 3D](${ReliefTerrainImage})
+
+
+
+
3D
+
Relief 3D
+
+ `;
+ var terrainLayerElement = DomUtils.stringToHTML(terrainLayerHtml.trim());
+ terrainLayerElement.addEventListener("click", () => {
+ if (terrainLayerElement.classList.contains("selectedLayer")) {
+ Globals.threeD.remove3dTerrain();
+ terrainLayerElement.classList.remove("selectedLayer");
+ } else {
+ Globals.threeD.add3dTerrain();
+ terrainLayerElement.classList.add("selectedLayer");
+ }
+ });
+ container.appendChild(terrainLayerElement);
+
+ // Ajout de la pastille de filtre "3D"
+ var buttonsContainer = target.querySelector("#thematicButtons");
+ var buttonElement = DomUtils.stringToHTML(`
+
+ `.trim());
+
+ buttonElement.addEventListener("click", (e) => {
+ var buttons = document.querySelectorAll(".thematicButton");
+ for (let h = 0; h < buttons.length; h++) {
+ const element = buttons[h];
+ element.classList.remove("thematic-button-active");
+ }
+ var layers = document.querySelectorAll(".thematicLayer");
+ for (let i = 0; i < layers.length; i++) {
+ const element = layers[i];
+ element.classList.add("layer-hidden");
+ }
+ var layersId = ["3D.BUILDINGS", "3D.TERRAIN"];
+ for (let j = 0; j < layersId.length; j++) {
+ const id = layersId[j];
+ var element = document.getElementById(id);
+ element.classList.remove("layer-hidden");
+ }
+ e.target.classList.add("thematic-button-active");
+ });
+ buttonsContainer.firstElementChild.after(buttonElement);
+
+ // Ajoute le réaffichage des boutons 3D au click sur la pastille "Tous"
+ buttonsContainer.querySelector("[data-name=Tous]").addEventListener("click", () => {
+ var layersId = ["3D.BUILDINGS", "3D.TERRAIN"];
+ for (let j = 0; j < layersId.length; j++) {
+ const id = layersId[j];
+ var element = document.getElementById(id);
+ element.classList.remove("layer-hidden");
+ }
+ });
+ }
+
/**
* Ecouteurs
*/
diff --git a/src/js/layer-manager/layer-switcher.js b/src/js/layer-manager/layer-switcher.js
index 108b6fd4..5c106933 100644
--- a/src/js/layer-manager/layer-switcher.js
+++ b/src/js/layer-manager/layer-switcher.js
@@ -625,7 +625,7 @@ class LayerSwitcher extends EventTarget {
this.layers[id].style = data_2.layers; // sauvegarde !
} catch (e) {
if (fallback) {
- fetchStyle(fallback, null);
+ await fetchStyle(fallback, null);
} else {
this.layers[id].error = true;
throw new Error(e);
@@ -683,6 +683,10 @@ class LayerSwitcher extends EventTarget {
this.#setColor(id, !layerOptions.gray);
}
this.#setVisibility(id, layerOptions.visible);
+ // Cas particulier : ajout de l'ombrage à plan IGN si la 3D est activée
+ if (id === "PLAN.IGN.INTERACTIF$GEOPORTAIL:GPP:TMS" && Globals.threeD && Globals.threeD.terrainOn) {
+ Globals.threeD.addHillShadeToPlanIgn();
+ }
/**
* Evenement "addlayer"
* @event addlayer
diff --git a/src/js/map-buttons-listeners.js b/src/js/map-buttons-listeners.js
index 04e92206..9f34c929 100644
--- a/src/js/map-buttons-listeners.js
+++ b/src/js/map-buttons-listeners.js
@@ -88,6 +88,7 @@ const addListeners = () => {
Globals.compareLandmark.location = [Globals.mapRLT1.getCenter().lng, Globals.mapRLT1.getCenter().lat];
}
});
+
};
export default {
diff --git a/src/js/three-d.js b/src/js/three-d.js
new file mode 100644
index 00000000..565ecd37
--- /dev/null
+++ b/src/js/three-d.js
@@ -0,0 +1,171 @@
+/**
+ * Copyright (c) Institut national de l'information géographique et forestière
+ *
+ * This program and the accompanying materials are made available under the terms of the GPL License, Version 3.0.
+ */
+
+import Globals from "./globals";
+import maplibregl from "maplibre-gl";
+
+const hillsLayer = {
+ id: "hills",
+ type: "hillshade",
+ source: "bil-terrain",
+ layout: {visibility: "visible"},
+ paint: {"hillshade-shadow-color": "#473B24"},
+ metadata: {group: "PLAN.IGN.INTERACTIF$GEOPORTAIL:GPP:TMS"},
+};
+
+/**
+ * Interface sur le contrôle 3d
+ * @module ThreeD
+ */
+class ThreeD {
+ /**
+ * constructeur
+ * @constructs
+ * @param {*} map
+ * @param {*} options
+ */
+ constructor(map, options) {
+ this.options = options || {
+ target: null,
+ // callback
+ openSearchControlCbk: null,
+ closeSearchControlCbk: null
+ };
+
+ this.map = map;
+
+ this.buildingsLayers = [];
+ this.terrainOn = false;
+
+ return this;
+ }
+
+ async #fetch3dBuildingsLayers() {
+ if (!Globals.map.getSource("bdtopo")) {
+ Globals.map.addSource("bdtopo", {
+ "type": "vector",
+ "maxzoom": 19,
+ "minzoom": 15,
+ "tiles": [
+ "https://data.geopf.fr/tms/1.0.0/BDTOPO/{z}/{x}/{y}.pbf"
+ ]
+ });
+ }
+ const response = await fetch("data/bati-3d.json");
+ const data = await response.json();
+ this.buildingsLayers = data.layers;
+ }
+
+ // Function to fetch and parse x-bil tile data
+ async #fetchAndParseXBil(url) {
+ const response = await fetch(url);
+ const arrayBuffer = await response.arrayBuffer();
+ const dataView = new DataView(arrayBuffer);
+ const width = Math.sqrt(dataView.byteLength / 4); // Assuming square tiles
+ const height = width;
+ const elevations = new Float32Array(width * height);
+ for (let i = 0; i < width * height; i++) {
+ elevations[i] = dataView.getFloat32(i * 4, true);
+ if (elevations[i] < -10 || elevations[i] > 4900) {
+ elevations[i] = 0;
+ }
+ }
+ return { elevations, width, height };
+ }
+
+ add3dTerrain() {
+ if (!Globals.map.getSource("bil-terrain")) {
+ Globals.map.addSource("bil-terrain", {
+ type: "raster-dem",
+ tiles: [
+ `dem://data.geopf.fr/private/wms-r/wms?apikey=${process.env.GPF_key}&bbox={bbox-epsg-3857}&format=image/x-bil;bits=32&service=WMS&version=1.3.0&request=GetMap&crs=EPSG:3857&width=256&height=256&styles=normal&layers=ELEVATION.ELEVATIONGRIDCOVERAGE.HIGHRES.LINEAR`
+ ],
+ minzoom: 6,
+ maxzoom: 14,
+ tileSize: 256
+ });
+
+ maplibregl.addProtocol("dem", async (params) => {
+ try {
+ const { elevations, width, height } = await this.#fetchAndParseXBil(`https://${params.url.split("://")[1]}`);
+ const data = new Uint8ClampedArray(width * height * 4);
+ for (let i = 0; i < elevations.length; i++) {
+ let elevation = Math.round(elevations[i] * 10) / 10;
+ // reverse https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data
+ const baseElevationValue = 10 * (elevation + 10000);
+ const red = Math.floor(baseElevationValue / (256 * 256)) % 256;
+ const green = Math.floor((baseElevationValue - red * 256 * 256) / 256) % 256;
+ const blue = baseElevationValue - red * 256 * 256 - green * 256;
+ data[4 * i] = red;
+ data[4 * i + 1] = green;
+ data[4 * i + 2] = blue;
+ data[4 * i + 3] = 255;
+ }
+ const imageData = new ImageData(data, width, height);
+ const imageBitmap = await createImageBitmap(imageData);
+ return {
+ data: imageBitmap
+ };
+ } catch (error) {
+ console.error(error);
+ throw error;
+ }
+ });
+ }
+
+ // Set terrain using the custom source
+ Globals.map.setTerrain({ source: "bil-terrain", exaggeration: 1.5 });
+ this.addHillShadeToPlanIgn();
+ this.terrainOn = true;
+ }
+
+ addHillShadeToPlanIgn() {
+ if (Globals.map.getLayer(hillsLayer.id)) {
+ return;
+ }
+ // HACK
+ // on positionne toujours le layer après la dernière couche de PLAN IGN
+ var beforeId = "detail_hydrographique$$$PLAN.IGN.INTERACTIF$GEOPORTAIL:GPP:TMS";
+ if (!Globals.map.getLayer(beforeId)) {
+ return;
+ }
+ var layerIndexBefore = Globals.map.getStyle().layers.findIndex((l) => l.id === beforeId) + 1;
+ var layerIdBefore = (layerIndexBefore !== -1) ? Globals.map.getStyle().layers[layerIndexBefore].id : null;
+ if (layerIdBefore) {
+ Globals.map.addLayer(hillsLayer, layerIdBefore);
+ }
+ }
+
+ async add3dBuildings() {
+ if (this.buildingsLayers.length === 0) {
+ await this.#fetch3dBuildingsLayers();
+ }
+ // HACK
+ // on positionne toujours le style avant ceux du calcul d'itineraires (directions)
+ // afin que le calcul soit toujours la couche visible du dessus !
+ var layerIndexBefore = Globals.map.getStyle().layers.findIndex((l) => l.source === "maplibre-gl-directions") + 1;
+ var layerIdBefore = (layerIndexBefore !== -1) ? Globals.map.getStyle().layers[layerIndexBefore].id : null;
+ this.buildingsLayers.forEach((layer) => {
+ Globals.map.addLayer(layer, layerIdBefore);
+ });
+ }
+
+ remove3dBuildings() {
+ this.buildingsLayers.forEach((layer) => {
+ Globals.map.removeLayer(layer.id);
+ });
+ }
+
+ remove3dTerrain() {
+ Globals.map.setTerrain();
+ if (Globals.map.getLayer(hillsLayer.id)) {
+ Globals.map.removeLayer(hillsLayer.id);
+ }
+ this.terrainOn = false;
+ }
+}
+
+export default ThreeD;
diff --git a/www/data/bati-3d.json b/www/data/bati-3d.json
new file mode 100644
index 00000000..7ac8d696
--- /dev/null
+++ b/www/data/bati-3d.json
@@ -0,0 +1,169 @@
+{
+ "version": 8,
+ "name": "PLAN IGN bâti 3d",
+ "glyphs": "https://data.geopf.fr/annexes/ressources/vectorTiles/fonts/{fontstack}/{range}.pbf",
+ "sprite": "data/poi-osm-sprite",
+ "metadata": {
+ "geoportail:tooltip": "BDTOPO/multicouche_bdtopo"
+ },
+ "sources": {
+ "bdtopo": {
+ "type": "vector",
+ "maxzoom": 19,
+ "minzoom": 15,
+ "tiles": [
+ "https://data.geopf.fr/tms/1.0.0/BDTOPO/{z}/{x}/{y}.pbf"
+ ]
+ }
+ },
+ "transition": {
+ "duration": 300,
+ "delay": 0
+ },
+ "layers": [
+ {
+ "id": "batiment_residentiel_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Résidentiel"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#F1EBD9"
+ }
+ },
+ {
+ "id": "batiment_annexe_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Annexe"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#F1EBD9"
+ }
+ },
+ {
+ "id": "batiment_agricole_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Agricole"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#E6E6E6"
+ }
+ },
+ {
+ "id": "batiment_commercial_et_services_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Commercial et services"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#E3BFE2"
+ }
+ },
+ {
+ "id": "batiment_industriel_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Industriel"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#E6E6E6"
+ }
+ },
+ {
+ "id": "batiment_sportif_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Sportif"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#DCE6E4"
+ }
+ },
+ {
+ "id": "batiment_religieux_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Religieux"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#F7E1E1"
+ }
+ },
+ {
+ "id": "batiment_indifferencie_vol",
+ "type": "fill-extrusion",
+ "source": "bdtopo",
+ "source-layer": "batiment",
+ "minzoom": 16,
+ "filter": [
+ "==",
+ "usage_1",
+ "Indifférencié"
+ ],
+ "paint": {
+ "fill-extrusion-height": [
+ "get", "hauteur"
+ ],
+ "fill-extrusion-color": "#F1EBD9"
+ }
+ }
+ ]
+}
\ No newline at end of file