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"; export class OverpassLayer implements BaseLayer { private overpassApiURL: string = "https://overpass.private.coffee/api/interpreter" data: GeoJSON.FeatureCollection = ({ type: 'FeatureCollection', features: [] }); 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, 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], }, } ], }; listeners = { "overpass": { onMouseDown: (e: MapMouseEvent) => { this.openPopup(e) }, onEnter: (e: MapMouseEvent) => { this.openPopup(e); }, } } 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) } } } 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.cacheData(x, y, response, activeQueries) this.loadIcons(activeQueries) } 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) { 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, }); } } } 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: {}, }; }