1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 05:05:17 +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 geopy.distance import geodesic
from integrations.models import ImmichIntegration
import gpxpy
import geojson
class ContentImageSerializer(CustomModelSerializer):
@ -107,6 +109,7 @@ class TrailSerializer(CustomModelSerializer):
return 'External Link'
class ActivitySerializer(CustomModelSerializer):
geojson = serializers.SerializerMethodField()
class Meta:
model = Activity
@ -115,7 +118,7 @@ class ActivitySerializer(CustomModelSerializer):
'distance', 'moving_time', 'elapsed_time', 'rest_time', 'elevation_gain',
'elevation_loss', 'elev_high', 'elev_low', 'start_date', 'start_date_local',
'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']
@ -125,9 +128,33 @@ class ActivitySerializer(CustomModelSerializer):
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}"
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):
activities = ActivitySerializer(many=True, read_only=True, required=False)
class Meta:

View file

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

View file

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

View file

@ -20,6 +20,7 @@
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import AttachmentCard from '$lib/components/AttachmentCard.svelte';
import { getBasemapUrl, isAllDay } from '$lib';
import ActivityCard from '$lib/components/ActivityCard.svelte';
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;
@ -110,6 +151,50 @@
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>) {
adventure = event.detail;
isEditModalOpen = false;
@ -157,7 +242,7 @@
{#if isImageModalOpen}
<ImageDisplayModal
images={adventure_images}
images={adventure.images}
initialIndex={modalInitialIndex}
on:close={closeImageModal}
/>
@ -242,6 +327,11 @@
{adventure.visits.length === 1 ? $t('adventures.visit') : $t('adventures.visits')}
</div>
{/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>
<!-- Image Navigation -->
@ -382,13 +472,6 @@
? `🌍 ${$t('adventures.public')}`
: `🔒 ${$t('adventures.private')}`}
</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}
<div class="badge badge-sm badge-outline">
📚
@ -414,6 +497,46 @@
</div>
{/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 -->
{#if adventure.visits.length > 0}
<div class="card bg-base-200 shadow-xl">
@ -429,7 +552,7 @@
{/if}
</div>
<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">
{#if isAllDay(visit.start_date)}
<div class="flex items-center gap-2 mb-2">
@ -476,6 +599,25 @@
<p class="text-sm italic">"{visit.notes}"</p>
</div>
{/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>
@ -487,7 +629,7 @@
{/if}
<!-- 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-body">
<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"
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>
{/if}
</div>
@ -639,6 +790,23 @@
</GeoJSON>
{/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}
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
<Popup openOn="click" offset={[0, -10]}>
@ -699,6 +867,37 @@
</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 -->
{#if adventure.sun_times && adventure.sun_times.length > 0}
<div class="card bg-base-200 shadow-xl">