Skip to content

Commit

Permalink
feat(mapper): basemap layer switcher integration for maplibre (#1835)
Browse files Browse the repository at this point in the history
* fix(legend): style fix

* fix(legend): use absolute imports

* feat(layer-switcher): layer switcher integration

* feat(baseLayers): base layer list add

* fix(+page): base layer switcher add to component

* fix(layer-switcher): close layer switcher on outside click

* fix(viteConfig): add alias path to constants

* fix(baseLayers): update baselayer layer id name

* fix(layerSwitcher): preserve user defined layers on baseLayer change

* fix(viteConfig): utilFunctions alias remove

* fix(geolocation): location icon not display fix

* fix(main): add props to layer switcher

* fix(main): style fix, prop fix

* fix(legend): key event add, style fix

* fix(layer-switcher): svelte5 syntax, baselayer-switcher ui change

* fix(geolocation): geolocation layer id change

* fix(baseLayers): remove duplicate osm layer
  • Loading branch information
NSUWAL123 authored Nov 18, 2024
1 parent 20dc13b commit 233ea91
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 43 deletions.
78 changes: 78 additions & 0 deletions src/mapper/src/constants/baseLayers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
let stamenStyle = {
id: 'Stamen Raster',
version: 8,
name: 'Black & White',
sources: {
stamen: {
type: 'raster',
tiles: ['https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png'],
minzoom: 0,
maxzoom: 19,
attribution: `© <a href="https://stadiamaps.com/" target="_blank">Stadia Maps</a> <a href="https://stamen.com/" target="_blank">
© Stamen Design</a>
© <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a>
`,
},
},
layers: [
{
id: 'stamen',
type: 'raster',
source: 'stamen',
layout: {
visibility: 'visible',
},
},
],
};

let esriStyle = {
id: 'ESRI Raster',
version: 8,
name: 'ESRI',
sources: {
esri: {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}'],
minzoom: 0,
maxzoom: 19,
attribution: '© ESRI',
},
},
layers: [
{
id: 'esri',
type: 'raster',
source: 'esri',
layout: {
visibility: 'visible',
},
},
],
};

let satellite = {
id: 'Satellite',
version: 8,
name: 'Satellite',
sources: {
satellite: {
type: 'raster',
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
tileSize: 256,
attribution: 'ArcGIS',
},
},
layers: [
{
id: 'satellite',
type: 'raster',
source: 'satellite',
layout: {
visibility: 'visible',
},
},
],
};

