1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 13:15:18 +02:00

feat: add geojson support to ActivitySerializer and ActivityCard; enhance location page with activity summaries and GPS tracks

This commit is contained in:
Sean Morley 2025-08-04 11:34:37 -04:00
parent eae416981d
commit 306b39d245
4 changed files with 251 additions and 20 deletions

View file

@ -7,6 +7,8 @@ from users.serializers import CustomUserDetailsSerializer
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer
from geopy.distance import geodesic from geopy.distance import geodesic
from integrations.models import ImmichIntegration from integrations.models import ImmichIntegration
import gpxpy
import geojson
class ContentImageSerializer(CustomModelSerializer): class ContentImageSerializer(CustomModelSerializer):
@ -107,6 +109,7 @@ class TrailSerializer(CustomModelSerializer):
return 'External Link' return 'External Link'
class ActivitySerializer(CustomModelSerializer): class ActivitySerializer(CustomModelSerializer):
geojson = serializers.SerializerMethodField()
class Meta: class Meta:
model = Activity model = Activity
@ -115,7 +118,7 @@ class ActivitySerializer(CustomModelSerializer):
'distance', 'moving_time', 'elapsed_time', 'rest_time', 'elevation_gain', 'distance', 'moving_time', 'elapsed_time', 'rest_time', 'elevation_gain',
'elevation_loss', 'elev_high', 'elev_low', 'start_date', 'start_date_local', 'elevation_loss', 'elev_high', 'elev_low', 'start_date', 'start_date_local',
'timezone', 'average_speed', 'max_speed', 'average_cadence', 'calories', 'timezone', 'average_speed', 'max_speed', 'average_cadence', 'calories',
'start_lat', 'start_lng', 'end_lat', 'end_lng', 'external_service_id' 'start_lat', 'start_lng', 'end_lat', 'end_lng', 'external_service_id', 'geojson'
] ]
read_only_fields = ['id', 'user'] read_only_fields = ['id', 'user']
@ -125,9 +128,33 @@ class ActivitySerializer(CustomModelSerializer):
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "") public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/').replace("'", "")
representation['gpx_file'] = f"{public_url}/media/{instance.gpx_file.name}" representation['gpx_file'] = f"{public_url}/media/{instance.gpx_file.name}"
return representation return representation
def get_geojson(self, obj):
if not obj.gpx_file:
return None
try:
with obj.gpx_file.open('r') as f:
gpx = gpxpy.parse(f)
features = []
for track in gpx.tracks:
for segment in track.segments:
coords = [(pt.longitude, pt.latitude) for pt in segment.points]
if not coords:
continue
features.append(geojson.Feature(
geometry=geojson.LineString(coords),
properties={"name": track.name or "GPX Track"}
))
return geojson.FeatureCollection(features)
except Exception as e:
return {"error": str(e)}
class VisitSerializer(serializers.ModelSerializer): class VisitSerializer(serializers.ModelSerializer):
activities = ActivitySerializer(many=True, read_only=True, required=False) activities = ActivitySerializer(many=True, read_only=True, required=False)
class Meta: class Meta:

View file

