From eac9b2e4894e260840e8cb41ca91cddfa1685b0a Mon Sep 17 00:00:00 2001 From: Christian Beutel <> Date: Sun, 13 Jul 2025 22:22:08 +0200 Subject: [PATCH] adds layer manager --- web/src/lib/assets/svgs/pois/bakery.svg | 3 + .../lib/components/base/radio_group.svelte | 2 +- .../lib/components/map/style_switcher.svelte | 67 +++-- .../trail/map_with_elevation_maplibre.svelte | 87 ++---- web/src/lib/i18n/locales/de.json | 3 + web/src/lib/i18n/locales/en.json | 3 + web/src/lib/i18n/locales/es.json | 3 + web/src/lib/i18n/locales/eu.json | 3 + web/src/lib/i18n/locales/fr.json | 3 + web/src/lib/i18n/locales/hu.json | 3 + web/src/lib/i18n/locales/it.json | 3 + web/src/lib/i18n/locales/nl.json | 3 + web/src/lib/i18n/locales/pl.json | 3 + web/src/lib/i18n/locales/pt.json | 3 + web/src/lib/i18n/locales/ru.json | 3 + web/src/lib/i18n/locales/zh.json | 3 + web/src/lib/util/maplibre_util.ts | 37 --- .../vendor/maplibre-layer-manager/layers.ts | 249 ++++++++++++++++++ .../maplibre-layer-manager.ts | 144 ++++++++++ .../maplibre-layer-manager/overlay-layer.ts | 10 + .../maplibre-layer-manager/overpass-layer.ts | 92 +++++++ .../vendor/maplibre-layer-manager/types.ts | 21 ++ .../style-switcher-control.ts | 16 +- 23 files changed, 631 insertions(+), 133 deletions(-) create mode 100644 web/src/lib/assets/svgs/pois/bakery.svg create mode 100644 web/src/lib/vendor/maplibre-layer-manager/layers.ts create mode 100644 web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts create mode 100644 web/src/lib/vendor/maplibre-layer-manager/overlay-layer.ts create mode 100644 web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts create mode 100644 web/src/lib/vendor/maplibre-layer-manager/types.ts diff --git a/web/src/lib/assets/svgs/pois/bakery.svg b/web/src/lib/assets/svgs/pois/bakery.svg new file mode 100644 index 00000000..124e97b6 --- /dev/null +++ b/web/src/lib/assets/svgs/pois/bakery.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/lib/components/base/radio_group.svelte b/web/src/lib/components/base/radio_group.svelte index 2901a5c6..49beb54a 100644 --- a/web/src/lib/components/base/radio_group.svelte +++ b/web/src/lib/components/base/radio_group.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 8f93dea1..06965a85 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -190,6 +190,7 @@ "joined": "Beigetreten", "language": "Sprache", "latitude": "Breitengrad", + "layer": "{n, plural, =1 {Ebene} other {Ebenen}}", "license": "Lizenz", "like-status": "\"Gefällt mir\" Status", "liked": "Gefällt mir", @@ -210,6 +211,7 @@ "make-one": "Neues erstellen!", "make-thumbnail": "Thumbnail festlegen", "map": "Karte", + "map-style": "Map style", "max-hiking-difficulty": "Max. Schwierigkeit der Wanderung", "metric": "Metrisch", "moderate": "Mittel", @@ -278,6 +280,7 @@ "pick-a-trail": "Route auswählen", "planned-a-trail": "hat eine Route geplant", "planned-tours": "Geplante Touren", + "pois": "Interessante Orte", "polish": "Polnisch", "portuguese": "Portugiesisch", "print": "Drucken", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 1f71e77f..5106e717 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -190,6 +190,7 @@ "joined": "Joined", "language": "Language", "latitude": "Latitude", + "layer": "{n, plural, =1 {Layer} other {Layers}}", "license": "License", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "Make one!", "make-thumbnail": "Make thumbnail", "map": "Map", + "map-style": "Map style", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Metric", "moderate": "Moderate", @@ -278,6 +280,7 @@ "pick-a-trail": "Pick a trail", "planned-a-trail": "planned a trail", "planned-tours": "Planned tours", + "pois": "POIs", "polish": "Polish", "portuguese": "Portuguese", "print": "Print", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 0428f651..5b2d7e89 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -190,6 +190,7 @@ "joined": "Afiliado", "language": "Idioma", "latitude": "Latitud", + "layer": "", "license": "Licencia", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "¡Crea uno!", "make-thumbnail": "Generar miniaturas", "map": "Mapa", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Métrica", "moderate": "Medio", @@ -278,6 +280,7 @@ "pick-a-trail": "Escoge una ruta", "planned-a-trail": "planificada una ruta", "planned-tours": "Planned tours", + "pois": "", "polish": "Polaco", "portuguese": "Portugués", "print": "Imprimir", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index 4644b87a..0788e1cf 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -190,6 +190,7 @@ "joined": "Joined", "language": "Language", "latitude": "Latitude", + "layer": "", "license": "License", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "Make one!", "make-thumbnail": "Make thumbnail", "map": "Map", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Metric", "moderate": "Moderate", @@ -278,6 +280,7 @@ "pick-a-trail": "Pick a trail", "planned-a-trail": "planned a trail", "planned-tours": "Planned tours", + "pois": "", "polish": "Polish", "portuguese": "Portuguese", "print": "Print", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index 3d0ddb83..4de69763 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -190,6 +190,7 @@ "joined": "Rejoint", "language": "Langue", "latitude": "Latitude", + "layer": "", "license": "Licence", "like-status": "Like Status", "liked": "Aimé", @@ -210,6 +211,7 @@ "make-one": "Faites-en un !", "make-thumbnail": "Créer une miniature", "map": "Carte", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Métrique", "moderate": "Moyenne", @@ -278,6 +280,7 @@ "pick-a-trail": "Choisir un itinéraire", "planned-a-trail": "a plannifié un itinéraire", "planned-tours": "Tournées planifiées", + "pois": "", "polish": "polonais", "portuguese": "Portugais", "print": "Imprimer", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 97c7abee..589879a7 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -190,6 +190,7 @@ "joined": "Joined", "language": "Nyelf", "latitude": "Szélesség", + "layer": "", "license": "License", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "Készítsen egyet!", "make-thumbnail": "Készítsen miniatűrképet", "map": "Térkép", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Metrikus", "moderate": "Mérsékelt", @@ -278,6 +280,7 @@ "pick-a-trail": "Válasszon útvonalat", "planned-a-trail": "planned a trail", "planned-tours": "Planned tours", + "pois": "", "polish": "Lengyel", "portuguese": "Portugál", "print": "Print", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 9f59b137..00799471 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -190,6 +190,7 @@ "joined": "Aggiunto", "language": "Lingua", "latitude": "Latitudine", + "layer": "", "license": "Licenza", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "Creane uno!", "make-thumbnail": "Imposta miniatura", "map": "Mappa", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Metrico", "moderate": "Moderato", @@ -278,6 +280,7 @@ "pick-a-trail": "Scegli un percorso", "planned-a-trail": "percorso pianificato", "planned-tours": "Planned tours", + "pois": "", "polish": "Polacco", "portuguese": "Portoghese", "print": "Stampa", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 8b43c4f7..8fc5e0b0 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -190,6 +190,7 @@ "joined": "Aangesloten", "language": "Taal", "latitude": "Breedtegraad", + "layer": "", "license": "Licentie", "like-status": "", "liked": "Leuk gevonden", @@ -210,6 +211,7 @@ "make-one": "Maak er een aan!", "make-thumbnail": "Miniatuur maken", "map": "Kaart", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Moeilijkheid", "metric": "Metrisch", "moderate": "Gemiddeld", @@ -278,6 +280,7 @@ "pick-a-trail": "Kies een Route", "planned-a-trail": "een route gepland", "planned-tours": "Geplande tochten", + "pois": "", "polish": "Pools", "portuguese": "Portugees", "print": "Afdrukken", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index ab7eedb7..a6431994 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -190,6 +190,7 @@ "joined": "Dołączono", "language": "Język", "latitude": "Szerokość", + "layer": "", "license": "Licencja", "like-status": "Status polubień", "liked": "Polubiony", @@ -210,6 +211,7 @@ "make-one": "Stwórz ją!", "make-thumbnail": "Zrób miniaturkę", "map": "Mapa", + "map-style": "", "max-hiking-difficulty": "Maksymalny poziom trudności wędrówki", "metric": "Metryczne", "moderate": "Średni", @@ -278,6 +280,7 @@ "pick-a-trail": "Wybierz szlak", "planned-a-trail": "zaplanowano szlak", "planned-tours": "Planowane trasy", + "pois": "", "polish": "Polski", "portuguese": "Portugalski", "print": "Drukuj", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 28a46c9c..b2603292 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -190,6 +190,7 @@ "joined": "Joined", "language": "Língua", "latitude": "Latitude", + "layer": "", "license": "Licença", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "Faz um!", "make-thumbnail": "Faça miniatura", "map": "Mapa", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "Métrica", "moderate": "Moderado", @@ -278,6 +280,7 @@ "pick-a-trail": "Escolha uma trilha", "planned-a-trail": "planned a trail", "planned-tours": "Planned tours", + "pois": "", "polish": "Polonês", "portuguese": "Português", "print": "Imprimir", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index dd136105..5cf19100 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -190,6 +190,7 @@ "joined": "Зарегистрирован", "language": "Язык", "latitude": "Широта", + "layer": "", "license": "Лицензия", "like-status": "Статус лайка", "liked": "Лайкнуто", @@ -210,6 +211,7 @@ "make-one": "Создайте!", "make-thumbnail": "Сделать миниатюру", "map": "Карта", + "map-style": "", "max-hiking-difficulty": "Макс. сложность", "metric": "Метрическая", "moderate": "Средний", @@ -278,6 +280,7 @@ "pick-a-trail": "Выберите трек", "planned-a-trail": "запланировал(а) трек", "planned-tours": "Запланированные маршруты", + "pois": "", "polish": "Польский", "portuguese": "Португальский", "print": "Печать", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index aa82851b..e3973812 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -190,6 +190,7 @@ "joined": "Joined", "language": "语言", "latitude": "维度", + "layer": "", "license": "开源协议", "like-status": "Like Status", "liked": "Liked", @@ -210,6 +211,7 @@ "make-one": "立刻注册!", "make-thumbnail": "生成缩略图", "map": "地图", + "map-style": "", "max-hiking-difficulty": "Max. Hiking Difficulty", "metric": "公制", "moderate": "中等", @@ -278,6 +280,7 @@ "pick-a-trail": "选择一个路线", "planned-a-trail": "planned a trail", "planned-tours": "Planned tours", + "pois": "", "polish": "波兰语", "portuguese": "葡萄牙语", "print": "打印", diff --git a/web/src/lib/util/maplibre_util.ts b/web/src/lib/util/maplibre_util.ts index b4003187..5ac195c0 100644 --- a/web/src/lib/util/maplibre_util.ts +++ b/web/src/lib/util/maplibre_util.ts @@ -11,43 +11,6 @@ import { handleFromRecordWithIRI } from "./activitypub_util"; import { getFileURL } from "./file_util"; import { formatDistance, formatElevation, formatTimeHHMM } from "./format_util"; import { icons } from "./icon_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-layer-manager/layers.ts b/web/src/lib/vendor/maplibre-layer-manager/layers.ts new file mode 100644 index 00000000..638ff34e --- /dev/null +++ b/web/src/lib/vendor/maplibre-layer-manager/layers.ts @@ -0,0 +1,249 @@ +import Bakery from "$lib/assets/svgs/pois/bakery.svg?raw" +import type { MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl" + +export interface BaseLayer { + markers?: Record, + spec: StyleSpecification, + listener?: { + onMouseUp?: (e: MapMouseEvent) => void, + onMouseDown?: (e: MapMouseEvent) => void, + onEnter?: (e: MapMouseEvent) => void, + onLeave?: (e: MapMouseEvent) => void, + onMouseMove?: (e: MapMouseEvent) => void, + } +} + +export const baseMapStyles: Record = { + "OpenFreeMap": "/styles/ofm.json", + "OpenTopoMap": { + version: 8, + sources: { + openTopoMap: { + type: 'raster', + tiles: ['https://tile.opentopomap.org/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 17, + attribution: + '© OpenTopoMap © OpenStreetMap', + }, + }, + layers: [ + { + id: 'openTopoMap', + type: 'raster', + source: 'openTopoMap', + }, + ], + }, + "CyclOSM": { + version: 8, + sources: { + cyclOSM: { + type: 'raster', + tiles: [ + 'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', + 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', + 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', + ], + tileSize: 256, + maxzoom: 18, + attribution: + '© CyclOSM © OpenStreetMap', + }, + }, + layers: [ + { + id: 'cyclOSM', + type: 'raster', + source: 'cyclOSM', + }, + ], + }, + "Carto Light": "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", + "Carto Dark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json" +} + +export const overlays: Record = { + hiking: { + version: 8, + name: "waymarkedTrailsHiking", + sources: { + waymarkedTrailsHiking: { + type: 'raster', + tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 18, + attribution: + '© Waymarked Trails', + }, + }, + layers: [ + { + id: 'waymarkedTrailsHiking', + type: 'raster', + source: 'waymarkedTrailsHiking', + }, + ], + }, + cycling: { + version: 8, + name: "waymarkedTrailsCycling", + + sources: { + waymarkedTrailsCycling: { + type: 'raster', + tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 18, + attribution: + '© Waymarked Trails', + }, + }, + layers: [ + { + id: 'waymarkedTrailsCycling', + type: 'raster', + source: 'waymarkedTrailsCycling', + }, + ], + }, + MTB: { + version: 8, + name: "waymarkedTrailsMTB", + + sources: { + waymarkedTrailsMTB: { + type: 'raster', + tiles: ['https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 18, + attribution: + '© Waymarked Trails', + }, + }, + layers: [ + { + id: 'waymarkedTrailsMTB', + type: 'raster', + source: 'waymarkedTrailsMTB', + }, + ], + }, + Skiing: { + name: "waymarkedTrailsWinter", + version: 8, + sources: { + waymarkedTrailsWinter: { + type: 'raster', + tiles: ['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 18, + attribution: + '© Waymarked Trails', + }, + }, + layers: [ + { + id: 'waymarkedTrailsWinter', + type: 'raster', + source: 'waymarkedTrailsWinter', + }, + ], + } +} + +export type POI = { q: string, icon: { svg: any, bg: string } } +export const pois: Record = { + "grocery-store": { q: "nwr[shop=supermarket];nwr[shop=convenience];", icon: { svg: "", bg: "red" } }, + "bakery": { q: "nwr[shop=bakery];", icon: { svg: Bakery, bg: "coral" } }, + "food-drinks": { q: "nwr[amenity=restaurant];nwr[amenity=fast_food];nwr[amenity=cafe];nwr[amenity=pub];nwr[amenity=bar];", icon: { svg: "", bg: "red" } }, + "toilet": { q: "nwr[amenity=toilets];", icon: { svg: "", bg: "red" } }, + "drinking-water": { q: "nwr[amenity=drinking_water];nwr[amenity=water_point];nwr[natural=spring][drinking_water=yes];", icon: { svg: "", bg: "red" } }, + "shower": { q: "nwr[amenity=shower];", icon: { svg: "", bg: "red" } }, + "shelter": { q: "nwr[amenity=shelter];", icon: { svg: "", bg: "red" } }, + "barrier": { q: "nwr[barrier=true];", icon: { svg: "", bg: "red" } }, + "attraction": { q: "nwr[tourism=attraction];", icon: { svg: "", bg: "red" } }, + "viewpoint": { q: "nwr[tourism=viewpoint];", icon: { svg: "", bg: "red" } }, + "hotel": { q: "nwr[tourism=hotel];nwr[tourism=hostel];nwr[tourism=guest_house];nwr[tourism=motel];", icon: { svg: "", bg: "red" } }, + "camp-site": { q: "nwr[tourism=camp_site];", icon: { svg: "", bg: "red" } }, + "hut": { q: "nwr[tourism=alpine_hut];nwr[tourism=wilderness_hut];", icon: { svg: "", bg: "red" } }, + "peak": { q: "nwr[natural=peak];", icon: { svg: "", bg: "red" } }, + "mountain-pass": { q: "nwr[mountain_pass=yes];", icon: { svg: "", bg: "red" } }, + "climbing": { q: "nwr[sport=climbing];", icon: { svg: "", bg: "red" } }, + "bicylce-parking": { q: "nwr[amenity=bicycle_parking];", icon: { svg: "", bg: "red" } }, + "bicycle-rental": { q: "nwr[amenity=bicycle_rental];", icon: { svg: "", bg: "red" } }, + "bicycle-shop": { q: "nwr[shop=bicycle];", icon: { svg: "", bg: "red" } }, + "gas-station": { q: "nwr[amenity=fuel];", icon: { svg: "", bg: "red" } }, + "parking": { q: "nwr[amenity=parking];", icon: { svg: "", bg: "red" } }, + "car-repair": { q: "nwr[shop=car_repair];", icon: { svg: "", bg: "red" } }, + "motorcycle-repair": { q: "nwr[shop=motorcycle_repair];", icon: { svg: "", bg: "red" } }, + "railway-station": { q: "nwr[railway=station];", icon: { svg: "", bg: "red" } }, + "subway": { q: "nwr[railway=subway_entrance];", icon: { svg: "", bg: "red" } }, + "tram": { q: "nwr[railway=tram_stop];", icon: { svg: "", bg: "red" } }, + "bus": { q: "nwr[public_transport=stop_position][bus=yes];nwr[public_transport=platform][bus=yes];", icon: { svg: "", bg: "red" } }, + "ferry": { q: "nwr[amenity=ferry_terminal];", icon: { svg: "", bg: "red" } }, +} + + + +export type MapState = { + base: keyof typeof baseMapStyles, + overlays: { [K in keyof typeof overlays]: boolean } + pois: Record> +} + +export const defaultMapState: MapState = { + base: "OpenFreeMap", + overlays: { + waymarkedTrailsHiking: false, + waymarkedTrailsCycling: false, + waymarkedTrailsHorseRiding: false, + waymarkedTrailsMTB: false, + waymarkedTrailsSkating: false, + waymarkedTrailsWinter: false, + }, + pois: { + food: { + "food-drinks": false, + "grocery-store": false, + bakery: false, + }, + tourism: { + "camp-site": false, + attraction: false, + hotel: false, + hut: false, + viewpoint: false, + }, + ammenity: { + "drinking-water": false, + barrier: false, + shelter: false, + shower: false, + toilet: false, + }, + hiking: { + "mountain-pass": false, + climbing: false, + peak: false, + }, + cycling: { + "bicycle-rental": false, + "bicycle-shop": false, + "bicylce-parking": false, + }, + "car-motorcycle": { + "car-repair": false, + "gas-station": false, + "motorcycle-repair": false, + parking: false, + }, + "public-transport": { + "railway-station": false, + bus: false, + ferry: false, + subway: false, + tram: false, + } + } +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts new file mode 100644 index 00000000..f29ac687 --- /dev/null +++ b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts @@ -0,0 +1,144 @@ +import * as M from "maplibre-gl"; +import { baseMapStyles, defaultMapState, pois, type BaseLayer, type MapState } from "./layers"; +import { OverlayLayer } from "./overlay-layer"; +import { OverpassLayer } from "./overpass-layer"; + + + +export class LayerManager { + private map: M.Map; + state!: MapState; + layers: Record = {}; + + constructor(map: M.Map) { + this.map = map; + + const storedMapState = localStorage.getItem("map-state") + if (storedMapState) { + this.state = JSON.parse(storedMapState) + } else { + this.state = defaultMapState + } + } + + init() { + this.map.on('moveend', () => { }); + + this.map.on('styledata', () => { + this.restoreLayers(); + }) + try { + this.update(this.state, true); + + const overpassLayer = new OverpassLayer() + this.addLayer("overpass", overpassLayer) + + } catch (e) { + console.error(e) + // map is probably not initialized yet + } + } + + update(newState: MapState, initialize: boolean = false) { + const oldState = this.state; + + if (oldState.base != newState.base || initialize) { + this.updateBaseLayer(baseMapStyles[newState.base]) + } + + for (const [name, active] of Object.entries(newState.overlays)) { + const oldOverlayActive = oldState.overlays[name] + + if (active && (!oldOverlayActive || initialize)) { + this.addLayer(name, new OverlayLayer(name)) + } else if (oldOverlayActive && !active) { + this.removeLayer(name) + } + } + + const overpassLayer = this.layers.overpass; + if (overpassLayer) { + const castedOverpassLayer = overpassLayer as OverpassLayer + castedOverpassLayer.updateLayer(newState, this.map.getBounds()).then(() => { + this.loadIcons(castedOverpassLayer.pois); + (this.map.getSource('overpass') as M.GeoJSONSource).setData(castedOverpassLayer.data); + }) + } + + this.state = newState + localStorage.setItem("map-state", JSON.stringify(this.state)); + } + + private updateBaseLayer(layer: string | M.StyleSpecification) { + this.map.setStyle(layer); + } + + + private addLayer(id: string, layer: BaseLayer) { + for (const [id, s] of Object.entries(layer.spec.sources)) { + if (!this.map.getSource(id)) { + this.map.addSource(id, s) + } + } + + for (const l of layer.spec.layers) { + if (!this.map.getLayer(l.id)) { + this.map.addLayer(l) + } + } + + this.layers[id] = layer + } + + private removeLayer(id: string) { + const layer = this.layers[id]; + if (!layer) { + return; + } + for (const l of layer.spec.layers) { + if (this.map.getLayer(l.id)) { + this.map.removeLayer(l.id) + } + } + + for (const [id, _] of Object.entries(layer.spec.sources)) { + if (this.map.getSource(id)) { + this.map.removeSource(id) + } + } + delete this.layers[id] + + } + + private restoreLayers() { + for (const [id, layer] of Object.entries(this.layers)) { + this.addLayer(id, layer) + } + } + + private loadIcons(activePois: string[]) { + activePois.forEach((poi) => { + if (!this.map.hasImage(`overpass-${poi}`)) { + let icon = new Image(100, 100); + icon.onload = () => { + if (!this.map.hasImage(`overpass-${poi}`)) { + this.map.addImage(`overpass-${poi}`, icon); + } + }; + + const svg = ` + + + + ${pois[poi].icon.svg} + + + ` + + icon.src = + 'data:image/svg+xml,' + + encodeURIComponent(svg); + } + }); + } +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-layer-manager/overlay-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/overlay-layer.ts new file mode 100644 index 00000000..a3219e4f --- /dev/null +++ b/web/src/lib/vendor/maplibre-layer-manager/overlay-layer.ts @@ -0,0 +1,10 @@ +import type { StyleSpecification } from "maplibre-gl"; +import { overlays, type BaseLayer } from "./layers"; + +export class OverlayLayer implements BaseLayer { + spec: StyleSpecification; + + constructor(name: string) { + this.spec = overlays[name] + } +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts new file mode 100644 index 00000000..021fed21 --- /dev/null +++ b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts @@ -0,0 +1,92 @@ +import type { LngLatBounds, StyleSpecification } from "maplibre-gl"; +import { pois, type BaseLayer, type MapState } from "./layers"; +import type { OverpassResponse } from "./types"; + +export class OverpassLayer implements BaseLayer { + private overpassApiURL: string = "https://overpass.private.coffee/api/interpreter" + + data: GeoJSON.FeatureCollection = ({ type: 'FeatureCollection', features: [] }); + pois: string[] = []; + + spec: StyleSpecification = { + version: 8, + name: "overpass", + sources: { + overpass: { + type: 'geojson', + data: this.data, + } + }, + layers: [ + { + id: 'overpass', + type: 'symbol', + source: 'overpass', + layout: { + 'icon-image': ['get', 'icon'], + 'icon-size': 0.25, + 'icon-padding': 0, + 'icon-allow-overlap': ['step', ['zoom'], false, 14, true], + }, + } + ], + }; + + + async updateLayer(state: MapState, bounds: LngLatBounds) { + let activePOIs: string[] = [] + for (const category of Object.keys(state.pois)) { + for (const [name, active] of Object.entries(state.pois[category])) { + if (active) { + activePOIs.push(name) + } + } + } + const q = this.getOverpassQuery(activePOIs, bounds) + if (!q.length) { + return; + } + const r = await fetch(`${this.overpassApiURL}?data=${q}`) + const response: OverpassResponse = await r.json(); + + this.updateData(response) + this.pois = activePOIs + } + + private updateData(data: OverpassResponse, ) { + let pois: GeoJSON.Feature[] = []; + + if (data.elements === undefined) { + return; + } + + for (let element of data.elements) { + console.log(element); + + pois.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: element.center + ? [element.center.lon, element.center.lat] + : [element.lon, element.lat], + }, + properties: { + id: element.id, + lat: element.center ? element.center.lat : element.lat, + lon: element.center ? element.center.lon : element.lon, + icon: `overpass-bakery`, + tags: element.tags, + type: element.type, + }, + }); + } + this.data.features = pois; + } + + private getOverpassQuery(data: string[], bounds: LngLatBounds) { + const q = data.map(p => pois[p].q).join('') + return `[bbox:${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}][out:json];(${q});out center;`; + } + +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-layer-manager/types.ts b/web/src/lib/vendor/maplibre-layer-manager/types.ts new file mode 100644 index 00000000..ee0154f0 --- /dev/null +++ b/web/src/lib/vendor/maplibre-layer-manager/types.ts @@ -0,0 +1,21 @@ +export interface OverpassResponse { + elements: OverpassElement[]; + generator: string; + osm3s: { + copyright: string; + timestamp_osm_base: string; + }; + version: number; +} +export interface OverpassElement { + id: number; + center?: { + lat: number, + lon: number, + } + lat: number; + lon: number; + nodes?: number[]; + tags?: Record; + type: string; +} \ No newline at end of file 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 f52e1406..c29c25bd 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,21 +1,15 @@ -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 { type ControlPosition, type IControl, type StyleSpecification } from "maplibre-gl"; import { mount } from "svelte"; +import type { MapState, pois } from "../maplibre-layer-manager/layers"; /** * Style switcher control options */ export type StyleSwitcherControlOptions = { - styles: RadioItem[]; - layers: RadioItem[]; - onMapStyleSwitch: (index: number, style: RadioItem) => void - onLayerChange: (checked: boolean, layer: RadioItem) => void - - state: { - selectedStyle: number; - selectedLayers: Record - }; + styles: Record; + onchange: (state: MapState) => void + state: MapState; }; export class StyleSwitcherControl implements IControl {