mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-08 23:15:11 -04:00
feat: add travel duration and GPX distance calculation to Transportation model and UI
This commit is contained in:
@@ -7,6 +7,7 @@ from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySer
|
||||
from geopy.distance import geodesic
|
||||
from integrations.models import ImmichIntegration
|
||||
from adventures.utils.geojson import gpx_to_geojson
|
||||
import gpxpy
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -424,6 +425,7 @@ class TransportationSerializer(CustomModelSerializer):
|
||||
distance = serializers.SerializerMethodField()
|
||||
images = serializers.SerializerMethodField()
|
||||
attachments = serializers.SerializerMethodField()
|
||||
travel_duration_minutes = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Transportation
|
||||
@@ -432,9 +434,10 @@ class TransportationSerializer(CustomModelSerializer):
|
||||
'link', 'date', 'flight_number', 'from_location', 'to_location',
|
||||
'is_public', 'collection', 'created_at', 'updated_at', 'end_date',
|
||||
'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude',
|
||||
'start_timezone', 'end_timezone', 'distance', 'images', 'attachments', 'start_code', 'end_code'
|
||||
'start_timezone', 'end_timezone', 'distance', 'images', 'attachments', 'start_code', 'end_code',
|
||||
'travel_duration_minutes'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance', 'travel_duration_minutes']
|
||||
|
||||
def get_images(self, obj):
|
||||
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
|
||||
@@ -447,6 +450,10 @@ class TransportationSerializer(CustomModelSerializer):
|
||||
return [attachment for attachment in serializer.data if attachment is not None]
|
||||
|
||||
def get_distance(self, obj):
|
||||
gpx_distance = self._get_gpx_distance_km(obj)
|
||||
if gpx_distance is not None:
|
||||
return gpx_distance
|
||||
|
||||
if (
|
||||
obj.origin_latitude and obj.origin_longitude and
|
||||
obj.destination_latitude and obj.destination_longitude
|
||||
@@ -459,6 +466,68 @@ class TransportationSerializer(CustomModelSerializer):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _get_gpx_distance_km(self, obj):
|
||||
gpx_attachments = obj.attachments.filter(file__iendswith='.gpx')
|
||||
for attachment in gpx_attachments:
|
||||
distance_km = self._parse_gpx_distance_km(attachment.file)
|
||||
if distance_km is not None:
|
||||
return distance_km
|
||||
return None
|
||||
|
||||
def _parse_gpx_distance_km(self, gpx_file_field):
|
||||
try:
|
||||
with gpx_file_field.open('r') as gpx_file:
|
||||
gpx = gpxpy.parse(gpx_file)
|
||||
|
||||
total_meters = 0.0
|
||||
|
||||
for track in gpx.tracks:
|
||||
for segment in track.segments:
|
||||
segment_length = segment.length_3d() or segment.length_2d()
|
||||
if segment_length:
|
||||
total_meters += segment_length
|
||||
|
||||
for route in gpx.routes:
|
||||
route_length = route.length_3d() or route.length_2d()
|
||||
if route_length:
|
||||
total_meters += route_length
|
||||
|
||||
if total_meters > 0:
|
||||
return round(total_meters / 1000, 2)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to calculate GPX distance for file %s: %s",
|
||||
getattr(gpx_file_field, 'name', 'unknown'),
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
def get_travel_duration_minutes(self, obj):
|
||||
if not obj.date or not obj.end_date:
|
||||
return None
|
||||
|
||||
if self._is_all_day(obj.date) and self._is_all_day(obj.end_date):
|
||||
return None
|
||||
|
||||
try:
|
||||
total_minutes = int((obj.end_date - obj.date).total_seconds() // 60)
|
||||
return total_minutes if total_minutes >= 0 else None
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to calculate travel duration for transportation %s",
|
||||
getattr(obj, "id", "unknown"),
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _is_all_day(self, dt_value):
|
||||
return (
|
||||
dt_value.time().hour == 0
|
||||
and dt_value.time().minute == 0
|
||||
and dt_value.time().second == 0
|
||||
and dt_value.time().microsecond == 0
|
||||
)
|
||||
|
||||
class LodgingSerializer(CustomModelSerializer):
|
||||
images = serializers.SerializerMethodField()
|
||||
attachments = serializers.SerializerMethodField()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
|
||||
import { isAllDay } from '$lib';
|
||||
import CardCarousel from '../CardCarousel.svelte';
|
||||
import TransportationRoutePreview from './TransportationRoutePreview.svelte';
|
||||
|
||||
import Eye from '~icons/mdi/eye';
|
||||
import EyeOff from '~icons/mdi/eye-off';
|
||||
@@ -48,6 +49,23 @@
|
||||
|
||||
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
|
||||
|
||||
const formatTravelDuration = (minutes: number | null | undefined) => {
|
||||
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return null;
|
||||
const safeMinutes = Math.max(0, Math.floor(minutes));
|
||||
const hours = Math.floor(safeMinutes / 60);
|
||||
const mins = safeMinutes % 60;
|
||||
const parts = [] as string[];
|
||||
if (hours) parts.push(`${hours}h`);
|
||||
parts.push(`${mins}m`);
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
let travelDurationLabel: string | null = null;
|
||||
$: travelDurationLabel = formatTravelDuration(transportation?.travel_duration_minutes ?? null);
|
||||
|
||||
$: routeGeojson =
|
||||
transportation?.attachments?.find((attachment) => attachment?.geojson)?.geojson ?? null;
|
||||
|
||||
let isWarningModalOpen: boolean = false;
|
||||
|
||||
function editTransportation() {
|
||||
@@ -101,11 +119,19 @@
|
||||
>
|
||||
<!-- Image Section with Overlay -->
|
||||
<div class="relative overflow-hidden rounded-t-2xl">
|
||||
<CardCarousel
|
||||
images={transportation.images}
|
||||
icon={getTransportationIcon(transportation.type)}
|
||||
name={transportation.name}
|
||||
/>
|
||||
{#if routeGeojson}
|
||||
<TransportationRoutePreview
|
||||
geojson={routeGeojson}
|
||||
name={transportation.name}
|
||||
images={transportation.images}
|
||||
/>
|
||||
{:else}
|
||||
<CardCarousel
|
||||
images={transportation.images}
|
||||
icon={getTransportationIcon(transportation.type)}
|
||||
name={transportation.name}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Privacy Indicator -->
|
||||
<div class="absolute top-2 right-4">
|
||||
@@ -250,6 +276,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if travelDurationLabel}
|
||||
<div class="badge badge-ghost badge-sm">
|
||||
⏱️ {travelDurationLabel}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if transportation.rating}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex -ml-1">
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import ImageDisplayModal from '../ImageDisplayModal.svelte';
|
||||
import type { ContentImage } from '$lib/types';
|
||||
|
||||
export let geojson: any;
|
||||
export let name: string = '';
|
||||
export let images: ContentImage[] = [];
|
||||
export let heightClass: string = 'h-48';
|
||||
|
||||
let showImageModal = false;
|
||||
let modalInitialIndex = 0;
|
||||
|
||||
$: sortedImages = [...images]
|
||||
.filter((img) => !!img?.image)
|
||||
.sort((a, b) => Number(b?.is_primary) - Number(a?.is_primary));
|
||||
|
||||
$: routeCoordinates = extractLineCoords(geojson);
|
||||
$: normalizedRoute = normalizeCoords(routeCoordinates);
|
||||
$: pathD = buildPath(normalizedRoute);
|
||||
$: hasRoute = !!pathD;
|
||||
$: startPoint = normalizedRoute.length > 0 ? normalizedRoute[0] : null;
|
||||
$: endPoint =
|
||||
normalizedRoute.length > 1 ? normalizedRoute[normalizedRoute.length - 1] : startPoint;
|
||||
|
||||
function openImageModal(initialIndex: number = 0) {
|
||||
if (!sortedImages.length) return;
|
||||
modalInitialIndex = initialIndex;
|
||||
showImageModal = true;
|
||||
}
|
||||
|
||||
function closeImageModal() {
|
||||
showImageModal = false;
|
||||
}
|
||||
|
||||
type LonLat = [number, number];
|
||||
type NormalizedPoint = { x: number; y: number };
|
||||
|
||||
function extractLineCoords(data: any): LonLat[] {
|
||||
if (!data || typeof data !== 'object') return [];
|
||||
|
||||
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
|
||||
return data.features.flatMap((feature: any) => extractLineCoords(feature));
|
||||
}
|
||||
|
||||
if (data.type === 'Feature') {
|
||||
return extractLineCoords(data.geometry);
|
||||
}
|
||||
|
||||
if (data.type === 'GeometryCollection' && Array.isArray(data.geometries)) {
|
||||
return data.geometries.flatMap((geom: any) => extractLineCoords(geom));
|
||||
}
|
||||
|
||||
if (data.type === 'LineString' && Array.isArray(data.coordinates)) {
|
||||
return sanitizeCoordinates(data.coordinates);
|
||||
}
|
||||
|
||||
if (data.type === 'MultiLineString' && Array.isArray(data.coordinates)) {
|
||||
return data.coordinates.flatMap((line: any) => sanitizeCoordinates(line));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function sanitizeCoordinates(raw: any): LonLat[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const coords: LonLat[] = [];
|
||||
for (const point of raw) {
|
||||
if (!Array.isArray(point) || point.length < 2) continue;
|
||||
const lon = Number(point[0]);
|
||||
const lat = Number(point[1]);
|
||||
if (Number.isFinite(lon) && Number.isFinite(lat)) {
|
||||
coords.push([lon, lat]);
|
||||
}
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
function normalizeCoords(coords: LonLat[]): NormalizedPoint[] {
|
||||
if (!coords.length) return [];
|
||||
|
||||
const lons = coords.map(([lon]) => lon);
|
||||
const lats = coords.map(([, lat]) => lat);
|
||||
const minLon = Math.min(...lons);
|
||||
const maxLon = Math.max(...lons);
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
|
||||
const spanLon = maxLon - minLon || 1;
|
||||
const spanLat = maxLat - minLat || 1;
|
||||
const padLon = spanLon * 0.05;
|
||||
const padLat = spanLat * 0.05;
|
||||
|
||||
const originLon = minLon - padLon;
|
||||
const originLat = minLat - padLat;
|
||||
const scaleX = 100 / (spanLon + padLon * 2);
|
||||
const scaleY = 100 / (spanLat + padLat * 2);
|
||||
|
||||
return coords.map(([lon, lat]) => ({
|
||||
x: (lon - originLon) * scaleX,
|
||||
y: 100 - (lat - originLat) * scaleY
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPath(points: NormalizedPoint[]): string {
|
||||
if (!points.length) return '';
|
||||
return points
|
||||
.map((p, idx) => `${idx === 0 ? 'M' : 'L'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
|
||||
.join(' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showImageModal && sortedImages.length > 0}
|
||||
<ImageDisplayModal
|
||||
images={sortedImages}
|
||||
initialIndex={modalInitialIndex}
|
||||
on:close={closeImageModal}
|
||||
{name}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class={`relative w-full ${heightClass} rounded-t-2xl bg-gradient-to-r from-success via-base to-primary overflow-hidden`}
|
||||
aria-label="route-preview"
|
||||
>
|
||||
<svg viewBox="0 0 100 100" class="w-full h-full" role="img" aria-label="Route preview">
|
||||
<!-- No separate bg; rely on parent gradient -->
|
||||
|
||||
{#if hasRoute}
|
||||
<g stroke-linecap="round" stroke-linejoin="round">
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="var(--color-base-content, #111827)"
|
||||
stroke-width="2.6"
|
||||
opacity="0.55"
|
||||
/>
|
||||
{#if startPoint}
|
||||
<circle
|
||||
cx={startPoint.x}
|
||||
cy={startPoint.y}
|
||||
r="2.4"
|
||||
fill="var(--color-base-content, #111827)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/if}
|
||||
{#if endPoint}
|
||||
<circle
|
||||
cx={endPoint.x}
|
||||
cy={endPoint.y}
|
||||
r="2.8"
|
||||
fill="var(--color-base-content, #111827)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/if}
|
||||
</g>
|
||||
{:else}
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
text-anchor="middle"
|
||||
fill="var(--color-base-content, #111827)"
|
||||
opacity="0.6"
|
||||
>
|
||||
Route unavailable
|
||||
</text>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<div class="absolute top-3 left-3 badge badge-primary gap-1 shadow">
|
||||
<span class="text-xs font-semibold">GPX</span>
|
||||
<span class="text-xs">Route</span>
|
||||
</div>
|
||||
|
||||
{#if sortedImages.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
on:click|stopPropagation={() => openImageModal(0)}
|
||||
class="btn btn-xs btn-neutral absolute bottom-3 right-3 shadow"
|
||||
>
|
||||
View photos ({sortedImages.length})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -84,6 +84,11 @@
|
||||
|
||||
$: user = currentUser;
|
||||
$: transportationToEdit = editingTransportation;
|
||||
// Set the full date range for constraining purposes (from collection)
|
||||
$: if (collection && collection.start_date && collection.end_date) {
|
||||
fullStartDate = `${collection.start_date}T00:00`;
|
||||
fullEndDate = `${collection.end_date}T23:59`;
|
||||
}
|
||||
// Only assign timezones when this is a timed transportation. Keep timezones null for all-day entries.
|
||||
$: {
|
||||
transportation.start_timezone = allDay ? null : selectedTimezone;
|
||||
|
||||
@@ -198,6 +198,7 @@ export type Transportation = {
|
||||
updated_at: string; // ISO 8601 date string
|
||||
images: ContentImage[]; // Array of images associated with the transportation
|
||||
attachments: Attachment[]; // Array of attachments associated with the transportation
|
||||
travel_duration_minutes?: number | null;
|
||||
};
|
||||
|
||||
export type Note = {
|
||||
|
||||
@@ -177,6 +177,33 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a distance given in kilometers according to the current user's
|
||||
* measurement system (metric or imperial). For metric show meters for <1km,
|
||||
* otherwise km; for imperial show feet for very small distances, otherwise miles.
|
||||
*/
|
||||
function formatDistance(distanceKm: number | null): string | null {
|
||||
if (distanceKm === null || distanceKm === undefined) return null;
|
||||
const ms = data.user?.measurement_system ?? 'metric';
|
||||
|
||||
if (ms === 'imperial') {
|
||||
const miles = distanceKm * 0.621371;
|
||||
// show miles if at least 0.1 mi, otherwise show feet
|
||||
if (miles >= 0.1) {
|
||||
return `${miles.toFixed(1)} mi`;
|
||||
}
|
||||
const feet = Math.round(miles * 5280);
|
||||
return `${feet} ft`;
|
||||
} else {
|
||||
// metric
|
||||
if (distanceKm >= 1) {
|
||||
return `${distanceKm.toFixed(1)} km`;
|
||||
}
|
||||
const meters = Math.round(distanceKm * 1000);
|
||||
return `${meters} m`;
|
||||
}
|
||||
}
|
||||
|
||||
function collectAttachmentGeojson(item: Transportation) {
|
||||
if (!item.attachments || item.attachments.length === 0) return null;
|
||||
const features: any[] = [];
|
||||
@@ -612,7 +639,7 @@
|
||||
<p class="font-semibold text-sm opacity-70">
|
||||
{$t('adventures.distance') ?? 'Distance'}
|
||||
</p>
|
||||
<p class="text-base">{transportation.distance} km</p>
|
||||
<p class="text-base">{formatDistance(transportation.distance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user