@ -11,6 +11,8 @@
export let trails: Trail[]; export let trails: Trail[];
export let visit: Visit | TransportationVisit; export let visit: Visit | TransportationVisit;
export let readOnly: boolean = false;
$: trail = activity.trail ? trails.find((t) => t.id === activity.trail) : null; $: trail = activity.trail ? trails.find((t) => t.id === activity.trail) : null;
console.log(activity.trail, trails, trail); console.log(activity.trail, trails, trail);
@ -75,12 +77,14 @@
</div> </div>
</div> </div>
<button {#if !readOnly}
class="btn btn-error btn-xs tooltip tooltip-top ml-2" <button
data-tip="Delete Activity" class="btn btn-error btn-xs tooltip tooltip-top ml-2"
on:click={() => deleteActivity(visit.id, activity.id)} data-tip="Delete Activity"
> on:click={() => deleteActivity(visit.id, activity.id)}
<TrashIcon class="w-3 h-3" /> >
</button> <TrashIcon class="w-3 h-3" />
</button>
{/if}
</div> </div>
</div> </div>

View file

@ -389,6 +389,7 @@ export type Activity = {
visit: string; visit: string;
trail: string | null; trail: string | null;
gpx_file: string | null; gpx_file: string | null;
geojson: any | undefined; // GeoJSON representation of the activity
name: string; name: string;
type: string; type: string;
sport_type: string | null; sport_type: string | null;

View file

@ -20,6 +20,7 @@
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import AttachmentCard from '$lib/components/AttachmentCard.svelte'; import AttachmentCard from '$lib/components/AttachmentCard.svelte';
import { getBasemapUrl, isAllDay } from '$lib'; import { getBasemapUrl, isAllDay } from '$lib';
import ActivityCard from '$lib/components/ActivityCard.svelte';
let geojson: any; let geojson: any;
@ -74,6 +75,46 @@
} }
}); });
} }
// for every location.visit there is an array of activitites which might have a field .geojson with a geojson object
if (adventure.visits && adventure.visits.length > 0) {
adventure.visits.forEach((visit) => {
if (visit.activities && visit.activities.length > 0) {
visit.activities.forEach((activity) => {
if (activity.geojson) {
// Ensure geojson object exists and has features array
if (!geojson || !geojson.features) {
geojson = {
type: 'FeatureCollection',
features: []
};
}
// Extract features from the activity's FeatureCollection
if (activity.geojson.features && Array.isArray(activity.geojson.features)) {
activity.geojson.features.forEach((feature: { properties: any }) => {
// Add activity metadata to each feature's properties
const enhancedFeature = {
...feature,
properties: {
...feature.properties,
name: activity.name || 'Activity',
description: activity.type || '',
activityType: activity.type || 'unknown',
visitId: visit.id,
visitStartDate: visit.start_date,
visitEndDate: visit.end_date,
visitNotes: visit.notes || ''
}
};
geojson.features.push(enhancedFeature);
});
}
}
});
}
});
}
} }
export let data: PageData; export let data: PageData;
@ -110,6 +151,50 @@
await getGpxFiles(); await getGpxFiles();
}); });
function hasActivityGeojson(adventure: AdditionalLocation) {
return adventure.visits.some((visit) => visit.activities.some((activity) => activity.geojson));
}
function getActivityColor(activityType: string) {
const colors: Record<string, string> = {
Hike: '#10B981',
Run: '#F59E0B',
Bike: '#3B82F6',
Walk: '#8B5CF6',
default: '#6B7280'
};
return colors[activityType] || colors.default;
}
function getTotalActivities(adventure: AdditionalLocation) {
return adventure.visits.reduce(
(total, visit) => total + (visit.activities ? visit.activities.length : 0),
0
);
}
function getTotalDistance(adventure: AdditionalLocation) {
return adventure.visits.reduce(
(total, visit) =>
total +
(visit.activities
? visit.activities.reduce((sum, activity) => sum + (activity.distance || 0), 0)
: 0),
0
);
}
function getTotalElevationGain(adventure: AdditionalLocation) {
return adventure.visits.reduce(
(total, visit) =>
total +
(visit.activities
? visit.activities.reduce((sum, activity) => sum + (activity.elevation_gain || 0), 0)
: 0),
0
);
}
async function saveEdit(event: CustomEvent<AdditionalLocation>) { async function saveEdit(event: CustomEvent<AdditionalLocation>) {
adventure = event.detail; adventure = event.detail;
isEditModalOpen = false; isEditModalOpen = false;
@ -157,7 +242,7 @@
{#if isImageModalOpen} {#if isImageModalOpen}
<ImageDisplayModal <ImageDisplayModal
images={adventure_images} images={adventure.images}
initialIndex={modalInitialIndex} initialIndex={modalInitialIndex}
on:close={closeImageModal} on:close={closeImageModal}
/> />
@ -242,6 +327,11 @@
{adventure.visits.length === 1 ? $t('adventures.visit') : $t('adventures.visits')} {adventure.visits.length === 1 ? $t('adventures.visit') : $t('adventures.visits')}
</div> </div>
{/if} {/if}
{#if adventure.trails && adventure.trails.length > 0}
<div class="badge badge-lg badge-info font-semibold px-4 py-3">
🥾 {adventure.trails.length} Trail{adventure.trails.length === 1 ? '' : 's'}
</div>
{/if}
</div> </div>
<!-- Image Navigation --> <!-- Image Navigation -->
@ -382,13 +472,6 @@
? `🌍 ${$t('adventures.public')}` ? `🌍 ${$t('adventures.public')}`
: `🔒 ${$t('adventures.private')}`} : `🔒 ${$t('adventures.private')}`}
</div> </div>
<!-- {#if data.props.collection}
<div class="badge badge-sm badge-outline">
📚 <a href="/collections/{data.props.collection.id}" class="link"
>{data.props.collection.name}</a
>
</div>
{/if} -->
{#if adventure.collections && adventure.collections.length > 0} {#if adventure.collections && adventure.collections.length > 0}
<div class="badge badge-sm badge-outline"> <div class="badge badge-sm badge-outline">
📚 📚
@ -414,6 +497,46 @@
</div> </div>
{/if} {/if}
<!-- Trails Section -->
{#if adventure.trails && adventure.trails.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">🥾 {$t('adventures.trails')}</h2>
<div class="grid gap-4">
{#each adventure.trails as trail}
<div class="card bg-base-100 shadow">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-bold text-lg">{trail.name}</h3>
<div class="flex items-center gap-2 mt-2">
{#if trail.provider}
<span class="badge badge-outline badge-sm">{trail.provider}</span>
{/if}
<span class="text-sm opacity-70">
Created: {new Date(trail.created_at).toLocaleDateString()}
</span>
</div>
</div>
{#if trail.link}
<a
href={trail.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-primary"
>
🔗 View Trail
</a>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Visits Timeline --> <!-- Visits Timeline -->
{#if adventure.visits.length > 0} {#if adventure.visits.length > 0}
<div class="card bg-base-200 shadow-xl"> <div class="card bg-base-200 shadow-xl">
@ -429,7 +552,7 @@
{/if} {/if}
</div> </div>
<div class="flex-1 pb-4"> <div class="flex-1 pb-4">
<div class="card bg-base-200 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body p-4"> <div class="card-body p-4">
{#if isAllDay(visit.start_date)} {#if isAllDay(visit.start_date)}
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
@ -476,6 +599,25 @@
<p class="text-sm italic">"{visit.notes}"</p> <p class="text-sm italic">"{visit.notes}"</p>
</div> </div>
{/if} {/if}
<!-- Activities Section -->
{#if visit.activities && visit.activities.length > 0}
<div class="mt-4">
<h4 class="font-semibold mb-3 flex items-center gap-2">
🏃‍♂️ Activities ({visit.activities.length})
</h4>
<div class="space-y-3">
{#each visit.activities as activity}
<ActivityCard
{activity}
readOnly={true}
trails={adventure.trails}
{visit}
/>
{/each}
</div>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@ -487,7 +629,7 @@
{/if} {/if}
<!-- Map Section --> <!-- Map Section -->
{#if (adventure.longitude && adventure.latitude) || geojson} {#if (adventure.longitude && adventure.latitude) || geojson || hasActivityGeojson(adventure)}
<div class="card bg-base-200 shadow-xl"> <div class="card bg-base-200 shadow-xl">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl mb-4">🗺️ {$t('adventures.location')}</h2> <h2 class="card-title text-2xl mb-4">🗺️ {$t('adventures.location')}</h2>
@ -561,7 +703,16 @@
class="btn btn-xs btn-outline hover:btn-success" class="btn btn-xs btn-outline hover:btn-success"
on:click={() => goto(`/worldtravel/${adventure.country?.country_code}`)} on:click={() => goto(`/worldtravel/${adventure.country?.country_code}`)}
> >
🌎 {adventure.country.name} {#if adventure.country.flag_url}
<img
src={adventure.country.flag_url}
alt={adventure.country.name}
class="w-4 h-3 rounded"
/>
{:else}
🌎
{/if}
{adventure.country.name}
</button> </button>
{/if} {/if}
</div> </div>
@ -639,6 +790,23 @@
</GeoJSON> </GeoJSON>
{/if} {/if}
<!-- Activity GPS tracks -->
{#each adventure.visits as visit}
{#each visit.activities as activity}
{#if activity.geojson}
<GeoJSON data={activity.geojson}>
<LineLayer
paint={{
'line-color': getActivityColor(activity.type),
'line-width': 3,
'line-opacity': 0.8
}}
/>
</GeoJSON>
{/if}
{/each}
{/each}
{#if adventure.longitude && adventure.latitude} {#if adventure.longitude && adventure.latitude}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}> <DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
<Popup openOn="click" offset={[0, -10]}> <Popup openOn="click" offset={[0, -10]}>
@ -699,6 +867,37 @@
</div> </div>
</div> </div>
<!-- Activity Summary -->
{#if getTotalActivities(adventure) > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg mb-4">🏃‍♂️ Activity Summary</h3>
<div class="space-y-2">
<div class="stat">
<div class="stat-title">Total Activities</div>
<div class="stat-value text-2xl">{getTotalActivities(adventure)}</div>
</div>
{#if getTotalDistance(adventure) > 0}
<div class="stat">
<div class="stat-title">Total Distance</div>
<div class="stat-value text-xl">
{getTotalDistance(adventure).toFixed(1)} km
</div>
</div>
{/if}
{#if getTotalElevationGain(adventure) > 0}
<div class="stat">
<div class="stat-title">Total Elevation</div>
<div class="stat-value text-xl">
{getTotalElevationGain(adventure).toFixed(0)} m
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Sunrise/Sunset --> <!-- Sunrise/Sunset -->
{#if adventure.sun_times && adventure.sun_times.length > 0} {#if adventure.sun_times && adventure.sun_times.length > 0}
<div class="card bg-base-200 shadow-xl"> <div class="card bg-base-200 shadow-xl">