Trip review panel, LatLng parser to support more copy/paste format, 💄 Trip collapsible panels, 💄 Trip minor UI, 🐛 Fix select overflow on long text

This commit is contained in:
itskovacs 2025-07-21 18:27:31 +02:00
parent 5ae894c577
commit c32a04c991
12 changed files with 314 additions and 110 deletions

View File

@ -1 +1 @@
__version__ = "1.3.0" __version__ = "1.3.1"

View File

@ -22,8 +22,8 @@
</div> </div>
<div class="flex md:hidden"> <div class="flex md:hidden">
<p-button (click)="menu.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" /> <p-button (click)="menuTripActions.toggle($event)" severity="secondary" text icon="pi pi-ellipsis-h" />
<p-menu #menu [model]="menuItems" [popup]="true" /> <p-menu #menuTripActions [model]="menuTripActionsItems" [popup]="true" />
</div> </div>
} }
@ -62,8 +62,8 @@
@defer { @defer {
@if (flattenedTripItems.length) { @if (flattenedTripItems.length) {
<p-table [value]="flattenedTripItems" styleClass="max-w-[85vw] md:max-w-full" rowGroupMode="rowspan" <p-table [value]="flattenedTripItems" class="print-striped-rows" styleClass="max-w-[85vw] md:max-w-full"
groupRowsBy="td_label"> rowGroupMode="rowspan" groupRowsBy="td_label">
<ng-template #header> <ng-template #header>
<tr> <tr>
<th>Day</th> <th>Day</th>
@ -97,7 +97,7 @@
</div> </div>
} @else {-} } @else {-}
</td> </td>
<td class="max-w-20 truncate">{{ tripitem.comment || '-' }}</td> <td class="max-w-20 truncate print:whitespace-normal">{{ tripitem.comment || '-' }}</td>
<td class="font-mono text-sm"> <td class="font-mono text-sm">
<div class="max-w-20 print:max-w-full truncate"> <div class="max-w-20 print:max-w-full truncate">
@if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} } @if (tripitem.lat) { {{ tripitem.lat }}, {{ tripitem.lng }} }
@ -214,50 +214,31 @@
</div> </div>
</div> </div>
} @else { }
<div class="p-4 shadow rounded-md w-full min-h-20"> <div class="z-10 p-4 shadow rounded-md w-full min-h-20 max-h-full overflow-y-auto">
<div class="p-2 mb-2 flex justify-between items-center"> <div class="p-2 mb-2 flex justify-between items-center">
<div> <div>
<h1 class="font-semibold tracking-tight text-xl">Days</h1> <h1 class="font-semibold tracking-tight text-xl">Map</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} days</span> <span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
</div> </div>
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text /> <p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text />
</div> </div>
<div class="max-h-[20vh] overflow-y-auto"> <div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div>
@defer {
@for (d of trip?.days; track d.id) {
<div class="group flex items-center rounded-md justify-between h-10 px-4 py-2 hover:bg-gray-50">
{{ d.label }}
<div>
<span class="bg-gray-100 text-gray-800 text-sm me-2 px-2.5 py-0.5 rounded-md group-hover:hidden">{{
getDayStats(d).price || '-' }} {{ currency$ | async }}</span>
<span class="bg-blue-100 text-blue-800 text-sm me-2 px-2.5 py-0.5 rounded-md group-hover:hidden">{{
getDayStats(d).places }}</span>
</div>
<div class="hidden group-hover:flex gap-2 items-center">
<p-button icon="pi pi-trash" severity="danger" [disabled]="trip?.archived" (click)="deleteDay(d)" text />
<p-button icon="pi pi-pencil" [disabled]="trip?.archived" (click)="editDay(d)" label="Edit" text />
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addItem(d.id)" label="Item" text />
</div>
</div>
} @empty {
<p-button label="Add" icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div>
</div> </div>
@if (!selectedItem) {
<div class="p-4 shadow rounded-md w-full min-h-20"> <div class="p-4 shadow rounded-md w-full min-h-20">
<div class="p-2 mb-2 flex justify-between items-center"> <div class="p-2 mb-2 flex justify-between items-center">
<div> <div class="group relative">
<h1 class="font-semibold tracking-tight text-xl">Places</h1> <h1 class="font-semibold tracking-tight text-xl">Places</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span> <span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
<div class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x">
<p-button [icon]="collapsedTripPlaces ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripPlaces = !collapsedTripPlaces" />
</div>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
@ -270,6 +251,7 @@
</div> </div>
</div> </div>
@if (!collapsedTripPlaces) {
<div class="max-h-[25vh] overflow-y-auto"> <div class="max-h-[25vh] overflow-y-auto">
@defer { @defer {
@for (p of places; track p.id) { @for (p of places; track p.id) {
@ -313,20 +295,104 @@
</div> </div>
} }
</div> </div>
}
</div>
<div class="p-4 shadow rounded-md w-full min-h-20">
<div class="p-2 mb-2 flex justify-between items-center">
<div class="group relative">
<h1 class="font-semibold tracking-tight text-xl">Days</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} days</span>
<div class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x">
<p-button [icon]="collapsedTripDays ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripDays = !collapsedTripDays" />
</div>
</div>
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
</div>
@if (!collapsedTripDays) {
<div class="max-h-[20vh] overflow-y-auto">
@defer {
@for (d of trip?.days; track d.id) {
<div
class="group flex items-center gap-4 rounded-md justify-between h-10 px-4 py-2 hover:bg-gray-50 w-full max-w-full">
<div class="line-clamp-1">
{{ d.label }}
</div>
<div class="flex items-center gap-2 flex-none">
<span class="bg-gray-100 text-gray-800 text-sm px-2.5 py-0.5 rounded-md min-w-fit group-hover:hidden">{{
getDayStats(d).price || '-' }} {{ currency$ | async }}</span>
<span class="bg-blue-100 text-blue-800 text-sm px-2.5 py-0.5 rounded-md group-hover:hidden">{{
getDayStats(d).places }}</span>
<div class="flex md:hidden">
<p-button (click)="selectedTripDayForMenu = d; menuTripDayActions.toggle($event)" severity="secondary"
text icon="pi pi-ellipsis-h" />
</div>
</div>
<div class="hidden group-hover:flex gap-2 items-center flex-none">
<p-button icon="pi pi-trash" severity="danger" [disabled]="trip?.archived" (click)="deleteDay(d)" text />
<p-button icon="pi pi-pencil" [disabled]="trip?.archived" (click)="editDay(d)" label="Edit" text />
<p-button icon="pi pi-plus" [disabled]="trip?.archived" (click)="addItem(d.id)" label="Item" text />
</div>
</div>
} @empty {
<p-button label="Add" icon="pi pi-plus" [disabled]="trip?.archived" (click)="addDay()" text />
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div> </div>
} }
<div class="z-10 p-4 shadow rounded-md w-full min-h-20 max-h-full overflow-y-auto">
<div class="p-2 mb-2 flex justify-between items-center">
<div>
<h1 class="font-semibold tracking-tight text-xl">Map</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} places</span>
</div> </div>
<p-button icon="pi pi-refresh" [disabled]="!places.length" (click)="resetMapBounds()" text /> <div class="p-4 shadow rounded-md w-full min-h-20">
<div class="group relative p-2 mb-2 flex flex-col items-start">
<h1 class="font-semibold tracking-tight text-xl">Review</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} pending/constraints</span>
<div class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x">
<p-button [icon]="collapsedTripStatuses ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripStatuses = !collapsedTripStatuses" />
</div>
</div> </div>
<div id="map" class="w-full rounded-md min-h-96 h-1/3 max-h-full"></div> @if (!collapsedTripStatuses) {
<div class="max-h-[20vh] overflow-y-auto">
@defer {
@for (item of getReviewData; track item.id) {
<div class="flex items-center gap-4 rounded-md justify-between h-10 px-4 py-2 w-full max-w-full">
<div class="line-clamp-1">
{{ item.text }}
</div> </div>
<div class="flex items-center gap-2 flex-none">
@if (item.status) {
<span [style.background]="item.status.color+'1A'" [style.color]="item.status.color"
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
item.status.label }}</span>
}
</div>
</div>
} @empty {
<p class="p-4 font-light text-gray-500">
Nothing to review
</p>
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div>
}
</div>
}
</div> </div>
</section> </section>
<p-menu #menuTripDayActions [model]="menuTripDayActionsItems" appendTo="body" [popup]="true" />

View File

@ -0,0 +1,10 @@
@media print {
.print-striped-rows tr:nth-child(even) {
background-color: #f9f9f9 !important;
}
.print-striped-rows tr:nth-child(even) td:first-child.truncate {
//HACK: The "day" column is truncated
background-color: white !important;
}
}

View File

@ -69,7 +69,14 @@ export class TripComponent implements AfterViewInit {
places: PlaceWithUsage[] = []; places: PlaceWithUsage[] = [];
flattenedTripItems: FlattenedTripItem[] = []; flattenedTripItems: FlattenedTripItem[] = [];
menuItems: MenuItem[] = []; menuTripActionsItems: MenuItem[] = [];
menuTripDayActionsItems: MenuItem[] = [];
selectedTripDayForMenu: TripDay | undefined;
collapsedTripPlaces: boolean = false;
collapsedTripDays: boolean = false;
collapsedTripStatuses: boolean = false;
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
@ -81,7 +88,7 @@ export class TripComponent implements AfterViewInit {
this.currency$ = this.utilsService.currency$; this.currency$ = this.utilsService.currency$;
this.statuses = this.utilsService.statuses; this.statuses = this.utilsService.statuses;
this.menuItems = [ this.menuTripActionsItems = [
{ {
label: "Actions", label: "Actions",
items: [ items: [
@ -112,6 +119,38 @@ export class TripComponent implements AfterViewInit {
], ],
}, },
]; ];
this.menuTripDayActionsItems = [
{
label: "Actions",
items: [
{
label: "Item",
icon: "pi pi-plus",
iconClass: "text-blue-500!",
command: () => {
this.addItem();
},
},
{
label: "Edit",
icon: "pi pi-pencil",
command: () => {
if (!this.selectedTripDayForMenu) return;
this.editDay(this.selectedTripDayForMenu);
},
},
{
label: "Delete",
icon: "pi pi-trash",
iconClass: "text-red-500!",
command: () => {
if (!this.selectedTripDayForMenu) return;
this.deleteDay(this.selectedTripDayForMenu);
},
},
],
},
];
} }
back() { back() {
@ -166,6 +205,20 @@ export class TripComponent implements AfterViewInit {
return stats; return stats;
} }
get getReviewData(): (TripItem & { status: TripStatus })[] {
if (!this.trip?.days) return [];
const data = this.trip!.days.map((day) =>
day.items.filter((item) => item.status),
).flat();
if (!data.length) return [];
return data.map((item) => ({
...item,
status: this.statusToTripStatus(item.status as string),
})) as (TripItem & { status: TripStatus })[];
}
statusToTripStatus(status?: string): TripStatus | undefined { statusToTripStatus(status?: string): TripStatus | undefined {
if (!status) return undefined; if (!status) return undefined;
return this.statuses.find((s) => s.label == status) as TripStatus; return this.statuses.find((s) => s.label == status) as TripStatus;
@ -576,6 +629,7 @@ export class TripComponent implements AfterViewInit {
this.trip?.days!, this.trip?.days!,
); );
} }
if (item.price) this.updateTotalPrice(item.price);
}, },
}); });
}, },

