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

Add StyleSelectControl element #206

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/odd-beers-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte-maplibre": minor
---

add StyleSelectControl element with basic thumbnail logic
2 changes: 1 addition & 1 deletion src/lib/ControlButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
export { classNames as class };
</script>

<button type="button" {title} on:click>
<button type="button" {title} on:click {...$$props}>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use ...$$restProps here instead. Also did it not work to just pass rounded-full in the class property?

<div class:maplibregl-ctrl-icon={icon} class:ctrl-btn-center={center} class={classNames}>
<slot />
</div>
Expand Down
239 changes: 239 additions & 0 deletions src/lib/StyleSelectControl.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
<!-- A component to render a style selection prompt, including a thumbnail

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move the JS script in this file to a .js file instead of a .sveltefile?

It will be much easier to integrate into other frontend frameworks (e.g. React) if the MapLibre lifecycle management were done in pure JS.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a good idea!

Do you mean to move this logic perhaps into styles.ts & export the functions for use in this component:

https://github.com/dimfeld/svelte-maplibre/blob/c39d6422cf2b68f5c198c579fde3bbfecac140a3/src/lib/StyleSelectControl.svelte#L46C4-L123C4

Then the component will basically just accept props and pass data to these functions - is that right?

preview of the style content.

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 { mapContext } from './context';
import Control from '$lib/Control.svelte';
import ControlButton from '$lib/ControlButton.svelte';

export let position: maplibregl.ControlPosition = 'top-right';
export let expandDirection: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
export let extraStyles: maplibregl.StyleSpecification[] = [];

const { map } = mapContext();
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) {
selectedStyleUrl = style.metadata.thumbnail;
$map?.setStyle(style);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug, as it would actually remove any additional layers added to the map.

If styles is used to load say a vector layer and a vector tile layer on top of the basemap raster layers. We would override the vector styles with only the basemap style.

Needs a fix before merge!

isClosed = true;
}

onDestroy(() => {
allStyles = [];
selectedStyleUrl = undefined;
isClosed = true;
});
</script>

<Control {position}>
<div
tabindex="-1"
role="button"
class={`style-control ${expandDirection} ${isClosed ? 'closed' : 'open'}`}
on:mouseenter={() => (isClosed = false)}
on:mouseleave={() => (isClosed = true)}
>
{#each allStyles as style, _}
<button
class="style-selector {selectedStyleUrl === style.metadata.thumbnail ? 'active' : ''}"
on:click={() => selectStyle(style)}
>
<ControlButton style="border-radius: 50%;">
<img src={style.metadata.thumbnail} alt="Style Thumbnail" class="basemap" />
<span class="tooltip {position.includes('top') ? 'tooltip-bottom' : 'tooltip-top'}">
{style.name}
</span>
</ControlButton>
</button>
{/each}
</div>
</Control>

<style>
.style-control {
display: flex;
position: relative;
}

.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;
margin: 0.625rem;
border-radius: 50%;
position: relative;
}

.style-selector .basemap {
width: 4rem;
height: 4rem;
border-radius: 50%;
box-shadow: 0 0.0625rem 0.3125rem rgba(0, 0, 0, 0.65);
border: 0.125rem solid #ccc;
}

.style-selector.active .basemap {
border-color: orange;
box-shadow: 0.125rem 0.125rem 0.25rem #000;
}

.style-selector:hover .basemap {
box-shadow: 0.125rem 0.125rem 0.25rem #000;
}

.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: white;
border: 0.0625rem solid #ddd;
box-shadow: 0.0625rem 0.0625rem 0.0625rem #ddd;
border-radius: 0.25rem;
padding: 0.25rem;
z-index: 100;
pointer-events: none;
}

.tooltip-top {
bottom: 110%;
}

.tooltip-bottom {
top: 110%;
}

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

.style-control.open .style-selector {
display: flex;
}
</style>
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export { default as RasterLayer } from './RasterLayer.svelte';
export { default as RasterDEMTileSource } from './RasterDEMTileSource.svelte';
export { default as RasterTileSource } from './RasterTileSource.svelte';
export { default as ScaleControl } from './ScaleControl.svelte';
export { default as StyleSelectControl } from './StyleSelectControl.svelte';
export { default as SymbolLayer } from './SymbolLayer.svelte';
export { default as Terrain } from './Terrain.svelte';
export { default as TerrainControl } from './TerrainControl.svelte';
Expand Down
1 change: 1 addition & 0 deletions src/routes/NavBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
{ href: '/examples/geojson_line_layer', title: `Styled Line` },
{ href: '/examples/heatmap', title: `Heatmap` },
{ href: '/examples/controls', title: `Controls` },
{ href: '/examples/style_selector', title: `Style Selector` },
{ href: '/examples/clusters', title: `Clusters and Popups` },
{ href: '/examples/custom_marker_clusters', title: `Custom Markers and Clusters` },
{ href: '/examples/image_symbols', title: `Images in a Symbol Layer` },
Expand Down
108 changes: 108 additions & 0 deletions src/routes/examples/style_selector/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<script lang="ts">
import MapLibre from '$lib/MapLibre.svelte';
import { mapClasses } from '../styles.js';
import code from './+page.svelte?raw';
import CodeSample from '$site/CodeSample.svelte';
import StyleSelectControl from '$lib/StyleSelectControl.svelte';

const osmStyle = {
id: 'OSM Raster',
version: 8,
name: 'OpenStreetMap',
sources: {
osm: {
type: 'raster',
tiles: [
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
],
minzoom: 0,
maxzoom: 19,
attribution:
'© <a target="_blank" rel="noopener" href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>',
},
},
layers: [
{
id: 'background',
type: 'raster',
source: 'osm',
layout: {
visibility: 'visible',
},
},
],
};

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: 'background',
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: 'background',
type: 'raster',
source: 'esri',
layout: {
visibility: 'visible',
},
},
],
};
</script>

<p>Select a different style by hovering over the map thumbnail.</p>

<MapLibre style={osmStyle} class={mapClasses} center={[-120, 50]} zoom={2} standardControls>
<StyleSelectControl
position="bottom-left"
expandDirection="right"
extraStyles={[
esriStyle,
stamenStyle,
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
]}
/>
</MapLibre>

<CodeSample {code} />
7 changes: 7 additions & 0 deletions src/routes/examples/style_selector/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';

export const load: PageLoad = () => {
return {
title: 'Style Selector',
};
};