diff --git a/web/package-lock.json b/web/package-lock.json index c22f33bd..7dcce3e9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "wanderer", - "version": "0.13.2", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wanderer", - "version": "0.13.2", + "version": "0.14.0", "dependencies": { "@felte/validator-zod": "^1.0.18", "@fortawesome/fontawesome-free": "^6.5.1", @@ -32,6 +32,7 @@ "jszip": "^3.10.1", "maplibre-gl": "^4.7.1", "meilisearch": "^0.37.0", + "ngeohash": "^0.6.3", "nouislider": "^15.7.1", "pdfkit": "^0.15.0", "photoswipe": "^5.4.3", @@ -4372,6 +4373,15 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "license": "ISC" }, + "node_modules/ngeohash": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz", + "integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==", + "license": "MIT", + "engines": { + "node": ">=v0.2.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/web/package.json b/web/package.json index a4868da3..1c92a05b 100644 --- a/web/package.json +++ b/web/package.json @@ -54,6 +54,7 @@ "jszip": "^3.10.1", "maplibre-gl": "^4.7.1", "meilisearch": "^0.37.0", + "ngeohash": "^0.6.3", "nouislider": "^15.7.1", "pdfkit": "^0.15.0", "photoswipe": "^5.4.3", diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index d12206fe..31e36987 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -67,7 +67,7 @@ const auth: Handle = async ({ event, resolve }) => { try { // get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any) if (pb.authStore.isValid) { - await pb.collection('users').authRefresh() + await pb.collection('users').authRefresh({requestKey: null}) } } catch (_) { // clear the auth store on failed refresh @@ -78,7 +78,7 @@ const auth: Handle = async ({ event, resolve }) => { let settings: Settings | undefined; if (pb.authStore.model) { meiliApiKey = pb.authStore.model.token - settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.model.id}"`) + settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.model.id}"`, {requestKey: null}) } else { const r = await event.fetch(pb.buildUrl("/public/search/token")); const response = await r.json(); @@ -111,4 +111,12 @@ const auth: Handle = async ({ event, resolve }) => { return response } -export const handle = sequence(csrf(['/api/v1']), auth) \ No newline at end of file +const removeLinkFromHeaders: Handle = + async ({ event, resolve }) => { + const response = await resolve(event); + response.headers.delete('link'); + return response; + } + + +export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders) \ No newline at end of file diff --git a/web/src/lib/components/settings/upload_dialog.svelte b/web/src/lib/components/settings/upload_dialog.svelte new file mode 100644 index 00000000..dd7aefb6 --- /dev/null +++ b/web/src/lib/components/settings/upload_dialog.svelte @@ -0,0 +1,195 @@ + + +{#if visibleUploads.length} + +{/if} diff --git a/web/src/lib/components/summit_log/summit_log_modal.svelte b/web/src/lib/components/summit_log/summit_log_modal.svelte index a211e778..99952093 100644 --- a/web/src/lib/components/summit_log/summit_log_modal.svelte +++ b/web/src/lib/components/summit_log/summit_log_modal.svelte @@ -93,7 +93,7 @@ throw gpxObject; } - const totals = gpxObject.getTotals(); + const totals = gpxObject.features; $data.duration = totals.duration / 1000; $data.elevation_gain = totals.elevationGain; diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index 37596b9d..86a8265e 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -70,7 +70,7 @@ trail: trail.id ?? "", }); - let commentsLoading: boolean = $state(activeTab == 4); + let commentsLoading: boolean = $state(activeTab == 2); let commentCreateLoading: boolean = $state(false); let commentDeleteLoading: boolean = false; @@ -150,7 +150,7 @@ : emptyStateTrailDark, ); $effect(() => { - if (browser && activeTab == 4) { + if (browser && activeTab == 2) { fetchComments(); } }); diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index 3fded2de..e59b2687 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -74,6 +74,7 @@ "download": "Herunterladen", "draw-a-route": "Route zeichnen", "driving": "Auto", + "duplicate": "", "duration": "Dauer", "dutch": "Niederländisch", "easy": "Einfach", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index fb45eb95..a6a1b8c6 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -74,6 +74,7 @@ "download": "Download", "draw-a-route": "Draw a route", "driving": "Driving", + "duplicate": "Duplicate", "duration": "Duration", "dutch": "Dutch", "easy": "Easy", diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json index 01407b06..19f909b1 100644 --- a/web/src/lib/i18n/locales/es.json +++ b/web/src/lib/i18n/locales/es.json @@ -74,6 +74,7 @@ "download": "Descarga", "draw-a-route": "Dibujar una ruta", "driving": "Conducir", + "duplicate": "", "duration": "Duración", "dutch": "Holandés", "easy": "Fácil", diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json index b663da32..c21a011d 100644 --- a/web/src/lib/i18n/locales/fr.json +++ b/web/src/lib/i18n/locales/fr.json @@ -74,6 +74,7 @@ "download": "Download", "draw-a-route": "Tracer un itinéraire", "driving": "Conduire", + "duplicate": "", "duration": "Durée", "dutch": "Néerlandais", "easy": "Facile", diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json index 7b627b99..0a4cfe2a 100644 --- a/web/src/lib/i18n/locales/hu.json +++ b/web/src/lib/i18n/locales/hu.json @@ -74,6 +74,7 @@ "download": "Download", "draw-a-route": "Draw a route", "driving": "Driving", + "duplicate": "", "duration": "Duration", "dutch": "Holland", "easy": "Könnyű", diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json index 3bab4931..ff6e1154 100644 --- a/web/src/lib/i18n/locales/it.json +++ b/web/src/lib/i18n/locales/it.json @@ -74,6 +74,7 @@ "download": "Scarica", "draw-a-route": "Disegna un percorso", "driving": "Guida", + "duplicate": "", "duration": "Durata", "dutch": "Olandese", "easy": "Facile", diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json index 9c7b3af6..59ad8446 100644 --- a/web/src/lib/i18n/locales/nl.json +++ b/web/src/lib/i18n/locales/nl.json @@ -74,6 +74,7 @@ "download": "Download", "draw-a-route": "Draw a route", "driving": "Driving", + "duplicate": "", "duration": "Duration", "dutch": "Nederlands", "easy": "Makkelijk", diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json index 3dd2da2b..7687a2c7 100644 --- a/web/src/lib/i18n/locales/pl.json +++ b/web/src/lib/i18n/locales/pl.json @@ -74,6 +74,7 @@ "download": "Pobierz", "draw-a-route": "Narysuj trasę", "driving": "Samochód", + "duplicate": "", "duration": "Czas trwania", "dutch": "Niderlandzki", "easy": "Łatwy", diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json index c8cac202..2ae966d1 100644 --- a/web/src/lib/i18n/locales/pt.json +++ b/web/src/lib/i18n/locales/pt.json @@ -74,6 +74,7 @@ "download": "Download", "draw-a-route": "Desenhar uma rota", "driving": "Conduzir", + "duplicate": "", "duration": "Duração", "dutch": "Holandês", "easy": "Fácil", diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json index f16f88f2..6fa9201b 100644 --- a/web/src/lib/i18n/locales/zh.json +++ b/web/src/lib/i18n/locales/zh.json @@ -74,6 +74,7 @@ "download": "Download", "draw-a-route": "绘制路线", "driving": "驾驶", + "duplicate": "", "duration": "持续时间", "dutch": "荷兰语", "easy": "简单", diff --git a/web/src/lib/models/gpx/gpx.ts b/web/src/lib/models/gpx/gpx.ts index d318768b..eb7e7076 100644 --- a/web/src/lib/models/gpx/gpx.ts +++ b/web/src/lib/models/gpx/gpx.ts @@ -4,6 +4,8 @@ import Route from './route'; import Track from './track'; import { allDatesToISOString, haversineDistance, removeEmpty } from './utils'; import Waypoint from './waypoint'; +//@ts-ignore +import geohash from "ngeohash" const defaultAttributes = { version: '1.1', @@ -14,6 +16,17 @@ const defaultAttributes = { 'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd' } + +type GPXFeature = { + centroid: { lat: number; lon: number }; + boundingBox: { minLat: number; maxLat: number; minLon: number; maxLon: number }; + distance: number; + elevationGain?: number; + elevationLoss?: number; + duration: number; + hash: string; // MinHash or Geohash for track shape +}; + export default class GPX { $: { version: string; @@ -27,6 +40,7 @@ export default class GPX { wpt?: Waypoint[]; rte?: Route[]; trk?: Track[]; + features: GPXFeature constructor(object: { $?: { @@ -69,18 +83,26 @@ export default class GPX { this.trk = object.trk.map(trk => new Track(trk)) } + this.features = this.getTotals(); + removeEmpty(this); } - getTotals() { + getTotals(): GPXFeature { let totalElevationGain = 0; let totalElevationLoss = 0; let totalDuration = 0; let totalDistance = 0; + let totalLat = 0 + let totalLon = 0 + let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity; + + const allPoints: Waypoint[] = [] for (const track of this.trk ?? []) { for (const segment of track.trkseg ?? []) { const points = segment.trkpt ?? []; + allPoints.push(...points); if (points.length >= 2) { const startTime = points[0].time; @@ -110,12 +132,51 @@ export default class GPX { point.$.lat ?? 0, point.$.lon ?? 0, ); + + totalLat += point.$.lat ?? 0; + totalLon += point.$.lon ?? 0; + + minLat = Math.min(minLat, point.$.lat ?? Infinity); + maxLat = Math.max(maxLat, point.$.lat ?? -Infinity); + minLon = Math.min(minLon, point.$.lon ?? Infinity); + maxLon = Math.max(maxLon, point.$.lon ?? -Infinity); totalDistance += distance; } } } - return { distance: totalDistance, elevationGain: totalElevationGain, elevationLoss: totalElevationLoss, duration: totalDuration } + const boundingBox = { minLat, maxLat, minLon, maxLon }; + const centroid = { lat: totalLat / allPoints.length, lon: totalLon / allPoints.length }; + + return { + centroid, + boundingBox, + distance: totalDistance, + elevationGain: totalElevationGain, + elevationLoss: totalElevationLoss, + duration: totalDuration, + hash: this.generateMinHash(allPoints) + } + } + + private generateMinHash(points: Waypoint[]): string { + const hashes = points.map(pt => geohash.encode(pt.$.lat, pt.$.lon)); + return hashes.sort().join('').slice(0, 10); + } + + isSimilar(other: GPX, distanceThreshold = 50): boolean { + const f1 = this.features; + const f2 = other.features; + const centroidDistance = haversineDistance(f1.centroid.lat, f1.centroid.lon, f2.centroid.lat, f2.centroid.lon); + const boundingBoxOverlap = + f1.boundingBox.minLat <= f2.boundingBox.maxLat && f1.boundingBox.maxLat >= f2.boundingBox.minLat && + f1.boundingBox.minLon <= f2.boundingBox.maxLon && f1.boundingBox.maxLon >= f2.boundingBox.minLon; + + const lengthDifference = Math.abs(f1.distance - f2.distance); + const hashSimilarity = f1.hash === f2.hash; + + return centroidDistance < distanceThreshold || boundingBoxOverlap || (lengthDifference < 100 && hashSimilarity); + } static parse(gpxString: string): Promise { diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index a7796224..d53fa30b 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -401,23 +401,40 @@ export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config } -export async function trails_upload(file: File, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch): Promise { - const fd = new FormData() +export async function trails_upload(file: File, ignoreDuplicates: boolean = false, onProgress?: (progress: number) => void) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const fd = new FormData(); + fd.append("name", file.name); + fd.append("file", file); + fd.append("ignoreDuplicates", ignoreDuplicates ? "true" : "false") - fd.append("name", file.name), - fd.append("file", file) + xhr.open("PUT", "/api/v1/trail/upload", true); - const r = await f('/api/v1/trail/upload', { - method: 'PUT', - body: fd - }) - if (!r.ok) { - const response = await r.json(); - throw new APIError(r.status, response.message, response.detail) - } + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 100; + onProgress?.(percentComplete) + } + }; - return await r.json(); + xhr.onload = async () => { + const responseText = xhr.responseText; + const response = responseText ? JSON.parse(responseText) : null; + if (xhr.status >= 200 && xhr.status < 300) { + resolve(response); + } else { + reject(new APIError(xhr.status, response?.message || "Upload failed", response)); + } + }; + + xhr.onerror = () => { + reject(new APIError(xhr.status, xhr.statusText)); + }; + + xhr.send(fd); + }); } export async function fetchGPX(trail: { gpx?: string } & Record, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { diff --git a/web/src/lib/stores/upload_store.svelte.ts b/web/src/lib/stores/upload_store.svelte.ts new file mode 100644 index 00000000..b35b690d --- /dev/null +++ b/web/src/lib/stores/upload_store.svelte.ts @@ -0,0 +1,57 @@ +import { APIError } from "$lib/util/api_util"; + +export type Upload = { + file: File; + status: "enqueued" | "uploading" | "cancelled" | "success" | "error" | "duplicate"; + error?: string; + duplicate?: { id: string, name: string }; + progress: number; + function: (f: File, ignoreDuplicates?: boolean, onProgress?: (p: number) => void) => Promise +}; + +class UploadStore { + enqueuedUploads: Upload[] = $state([]); + completedUploads: Upload[] = $state([]); + uploading: boolean = $state(false); +} + +export const uploadStore = new UploadStore(); + + +export async function processUploadQueue(batchSize: number = 3, ignoreDuplicates: boolean = false) { + if (uploadStore.uploading) { + return; + } + uploadStore.uploading = true; + + while (uploadStore.enqueuedUploads.length > 0) { + const batch = uploadStore.enqueuedUploads.slice(0, batchSize); + const uploadPromises: Promise[] = []; + for (const b of batch) { + b.status = "uploading"; + uploadPromises.push( + b.function(b.file, ignoreDuplicates, (p: number) => { + b.progress = p + }) + ); + } + const results = await Promise.all( + uploadPromises.map((p) => p.catch((e) => e)), + ); + results.forEach((r, i) => { + const u = batch[i]; + if (r instanceof APIError && r.message == "Duplicate trail") { + u.status = "duplicate" + u.duplicate = { id: r.detail.id, name: r.detail.name }; + } else if (r instanceof APIError) { + u.status = "error" + u.error = r.message + } else { + u.status = "success" + } + uploadStore.completedUploads.push(u); + }); + uploadStore.enqueuedUploads.splice(0, batchSize) + } + uploadStore.uploading = false; +} \ No newline at end of file diff --git a/web/src/lib/util/api_util.ts b/web/src/lib/util/api_util.ts index d34eae61..7ca5a526 100644 --- a/web/src/lib/util/api_util.ts +++ b/web/src/lib/util/api_util.ts @@ -80,7 +80,7 @@ export async function create(event: RequestEvent, schema: ZodSchema, collecti const data = await event.request.json(); const safeData = schema.parse(data); - const r = await pb.collection(Collection[collection]).create(safeData, safeSearchParams) + const r = await pb.collection(Collection[collection]).create(safeData, {...safeSearchParams, requestKey: null}) return r } @@ -124,7 +124,7 @@ export function handleError(e: any) { if (e instanceof ZodError) { return error(400, { message: "invalid_params", detail: e.issues } as any) } else if (e instanceof ClientResponseError && e.status > 0) { - return error(e.status as NumericRange<400, 599>, { message: e.message, detail: e.originalError.data } as any) + return error(e.status as NumericRange<400, 599>, {...e.response, message: e.message, detail: e.originalError.data } as any) } else if (e instanceof SyntaxError) { return error(400, "invalid_json") } else { diff --git a/web/src/lib/util/gpx_util.ts b/web/src/lib/util/gpx_util.ts index 9ccd03e1..54be39b8 100644 --- a/web/src/lib/util/gpx_util.ts +++ b/web/src/lib/util/gpx_util.ts @@ -11,7 +11,7 @@ import Track from "$lib/models/gpx/track"; import TrackSegment from "$lib/models/gpx/track-segment"; import GPXWaypoint from "$lib/models/gpx/waypoint"; import EasyFit from "$lib/vendor/easy-fit/easy-fit"; -import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson'; +import type { Feature, FeatureCollection, GeoJSON, GeoJsonProperties, Position } from 'geojson'; import * as xmldom from 'xmldom'; import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util"; @@ -38,7 +38,7 @@ export async function gpx2trail(gpxString: string, fallbackName?: string) { trail.expand!.waypoints.push(wp); } - const totals = gpx.getTotals() + const totals = gpx.features const trackPoints = gpx.trk?.at(0)?.trkseg?.at(0)?.trkpt const routePoints = gpx.rte?.at(0)?.rtept; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 5b710102..76db674f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -5,6 +5,8 @@ import Toast from "$lib/components/base/toast.svelte"; import Footer from "$lib/components/footer.svelte"; import NavBar from "$lib/components/nav_bar.svelte"; + import PageLoadingBar from "$lib/components/page_loading_bar.svelte"; + import UploadDialog from "$lib/components/settings/upload_dialog.svelte"; import { currentUser } from "$lib/stores/user_store"; import { isRouteProtected } from "$lib/util/authorization_util"; import "@fortawesome/fontawesome-free/css/all.min.css"; @@ -13,7 +15,6 @@ import "../css/app.css"; import "../css/components.css"; import "../css/theme.css"; - import PageLoadingBar from "$lib/components/page_loading_bar.svelte"; interface Props { children?: Snippet; } @@ -71,8 +72,9 @@ may cause errors.

@@ -81,6 +83,7 @@ + {@render children?.()}
diff --git a/web/src/routes/api/v1/trail/upload/+server.ts b/web/src/routes/api/v1/trail/upload/+server.ts index c1437146..4e5165bc 100644 --- a/web/src/routes/api/v1/trail/upload/+server.ts +++ b/web/src/routes/api/v1/trail/upload/+server.ts @@ -1,6 +1,9 @@ +import GPX from "$lib/models/gpx/gpx"; +import { haversineDistance } from "$lib/models/gpx/utils"; import { SummitLog } from "$lib/models/summit_log"; import type { Trail } from "$lib/models/trail"; -import { trails_create } from "$lib/stores/trail_store"; +import { pb } from "$lib/pocketbase"; +import { fetchGPX, trails_create } from "$lib/stores/trail_store"; import { handleError } from "$lib/util/api_util"; import { fromFile, gpx2trail } from "$lib/util/gpx_util"; import { json, type RequestEvent } from "@sveltejs/kit"; @@ -15,29 +18,45 @@ export async function PUT(event: RequestEvent) { if (!gpxData.length) { throw new ClientResponseError({ status: 400, response: { message: "Empty file" } }) } - let trail: Trail; + let parseResult: { trail: Trail, gpx: GPX }; try { - trail = (await gpx2trail(gpxData, data.get("name") as string | undefined)).trail; - - const log = new SummitLog(trail.date as string, { - distance: trail.distance, - elevation_gain: trail.elevation_gain, - elevation_loss: trail.elevation_loss, - duration: trail.duration ? trail.duration * 60 : undefined, - }) - log.expand!.gpx_data = gpxData; - const fileName = (data.get("name") as string | null)?.length ? data.get("name") as string : "file" - log._gpx = new File([gpxFile], fileName); - - trail.expand!.summit_logs.push(log); + parseResult = (await gpx2trail(gpxData, data.get("name") as string | undefined)); } catch (e: any) { console.error(e) throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } }) } + let trail = parseResult.trail; + + const ignoreDuplicates = data.get("ignoreDuplicates") === "true" + if (!ignoreDuplicates) { + let duplicate: Trail | null = null; + try { + duplicate = await findDuplicate(trail) + } catch (e: any) { + throw new ClientResponseError({ status: 500, response: { message: "Error checking for duplicates" } }) + } + if (duplicate !== null) { + throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name }, }) + } + } + + const log = new SummitLog(trail.date as string, { + distance: trail.distance, + elevation_gain: trail.elevation_gain, + elevation_loss: trail.elevation_loss, + duration: trail.duration ? trail.duration * 60 : undefined, + }) + log.expand!.gpx_data = gpxData; + const fileName = (data.get("name") as string | null)?.length ? data.get("name") as string : "file" + log._gpx = new File([gpxFile], fileName); + + trail.expand!.summit_logs.push(log); + try { trail = await trails_create(trail, [], gpxFile, event.fetch); } catch (e: any) { + console.error(e) throw handleError(e) } return json(trail); @@ -45,4 +64,25 @@ export async function PUT(event: RequestEvent) { } catch (e: any) { throw handleError(e) } -} \ No newline at end of file +} + +async function findDuplicate(t1: Trail) { + const trails: Trail[] = await pb.collection('trails').getFullList({ requestKey: null }); + + const distanceThreshold = 100; + const elevationThreshhold = 50; + const lengthThreshhold = 50; + + for (const t2 of trails) { + const lengthDifference = Math.abs((t1.distance ?? 0) - (t2.distance ?? 0)); + const elevationGainDifference = Math.abs((t1.elevation_gain ?? 0) - (t2.elevation_gain ?? 0)); + const elevationLossDifference = Math.abs((t1.elevation_loss ?? 0) - (t2.elevation_loss ?? 0)); + const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2.lat ?? 0, t2.lon ?? 0) + + if (lengthDifference < lengthThreshhold && elevationGainDifference < elevationThreshhold && elevationLossDifference < elevationThreshhold && startpointDifference < distanceThreshold) { + return t2 + } + } + + return null +} diff --git a/web/src/routes/settings/export/+page.svelte b/web/src/routes/settings/export/+page.svelte index 9c378ef4..15b06ce1 100644 --- a/web/src/routes/settings/export/+page.svelte +++ b/web/src/routes/settings/export/+page.svelte @@ -1,45 +1,23 @@