From baa7bb4ecf9d36b26da10f11fd2ccfb9e46cc970 Mon Sep 17 00:00:00 2001
From: Christian Beutel <>
Date: Sat, 12 Jul 2025 20:37:51 +0200
Subject: [PATCH] add overlay layers
---
.../lib/components/map/style_switcher.svelte | 81 ++++++++++
.../trail/map_with_elevation_maplibre.svelte | 97 +++++++-----
web/src/lib/util/maplibre_util.ts | 39 ++++-
.../style-switcher-control.ts | 140 +++---------------
4 files changed, 199 insertions(+), 158 deletions(-)
create mode 100644 web/src/lib/components/map/style_switcher.svelte
diff --git a/web/src/lib/components/map/style_switcher.svelte b/web/src/lib/components/map/style_switcher.svelte
new file mode 100644
index 00000000..9d22736b
--- /dev/null
+++ b/web/src/lib/components/map/style_switcher.svelte
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+ Map style
+
+
+
settings.onMapStyleSwitch(settings.styles.indexOf(style), style)}
+ >
+
+
Layers
+ {#each settings.layers as l, i}
+
+ {
+ const checked = (e.target as HTMLInputElement).checked;
+ settings.onLayerChange(checked, l);
+ }}
+ />
+
+
+
+ {/each}
+
+
diff --git a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte
index 5c33c90a..5afe212e 100644
--- a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte
+++ b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte
@@ -9,18 +9,24 @@
import { findStartAndEndPoints } from "$lib/util/geojson_util";
import { toGeoJson } from "$lib/util/gpx_util";
import {
+ baseMapStyles,
createMarkerFromWaypoint,
createPopupFromTrail,
FontawesomeMarker,
+ overlayLayers,
} from "$lib/util/maplibre_util";
import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control";
import { FullscreenControl } from "$lib/vendor/maplibre-fullscreen/fullscreen-control";
import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule";
- import { StyleSwitcherControl } from "$lib/vendor/maplibre-style-switcher/style-switcher-control";
+ import {
+ StyleSwitcherControl,
+ type StyleSwitcherControlOptions,
+ } from "$lib/vendor/maplibre-style-switcher/style-switcher-control";
import type { Feature, FeatureCollection, GeoJSON } from "geojson";
import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { onDestroy, onMount, untrack } from "svelte";
+ import type { RadioItem } from "../base/radio_group.svelte";
interface Props {
trails?: Trail[];
@@ -92,6 +98,8 @@
let epc: ElevationProfileControl;
let graticule: MaplibreGraticule;
+ let mapState: StyleSwitcherControlOptions["state"];
+
let layers: Record<
string,
{
@@ -237,6 +245,16 @@
}
});
+ if (mapLoaded) {
+ for (const [k, v] of Object.entries(mapState.selectedLayers)) {
+ if (v) {
+ const layer = overlayLayers.find((l) => l.text === k);
+ if (!layer) continue;
+ addOverlayLayer(layer);
+ }
+ }
+ }
+
if (
!drawing &&
fitBounds !== "off" &&
@@ -864,36 +882,13 @@
value: t.url,
}),
),
- {
- text: "OpenFreeMap",
- value: "/styles/ofm.json",
- thumbnail: "https://tile.openstreetmap.org/1/0/0.png",
- },
- {
- text: "OpenTopoMap",
- value: "/styles/otm.json",
- thumbnail: "https://tile.opentopomap.org/1/0/0.png",
- },
- {
- text: "Carto Light",
- value: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
- thumbnail:
- "https://basemaps.cartocdn.com/light_all/1/0/0@2x.png",
- },
- {
- text: "Carto Dark",
- value: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
- thumbnail:
- "https://basemaps.cartocdn.com/dark_all/1/0/0@2x.png",
- },
+ ...baseMapStyles,
];
- let preferredMapStyleIndex = mapStyles.findIndex(
- (s) => s.text === localStorage.getItem("layer"),
- );
- if (preferredMapStyleIndex == -1) {
- preferredMapStyleIndex = 0;
- }
+ mapState = JSON.parse(
+ localStorage.getItem("map-state") ??
+ '{"selectedLayers": {}, "selectedStyle": 0}',
+ );
if (!mapContainer) {
return;
@@ -902,9 +897,7 @@
const finalMapOptions: M.MapOptions = {
...{
container: mapContainer,
- style:
- mapStyles[preferredMapStyleIndex].value ??
- mapStyles[0].value,
+ style: mapStyles[mapState.selectedStyle].value,
center: [initialState.lng, initialState.lat],
zoom: initialState.zoom,
},
@@ -932,14 +925,26 @@
const switcherControl = new StyleSwitcherControl({
styles: mapStyles,
- onSwitch: (style) => {
+ layers: overlayLayers,
+ onMapStyleSwitch: (i, style) => {
layers = {};
map?.setStyle(style.value);
- localStorage.setItem("layer", style.text);
+ mapState.selectedStyle = i;
+ localStorage.setItem("map-state", JSON.stringify(mapState));
},
- selectedIndex:
- preferredMapStyleIndex !== -1 ? preferredMapStyleIndex : 0,
+ onLayerChange: (checked, layer) => {
+ mapState.selectedLayers[layer.text] = checked;
+ localStorage.setItem("map-state", JSON.stringify(mapState));
+
+ if (checked) {
+ addOverlayLayer(layer);
+ } else {
+ removeOverlayLayer(layer);
+ }
+ },
+ state: mapState,
});
+
map.addControl(
new M.NavigationControl({ visualizePitch: showTerrain }),
);
@@ -1176,6 +1181,26 @@
}
}
}
+
+ function addOverlayLayer(layer: RadioItem) {
+ map?.addSource(layer.text + "overlay-source", {
+ type: "raster",
+ tiles: [layer.value],
+ tileSize: 256,
+ });
+
+ map?.addLayer({
+ id: layer.text + "overlay-layer",
+ type: "raster",
+ source: layer.text + "overlay-source",
+ minzoom: 4,
+ });
+ }
+
+ function removeOverlayLayer(layer: RadioItem) {
+ map?.removeLayer(layer.text + "overlay-layer");
+ map?.removeSource(layer.text + "overlay-source");
+ }
diff --git a/web/src/lib/util/maplibre_util.ts b/web/src/lib/util/maplibre_util.ts
index 5da186f5..b4003187 100644
--- a/web/src/lib/util/maplibre_util.ts
+++ b/web/src/lib/util/maplibre_util.ts
@@ -7,10 +7,47 @@ import { theme } from "$lib/stores/theme_store";
import M from "maplibre-gl";
import { _ } from "svelte-i18n";
import { get } from "svelte/store";
+import { handleFromRecordWithIRI } from "./activitypub_util";
import { getFileURL } from "./file_util";
import { formatDistance, formatElevation, formatTimeHHMM } from "./format_util";
import { icons } from "./icon_util";
-import { handleFromRecordWithIRI } from "./activitypub_util";
+import type { RadioItem } from "$lib/components/base/radio_group.svelte";
+
+
+export const baseMapStyles: RadioItem[] = [
+ {
+ text: "OpenFreeMap",
+ value: "/styles/ofm.json",
+ },
+ {
+ text: "OpenTopoMap",
+ value: "/styles/otm.json",
+ },
+ {
+ text: "Carto Light",
+ value: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
+ },
+ {
+ text: "Carto Dark",
+ value: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
+ },
+]
+
+export const overlayLayers: RadioItem[] = [
+ {
+ text: "hiking",
+ value: "https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png",
+ },
+ {
+ text: "cycling",
+ value: "https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png",
+ },
+ {
+ text: "MTB",
+ value: "https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png",
+ },
+]
+
export class FontawesomeMarker extends M.Marker {
constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string, id?: string }, markerOptions?: M.MarkerOptions) {
diff --git a/web/src/lib/vendor/maplibre-style-switcher/style-switcher-control.ts b/web/src/lib/vendor/maplibre-style-switcher/style-switcher-control.ts
index b25889fe..f52e1406 100644
--- a/web/src/lib/vendor/maplibre-style-switcher/style-switcher-control.ts
+++ b/web/src/lib/vendor/maplibre-style-switcher/style-switcher-control.ts
@@ -1,27 +1,25 @@
-import * as M from "maplibre-gl";
+import type { RadioItem } from "$lib/components/base/radio_group.svelte";
+import StyleSwitcher from "$lib/components/map/style_switcher.svelte";
import { type ControlPosition, type IControl } from "maplibre-gl";
+import { mount } from "svelte";
/**
* Style switcher control options
*/
-type MapStyle = {
- text: string;
- value: string;
- thumbnail?: string;
-}
-
export type StyleSwitcherControlOptions = {
- styles: MapStyle[];
- onSwitch: (style: MapStyle) => void
- selectedIndex: number;
+ styles: RadioItem[];
+ layers: RadioItem[];
+ onMapStyleSwitch: (index: number, style: RadioItem) => void
+ onLayerChange: (checked: boolean, layer: RadioItem) => void
+
+ state: {
+ selectedStyle: number;
+ selectedLayers: Record
+ };
};
export class StyleSwitcherControl implements IControl {
- private buttonContainer?: HTMLDivElement;
- private toggleButton?: HTMLButtonElement;
- private isSwitcherShown = false;
- private iconSpan?: HTMLSpanElement;
- private styleLis: HTMLLIElement[] = [];
+ private container?: HTMLElement;
private switcherContainer?: HTMLDivElement;
private settings: StyleSwitcherControlOptions;
@@ -36,117 +34,17 @@ export class StyleSwitcherControl implements IControl {
return this.switcherContainer;
}
- onAdd(map: M.Map): HTMLElement {
- this.buttonContainer = document.createElement("div");
-
- this.buttonContainer.classList.add(
- "maplibregl-ctrl",
- "maplibregl-ctrl-group",
- "relative",
- "z-10"
- );
- this.toggleButton = document.createElement("button");
- this.buttonContainer.appendChild(this.toggleButton);
- // this.buttonContainer.classList.add("w-16", "aspect-square")
- this.iconSpan = document.createElement("i");
- this.iconSpan.classList.add("fa", "fa-layer-group", "text-black");
- this.toggleButton.appendChild(this.iconSpan);
- this.toggleButton.addEventListener("click", this.toggleSwitcher.bind(this));
-
-
- this.switcherContainer = document.createElement("div");
- this.switcherContainer.style.setProperty("display", "none");
- this.switcherContainer.classList.add("bg-menu-background", "rounded-lg", "py-3", "mt-2", "shadow-xl")
- this.switcherContainer.style.setProperty("position", "absolute");
- this.switcherContainer.style.setProperty("transform", "translateX(calc(-100% + 29px))");
- // this.switcherContainer.style.setProperty("max-width", "120px");
-
- const headingDiv = document.createElement("div");
- headingDiv.classList.add("flex", "items-center", "gap-x-12", "px-3");
- const closeButton = document.createElement("button");
- closeButton.classList.add("fa", "fa-close", "!rounded-full");
- closeButton.addEventListener("click", () => this.hideSwitcher());
- const switcherContainerHeading = document.createElement("span")
- switcherContainerHeading.classList.add("text-lg", "font-semibold", "whitespace-nowrap")
- switcherContainerHeading.textContent = "Select map style"
- headingDiv.appendChild(switcherContainerHeading);
- headingDiv.appendChild(closeButton);
-
- this.switcherContainer.appendChild(headingDiv)
-
- const buttonDiv = document.createElement("ul")
- buttonDiv.classList.add("mt-2", "max-h-64", "overflow-y-scroll");
- this.settings.styles.forEach((style, i) => {
- const styleLi = document.createElement("li");
- styleLi.classList.add("flex", "items-center", "gap-x-4", "px-3", "py-2", "cursor-pointer", "hover:bg-menu-item-background-hover")
- if (style.thumbnail) {
- const styleImg = document.createElement("img");
- styleImg.classList.add("w-12", "h-12", "rounded-md")
- styleImg.src = style.thumbnail;
- styleLi.appendChild(styleImg)
- } else {
- const styleIconContainer = document.createElement("i");
- styleIconContainer.classList.add("w-12", "h-12", "rounded-md", "bg-blue-200", "flex", "items-center", "justify-center")
- const styleIcon = document.createElement("i");
- styleIcon.classList.add("fa", "fa-map-location-dot", "text-xl", "text-black")
- styleIconContainer.appendChild(styleIcon)
- styleLi.appendChild(styleIconContainer)
- }
-
- const styleName = document.createElement("span");
- styleName.classList.add("!text-sm")
- if (i == this.settings.selectedIndex) {
- styleName.classList.add("font-semibold")
- }
- styleName.textContent = style.text;
- styleLi.appendChild(styleName)
-
- styleLi.addEventListener("click", () => {
- this.hideSwitcher();
- this.styleLis?.at(this.settings.selectedIndex)?.lastElementChild?.classList.remove("font-semibold")
- this.settings.selectedIndex = i;
- styleName.classList.add("font-semibold")
- this.settings.onSwitch(style)
- })
-
- this.styleLis?.push(styleLi)
- buttonDiv.appendChild(styleLi)
- })
- this.switcherContainer.appendChild(buttonDiv)
- this.buttonContainer.appendChild(this.switcherContainer);
-
-
- return this.buttonContainer;
- }
-
- private toggleSwitcher() {
- if (!this.switcherContainer) return;
-
- if (this.isSwitcherShown) {
- this.hideSwitcher();
- } else {
- this.showSwitcher();
- }
- }
-
- showSwitcher() {
- this.switcherContainer?.style.setProperty("display", "inherit");
- this.isSwitcherShown = true;
- }
-
- hideSwitcher() {
- this.switcherContainer?.style.setProperty("display", "none");
- this.isSwitcherShown = false;
+ onAdd(): HTMLElement {
+ this.container = document.createElement('div');
+ mount(StyleSwitcher, { target: this.container, props: { settings: this.settings } });
+ return this.container;
}
onRemove(): void {
// remove button
- if (this.buttonContainer?.parentNode) {
- this.buttonContainer.parentNode.removeChild(this.buttonContainer);
+ if (this.container?.parentNode) {
+ this.container.parentNode.removeChild(this.container);
}
- this.buttonContainer = undefined;
- this.toggleButton = undefined;
- this.isSwitcherShown = false;
}
getDefaultPosition?: (() => ControlPosition) | undefined;