From 5fa49257fe61d4ba0f734d69279d5ced925064be Mon Sep 17 00:00:00 2001 From: NicolasRichel Date: Wed, 15 Apr 2026 11:28:01 +0200 Subject: [PATCH] MINOR: feat: add projects map to space board --- .../maplibre-wrapper/MaplibreWrapper.scss | 25 ----- .../maplibre-wrapper/MaplibreWrapper.vue | 40 ++++++-- .../specific/models/models-map/ModelsMap.vue | 94 +++++++++++++++++++ .../projects/project-card/ProjectCard.vue | 61 +++++------- .../projects/projects-map/ProjectsMap.vue | 86 +++++++++++++++++ src/composables/maplibre.js | 79 ---------------- src/services/ModelService.js | 22 ++--- src/utils/location.js | 4 +- src/utils/maplibre.js | 63 +++++++++++++ src/views/space-board/SpaceBoard.scss | 34 ------- src/views/space-board/SpaceBoard.vue | 87 ++++++++++++++--- 11 files changed, 388 insertions(+), 207 deletions(-) delete mode 100644 src/components/generic/maplibre-wrapper/MaplibreWrapper.scss create mode 100644 src/components/specific/models/models-map/ModelsMap.vue create mode 100644 src/components/specific/projects/projects-map/ProjectsMap.vue delete mode 100644 src/composables/maplibre.js create mode 100644 src/utils/maplibre.js delete mode 100644 src/views/space-board/SpaceBoard.scss diff --git a/src/components/generic/maplibre-wrapper/MaplibreWrapper.scss b/src/components/generic/maplibre-wrapper/MaplibreWrapper.scss deleted file mode 100644 index 8ec8786f1..000000000 --- a/src/components/generic/maplibre-wrapper/MaplibreWrapper.scss +++ /dev/null @@ -1,25 +0,0 @@ -.maplibre-wrapper { - position: relative; - width: 100%; - height: 100%; - filter: grayscale(0.7); - - .maptiler-link { - position: absolute; - z-index: 1; - bottom: calc(var(--spacing-unit) / 4); - left: calc(var(--spacing-unit) / 2); - user-select: none; - } - - &:deep() { - .maplibregl-canvas-container { - width: 100%; - height: 100%; - } - - .maplibregl-canvas { - outline: none; - } - } -} diff --git a/src/components/generic/maplibre-wrapper/MaplibreWrapper.vue b/src/components/generic/maplibre-wrapper/MaplibreWrapper.vue index 42c427855..4a2aafc65 100644 --- a/src/components/generic/maplibre-wrapper/MaplibreWrapper.vue +++ b/src/components/generic/maplibre-wrapper/MaplibreWrapper.vue @@ -3,13 +3,13 @@ - + - + diff --git a/src/components/specific/models/models-map/ModelsMap.vue b/src/components/specific/models/models-map/ModelsMap.vue new file mode 100644 index 000000000..bd5ef3d57 --- /dev/null +++ b/src/components/specific/models/models-map/ModelsMap.vue @@ -0,0 +1,94 @@ + + + + + + diff --git a/src/components/specific/projects/project-card/ProjectCard.vue b/src/components/specific/projects/project-card/ProjectCard.vue index 8528ac7c8..7b31b7a29 100644 --- a/src/components/specific/projects/project-card/ProjectCard.vue +++ b/src/components/specific/projects/project-card/ProjectCard.vue @@ -67,7 +67,7 @@ + + + diff --git a/src/composables/maplibre.js b/src/composables/maplibre.js deleted file mode 100644 index 382f6309e..000000000 --- a/src/composables/maplibre.js +++ /dev/null @@ -1,79 +0,0 @@ -import maplibregl from "maplibre-gl"; - -const MAP_TILER_TOKEN = ENV.VUE_APP_MAPTILER_TOKEN; - -export function useMaplibre(containerID) { - const loadMap = (longitude, latitude) => { - if (!document.getElementById(containerID)) { - // Do not try to load map if container element does not exist - return; - } - - if (longitude && latitude) { - try { - const map = new maplibregl.Map({ - container: containerID, - style: `https://api.maptiler.com/maps/streets/style.json?key=${MAP_TILER_TOKEN}`, - center: [longitude, latitude], - zoom: 15.5, - pitch: 45, - bearing: -17.6, - attributionControl: false, - antialias: true - }); - - map.on("load", () => { - const labelsLayer = map - .getStyle() - .layers.find( - layer => layer.type === "symbol" && layer.layout["text-field"] - ); - - map.addLayer( - { - type: "fill-extrusion", - id: "3d-buildings", - source: "openmaptiles", - "source-layer": "building", - filter: ["==", "extrude", "true"], - minzoom: 15, - paint: { - "fill-extrusion-color": "#aaa", - "fill-extrusion-opacity": 0.6, - "fill-extrusion-height": [ - "interpolate", - ["linear"], - ["zoom"], - 15, - 0, - 15.05, - ["get", "height"] - ], - "fill-extrusion-base": [ - "interpolate", - ["linear"], - ["zoom"], - 15, - 0, - 15.05, - ["get", "min_height"] - ] - } - }, - labelsLayer ? labelsLayer.id : undefined - ); - - new maplibregl.Marker({ color: "#2F374A" /* color primary */ }) - .setLngLat([longitude, latitude]) - .addTo(map); - }); - } catch (error) { - console.warn(error); - } - } - }; - - return { - loadMap - }; -} diff --git a/src/services/ModelService.js b/src/services/ModelService.js index 64abe7109..acd742233 100644 --- a/src/services/ModelService.js +++ b/src/services/ModelService.js @@ -6,9 +6,7 @@ import { ERRORS, RuntimeError, ErrorService } from "./ErrorService.js"; class ModelService { constructor() { this.cache = new Map(); - this.callQueue = queue(async task => { - return await task(); - }, 40); + this.callQueue = queue(async task => { return await task(); }, 40); } async fetchModels(project, { cache } = {}) { @@ -113,14 +111,16 @@ class ModelService { } fetchModelElements(project, model, params = {}) { - return apiClient.modelApi.getElements( - project.cloud.id, - model.id, - project.id, - params.classification, - params.classificationNotation, - undefined, // property_filter - params.type + return this.callQueue.push(() => + apiClient.modelApi.getElements( + project.cloud.id, + model.id, + project.id, + params.classification, + params.classificationNotation, + undefined, // property_filter + params.type + ) ); } diff --git a/src/utils/location.js b/src/utils/location.js index e606f0343..f35c08e38 100644 --- a/src/utils/location.js +++ b/src/utils/location.js @@ -96,7 +96,7 @@ function DMS2DD([degrees, minutes, seconds, secondsFraction = 0], type) { seconds *= -1; secondsFraction *= -1; } - seconds += secondsFraction/1000000; + seconds += secondsFraction / 1_000_000; const dmsString = `${degrees}°${minutes}′${seconds}″ ${direction}`; return parseDMS(dmsString); } @@ -132,7 +132,7 @@ function DD2DMS(lat, long) { const MAP_TILER_TOKEN = ENV.VUE_APP_MAPTILER_TOKEN; /** - * Get the DD coordinates of a given address using OpenStreetMapAPI. + * Get the DD coordinates of a given address using Maptiler API. * The returned value is an object with a "longitude" and "latitude" fields. * If no coordinates are found, "longitude" and "latitude" will be null. * diff --git a/src/utils/maplibre.js b/src/utils/maplibre.js new file mode 100644 index 000000000..e9a2c6aa4 --- /dev/null +++ b/src/utils/maplibre.js @@ -0,0 +1,63 @@ +import maplibregl from "maplibre-gl"; + +const MAP_TILER_TOKEN = ENV.VUE_APP_MAPTILER_TOKEN; +const MAP_STYLE_URL = `https://api.maptiler.com/maps/streets/style.json?key=${MAP_TILER_TOKEN}`; +const MAP_DEFAULT_CENTER = [2.294481, 48.858370]; // Tour Eiffel + +const MARKER_COLOR = "#2F374A"; // color primary + +export class MaplibreMap { + constructor(containerId, center = MAP_DEFAULT_CENTER) { + this.map = new maplibregl.Map({ + container: containerId, + style: MAP_STYLE_URL, + center, + zoom: 15.5, + pitch: 45, + bearing: 0, + attributionControl: false, + }); + + const { promise, resolve } = Promise.withResolvers(); + this.isReady = promise; + + this.map.on("load", resolve); + } + + setCenter(center) { + this.map.setCenter(center); + } + + addMarker([longitude, latitude]) { + const marker = new maplibregl.Marker({ color: MARKER_COLOR }); + marker.setLngLat([longitude, latitude]); + + return this.isReady.then(() => marker.addTo(this.map)); + } +} + +export function computeBounds(markers) { + if (markers.length < 2) { + console.warn("[computeBounds] We need at least 2 markers to compute map bounds."); + return; + } + + markers = markers.map(m => { + const { lng, lat } = m.getLngLat(); + return [lng, lat]; + }); + + let [a0, b0] = markers.pop(), [a1, b1] = markers.pop(); + let sw = [Math.min(a0, a1), Math.min(b0, b1)]; // south-west + let ne = [Math.max(a0, a1), Math.max(b0, b1)]; // north-east + + while (markers.length > 0) { + let [lng, lat] = markers.pop(); + if (lng < sw[0]) sw[0] = lng; + if (lat < sw[1]) sw[1] = lat; + if (lng > ne[0]) ne[0] = lng; + if (lat > ne[1]) ne[1] = lat; + } + + return [sw, ne]; +} diff --git a/src/views/space-board/SpaceBoard.scss b/src/views/space-board/SpaceBoard.scss deleted file mode 100644 index cbe5cd4c2..000000000 --- a/src/views/space-board/SpaceBoard.scss +++ /dev/null @@ -1,34 +0,0 @@ -.space-board { - height: 100%; - - &__banner { - position: absolute; - top: 0; - left: 0; - } - - // Add top padding to header when banner is displayed - &__banner.visible + &__header { - padding-top: var(--spacing-unit); - } - - &__header { - &__search { - background-color: var(--color-white); - } - - &__actions { - display: flex; - align-items: center; - gap: var(--spacing-unit); - } - - &__btn { - color: var(--color-granite-light); - } - } - - &__body { - padding: calc(var(--spacing-unit) * 2) 0; - } -} diff --git a/src/views/space-board/SpaceBoard.vue b/src/views/space-board/SpaceBoard.vue index e75f4667e..bac572ad4 100644 --- a/src/views/space-board/SpaceBoard.vue +++ b/src/views/space-board/SpaceBoard.vue @@ -61,18 +61,22 @@ - - - - - - +
+
+ +
+ + + + + + +
@@ -100,10 +104,11 @@ import GoBackButton from "../../components/specific/app/go-back-button/GoBackBut import ViewHeader from "../../components/specific/app/view-header/ViewHeader.vue"; import ProjectCard from "../../components/specific/projects/project-card/ProjectCard.vue"; import ProjectCreationCard from "../../components/specific/projects/project-creation-card/ProjectCreationCard.vue"; +import ProjectsMap from "../../components/specific/projects/projects-map/ProjectsMap.vue"; +import StatusFilterButton from "../../components/specific/projects/status-filter-button/StatusFilterButton.vue"; import SpaceSizeInfo from "../../components/specific/subscriptions/space-size-info/SpaceSizeInfo.vue"; import SubscriptionStatusBanner from "../../components/specific/subscriptions/subscription-status-banner/SubscriptionStatusBanner.vue"; import SpaceUsersManager from "../../components/specific/users/space-users-manager/SpaceUsersManager.vue"; -import StatusFilterButton from "../../components/specific/projects/status-filter-button/StatusFilterButton.vue"; export default { components: { @@ -113,11 +118,12 @@ export default { GoBackButton, ProjectCard, ProjectCreationCard, + ProjectsMap, SpaceSizeInfo, SpaceUsersManager, + StatusFilterButton, SubscriptionStatusBanner, ViewHeader, - StatusFilterButton, }, setup() { const viewContainer = inject("viewContainer"); @@ -142,6 +148,12 @@ export default { const { sortToggle: sortProjects } = useListSort(displayedProjects, (project) => project.name); + const map = ref(null); + const onProjectLoaded = ({ project, models }) => { + project.models = models; + map.value.addProject(project); + }; + useInterval(() => { if (isOpenRight.value) { loadSpaceUsers(currentSpace.value); @@ -168,6 +180,7 @@ export default { // References invitations: spaceInvitations, IS_SUBSCRIPTION_ENABLED, + map, projects: displayedProjects, searchText, space: currentSpace, @@ -177,6 +190,7 @@ export default { filteredProjects, // Methods isSpaceAdmin, + onProjectLoaded, openUsersManager: openSidePanel, sortProjects, // Responsive breakpoints @@ -186,4 +200,47 @@ export default { }; - +