View File

@ -21,15 +21,19 @@
<label for="place">Place</label> <label for="place">Place</label>
</p-floatlabel> </p-floatlabel>
<p-inputgroup-addon> <p-inputgroup-addon>
<p-button icon="pi pi-info-circle" [pTooltip]="placeInputTooltip" [escape]="false" tooltipEvent="focus" <p-button icon="pi pi-info-circle" tabindex="-1" [pTooltip]="placeInputTooltip" [escape]="false"
severity="secondary" class="h-full" /> tooltipEvent="focus" severity="secondary" class="h-full" />
</p-inputgroup-addon> </p-inputgroup-addon>
</p-inputgroup> </p-inputgroup>
<p-floatlabel variant="in" class="col-span-2 md:col-span-1"> <p-floatlabel variant="in" class="col-span-2 md:col-span-1">
<p-select [options]="(categories$ | async) || []" optionValue="id" optionLabel="name" <p-select [options]="(categories$ | async) || []" optionValue="id" optionLabel="name"
[loading]="!(categories$ | async)?.length" inputId="category" id="category" formControlName="category" [loading]="!(categories$ | async)?.length" inputId="category" id="category" formControlName="category"
[checkmark]="true" class="w-full capitalize" fluid /> [checkmark]="true" class="capitalize" fluid>
<ng-template let-category #item>
<div class="whitespace-normal">{{ category.name }}</div>
</ng-template>
</p-select>
<label for="category">Category</label> <label for="category">Category</label>
</p-floatlabel> </p-floatlabel>

