diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index d035522..993ce85 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet, IcsCalendarGeneratorViewSet +from .views import AdventureViewSet, ChecklistViewSet, CollectionViewSet, NoteViewSet, StatsViewSet, GenerateDescription, ActivityTypesView, TransportationViewSet, AdventureImageViewSet, ReverseGeocodeViewSet, CategoryViewSet, IcsCalendarGeneratorViewSet, OverpassViewSet router = DefaultRouter() router.register(r'adventures', AdventureViewSet, basename='adventures') @@ -15,6 +15,7 @@ router.register(r'images', AdventureImageViewSet, basename='images') router.register(r'reverse-geocode', ReverseGeocodeViewSet, basename='reverse-geocode') router.register(r'categories', CategoryViewSet, basename='categories') router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-calendar') +router.register(r'overpass', OverpassViewSet, basename='overpass') urlpatterns = [ diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py index 2dbb013..1961fdc 100644 --- a/backend/server/adventures/views.py +++ b/backend/server/adventures/views.py @@ -21,6 +21,7 @@ from icalendar import Calendar, Event, vText, vCalAddress from django.http import HttpResponse from datetime import datetime from django.db.models import Max +from overpy import Overpass User = get_user_model() @@ -1329,3 +1330,184 @@ class IcsCalendarGeneratorViewSet(viewsets.ViewSet): response = HttpResponse(cal.to_ical(), content_type='text/calendar') response['Content-Disposition'] = 'attachment; filename=adventures.ics' return response + +class OverpassViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + BASE_URL = "https://overpass-api.de/api/interpreter" + HEADERS = {'User-Agent': 'AdventureLog Server'} + + def make_overpass_query(self, query): + """ + Sends a query to the Overpass API and returns the response data. + Args: + query (str): The Overpass QL query string. + Returns: + dict: Parsed JSON response from the Overpass API. + Raises: + Response: DRF Response object with an error message in case of failure. + """ + url = f"{self.BASE_URL}?data={query}" + try: + response = requests.get(url, headers=self.HEADERS) + response.raise_for_status() # Raise an exception for HTTP errors + return response.json() + except requests.exceptions.RequestException as e: + return Response({"error": str(e)}, status=500) + except requests.exceptions.JSONDecodeError: + return Response({"error": "Invalid response from Overpass API"}, status=400) + + def parse_overpass_response(self, data, request): + """ + Parses the JSON response from the Overpass API and extracts relevant data, + turning it into an adventure-structured object. + + Args: + response (dict): The JSON response from the Overpass API. + + Returns: + list: A list of adventure objects with structured data. + """ + # Extract elements (nodes/ways/relations) from the response + nodes = data.get('elements', []) + adventures = [] + + # include all entries, even the ones that do not have lat long + all = request.query_params.get('all', False) + + for node in nodes: + # Ensure we are working with a "node" type (can also handle "way" or "relation" if needed) + if node.get('type') not in ['node', 'way', 'relation']: + continue + + # Extract tags and general data + tags = node.get('tags', {}) + adventure = { + "id": node.get('id'), # Include the unique OSM ID + "type": node.get('type'), # Type of element (node, way, relation) + "name": tags.get('name', tags.get('official_name', '')), # Fallback to 'official_name' + "description": tags.get('description', None), # Additional descriptive information + "latitude": node.get('lat', None), # Use None for consistency with missing values + "longitude": node.get('lon', None), + "address": { + "city": tags.get('addr:city', None), + "housenumber": tags.get('addr:housenumber', None), + "postcode": tags.get('addr:postcode', None), + "state": tags.get('addr:state', None), + "street": tags.get('addr:street', None), + "country": tags.get('addr:country', None), # Add 'country' if available + "suburb": tags.get('addr:suburb', None), # Add 'suburb' for more granularity + }, + "feature_id": tags.get('gnis:feature_id', None), + "tag": next((tags.get(key, None) for key in ['leisure', 'tourism', 'natural', 'historic', 'amenity'] if key in tags), None), + "contact": { + "phone": tags.get('phone', None), + "email": tags.get('contact:email', None), + "website": tags.get('website', None), + "facebook": tags.get('contact:facebook', None), # Social media links + "twitter": tags.get('contact:twitter', None), + }, + # "tags": tags, # Include all raw tags for future use + } + + # Filter out adventures with no meaningful data + if any([ + adventure["name"], + adventure["latitude"], + adventure["longitude"], + ] + ) or all: + adventures.append(adventure) + + return adventures + + + @action(detail=False, methods=['get']) + def query(self, request): + """ + Radius-based search for tourism-related locations around given coordinates. + """ + lat = request.query_params.get('lat') + lon = request.query_params.get('lon') + radius = request.query_params.get('radius', '1000') # Default radius: 1000 meters + + valid_categories = ['lodging', 'food', 'tourism'] + category = request.query_params.get('category', 'all') + if category not in valid_categories: + return Response({"error": f"Invalid category. Valid categories: {', '.join(valid_categories)}"}, status=400) + + if category == 'tourism': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["tourism"]; + node(around:{radius},{lat},{lon})["leisure"]; + node(around:{radius},{lat},{lon})["historic"]; + node(around:{radius},{lat},{lon})["sport"]; + node(around:{radius},{lat},{lon})["natural"]; + node(around:{radius},{lat},{lon})["attraction"]; + node(around:{radius},{lat},{lon})["museum"]; + node(around:{radius},{lat},{lon})["zoo"]; + node(around:{radius},{lat},{lon})["aquarium"]; + ); + out; + """ + if category == 'lodging': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["tourism"="hotel"]; + node(around:{radius},{lat},{lon})["tourism"="motel"]; + node(around:{radius},{lat},{lon})["tourism"="guest_house"]; + node(around:{radius},{lat},{lon})["tourism"="hostel"]; + node(around:{radius},{lat},{lon})["tourism"="camp_site"]; + node(around:{radius},{lat},{lon})["tourism"="caravan_site"]; + node(around:{radius},{lat},{lon})["tourism"="chalet"]; + node(around:{radius},{lat},{lon})["tourism"="alpine_hut"]; + node(around:{radius},{lat},{lon})["tourism"="apartment"]; + ); + out; + """ + if category == 'food': + query = f""" + [out:json]; + ( + node(around:{radius},{lat},{lon})["amenity"="restaurant"]; + node(around:{radius},{lat},{lon})["amenity"="cafe"]; + node(around:{radius},{lat},{lon})["amenity"="fast_food"]; + node(around:{radius},{lat},{lon})["amenity"="pub"]; + node(around:{radius},{lat},{lon})["amenity"="bar"]; + node(around:{radius},{lat},{lon})["amenity"="food_court"]; + node(around:{radius},{lat},{lon})["amenity"="ice_cream"]; + node(around:{radius},{lat},{lon})["amenity"="bakery"]; + node(around:{radius},{lat},{lon})["amenity"="confectionery"]; + ); + out; + """ + + # Validate required parameters + if not lat or not lon: + return Response( + {"error": "Latitude and longitude parameters are required."}, status=400 + ) + + data = self.make_overpass_query(query) + adventures = self.parse_overpass_response(data, request) + return Response(adventures) + + @action(detail=False, methods=['get'], url_path='search') + def search(self, request): + """ + Name-based search for nodes with the specified name. + """ + name = request.query_params.get('name') + + # Validate required parameter + if not name: + return Response({"error": "Name parameter is required."}, status=400) + + # Construct Overpass API query + query = f'[out:json];node["name"~"{name}",i];out;' + data = self.make_overpass_query(query) + + adventures = self.parse_overpass_response(data, request) + return Response(adventures) diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index 0e2ccbf..80ba65b 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -19,4 +19,5 @@ django-widget-tweaks==1.5.0 django-ical==1.9.2 icalendar==6.1.0 ijson==3.3.0 -tqdm==4.67.1 \ No newline at end of file +tqdm==4.67.1 +overpy==0.7 \ No newline at end of file diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index b476fe0..17dcae4 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -347,3 +347,120 @@ export let themes = [ { name: 'aestheticDark', label: 'Aesthetic Dark' }, { name: 'northernLights', label: 'Northern Lights' } ]; + +export function osmTagToEmoji(tag: string) { + switch (tag) { + case 'camp_site': + return 'πŸ•οΈ'; + case 'slipway': + return 'πŸ›³οΈ'; + case 'playground': + return 'πŸ›'; + case 'viewpoint': + return 'πŸ‘€'; + case 'cape': + return '🏞️'; + case 'beach': + return 'πŸ–οΈ'; + case 'park': + return '🌳'; + case 'museum': + return 'πŸ›οΈ'; + case 'theme_park': + return '🎒'; + case 'nature_reserve': + return '🌲'; + case 'memorial': + return 'πŸ•ŠοΈ'; + case 'monument': + return 'πŸ—Ώ'; + case 'wood': + return '🌲'; + case 'zoo': + return '🦁'; + case 'attraction': + return '🎑'; + case 'ruins': + return '🏚️'; + case 'bay': + return '🌊'; + case 'hotel': + return '🏨'; + case 'motel': + return '🏩'; + case 'pub': + return '🍺'; + case 'restaurant': + return '🍽️'; + case 'cafe': + return 'β˜•'; + case 'bakery': + return 'πŸ₯'; + case 'archaeological_site': + return '🏺'; + case 'lighthouse': + return 'πŸ—Ό'; + case 'tree': + return '🌳'; + case 'cliff': + return '⛰️'; + case 'water': + return 'πŸ’§'; + case 'fishing': + return '🎣'; + case 'golf_course': + return 'β›³'; + case 'swimming_pool': + return '🏊'; + case 'stadium': + return '🏟️'; + case 'cave_entrance': + return 'πŸ•³οΈ'; + case 'anchor': + return 'βš“'; + case 'garden': + return '🌼'; + case 'disc_golf_course': + return 'πŸ₯'; + case 'natural': + return '🌿'; + case 'ice_rink': + return '⛸️'; + case 'horse_riding': + return '🐎'; + case 'wreck': + return '🚒'; + case 'water_park': + return 'πŸ’¦'; + case 'picnic_site': + return '🧺'; + case 'axe_throwing': + return 'πŸͺ“'; + case 'fort': + return '🏰'; + case 'amusement_arcade': + return 'πŸ•ΉοΈ'; + case 'tepee': + return 'πŸ•οΈ'; + case 'track': + return 'πŸƒ'; + case 'trampoline_park': + return '🀸'; + case 'dojo': + return 'πŸ₯‹'; + case 'tree_stump': + return 'πŸͺ΅'; + case 'peak': + return 'πŸ”οΈ'; + case 'fitness_centre': + return 'πŸ‹οΈ'; + case 'artwork': + return '🎨'; + case 'fast_food': + return 'πŸ”'; + case 'ice_cream': + return '🍦'; + default: + return 'πŸ“'; // Default placeholder emoji for unknown tags + } +} diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index c4107b8..b72cf18 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -26,7 +26,8 @@ groupAdventuresByDate, groupNotesByDate, groupTransportationsByDate, - groupChecklistsByDate + groupChecklistsByDate, + osmTagToEmoji } from '$lib'; import ChecklistCard from '$lib/components/ChecklistCard.svelte'; import ChecklistModal from '$lib/components/ChecklistModal.svelte'; @@ -240,6 +241,42 @@ isAdventureModalOpen = false; } + let isPopupOpen = false; + + function togglePopup() { + isPopupOpen = !isPopupOpen; + } + + let recomendationsData: any; + let loadingRecomendations: boolean = false; + let recomendationsRange: number = 1600; + let recomendationType: string = 'tourism'; + let recomendationTags: string[] = []; + let selectedRecomendationTag: string = ''; + + async function getRecomendations(adventure: Adventure) { + recomendationsData = null; + loadingRecomendations = true; + let res = await fetch( + `/api/overpass/query/?lat=${adventure.latitude}&lon=${adventure.longitude}&radius=${recomendationsRange}&category=${recomendationType}` + ); + if (!res.ok) { + console.log('Error fetching recommendations'); + return; + } + let data = await res.json(); + recomendationsData = data; + + console.log(data); + if (recomendationsData) { + recomendationTags = [ + ...new Set(recomendationsData.map((r: any) => r.tag).filter(Boolean)) + ] as string[]; + } + loadingRecomendations = false; + console.log(recomendationTags); + } + function saveOrCreateTransportation(event: CustomEvent) { if (transportations.find((transportation) => transportation.id === event.detail.id)) { // Update existing transportation @@ -476,7 +513,7 @@ {#if collection.id}
- {/if} @@ -800,6 +845,167 @@
{/if} + {#if currentView == 'recommendations'} +
+
+

Adventure Recommendations

+ {#each adventures as adventure} + + {/each} +
+ +
+ {Math.round(recomendationsRange / 1600)} mile ({( + (recomendationsRange / 1600) * + 1.6 + ).toFixed(1)} km) +
+
+ (recomendationType = 'tourism')} + /> + (recomendationType = 'food')} + /> + (recomendationType = 'lodging')} + /> +
+ {#if recomendationTags.length > 0} + + {/if} +
+ + {#if recomendationsData} + + {#each recomendationsData as recomendation} + {#if recomendation.longitude && recomendation.latitude && recomendation.name} + + + {osmTagToEmoji(recomendation.tag)} + + {#if isPopupOpen} + (isPopupOpen = false)}> +
{recomendation.name}
+ +

+ {`${recomendation.tag} ${osmTagToEmoji(recomendation.tag)}`} +

+ + +
+ {/if} +
+ {/if} + {/each} +
+ {#each recomendationsData as recomendation} + {#if recomendation.name && recomendation.longitude && recomendation.latitude} +
+
+

+ {recomendation.name || 'Recommendation'} +

+
{recomendation.tag}
+

{recomendation.description || 'No description available.'}

+ {#if recomendation.address} +

+ Address: + {recomendation.address.housenumber} + {recomendation.address.street}, {recomendation.address.city}, {recomendation + .address.state} + {recomendation.address.postcode} +

+ {/if} + {#if recomendation.contact} +

+ Contact: + {#if recomendation.contact.phone} + Phone: {recomendation.contact.phone} + {/if} + {#if recomendation.contact.email} + Email: {recomendation.contact.email} + {/if} + {#if recomendation.contact.website} + Website: {recomendation.contact.website} + {/if} +

+ {/if} +
+
+ {/if} + {/each} + {/if} + {#if loadingRecomendations} +
+
+
+ +
+

+ Discovering hidden gems for your next adventure... +

+
+
+
+
+ {/if} +
+
+ {/if} {/if} diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index d85902f..6c35530 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -130,7 +130,7 @@ class="join-item btn" type="radio" name="filter" - aria-label={$t('adventures.activity_types')} + aria-label={$t('adventures.tags')} id="activity_types" on:change={() => (property = 'activity_types')} />