UNPKG

@opendatasoft/visualizations

Version:

Opendatasoft's components to easily build dashboards and visualizations.

693 lines (617 loc) 25.4 kB
import type { BBox } from 'geojson'; import { debounce, difference } from 'lodash'; import MaplibreGl from 'maplibre-gl'; import type { Map, LngLatBoundsLike, LngLatLike, MapGeoJSONFeature, MapLayerMouseEvent, MapMouseEvent, MapOptions, StyleSpecification, CircleLayerSpecification, SymbolLayerSpecification, } from 'maplibre-gl'; import { CONTROL_POSITION, POPUP_FEATURE_CONTENT, POPUP_FEATURE_CONTENT_LOADING, POPUP_DISPLAY_CLASSNAME_MODIFIER, POPUP_NAVIGATION_CONTROLS_CLASSNAME, POPUP_NAVIGATION_ARROWS_WRAPPER_CLASSNAME, POPUP_NAVIGATION_ARROW_BUTTON_CLASSNAME, POPUP_NAVIGATION_ARROW_BUTTON_ICON_CLASSNAME, POPUP_NAVIGATION_CLOSE_BUTTON_CLASSNAME, POPUP_NAVIGATION_CLOSE_BUTTON_ICON_CLASSNAME, POPUP_OPTIONS, POPUP_WIDTH, POPUP_NAVIGATION_CONTROLS_OFFSET_CLASSNAME, DEFAULT_SORT_KEY_VALUE, POPUP_DISPLAY, } from './constants'; import type { PopupConfigurationByLayers, CenterZoomOptions, PopupDisplayTypes, Images, OnFeatureClick, } from './types'; const CURSOR = { DEFAULT: 'default', HOVER: 'pointer', DRAG: 'move', }; const ACTIVE_FEATURE_RATIO_SIZE = 1.3; /** Sorts features in a layer by setting a sort key for a specific feature. */ const sortLayerFeatures = ( map: Map, layer: MapGeoJSONFeature['layer'], feature: MapGeoJSONFeature ) => { map.setLayoutProperty(layer.id, `${layer.type}-sort-key`, [ 'case', ['==', ['id'], feature.id], 1, 0, ]); }; /** Restores the original sorting order of features in a layer */ const unsortLayerFeatures = (map: Map, layer: MapGeoJSONFeature['layer']) => { map.setLayoutProperty(layer.id, `${layer.type}-sort-key`, DEFAULT_SORT_KEY_VALUE); }; type MapFunction = (map: Map) => unknown; type ActiveFeatureType = MapGeoJSONFeature | null; export default class MapPOI { /** The Map object representing the Map instance. */ private map: Map | null = null; /** Map resize observer */ private mapResizeObserver: ResizeObserver | null = null; /** Flag indicating whether the map is ready. */ private isReady = false; /** The base style of the map */ private baseStyle: StyleSpecification | null = null; /** A navigation control for the map. */ private navigationControl = new MaplibreGl.NavigationControl({ showCompass: false }); /** A fullscreen control for the map. */ private fullscreenControl = new MaplibreGl.FullscreenControl({}); /** A popup for displaying information on the map. */ private popup = new MaplibreGl.Popup(POPUP_OPTIONS); /** 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: ActiveFeatureType = null; /** All available GeoJSONFeatures on click event */ private availableFeaturesOnClick: ActiveFeatureType[] = []; /** An array of functions to be executed when the map is ready. */ private queuedFunctions: Array<MapFunction> = []; /** Additional custom click handler */ private onFeatureClick: OnFeatureClick | null = null; /** To queue functions that depend on map readiness. Will be executed when the card is ready. */ private queue(fn: MapFunction) { if (this.isReady && this.map) return fn(this.map); return this.queuedFunctions.push(fn); } /** Execute queued functions */ private enqueue(map: Map) { this.queuedFunctions.forEach((fn) => fn(map)); this.queuedFunctions = []; } /** Make active feature bigger and sort it on top of other features in the layer */ private highlightFeature(feature: ActiveFeatureType) { if (!feature) return; const { layer } = feature; this.queue((map) => { sortLayerFeatures(map, layer, feature); switch (layer.type) { case 'symbol': // eslint-disable-next-line no-case-declarations const iconSize = ((layer as SymbolLayerSpecification).layout?.['icon-size'] || 1) as number; map.setLayoutProperty(layer.id, 'icon-size', [ 'case', ['==', ['id'], feature.id], iconSize * ACTIVE_FEATURE_RATIO_SIZE, iconSize, ]); break; case 'circle': // eslint-disable-next-line no-case-declarations const circleRadius = (layer as CircleLayerSpecification).paint?.[ 'circle-radius' ] as number; map.setPaintProperty(layer.id, 'circle-radius', [ 'case', ['==', ['id'], feature.id], circleRadius * ACTIVE_FEATURE_RATIO_SIZE, circleRadius, ]); break; default: break; } }); } /** Reset active feature highlight state */ private unhighlightFeature(feature: ActiveFeatureType) { if (!feature) return; const { layer } = feature; this.queue((map) => { unsortLayerFeatures(map, layer); switch (layer.type) { case 'symbol': map.setLayoutProperty( layer.id, 'icon-size', (layer as SymbolLayerSpecification).layout?.['icon-size'] || 1 ); break; case 'circle': // eslint-disable-next-line no-case-declarations const circleRadius = (layer as CircleLayerSpecification).paint?.[ 'circle-radius' ]; /* * FIXME: As of Maplibre 2.2.1, resetting 'circle-radius' with a numeric value alone is not sufficient. * An expression with a case statement based on the feature ID is still required. * Without this, the feature's clickability is compromised, as the hitbox becomes minimal, * failing to reflect the expected behavior of the circleRadius property. */ map.setPaintProperty(layer.id, 'circle-radius', [ 'case', ['==', ['id'], ''], circleRadius, circleRadius, ]); break; default: break; } }); } /** Initialize a resize observer to always fit the map to its container */ private initializeMapResizer(map: Map, container: HTMLElement) { // Set a resizeObserver to resize map on container size changes this.mapResizeObserver = new ResizeObserver( debounce(() => { map.resize(); }, 100) ); this.mapResizeObserver.observe(container); } /** * Event handler for mousemove event. * Show a pointer cursor if hovering a feature with a popup configuration on an onClick callback */ private onMouseMove({ point }: MapMouseEvent) { this.queue((map) => { const canvas = map.getCanvas(); const layers = [ ...Object.keys(this.popupConfigurationByLayers), ...(this.onFeatureClick?.layers || []), ]; const features = map.queryRenderedFeatures(point, { layers }); canvas.style.cursor = features.length ? CURSOR.HOVER : CURSOR.DEFAULT; }); } private bindedOnMouseMove = this.onMouseMove.bind(this); /** * How cursor should react on drag and when mouse move over the map */ private initializeCursorBehavior(map: Map) { const canvas = map.getCanvas(); map.on('dragstart', () => { canvas.style.cursor = CURSOR.DRAG; }); map.on('dragend', () => { canvas.style.cursor = CURSOR.DEFAULT; }); } /** * Event handler for click events on the map. * Currently, is only used to handle popup display. * @param {MapLayerMouseEvent} event */ private onMapClick({ point }: MapLayerMouseEvent) { this.queue((map) => { this.handlePopupAfterMapClick(map, point); if (this?.onFeatureClick) { this.handleCustomFeatureClick(map, point, this.onFeatureClick); } }); } private bindedOnMapClick = this.onMapClick.bind(this); /** Update popup display between tooltip, sidebar and modal modes */ 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 navigateToFeature(direction: number) { this.unhighlightFeature(this.activeFeature); const activeFeatureIndex = this.availableFeaturesOnClick.indexOf(this.activeFeature); this.activeFeature = this.availableFeaturesOnClick[activeFeatureIndex + direction]; this.updatePopupContent(); this.updatePopupDisplay(); if (this.activeFeature?.geometry.type === 'Point') { this.popup.setLngLat(this.activeFeature?.geometry.coordinates as LngLatLike); } this.highlightFeature(this.activeFeature); } private renderFeaturesNavigationControls() { const popupNavigationDiv = document.createElement('div'); popupNavigationDiv.classList.add(POPUP_NAVIGATION_CONTROLS_CLASSNAME); const availableFeaturesTotal = this.availableFeaturesOnClick.length; let arrows = ''; if (availableFeaturesTotal > 1) { const activeFeatureHumanIndex = this.availableFeaturesOnClick.indexOf(this.activeFeature) + 1; arrows = `<div class="${POPUP_NAVIGATION_CONTROLS_OFFSET_CLASSNAME}"></div><div class="${POPUP_NAVIGATION_ARROWS_WRAPPER_CLASSNAME}"><button class="${POPUP_NAVIGATION_ARROW_BUTTON_CLASSNAME}" id="prevButton" ${ activeFeatureHumanIndex === 1 ? 'disabled' : '' }><span class="${POPUP_NAVIGATION_ARROW_BUTTON_ICON_CLASSNAME}"></span></button> <div class="feature-count">${activeFeatureHumanIndex} / ${availableFeaturesTotal}</div> <button class="${POPUP_NAVIGATION_ARROW_BUTTON_CLASSNAME}" id="nextButton" ${ activeFeatureHumanIndex === availableFeaturesTotal ? 'disabled' : '' }><span class="${POPUP_NAVIGATION_ARROW_BUTTON_ICON_CLASSNAME}"></span></button></div>`; } popupNavigationDiv.innerHTML = ` ${arrows} <button class="${POPUP_NAVIGATION_CLOSE_BUTTON_CLASSNAME}"><span class="${POPUP_NAVIGATION_CLOSE_BUTTON_ICON_CLASSNAME}"></span></button> `; const prevButton = popupNavigationDiv.querySelector('#prevButton'); prevButton?.addEventListener('click', () => this.navigateToFeature(-1)); const nextButton = popupNavigationDiv.querySelector('#nextButton'); nextButton?.addEventListener('click', () => this.navigateToFeature(1)); const closeButton = popupNavigationDiv.querySelector( `.${POPUP_NAVIGATION_CLOSE_BUTTON_CLASSNAME}` ); closeButton?.addEventListener('click', () => this.popup.remove()); return popupNavigationDiv; } /** * Update popup content. * - First add a loading state, * - Then replace it with content * * Navigation controls element is always displayed */ 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; const controlsDiv = this.renderFeaturesNavigationControls(); const loadingWrapper = document.createElement('div'); const popupFeatureContentLoading = document.createElement('div'); popupFeatureContentLoading.classList.add(POPUP_FEATURE_CONTENT_LOADING); popupFeatureContentLoading.innerHTML = getLoadingContent(); loadingWrapper.append(controlsDiv, popupFeatureContentLoading); this.popup.setDOMContent(loadingWrapper); getContent(id, properties).then((content) => { const wrapper = document.createElement('div'); const popupFeatureContent = document.createElement('div'); popupFeatureContent.classList.add(POPUP_FEATURE_CONTENT); popupFeatureContent.innerHTML = content; wrapper.append(controlsDiv, popupFeatureContent); this.popup.setDOMContent(wrapper); }); } /** Handler for popup display changes */ private onPopupDisplayUpdate( oldDisplay: PopupDisplayTypes | null, newDisplay: PopupDisplayTypes | null ) { if (oldDisplay === newDisplay) return; this.queue((map) => { /** * When the popup is display as a sidebar, the feature may be behind the the popup. * To avoid this we add a padding to the map, so that the feature remains visible. */ 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 (oldDisplay === POPUP_DISPLAY.sidebar) { map.easeTo({ padding: { left: 0 } }); } /* * When the popup is displayed as a modal, it overlaps the controls. * The result is a double shadow in the top right-hand corner. * To get rid of it, we toggle the controls according to the current display. */ if (newDisplay === POPUP_DISPLAY.modal) { this.removeControls(); } else { this.addControls(); } }); } private handleCustomFeatureClick( map: Map, point: MapMouseEvent['point'], onFeatureClick: OnFeatureClick ) { /* * Get features close to the click area. * We ask for features that are not in base style layers and for which a popup config is defined. */ const { callback, layers } = onFeatureClick; const features = map.queryRenderedFeatures(point, { layers }); return callback(features); } /** * Is triggered when a click has been made on the map. * Is responsible for opening and closing the popup. * * Opening the popup happens when: * - A feature is clicked for which a popup configuration is available (popup configuration are set by layer) * * Closing the popup happens when: * - No features are near the click * - The clicked feature is the current feature (activeFeature) displayed in the popup * - The button icon button in the popup is clicked * * @param map The map instance * @param point The pixel coordinates of the cursor click, relative to the map */ private handlePopupAfterMapClick(map: Map, point: MapMouseEvent['point']) { /* * Get features close to the click area. * We ask for features that are not in base style layers and for which a popup config is defined. */ const features = map.queryRenderedFeatures(point, { layers: Object.keys(this.popupConfigurationByLayers), }); // Removing feature state for the obsolete active feature. this.unhighlightFeature(this.activeFeature); 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. const isSelectedFeatureSameAsActiveFeature = hasFeatures && !!this.activeFeature && features[0].layer.id === this.activeFeature?.layer.id && features[0].id === this.activeFeature.id; // Close popup if: // - No features in parameters // - Selected feature is the same as the active feature: This means that I clicked on the feature for which the popup is open. if (!hasFeatures || isSelectedFeatureSameAsActiveFeature) { this.popup.remove(); this.availableFeaturesOnClick = []; return; } // FIXME: remove eslint comment. // eslint-disable-next-line prefer-destructuring this.activeFeature = features[0]; this.availableFeaturesOnClick = features; const { geometry } = this.activeFeature; if (geometry.type !== 'Point') return; if (!this.popup.isOpen()) { this.popup.addTo(map); } this.popup.setLngLat(geometry.coordinates as LngLatLike); this.updatePopupContent(); this.updatePopupDisplay(); this.highlightFeature(this.activeFeature); } /** * Check if all specified controls exist on the map. */ private hasAllControls(map: Map) { return [this.navigationControl, this.fullscreenControl].every((control) => map.hasControl(control) ); } /** * Add navigation and fullscreen controls to the map. */ private addControls() { this.queue((map) => { if (this.hasAllControls(map)) return; map.addControl(this.navigationControl, CONTROL_POSITION); map.addControl(this.fullscreenControl, CONTROL_POSITION); }); } /* * Remove navigation and fullscreen controls from the map. */ private removeControls() { this.queue((map) => { if (!this.hasAllControls(map)) return; map.removeControl(this.navigationControl); map.removeControl(this.fullscreenControl); }); } initialize( style: MapOptions['style'], container: HTMLElement, options: Omit<MapOptions, 'style' | 'container'> ) { this.map = new MaplibreGl.Map({ style, container, ...options }); this.queue((map) => this.initializeMapResizer(map, container)); this.queue((map) => this.initializeCursorBehavior(map)); this.map.on('load', () => { this.isReady = true; if (this.map) { // Store base style after the first load this.baseStyle = this.map.getStyle(); this.enqueue(this.map); } }); } destroy() { this.activePopupDisplay = null; this.activeFeature = null; this.availableFeaturesOnClick = []; this.popup.remove(); this.queue((map) => map.remove()); this.mapResizeObserver?.disconnect(); } // TODO: add tests to check that layers are at the end of the array /* * TODO: When updating Maplibre to a 3.2.2 version or up * - Update this code to use the option transformStyle. * https://maplibre.org/maplibre-gl-js/docs/API/types/maplibregl.TransformStyleFunction/ * - `baseStyle` could be removed * - The key block could also be removed in MapRender.svelte */ /** * Update the sources and layers of the map. * Layers of the map base style are untouched. */ setSourcesAndLayers( sources: StyleSpecification['sources'], layers: StyleSpecification['layers'] ) { this.queue((map) => { if (this.baseStyle) { map.setStyle({ ...this.baseStyle, sources: { ...sources, ...this.baseStyle.sources, }, layers: [...this.baseStyle.layers, ...layers], }); } }); } setMinZoom(minZoom?: number) { this.queue((map) => { map.setMinZoom(minZoom); }); } setMaxZoom(maxZoom?: number) { this.queue((map) => { map.setMaxZoom(maxZoom); }); } setBbox(bbox?: BBox) { this.queue((map) => { if (!bbox) { // zoom-out to bounds defined in the initialization map.setZoom(map.getMinZoom()); return; } // Cancel any saved max bounds to properly fitBounds map.setMaxBounds(null); // Using padding, keep enough room for controls (zoom) to make sure they don't hide anything map.fitBounds(bbox as LngLatBoundsLike, { animate: false, padding: 40, }); }); } updateClickHandler(handler?: OnFeatureClick) { if (handler) { this.onFeatureClick = handler; } } /** * Changes any combination of center and zoom without an animated transition. * The map will retain its current values for any details not specified in options */ jumpTo(options: CenterZoomOptions) { this.queue((map) => map.jumpTo(options)); } /** * Store the new popup configuration for each layer. * When this configuration is updated, we need to update the popup content and display * to reflect the new configuration. * @param config Popups configuration */ setPopupConfigurationByLayers(config: PopupConfigurationByLayers) { this.popupConfigurationByLayers = config; this.updatePopupContent(); this.updatePopupDisplay(); } /** * Load images into the map. * Remove automatically any images previously loaded that are no longer defined in the images object. */ loadImages(images?: Images) { if (!images) return; this.queue((map) => { const loadedImages = map.listImages(); const imagesIds = Object.values(images).map(({ id }) => id); const imagesToRemove = difference(loadedImages, imagesIds); const imagesToAdd = difference(imagesIds, loadedImages); imagesToRemove.forEach((imageId) => { map.removeImage(imageId); }); imagesToAdd.forEach((imageId) => { const { url, options } = images[imageId]; map.loadImage(url, (error, image) => { if (error || !image) { // eslint-disable-next-line no-console console.warn(`Fail to load image: ${imageId}`); } else { map.addImage(imageId, image, options); } }); }); }); } toggleInteractivity( interaction: 'enable' | 'disable', { onDisable, onEnable }: { onDisable?: () => void; onEnable?: () => void } ) { this.queue((map) => { map.boxZoom[interaction](); map.doubleClickZoom[interaction](); map.dragPan[interaction](); map.dragRotate[interaction](); map.keyboard[interaction](); map.scrollZoom[interaction](); map.touchZoomRotate[interaction](); const eventFunction = interaction === 'enable' ? 'on' : 'off'; map[eventFunction]('click', this.bindedOnMapClick); map[eventFunction]('mousemove', this.bindedOnMouseMove); if (interaction === 'disable') { onDisable?.(); this.popup.remove(); this.removeControls(); } if (interaction === 'enable') { onEnable?.(); this.addControls(); } }); } constructor() { this.popup.on('close', () => { this.unhighlightFeature(this.activeFeature); this.onPopupDisplayUpdate(this.activePopupDisplay, null); this.activePopupDisplay = null; this.activeFeature = null; }); } }