View File

@ -21,6 +21,7 @@ import { FocusTrapModule } from "primeng/focustrap";
import { Category, Place } from "../../types/poi"; import { Category, Place } from "../../types/poi";
import { CheckboxModule } from "primeng/checkbox"; import { CheckboxModule } from "primeng/checkbox";
import { TooltipModule } from "primeng/tooltip"; import { TooltipModule } from "primeng/tooltip";
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
@Component({ @Component({
selector: "app-place-create-modal", selector: "app-place-create-modal",
@ -114,30 +115,18 @@ export class PlaceCreateModalComponent {
this.placeForm.get("lat")?.valueChanges.subscribe({ this.placeForm.get("lat")?.valueChanges.subscribe({
next: (value: string) => { next: (value: string) => {
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) { const result = checkAndParseLatLng(value);
let [lat, lng] = value.split(", "); if (!result) return;
const latLength = lat.split(".")[1].length; const [lat, lng] = result;
const lngLength = lng.split(".")[1].length;
const latControl = this.placeForm.get("lat"); const latControl = this.placeForm.get("lat");
const lngControl = this.placeForm.get("lng"); const lngControl = this.placeForm.get("lng");
latControl?.setValue( latControl?.setValue(formatLatLng(lat), { emitEvent: false });
parseFloat(lat).toFixed(latLength > 5 ? 5 : latLength), lngControl?.setValue(formatLatLng(lng), { emitEvent: false });
{
emitEvent: false,
},
);
lngControl?.setValue(
parseFloat(lng).toFixed(lngLength > 5 ? 5 : lngLength),
{
emitEvent: false,
},
);
lngControl?.markAsDirty(); lngControl?.markAsDirty();
lngControl?.updateValueAndValidity(); lngControl?.updateValueAndValidity();
}
}, },
}); });
} }

View File

@ -3,7 +3,11 @@
<div class="grid md:grid-cols-5 gap-4"> <div class="grid md:grid-cols-5 gap-4">
<p-floatlabel variant="in"> <p-floatlabel variant="in">
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id" <p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
formControlName="day_id" [checkmark]="true" class="capitalize" fluid /> formControlName="day_id" [checkmark]="true" class="capitalize" fluid>
<ng-template let-day #item>
<div class="whitespace-normal">{{ day.label }}</div>
</ng-template>
</p-select>
<label for="day_id">Day</label> <label for="day_id">Day</label>
</p-floatlabel> </p-floatlabel>
@ -26,7 +30,7 @@
formControlName="place" [showClear]="true" class="capitalize" fluid> formControlName="place" [showClear]="true" class="capitalize" fluid>
<ng-template let-place #item> <ng-template let-place #item>
<div class="flex items-center gap-2" [class.text-gray-500]="place.placeUsage"> <div class="flex items-center whitespace-normal gap-2" [class.text-gray-500]="place.placeUsage">
<img [src]="place.image" class="rounded-full size-6" /> <img [src]="place.image" class="rounded-full size-6" />
<div>{{ place.name }}</div> <div>{{ place.name }}</div>
</div> </div>
@ -61,6 +65,9 @@
</div> </div>
} }
</ng-template> </ng-template>
<ng-template let-option #item>
<div class="whitespace-normal">{{ option.label }}</div>
</ng-template>
</p-select> </p-select>
<label for="status">Status</label> <label for="status">Status</label>
</p-floatlabel> </p-floatlabel>

