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;