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 +
+
+
+
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 +
+
+
+
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