View File

@ -15,6 +15,7 @@ import { SelectModule } from "primeng/select";
import { TextareaModule } from "primeng/textarea"; import { TextareaModule } from "primeng/textarea";
import { InputMaskModule } from "primeng/inputmask"; import { InputMaskModule } from "primeng/inputmask";
import { UtilsService } from "../../services/utils.service"; import { UtilsService } from "../../services/utils.service";
import { checkAndParseLatLng, formatLatLng } from "../../shared/latlng-parser";
@Component({ @Component({
selector: "app-trip-create-day-item-modal", selector: "app-trip-create-day-item-modal",
@ -70,6 +71,7 @@ export class TripCreateDayItemModalComponent {
"", "",
{ {
validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"), validators: Validators.pattern("-?(90(\\.0+)?|[1-8]?\\d(\\.\\d+)?)"),
updateOn: "blur",
}, },
], ],
lng: [ lng: [
@ -104,17 +106,26 @@ export class TripCreateDayItemModalComponent {
this.itemForm.get("lat")?.setValue(p.lat); this.itemForm.get("lat")?.setValue(p.lat);
this.itemForm.get("lng")?.setValue(p.lng); this.itemForm.get("lng")?.setValue(p.lng);
this.itemForm.get("price")?.setValue(p.price || 0); this.itemForm.get("price")?.setValue(p.price || 0);
if (!this.itemForm.get("text")?.value)
this.itemForm.get("text")?.setValue(p.name);
} }
}, },
}); });
this.itemForm.get("lat")?.valueChanges.subscribe({ this.itemForm.get("lat")?.valueChanges.subscribe({
next: (value: string) => { next: (value: string) => {
if (/\-?\d+\.\d+,\s\-?\d+\.\d+/.test(value)) { const result = checkAndParseLatLng(value);
let [lat, lng] = value.split(", "); if (!result) return;
this.itemForm.get("lat")?.setValue(parseFloat(lat).toFixed(5));
this.itemForm.get("lng")?.setValue(parseFloat(lng).toFixed(5)); const [lat, lng] = result;
} const latControl = this.itemForm.get("lat");
const lngControl = this.itemForm.get("lng");
latControl?.setValue(formatLatLng(lat), { emitEvent: false });
lngControl?.setValue(formatLatLng(lng), { emitEvent: false });
lngControl?.markAsDirty();
lngControl?.updateValueAndValidity();
}, },
}); });
} }

