Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Base layer switcher integration #1835

Draft
wants to merge 11 commits into
base: development
Choose a base branch
from
105 changes: 105 additions & 0 deletions src/mapper/src/constants/baseLayers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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 osm = {
id: 'OSM',
version: 8,
name: 'OSM',
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
// minzoom: 0,
// maxzoom: 19,
tileSize: 256,
attribution:
'Map tiles by <a target="_top" rel="noopener" href="https://tile.openstreetmap.org/">OpenStreetMap tile servers</a>, under the <a target="_top" rel="noopener" href="https://operations.osmfoundation.org/policies/tiles/">tile usage policy</a>. Data by <a target="_top" rel="noopener" href="http://openstreetmap.org">OpenStreetMap</a>',
},
},
layers: [
{
id: 'osm',
type: 'raster',
source: 'osm',
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, osm, satellite];
282 changes: 265 additions & 17 deletions src/mapper/src/lib/components/page/layer-switcher.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,270 @@
<script>
import { clickOutside } from '../../../utilFunctions/clickOutside.ts';
<!-- A component to render a style selection prompt, including a thumbnail
preview of the style content.

let isOpen = 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 '$utilFunctions/clickOutside.ts';

export let position: maplibregl.ControlPosition = 'top-right';
export let expandDirection: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
export let extraStyles: maplibregl.StyleSpecification[] = [];
export let map: maplibregl.Map | undefined;
export let sourcesIdToReAdd: string[] = []; // source id and layer source that needs to be preserved

let allStyles: MapLibreStylePlusMetadata[] | [] = [];
let selectedStyleUrl: string | undefined = undefined;
let isClosed = true;

$: 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();
console.log(currentMapStyle, 'currentMapStyle');

// 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 on:click_outside={() => (isOpen = false)} class="relative">
<div class="group absolute bottom-16 right-0 text-nowrap cursor-pointer" on:click={() => (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>
<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`}
>
Layer Switcher
</div>
<div
use:clickOutside
on:click_outside={() => (isClosed = true)}
tabindex="-1"
role="button"
class={`style-control ${expandDirection} ${isClosed ? 'closed' : 'open'}`}
>
{#each allStyles as style, _}
<button
class="style-selector {selectedStyleUrl === style.metadata.thumbnail ? 'active' : ''}"
on:click={() => selectStyle(style)}
>
<img src={style.metadata.thumbnail} alt="Style Thumbnail" class="basemap" />
<span class="tooltip {position.includes('top') ? 'tooltip-bottom' : 'tooltip-top'}">
{style.name}
</span>
</button>
{/each}
</div>

<style></style>
<style>
.style-control {
display: flex;
position: relative;
animation-duration: 200ms;
gap: 5px;
}

.style-control.right {
flex-direction: row;
}

.style-control.left {
flex-direction: row-reverse;
}

.style-control.bottom {
flex-direction: column;
}

.style-control.top {
flex-direction: column-reverse;
}

.style-selector {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 0;
position: relative;
background-color: transparent;
padding: 0;
}

.style-selector:hover {
background-color: transparent;
}

.style-selector .basemap {
width: 2.7rem;
height: 2.7rem;
border-radius: 50%;
border: 1px solid #ccc;
background-color: white;
}

.style-selector.active .basemap {
border-color: #d73f3f;
}

.style-selector:hover .tooltip {
opacity: 1;
}

.style-selector .tooltip {
opacity: 0;
transition: opacity 0.3s ease;
position: absolute;
left: 50%;
transform: translateX(-50%);
background-color: rgb(104, 104, 104);
color: white;
box-shadow: 0.0625rem 0.0625rem 0.0625rem #ddd;
border-radius: 0.25rem;
padding: 0.25rem;
z-index: 100;
pointer-events: none;
text-wrap: nowrap;
}

.tooltip-top {
bottom: 110%;
}

.tooltip-bottom {
top: 110%;
}

.style-control.closed .style-selector:not(.active) {
display: none;
transition-duration: 200ms;
}

.style-control.open .style-selector {
display: flex;
transition-duration: 200ms;
}
</style>
Loading