diff --git a/website/src/lib/assets/layers.ts b/website/src/lib/assets/layers.ts index fb0f2459..4852138a 100644 --- a/website/src/lib/assets/layers.ts +++ b/website/src/lib/assets/layers.ts @@ -1,5 +1,5 @@ import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public'; -import { TramFront } from 'lucide-static'; +import { TramFront, Utensils, ShoppingBasket, Droplet, ShowerHead, Fuel, CircleParking, Fence, FerrisWheel, Telescope, Bed, Mountain, Pickaxe, Store, TrainFront, Bus, Ship, Croissant } from 'lucide-static'; import { type AnySourceData, type Style } from 'mapbox-gl'; export const basemaps: { [key: string]: string | Style; } = { @@ -544,8 +544,38 @@ export const overlayTree: LayerTreeType = { // Hierachy containing all Overpass layers export const overpassTree: LayerTreeType = { points_of_interest: { - transport: { - tram: true, + food: { + bakery: true, + "food-store": true, + "eat-and-drink": true, + }, + amenities: { + toilets: true, + "water": true, + "water-spring": true, + shower: true, + "fuel-station": true, + parking: true, + barrier: true + }, + tourism: { + attraction: true, + viewpoint: true, + sleep: true, + summit: true, + pass: true, + climbing: true, + }, + bicycle: { + "bicycle-parking": true, + "bicycle-rental": true, + "bicycle-shop": true + }, + "public-transport": { + "railway-station": true, + "tram-stop": true, + "bus-stop": true, + ferry: true }, }, }; @@ -598,8 +628,38 @@ export const defaultOverlays = { // Default Overpass queries used (none) export const defaultOverpassQueries: LayerTreeType = { points_of_interest: { - transport: { - tram: false, + "food": { + bakery: false, + "food-store": false, + "eat-and-drink": false, + }, + amenities: { + toilets: false, + "water": false, + "water-spring": false, + shower: false, + "fuel-station": false, + parking: false, + barrier: false + }, + tourism: { + attraction: false, + viewpoint: false, + sleep: false, + summit: false, + pass: false, + climbing: false + }, + bicycle: { + "bicycle-parking": false, + "bicycle-rental": false, + "bicycle-shop": false + }, + "public-transport": { + "railway-station": false, + "tram-stop": false, + "bus-stop": false, + ferry: false }, }, }; @@ -702,8 +762,38 @@ export const defaultOverlayTree: LayerTreeType = { // Default Overpass queries shown in the layer menu export const defaultOverpassTree: LayerTreeType = { points_of_interest: { - transport: { - tram: true, + "food": { + bakery: true, + "food-store": true, + "eat-and-drink": true, + }, + amenities: { + toilets: true, + "water": true, + "water-spring": true, + shower: true, + "fuel-station": false, + parking: false, + barrier: false + }, + tourism: { + attraction: true, + viewpoint: true, + sleep: true, + summit: true, + pass: true, + climbing: false + }, + bicycle: { + "bicycle-parking": true, + "bicycle-rental": true, + "bicycle-shop": true + }, + "public-transport": { + "railway-station": true, + "tram-stop": true, + "bus-stop": true, + ferry: true }, }, }; @@ -718,18 +808,223 @@ export type CustomLayer = { value: string | {}, }; -type OverpassQuery = Record; - -export const overpassQueries: Record = { - tram: { - railway: 'tram_stop', +type OverpassQueryData = { + icon: { + svg: string, + color: string, }, + tags: Record, }; -export const overpassIcons: Record = { - tram: { - svg: TramFront, - color: '#000000', +export const overpassQueryData: Record = { + "bakery": { + icon: { + svg: Croissant, + color: "Coral", + }, + tags: { + shop: "bakery" + } + }, + "food-store": { + icon: { + svg: ShoppingBasket, + color: "Coral", + }, + tags: { + shop: ["supermarket", "convenience"], + } + }, + "eat-and-drink": { + icon: { + svg: Utensils, + color: "Coral", + }, + tags: { + amenity: ["restaurant", "fast_food", "cafe", "pub", "bar"] + } + }, + "toilets": { + icon: { + svg: Droplet, + color: "DeepSkyBlue", + }, + tags: { + amenity: "toilets" + } + }, + water: { + icon: { + svg: Droplet, + color: "DeepSkyBlue", + }, + tags: { + amenity: ["drinking_water", "water_point"] + } + }, + "water-spring": { + icon: { + svg: Droplet, + color: "DeepSkyBlue", + }, + tags: { + natural: "spring", + drinking_water: "yes" + } + }, + shower: { + icon: { + svg: ShowerHead, + color: "DeepSkyBlue", + }, + tags: { + amenity: "shower" + } + }, + "fuel-station": { + icon: { + svg: Fuel, + color: "#000000", + }, + tags: { + amenity: "fuel" + } + }, + parking: { + icon: { + svg: CircleParking, + color: "#000000", + }, + tags: { + amenity: "parking" + } + }, + barrier: { + icon: { + svg: Fence, + color: "#000000", + }, + tags: { + barrier: true + } + }, + attraction: { + icon: { + svg: FerrisWheel, + color: "Green", + }, + tags: { + tourism: "attraction" + } + }, + viewpoint: { + icon: { + svg: Telescope, + color: "Green", + }, + tags: { + tourism: "viewpoint" + } + }, + sleep: { + icon: { + svg: Bed, + color: "Green", + }, + tags: { + tourism: ["hotel", "hostel", "guest_house", "motel", "camp_site", "alpine_hut", "wilderness_hut"] + } + }, + summit: { + icon: { + svg: Mountain, + color: "Green", + }, + tags: { + natural: "peak" + } + }, + pass: { + icon: { + svg: Mountain, + color: "Green", + }, + tags: { + mountain_pass: "yes" + } + }, + climbing: { + icon: { + svg: Pickaxe, + color: "Green", + }, + tags: { + sport: "climbing" + } + }, + "bicycle-parking": { + icon: { + svg: CircleParking, + color: "HotPink", + }, + tags: { + amenity: "bicycle_parking" + } + }, + "bicycle-rental": { + icon: { + svg: Store, + color: "HotPink", + }, + tags: { + amenity: "bicycle_rental" + } + }, + "bicycle-shop": { + icon: { + svg: Store, + color: "HotPink", + }, + tags: { + shop: "bicycle" + } + }, + "railway-station": { + icon: { + svg: TrainFront, + color: "DarkBlue", + }, + tags: { + railway: "station" + } + }, + "tram-stop": { + icon: { + svg: TramFront, + color: 'DarkBlue', + }, + tags: { + railway: "tram_stop" + }, + }, + "bus-stop": { + icon: { + svg: Bus, + color: "DarkBlue", + }, + tags: { + "public_transport": ["stop_position", "platform"], + bus: "yes" + } + }, + ferry: { + icon: { + svg: Ship, + color: "DarkBlue", + }, + tags: { + amenity: "ferry_terminal" + } } }; diff --git a/website/src/lib/components/layer-control/LayerControl.svelte b/website/src/lib/components/layer-control/LayerControl.svelte index 4c2bc810..1e85e68f 100644 --- a/website/src/lib/components/layer-control/LayerControl.svelte +++ b/website/src/lib/components/layer-control/LayerControl.svelte @@ -13,6 +13,7 @@ import { get, writable } from 'svelte/store'; import { getLayers } from './utils'; import { OverpassLayer } from './OverpassLayer'; + import OverpassPopup from './OverpassPopup.svelte'; let container: HTMLDivElement; let overpassLayer: OverpassLayer; @@ -186,6 +187,8 @@ + + { if (open && !cancelEvents && !container.contains(e.target)) { diff --git a/website/src/lib/components/layer-control/OverpassLayer.ts b/website/src/lib/components/layer-control/OverpassLayer.ts index 9a6e5112..011fd065 100644 --- a/website/src/lib/components/layer-control/OverpassLayer.ts +++ b/website/src/lib/components/layer-control/OverpassLayer.ts @@ -4,7 +4,7 @@ import mapboxgl from "mapbox-gl"; import { get, writable } from "svelte/store"; import { liveQuery } from "dexie"; import { db, settings } from "$lib/db"; -import { overpassIcons, overpassQueries } from "$lib/assets/layers"; +import { overpassQueryData } from "$lib/assets/layers"; const { currentOverpassQueries @@ -14,6 +14,14 @@ const mercator = new SphericalMercator({ size: 256, }); +export const overpassPopupPOI = writable | null>(null); + +export const overpassPopup = new mapboxgl.Popup({ + closeButton: false, + maxWidth: undefined, + offset: 15, +}); + let data = writable({ type: 'FeatureCollection', features: [] }); liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => { @@ -27,10 +35,13 @@ export class OverpassLayer { map: mapboxgl.Map; currentQueries: Set = new Set(); + nextQueries: Map = new Map(); unsubscribes: (() => void)[] = []; queryIfNeededBinded = this.queryIfNeeded.bind(this); updateBinded = this.update.bind(this); + onHoverBinded = this.onHover.bind(this); + maybeHidePopupBinded = this.maybeHidePopup.bind(this); constructor(map: mapboxgl.Map) { this.map = map; @@ -45,8 +56,6 @@ export class OverpassLayer { this.queryIfNeededBinded(); })); - this.map.showTileBoundaries = true; - this.update(); } @@ -79,10 +88,13 @@ export class OverpassLayer { type: 'symbol', source: 'overpass', layout: { - 'icon-image': ['get', 'query'], + 'icon-image': ['get', 'icon'], 'icon-size': 0.25, }, }); + + this.map.on('mouseenter', 'overpass', this.onHoverBinded); + this.map.on('click', 'overpass', this.onHoverBinded); } this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]); @@ -105,6 +117,27 @@ export class OverpassLayer { } } + onHover(e: any) { + overpassPopupPOI.set(e.features[0].properties); + overpassPopup.setLngLat(e.features[0].geometry.coordinates); + overpassPopup.addTo(this.map); + this.map.on('mousemove', this.maybeHidePopupBinded); + } + + maybeHidePopup(e: any) { + let poi = get(overpassPopupPOI); + if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 100) { + this.hideWaypointPopup(); + } + } + + hideWaypointPopup() { + overpassPopupPOI.set(null); + overpassPopup.remove(); + + this.map.off('mousemove', this.maybeHidePopupBinded); + } + query(bbox: [number, number, number, number]) { let queries = getCurrentQueries(); if (queries.length === 0) { @@ -130,11 +163,23 @@ export class OverpassLayer { } queryTile(x: number, y: number, queries: string[]) { + if (this.currentQueries.size > 5) { + return; + } + this.currentQueries.add(`${x},${y}`); const bounds = mercator.bbox(x, y, this.queryZoom); fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`) - .then((response) => response.json()) + .then((response) => { + if (response.ok) { + try { + return response.json(); + } catch (e) { } + } + this.currentQueries.delete(`${x},${y}`); + return Promise.reject(); + }, () => (this.currentQueries.delete(`${x},${y}`))) .then((data) => this.storeOverpassData(x, y, queries, data)); } @@ -142,6 +187,10 @@ export class OverpassLayer { let queryTiles = queries.map((query) => ({ x, y, query })); let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = []; + if (data.elements === undefined) { + return; + } + for (let element of data.elements) { for (let query of queries) { if (belongsToQuery(element, query)) { @@ -152,13 +201,18 @@ export class OverpassLayer { type: 'Feature', geometry: { type: 'Point', - coordinates: [element.lon, element.lat], + 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 }, - properties: { query, tags: element.tags }, } }); - - break; } } } @@ -174,17 +228,21 @@ export class OverpassLayer { loadIcons() { let currentQueries = getCurrentQueries(); currentQueries.forEach((query) => { - if (!this.map.hasImage(query)) { + if (!this.map.hasImage(`overpass-${query}`)) { let icon = new Image(100, 100); - icon.onload = () => this.map.addImage(query, icon); + icon.onload = () => { + if (!this.map.hasImage(`overpass-${query}`)) { + this.map.addImage(`overpass-${query}`, icon); + } + } // Lucide icons are SVG files with a 24x24 viewBox // Create a new SVG with a 32x32 viewBox and center the icon in a circle icon.src = 'data:image/svg+xml,' + encodeURIComponent(` - + - ${overpassIcons[query].svg.replace('stroke="currentColor"', 'stroke="white"')} + ${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')} `); @@ -194,22 +252,29 @@ export class OverpassLayer { } function getQueryForBounds(bounds: [number, number, number, number], queries: string[]) { - return `[bbox:${bounds[1]},${bounds[0]},${bounds[3]},${bounds[2]}][out:json];(${getQueries(queries)});out;`; + return `[bbox:${bounds[1]},${bounds[0]},${bounds[3]},${bounds[2]}][out:json];(${getQueries(queries)});out center;`; } function getQueries(queries: string[]) { - return queries.map((query) => `node${getQuery(query)};`).join(''); + return queries.map((query) => getQuery(query)).join(''); } function getQuery(query: string) { - return Object.entries(overpassQueries[query]) - .map(([tag, value]) => value ? `[${tag}=${value}]` : `[${tag}]`) - .join(''); + let arrayEntry = Object.entries(overpassQueryData[query].tags).find(([_, value]) => Array.isArray(value)); + if (arrayEntry !== undefined) { + return arrayEntry[1].map((val) => `nwr${Object.entries(overpassQueryData[query].tags) + .map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`) + .join('')};`).join(''); + } else { + return `nwr${Object.entries(overpassQueryData[query].tags) + .map(([tag, value]) => `[${tag}=${value}]`) + .join('')};`; + } } function belongsToQuery(element: any, query: string) { - return Object.entries(overpassQueries[query]) - .every(([tag, value]) => value ? element.tags[tag] === value : element.tags[tag]); + return Object.entries(overpassQueryData[query].tags) + .every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value); } function getCurrentQueries() { diff --git a/website/src/lib/components/layer-control/OverpassPopup.svelte b/website/src/lib/components/layer-control/OverpassPopup.svelte new file mode 100644 index 00000000..bf0f2853 --- /dev/null +++ b/website/src/lib/components/layer-control/OverpassPopup.svelte @@ -0,0 +1,70 @@ + + + diff --git a/website/src/locales/en.json b/website/src/locales/en.json index dc812d20..af79303b 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -285,7 +285,36 @@ "waymarkedTrailsMTB": "MTB", "waymarkedTrailsSkating": "Skating", "waymarkedTrailsHorseRiding": "Horse Riding", - "waymarkedTrailsWinter": "Winter" + "waymarkedTrailsWinter": "Winter", + "points_of_interest": "Points of interest", + "food": "Food", + "bakery": "Bakery", + "food-store": "Food Store", + "eat-and-drink": "Eat and Drink", + "amenities": "Amenities", + "toilets": "Toilets", + "water": "Water", + "water-spring": "Water Spring", + "shower": "Shower", + "fuel-station": "Fuel Station", + "parking": "Parking", + "barrier": "Barrier", + "tourism": "Tourism", + "attraction": "Attraction", + "viewpoint": "Viewpoint", + "sleep": "Sleep", + "summit": "Summit", + "pass": "Pass", + "climbing": "Climbing", + "bicycle": "Bicycle", + "bicycle-parking": "Bicycle Parking", + "bicycle-rental": "Bicycle Rental", + "bicycle-shop": "Bicycle Shop", + "public-transport": "Public Transport", + "railway-station": "Railway Station", + "tram-stop": "Tram Stop", + "bus-stop": "Bus Stop", + "ferry": "Ferry" }, "color": { "blue": "Blue",