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

feat: add geojson support for attachments and refactor GPX handling in location page

This commit is contained in:
Sean Morley 2025-08-07 20:14:50 -04:00
parent b33587e516
commit de53456bc7
5 changed files with 69 additions and 125 deletions

View file

@ -7,6 +7,7 @@ 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
from adventures.utils.geojson import gpx_to_geojson
import gpxpy import gpxpy
import geojson import geojson
import logging import logging
@ -45,9 +46,10 @@ class ContentImageSerializer(CustomModelSerializer):
class AttachmentSerializer(CustomModelSerializer): class AttachmentSerializer(CustomModelSerializer):
extension = serializers.SerializerMethodField() extension = serializers.SerializerMethodField()
geojson = serializers.SerializerMethodField()
class Meta: class Meta:
model = ContentAttachment model = ContentAttachment
fields = ['id', 'file', 'extension', 'name', 'user'] fields = ['id', 'file', 'extension', 'name', 'user', 'geojson']
read_only_fields = ['id', 'user'] read_only_fields = ['id', 'user']
def get_extension(self, obj): def get_extension(self, obj):
@ -63,6 +65,11 @@ class AttachmentSerializer(CustomModelSerializer):
representation['file'] = f"{public_url}/media/{instance.file.name}" representation['file'] = f"{public_url}/media/{instance.file.name}"
return representation return representation
def get_geojson(self, obj):
if obj.file and obj.file.name.endswith('.gpx'):
return gpx_to_geojson(obj.file)
return None
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
num_locations = serializers.SerializerMethodField() num_locations = serializers.SerializerMethodField()
class Meta: class Meta:
@ -186,28 +193,7 @@ class ActivitySerializer(CustomModelSerializer):
return representation return representation
def get_geojson(self, obj): def get_geojson(self, obj):
if not obj.gpx_file: return gpx_to_geojson(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):

View file

@ -0,0 +1,39 @@
import gpxpy
import geojson
def gpx_to_geojson(gpx_file):
"""
Convert a GPX file to GeoJSON format.
Args:
gpx_file: Django FileField or file-like object containing GPX data
Returns:
dict: GeoJSON FeatureCollection or error dict
"""
if not gpx_file:
return None
try:
with gpx_file.open('r') as f:
gpx = gpxpy.parse(f)
features = []
for track in gpx.tracks:
track_name = track.name or "GPX Track"
for segment in track.segments:
coords = [(point.longitude, point.latitude) for point in segment.points]
if coords:
feature = geojson.Feature(
geometry=geojson.LineString(coords),
properties={"name": track_name}
)
features.append(feature)
return geojson.FeatureCollection(features)
except Exception as e:
return {
"error": str(e),
"message": "Failed to convert GPX to GeoJSON"
}

View file

@ -40,7 +40,6 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@lukulent/svelte-umami": "^0.0.3", "@lukulent/svelte-umami": "^0.0.3",
"@mapbox/togeojson": "^0.16.2",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"emoji-picker-element": "^1.26.0", "emoji-picker-element": "^1.26.0",
"gsap": "^3.12.7", "gsap": "^3.12.7",

View file

@ -278,6 +278,7 @@ export type Attachment = {
extension: string; extension: string;
user: string; user: string;
name: string; name: string;
geojson: any | null; // GeoJSON representation of the attachment if the file is a GPX
}; };
export type Lodging = { export type Lodging = {
@ -417,7 +418,7 @@ export type Visit = {
end_date: string; end_date: string;
notes: string; notes: string;
timezone: string | null; timezone: string | null;
activities?: Activity[]; activities: Activity[];
location: string; location: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;

View file

@ -9,8 +9,6 @@
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
// @ts-ignore // @ts-ignore
import toGeoJSON from '@mapbox/togeojson';
// @ts-ignore
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import LightbulbOn from '~icons/mdi/lightbulb-on'; import LightbulbOn from '~icons/mdi/lightbulb-on';
@ -29,95 +27,6 @@
return marked(markdown) as string; return marked(markdown) as string;
}; };
async function getGpxFiles() {
let gpxfiles: string[] = [];
if (adventure.attachments && adventure.attachments.length > 0) {
gpxfiles = adventure.attachments
.filter((attachment) => attachment.extension === 'gpx')
.map((attachment) => attachment.file);
}
geojson = {
type: 'FeatureCollection',
features: []
};
if (gpxfiles.length > 0) {
const promises = gpxfiles.map(async (gpxfile) => {
try {
const gpxFileName = gpxfile.split('/').pop();
const res = await fetch(gpxfile, {
credentials: 'include'
});
if (!res.ok) {
console.error(`Failed to fetch GPX file: ${gpxFileName}`);
return [];
}
const gpxData = await res.text();
const parser = new DOMParser();
const gpx = parser.parseFromString(gpxData, 'text/xml');
const convertedGeoJSON = toGeoJSON.gpx(gpx);
return convertedGeoJSON.features || [];
} catch (error) {
console.error(`Error processing GPX file ${gpxfile}:`, error);
return [];
}
});
const results = await Promise.allSettled(promises);
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value.length > 0) {
geojson.features.push(...result.value);
}
});
}
// 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;
let measurementSystem = data.user?.measurement_system || 'metric'; let measurementSystem = data.user?.measurement_system || 'metric';
console.log(data); console.log(data);
@ -150,13 +59,16 @@
} else { } else {
notFound = true; notFound = true;
} }
await getGpxFiles();
}); });
function hasActivityGeojson(adventure: AdditionalLocation) { function hasActivityGeojson(adventure: AdditionalLocation) {
return adventure.visits.some((visit) => visit.activities.some((activity) => activity.geojson)); return adventure.visits.some((visit) => visit.activities.some((activity) => activity.geojson));
} }
function hasAttachmentGeojson(adventure: AdditionalLocation) {
return adventure.attachments.some((attachment) => attachment.geojson);
}
function getActivityColor(activityType: string) { function getActivityColor(activityType: string) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
Hike: '#10B981', Hike: '#10B981',
@ -204,13 +116,6 @@
return measurementSystem === 'imperial' ? totalMeters * 3.28084 : totalMeters; return measurementSystem === 'imperial' ? totalMeters * 3.28084 : totalMeters;
} }
async function saveEdit(event: CustomEvent<AdditionalLocation>) {
adventure = event.detail;
isEditModalOpen = false;
geojson = null;
await getGpxFiles();
}
function closeImageModal() { function closeImageModal() {
isImageModalOpen = false; isImageModalOpen = false;
} }
@ -616,7 +521,7 @@
{/if} {/if}
<!-- Map Section --> <!-- Map Section -->
{#if (adventure.longitude && adventure.latitude) || geojson || hasActivityGeojson(adventure)} {#if (adventure.longitude && adventure.latitude) || hasAttachmentGeojson(adventure) || 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>
@ -794,6 +699,20 @@
{/each} {/each}
{/each} {/each}
{#each adventure.attachments as attachment}
{#if attachment.geojson}
<GeoJSON data={attachment.geojson}>
<LineLayer
paint={{
'line-color': '#00FF00',
'line-width': 2,
'line-opacity': 0.6
}}
/>
</GeoJSON>
{/if}
{/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]}>