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:
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 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):
|
||||||
|
|
||||||
|
|
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",
|
"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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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]}>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue