1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-23 14:59:36 +02:00

feat: add OverpassViewSet and implement osmTagToEmoji function; update requirements and aria-labels

This commit is contained in:
Sean Morley 2025-01-15 15:39:21 -05:00
parent 0588555707
commit 62efa2478e
6 changed files with 512 additions and 5 deletions

View file

@ -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 = [

View file

@ -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)

View file

@ -20,3 +20,4 @@ django-ical==1.9.2
icalendar==6.1.0
ijson==3.3.0
tqdm==4.67.1
overpy==0.7

View file

@ -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
}
}

View file

@ -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<Transportation>) {
if (transportations.find((transportation) => transportation.id === event.detail.id)) {
// Update existing transportation
@ -476,7 +513,7 @@
{#if collection.id}
<div class="flex justify-center mx-auto">
<!-- svelte-ignore a11y-missing-attribute -->
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-xl">
<div role="tablist" class="tabs tabs-boxed tabs-lg max-w-full">
<!-- svelte-ignore a11y-missing-attribute -->
{#if collection.start_date}
<a
@ -508,6 +545,14 @@
on:click={() => (currentView = 'map')}
on:keydown={(e) => e.key === 'Enter' && (currentView = 'map')}>Map</a
>
<a
role="tab"
class="tab {currentView === 'recommendations' ? 'tab-active' : ''}"
tabindex="0"
on:click={() => (currentView = 'recommendations')}
on:keydown={(e) => e.key === 'Enter' && (currentView = 'recommendations')}
>Recommendations</a
>
</div>
</div>
{/if}
@ -800,6 +845,167 @@
</div>
</div>
{/if}
{#if currentView == 'recommendations'}
<div class="card bg-base-200 shadow-xl my-8 mx-auto w-10/12">
<div class="card-body">
<h2 class="card-title text-3xl justify-center mb-4">Adventure Recommendations</h2>
{#each adventures as adventure}
<button on:click={() => getRecomendations(adventure)} class="btn btn-neutral"
>{adventure.name}</button
>
{/each}
<div class="mt-4">
<input
type="range"
min="1600"
max="80467"
class="range"
step="1600"
bind:value={recomendationsRange}
/>
<div class="flex w-full justify-between px-2">
<span class="text-lg"
>{Math.round(recomendationsRange / 1600)} mile ({(
(recomendationsRange / 1600) *
1.6
).toFixed(1)} km)</span
>
</div>
<div class="join flex items-center justify-center mt-4">
<input
class="join-item btn"
type="radio"
name="options"
aria-label="Tourism"
checked={recomendationType == 'tourism'}
on:click={() => (recomendationType = 'tourism')}
/>
<input
class="join-item btn"
type="radio"
name="options"
aria-label="Food"
checked={recomendationType == 'food'}
on:click={() => (recomendationType = 'food')}
/>
<input
class="join-item btn"
type="radio"
name="options"
aria-label="Lodging"
checked={recomendationType == 'lodging'}
on:click={() => (recomendationType = 'lodging')}
/>
</div>
{#if recomendationTags.length > 0}
<select class="select select-bordered w-full max-w-xs">
<option disabled selected>Select a tag</option>
{#each recomendationTags as tag}
<option on:click={() => (selectedRecomendationTag = tag)}>{tag}</option>
{/each}
</select>
{/if}
</div>
{#if recomendationsData}
<MapLibre
style="https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json"
class="aspect-[9/16] max-h-[70vh] sm:aspect-video sm:max
-h-full w-full rounded-lg"
standardControls
center={{ lng: recomendationsData[0].longitude, lat: recomendationsData[0].latitude }}
zoom={12}
>
{#each recomendationsData as recomendation}
{#if recomendation.longitude && recomendation.latitude && recomendation.name}
<Marker
lngLat={[recomendation.longitude, recomendation.latitude]}
class="grid h-8 w-8 place-items-center rounded-full border border-gray-200 bg-blue-300 text-black focus:outline-6 focus:outline-black"
on:click={togglePopup}
>
<span class="text-xl">
{osmTagToEmoji(recomendation.tag)}
</span>
{#if isPopupOpen}
<Popup openOn="click" offset={[0, -10]} on:close={() => (isPopupOpen = false)}>
<div class="text-lg text-black font-bold">{recomendation.name}</div>
<p class="font-semibold text-black text-md">
{`${recomendation.tag} ${osmTagToEmoji(recomendation.tag)}`}
</p>
<button
class="btn btn-neutral btn-wide btn-sm mt-4"
on:click={() =>
window.open(
`https://www.openstreetmap.org/node/${recomendation.id}`,
'_blank'
)}>{$t('map.view_details')}</button
>
</Popup>
{/if}
</Marker>
{/if}
{/each}
</MapLibre>
{#each recomendationsData as recomendation}
{#if recomendation.name && recomendation.longitude && recomendation.latitude}
<div class="card bg-base-100 shadow-xl my-4 w-full">
<div class="card-body">
<h2 class="card-title text-xl font-bold">
{recomendation.name || 'Recommendation'}
</h2>
<div class="badge badge-primary">{recomendation.tag}</div>
<p class="text-md">{recomendation.description || 'No description available.'}</p>
{#if recomendation.address}
<p class="text-md">
<strong>Address:</strong>
{recomendation.address.housenumber}
{recomendation.address.street}, {recomendation.address.city}, {recomendation
.address.state}
{recomendation.address.postcode}
</p>
{/if}
{#if recomendation.contact}
<p class="text-md">
<strong>Contact:</strong>
{#if recomendation.contact.phone}
Phone: {recomendation.contact.phone}
{/if}
{#if recomendation.contact.email}
Email: {recomendation.contact.email}
{/if}
{#if recomendation.contact.website}
Website: <a
href={recomendation.contact.website}
target="_blank"
rel="noopener noreferrer">{recomendation.contact.website}</a
>
{/if}
</p>
{/if}
</div>
</div>
{/if}
{/each}
{/if}
{#if loadingRecomendations}
<div class="card bg-base-100 shadow-xl my-4 w-full">
<div class="card-body">
<div class="flex flex-col items-center justify-center">
<span class="loading loading-ring loading-lg"></span>
<div class="mt-2">
<p class="text-center text-lg">
Discovering hidden gems for your next adventure...
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
{/if}
<svelte:head>

View file

@ -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')}
/>