From 1e373b577f6709d70b534247c506a710d88c3499 Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Tue, 7 Nov 2023 12:14:47 +0100 Subject: [PATCH] feat(POI Map): allow symbol layers --- .../stories/Poi/PoiMap.stories.tsx | 66 ++++++-- .../src/components/MapPoi/Map.svelte | 5 +- .../src/components/MapPoi/Map.ts | 32 +++- .../src/components/MapPoi/MapRender.svelte | 4 +- .../src/components/MapPoi/types.ts | 38 ++++- .../src/components/MapPoi/utils.ts | 148 +++++++++++------- 6 files changed, 214 insertions(+), 79 deletions(-) diff --git a/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx b/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx index 6bf9a2fe..70858b76 100644 --- a/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx +++ b/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { BBox } from 'geojson'; import { CATEGORY_ITEM_VARIANT, PopupDisplayTypes } from '@opendatasoft/visualizations'; import { ComponentMeta, ComponentStory } from '@storybook/react'; -import type { Layer } from '@opendatasoft/visualizations'; +import type { Layer, PoiMapOptions } from '@opendatasoft/visualizations'; import { defaultSource, timeout } from '../utils'; @@ -31,9 +31,8 @@ const layer1: Layer = { const layer2: Layer = { id: 'layer-002', source: 'battles', - type: 'circle', - color: 'red', - borderColor: 'white', + type: 'symbol', + iconImageId: 'battle-icon', popup: { display: PopupDisplayTypes.Sidebar, getContent: async (_, properties) => { @@ -57,6 +56,11 @@ const citiesColorMatch = { borderColors: { Paris: 'white', Nantes: 'black', Bordeaux: 'white', Marseille: 'black' }, }; +const battleImageMatch = { + key: 'name', + imageIds: { 'Battle of Verdun': 'battle-icon-red' }, +}; + const bbox: BBox = [-6.855469, 41.343825, 11.645508, 51.37178]; const legend = { @@ -91,13 +95,19 @@ const legend = { align: 'start' as const, }; -const options = { +const options: PoiMapOptions = { style: BASE_STYLE, bbox, title: 'Lorem Ipsum', subtitle: 'Dolor Sit Amet', - desciption: 'More aria description', + description: 'More aria description', sourceLink: defaultSource, + images: { + 'battle-icon': + 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/50/Big_battle_symbol.svg/14px-Big_battle_symbol.svg.png', + 'battle-icon-red': + 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Battle_icon_gladii_red.svg/14px-Battle_icon_gladii_red.svg.png', + }, }; const meta: ComponentMeta = { @@ -107,7 +117,7 @@ const meta: ComponentMeta = { export default meta; -const Template: ComponentStory = args => ( +const Template: ComponentStory = (args) => (
= Template.bind({}); const PoiMapMatchExpressionArgs = { - data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, + data: { + value: { + layers: [ + { ...layer1, colorMatch: citiesColorMatch }, + { ...layer2, iconImageMatch: battleImageMatch }, + ], + sources, + }, + }, options, }; PoiMapMatchExpression.args = PoiMapMatchExpressionArgs; @@ -156,7 +174,15 @@ PoiMapMatchExpression.args = PoiMapMatchExpressionArgs; */ export const PoiMapLegendStart: ComponentStory = Template.bind({}); const PoiMapLegendStartArgs = { - data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, + data: { + value: { + layers: [ + { ...layer1, colorMatch: citiesColorMatch }, + { ...layer2, iconImageMatch: battleImageMatch }, + ], + sources, + }, + }, options: { ...options, legend }, }; PoiMapLegendStart.args = PoiMapLegendStartArgs; @@ -166,7 +192,15 @@ PoiMapLegendStart.args = PoiMapLegendStartArgs; */ export const PoiMapLegendCenter: ComponentStory = Template.bind({}); const PoiMapLegendCenterArgs = { - data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, + data: { + value: { + layers: [ + { ...layer1, colorMatch: citiesColorMatch }, + { ...layer2, iconImageMatch: battleImageMatch }, + ], + sources, + }, + }, options: { ...options, legend: { ...legend, align: 'center' as const } }, }; PoiMapLegendCenter.args = PoiMapLegendCenterArgs; @@ -176,8 +210,16 @@ PoiMapLegendCenter.args = PoiMapLegendCenterArgs; */ export const PoiMapMinMaxZooms: ComponentStory = Template.bind({}); const PoiMapMinMaxZoomsArgs = { - data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, - options: { + data: { + value: { + layers: [ + { ...layer1, colorMatch: citiesColorMatch }, + { ...layer2, iconImageMatch: battleImageMatch }, + ], + sources, + }, + }, + options: { ...options, legend, minZoom: 3, diff --git a/packages/visualizations/src/components/MapPoi/Map.svelte b/packages/visualizations/src/components/MapPoi/Map.svelte index 9a4cdf70..cb1291d1 100644 --- a/packages/visualizations/src/components/MapPoi/Map.svelte +++ b/packages/visualizations/src/components/MapPoi/Map.svelte @@ -35,6 +35,7 @@ sourceLink, aspectRatio, interactive, + images, transformRequest, } = getMapOptions(options)); @@ -63,10 +64,8 @@ {sourceLink} {aspectRatio} {interactive} + {images} {transformRequest} /> {/key}
- - diff --git a/packages/visualizations/src/components/MapPoi/Map.ts b/packages/visualizations/src/components/MapPoi/Map.ts index f883a575..17338f5b 100644 --- a/packages/visualizations/src/components/MapPoi/Map.ts +++ b/packages/visualizations/src/components/MapPoi/Map.ts @@ -1,5 +1,5 @@ import type { BBox } from 'geojson'; -import { debounce } from 'lodash'; +import { debounce, difference } from 'lodash'; import maplibregl, { LngLatBoundsLike, LngLatLike, @@ -318,6 +318,36 @@ export default class MapPOI { this.queue((map) => this.setPopup(map)); } + /** + * Load images into the map. + * Remove automatically any images previously loaded that are no longer defined in the images object. + */ + loadImages(images?: Record) { + if (!images) return; + this.queue((map) => { + const loadedImages = map.listImages(); + const imagesIds = Object.keys(images); + + const imagesToRemove = difference(loadedImages, imagesIds); + const imagesToAdd = difference(imagesIds, loadedImages); + + imagesToRemove.forEach((imageId) => { + map.removeImage(imageId); + }); + + imagesToAdd.forEach((imageId) => { + map.loadImage(images[imageId], (error, image) => { + if (error || !image) { + // eslint-disable-next-line no-console + console.warn(`Fail to load image: ${imageId}`); + } else { + map.addImage(imageId, image); + } + }); + }); + }); + } + toggleInteractivity( interaction: 'enable' | 'disable', { onDisable, onEnable }: { onDisable?: () => void; onEnable?: () => void } diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte index a0a66fae..f43d34ca 100644 --- a/packages/visualizations/src/components/MapPoi/MapRender.svelte +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -11,7 +11,7 @@ import Map from './Map'; import { getCenterZoomOptions } from './utils'; - import type { PopupsConfiguration } from './types'; + import type { PopupsConfiguration, PoiMapOptions } from './types'; // Base style, sources and layers export let style: MapOptions['style']; @@ -25,6 +25,7 @@ export let minZoom: number | undefined; export let maxZoom: number | undefined; export let center: LngLatLike | undefined; + export let images: PoiMapOptions['images']; export let aspectRatio: number; export let interactive: boolean; export let title: string | undefined; @@ -53,6 +54,7 @@ $: map.setSourcesAndLayers(sources, layers); $: map.setPopupsConfiguration(popupsConfiguration); $: map.jumpTo(getCenterZoomOptions({ zoom, center })); + $: map.loadImages(images); $: cssVarStyles = `--aspect-ratio:${aspectRatio};`; // Lifecycle diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts index 2257838a..c4d5af52 100644 --- a/packages/visualizations/src/components/MapPoi/types.ts +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -4,6 +4,7 @@ import type { GeoJSONFeature, LngLatLike, RequestTransformFunction, + SymbolLayerSpecification, } from 'maplibre-gl'; import type { BBox, GeoJsonProperties } from 'geojson'; @@ -50,22 +51,30 @@ export interface PoiMapOptions { legend?: CategoryLegend; /** Link button to source */ sourceLink?: Source; + /** Images to load by the Map. keys are image ids */ + images?: Record; } export type PoiMapStyleOption = Partial>; // Supported layers -export type LayerSpecification = CircleLayerSpecification; +export type LayerSpecification = CircleLayerSpecification | SymbolLayerSpecification; +type BaseLayer = { + id: string; + source: string; + sourceLayer?: string; + /** + * A feature for which a popup is defined will update the cursor style in pointer mode + */ + popup?: PopupLayer; +}; /** * Base on Maplibre layers https://maplibre.org/maplibre-style-spec/layers/ * Use only part of the configuration to build layers with consistent styles. */ -export type Layer = { - id: string; - source: string; - sourceLayer?: string; - type: LayerSpecification['type']; +export type CircleLayer = BaseLayer & { + type: CircleLayerSpecification['type']; color: Color; borderColor?: Color; circleRadius?: number; @@ -79,12 +88,25 @@ export type Layer = { colors: { [key: string]: Color }; borderColors?: { [key: string]: Color }; }; +}; + +export type SymbolLayer = BaseLayer & { + type: SymbolLayerSpecification['type']; + /** id of the image to use as icon-image */ + iconImageId: string; /** - * A feature for which a popup is defined will update the cursor style in pointer mode + * Set a icon image based on a value. + * If no match, default icon image comes from `iconImageId` */ - popup?: PopupLayer; + iconImageMatch?: { + key: string; + /** Keys must match from the options.images keys */ + imageIds: Record; + }; }; +export type Layer = CircleLayer | SymbolLayer; + export enum PopupDisplayTypes { Tooltip = 'tooltip', Sidebar = 'sidebar', diff --git a/packages/visualizations/src/components/MapPoi/utils.ts b/packages/visualizations/src/components/MapPoi/utils.ts index 064b9709..0961773a 100644 --- a/packages/visualizations/src/components/MapPoi/utils.ts +++ b/packages/visualizations/src/components/MapPoi/utils.ts @@ -1,18 +1,19 @@ import type { CircleLayerSpecification, - DataDrivenPropertyValueSpecification, MapOptions, StyleSpecification, + SymbolLayerSpecification, } from 'maplibre-gl'; -import type { Color } from '../types'; - import type { CenterZoomOptions, + CircleLayer, + LayerSpecification, Layer, PoiMapData, PoiMapOptions, PopupsConfiguration, + SymbolLayer, } from './types'; import { DEFAULT_DARK_GREY, DEFAULT_BASEMAP_STYLE, DEFAULT_ASPECT_RATIO } from './constants'; @@ -26,63 +27,100 @@ export const getMapSources = (sources: PoiMapData['sources']): StyleSpecificatio return sources; }; -// Only circle layers are supported -export const getMapLayers = (layers?: Layer[]): CircleLayerSpecification[] => { - if (!layers) return []; +const getBaseMapLayerConfiguration = (layer: Layer) => { + const { id, source, sourceLayer } = layer; + return { + id, + source, + ...(sourceLayer ? { 'source-layer': sourceLayer } : undefined), + filter: ['==', ['geometry-type'], 'Point'], + }; +}; - return layers.map((layer) => { - const { - id, - type, - source, - sourceLayer, - circleRadius = 7, - circleStrokeWidth = 1.5, - colorMatch, - color: layerColor, - borderColor: layerBorderColor, - } = layer; +const getMapCircleLayer = (layer: CircleLayer): CircleLayerSpecification => { + const { + type, + circleRadius = 7, + circleStrokeWidth = 1.5, + colorMatch, + color: layerColor, + borderColor: layerBorderColor, + } = layer; + + let circleColor: Required['paint']['circle-color'] = layerColor; + let circleBorderColor: Required['paint']['circle-stroke-color'] = + layerBorderColor; - let circleColor: DataDrivenPropertyValueSpecification | Color = layerColor; - let circleBorderColor: DataDrivenPropertyValueSpecification | Color | undefined = - layerBorderColor; + if (colorMatch) { + const { key, colors, borderColors } = colorMatch; + const groupByColors = ['match', ['get', key]]; + Object.keys(colors).forEach((color) => { + groupByColors.push(color, colors[color]); + }); + groupByColors.push(layerColor); + circleColor = groupByColors; - if (colorMatch) { - const { key, colors, borderColors } = colorMatch; - const groupByColors = ['match', ['get', key]]; - Object.keys(colors).forEach((color) => { - groupByColors.push(color, colors[color]); + if (borderColors) { + const groupBordersByColors = ['match', ['get', key]]; + Object.keys(borderColors).forEach((borderColor) => { + groupBordersByColors.push(borderColor, borderColors[borderColor]); }); - groupByColors.push(layerColor); - circleColor = groupByColors; + groupBordersByColors.push(circleBorderColor || DEFAULT_DARK_GREY); + circleBorderColor = groupBordersByColors; + } + } + return { + ...getBaseMapLayerConfiguration(layer), + type, + paint: { + 'circle-radius': [ + 'case', + ['boolean', ['feature-state', 'popup-feature'], false], + circleRadius * 1.3, + circleRadius, + ], + ...(circleBorderColor && { 'circle-stroke-width': circleStrokeWidth }), + 'circle-color': circleColor, + ...(circleBorderColor && { 'circle-stroke-color': circleBorderColor }), + }, + }; +}; + +const getMapSymbolLayer = (layer: SymbolLayer): SymbolLayerSpecification => { + const { type, iconImageId, iconImageMatch } = layer; - if (borderColors) { - const groupBordersByColors = ['match', ['get', key]]; - Object.keys(borderColors).forEach((borderColor) => { - groupBordersByColors.push(borderColor, borderColors[borderColor]); - }); - groupBordersByColors.push(circleBorderColor || DEFAULT_DARK_GREY); - circleBorderColor = groupBordersByColors; - } + let iconImage: Required['layout']['icon-image'] = iconImageId; + if (iconImageMatch) { + const { key, imageIds } = iconImageMatch; + const groupByIconImages = ['match', ['get', key]]; + Object.keys(imageIds).forEach((value) => { + groupByIconImages.push(value, imageIds[value]); + }); + groupByIconImages.push(iconImageId); + iconImage = groupByIconImages; + } + + return { + ...getBaseMapLayerConfiguration(layer), + type, + layout: { + 'icon-allow-overlap': true, + 'icon-image': iconImage, + }, + }; +}; +// Only circle and symbol layers are supported +export const getMapLayers = (layers?: Layer[]): LayerSpecification[] => { + if (!layers) return []; + return layers.map((layer) => { + switch (layer.type) { + case 'circle': + return getMapCircleLayer(layer); + case 'symbol': + return getMapSymbolLayer(layer); + default: + throw new Error(`Unexepected layer type for layer: ${layer}`); } - return { - id, - type, - source, - ...(sourceLayer ? { 'source-layer': sourceLayer } : undefined), - paint: { - 'circle-radius': [ - 'case', - ['boolean', ['feature-state', 'popup-feature'], false], - circleRadius * 1.3, - circleRadius, - ], - ...(circleBorderColor && { 'circle-stroke-width': circleStrokeWidth }), - 'circle-color': circleColor, - ...(circleBorderColor && { 'circle-stroke-color': circleBorderColor }), - }, - filter: ['==', ['geometry-type'], 'Point'], - }; }); }; @@ -111,6 +149,7 @@ export const getMapOptions = (options: PoiMapOptions) => { legend, sourceLink, transformRequest, + images, } = options; return { aspectRatio, @@ -126,6 +165,7 @@ export const getMapOptions = (options: PoiMapOptions) => { legend, sourceLink, transformRequest, + images, }; };