diff --git a/packages/visualizations/src/components/MapPoi/Map.svelte b/packages/visualizations/src/components/MapPoi/Map.svelte index 4d10e88a..9f4275a8 100644 --- a/packages/visualizations/src/components/MapPoi/Map.svelte +++ b/packages/visualizations/src/components/MapPoi/Map.svelte @@ -9,7 +9,7 @@ getMapStyle, getMapSources, getMapLayers, - getPopupsConfiguration, + getPopupConfigurationByLayers, getMapOptions, } from './utils'; import type { PoiMapData, PoiMapOptions } from './types'; @@ -21,7 +21,7 @@ $: style = getMapStyle(styleOptions); $: sources = getMapSources(data.value?.sources); $: layers = getMapLayers(data.value?.layers); - $: popupsConfiguration = getPopupsConfiguration(data.value?.layers); + $: popupConfigurationByLayers = getPopupConfigurationByLayers(data.value?.layers); $: ({ bbox: _bbox, @@ -53,7 +53,7 @@ {style} {sources} {layers} - {popupsConfiguration} + {popupConfigurationByLayers} bbox={$bbox} center={$center} {zoom} diff --git a/packages/visualizations/src/components/MapPoi/Map.ts b/packages/visualizations/src/components/MapPoi/Map.ts index 17e13e6b..77abbee9 100644 --- a/packages/visualizations/src/components/MapPoi/Map.ts +++ b/packages/visualizations/src/components/MapPoi/Map.ts @@ -16,7 +16,8 @@ import { POPUP_OPTIONS, POPUP_WIDTH, } from './constants'; -import type { PopupsConfiguration, CenterZoomOptions, PopupDisplayTypes } from './types'; + +import type { PopupConfigurationByLayers, CenterZoomOptions, PopupDisplayTypes } from './types'; import { POPUP_DISPLAY } from './types'; const CURSOR = { @@ -25,8 +26,23 @@ const CURSOR = { DRAG: 'move', }; +const POPUP_FEATURE_STATE_KEY = 'popup-feature'; + type MapFunction = (map: maplibregl.Map) => unknown; +type ActiveFeatureType = MapGeoJSONFeature | null; + +function updateFeatureState( + map: maplibregl.Map, + feature: ActiveFeatureType, + state: string, + stateValue: unknown +) { + if (!feature) return; + const { id, source, sourceLayer } = feature; + const featureState = map.getFeatureState({ source, sourceLayer, id }); + map.setFeatureState({ id, source, sourceLayer }, { ...featureState, [state]: stateValue }); +} export default class MapPOI { /** The Map object representing the maplibregl.Map instance. */ private map: maplibregl.Map | null = null; @@ -43,20 +59,20 @@ export default class MapPOI { /** Array of layer IDs that are not from the base style of the map */ private layerIds: string[] = []; - /** Array of source IDs used in the map */ - private sourceIds: string[] = []; - /** A navigation control for the map. */ private navigationControl = new maplibregl.NavigationControl({ showCompass: false }); /** A popup for displaying information on the map. */ private popup = new maplibregl.Popup(POPUP_OPTIONS); - /** Popups configurations. One configuration by layer */ - private popupsConfiguration: PopupsConfiguration = {}; + /** An object to store popup configurations for each layers */ + private popupConfigurationByLayers: PopupConfigurationByLayers = {}; + + /** Value to represent the active display of the popup */ + private activePopupDisplay: PopupDisplayTypes | null = null; /** An active GeoJSONFeature. Its information are displayed within the popup. */ - private activeFeature: MapGeoJSONFeature | null = null; + private activeFeature: ActiveFeatureType = null; /** An array of functions to be executed when the map is ready. */ private queuedFunctions: Array = []; @@ -94,9 +110,7 @@ export default class MapPOI { const features = map.queryRenderedFeatures(point, { layers: this.layerIds }); const isMovingOverFeatureWithPopup = features.length && - features.some((feature) => - Object.keys(this.popupsConfiguration).includes(feature.layer.id) - ); + features.some((feature) => feature.layer.id in this.popupConfigurationByLayers); canvas.style.cursor = isMovingOverFeatureWithPopup ? CURSOR.HOVER : CURSOR.DEFAULT; }); } @@ -128,49 +142,100 @@ export default class MapPOI { * We ask for features that are not in base style layers */ const features = map.queryRenderedFeatures(point, { layers: this.layerIds }); - this.updatePopup(map, features); + this.onFeaturesClick(map, features); }); } private bindedOnClick = this.onClick.bind(this); - private resetLeftPaddingPopup() { - this.queue((map) => map.easeTo({ padding: { left: 0 } })); - this.popup.off('close', this.bindedResetLeftPaddingPopup); + /** Update popup display between tooltip, sidebar and modal mode */ + private updatePopupDisplay() { + if (!this.activeFeature) return; + const { + layer: { id: layerId }, + } = this.activeFeature; + const oldDisplay = this.activePopupDisplay; + const { display: newDisplay } = this.popupConfigurationByLayers[layerId] || {}; + if (oldDisplay !== newDisplay) { + if (oldDisplay) { + this.popup.removeClassName(POPUP_DISPLAY_CLASSNAME_MODIFIER[oldDisplay]); + } + if (!newDisplay) { + this.popup.remove(); + } else { + this.popup.addClassName(POPUP_DISPLAY_CLASSNAME_MODIFIER[newDisplay]); + this.activePopupDisplay = newDisplay; + } + } + this.onPopupDisplayUpdate(oldDisplay, newDisplay); } - private bindedResetLeftPaddingPopup = this.resetLeftPaddingPopup.bind(this); + private updatePopupContent() { + if (!this.activeFeature) return; + const { + id, + properties, + layer: { id: layerId }, + } = this.activeFeature; + const popupLayerConfiguration = this.popupConfigurationByLayers[layerId]; + if (!popupLayerConfiguration) return; + const { getLoadingContent, getContent } = popupLayerConfiguration; - /** Update popup display between tooltip, sidebar and modal mode */ - private updatePopupDisplay(display: PopupDisplayTypes) { - // Navigation controls could have been removed if the previous popup display was 'modal'. - this.queue((map) => map.addControl(this.navigationControl)); + this.popup.addClassName(`${POPUP_CLASSNAME}--loading`); + this.popup.setHTML(getLoadingContent()); - // Remove all popup display classname modifier before adding the current modifier. - Object.keys(POPUP_DISPLAY_CLASSNAME_MODIFIER).forEach((d) => { - this.popup.removeClassName(POPUP_DISPLAY_CLASSNAME_MODIFIER[d as PopupDisplayTypes]); + getContent(id, properties).then((content) => { + this.popup.setHTML(content); + this.popup.removeClassName(`${POPUP_CLASSNAME}--loading`); }); - this.popup.addClassName(POPUP_DISPLAY_CLASSNAME_MODIFIER[display]); - - /* - * Remove navigation controls when popup display is 'modal'. - * When the popup closes, add navigation controls to the maps. - */ - if (display === POPUP_DISPLAY.modal) { - this.queue((map) => map.removeControl(this.navigationControl)); - this.popup.once('close', () => - this.queue((map) => map.addControl(this.navigationControl)) - ); - } } - /** Set the popup content and positioning */ - private updatePopup(map: maplibregl.Map, features: MapGeoJSONFeature[]) { + private onPopupDisplayUpdate( + oldDisplay: PopupDisplayTypes | null, + newDisplay: PopupDisplayTypes | null + ) { + if (oldDisplay === newDisplay) return; + this.queue(map => { + if (oldDisplay === POPUP_DISPLAY.sidebar) { + map.easeTo({ padding: { left: 0 } }); + } + if ( + newDisplay === POPUP_DISPLAY.sidebar && + this.activeFeature && + this.activeFeature.geometry.type === 'Point' + ) { + map.easeTo({ + center: this.activeFeature.geometry.coordinates as LngLatLike, + padding: { left: POPUP_WIDTH }, + }); + } + if (newDisplay === POPUP_DISPLAY.modal) { + map.removeControl(this.navigationControl); + } else { + map.addControl(this.navigationControl); + } + }); + + } + + /** + * Is triggered when a click has been made on the map. + * Is responsible for closing or opening the popup. + * + * Closing the popup happens when: + * - No features are closed to the click + * - When the feature clicked is the active feature displayed in the popup + * + * Opening the popup happens when: + * - A feature is clicked for which a popup configuration is available (popup configuration are set by layer) + * + * @param map The map instance + * @param features Features closed where the map has been clicked + */ + private onFeaturesClick(map: maplibregl.Map, features: MapGeoJSONFeature[]) { // Removing feature state for the obsolete active feature. - if (this.activeFeature) { - const { id, source, sourceLayer } = this.activeFeature; - map.setFeatureState({ source, sourceLayer, id }, { 'popup-feature': false }); - } + updateFeatureState(map, this.activeFeature, POPUP_FEATURE_STATE_KEY, false); + const hasFeatures = !!features.length; // Current rule: use the first feature to build the popup. // TO DO: Create a menu to display a list of feature to choose from. @@ -192,55 +257,17 @@ export default class MapPOI { // eslint-disable-next-line prefer-destructuring this.activeFeature = features[0]; - const { - id: featureId, - layer: { id: layerId }, - geometry, - properties, - source, - sourceLayer, - } = this.activeFeature; + const { geometry } = this.activeFeature; if (geometry.type !== 'Point') return; - - /* - * We remove the popup if: - * - no popup configuration for a layer - * - popup's source is no longer used in the map - */ - const popupConfiguration = this.popupsConfiguration[layerId]; - if (!popupConfiguration || !this.sourceIds.includes(source)) { - this.popup.remove(); - return; - } - - const { display, getContent, getLoadingContent } = popupConfiguration; - if (!this.popup.isOpen()) { this.popup.addTo(map); } - this.popup.addClassName(`${POPUP_CLASSNAME}--loading`); - this.popup.setLngLat(geometry.coordinates as LngLatLike).setHTML(getLoadingContent()); + this.popup.setLngLat(geometry.coordinates as LngLatLike); - getContent(featureId, properties).then((content) => { - this.popup.setHTML(content); - this.popup.removeClassName(`${POPUP_CLASSNAME}--loading`); - }); - this.updatePopupDisplay(display); - map.setFeatureState({ source, sourceLayer, id: featureId }, { 'popup-feature': true }); - - this.popup.once('close', () => { - this.activeFeature = null; - map.setFeatureState({ source, sourceLayer, id: featureId }, { 'popup-feature': false }); - }); - - if (display === 'sidebar') { - map.easeTo({ - center: geometry.coordinates as LngLatLike, - padding: { left: POPUP_WIDTH }, - }); - this.popup.on('close', this.bindedResetLeftPaddingPopup); - } + this.updatePopupContent(); + this.updatePopupDisplay(); + updateFeatureState(map, this.activeFeature, POPUP_FEATURE_STATE_KEY, true); } initialize( @@ -264,8 +291,6 @@ export default class MapPOI { } destroy() { - this.activeFeature = null; - this.popup.remove(); this.queue((map) => map.remove()); this.mapResizeObserver?.disconnect(); } @@ -298,7 +323,6 @@ export default class MapPOI { }); } this.layerIds = layers.map(({ id }) => id); - this.sourceIds = Object.keys(sources); }); } @@ -340,12 +364,10 @@ export default class MapPOI { this.queue((map) => map.jumpTo(options)); } - setPopupsConfiguration(config: PopupsConfiguration) { - this.popupsConfiguration = config; - if (!this.activeFeature) return; - const newPopupConfiguration = this.popupsConfiguration[this.activeFeature.layer.id]; - if (!newPopupConfiguration) return; - this.updatePopupDisplay(newPopupConfiguration.display); + setpopupConfigurationByLayers(config: PopupConfigurationByLayers) { + this.popupConfigurationByLayers = config; + this.updatePopupContent(); + this.updatePopupDisplay(); } /** @@ -412,4 +434,15 @@ export default class MapPOI { } }); } + + constructor() { + this.popup.on('close', () => { + this.queue((map) => { + updateFeatureState(map, this.activeFeature, POPUP_FEATURE_STATE_KEY, false); + }); + this.onPopupDisplayUpdate(this.activePopupDisplay, null); + this.activePopupDisplay = null; + this.activeFeature = null; + }); + } } diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte index 7645809d..368fd505 100644 --- a/packages/visualizations/src/components/MapPoi/MapRender.svelte +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -17,7 +17,7 @@ import Map from './Map'; import { getCenterZoomOptions } from './utils'; - import type { PopupsConfiguration, PoiMapOptions } from './types'; + import type { PopupConfigurationByLayers, PoiMapOptions } from './types'; // Base style, sources and layers export let style: MapOptions['style']; @@ -45,7 +45,7 @@ // Used in front of console and error messages to debug multiple maps on a same page const mapId = Math.floor(Math.random() * 1000); - export let popupsConfiguration: PopupsConfiguration; + export let popupConfigurationByLayers: PopupConfigurationByLayers; let container: HTMLElement; const map = new Map(); @@ -59,7 +59,7 @@ $: map.setMinZoom(minZoom); $: map.setMaxZoom(maxZoom); $: map.setSourcesAndLayers(sources, layers); - $: map.setPopupsConfiguration(popupsConfiguration); + $: map.setpopupConfigurationByLayers(popupConfigurationByLayers); $: map.jumpTo(getCenterZoomOptions({ zoom, center })); $: map.loadImages(images); $: cssVarStyles = `--aspect-ratio:${aspectRatio};`; diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts index d45289d5..6a7efb63 100644 --- a/packages/visualizations/src/components/MapPoi/types.ts +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -132,7 +132,7 @@ export type GeoPoint = { lat: number; lon: number; }; - -export type PopupsConfiguration = { [key: string]: PopupLayer }; +/** A configuration map for popups where keys are layer ids and values are PopupLayer object. */ +export type PopupConfigurationByLayers = { [key: string]: PopupLayer }; export type CenterZoomOptions = { zoom?: number; center?: LngLatLike }; diff --git a/packages/visualizations/src/components/MapPoi/utils.ts b/packages/visualizations/src/components/MapPoi/utils.ts index 5b94973f..ec97297c 100644 --- a/packages/visualizations/src/components/MapPoi/utils.ts +++ b/packages/visualizations/src/components/MapPoi/utils.ts @@ -17,7 +17,7 @@ import type { Layer, PoiMapData, PoiMapOptions, - PopupsConfiguration, + PopupConfigurationByLayers, SymbolLayer, } from './types'; import { DEFAULT_DARK_GREY, DEFAULT_BASEMAP_STYLE, DEFAULT_ASPECT_RATIO } from './constants'; @@ -144,14 +144,14 @@ export const getMapLayers = (layers?: Layer[]): LayerSpecification[] => { }); }; -export const getPopupsConfiguration = (layers?: Layer[]): PopupsConfiguration => { - const configuration: PopupsConfiguration = {}; +export const getPopupConfigurationByLayers = (layers?: Layer[]): PopupConfigurationByLayers => { + const configurationByLayers: PopupConfigurationByLayers = {}; layers?.forEach(({ id, popup }) => { if (popup) { - configuration[id] = popup; + configurationByLayers[id] = popup; } }); - return configuration; + return configurationByLayers; }; export const getMapOptions = (options: PoiMapOptions) => {