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 = ` - - - - ${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/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 = ` + + + + ${pois[q].icon.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,