export const baseLayers = [stamenStyle, esriStyle, satellite];
43 changes: 19 additions & 24 deletions src/mapper/src/lib/components/map/geolocation.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script lang="ts">
import { GeoJSON, SymbolLayer } from 'svelte-maplibre';
import type { FeatureCollection } from 'geojson';
import { GeoJSON, SymbolLayer } from 'svelte-maplibre';
import type { FeatureCollection } from 'geojson';
import { GetDeviceRotation } from '$lib/utils/getDeviceRotation';
import { GetDeviceRotation } from '$lib/utils/getDeviceRotation';
import { getAlertStore } from '$store/common.svelte.ts';
const alertStore = getAlertStore();
Expand All @@ -12,10 +12,7 @@
toggleGeolocationStatus?: boolean;
}
let {
map=$bindable(),
toggleGeolocationStatus = $bindable(false),
}: Props = $props();
let { map = $bindable(), toggleGeolocationStatus = $bindable(false) }: Props = $props();
let coords: [number, number] | undefined = $state();
let rotationDeg: number | undefined = $state();
Expand Down Expand Up @@ -75,7 +72,6 @@
},
],
});
$effect(() => {
if (map && toggleGeolocationStatus) {
Expand Down Expand Up @@ -112,20 +108,19 @@
});
</script>

<GeoJSON data={locationGeojson} id="point">
<SymbolLayer
applyToClusters={false}
hoverCursor="pointer"
layout={{
// if orientation true (meaning the browser supports device orientation sensor show location dot with orientation sign)
'icon-image': ['case', ['==', ['get', 'orientation'], true], 'locationArc', 'locationDot'],
'icon-allow-overlap': true,
'text-field': '{mag}',
'text-offset': [0, -2],
'text-size': 12,
'icon-rotate': rotationDeg || 0, // rotate location icon acc to device orientation
'icon-rotation-alignment': 'map',
'icon-size': 0.5,
}}
/>
<GeoJSON data={locationGeojson} id="geolocation">
<SymbolLayer
applyToClusters={false}
hoverCursor="pointer"
layout={{
// if orientation true (meaning the browser supports device orientation sensor show location dot with orientation sign)
'icon-image': ['case', ['==', ['get', 'orientation'], true], 'locationArc', 'locationDot'],
'icon-allow-overlap': true,
'text-offset': [0, -2],
'text-size': 12,
'icon-rotate': rotationDeg || 0, // rotate location icon acc to device orientation
'icon-rotation-alignment': 'map',
'icon-size': 0.5,
}}
/>
</GeoJSON>
222 changes: 209 additions & 13 deletions src/mapper/src/lib/components/map/layer-switcher.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,218 @@
<script>
import { clickOutside } from '$lib/utils/clickOutside.ts';
<!-- A component to render a style selection prompt, including a thumbnail
preview of the style content.
let isOpen = $state(false);
Currently a thumbnail is render only for raster style types.
We should be able to handle:
- RasterDEMSourceSpecification
- RasterSourceSpecification
- VectorSourceSpecification
To achieve this and make it more flexible, it would probably be best
to render a MapLibre minimap for each style, allowing the library
to handle the parsing of URLs and rendering. The zoom could be
set to the minZoom to display a thumbnail image.
E.g.
```
map = new Map({
container: div,
style: uri,
attributionControl: false,
interactive: false
});
``` -->

<script lang="ts">
import { onDestroy } from 'svelte';
import { clickOutside } from '$lib/utils/clickOutside';
type Props = {
extraStyles: maplibregl.StyleSpecification[];
map: maplibregl.Map | undefined;
sourcesIdToReAdd: string[];
};
const { extraStyles, map, sourcesIdToReAdd }: Props = $props();
let allStyles: MapLibreStylePlusMetadata[] | [] = $state([]);
let selectedStyleUrl: string | undefined = $state(undefined);
let isClosed = $state(true);
let isOpen = $state(true);
$effect(() => {
if (extraStyles.length > 0) {
fetchStyleInfo();
} else {
allStyles = [];
}
});
type MapLibreStylePlusMetadata = maplibregl.StyleSpecification & {
metadata: {
thumbnail?: string;
};
};
/**
* Extract the raster thumbnail root tile, or return an empty string.
*/
function getRasterThumbnailUrl(style: maplibregl.StyleSpecification): string {
const rasterSource = Object.values(style.sources).find((source) => source.type === 'raster') as
| maplibregl.RasterSourceSpecification
| undefined;
if (!rasterSource || !rasterSource.tiles?.length) {
const placeholderSvg = `
data:image/svg+xml,<svg id="map_placeholder" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 105.93 122.88">
<defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>map</title><path class="cls-1"
d="M56.92,73.14a1.62,1.62,0,0,1-1.86.06A65.25,65.25,0,0,1,38.92,58.8,51.29,51.29,0,0,1,28.06,35.37C26.77,27.38,28,19.7,32,13.45a27,27,
0,0,1,6-6.66A29.23,29.23,0,0,1,56.36,0,26,26,0,0,1,73.82,7.12a26,26,0,0,1,4.66,5.68c4.27,7,5.19,16,3.31,25.12A55.29,55.29,0,0,1,56.92,
73.14Zm-19,.74V101.7l30.15,13V78.87a65.17,65.17,0,0,0,6.45-5.63v41.18l25-12.59v-56l-9.61,3.7a61.61,61.61,0,0,0,2.38-7.81l9.3-3.59A3.22,
3.22,0,0,1,105.7,40a3.18,3.18,0,0,1,.22,1.16v62.7a3.23,3.23,0,0,1-2,3L72.72,122.53a3.23,3.23,0,0,1-2.92,0l-35-15.17L4.68,122.53a3.22,
3.22,0,0,1-4.33-1.42A3.28,3.28,0,0,1,0,119.66V53.24a3.23,3.23,0,0,1,2.32-3.1L18.7,43.82a58.63,58.63,0,0,0,2.16,6.07L6.46,
55.44v59l25-12.59V67.09a76.28,76.28,0,0,0,6.46,6.79ZM55.15,14.21A13.72,13.72,0,1,1,41.43,27.93,13.72,13.72,0,0,1,55.15,14.21Z"/></svg>`;
return placeholderSvg;
}
const firstTileUrl = rasterSource.tiles[0];
const minzoom = rasterSource.minzoom || 0;
return firstTileUrl.replace('{x}', '0').replace('{y}', '0').replace('{z}', minzoom.toString());
}
/**
* Process the style to add metadata and return it.
*/
function processStyle(style: maplibregl.StyleSpecification): MapLibreStylePlusMetadata {
const thumbnailUrl = getRasterThumbnailUrl(style);
return {
...style,
metadata: {
...style.metadata,
thumbnail: thumbnailUrl,
},
};
}
/**
* Fetch styles and prepare them with thumbnails.
*/
async function fetchStyleInfo() {
const currentMapStyle = map?.getStyle();
if (currentMapStyle) {
const processedStyle = processStyle(currentMapStyle);
selectedStyleUrl = processedStyle?.metadata?.thumbnail || undefined;
allStyles = [processedStyle];
}
const extraProcessedStyles = await Promise.all(
extraStyles.map(async (style) => {
if (typeof style === 'string') {
const styleResponse = await fetch(style);
const styleJson = await styleResponse.json();
return processStyle(styleJson);
} else {
return processStyle(style);
}
}),
);
allStyles = allStyles.concat(extraProcessedStyles);
}
function selectStyle(style: MapLibreStylePlusMetadata) {
// returns all the map style i.e. all layers, sources
const currentMapStyle = map?.getStyle();
// reAddLayers: user defined layers that needs to be preserved
const reAddLayers = currentMapStyle?.layers?.filter((layer) => {
return sourcesIdToReAdd?.includes(layer?.source);
});
// reAddSources: user defined sources that needs to be preserved
const reAddSources = Object?.entries(currentMapStyle?.sources)
?.filter(([key]) => sourcesIdToReAdd?.includes(key))
?.map(([key, value]) => ({ [key]: value }));
selectedStyleUrl = style.metadata.thumbnail;
// changes to selected base layer (note: user defined layer and sources are lost)
map?.setStyle(style);
isClosed = !isClosed;
// reapply user defined source
if (reAddSources?.length > 0) {
for (const reAddSource of reAddSources) {
for (const [id, source] of Object.entries(reAddSource)) {
map?.addSource(id, source);
}
}
}
// reapply user defined layers
if (reAddLayers?.length > 0) {
for (const layer of reAddLayers) {
map?.addLayer(layer);
}
}
}
onDestroy(() => {
allStyles = [];
selectedStyleUrl = undefined;
isClosed = true;
});
</script>

<div use:clickOutside onclick_outside={() => (isOpen = false)} class="relative">
<div class="group absolute bottom-16 right-0 text-nowrap cursor-pointer" onclick={() => (isOpen = !isOpen)}>
<hot-icon
style="border: 1px solid #D7D7D7;"
name="layer"
class={`!text-[1.7rem] text-[#333333] bg-white p-2 rounded-full group-hover:text-red-600 duration-200 ${isOpen && 'text-red-600'}`}
></hot-icon>
<div class="relative" use:clickOutside onclick_outside={() => (isOpen = false)}>
<div
onclick={() => (isOpen = !isOpen)}
role="button"
onkeydown={(e) => {
if (e.key === 'Enter') {
isOpen = !isOpen;
}
}}
tabindex="0"
>
<img
style="border: 1px solid #d73f3f;"
class="w-[2.824rem] h-[2.824rem] rounded-full"
src={selectedStyleUrl}
alt="Basemap Icon"
/>
</div>
<div
class={`absolute bottom-16 right-14 w-[10rem] bg-white rounded-md p-4 duration-200 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'} overflow-hidden`}
class={`absolute bottom-0 right-14 bg-white rounded-md p-4 duration-200 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
Layer Switcher
<p class="font-barlow-semibold text-lg mb-2">Base Maps</p>
<div class="grid grid-cols-2 w-[212px] gap-3">
{#each allStyles as style, _}
<div
class={`layer-card ${selectedStyleUrl === style.metadata.thumbnail ? 'active' : ''} h-[3.75rem] relative overflow-hidden rounded-md cursor-pointer hover:border-red-600`}
onclick={() => selectStyle(style)}
role="button"
onkeydown={(e) => {
if (e.key === 'Enter') selectStyle(style);
}}
tabindex="0"
>
<img src={style.metadata.thumbnail} alt="Style Thumbnail" class="w-full h-full object-cover" />
<span class="absolute top-0 left-0 bg-white bg-opacity-80 px-1 rounded-br">{style.name}</span>
</div>{/each}
</div>
</div>
</div>

<style></style>
<style>
.layer-card {
border: 2px solid white;
}
.layer-card:hover {
border-color: #d73f3f;
transition-duration: 200ms;
}
.active {
border-color: #d73f3f;
}
</style>
Loading

0 comments on commit 233ea91

Please sign in to comment.