adds layer manager

This commit is contained in:
Christian Beutel
2025-07-13 22:22:08 +02:00
parent baa7bb4ecf
commit eac9b2e489
23 changed files with 631 additions and 133 deletions

View File

@@ -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<string, Marker>,
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<string, string | StyleSpecification> = {
"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:
'&copy; <a href="https://www.opentopomap.org" target="_blank">OpenTopoMap</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>',
},
},
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:
'&copy; <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
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<string, StyleSpecification> = {
hiking: {
version: 8,
name: "waymarkedTrailsHiking",
sources: {
waymarkedTrailsHiking: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution:
'&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>',
},
},
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:
'&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>',
},
},
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:
'&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>',
},
},
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:
'&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>',
},
},
layers: [
{
id: 'waymarkedTrailsWinter',
type: 'raster',
source: 'waymarkedTrailsWinter',
},
],
}
}
export type POI = { q: string, icon: { svg: any, bg: string } }
export const pois: Record<string, POI> = {
"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<string, Record<string, boolean>>
}
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,
}
}
}

View File

@@ -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<string, BaseLayer> = {};
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 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${pois[poi].icon.bg}" />
<g transform="translate(8 8) scale(0.05)">
${pois[poi].icon.svg}
</g>
</svg>
`
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(svg);
}
});
}
}

View File

@@ -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]
}
}

View File

@@ -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;`;
}
}

View File

@@ -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<string, string>;
type: string;
}

View File

@@ -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<string, boolean>
};
styles: Record<string, string | StyleSpecification>;
onchange: (state: MapState) => void
state: MapState;
};
export class StyleSwitcherControl implements IControl {