View File

@ -1,7 +1,11 @@
<section [formGroup]="itemBatchForm" class="grid gap-4"> <section [formGroup]="itemBatchForm" class="grid gap-4">
<p-floatlabel variant="in"> <p-floatlabel variant="in">
<p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id" <p-select [options]="days" optionValue="id" optionLabel="label" inputId="day_id" id="day_id"
formControlName="day_id" [checkmark]="true" class="w-full capitalize" fluid /> formControlName="day_id" [checkmark]="true" class="w-full capitalize" fluid>
<ng-template let-day #item>
<div class="whitespace-normal">{{ day.label }}</div>
</ng-template>
</p-select>
<label for="day_id">Day</label> <label for="day_id">Day</label>
</p-floatlabel> </p-floatlabel>

View File

@ -10,7 +10,13 @@ import { SkeletonModule } from "primeng/skeleton";
@Component({ @Component({
selector: "app-trip-place-select-modal", selector: "app-trip-place-select-modal",
imports: [FloatLabelModule, InputTextModule, ButtonModule, ReactiveFormsModule, SkeletonModule], imports: [
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
SkeletonModule,
],
standalone: true, standalone: true,
templateUrl: "./trip-place-select-modal.component.html", templateUrl: "./trip-place-select-modal.component.html",
styleUrl: "./trip-place-select-modal.component.scss", styleUrl: "./trip-place-select-modal.component.scss",
@ -27,11 +33,11 @@ export class TripPlaceSelectModalComponent {
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private ref: DynamicDialogRef, private ref: DynamicDialogRef,
private config: DynamicDialogConfig private config: DynamicDialogConfig,
) { ) {
this.apiService.getPlaces().subscribe({ this.apiService.getPlaces().subscribe({
next: (places) => { next: (places) => {
this.places = places; this.places = places.sort((a, b) => a.name.localeCompare(b.name));
this.displayedPlaces = places; this.displayedPlaces = places;
}, },
}); });
@ -51,7 +57,9 @@ export class TripPlaceSelectModalComponent {
let v = value.toLowerCase(); let v = value.toLowerCase();
this.displayedPlaces = this.places.filter( this.displayedPlaces = this.places.filter(
(p) => p.name.toLowerCase().includes(v) || p.description?.toLowerCase().includes(v) (p) =>
p.name.toLowerCase().includes(v) ||
p.description?.toLowerCase().includes(v),
); );
}, },
}); });
@ -62,7 +70,7 @@ export class TripPlaceSelectModalComponent {
this.selectedPlacesID.splice(this.selectedPlacesID.indexOf(p.id), 1); this.selectedPlacesID.splice(this.selectedPlacesID.indexOf(p.id), 1);
this.selectedPlaces.splice( this.selectedPlaces.splice(
this.selectedPlaces.findIndex((place) => place.id === p.id), this.selectedPlaces.findIndex((place) => place.id === p.id),
1 1,
); );
return; return;
} }

