From f46f8055c4f00ed7c0ae8d8e4185a91cf9786818 Mon Sep 17 00:00:00 2001
From: Christian Beutel <>
Date: Fri, 18 Jul 2025 20:02:06 +0200
Subject: [PATCH] fixes POI overlay
---
.../lib/components/map/style_switcher.svelte | 2 +-
.../trail/map_with_elevation_maplibre.svelte | 2 +-
web/src/lib/util/maplibre_util.ts | 37 ++
.../maplibre-layer-manager/debug-layer.ts | 30 ++
.../vendor/maplibre-layer-manager/layers.ts | 356 +++++++++++++++---
.../maplibre-layer-manager.ts | 117 ++++--
.../maplibre-layer-manager/overpass-layer.ts | 302 +++++++++++++--
web/src/routes/map/+page.svelte | 1 -
8 files changed, 718 insertions(+), 129 deletions(-)
create mode 100644 web/src/lib/vendor/maplibre-layer-manager/debug-layer.ts
diff --git a/web/src/lib/components/map/style_switcher.svelte b/web/src/lib/components/map/style_switcher.svelte
index 777e073c..bcf34599 100644
--- a/web/src/lib/components/map/style_switcher.svelte
+++ b/web/src/lib/components/map/style_switcher.svelte
@@ -52,7 +52,7 @@
class:hidden={!showSwitcher}
style="transform: translateX(calc(-100% + 29px))"
>
-
+
{$_("map-style")}
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 42039c52..0cad2198 100644
--- a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte
+++ b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte
@@ -1094,7 +1094,7 @@
map!.getCanvas().style.cursor = "";
});
- map.on("load", () => {
+ map.on("load", () => {
initMap(true);
layerManager.init();
oninit?.(map!);
diff --git a/web/src/lib/util/maplibre_util.ts b/web/src/lib/util/maplibre_util.ts
index 5ac195c0..ff677e71 100644
--- a/web/src/lib/util/maplibre_util.ts
+++ b/web/src/lib/util/maplibre_util.ts
@@ -257,6 +257,43 @@ export function createPopupFromTrail(trail: Trail) {
return popup;
}
+export function createOverpassPopup(tags: Record, coordinates: GeoJSON.Position) {
+ const name = tags.name ?? "?"
+
+ const popupContainer = document.createElement("div");
+ popupContainer.className = "p-4"
+
+ const popupHeading = document.createElement("h1");
+ popupHeading.classList = "font-medium text-lg"
+ popupHeading.textContent = name;
+
+ const coordinateSubtitle = document.createElement("p")
+ coordinateSubtitle.classList = "text-gray-500"
+ coordinateSubtitle.textContent = `${coordinates[0].toFixed(6)}, ${coordinates[1].toFixed(6)}`
+
+ popupContainer.appendChild(popupHeading)
+ popupContainer.appendChild(coordinateSubtitle)
+
+ const tagsGrid = document.createElement("div")
+ tagsGrid.classList = "grid grid-cols-2 gap-x-4 mt-4"
+
+ Object.entries(tags).forEach((([k, v]) => {
+ if (k == "name") return;
+ const kSpan = document.createElement("span")
+ kSpan.classList = "font-mono"
+ kSpan.textContent = k;
+ const vSpan = document.createElement("span")
+ vSpan.textContent = v;
+
+ tagsGrid.appendChild(kSpan);
+ tagsGrid.appendChild(vSpan);
+ }));
+
+ popupContainer.appendChild(tagsGrid)
+
+ return popupContainer;
+}
+
export function calculatePixelPerMeter(map: M.Map, meters: number) {
const y = map.getCanvas().getBoundingClientRect().y;
const x = map.getCanvas().getBoundingClientRect().x;
diff --git a/web/src/lib/vendor/maplibre-layer-manager/debug-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/debug-layer.ts
new file mode 100644
index 00000000..3605c6a6
--- /dev/null
+++ b/web/src/lib/vendor/maplibre-layer-manager/debug-layer.ts
@@ -0,0 +1,30 @@
+import type { Marker, StyleSpecification, MapMouseEvent } from "maplibre-gl";
+import type { BaseLayer } from "./layers";
+import * as M from "maplibre-gl";
+
+export class DebugLayer implements BaseLayer {
+ spec: StyleSpecification = {
+ version: 8,
+ name: "debug",
+ sources: {
+ debug: {
+ type: 'geojson',
+ data: {
+ type: 'FeatureCollection',
+ features: [],
+ },
+ }
+ },
+ layers: [{
+ id: 'debug-layer',
+ type: 'line',
+ source: 'debug',
+ paint: {
+ 'line-color': '#ff0000',
+ 'line-width': 2,
+ 'line-dasharray': [2, 2],
+ },
+ }]
+
+ };
+}
\ No newline at end of file
diff --git a/web/src/lib/vendor/maplibre-layer-manager/layers.ts b/web/src/lib/vendor/maplibre-layer-manager/layers.ts
index 638ff34e..68b45e25 100644
--- a/web/src/lib/vendor/maplibre-layer-manager/layers.ts
+++ b/web/src/lib/vendor/maplibre-layer-manager/layers.ts
@@ -1,16 +1,18 @@
import Bakery from "$lib/assets/svgs/pois/bakery.svg?raw"
-import type { MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl"
+import type { FilterSpecification, MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl"
export interface BaseLayer {
markers?: Record,
spec: StyleSpecification,
- listener?: {
+ filter?: FilterSpecification,
+ listeners?: Record void,
onMouseDown?: (e: MapMouseEvent) => void,
onEnter?: (e: MapMouseEvent) => void,
onLeave?: (e: MapMouseEvent) => void,
onMouseMove?: (e: MapMouseEvent) => void,
- }
+ }>
+
}
export const baseMapStyles: Record = {
@@ -129,7 +131,7 @@ export const overlays: Record = {
},
],
},
- Skiing: {
+ skiing: {
name: "waymarkedTrailsWinter",
version: 8,
sources: {
@@ -152,38 +154,293 @@ export const overlays: Record = {
}
}
-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 POI = {
+ tags:
+ | Record
+ | Record[], icon: { svg: any, bg: string }
}
+export const pois: Record = {
+ bakery: {
+ icon: {
+ svg: Bakery,
+ bg: 'Coral',
+ },
+ tags: {
+ shop: 'bakery',
+ },
+ },
+ 'grocery-store': {
+ icon: {
+ svg: "",
+ bg: 'Coral',
+ },
+ tags: {
+ shop: ['supermarket', 'convenience'],
+ },
+ },
+ "food-drinks": {
+ icon: {
+ svg: "",
+ bg: 'Coral',
+ },
+ tags: {
+ amenity: ['restaurant', 'fast_food', 'cafe', 'pub', 'bar'],
+ },
+
+ },
+ toilets: {
+ icon: {
+ svg: "",
+ bg: 'DeepSkyBlue',
+ },
+ tags: {
+ amenity: 'toilets',
+ },
+
+ },
+ water: {
+ icon: {
+ svg: "",
+ bg: 'DeepSkyBlue',
+ },
+ tags: [
+ {
+ amenity: ['drinking_water', 'water_point'],
+ },
+ {
+ natural: 'spring',
+ drinking_water: 'yes',
+ },
+ ],
+
+ },
+ shower: {
+ icon: {
+ svg: "",
+ bg: 'DeepSkyBlue',
+ },
+ tags: {
+ amenity: 'shower',
+ },
+
+ },
+ shelter: {
+ icon: {
+ svg: "",
+ bg: '#000000',
+ },
+ tags: {
+ amenity: 'shelter',
+ },
+
+ },
+ 'gas-station': {
+ icon: {
+ svg: "",
+ bg: '#000000',
+ },
+ tags: {
+ amenity: 'fuel',
+ },
+
+ },
+ parking: {
+ icon: {
+ svg: "",
+ bg: '#000000',
+ },
+ tags: {
+ amenity: 'parking',
+ },
+
+ },
+ garage: {
+ icon: {
+ svg: "",
+ bg: '#000000',
+ },
+ tags: {
+ shop: ['car_repair', 'motorcycle_repair'],
+ },
+
+ },
+ barrier: {
+ icon: {
+ svg: "",
+ bg: '#000000',
+ },
+ tags: {
+ barrier: true,
+ },
+ },
+ attraction: {
+ icon: {
+ svg: "",
+ bg: 'Green',
+ },
+ tags: {
+ tourism: 'attraction',
+ },
+ },
+ viewpoint: {
+ icon: {
+ svg: "",
+ bg: 'Green',
+ },
+ tags: {
+ tourism: 'viewpoint',
+ },
+
+ },
+ hotel: {
+ icon: {
+ svg: "",
+ bg: '#e6c100',
+ },
+ tags: {
+ tourism: ['hotel', 'hostel', 'guest_house', 'motel'],
+ },
+
+ },
+ campsite: {
+ icon: {
+ svg: "",
+ bg: '#e6c100',
+ },
+ tags: {
+ tourism: 'camp_site',
+ },
+
+ },
+ hut: {
+ icon: {
+ svg: "",
+ bg: '#e6c100',
+ },
+ tags: {
+ tourism: ['alpine_hut', 'wilderness_hut'],
+ },
+
+ },
+ picnic: {
+ icon: {
+ svg: "",
+ bg: 'Green',
+ },
+ tags: {
+ tourism: 'picnic_site',
+ },
+
+ },
+ summit: {
+ icon: {
+ svg: "",
+ bg: 'Green',
+ },
+ tags: {
+ natural: 'peak',
+ },
+
+ },
+ pass: {
+ icon: {
+ svg: "",
+ bg: 'Green',
+ },
+ tags: {
+ mountain_pass: 'yes',
+ },
+ },
+ climbing: {
+ icon: {
+ svg: "",
+ bg: 'Green',
+ },
+ tags: {
+ sport: 'climbing',
+ },
+ },
+ 'bicycle-parking': {
+ icon: {
+ svg: "",
+ bg: 'HotPink',
+ },
+ tags: {
+ amenity: 'bicycle_parking',
+ },
+
+ },
+ 'bicycle-rental': {
+ icon: {
+ svg: "",
+ bg: 'HotPink',
+ },
+ tags: {
+ amenity: 'bicycle_rental',
+ },
+ },
+ 'bicycle-shop': {
+ icon: {
+ svg: "",
+ bg: 'HotPink',
+ },
+ tags: {
+ shop: 'bicycle',
+ },
+ },
+ 'railway-station': {
+ icon: {
+ svg: "",
+ bg: 'DarkBlue',
+ },
+ tags: {
+ railway: 'station',
+ },
+
+ },
+ 'tram-stop': {
+ icon: {
+ svg: "",
+ bg: 'DarkBlue',
+ },
+ tags: {
+ railway: 'tram_stop',
+ },
+
+ },
+ 'subway-stop': {
+ icon: {
+ svg: "",
+ bg: 'DarkBlue',
+ },
+ tags: {
+ railway: 'subway_entrance',
+ },
+
+ },
+ 'bus-stop': {
+ icon: {
+ svg: "",
+ bg: 'DarkBlue',
+ },
+ tags: {
+ public_transport: ['stop_position', 'platform'],
+ bus: 'yes',
+ },
+
+ },
+ ferry: {
+ icon: {
+ svg: "",
+ bg: 'DarkBlue',
+ },
+ tags: {
+ amenity: 'ferry_terminal',
+ },
+
+ },
+};
export type MapState = {
@@ -195,12 +452,10 @@ export type MapState = {
export const defaultMapState: MapState = {
base: "OpenFreeMap",
overlays: {
- waymarkedTrailsHiking: false,
- waymarkedTrailsCycling: false,
- waymarkedTrailsHorseRiding: false,
- waymarkedTrailsMTB: false,
- waymarkedTrailsSkating: false,
- waymarkedTrailsWinter: false,
+ hiking: false,
+ cycling: false,
+ MTB: false,
+ skiing: false,
},
pois: {
food: {
@@ -209,41 +464,40 @@ export const defaultMapState: MapState = {
bakery: false,
},
tourism: {
- "camp-site": false,
+ campsite: false,
attraction: false,
hotel: false,
hut: false,
viewpoint: false,
},
ammenity: {
- "drinking-water": false,
+ water: false,
barrier: false,
shelter: false,
shower: false,
- toilet: false,
+ toilets: false,
},
hiking: {
- "mountain-pass": false,
+ pass: false,
climbing: false,
- peak: false,
+ summit: false,
},
cycling: {
"bicycle-rental": false,
"bicycle-shop": false,
- "bicylce-parking": false,
+ "bicycle-parking": false,
},
"car-motorcycle": {
- "car-repair": false,
+ "garage": false,
"gas-station": false,
- "motorcycle-repair": false,
parking: false,
},
"public-transport": {
"railway-station": false,
- bus: false,
+ "bus-stop": false,
ferry: false,
- subway: false,
- tram: false,
+ "subway-stop": false,
+ "tram-stop": 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
index f29ac687..b78c70ba 100644
--- a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts
+++ b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts
@@ -1,5 +1,6 @@
import * as M from "maplibre-gl";
-import { baseMapStyles, defaultMapState, pois, type BaseLayer, type MapState } from "./layers";
+import { DebugLayer } from "./debug-layer";
+import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from "./layers";
import { OverlayLayer } from "./overlay-layer";
import { OverpassLayer } from "./overpass-layer";
@@ -8,7 +9,8 @@ import { OverpassLayer } from "./overpass-layer";
export class LayerManager {
private map: M.Map;
state!: MapState;
- layers: Record = {};
+ private layers: Record = {};
+ private addedListeners: Set = new Set();
constructor(map: M.Map) {
this.map = map;
@@ -30,9 +32,13 @@ export class LayerManager {
try {
this.update(this.state, true);
- const overpassLayer = new OverpassLayer()
- this.addLayer("overpass", overpassLayer)
+ const overpassLayer = new OverpassLayer(this.map)
+ const debugLayer = new DebugLayer()
+ this.addLayer("overpass", overpassLayer)
+ this.addLayer("debug", debugLayer)
+
+ this.map.on('moveend', this.updateOverpassLayerAfterMapMoveBinded);
} catch (e) {
console.error(e)
// map is probably not initialized yet
@@ -56,25 +62,35 @@ export class LayerManager {
}
}
- 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.updateOverpassLayer(newState);
this.state = newState
localStorage.setItem("map-state", JSON.stringify(this.state));
}
+ updateOverpassLayerAfterMapMoveBinded = this.updateOverpassLayerAfterMapMove.bind(this);
+ updateOverpassLayerAfterMapMove() {
+ this.updateOverpassLayer(this.state)
+ }
+
+ private async updateOverpassLayer(newState: MapState) {
+ const overpassLayer = this.layers.overpass;
+ if (overpassLayer) {
+ const castedOverpassLayer = overpassLayer as OverpassLayer;
+ overpassLayer.filter = await castedOverpassLayer.updateLayerIfNeeded(newState, this.map.getBounds());
+ (this.map.getSource('overpass') as M.GeoJSONSource).setData(castedOverpassLayer.data);
+ }
+ }
+
private updateBaseLayer(layer: string | M.StyleSpecification) {
this.map.setStyle(layer);
}
private addLayer(id: string, layer: BaseLayer) {
+ if (this.layers[id] && this.map.getLayer(id)) {
+ return;
+ }
for (const [id, s] of Object.entries(layer.spec.sources)) {
if (!this.map.getSource(id)) {
this.map.addSource(id, s)
@@ -87,6 +103,34 @@ export class LayerManager {
}
}
+ if (layer.listeners) {
+ for (const [id, listener] of Object.entries(layer.listeners)) {
+ if (listener.onEnter && !this.addedListeners.has("mouseenter-" + id)) {
+ this.addedListeners.add("mouseenter-" + id)
+ this.map.on('mouseenter', id, listener.onEnter);
+ }
+
+ if (listener.onLeave && !this.addedListeners.has("onleave-" + id)) {
+ this.addedListeners.add("mouseleave-" + id)
+ this.map.on('mouseleave', id, listener.onLeave);
+ }
+
+ if (listener.onMouseDown && !this.addedListeners.has("click-" + id)) {
+ this.addedListeners.add("click-" + id)
+ this.map.on('click', id, listener.onMouseDown);
+ }
+
+ if (listener.onMouseMove && !this.addedListeners.has("mousemove-" + id)) {
+ this.addedListeners.add("mousemove-" + id)
+ this.map.on('mousemove', id, listener.onMouseMove);
+ }
+ }
+ }
+
+ if (layer.filter && !this.map.getFilter(id)) {
+ this.map.setFilter(id, layer.filter)
+ }
+
this.layers[id] = layer
}
@@ -106,6 +150,29 @@ export class LayerManager {
this.map.removeSource(id)
}
}
+
+ if (layer.listeners) {
+ for (const [id, listener] of Object.entries(layer.listeners)) {
+ if (listener.onEnter) {
+ this.addedListeners.delete("mouseenter-" + id)
+ this.map.off('mouseenter', id, listener.onEnter);
+ }
+
+ if (listener.onLeave) {
+ this.addedListeners.delete("mouseleave-" + id)
+ this.map.off('mouseleave', id, listener.onLeave);
+ }
+ if (listener.onMouseDown) {
+ this.addedListeners.delete("click-" + id)
+ this.map.off('click', id, listener.onMouseDown);
+ }
+ if (listener.onMouseMove) {
+ this.addedListeners.delete("mousemove-" + id)
+ this.map.off('mousemove', id, listener.onMouseMove);
+ }
+ }
+ }
+
delete this.layers[id]
}
@@ -115,30 +182,4 @@ export class LayerManager {
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 = `
-
- `
-
- icon.src =
- 'data:image/svg+xml,' +
- encodeURIComponent(svg);
- }
- });
- }
}
\ 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
index 021fed21..d793d272 100644
--- a/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts
+++ b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts
@@ -1,4 +1,6 @@
-import type { LngLatBounds, StyleSpecification } from "maplibre-gl";
+import { createOverpassPopup } from "$lib/util/maplibre_util";
+import * as M from "maplibre-gl";
+import { type LngLatBounds, type MapMouseEvent, type StyleSpecification } from "maplibre-gl";
import { pois, type BaseLayer, type MapState } from "./layers";
import type { OverpassResponse } from "./types";
@@ -6,7 +8,13 @@ export class OverpassLayer implements BaseLayer {
private overpassApiURL: string = "https://overpass.private.coffee/api/interpreter"
data: GeoJSON.FeatureCollection = ({ type: 'FeatureCollection', features: [] });
- pois: string[] = [];
+
+ private minZoom = 12;
+
+ private cachedQueries: { x: string, y: string, query: string }[] = [];
+ private cachedData: { query: string, id: number, feature: GeoJSON.Feature }[] = []
+ private tileSize = 0.1;
+
spec: StyleSpecification = {
version: 8,
@@ -32,61 +40,281 @@ export class OverpassLayer implements BaseLayer {
],
};
+ listeners = {
+ "overpass": {
+ onMouseDown: (e: MapMouseEvent) => {
+ this.openPopup(e)
+ },
+ onEnter: (e: MapMouseEvent) => {
+ this.openPopup(e);
+ },
+ }
+ }
- 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)
+ private popup: M.Popup;
+ private map: M.Map;
+ private currentPopupCoordinates: GeoJSON.Position | null = null
+
+ constructor(map: M.Map) {
+ this.map = map;
+ this.popup = new M.Popup()
+ .setMaxWidth("420px")
+ }
+
+ private openPopup(e: MapMouseEvent) {
+ const features = (e as any).features as GeoJSON.Feature[];
+ const point = features[0].geometry as GeoJSON.Point;
+ const tags = JSON.parse(features[0].properties?.tags);
+ const content = createOverpassPopup(tags, point.coordinates);
+
+ this.currentPopupCoordinates = point.coordinates;
+ this.popup
+ .setLngLat(point.coordinates as M.LngLatLike)
+ .setDOMContent(content)
+ .addTo(this.map);
+
+ this.map.on("mousemove", this.distanceNotifierBinded)
+ }
+
+ distanceNotifierBinded = this.distanceNotifier.bind(this);
+ private distanceNotifier(e: MapMouseEvent) {
+ if (this.currentPopupCoordinates === null) {
+ return
+ }
+
+ if (this.map.project(this.currentPopupCoordinates as M.LngLatLike).dist(this.map.project(e.lngLat)) > 60) {
+ this.popup.remove();
+ this.map.off("mousemove", this.distanceNotifier)
+ this.currentPopupCoordinates = null;
+ }
+ }
+
+ async updateLayerIfNeeded(state: MapState, bounds: LngLatBounds) {
+
+ let activeQueries: string[] = this.getActiveQueries(state);
+
+ if (this.map.getZoom() >= this.minZoom) {
+
+ await this.fetchMissingTilesForBounds(bounds, activeQueries);
+ }
+
+
+ const filter: M.FilterSpecification = ['in', 'query', ...this.getActiveQueries(state)];
+
+ this.map.setFilter('overpass', filter);
+
+ return filter
+ }
+
+ private async fetchMissingTilesForBounds(bounds: M.LngLatBounds, activeQueries: string[]) {
+ const result: [string, LngLatBounds][] = [];
+
+ const south = bounds.getSouth();
+ const north = bounds.getNorth();
+ const west = bounds.getWest();
+ const east = bounds.getEast();
+
+ for (let lat = south; lat < north; lat += this.tileSize) {
+ for (let lng = west; lng < east; lng += this.tileSize) {
+ const x = Math.floor(lat / this.tileSize) * this.tileSize;
+ const y = Math.floor(lng / this.tileSize) * this.tileSize;
+ const cachedQueriesAtPosition = this.cachedQueries.filter(q => q.x == x.toFixed(4) && q.y == y.toFixed(4)) ?? [];
+ const missingQueries = activeQueries.filter(
+ (query) =>
+ !cachedQueriesAtPosition.some(
+ (querytile) =>
+ querytile.query === query
+ )
+ );
+ if (missingQueries.length > 0) {
+ const tileBounds = this.getBoundsForTile(x, y)
+ await this.fetchTile(x, y, missingQueries, tileBounds)
}
+
}
}
- const q = this.getOverpassQuery(activePOIs, bounds)
+
+ return result;
+ }
+
+ private async fetchTile(x: number, y: number, activeQueries: string[], bounds: LngLatBounds) {
+ const q = this.getOverpassQuery(activeQueries, 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
+ this.cacheData(x, y, response, activeQueries)
+ this.loadIcons(activeQueries)
+
}
- private updateData(data: OverpassResponse, ) {
- let pois: GeoJSON.Feature[] = [];
-
+ private cacheData(x: number, y: number, data: OverpassResponse, activeQueries: string[]) {
if (data.elements === undefined) {
return;
}
+ this.cachedQueries = this.cachedQueries.concat(activeQueries.map((query) => ({ x: x.toFixed(4), y: y.toFixed(4), query })));
+
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,
- },
+ for (let query of activeQueries) {
+ if (this.belongsToQuery(element, query)) {
+ this.cachedData.push({
+ query,
+ id: element.id,
+ feature: {
+ 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,
+ query: query,
+ icon: `overpass-${query}`,
+ tags: element.tags,
+ type: element.type,
+ },
+ },
+ });
+ }
+ }
+ }
+ this.data.features = this.cachedData.map(d => d.feature);
+ }
+
+ private getActiveQueries(state: MapState) {
+ const activeQueries: string[] = []
+ for (const category of Object.keys(state.pois)) {
+ for (const [name, active] of Object.entries(state.pois[category])) {
+ if (active) {
+ activeQueries.push(name)
+ }
+ }
+ }
+
+ return activeQueries
+ }
+
+ private belongsToQuery(element: OverpassResponse["elements"][number], query: string) {
+ if (Array.isArray(pois[query].tags)) {
+ return pois[query].tags.some((tags) => this.belongsToQueryItem(element, tags));
+ } else {
+ return this.belongsToQueryItem(element, pois[query].tags);
+ }
+ }
+
+ private belongsToQueryItem(element: any, tags: Record) {
+ return Object.entries(tags).every(([tag, value]) =>
+ Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
+ );
+ }
+
+ private getOverpassQuery(activeQueries: string[], bounds: LngLatBounds) {
+ return `[bbox:${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}][out:json];(${this.getQueries(activeQueries)});out center;`;
+
+ }
+
+ private getQueries(queries: string[]) {
+ return queries.map((query) => this.getQuery(query)).join('');
+ }
+
+ private getQuery(query: string) {
+ if (Array.isArray(pois[query].tags)) {
+ return pois[query].tags.map((tags) => this.getQueryItem(tags)).join('');
+ } else {
+ return this.getQueryItem(pois[query].tags);
+ }
+ }
+
+ private getQueryItem(tags: Record) {
+ let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
+ if (arrayEntry !== undefined) {
+ return (arrayEntry[1] as string[])
+ .map(
+ (val) =>
+ `nwr${Object.entries(tags)
+ .map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
+ .join('')};`
+ )
+ .join('');
+ } else {
+ return `nwr${Object.entries(tags)
+ .map(([tag, value]) => `[${tag}=${value}]`)
+ .join('')};`;
+ }
+ }
+
+ private loadIcons(activeQueries: string[]) {
+ activeQueries.forEach((q) => {
+ if (!this.map.hasImage(`overpass-${q}`)) {
+ let icon = new Image(100, 100);
+ icon.onload = () => {
+ if (!this.map.hasImage(`overpass-${q}`)) {
+ this.map.addImage(`overpass-${q}`, icon);
+ }
+ };
+
+ const svg = `
+
+ `
+
+ icon.src =
+ 'data:image/svg+xml,' +
+ encodeURIComponent(svg);
+ }
+ });
+ }
+
+ private getBoundsForTile(lat: number, lng: number): LngLatBounds {
+ return new M.LngLatBounds(
+ [lng, lat],
+ [lng + this.tileSize, lat + this.tileSize]
+ );
+ }
+
+ private showDebugTiles(tiles: [string, LngLatBounds][]) {
+ const features = tiles.map(([_, bounds]) => boundsToPolygonFeature(bounds));
+
+ const debugSource = this.map.getSource('debug') as M.GeoJSONSource;
+
+ if (debugSource) {
+ debugSource.setData({
+ type: 'FeatureCollection',
+ features,
});
}
- 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;`;
- }
+}
+
+function boundsToPolygonFeature(bounds: M.LngLatBounds): GeoJSON.Feature {
+ const [[west, south], [east, north]] = bounds.toArray();
+ const coordinates = [[
+ [west, south],
+ [east, south],
+ [east, north],
+ [west, north],
+ [west, south],
+ ]];
+
+ return {
+ type: 'Feature',
+ geometry: {
+ type: 'Polygon',
+ coordinates: coordinates,
+ },
+ properties: {},
+ };
}
\ No newline at end of file
diff --git a/web/src/routes/map/+page.svelte b/web/src/routes/map/+page.svelte
index 32de7ea5..cea10ba2 100644
--- a/web/src/routes/map/+page.svelte
+++ b/web/src/routes/map/+page.svelte
@@ -141,7 +141,6 @@
loading = true;
}
- console.log(filter);
const trailsInBox = await trails_search_bounding_box(
northEast,
southWest,