diff --git a/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx b/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx index d3e4fc05..d4397180 100644 --- a/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx +++ b/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx @@ -16,6 +16,10 @@ const layers : PoiMapData["layers"] = [{ colorMatch: { key: 'key', colors: {Paris: 'blue', Nantes: 'yellow', Bordeaux: 'purple', Corsica: 'white', Marseille : 'lightblue' } + }, + popup: { + display: 'tooltip', + 'getContent': () => "data-layer-001 popup" } }]; diff --git a/packages/visualizations/src/components/MapPoi/Map.svelte b/packages/visualizations/src/components/MapPoi/Map.svelte index 05346d3b..4cd24cea 100644 --- a/packages/visualizations/src/components/MapPoi/Map.svelte +++ b/packages/visualizations/src/components/MapPoi/Map.svelte @@ -3,7 +3,7 @@ import type { Async } from '../../types'; - import { getMapStyle, getMapSources, getMapLayers, getMapOptions } from './utils'; + import { getMapStyle, getMapSources, getMapLayers, getPopupsConfiguration, getMapOptions } from './utils'; import type { PoiMapData, PoiMapOptions } from './types'; export let data: Async; @@ -12,13 +12,14 @@ $: style = getMapStyle(options.style); $: sources = getMapSources(data.value?.sources); $: layers = getMapLayers(data.value?.layers); + $: popupsConfiguration = getPopupsConfiguration(data.value?.layers); $: computedOptions = getMapOptions(options);
{#key style} - + {/key}
diff --git a/packages/visualizations/src/components/MapPoi/Map.ts b/packages/visualizations/src/components/MapPoi/Map.ts index 9719f69c..a06108f3 100644 --- a/packages/visualizations/src/components/MapPoi/Map.ts +++ b/packages/visualizations/src/components/MapPoi/Map.ts @@ -2,10 +2,14 @@ import type { BBox } from 'geojson'; import maplibregl, { LngLatBoundsLike, LngLatLike, + MapGeoJSONFeature, + MapLayerMouseEvent, MapOptions, StyleSpecification, } from 'maplibre-gl'; +import type { PopupsConfiguration } from './types'; + type MapFunction = (map: maplibregl.Map) => unknown; const DEFAULT_CENTER: LngLatLike = [3.5, 46]; @@ -17,10 +21,18 @@ export default class MapPOI { private baseStyle: StyleSpecification | null = null; - private queuedFunctions: Array = []; - + private layerIds : string[] = []; + private navigation = new maplibregl.NavigationControl({ showCompass: false }); + private popup = new maplibregl.Popup({className: 'poi-map__popup'}); + + private popupsConfiguration : PopupsConfiguration = {}; + + private popupFeatures : MapGeoJSONFeature[] = []; + + private queuedFunctions: Array = []; + private queue(fn: MapFunction) { if (this.isReady && this.map) fn(this.map); else this.queuedFunctions.push(fn); @@ -31,6 +43,51 @@ export default class MapPOI { this.queuedFunctions = []; } + private onClick({point}: MapLayerMouseEvent) { + this.queue(map => { + /** + * Get features closed to the click. + * We ask for features that are not in base style layers + */ + const features = map.queryRenderedFeatures(point, {layers: this.layerIds}); + this.popupFeatures = features; + this.setPopup(map); + }); + } + + private bindedOnClick = this.onClick.bind(this); + + private setPopup(map: maplibregl.Map) { + if(!this.popupFeatures.length) return; + + // 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 {id, layer: {id: layerId}, geometry, properties} = this.popupFeatures[0]; + + if(geometry.type !== "Point") return; + + // Get the popup configuration for a layer + const popupConfiguration = this.popupsConfiguration[layerId]; + + // If no popup configuration for a layer, we remove the popup + if (!popupConfiguration) { + this.popup.remove(); + this.popupFeatures = []; + return; + } + + + const {display, getContent} = popupConfiguration; + + this.popup + .setLngLat(geometry.coordinates.slice() as LngLatLike) + .setHTML(getContent(id, properties)) + .addTo(map); + + const classnameModifier = display === 'sidebar' ? 'addClassName' : 'removeClassName'; + this.popup[classnameModifier]("poi-map__popup--as-sidebar"); + } + initialize( style: MapOptions['style'], container: HTMLElement, @@ -39,9 +96,10 @@ export default class MapPOI { this.map = new maplibregl.Map({ style, container, center: DEFAULT_CENTER, ...options }); this.map.on('load', () => { this.isReady = true; - // Store base style after the first loads if (this.map) { + // Store base style after the first load this.baseStyle = this.map?.getStyle(); + this.map.on("click", this.bindedOnClick); this.enqueue(this.map); } }); @@ -74,6 +132,7 @@ export default class MapPOI { layers: [...this.baseStyle.layers, ...layers], }); } + this.layerIds = layers.map(({id}) => id); }); } @@ -95,6 +154,11 @@ export default class MapPOI { }); } + setPopupsConfiguration(config : PopupsConfiguration) { + this.popupsConfiguration = config; + this.queue((map) => this.setPopup(map)); + } + toggleInteractivity(interaction: 'enable' | 'disable') { this.queue((map) => { map.boxZoom[interaction](); @@ -105,13 +169,16 @@ export default class MapPOI { map.scrollZoom[interaction](); map.touchZoomRotate[interaction](); - const hasNavigation = map.hasControl(this.navigation); + const hasControl = map.hasControl(this.navigation); - if (interaction === 'disable' && hasNavigation) { - map.removeControl(this.navigation); + if (interaction === 'disable') { + this.popup.remove(); + map.off('click', this.bindedOnClick); + if (hasControl) {map.removeControl(this.navigation);} } - if (!hasNavigation && interaction === 'enable') { - map.addControl(this.navigation, 'top-right'); + if (interaction === 'enable') { + if(!hasControl) {map.addControl(this.navigation, 'top-right');} + map.on('click', this.bindedOnClick); } }); } diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte index 12996dd2..bf304725 100644 --- a/packages/visualizations/src/components/MapPoi/MapRender.svelte +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -6,6 +6,7 @@ import { onDestroy, onMount } from 'svelte'; import Map from './Map'; + import type { PopupsConfiguration } from './types'; // Base style, sources and layers export let style: MapOptions['style']; @@ -16,6 +17,7 @@ export let bbox: BBox; export let aspectRatio: number; export let interactive: boolean; + export let popupsConfiguration: PopupsConfiguration; let container: HTMLElement; const map = new Map(); @@ -23,6 +25,7 @@ $: map.toggleInteractivity(interactive ? 'enable' : 'disable'); $: map.setBbox(bbox); $: map.setSourcesAndLayers(sources, layers); + $: map.setPopupsConfiguration(popupsConfiguration); $: cssVarStyles = `--aspect-ratio:${aspectRatio};`; // Lifecycle @@ -53,18 +56,9 @@ position: relative; } /* To add classes programmatically in svelte we will use a global selector. We place it inside a local selector to obtain some encapsulation and avoid side effects */ - .map-card :global(.tooltip-on-hover > .maplibregl-popup-content) { - border-radius: 6px; - box-shadow: 0px 6px 13px rgba(0, 0, 0, 0.26); - padding: 13px; + .map-card :global(.poi-map__popup.poi-map__popup--as-sidebar) { + transform: none !important; } - .map-card :global(.tooltip-on-hover .maplibregl-popup-tip) { - border-top-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; - border-right-color: transparent; - } - .main { aspect-ratio: var(--aspect-ratio); flex-grow: 1; diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts index ed6a57f0..1a02d5d6 100644 --- a/packages/visualizations/src/components/MapPoi/types.ts +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -1,5 +1,5 @@ -import type { CircleLayerSpecification, StyleSpecification } from 'maplibre-gl'; -import type { BBox } from 'geojson'; +import type { CircleLayerSpecification, GeoJSONFeature, StyleSpecification } from 'maplibre-gl'; +import type { BBox, GeoJsonProperties } from 'geojson'; import type { Color } from '../types'; // To render data layers on the map @@ -41,6 +41,7 @@ export type Layer = { sourceLayer?: string; type: LayerSpecification['type']; color: Color; + popup?: PopupLayer /** * Set a marker color based on a value. * If no match, default color comes from `color` @@ -51,7 +52,19 @@ export type Layer = { }; }; +export type PopupLayer = { + /** + * Control where to display the popup + * - `sidebar`: As a side element (on the left) + * - `tooltip`: Above the feature that has been clicked + */ + display: 'sidebar' | 'tooltip'; + getContent: (id: GeoJSONFeature["id"], properties?: GeoJsonProperties) => string; +}; + export type GeoPoint = { lat: number; lon: number; }; + +export type PopupsConfiguration = {[key: string] : PopupLayer}; diff --git a/packages/visualizations/src/components/MapPoi/utils.ts b/packages/visualizations/src/components/MapPoi/utils.ts index 7b5be655..3f084bfe 100644 --- a/packages/visualizations/src/components/MapPoi/utils.ts +++ b/packages/visualizations/src/components/MapPoi/utils.ts @@ -7,7 +7,7 @@ import type { import type { Color } from '../types'; -import type { Layer, PoiMapData, PoiMapOptions } from './types'; +import type { Layer, PoiMapData, PoiMapOptions, PopupsConfiguration } from './types'; import { DEFAULT_BASEMAP_STYLE, DEFAULT_ASPECT_RATIO, DEFAULT_BBOX } from './constants'; export const getMapStyle = (style: PoiMapOptions['style']): MapOptions['style'] => { @@ -52,6 +52,16 @@ export const getMapLayers = (layers?: Layer[]): CircleLayerSpecification[] => { }); }; +export const getPopupsConfiguration = (layers?: Layer[]): PopupsConfiguration => { + const configuration : PopupsConfiguration = {}; + layers?.forEach(({id, popup}) => { + if(popup) { + configuration[id] = popup; + } + }); + return configuration; +}; + export const getMapOptions = (options: PoiMapOptions) => { const { aspectRatio = DEFAULT_ASPECT_RATIO, bbox = DEFAULT_BBOX, interactive = true } = options; return {