View File

@ -0,0 +1,64 @@
const patternDEC = /^\s*(-?\d{1,3}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
const patternDD =
/^\s*(\d{1,3}(?:\.\d+)?)°?\s*([NS])\s*,\s*(\d{1,3}(?:\.\d+)?)°?\s*([EW])\s*$/i;
const patternDMS =
/^\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2})[']\s*(\d{1,2}(?:\.\d+)?)["″]?\s*([EW])\s*$/i;
const patternDMM =
/^\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([NS])\s*,\s*(\d{1,3})°\s*(\d{1,2}(?:\.\d+)?)[']?\s*([EW])\s*$/i;
function _dmsToDecimal(
deg: number,
min: number,
sec: number,
dir: string,
): number {
let dec = deg + min / 60 + sec / 3600;
return /[SW]/i.test(dir) ? -dec : dec;
}
function _dmmToDecimal(deg: number, min: number, dir: string): number {
let dec = deg + min / 60;
return /[SW]/i.test(dir) ? -dec : dec;
}
export function formatLatLng(num: number): string {
const decimals = num.toString().split(".")[1]?.length || 0;
return num.toFixed(Math.min(decimals, 5));
}
export function checkAndParseLatLng(str: string): [number, number] | undefined {
// Parse DMS, DD, DDM to decimal [Lat, Lng]
const dec = str.match(patternDEC);
if (dec) {
const lat = parseFloat(dec[1]);
const lng = parseFloat(dec[2]);
if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) {
return [lat, lng];
}
}
const dd = str.match(patternDD);
if (dd) {
let lat = parseFloat(dd[1]);
let lng = parseFloat(dd[3]);
lat *= /S/i.test(dd[2]) ? -1 : 1;
lng *= /W/i.test(dd[4]) ? -1 : 1;
return [lat, lng];
}
const dms = str.match(patternDMS);
if (dms) {
const lat = _dmsToDecimal(+dms[1], +dms[2], +dms[3], dms[4]);
const lng = _dmsToDecimal(+dms[5], +dms[6], +dms[7], dms[8]);
return [lat, lng];
}
const dmm = str.match(patternDMM);
if (dmm) {
const lat = _dmmToDecimal(+dmm[1], +dmm[2], dmm[3]);
const lng = _dmmToDecimal(+dmm[4], +dmm[5], dmm[6]);
return [lat, lng];
}
return undefined;
}

View File

@ -46,17 +46,6 @@ html {
line-height: normal; line-height: normal;
} }
@keyframes slideY {
0% {
opacity: 0;
transform: translateY(20px);
}
}
.slideY {
animation: slideY 0.3s both;
}
.class-tooltip { .class-tooltip {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
@ -112,35 +101,22 @@ html {
border-top: 1px solid transparent; border-top: 1px solid transparent;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
cursor: default; cursor: pointer;
outline: none; outline: none;
} }
.leaflet-contextmenu a.leaflet-contextmenu-item-disabled {
opacity: 0.5;
}
.leaflet-contextmenu a.leaflet-contextmenu-item.over { .leaflet-contextmenu a.leaflet-contextmenu-item.over {
background-color: #f1f3f7; background-color: #f1f3f7;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
} }
.leaflet-contextmenu a.leaflet-contextmenu-item-disabled.over {
background-color: inherit;
}
.leaflet-contextmenu-icon { .leaflet-contextmenu-icon {
display: flex; display: flex;
align-items: center; align-items: center;
width: 24px; width: 24px;
} }
.leaflet-contextmenu-separator {
border-bottom: 1px solid #ccc;
margin: 5px 0;
}
.image-marker { .image-marker {
border-radius: 50%; border-radius: 50%;
border: 2px solid #405cf5; border: 2px solid #405cf5;
@ -167,3 +143,14 @@ html {
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
} }
} }
@keyframes slide-x {
0% {
opacity: 0;
transform: translateX(-20px);
}
}
.slide-x {
animation: slide-x 0.3s both;
}