mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-08 14:45:17 +02:00
feat: add geojson support for attachments and refactor GPX handling in location page
This commit is contained in:
parent
b33587e516
commit
de53456bc7
5 changed files with 69 additions and 125 deletions
|
@ -7,6 +7,7 @@ from users.serializers import CustomUserDetailsSerializer
|
|||
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer
|
||||
from geopy.distance import geodesic
|
||||
from integrations.models import ImmichIntegration
|
||||
from adventures.utils.geojson import gpx_to_geojson
|
||||
import gpxpy
|
||||
import geojson
|
||||
import logging
|
||||
|
@ -45,9 +46,10 @@ class ContentImageSerializer(CustomModelSerializer):
|
|||
|
||||
class AttachmentSerializer(CustomModelSerializer):
|
||||
extension = serializers.SerializerMethodField()
|
||||
geojson = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = ContentAttachment
|
||||
fields = ['id', 'file', 'extension', 'name', 'user']
|
||||
fields = ['id', 'file', 'extension', 'name', 'user', 'geojson']
|
||||
read_only_fields = ['id', 'user']
|
||||
|
||||
def get_extension(self, obj):
|
||||
|
@ -62,6 +64,11 @@ class AttachmentSerializer(CustomModelSerializer):
|
|||
public_url = public_url.replace("'", "")
|
||||
representation['file'] = f"{public_url}/media/{instance.file.name}"
|
||||
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):
|
||||
num_locations = serializers.SerializerMethodField()
|
||||
|
@ -186,28 +193,7 @@ class ActivitySerializer(CustomModelSerializer):
|
|||
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)}
|
||||
return gpx_to_geojson(obj.gpx_file)
|
||||
|
||||
class VisitSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
|
39
backend/server/adventures/utils/geojson.py
Normal file
39
backend/server/adventures/utils/geojson.py
Normal 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"
|
||||
}
|
|
@ -40,7 +40,6 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@lukulent/svelte-umami": "^0.0.3",
|
||||
"@mapbox/togeojson": "^0.16.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"emoji-picker-element": "^1.26.0",
|
||||
"gsap": "^3.12.7",
|
||||
|
|
|
@ -278,6 +278,7 @@ export type Attachment = {
|
|||
extension: string;
|
||||
user: string;
|
||||
name: string;
|
||||
geojson: any | null; // GeoJSON representation of the attachment if the file is a GPX
|
||||
};
|
||||
|
||||
export type Lodging = {
|
||||
|
@ -417,7 +418,7 @@ export type Visit = {
|
|||
end_date: string;
|
||||
notes: string;
|
||||
timezone: string | null;
|
||||
activities?: Activity[];
|
||||
activities: Activity[];
|
||||
location: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
|
@ -9,8 +9,6 @@
|
|||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
// @ts-ignore
|
||||
import toGeoJSON from '@mapbox/togeojson';
|
||||
// @ts-ignore
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import LightbulbOn from '~icons/mdi/lightbulb-on';
|
||||
|
@ -29,95 +27,6 @@
|
|||
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;
|
||||
let measurementSystem = data.user?.measurement_system || 'metric';
|
||||
console.log(data);
|
||||
|
@ -150,13 +59,16 @@
|
|||
} else {
|
||||
notFound = true;
|
||||
}
|
||||
await getGpxFiles();
|
||||
});
|
||||
|
||||
function hasActivityGeojson(adventure: AdditionalLocation) {
|
||||
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) {
|
||||
const colors: Record<string, string> = {
|
||||
Hike: '#10B981',
|
||||
|
@ -204,13 +116,6 @@
|
|||
return measurementSystem === 'imperial' ? totalMeters * 3.28084 : totalMeters;
|
||||
}
|
||||
|
||||
async function saveEdit(event: CustomEvent<AdditionalLocation>) {
|
||||
adventure = event.detail;
|
||||
isEditModalOpen = false;
|
||||
geojson = null;
|
||||
await getGpxFiles();
|
||||
}
|
||||
|
||||
function closeImageModal() {
|
||||
isImageModalOpen = false;
|
||||
}
|
||||
|
@ -616,7 +521,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- 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-body">
|
||||
<h2 class="card-title text-2xl mb-4">🗺️ {$t('adventures.location')}</h2>
|
||||
|
@ -794,6 +699,20 @@
|
|||
{/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}
|
||||
<DefaultMarker lngLat={{ lng: adventure.longitude, lat: adventure.latitude }}>
|
||||
<Popup openOn="click" offset={[0, -10]}>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue