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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8cd8d4e
fix(legend): style fix
NSUWAL123 Oct 24, 2024
2a30ffd
fix(legend): use absolute imports
NSUWAL123 Oct 24, 2024
c3436e4
feat(layer-switcher): layer switcher integration
NSUWAL123 Oct 24, 2024
a9b3eb5
feat(baseLayers): base layer list add
NSUWAL123 Oct 24, 2024
70b0bea
fix(+page): base layer switcher add to component
NSUWAL123 Oct 24, 2024
b4e64de
fix(layer-switcher): close layer switcher on outside click
NSUWAL123 Oct 24, 2024
d930cb4
fix(viteConfig): add alias path to constants
NSUWAL123 Oct 24, 2024
71c4d06
Merge branch 'development' of github.com:hotosm/fmtm into feat/base-l…
NSUWAL123 Oct 24, 2024
ab295a7
fix(baseLayers): update baselayer layer id name
NSUWAL123 Oct 25, 2024
58a2c77
fix(layerSwitcher): preserve user defined layers on baseLayer change
NSUWAL123 Oct 25, 2024
f80f326
Merge branch 'development' of github.com:hotosm/fmtm into feat/base-l…
NSUWAL123 Oct 25, 2024
3d5330d
fix(merge): merge conflict solve
NSUWAL123 Nov 15, 2024
363a08c
fix(viteConfig): utilFunctions alias remove
NSUWAL123 Nov 15, 2024
2f7635e
fix(geolocation): location icon not display fix
NSUWAL123 Nov 15, 2024
d9a8547
fix(main): add props to layer switcher
NSUWAL123 Nov 15, 2024
196752b
fix(main): style fix, prop fix
NSUWAL123 Nov 18, 2024
7bb099e
fix(legend): key event add, style fix
NSUWAL123 Nov 18, 2024
1ffad88
fix(layer-switcher): svelte5 syntax, baselayer-switcher ui change
NSUWAL123 Nov 18, 2024
a4e807b
fix(geolocation): geolocation layer id change
NSUWAL123 Nov 18, 2024
9d5ebb6
Merge branch 'development' of github.com:hotosm/fmtm into feat/base-l…
NSUWAL123 Nov 18, 2024
e091db6
fix(baseLayers): remove duplicate osm layer
NSUWAL123 Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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