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:
parent
eae416981d
commit
306b39d245
4 changed files with 251 additions and 20 deletions
|
@ -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:
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue