From 460cdc93fee533545410d477e3527b0a52ae5732 Mon Sep 17 00:00:00 2001 From: itskovacs Date: Sun, 21 Sep 2025 15:14:32 +0200 Subject: [PATCH] :sparkles: Trip: item image and gpx, Trip: table distance between items --- .../app/components/trip/trip.component.html | 51 +++++- src/src/app/components/trip/trip.component.ts | 145 ++++++++++++++---- .../trip-create-day-item-modal.component.html | 54 ++++++- .../trip-create-day-item-modal.component.ts | 62 ++++++++ src/src/app/shared/haversine.ts | 20 +++ src/src/app/types/trip.ts | 7 + 6 files changed, 297 insertions(+), 42 deletions(-) create mode 100644 src/src/app/shared/haversine.ts diff --git a/src/src/app/components/trip/trip.component.html b/src/src/app/components/trip/trip.component.html index df6c204..05b29f9 100644 --- a/src/src/app/components/trip/trip.component.html +++ b/src/src/app/components/trip/trip.component.html @@ -111,6 +111,7 @@ @if (tripTableSelectedColumns.includes('LatLng')) {LatLng} @if (tripTableSelectedColumns.includes('price')) {Price} @if (tripTableSelectedColumns.includes('status')) {Status} + @if (tripTableSelectedColumns.includes('distance')) {Distance (km)} @if (tableExpandableMode) { @@ -148,10 +149,19 @@ } @else {-} } - @if (tripTableSelectedColumns.includes('comment')) { + @if (tripTableSelectedColumns.includes('comment')) { + @if (tripitem.image) { +
+ {{ + tripitem.comment }} +
+ } @else {
{{ tripitem.comment || '-' }}
+ } } @if (tripTableSelectedColumns.includes('LatLng')) {
@@ -166,6 +176,9 @@ [style.background]="tripitem.status.color+'1A'" [style.color]="tripitem.status.color" class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{ tripitem.status.label }}}} + @if (tripTableSelectedColumns.includes('distance')) { +
{{ tripitem.distance || '-' }}
+ } } @@ -192,15 +205,24 @@ @if (tripitem.place) {
{{ + class="absolute left-0 top-1/2 -translate-y-1/2 size-9 rounded-full object-cover print:hidden" /> {{ tripitem.place.name }}
} @else {-} } - @if (tripTableSelectedColumns.includes('comment')) { + @if (tripTableSelectedColumns.includes('comment')) { + @if (tripitem.image) { +
+ {{ + tripitem.comment }} +
+ } @else {
{{ tripitem.comment || '-' }}
+ } } @if (tripTableSelectedColumns.includes('LatLng')) {
@@ -215,6 +237,9 @@ [style.background]="tripitem.status.color+'1A'" [style.color]="tripitem.status.color" class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{ tripitem.status.label }}}} + @if (tripTableSelectedColumns.includes('distance')) { +
{{ tripitem.distance || '-' }}
+ } } @@ -247,7 +272,7 @@
@if (selectedItem) { -
+
@if (selectedItem.place) { @@ -260,6 +285,9 @@
+ @if (selectedItem.gpx) { + + } @if (selectedItem.lat && selectedItem.lng) { } @@ -285,9 +313,11 @@
@if (selectedItem.place) { -
+

Place

{{ selectedItem.place.name }}
+
} @@ -324,6 +354,12 @@
}
+ + @if (selectedItem.image) { +
+ +
+ }
} @@ -379,7 +415,7 @@ @defer { @for (p of places; track p.id) {
+ (mouseenter)="placeHighlightMarker(p)" (mouseleave)="resetPlaceHighlightMarker()">
@@ -546,8 +582,9 @@
-
+
+
diff --git a/src/src/app/components/trip/trip.component.ts b/src/src/app/components/trip/trip.component.ts index 7b119bc..ada50d9 100644 --- a/src/src/app/components/trip/trip.component.ts +++ b/src/src/app/components/trip/trip.component.ts @@ -24,6 +24,7 @@ import { placeToMarker, createClusterGroup, tripDayMarker, + gpxToPolyline, } from "../../shared/map"; import { ActivatedRoute, Router } from "@angular/router"; import { DialogService, DynamicDialogRef } from "primeng/dynamicdialog"; @@ -59,6 +60,7 @@ import { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox"; import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component"; import { TripCreateChecklistModalComponent } from "../../modals/trip-create-checklist-modal/trip-create-checklist-modal.component"; import { TripInviteMemberModalComponent } from "../../modals/trip-invite-member-modal/trip-invite-member-modal.component"; +import { calculateDistanceBetween } from "../../shared/haversine"; @Component({ selector: "app-trip", @@ -113,6 +115,7 @@ export class TripComponent implements AfterViewInit { map?: L.Map; markerClusterGroup?: L.MarkerClusterGroup; tripMapTemporaryMarker?: L.Marker; + tripMapGpxLayer?: L.Layer; tripMapHoveredElement?: HTMLElement; tripMapAntLayer?: L.FeatureGroup; tripMapAntLayerDayID?: number; @@ -262,6 +265,7 @@ export class TripComponent implements AfterViewInit { "LatLng", "price", "status", + "distance", ]; tripTableSelectedColumns: string[] = [ "day", @@ -409,6 +413,7 @@ export class TripComponent implements AfterViewInit { flattenTripDayItems(searchValue?: string) { this.sortTripDays(); + let prevLat: number, prevLng: number; this.flattenedTripItems = this.trip!.days.flatMap((day) => [...day.items] .filter((item) => @@ -419,20 +424,39 @@ export class TripComponent implements AfterViewInit { : true, ) .sort((a, b) => a.time.localeCompare(b.time)) - .map((item) => ({ - td_id: day.id, - td_label: day.label, - id: item.id, - time: item.time, - text: item.text, - status: this.statusToTripStatus(item.status as string), - comment: item.comment, - price: item.price || undefined, - day_id: item.day_id, - place: item.place, - lat: item.lat || (item.place ? item.place.lat : undefined), - lng: item.lng || (item.place ? item.place.lng : undefined), - })), + .map((item) => { + const lat = item.lat ?? (item.place ? item.place.lat : undefined); + const lng = item.lng ?? (item.place ? item.place.lng : undefined); + + let distance: number | undefined; + if (lat && lng) { + if (prevLat && prevLng) { + const d = calculateDistanceBetween(prevLat, prevLng, lat, lng); + distance = +(Math.round(d * 1000) / 1000).toFixed(2); + } + prevLat = lat; + prevLng = lng; + } + + return { + td_id: day.id, + td_label: day.label, + id: item.id, + time: item.time, + text: item.text, + status: this.statusToTripStatus(item.status as string), + comment: item.comment, + price: item.price || undefined, + day_id: item.day_id, + place: item.place, + image: item.image, + image_id: item.image_id, + gpx: item.gpx, + lat, + lng, + distance, + }; + }), ); } @@ -517,30 +541,39 @@ export class TripComponent implements AfterViewInit { this.map?.removeLayer(this.tripMapTemporaryMarker); this.tripMapTemporaryMarker = undefined; } + + if (this.tripMapGpxLayer) { + this.map?.removeLayer(this.tripMapGpxLayer); + this.tripMapGpxLayer = undefined; + } this.resetMapBounds(); } - placeHighlightMarker(lat: number, lng: number) { + placeHighlightMarker(item: any) { if (this.tripMapHoveredElement || this.tripMapTemporaryMarker) this.resetPlaceHighlightMarker(); let marker: L.Marker | undefined; this.markerClusterGroup?.eachLayer((layer: any) => { - if (layer.getLatLng && layer.getLatLng().equals([lat, lng])) { + if (layer.getLatLng && layer.getLatLng().equals([item.lat, item.lng])) { marker = layer; } }); + if (item.gpx) { + this.tripMapGpxLayer = gpxToPolyline(item.gpx); + this.tripMapGpxLayer.addTo(this.map!); + } + if (!marker) { // TripItem without place, but latlng - const item = { - text: this.selectedItem?.text || "", - lat: lat, - lng: lng, - time: this.selectedItem?.time || "", - }; this.tripMapTemporaryMarker = tripDayMarker(item).addTo(this.map!); - this.map?.fitBounds([[lat, lng]], { padding: [60, 60] }); + if (this.tripMapGpxLayer) { + this.map?.fitBounds( + [[item.lat, item.lng], (this.tripMapGpxLayer as any).getBounds()], + { padding: [30, 30] }, + ); + } else this.map?.fitBounds([[item.lat, item.lng]], { padding: [60, 60] }); return; } @@ -602,6 +635,7 @@ export class TripComponent implements AfterViewInit { isPlace: !!item.place, idx: idx, time: item.time, + gpx: item.gpx, }; if (item.lat && item.lng) @@ -680,8 +714,9 @@ export class TripComponent implements AfterViewInit { prevPoint = coords[0]; } - group.forEach((day: any) => { - if (!day.isPlace) layGroup.addLayer(tripDayMarker(day)); + group.forEach((data: any) => { + if (!data.isPlace) layGroup.addLayer(tripDayMarker(data)); + if (data.gpx) layGroup.addLayer(gpxToPolyline(data.gpx)); }); }); @@ -724,6 +759,7 @@ export class TripComponent implements AfterViewInit { lng: item.lng, isPlace: !!item.place, time: item.time, + gpx: item.gpx, }; if (item.place && item.place) return { @@ -732,6 +768,7 @@ export class TripComponent implements AfterViewInit { lng: item.place.lng, isPlace: true, time: item.time, + gpx: item.gpx, }; return undefined; }) @@ -769,6 +806,7 @@ export class TripComponent implements AfterViewInit { layGroup.addLayer(path); items.forEach((item) => { if (!item.isPlace) layGroup.addLayer(tripDayMarker(item)); + if (item.gpx) layGroup.addLayer(gpxToPolyline(item.gpx)); }); if (this.tripMapAntLayer) { @@ -790,7 +828,7 @@ export class TripComponent implements AfterViewInit { this.resetPlaceHighlightMarker(); } else { this.selectedItem = item; - if (item.lat && item.lng) this.placeHighlightMarker(item.lat, item.lng); + if (item.lat && item.lng) this.placeHighlightMarker(item); } } @@ -809,7 +847,7 @@ export class TripComponent implements AfterViewInit { this.resetPlaceHighlightMarker(); this.selectedItem = item; - this.placeHighlightMarker(item.lat!, item.lng!); + this.placeHighlightMarker(item); } deleteTrip() { @@ -942,6 +980,18 @@ export class TripComponent implements AfterViewInit { window.open(url, "_blank"); } + downloadItemGPX() { + if (!this.selectedItem?.gpx) return; + const dataBlob = new Blob([this.selectedItem.gpx]); + const downloadURL = URL.createObjectURL(dataBlob); + const link = document.createElement("a"); + link.href = downloadURL; + link.download = `TRIP_${this.trip?.name}_${this.selectedItem.text}.gpx`; + link.click(); + link.remove(); + URL.revokeObjectURL(downloadURL); + } + tripToNavigation() { // TODO: More services const items = this.flattenedTripItems.filter( @@ -1284,6 +1334,47 @@ export class TripComponent implements AfterViewInit { }); } + editPlace(pEdit: Place) { + const modal: DynamicDialogRef = this.dialogService.open( + PlaceCreateModalComponent, + { + header: "Edit Place", + modal: true, + appendTo: "body", + closable: true, + dismissableMask: true, + width: "55vw", + breakpoints: { + "1920px": "70vw", + "1260px": "90vw", + }, + data: { + place: { ...pEdit, category: pEdit.category.id }, + }, + }, + ); + + modal.onClose.pipe(take(1)).subscribe({ + next: (p: Place | null) => { + if (!p) return; + + this.apiService + .putPlace(p.id, p) + .pipe(take(1)) + .subscribe({ + next: (place: Place) => { + const places = [...this.places]; + const idx = places.findIndex((p) => p.id == place.id); + if (idx > -1) places.splice(idx, 1, place); + places.push(place); + places.sort((a, b) => a.name.localeCompare(b.name)); + if (this.selectedItem?.place) this.selectedItem.place = place; + }, + }); + }, + }); + } + updateItemFromTrip(old: TripItem, updated: TripItem): void { if (!this.trip) return; diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html index 8b1cef3..7a09598 100644 --- a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html +++ b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.html @@ -4,7 +4,7 @@ @if (itemForm.get('id')?.value !== -1) { + scrollHeight="320px" appendTo="body" formControlName="day_id" [checkmark]="true" [filter]="true" fluid>
{{ day.label }}
@@ -14,7 +14,7 @@ } @else { + appendTo="body" selectedItemsLabel="{0} days selected" [filter]="true" fluid>
{{ day.label }}
@@ -34,8 +34,8 @@
-
- +
+ @@ -84,11 +84,38 @@
-
- - +
+ + + +
+ @if (itemForm.get("image")?.value) { +
+ +
+ +
+
+ } @else { + + } + +
+ +
+
+ @if (itemForm.get('gpx')?.value) { + + } @else { + + } + +
+
@@ -96,4 +123,15 @@ {{ itemForm.get("id")?.value !== -1 ? "Update" : "Create" }}
-
\ No newline at end of file + + + + Members +
+ @for (member of members; track member) { +
{{ member.user + }}
+ } +
+
\ No newline at end of file diff --git a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts index 2fc3c5c..5965bba 100644 --- a/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts +++ b/src/src/app/modals/trip-create-day-item-modal/trip-create-day-item-modal.component.ts @@ -46,6 +46,8 @@ export class TripCreateDayItemModalComponent { days: TripDay[] = []; places: Place[] = []; statuses: TripStatus[] = []; + previous_image_id: number | null = null; + previous_image: string | null = null; constructor( private ref: DynamicDialogRef, @@ -72,6 +74,9 @@ export class TripCreateDayItemModalComponent { place: null, status: null, price: null, + image: null, + image_id: null, + gpx: null, lat: [ "", { @@ -156,7 +161,64 @@ export class TripCreateDayItemModalComponent { ret["lat"] = null; ret["lng"] = null; } + if (ret["image_id"]) { + delete ret["image"]; + delete ret["image_id"]; + } + if (ret["gpx"] == "1") delete ret["gpx"]; if (!ret["place"]) delete ret["place"]; this.ref.close(ret); } + + onImageSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files?.length) { + const file = input.files[0]; + const reader = new FileReader(); + + reader.onload = (e) => { + if (this.itemForm.get("image_id")?.value) { + this.previous_image_id = this.itemForm.get("image_id")?.value; + this.previous_image = this.itemForm.get("image")?.value; + this.itemForm.get("image_id")?.setValue(null); + } + + this.itemForm.get("image")?.setValue(e.target?.result as string); + this.itemForm.get("image")?.markAsDirty(); + }; + + reader.readAsDataURL(file); + } + } + + clearImage() { + this.itemForm.get("image")?.setValue(null); + this.itemForm.get("image_id")?.setValue(null); + this.itemForm.markAsDirty(); + + if (this.previous_image && this.previous_image_id) { + this.itemForm.get("image_id")?.setValue(this.previous_image_id); + this.itemForm.get("image")?.setValue(this.previous_image); + } + } + + onGPXSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + const reader = new FileReader(); + + reader.onload = (e) => { + this.itemForm.get("gpx")?.setValue(e.target?.result as string); + this.itemForm.get("gpx")?.markAsDirty(); + }; + + reader.readAsText(file); + } + } + + clearGPX() { + this.itemForm.get("gpx")?.setValue(null); + this.itemForm.get("gpx")?.markAsDirty(); + } } diff --git a/src/src/app/shared/haversine.ts b/src/src/app/shared/haversine.ts new file mode 100644 index 0000000..7bd7d79 --- /dev/null +++ b/src/src/app/shared/haversine.ts @@ -0,0 +1,20 @@ +export function calculateDistanceBetween( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +) { + // returns d in meter + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const rLat1 = toRad(lat1); + const rLat2 = toRad(lat2); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(rLat1) * Math.cos(rLat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const R = 6371; + return R * c; +} diff --git a/src/src/app/types/trip.ts b/src/src/app/types/trip.ts index e3ee3b5..eaffa1c 100644 --- a/src/src/app/types/trip.ts +++ b/src/src/app/types/trip.ts @@ -44,6 +44,9 @@ export interface TripItem { price?: number; day_id: number; status?: string | TripStatus; + image?: string; + image_id?: number; + gpx?: string; } export interface TripStatus { @@ -64,6 +67,10 @@ export interface FlattenedTripItem { lng?: number; day_id: number; status?: TripStatus; + distance?: number; + image?: string; + image_id?: number; + gpx?: string; } export interface TripMember {