diff --git a/docs/src/content/docs/use/create-a-trail.md b/docs/src/content/docs/use/create-a-trail.md index f9a7d5c1..ca4fd441 100644 --- a/docs/src/content/docs/use/create-a-trail.md +++ b/docs/src/content/docs/use/create-a-trail.md @@ -37,6 +37,9 @@ Click the **Draw a route** button to manually define a route on the map. While i - Click on the map to place waypoints - wanderer will automatically route between points using the [Valhalla routing engine](https://github.com/valhalla/valhalla) - You can drag points to reposition them +- The anchor list next to the map shows start, intermediate, and finish points with segment distance and elevation stats +- Hover an item in the anchor list to highlight its marker on the map +- Reorder intermediate anchors from the list to adjust the route sequence - Use the top-left menu to change routing mode (e.g. walking, cycling) - To remove a point, click on it and then click the red trash icon @@ -105,4 +108,3 @@ To learn more about summit logs visit the [dedicated section](/use/summit-logs) ## Step 6: Save the trail When you're done, click to persist your trail to the database. This will also re-index it for search and display it in your trail list. - diff --git a/web/src/lib/components/base/dropdown.svelte b/web/src/lib/components/base/dropdown.svelte index 3b64c82d..6de69796 100644 --- a/web/src/lib/components/base/dropdown.svelte +++ b/web/src/lib/components/base/dropdown.svelte @@ -4,6 +4,7 @@ value: any; icon?: string; separator?: boolean; + danger?: boolean; }; @@ -138,6 +139,8 @@ {:else} + {/each} + + + diff --git a/web/src/lib/components/trail/trail_dropdown.svelte b/web/src/lib/components/trail/trail_dropdown.svelte index 3e711f63..8bfb051c 100644 --- a/web/src/lib/components/trail/trail_dropdown.svelte +++ b/web/src/lib/components/trail/trail_dropdown.svelte @@ -289,6 +289,7 @@ text: $_("delete"), value: "delete", icon: "trash", + danger: true, }, ] : []), @@ -413,6 +414,7 @@ text: $_("delete"), value: "delete", icon: "trash", + danger: true, }, ] : []), diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index 2e203190..60e339ec 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -4,6 +4,7 @@ import Tabs from "$lib/components/base/tabs.svelte"; import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte"; import { Comment } from "$lib/models/comment"; + import { Tag } from "$lib/models/tag"; import type { Trail } from "$lib/models/trail"; import { @@ -57,7 +58,12 @@ import { handleFromRecordWithIRI } from "$lib/util/activitypub_util"; import LikeButton from "./like_button.svelte"; import Editor from "../base/editor.svelte"; - import { trails_update } from "$lib/stores/trail_store"; + import { + trails_update, + trails_update_metadata, + } from "$lib/stores/trail_store"; + import Combobox, { type ComboboxItem } from "../base/combobox.svelte"; + import { tags_index } from "$lib/stores/tag_store"; interface Props { initTrail: Trail; @@ -87,15 +93,16 @@ ...($currentUser ? [$_("comment", { values: { n: 2 } })] : []), ]; - const trailIsShared = - (trail.expand?.trail_share_via_trail?.length ?? 0) > 0; + const trailIsShared = $derived( + (trail.expand?.trail_share_via_trail?.length ?? 0) > 0, + ); let gallery: PhotoGallery; let newComment: Comment = $state({ text: "", author: "", - trail: untrack(() => handle) + "/" + (trail.id ?? ""), + trail: untrack(() => `${handle}/${trail.id ?? ""}`), }); let commentsLoading: boolean = $state(untrack(() => activeTab == 2)); @@ -106,6 +113,26 @@ let summitLogCreateLoading: boolean = $state(false); let fullDescription: boolean = $state(false); + let metadataSaving: boolean = $state(false); + let editingName: boolean = $state(false); + let editingDescription: boolean = $state(false); + let editingTags: boolean = $state(false); + let nameDraft: string = $state(""); + let descriptionDraft: string = $state(""); + let tagDraftItems: ComboboxItem[] = $state([]); + let tagItems: ComboboxItem[] = $state([]); + + const canEditTrail = $derived( + Boolean( + $currentUser && + (trail.author === $currentUser.actor || + trail.expand?.trail_share_via_trail?.some( + (share) => + share.permission === "edit" && + share.actor === $currentUser.actor, + )), + ), + ); onMount(async () => {}); @@ -289,6 +316,133 @@ const updatedTrail: Trail = { ...trail }; await trails_update(trail, updatedTrail); } + + function cloneTrail(value: Trail): Trail { + return JSON.parse(JSON.stringify(value)); + } + + function mergeTrailUpdate(previousTrail: Trail, updatedTrail: Trail): Trail { + return { + ...previousTrail, + ...updatedTrail, + expand: { + ...previousTrail.expand, + ...updatedTrail.expand, + author: previousTrail.expand?.author, + trail_like_via_trail: + previousTrail.expand?.trail_like_via_trail, + }, + }; + } + + async function saveTrailMetadata(update: (nextTrail: Trail) => void) { + if (!canEditTrail || metadataSaving) { + return false; + } + + metadataSaving = true; + const oldTrail = cloneTrail(trail); + const nextTrail = cloneTrail(trail); + nextTrail.expand ??= {}; + nextTrail.tags = [...(trail.tags ?? [])]; + update(nextTrail); + const tagsChanged = + JSON.stringify(nextTrail.expand?.tags ?? []) !== + JSON.stringify(oldTrail.expand?.tags ?? []); + + try { + const updatedTrail = await trails_update_metadata(oldTrail, { + name: + nextTrail.name !== oldTrail.name + ? nextTrail.name + : undefined, + description: + nextTrail.description !== oldTrail.description + ? nextTrail.description + : undefined, + expand: tagsChanged ? { tags: nextTrail.expand?.tags } : undefined, + }); + trail = mergeTrailUpdate(trail, updatedTrail); + show_toast({ + icon: "check", + type: "success", + text: $_("trail-saved-successfully"), + }); + return true; + } catch (e) { + console.error(e); + show_toast({ + icon: "close", + type: "error", + text: $_("error-saving-trail"), + }); + return false; + } finally { + metadataSaving = false; + } + } + + function startNameEdit() { + nameDraft = trail.name; + editingName = true; + } + + async function saveNameEdit() { + const name = nameDraft.trim(); + if (!name) { + return; + } + const saved = await saveTrailMetadata((nextTrail) => { + nextTrail.name = name; + }); + editingName = !saved; + } + + function startDescriptionEdit() { + descriptionDraft = trail.description ?? ""; + editingDescription = true; + } + + async function saveDescriptionEdit() { + const saved = await saveTrailMetadata((nextTrail) => { + nextTrail.description = descriptionDraft; + }); + editingDescription = !saved; + if (saved) { + fullDescription = true; + } + } + + function getTrailTagItems() { + return ( + trail.expand?.tags?.map((tag) => ({ + text: tag.name, + value: tag, + })) ?? [] + ); + } + + function startTagsEdit() { + tagDraftItems = getTrailTagItems(); + editingTags = true; + } + + async function searchTags(q: string) { + const result = await tags_index(q); + tagItems = result.items.map((tag) => ({ + text: tag.name, + value: tag, + })); + } + + async function saveTagsEdit() { + const saved = await saveTrailMetadata((nextTrail) => { + nextTrail.expand!.tags = tagDraftItems.map((item) => + item.value ? item.value : new Tag(item.text), + ); + }); + editingTags = !saved; + }
- {#if trail.expand?.tags && trail.expand.tags.length > 0} -
+ {#if editingTags} +
+ +
+ + +
+
+ {:else if trail.expand?.tags && trail.expand.tags.length > 0} +
{#each trail.expand.tags as tag} {/each} + {#if canEditTrail} + + {/if}
+ {:else if canEditTrail} + {/if} {#if (trail.public || trailIsShared) && $currentUser}
-

- {trail.name} -

+ {#if editingName} +
+ { + if (e.key === "Enter") { + void saveNameEdit(); + } else if (e.key === "Escape") { + editingName = false; + } + }} + /> +
+ + +
+
+ {:else} +
+

+ {trail.name} +

+ {#if canEditTrail} + + {/if} +
+ {/if} {#if trail.date}
{new Date(trail.date).toLocaleDateString( @@ -514,10 +761,44 @@ class:xl:grid-cols-[1fr_18rem]={mode == "overview"} >
-

- {$_("description")} -

- {#if trail.description?.length} +
+

+ {$_("description")} +

+ {#if canEditTrail && !editingDescription} + + {/if} +
+ {#if editingDescription} +
+ +
+ + +
+
+ {:else if trail.description?.length}
diff --git a/web/src/lib/i18n/locales/cs.json b/web/src/lib/i18n/locales/cs.json index 6ddfbd92..7cf90ee5 100644 --- a/web/src/lib/i18n/locales/cs.json +++ b/web/src/lib/i18n/locales/cs.json @@ -373,6 +373,7 @@ "road": "Silnice", "route": "{n, plural, =1 {Trasa} few {Trasy} other {Tras}}", "route-point": "Bod trasy", + "add-as-endpoint": "Add as endpoint", "russian": "Ruština", "save": "Uložit", "save-list": "Uložit seznam", diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 6cc6ea82..3a312a83 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -102,6 +102,7 @@ "creation-date": "Erstellungsdatum", "crop": "Zuschneiden", "cross": "Querfeldein", + "cumulative": "Kumulativ", "current-password": "Aktuelles Passwort", "cycling": "Radfahren", "cycling-speed": "Radfahrgeschwindigkeit", @@ -116,6 +117,7 @@ "delete-linked-trails": "Verknüpfte Routen löschen", "delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.", "delete-summit-log-confirm": "Möchtest du diesen Gipfelbuch-Eintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "delete-route-point": "Routenpunkt löschen", "delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "describe-your-trail": "Beschreibe deine Route", "description": "Beschreibung", @@ -292,6 +294,7 @@ "moderate": "Mittel", "more": "weitere", "more-route-settings": "Weitere Routen-Einstellungen", + "move-route-point": "Routenpunkt verschieben", "mountain": "Berg", "mountain-pass": "Bergpass", "must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein", @@ -391,10 +394,13 @@ "required": "Pflichtfeld", "reset": "Zurücksetzen", "reset-password": "Passwort zurücksetzen", + "reset-route": "Route zurücksetzen", "reverse-direction": "Richtung umkehren", "road": "Straße", "route": "{n, plural, =1 {Route} other {Routen}}", "route-point": "Punkt auf Route", + "reset-route-confirm": "Die aktuelle Route wird entfernt. Trail-Details, Fotos, Listen und andere Metadaten bleiben erhalten.", + "add-as-endpoint": "Als Endpunkt hinzufügen", "russian": "Russisch", "save": "Speichern", "save-list": "Liste speichern", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 34f3186e..52d26b58 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -102,6 +102,7 @@ "creation-date": "Creation date", "crop": "Crop", "cross": "Cross", + "cumulative": "Cumulative", "current-password": "Current password", "cycling": "Cycling", "cycling-speed": "Cycling Speed", @@ -116,6 +117,7 @@ "delete-linked-trails": "Delete linked trails", "delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.", "delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.", + "delete-route-point": "Delete route point", "delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.", "describe-your-trail": "Describe your trail", "description": "Description", @@ -292,6 +294,7 @@ "moderate": "Moderate", "more": "More", "more-route-settings": "More route settings", + "move-route-point": "Move route point", "mountain": "Mountain", "mountain-pass": "Mountain pass", "must-be-at-least-n-characters-long": "Must be at least {n} characters long", @@ -391,10 +394,13 @@ "required": "Required", "reset": "Reset", "reset-password": "Reset Password", + "reset-route": "Reset route", "reverse-direction": "Reverse direction", "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "reset-route-confirm": "The current route will be removed. Trail details, photos, lists and other metadata will be kept.", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Save", "save-list": "Save List", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 17401acf..fb3675bd 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -373,6 +373,7 @@ "road": "Carretera", "route": "{n, plural, one {}=1 {Ruta} other {Rutas}}", "route-point": "Punto de ruta", + "add-as-endpoint": "Add as endpoint", "russian": "Ruso", "save": "Guardar", "save-list": "Guardar Lista", diff --git a/web/src/lib/i18n/locales/eu.json b/web/src/lib/i18n/locales/eu.json index e95dcd0e..065448aa 100644 --- a/web/src/lib/i18n/locales/eu.json +++ b/web/src/lib/i18n/locales/eu.json @@ -373,6 +373,7 @@ "road": "Errepidea", "route": "{n, plural, one {}=1 {ibilbide} other {ibilbide}}", "route-point": "Ibilbideko puntua", + "add-as-endpoint": "Add as endpoint", "russian": "Errusiera", "save": "Gorde", "save-list": "Gorde zerrenda", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index 4bcb3ff1..1449efa3 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -373,6 +373,7 @@ "road": "Route", "route": "{n, plural, =1 {Itinéraire} other {Itinéraires}}", "route-point": "Étape", + "add-as-endpoint": "Add as endpoint", "russian": "Russe", "save": "Sauvegarder", "save-list": "Sauvegarder la liste", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 9c98d71b..24f37bb0 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -373,6 +373,7 @@ "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Mentés", "save-list": "Save List", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 8593d836..544140cb 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -373,6 +373,7 @@ "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Salva", "save-list": "Salta Lista", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 463e5d22..05bd37f0 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -373,6 +373,7 @@ "road": "Weg", "route": "{n, plural,=1 {Tocht} other {Tochten}}", "route-point": "Routepunt", + "add-as-endpoint": "Add as endpoint", "russian": "Russisch", "save": "Bewaren", "save-list": "Bewaar lijst", diff --git a/web/src/lib/i18n/locales/no.json b/web/src/lib/i18n/locales/no.json index 3423732a..73c498a3 100644 --- a/web/src/lib/i18n/locales/no.json +++ b/web/src/lib/i18n/locales/no.json @@ -373,6 +373,7 @@ "road": "Vei", "route": "{n, plural, =1 {Rute} other {Ruter}}", "route-point": "Rutepunkt", + "add-as-endpoint": "Add as endpoint", "russian": "Russisk", "save": "Lagre", "save-list": "Lagre liste", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 28193240..151143f5 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -373,6 +373,7 @@ "road": "Droga", "route": "{n, plural,=1 {Trasa} other {Trasy}}", "route-point": "Punkt trasy", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Zapisz", "save-list": "Zapisz listę", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index 76817687..7cfb3723 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -373,6 +373,7 @@ "road": "Road", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "Route Point", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "Guardar", "save-list": "Gravar lista", diff --git a/web/src/lib/i18n/locales/ru.json b/web/src/lib/i18n/locales/ru.json index f1621fa4..a8e37d1f 100644 --- a/web/src/lib/i18n/locales/ru.json +++ b/web/src/lib/i18n/locales/ru.json @@ -373,6 +373,7 @@ "road": "Шоссе", "route": "{n, plural, =1 {Маршрут} other {Маршрутов}}", "route-point": "Точка маршрута", + "add-as-endpoint": "Add as endpoint", "russian": "Русский", "save": "Сохранить", "save-list": "Сохранить список", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index 4d589fee..81e6f181 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -373,6 +373,7 @@ "road": "道路", "route": "{n, plural, =1 {Route} other {Routes}}", "route-point": "路线点", + "add-as-endpoint": "Add as endpoint", "russian": "Russian", "save": "保存", "save-list": "保存列表", diff --git a/web/src/lib/models/api/trail_schema.ts b/web/src/lib/models/api/trail_schema.ts index 30d52a8e..609b6380 100644 --- a/web/src/lib/models/api/trail_schema.ts +++ b/web/src/lib/models/api/trail_schema.ts @@ -45,7 +45,7 @@ const TrailUpdateSchema = z.object({ "photos-": z.string().optional(), "photos+": z.string().optional(), thumbnail: z.number().int().nonnegative().optional(), - like_count: z.number().int().min(0).optional().default(0), + like_count: z.number().int().min(0).optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), gpx: z.string().optional(), diff --git a/web/src/lib/stores/search_store.ts b/web/src/lib/stores/search_store.ts index a25a1971..c583422b 100644 --- a/web/src/lib/stores/search_store.ts +++ b/web/src/lib/stores/search_store.ts @@ -128,20 +128,46 @@ export async function searchLocations(q: string, limit?: number, f: (url: Reques })) } -async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch): Promise { +async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch, signal?: AbortSignal): Promise { const query = params.toString(); const url = query.length ? `/api/v1/geocoding/${path}?${query}` : `/api/v1/geocoding/${path}`; - return await f(url); + return await f(url, signal ? { signal } : undefined); } -export async function searchLocationReverse(lat: number, lon: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { +type ReverseGeocodingOptions = { + includeRoad?: boolean; + signal?: AbortSignal; +} + +export type ReverseLocationResult = { + label: string; + fullLabel: string; + country: string; +} + +export type FetchFunction = (url: RequestInfo | URL, config?: RequestInit) => Promise; + +export async function searchLocationReverse( + lat: number, + lon: number, + options: ReverseGeocodingOptions = {}, + f: FetchFunction = fetch, +) { + const location = await searchLocationReverseStructured(lat, lon, options, f); + return location?.fullLabel ?? ""; +} + +export async function searchLocationReverseStructured( + lat: number, + lon: number, + options: ReverseGeocodingOptions = {}, + f: FetchFunction = fetch, +): Promise { const params = new URLSearchParams({ - lat: String(lat), - lon: String(lon), - format: "geojson", - addressdetails: "1", - }); - const r = await fetchGeocoding("reverse", params, f); + lat: String(lat), + lon: String(lon), + }); + const r = await fetchGeocoding("reverse", params, f, options.signal); if (!r.ok) { const response = await r.json(); throw new APIError(r.status, response.message, response.detail) @@ -149,30 +175,52 @@ export async function searchLocationReverse(lat: number, lon: number, f: (url: R const response: NominatimResponse = await r.json(); if (response.features?.at(0)?.properties.address) { - return getLocationDescription(response.features[0].properties.address) + return getReverseLocationResult(response.features[0].properties.address, options); } - return "" + return null } -function getLocationDescription(address: Address) { - let description = "" +function getReverseLocationResult( + address: Address, + options: ReverseGeocodingOptions = {}, +): ReverseLocationResult { + const country = address.country ?? ""; + const label = getLocationDescription(address, { ...options, includeCountry: false }); + const fullLabel = getLocationDescription(address, options); - if (address.country) { - description += address.country; - } - if (address.state) { - description = `${address.state}, ` + description + return { + label: label || fullLabel, + fullLabel, + country, + }; +} + +function getLocationDescription( + address: Address, + options: ReverseGeocodingOptions & { includeCountry?: boolean } = {}, +) { + const parts = []; + + if (options.includeRoad && address.road) { + parts.push(address.road); } if (address.city) { - description = `${address.city}, ` + description + parts.push(address.city); } else if (address.town) { - description = `${address.town}, ` + description + parts.push(address.town); } else if (address.hamlet) { - description = `${address.hamlet}, ` + description + parts.push(address.hamlet); } else if (address.village) { - description = `${address.village}, ` + description + parts.push(address.village); } - return description; + if (address.state) { + parts.push(address.state); + } + if (options.includeCountry !== false && address.country) { + parts.push(address.country); + } + + return parts.join(", "); } export async function searchMulti(options: MultiSearchParams): Promise[]> { diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index ca4278dd..628236db 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -334,9 +334,11 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F } - let r = await fetch(`/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({ + const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({ expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags", - }), { + }); + + let r = await fetch(updateUrl, { method: 'POST', body: formData, }) @@ -360,6 +362,54 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F return model; } +export async function trails_update_metadata( + currentTrail: Trail, + patch: Pick, "name" | "description" | "tags"> & { + expand?: Pick, "tags">; + }, +) { + const tagIds: string[] | undefined = patch.expand?.tags + ? [] + : patch.tags; + + for (const tag of patch.expand?.tags ?? []) { + if (!tag.id) { + const model = await tags_create(tag); + tagIds!.push(model.id!); + } else { + tagIds!.push(tag.id); + } + } + + const searchParams = new URLSearchParams( + tagIds !== undefined ? { expand: "tags" } : {}, + ); + const query = searchParams.toString(); + const url = `/api/v1/trail/${currentTrail.id}${query ? `?${query}` : ""}`; + const payload = { + name: patch.name ?? currentTrail.name, + ...(patch.description !== undefined + ? { description: patch.description } + : {}), + ...(tagIds !== undefined ? { tags: tagIds } : {}), + }; + + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail); + } + + const model: Trail = await r.json(); + trail.set(model); + + return model; +} export async function trails_delete(trail: Trail) { const r = await fetch('/api/v1/trail/' + trail.id, { diff --git a/web/src/lib/stores/valhalla_store.svelte.ts b/web/src/lib/stores/valhalla_store.svelte.ts index ca1453a6..567b0b28 100644 --- a/web/src/lib/stores/valhalla_store.svelte.ts +++ b/web/src/lib/stores/valhalla_store.svelte.ts @@ -6,6 +6,7 @@ import Waypoint from "$lib/models/gpx/waypoint"; import { type RoutingOptions, type ValhallaAnchor, type ValhallaHeightResponse, type ValhallaRouteResponse } from "$lib/models/valhalla"; import { APIError } from "$lib/util/api_util"; import { decodePolyline, encodePolyline } from "$lib/util/polyline_util"; +import { renderValhallaAnchorMarker, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util"; import { applyChangeset, diff, revertChangeset, type Changeset } from 'json-diff-ts'; import type { LngLat } from "maplibre-gl"; import { _ } from "svelte-i18n"; @@ -16,8 +17,8 @@ const emtpyTrack = new Track({ trkseg: [] }) class ValhallaStore { route: GPX = $state(new GPX({ trk: [emtpyTrack] })); anchors: ValhallaAnchor[] = $state([]); - undoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]); - redoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]); + undoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]); + redoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]); } export const valhallaStore = new ValhallaStore(); @@ -179,14 +180,21 @@ export function reverseRoute() { if (!a.marker) { return; } - a.marker.getElement().textContent = "" + (i + 1); + renderValhallaAnchorMarker( + a.marker.getElement(), + i, + valhallaStore.anchors.length, + ); const anchorPopupHeading = a.marker .getPopup() ._content.getElementsByTagName("h5")[0]; if (anchorPopupHeading) { - anchorPopupHeading.textContent = - get(_)("route-point") + " #" + (i + 1); + anchorPopupHeading.textContent = valhallaAnchorTitle( + i, + valhallaStore.anchors.length, + get(_), + ); } }); } @@ -235,8 +243,8 @@ export async function splitSegment(index: number, pos: LngLat) { const firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint]; const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)]; - editRoute(index, firstSegmentPoints) - insertIntoRoute(secondSegmentPoints, index + 1) + await editRoute(index, firstSegmentPoints) + await insertIntoRoute(secondSegmentPoints, index + 1) } @@ -263,21 +271,30 @@ export function normalizeRouteTime() { export function undo() { const historyItem = valhallaStore.undoStack.pop() if (!historyItem) { - return + return undefined } valhallaStore.redoStack.push(historyItem) valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta); valhallaStore.route.features = valhallaStore.route.getTotals(); + return historyItem; +} + +export function revertRouteChange() { + const historyItem = valhallaStore.undoStack.pop(); + if (!historyItem) return; + valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta); + valhallaStore.route.features = valhallaStore.route.getTotals(); } export function redo() { const historyItem = valhallaStore.redoStack.pop() if (!historyItem) { - return + return undefined } valhallaStore.undoStack.push(historyItem) valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.delta); valhallaStore.route.features = valhallaStore.route.getTotals(); + return historyItem; } diff --git a/web/src/lib/util/format_util.ts b/web/src/lib/util/format_util.ts index 2947ae9e..cec9ab3f 100644 --- a/web/src/lib/util/format_util.ts +++ b/web/src/lib/util/format_util.ts @@ -13,7 +13,10 @@ export function formatTimeHHMM(seconds?: number) { return (h < 10 ? "0" : "") + h.toString() + "h " + (m < 10 ? "0" : "") + m.toString() + "m"; } -export function formatDistance(meters?: number) { +export function formatDistance( + meters?: number, + options: { compact?: boolean } = {}, +) { if (meters === undefined) { return "-"; } @@ -22,7 +25,14 @@ export function formatDistance(meters?: number) { if (unit == "metric") { if (meters >= 1000) { - return `${(meters / 1000).toFixed(2)} km` + const kilometers = meters / 1000; + if (options.compact && kilometers >= 100) { + return `${kilometers.toFixed(0)} km`; + } + if (options.compact && kilometers >= 10) { + return `${kilometers.toFixed(1)} km`; + } + return `${kilometers.toFixed(2)} km` } else { return meters % 1 == 0 ? `${meters} m` : `${Math.round(meters)} m`; } @@ -148,4 +158,4 @@ export function formatHTMLAsText(html?: string) { // Trim the result return text.trim(); -} \ No newline at end of file +} diff --git a/web/src/lib/util/maplibre_util.ts b/web/src/lib/util/maplibre_util.ts index df5b20ef..90b2c3de 100644 --- a/web/src/lib/util/maplibre_util.ts +++ b/web/src/lib/util/maplibre_util.ts @@ -76,13 +76,12 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker return marker; } -export function createAnchorMarker(lat: number, lon: number, index: number, +export function createAnchorMarker(lat: number, lon: number, onDeleteClick: () => void, onLoopClick: () => void, onDragStart: (event: Event) => void, onDragEnd: (event: Event) => void): FontawesomeMarker { const anchorElement = document.createElement("span") - anchorElement.className = "route-anchor cursor-pointer rounded-full w-6 h-6 border border-black text-center bg-primary text-white" - anchorElement.textContent = "" + index + anchorElement.className = "route-anchor cursor-pointer flex items-center justify-center rounded-full w-6 h-6 border border-black bg-primary text-white" const marker = new M.Marker( { draggable: true, @@ -96,7 +95,7 @@ export function createAnchorMarker(lat: number, lon: number, index: number, popupContent.className = "py-3 pl-3" const anchorH = document.createElement("h5") anchorH.classList.add("text-base", "font-medium"); - anchorH.textContent = get(_)("route-point") + " #" + index; + anchorH.textContent = get(_)("route-point"); const deleteButton = document.createElement("button"); deleteButton.className = "btn-secondary w-full mt-2 text-sm"; @@ -260,19 +259,33 @@ export function createPopupFromTrail(trail: Trail) { return popup; } -export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJSON.Position) { +export type OverpassPopupAction = { + label: string; + onClick: () => void; + disabled?: boolean; + helperText?: string; + icon?: string; +}; + +export function createOverpassPopup( + feature: GeoJSON.Feature, + coordinates: GeoJSON.Position, + action?: OverpassPopupAction, +) { const tags: Record = JSON.parse(feature.properties?.tags); const name = tags.name ?? get(_)(feature.properties?.query) ?? "?" const popupContainer = document.createElement("div"); - popupContainer.className = "p-4" + popupContainer.className = "p-4 relative" + + const indent = action ? "pl-12 " : ""; const popupHeading = document.createElement("h1"); - popupHeading.className = "font-medium text-lg" + popupHeading.className = indent + "font-medium text-lg" popupHeading.textContent = name; const coordinateSubtitle = document.createElement("p") - coordinateSubtitle.className = "text-gray-500" + coordinateSubtitle.className = indent + "text-gray-500" coordinateSubtitle.textContent = `${coordinates[0].toFixed(6)}, ${coordinates[1].toFixed(6)}` popupContainer.appendChild(popupHeading) @@ -295,6 +308,36 @@ export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJS popupContainer.appendChild(tagsGrid) + if (action) { + const actionButton = document.createElement("button"); + actionButton.type = "button"; + actionButton.className = + "flex h-9 w-9 items-center justify-center absolute top-4 left-4 rounded-full p-0 text-xl text-content hover:bg-secondary-hover disabled:opacity-40 disabled:cursor-not-allowed"; + actionButton.disabled = action.disabled ?? false; + actionButton.setAttribute("aria-label", action.label); + actionButton.setAttribute("title", action.label); + + const iconElement = document.createElement("i"); + iconElement.className = (action.icon ?? "fa fa-flag-checkered"); + iconElement.setAttribute("aria-hidden", "true"); + actionButton.appendChild(iconElement); + + actionButton.addEventListener("click", () => { + if (!actionButton.disabled) { + action.onClick(); + } + }); + + popupContainer.appendChild(actionButton); + + if (action.helperText) { + const helper = document.createElement("p"); + helper.className = "text-xs text-gray-500 mt-2"; + helper.textContent = action.helperText; + popupContainer.appendChild(helper); + } + } + return popupContainer; } @@ -336,4 +379,4 @@ export function calculateScaleFactor(map: M.Map) { const scaleFactor = realWorldMetersPer100Pixels / screenMetersPer100Pixels return scaleFactor -} \ No newline at end of file +} diff --git a/web/src/lib/util/valhalla_anchor_util.ts b/web/src/lib/util/valhalla_anchor_util.ts new file mode 100644 index 00000000..4d9437a9 --- /dev/null +++ b/web/src/lib/util/valhalla_anchor_util.ts @@ -0,0 +1,60 @@ +interface ValhallaAnchorDisplay { + icon: string; + number: number | null; + titleKey: "start" | "finish" | "route-point"; +} + +export function valhallaAnchorDisplay(index: number, total: number): ValhallaAnchorDisplay { + if (index === 0) { + return { + icon: "fa-bullseye", + number: null, + titleKey: "start", + }; + } + + if (index === total - 1) { + return { + icon: "fa-flag-checkered", + number: null, + titleKey: "finish", + }; + } + + return { + icon: "fa-location-dot", + number: index, + titleKey: "route-point", + }; +} + +export function valhallaAnchorTitle( + index: number, + total: number, + translate: (key: string) => string, +) { + const display = valhallaAnchorDisplay(index, total); + if (display.number === null) { + return translate(display.titleKey); + } + + return `${translate(display.titleKey)} #${display.number}`; +} + +export function renderValhallaAnchorMarker( + element: HTMLElement, + index: number, + total: number, +) { + const display = valhallaAnchorDisplay(index, total); + element.replaceChildren(); + + if (display.number !== null) { + element.textContent = `${display.number}`; + return; + } + + const icon = document.createElement("i"); + icon.classList.add("fa", display.icon); + element.appendChild(icon); +} diff --git a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts index c34aadf4..11f8b8a8 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/maplibre-layer-manager.ts @@ -2,7 +2,7 @@ import * as M from "maplibre-gl"; import { DebugLayer } from "./debug-layer"; import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from "./layers"; import { OverlayLayer } from "./overlay-layer"; -import { OverpassLayer } from "./overpass-layer"; +import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer"; @@ -11,9 +11,11 @@ export class LayerManager { state!: MapState; layers: Record = {}; private addedListeners: Set = new Set(); + private overpassActionFactory?: OverpassPopupActionFactory; - constructor(map: M.Map) { + constructor(map: M.Map, options?: { overpassActionFactory?: OverpassPopupActionFactory }) { this.map = map; + this.overpassActionFactory = options?.overpassActionFactory; const storedMapState = localStorage.getItem("map-state") if (storedMapState) { @@ -40,7 +42,7 @@ export class LayerManager { try { this.update(this.state, true); - const overpassLayer = new OverpassLayer(this.map) + const overpassLayer = new OverpassLayer(this.map, this.overpassActionFactory) const debugLayer = new DebugLayer() this.addLayer("overpass", overpassLayer) diff --git a/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts index d7e7e805..49704a80 100644 --- a/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts +++ b/web/src/lib/vendor/maplibre-layer-manager/overpass-layer.ts @@ -4,13 +4,18 @@ * License: MIT */ -import { createOverpassPopup } from "$lib/util/maplibre_util"; +import { createOverpassPopup, type OverpassPopupAction } from "$lib/util/maplibre_util"; import * as M from "maplibre-gl"; import { type LngLatBounds, type MapMouseEvent, type StyleSpecification } from "maplibre-gl"; import { pois, type BaseLayer, type MapState } from "./layers"; import type { OverpassResponse } from "./types"; import { env } from '$env/dynamic/public' +export type OverpassPopupActionFactory = ( + feature: GeoJSON.Feature, + coordinates: GeoJSON.Position, +) => OverpassPopupAction | null | undefined; + export class OverpassLayer implements BaseLayer { private overpassApiURL: string = "/api/v1/overpass/interpreter"; @@ -61,9 +66,11 @@ export class OverpassLayer implements BaseLayer { private popup: M.Popup; private map: M.Map; private currentPopupCoordinates: GeoJSON.Position | null = null + private popupActionFactory?: OverpassPopupActionFactory; - constructor(map: M.Map) { + constructor(map: M.Map, popupActionFactory?: OverpassPopupActionFactory) { this.map = map; + this.popupActionFactory = popupActionFactory; this.popup = new M.Popup() .setMaxWidth("420px") } @@ -71,7 +78,8 @@ export class OverpassLayer implements BaseLayer { private openPopup(e: MapMouseEvent) { const features = (e as any).features as GeoJSON.Feature[]; const point = features[0].geometry as GeoJSON.Point; - const content = createOverpassPopup(features[0], point.coordinates); + const action = this.popupActionFactory?.(features[0], point.coordinates); + const content = createOverpassPopup(features[0], point.coordinates, action ?? undefined); this.currentPopupCoordinates = point.coordinates; this.popup diff --git a/web/src/routes/api/v1/trail/upload/+server.ts b/web/src/routes/api/v1/trail/upload/+server.ts index 46702a1e..623aa16d 100644 --- a/web/src/routes/api/v1/trail/upload/+server.ts +++ b/web/src/routes/api/v1/trail/upload/+server.ts @@ -76,7 +76,12 @@ export async function PUT(event: RequestEvent) { if (trail.lat && trail.lon) { try { - const location = await searchLocationReverse(trail.lat, trail.lon, event.fetch) + const location = await searchLocationReverse( + trail.lat, + trail.lon, + {}, + event.fetch, + ) trail.location ??= location; } catch (e: any) { console.warn("Reverse geocoding failed during upload", e); diff --git a/web/src/routes/trail/edit/[id]/+page.svelte b/web/src/routes/trail/edit/[id]/+page.svelte index c2decf89..ab69fe26 100644 --- a/web/src/routes/trail/edit/[id]/+page.svelte +++ b/web/src/routes/trail/edit/[id]/+page.svelte @@ -9,6 +9,7 @@ import SummitLogModal from "$lib/components/summit_log/summit_log_modal.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import PhotoPicker from "$lib/components/trail/photo_picker.svelte"; + import TrailAnchorList from "$lib/components/trail/trail_anchor_list.svelte"; import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte"; import WaypointMergeModal, { type WaypointMergeOptions, @@ -23,6 +24,8 @@ import { SummitLog } from "$lib/models/summit_log"; import { Trail } from "$lib/models/trail"; import type { RoutingOptions, ValhallaAnchor } from "$lib/models/valhalla"; + import type { OverpassPopupActionFactory } from "$lib/vendor/maplibre-layer-manager/overpass-layer"; + import { type OverpassPopupAction } from "$lib/util/maplibre_util"; import { Waypoint } from "$lib/models/waypoint"; import { categories } from "$lib/stores/category_store"; import { @@ -52,6 +55,7 @@ splitSegment, undo, redo, + revertRouteChange, clearUndoRedoStack, } from "$lib/stores/valhalla_store.svelte.js"; import { waypoint } from "$lib/stores/waypoint_store"; @@ -91,6 +95,10 @@ createEditTrailMapPopup, FontawesomeMarker, } from "$lib/util/maplibre_util"; + import { + renderValhallaAnchorMarker, + valhallaAnchorTitle, + } from "$lib/util/valhalla_anchor_util"; import EXIF from "$lib/vendor/exif-js/exif.js"; import { validator } from "@felte/validator-zod"; import cryptoRandomString from "crypto-random-string"; @@ -117,6 +125,7 @@ let summitLogModal: SummitLogModal; let listSelectModal: ListSearchModal; let markTrailAsCompletedModal: ConfirmModal; + let replaceRouteModal: ConfirmModal; let loading = $state(false); @@ -127,6 +136,8 @@ let gpxFile: File | Blob | null = null; let drawingActive = $state(false); + let replacingRoute = $state(false); + let isNewTrail = $derived(page.params.id === "new"); function routeCalculationErrorText(error: unknown) { if (error instanceof Error && error.message) { @@ -142,6 +153,7 @@ | undefined = $state(); let searchDropdownItems: SearchItem[] = $state([]); + let selectedSearchLocation: SearchItem | null = $state(null); let cropStartMarker: FontawesomeMarker; let cropEndMarker: FontawesomeMarker; @@ -171,6 +183,8 @@ autoRouting: true, modeOfTransport: "pedestrian", }); + let routeAnchorListUpdating = $state(false); + let routeSegments = $state([]); let savedAtLeastOnce = $state(false); @@ -308,10 +322,42 @@ initRouteAnchors(gpx); updateTrailOnMap(); + + if (!isNewTrail) { + startDrawing(); + } } } }); + function fitCurrentRoute(initializedMap: M.Map) { + const bounds = valhallaStore.route.toGeoJSON().bbox; + if (!bounds) { + return; + } + + initializedMap.fitBounds(bounds as M.LngLatBoundsLike, { + animate: false, + padding: { + top: 16, + left: 16, + right: 16, + bottom: 16, + }, + }); + } + + function handleMapInit(initializedMap: M.Map) { + if (drawingActive) { + for (const anchor of valhallaStore.anchors) { + anchor.marker?.addTo(initializedMap); + } + } + if (!isNewTrail) { + fitCurrentRoute(initializedMap); + } + } + function openFileBrowser() { document.getElementById("fileInput")!.click(); } @@ -325,7 +371,10 @@ return; } - clearWaypoints(); + const replaceExistingRoute = replacingRoute && !isNewTrail; + if (!replaceExistingRoute) { + clearWaypoints(); + } clearAnchors(); clearUndoRedoStack(); clearRoute(); @@ -339,18 +388,29 @@ try { const prevId = $formData.id; const parseResult = await gpx2trail(gpxData, selectedFile.name); - setFields(parseResult.trail); + if (replaceExistingRoute) { + setFields("lat", parseResult.trail.lat); + setFields("lon", parseResult.trail.lon); + setFields("distance", parseResult.trail.distance); + setFields("duration", parseResult.trail.duration); + setFields("elevation_gain", parseResult.trail.elevation_gain); + setFields("elevation_loss", parseResult.trail.elevation_loss); + } else { + setFields(parseResult.trail); + } $formData.id = prevId ?? cryptoRandomString({ length: 15 }); $formData.expand!.gpx_data = gpxData; - setFields( - "category", - page.data.settings.category || $categories[0].id, - ); - setFields( - "public", - page.data.settings?.privacy?.trails === "public", - ); + if (!replaceExistingRoute) { + setFields( + "category", + page.data.settings.category || $categories[0].id, + ); + setFields( + "public", + page.data.settings?.privacy?.trails === "public", + ); + } // const log = new SummitLog(parseResult.trail.date as string, { // distance: $formData.distance, @@ -383,6 +443,13 @@ } setRoute(parseResult.gpx); initRouteAnchors(parseResult.gpx); + replacingRoute = false; + if (!isNewTrail) { + startDrawing(); + if (map) { + fitCurrentRoute(map); + } + } updateTrailOnMap(); } catch (e) { @@ -745,17 +812,23 @@ } function startDrawing() { + drawingActive = true; + routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])]; + if (!map) { return; } - drawingActive = true; - if (!valhallaStore.route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.length) { - } + for (const anchor of valhallaStore.anchors) { anchor.marker?.addTo(map); } } + function startReplacementDrawing() { + replacingRoute = false; + startDrawing(); + } + async function stopDrawing() { drawingActive = false; for (const anchor of valhallaStore.anchors) { @@ -816,8 +889,13 @@ async function addAnchorAndRecalculate(lat: number, lon: number) { const previousAnchor = valhallaStore.anchors[valhallaStore.anchors.length - 1]; + if (!previousAnchor) { + addAnchor(lat, lon, 0); + return; + } + const anchor = addAnchor(lat, lon, valhallaStore.anchors.length); - const markerText = startAnchorLoading(anchor); + startAnchorLoading(anchor); try { const routeWaypoints = await calculateRouteBetween( previousAnchor.lat, @@ -826,9 +904,9 @@ lon, routingOptions, ); - insertIntoRoute(routeWaypoints); - updateTrailWithRouteData(); + await insertIntoRoute(routeWaypoints); normalizeRouteTime(); + updateTrailWithRouteData(); } catch (e) { console.error(e); show_toast({ @@ -837,7 +915,7 @@ type: "error", }); } finally { - stopAnchorLoading(anchor, markerText); + stopAnchorLoading(anchor); } } @@ -855,7 +933,6 @@ const marker = createAnchorMarker( lat, lon, - index + 1, () => { removeAnchor( valhallaStore.anchors.findIndex((a) => a.id == anchor.id), @@ -896,6 +973,7 @@ } anchor.marker = marker; valhallaStore.anchors.splice(index, 0, anchor); + refreshAnchorLabels(Math.max(0, index - 1)); return anchor; } @@ -903,18 +981,15 @@ function startAnchorLoading(anchor: ValhallaAnchor) { const markerIcon = anchor.marker?.getElement(); if (!markerIcon) { - return null; + return; } markerIcon.classList.add("spinner", "spinner-light", "spinner-small"); - const savedMarkerNumber = markerIcon.textContent; - markerIcon.textContent = ""; - - return savedMarkerNumber; + markerIcon.replaceChildren(); } - function stopAnchorLoading(anchor: ValhallaAnchor, index: string | null) { + function stopAnchorLoading(anchor: ValhallaAnchor) { const markerIcon = anchor.marker?.getElement(); - if (!markerIcon || !index) { + if (!markerIcon) { return; } markerIcon.classList.remove( @@ -922,7 +997,47 @@ "spinner-light", "spinner-small", ); - markerIcon.textContent = index; + refreshAnchorLabel(valhallaStore.anchors.findIndex((a) => a.id === anchor.id)); + } + + function refreshAnchorLabel(index: number) { + if (index < 0) { + return; + } + + const anchor = valhallaStore.anchors[index]; + const markerIcon = anchor.marker?.getElement(); + if (markerIcon) { + renderValhallaAnchorMarker( + markerIcon, + index, + valhallaStore.anchors.length, + ); + anchor + .marker!.getPopup() + ._content.getElementsByTagName("h5")[0].textContent = + valhallaAnchorTitle(index, valhallaStore.anchors.length, $_); + } + } + + function refreshAnchorLabels(startIndex: number = 0) { + for (let i = startIndex; i < valhallaStore.anchors.length; i++) { + refreshAnchorLabel(i); + } + } + + function highlightAnchorMarker(index: number | null) { + for (const anchor of valhallaStore.anchors) { + anchor.marker?.getElement().classList.remove("anchor-list-highlight"); + } + + if (index === null) { + return; + } + + valhallaStore.anchors[index]?.marker + ?.getElement() + .classList.add("anchor-list-highlight"); } async function removeAnchor(anchorIndex: number) { @@ -931,20 +1046,7 @@ } valhallaStore.anchors[anchorIndex]?.marker?.remove(); valhallaStore.anchors.splice(anchorIndex, 1); - for (let i = anchorIndex; i < valhallaStore.anchors.length; i++) { - const anchor = valhallaStore.anchors[i]; - const markerIcon = anchor.marker?.getElement(); - if (markerIcon) { - const markerText = markerIcon.textContent ?? "0"; - const markerIndex = parseInt(markerText); - const newIndex = markerIndex - 1; - markerIcon.textContent = newIndex + ""; - anchor - .marker!.getPopup() - ._content.getElementsByTagName("h5")[0].textContent = - $_("route-point") + " #" + newIndex; - } - } + refreshAnchorLabels(anchorIndex); if (anchorIndex == 0) { deleteFromRoute(anchorIndex); if ($formData.expand?.gpx_data) { @@ -955,24 +1057,141 @@ updateTrailWithRouteData(); } else { deleteFromRoute(anchorIndex - 1); - await recalculateRoute(anchorIndex); + await recalculateRoute(anchorIndex, [anchorIndex - 1, anchorIndex]); } } - async function recalculateRoute(anchorIndex: number) { - const markerText = startAnchorLoading( - valhallaStore.anchors[anchorIndex], - ); + async function recalculateRouteFromAnchors(fromIndex: number, toIndex: number) { + const anchors = valhallaStore.anchors; + const N = anchors.length; + if (N < 2) { + setRoute(new GPX({ trk: [new Track({ trkseg: [] })] }), true); + updateTrailWithRouteData(); + return; + } + + // Segments not touching the moved anchor are reused (shifted by ±1); only the 2–3 boundary segments are recalculated. + const oldSegments = valhallaStore.route.trk?.at(0)?.trkseg ?? []; + const newSegments: (TrackSegment | null)[] = new Array(N - 1).fill(null); + const toRecalc: number[] = []; + + if (fromIndex < toIndex) { + for (let i = 0; i < fromIndex - 1; i++) newSegments[i] = oldSegments[i] ?? null; + for (let i = fromIndex; i <= toIndex - 2; i++) newSegments[i] = oldSegments[i + 1] ?? null; + for (let i = toIndex + 1; i < N - 1; i++) newSegments[i] = oldSegments[i] ?? null; + if (fromIndex > 0) toRecalc.push(fromIndex - 1); + toRecalc.push(toIndex - 1); + if (toIndex < N - 1) toRecalc.push(toIndex); + } else { + for (let i = 0; i < toIndex - 1; i++) newSegments[i] = oldSegments[i] ?? null; + for (let i = toIndex + 1; i <= fromIndex - 1; i++) newSegments[i] = oldSegments[i - 1] ?? null; + for (let i = fromIndex + 1; i < N - 1; i++) newSegments[i] = oldSegments[i] ?? null; + if (toIndex > 0) toRecalc.push(toIndex - 1); + toRecalc.push(toIndex); + if (fromIndex < N - 1) toRecalc.push(fromIndex); + } + + const loadingAnchorIndexes = [...new Set(toRecalc.flatMap((i) => [i, i + 1]))]; + for (const index of loadingAnchorIndexes) { + startAnchorLoading(anchors[index]); + } + try { + const recalcResults = await Promise.all( + toRecalc.map((i) => + calculateRouteBetween( + anchors[i].lat, + anchors[i].lon, + anchors[i + 1].lat, + anchors[i + 1].lon, + routingOptions, + ).then((pts) => ({ i, segment: new TrackSegment({ trkpt: pts }) })), + ), + ); + + for (const { i, segment } of recalcResults) { + newSegments[i] = segment; + } + + setRoute( + new GPX({ trk: [new Track({ trkseg: newSegments.filter((s): s is TrackSegment => s !== null) })] }), + true, + ); + normalizeRouteTime(); + updateTrailWithRouteData(); + } finally { + for (const index of loadingAnchorIndexes) { + stopAnchorLoading(anchors[index]); + } + } + } + + async function moveAnchor(fromIndex: number, toIndex: number) { + if ( + routeAnchorListUpdating || + !drawingActive || + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= valhallaStore.anchors.length || + toIndex >= valhallaStore.anchors.length + ) { + return; + } + + const previousAnchors = [...valhallaStore.anchors]; + const previousUndoStackLength = valhallaStore.undoStack.length; + const [anchor] = valhallaStore.anchors.splice(fromIndex, 1); + valhallaStore.anchors.splice(toIndex, 0, anchor); + refreshAnchorLabels(Math.min(fromIndex, toIndex)); + + routeAnchorListUpdating = true; + try { + await recalculateRouteFromAnchors(fromIndex, toIndex); + const lastEntry = valhallaStore.undoStack.at(-1); + if (lastEntry && valhallaStore.undoStack.length > previousUndoStackLength) { + lastEntry.anchorsBefore = previousAnchors; + lastEntry.anchorsAfter = [...valhallaStore.anchors]; + } + } catch (e) { + while (valhallaStore.undoStack.length > previousUndoStackLength) { + revertRouteChange(); + } + routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])]; + valhallaStore.anchors = previousAnchors; + refreshAnchorLabels(Math.min(fromIndex, toIndex)); + console.error(e); + show_toast({ + text: routeCalculationErrorText(e), + icon: "close", + type: "error", + }); + } finally { + routeAnchorListUpdating = false; + } + } + + async function recalculateRoute(anchorIndex: number, loadingAnchorIndexes = [anchorIndex]) { const anchor = valhallaStore.anchors[anchorIndex]; if (!anchor) { return; } + const anchors = valhallaStore.anchors; + const loadingAnchors = [ + ...new Set( + loadingAnchorIndexes + .map((index) => anchors[index]) + .filter((anchor): anchor is ValhallaAnchor => Boolean(anchor)), + ), + ]; + for (const loadingAnchor of loadingAnchors) { + startAnchorLoading(loadingAnchor); + } let nextRouteSegment; let previousRouteSegment; try { - if (anchorIndex < valhallaStore.anchors.length - 1) { - const nextAnchor = valhallaStore.anchors[anchorIndex + 1]; + if (anchorIndex < anchors.length - 1) { + const nextAnchor = anchors[anchorIndex + 1]; nextRouteSegment = await calculateRouteBetween( anchor.lat, @@ -983,7 +1202,7 @@ ); } if (anchorIndex > 0) { - const previousAnchor = valhallaStore.anchors[anchorIndex - 1]; + const previousAnchor = anchors[anchorIndex - 1]; previousRouteSegment = await calculateRouteBetween( previousAnchor.lat, previousAnchor.lon, @@ -994,13 +1213,13 @@ } if (nextRouteSegment) { - editRoute(anchorIndex, nextRouteSegment); + await editRoute(anchorIndex, nextRouteSegment); } if (previousRouteSegment) { - editRoute(anchorIndex - 1, previousRouteSegment); + await editRoute(anchorIndex - 1, previousRouteSegment); } - updateTrailWithRouteData(); normalizeRouteTime(); + updateTrailWithRouteData(); } catch (e) { console.error(e); show_toast({ @@ -1009,7 +1228,9 @@ type: "error", }); } finally { - stopAnchorLoading(valhallaStore.anchors[anchorIndex], markerText); + for (const loadingAnchor of loadingAnchors) { + stopAnchorLoading(loadingAnchor); + } } } @@ -1025,8 +1246,7 @@ data.event.lngLat.lng, data.segment + 1, ); - const markerText = startAnchorLoading(anchor); - updateFollowingAnchors(data.segment); + startAnchorLoading(anchor); const previousAnchor = valhallaStore.anchors[data.segment]; const nextAnchor = valhallaStore.anchors[data.segment + 2]; @@ -1047,8 +1267,8 @@ routingOptions, ); - editRoute(data.segment, previousRouteSegment); - insertIntoRoute(nextRouteSegment, data.segment + 1); + await editRoute(data.segment, previousRouteSegment); + await insertIntoRoute(nextRouteSegment, data.segment + 1); normalizeRouteTime(); updateTrailWithRouteData(); } catch (e) { @@ -1059,24 +1279,7 @@ type: "error", }); } finally { - stopAnchorLoading(anchor, markerText); - } - } - - function updateFollowingAnchors(segment: number) { - for (let i = segment + 2; i < valhallaStore.anchors.length; i++) { - const anchor = valhallaStore.anchors[i]; - const markerIcon = anchor.marker?.getElement(); - if (markerIcon) { - const markerText = markerIcon.textContent ?? "0"; - const markerIndex = parseInt(markerText); - const newIndex = markerIndex + 1; - markerIcon.textContent = newIndex + ""; - anchor - .marker!.getPopup() - ._content.getElementsByTagName("h5")[0].textContent = - $_("route-point") + " #" + newIndex; - } + stopAnchorLoading(anchor); } } @@ -1090,8 +1293,7 @@ data.segment + 1, ); - splitSegment(data.segment, data.event.lngLat); - updateFollowingAnchors(data.segment); + await splitSegment(data.segment, data.event.lngLat); updateTrailWithRouteData(); } @@ -1107,6 +1309,22 @@ updateTrailWithRouteData(); } + function requestReplaceRoute() { + replaceRouteModal.openModal(); + } + + function replaceRoute() { + resetRoute(); + clearUndoRedoStack(); + gpxFile = null; + overwriteGPX = true; + replacingRoute = true; + drawingActive = false; + routeSegments = []; + $formData.expand!.gpx_data = undefined; + updateTrailWithRouteData(); + } + async function recalculateElevationData() { await recalculateHeight(); @@ -1228,6 +1446,7 @@ function updateTrailWithRouteData() { overwriteGPX = true; + routeSegments = [...(valhallaStore.route.trk?.at(0)?.trkseg ?? [])]; updateTotals(valhallaStore.route); if (!$formData.id) { @@ -1259,6 +1478,42 @@ zoom: 13, animate: false, }); + selectedSearchLocation = item; + } + + function clearSelectedSearchLocation() { + selectedSearchLocation = null; + } + + const buildPoiAnchorAction: OverpassPopupActionFactory = ( + _feature, + coordinates, + ) => { + const [lon, lat] = coordinates; + if (typeof lat !== "number" || typeof lon !== "number") { + return null; + } + if (!drawingActive) { + return null; + } + return { + label: $_("add-as-endpoint"), + icon: "fa fa-flag-checkered", + onClick: () => addAnchorAndRecalculate(lat, lon), + } satisfies OverpassPopupAction; + }; + + async function addSelectedLocationAsEndpoint() { + if (!selectedSearchLocation) { + return; + } + const { lat, lon } = selectedSearchLocation.value; + if (valhallaStore.anchors.length === 0) { + addAnchor(lat, lon, 0); + } else { + await addAnchorAndRecalculate(lat, lon); + } + selectedSearchLocation = null; } async function searchCities(q: string) { @@ -1460,16 +1715,26 @@ } function undoRouteEdit() { - undo(); - clearAnchors(); - initRouteAnchors(valhallaStore.route, true); + const entry = undo(); + if (entry?.anchorsBefore) { + valhallaStore.anchors = entry.anchorsBefore; + refreshAnchorLabels(); + } else { + clearAnchors(); + initRouteAnchors(valhallaStore.route, true); + } updateTrailWithRouteData(); } function redoRouteEdit() { - redo(); - clearAnchors(); - initRouteAnchors(valhallaStore.route, true); + const entry = redo(); + if (entry?.anchorsAfter) { + valhallaStore.anchors = entry.anchorsAfter; + refreshAnchorLabels(); + } else { + clearAnchors(); + initRouteAnchors(valhallaStore.route, true); + } updateTrailWithRouteData(); } @@ -1498,41 +1763,91 @@ placeholder="{$_('search-places')}..." items={searchDropdownItems} > + {#if selectedSearchLocation && drawingActive} +
+
+ +
+

+ {selectedSearchLocation.text} +

+ {#if selectedSearchLocation.description} +

+ {selectedSearchLocation.description} +

+ {/if} +
+ +
+
+ {/if}
-

{$_("pick-a-trail")}

- + {#if isNewTrail || replacingRoute} +

{$_("pick-a-trail")}

+ + {/if} + {#if drawingActive && valhallaStore.anchors.length} + + {/if} + {#if !drawingActive && (isNewTrail || replacingRoute)}

{$_("or")}
- {$formData.expand?.gpx_data + ? $_("upload-new-file") + : $_("upload-file")} + {/if} handleMapClick(target)} onsegmentclick={(data) => handleSegmentClick(data)} onsegmentdragend={(data) => handleSegmentDragEnd(data)} mapOptions={{ preserveDrawingBuffer: true }} + {buildPoiAnchorAction} >
@@ -1853,6 +2172,15 @@ bind:this={markTrailAsCompletedModal} onconfirm={markTrailAsCompleted} > +