starts transition to maplibre

This commit is contained in:
Christian Beutel
2024-11-26 00:19:41 +01:00
parent b9ae01846b
commit b6b409e855
21 changed files with 2143 additions and 34 deletions

View File

@@ -7,9 +7,9 @@
import type { Trail } from "$lib/models/trail";
import { gpx2trail } from "$lib/util/gpx_util";
import type { Map } from "leaflet";
import * as M from "maplibre-gl";
import { _ } from "svelte-i18n";
import MapWithElevation from "../trail/map_with_elevation.svelte";
import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte";
export let summitLogs: SummitLog[] = [];
export let showCategory: boolean = false;
@@ -22,7 +22,7 @@
let openTextModal: () => void;
let closeTextModal: () => void;
let map: Map;
let map: M.Map;
let trail: Trail | null = null;
@@ -40,8 +40,6 @@
openMapModal();
await tick();
map.invalidateSize();
return;
}
@@ -103,7 +101,10 @@
bind:closeModal={closeMapModal}
>
<div slot="content" id="summit-log-table-map" class="h-[32rem]">
<MapWithElevation {trail} bind:map></MapWithElevation>
{#if trail}
<MapWithElevationMaplibre {trail} bind:map
></MapWithElevationMaplibre>
{/if}
</div>
</Modal>
<Modal

View File

@@ -0,0 +1,166 @@
<script lang="ts">
import { page } from "$app/stores";
import type { Trail } from "$lib/models/trail";
import { theme } from "$lib/stores/theme_store";
import { findStartAndEndPoints } from "$lib/util/geojson_util";
import { toGeoJson } from "$lib/util/gpx_util";
import {
createMarkerFromWaypoint,
FontawesomeMarker,
} from "$lib/util/maplibre_util";
import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control";
import type { GeoJsonObject } from "geojson";
import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { onDestroy, onMount } from "svelte";
export let trail: Trail;
export let markers: M.Marker[] = [];
export let map: M.Map | null = null;
export let crosshairCursor: boolean = false;
let mapContainer: HTMLDivElement;
let epc: ElevationProfileControl;
$: data = toGeoJson(trail.expand.gpx_data!) as GeoJsonObject;
$: if ($theme == "dark") {
epc?.toggleTheme({
profileBackgroundColor: "#191b24",
elevationGridColor: "#ddd2",
labelColor: "#ddd8",
crosshairColor: "#fff5",
});
} else {
epc?.toggleTheme({
profileBackgroundColor: "#242734",
elevationGridColor: "#0002",
labelColor: "#0009",
crosshairColor: "#0005",
});
}
onMount(async () => {
const initialState = {
lng: 0,
lat: 0,
zoom: 14,
};
const ElevationProfileControl = (
await import(
"$lib/vendor/maplibre-elevation-profile/elevationprofile-control"
)
).ElevationProfileControl;
map = new M.Map({
container: mapContainer,
style: `https://basemaps.cartocdn.com/gl/positron-gl-style/style.json`,
center: [initialState.lng, initialState.lat],
zoom: initialState.zoom,
});
for (const waypoint of trail?.expand.waypoints ?? []) {
const marker = createMarkerFromWaypoint(waypoint);
marker.addTo(map);
markers.push(marker);
}
const startEndPoint = findStartAndEndPoints(data);
const startMarker = new FontawesomeMarker(
{ icon: "fa fa-bullseye" },
{},
)
.setLngLat(startEndPoint[0] as M.LngLatLike)
.addTo(map);
const endMarker = new FontawesomeMarker(
{ icon: "fa fa-flag-checkered" },
{},
)
.setLngLat(startEndPoint[1] as M.LngLatLike)
.addTo(map);
const elevationMarker = new FontawesomeMarker(
{
icon: "fa-regular fa-circle",
fontSize: "xs",
width: 4,
backgroundColor: "primary",
fontColor: "white",
},
{},
);
elevationMarker.setLngLat([0, 0]).addTo(map);
elevationMarker.setOpacity("0");
epc = new ElevationProfileControl({
visible: true,
profileBackgroundColor: $theme == "light" ? "#242734" : "#191b24",
backgroundColor: "bg-menu-background/90",
unit: $page.data.settings?.unit ?? "metric",
profileLineWidth: 3,
displayDistanceGrid: true,
tooltipDisplayDPlus: false,
onEnter: () => {
elevationMarker.setOpacity("1");
},
onLeave: () => {
elevationMarker.setOpacity("0");
},
onMove: (data) => {
elevationMarker.setLngLat(data.position as M.LngLatLike);
},
});
map.addControl(new M.NavigationControl());
map.addControl(
new M.ScaleControl({
maxWidth: 120,
unit: $page.data.settings?.unit ?? "metric",
}),
"top-left",
);
map.addControl(epc);
epc.setData(data, trail.expand.waypoints);
map.on("load", () => {
map!.addSource("trail-source", {
type: "geojson",
data: data as any,
});
map!.addLayer({
id: "uploaded-polygons",
type: "line",
source: "trail-source",
paint: {
"line-color": "#648ad5",
"line-width": 5,
},
});
map!.fitBounds(data.bbox as any, {
animate: false,
padding: {
top: 16,
left: 16,
right: 16,
bottom: map!.getContainer().clientHeight * 0.3 + 16,
},
});
});
});
onDestroy(() => {
map?.remove();
});
</script>
<div id="map" class:cursor-pointer={crosshairCursor} bind:this={mapContainer}></div>
<style>
#map {
width: 100%;
height: 100%;
}
</style>

View File

@@ -39,10 +39,11 @@
import ShareInfo from "../share_info.svelte";
import SummitLogTable from "../summit_log/summit_log_table.svelte";
import { pb } from "$lib/pocketbase";
import * as M from "maplibre-gl";
export let trail: Trail;
export let mode: "overview" | "map" | "list" = "map";
export let markers: Marker[] = [];
export let markers: (Marker | M.Marker)[] = [];
const tabs = [
$_("description"),
@@ -118,11 +119,19 @@
});
function openMarkerPopup(i: number) {
markers[i].openPopup();
if (typeof (markers[i] as Marker<any>).openPopup === "function") {
(markers[i] as Marker<any>).openPopup();
} else {
(markers[i] as M.Marker).togglePopup();
}
}
function closeMarkerPopup(i: number) {
markers[i].closePopup();
if (typeof (markers[i] as Marker<any>).closePopup === "function") {
(markers[i] as Marker<any>).closePopup();
} else {
(markers[i] as M.Marker).togglePopup();
}
}
async function toggleMapFullScreen() {
@@ -239,7 +248,11 @@
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`}
alt="avatar"
/>
<a class="underline" href="/profile/{trail.expand.author.id}">{trail.expand.author.username}</a>
<a
class="underline"
href="/profile/{trail.expand.author.id}"
>{trail.expand.author.username}</a
>
</p>
{/if}
<div class="flex flex-wrap gap-x-8 gap-y-2 mt-4 mr-8">
@@ -358,7 +371,9 @@
{/if}
{#if activeTab == 3}
<div class="overflow-x-auto">
<SummitLogTable summitLogs={trail.expand.summit_logs} showAuthor
<SummitLogTable
summitLogs={trail.expand.summit_logs}
showAuthor
></SummitLogTable>
</div>
{/if}

View File

@@ -2,7 +2,7 @@ import * as xml2js from 'isomorphic-xml2js';
import Metadata from './metadata';
import Route from './route';
import Track from './track';
import { allDatesToISOString, calculateDistance, removeEmpty } from './utils';
import { allDatesToISOString, haversineDistance, removeEmpty } from './utils';
import Waypoint from './waypoint';
const defaultAttributes = {
@@ -104,7 +104,7 @@ export default class GPX {
totalElevationLoss += Math.abs(elevationDiff)
}
const distance = calculateDistance(
const distance = haversineDistance(
prevPoint.$.lat ?? 0,
prevPoint.$.lon ?? 0,
point.$.lat ?? 0,

View File

@@ -20,7 +20,7 @@ function allDatesToISOString(obj: Record<string, any>) {
});
}
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Radius of the Earth in km
const dLat = (lat2 - lat1) * (Math.PI / 180); // Convert degrees to radians
const dLon = (lon2 - lon1) * (Math.PI / 180);
@@ -33,4 +33,4 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
return distance;
}
export { removeEmpty, allDatesToISOString, calculateDistance };
export { removeEmpty, allDatesToISOString, haversineDistance };

View File

@@ -1,5 +1,4 @@
import { page } from "$app/stores";
import { currentUser } from "$lib/stores/user_store";
import { get } from "svelte/store";
export function formatTimeHHMM(minutes?: number) {

View File

@@ -0,0 +1,216 @@
import type { BBox, Feature, FeatureCollection, GeoJSON, GeoJsonObject, GeometryCollection, Position } from "geojson";
export function bbox(
geojson: GeoJSON,
): BBox {
if (geojson.bbox != null) {
return geojson.bbox;
}
const result: BBox = [Infinity, Infinity, -Infinity, -Infinity];
let startPoint: Position, endPoint: Position;
coordEach(geojson, (coord) => {
startPoint ??= [coord[0], coord[1]];
endPoint = coord;
if (result[0] > coord[0]) {
result[0] = coord[0];
}
if (result[1] > coord[1]) {
result[1] = coord[1];
}
if (result[2] < coord[0]) {
result[2] = coord[0];
}
if (result[3] < coord[1]) {
result[3] = coord[1];
}
});
return result;
}
export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] {
const startEndPoints: Position[] = [];
(geojson as FeatureCollection).features.forEach((feature) => {
const geometry = feature.geometry;
if (geometry.type === "LineString") {
const coords = geometry.coordinates as number[][];
const start: [number, number] = [coords[0][0], coords[0][1]]; // First point
const end: [number, number] = [
coords[coords.length - 1][0],
coords[coords.length - 1][1],
]; // Last point
startEndPoints.push(start);
startEndPoints.push(end)
} else if (geometry.type === "MultiLineString") {
const coords = geometry.coordinates as number[][][];
const start: [number, number] = [
coords[0][0][0],
coords[0][0][1],
]; // First point of the first line
const lastLine = coords[coords.length - 1];
const end: [number, number] = [
lastLine[lastLine.length - 1][0],
lastLine[lastLine.length - 1][1],
]; // Last point of the last line
startEndPoints.push(start);
startEndPoints.push(end)
} else {
console.warn(
`Geometry type ${geometry.type} is not supported for start/end point extraction.`
);
}
});
return startEndPoints;
}
function coordEach(geojson: GeoJSON, callback: (
currentCoord: number[],
coordIndex: number,
featureIndex: number,
multiFeatureIndex: number,
geometryIndex: number
) => void | false,
excludeWrapCoord?: boolean,) {
// Handles null Geometry -- Skips this GeoJSON
if (geojson === null) return;
var j,
k,
l,
geometry,
stopG,
coords,
geometryMaybeCollection,
wrapShrink = 0,
coordIndex = 0,
isGeometryCollection,
type = geojson.type,
isFeatureCollection = type === "FeatureCollection",
isFeature = type === "Feature",
stop = isFeatureCollection ? (geojson as FeatureCollection).features.length : 1;
for (var featureIndex = 0; featureIndex < stop; featureIndex++) {
geometryMaybeCollection = isFeatureCollection
? (geojson as FeatureCollection).features[featureIndex].geometry
: isFeature
? (geojson as Feature).geometry
: geojson;
isGeometryCollection = geometryMaybeCollection
? geometryMaybeCollection.type === "GeometryCollection"
: false;
stopG = isGeometryCollection
? (geometryMaybeCollection as GeometryCollection).geometries.length
: 1;
for (var geomIndex = 0; geomIndex < stopG; geomIndex++) {
var multiFeatureIndex = 0;
var geometryIndex = 0;
geometry = isGeometryCollection
? (geometryMaybeCollection as GeometryCollection).geometries[geomIndex]
: geometryMaybeCollection;
// Handles null Geometry -- Skips this geometry
if (geometry === null) continue;
coords = (geometry as any).coordinates;
var geomType = geometry.type;
wrapShrink =
excludeWrapCoord &&
(geomType === "Polygon" || geomType === "MultiPolygon")
? 1
: 0;
switch (geomType) {
case null:
break;
case "Point":
if (
callback(
coords,
coordIndex,
featureIndex,
multiFeatureIndex,
geometryIndex
) === false
)
return false;
coordIndex++;
multiFeatureIndex++;
break;
case "LineString":
case "MultiPoint":
for (j = 0; j < coords.length; j++) {
if (
callback(
coords[j],
coordIndex,
featureIndex,
multiFeatureIndex,
geometryIndex
) === false
)
return false;
coordIndex++;
if (geomType === "MultiPoint") multiFeatureIndex++;
}
if (geomType === "LineString") multiFeatureIndex++;
break;
case "Polygon":
case "MultiLineString":
for (j = 0; j < coords.length; j++) {
for (k = 0; k < coords[j].length - wrapShrink; k++) {
if (
callback(
coords[j][k],
coordIndex,
featureIndex,
multiFeatureIndex,
geometryIndex
) === false
)
return false;
coordIndex++;
}
if (geomType === "MultiLineString") multiFeatureIndex++;
if (geomType === "Polygon") geometryIndex++;
}
if (geomType === "Polygon") multiFeatureIndex++;
break;
case "MultiPolygon":
for (j = 0; j < coords.length; j++) {
geometryIndex = 0;
for (k = 0; k < coords[j].length; k++) {
for (l = 0; l < coords[j][k].length - wrapShrink; l++) {
if (
callback(
coords[j][k][l],
coordIndex,
featureIndex,
multiFeatureIndex,
geometryIndex
) === false
)
return false;
coordIndex++;
}
geometryIndex++;
}
multiFeatureIndex++;
}
break;
case "GeometryCollection":
for (j = 0; j < (geometry as GeometryCollection).geometries.length; j++)
if (
coordEach((geometry as GeometryCollection).geometries[j], callback, excludeWrapCoord) ===
false
)
return false;
break;
default:
throw new Error("Unknown Geometry Type");
}
}
}
}

View File

@@ -2,21 +2,22 @@ import GPX from "$lib/models/gpx/gpx";
import { Trail } from "$lib/models/trail";
import { Waypoint } from "$lib/models/waypoint";
import { currentUser } from "$lib/stores/user_store";
import { kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import { gpx, kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import cryptoRandomString from "crypto-random-string";
import { get } from "svelte/store";
//@ts-ignore
import EasyFit from "$lib/vendor/easy-fit/easy-fit"
import { browser } from "$app/environment";
import Track from "$lib/models/gpx/track";
import TrackSegment from "$lib/models/gpx/track-segment";
import GPXWaypoint from "$lib/models/gpx/waypoint";
import { browser } from "$app/environment";
import * as xmldom from 'xmldom'
import type { Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
import EasyFit from "$lib/vendor/easy-fit/easy-fit";
import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
import * as xmldom from 'xmldom';
import { bbox } from "./geojson_util";
export async function gpx2trail(gpxString: string, fallbackName?: string) {
const gpx = await GPX.parse(gpxString);
if (gpx instanceof Error) {
@@ -281,4 +282,13 @@ export function isFITFile(buffer: ArrayBuffer) {
}
return true;
}
export function toGeoJson(gpxData: string) {
const parser = browser ? new DOMParser() : new xmldom.DOMParser();
const geojson = gpx(
parser.parseFromString(gpxData, "text/xml"),
) as GeoJSON;
geojson.bbox = bbox(geojson)
return geojson
}

View File

@@ -0,0 +1,44 @@
import type { Waypoint } from "$lib/models/waypoint";
import M from "maplibre-gl";
export class FontawesomeMarker extends M.Marker {
constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string }, markerOptions?: M.MarkerOptions) {
const element = document.createElement('div')
element.className = `cursor-pointer flex items-center justify-center w-${options.width ?? 7} aspect-square bg-${options.backgroundColor ?? "gray-500"} rounded-full text-${options.fontSize ?? "normal"}`
super({ element: element, ...markerOptions });
const {
icon,
} = options
const iconElementString = `<i class="text-${options.fontColor ?? "white"} ${icon}"></i>`
this._element.insertAdjacentHTML('beforeend', iconElementString)
}
}
export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker: M.Marker) => void): FontawesomeMarker {
const marker = new FontawesomeMarker({
icon: `fa fa-${waypoint.icon}`,
}, {
draggable: onDragEnd != null,
color: "#6b7280"
})
const popup = new M.Popup({ offset: 25, closeButton: false }).setHTML(
"<b>" +
waypoint.name +
"</b>" +
(waypoint.description && waypoint.description.length > 0
? "<br>" + waypoint.description
: ""),
);
marker
.setLngLat([waypoint.lon, waypoint.lat])
.setPopup(popup)
if (onDragEnd) {
marker.on("dragend", () => onDragEnd(marker));
}
return marker;
}

View File

@@ -0,0 +1,218 @@
import { type ControlPosition, type IControl } from "maplibre-gl";
import * as M from "maplibre-gl";
import { ElevationProfile, type ElevationProfileOptions } from "./elevationprofile";
// @ts-ignore
import type { GeoJsonObject } from "geojson";
import type { Waypoint } from "$lib/models/waypoint";
/**
* Elevation profile control options
*/
export type ElevationProfileControlOptions = ElevationProfileOptions & {
/**
* If `true`, the elevation profile control will be visible as soon as it's ready.
* If `false`, a click on the control button (or a programmatic call to `.showProfile()`)
* will be neccesary to show the profile.
*
* Default: `false`
*/
visible?: boolean;
/**
* Size of the profile as a CSS rule.
* This `size` will be the `width` if the `.position` is "left" or "right",
* and will be the `height` if the `.position` is "top" or "bottom".
*
* Default: `"30%"`
*/
size?: string;
/**
* Position of the elevation profile chart when shown.
*
* Default: `"botton"`
*/
position?: "top" | "left" | "right" | "bottom";
/**
* Show the control button. If can be handy to hide it, especially if the profile is displayed
* in a custom container and that its visiblity is managed by logic external to this control.
*
* Default: `true`
*/
showButton?: boolean;
/**
* A CSS class to add to the container. This is especially relevant when the options `.container` is not provided.
* Important: if provided, no styling is added by this control and even placement will have to be managed by external CSS.
*
* Default: `""`
*/
containerClass?: string;
/**
* DIV element to contain the control.
* Important: if provided, no styling is added by this control.
* Default: automatically created inside the map container
*/
container?: string | HTMLDivElement;
};
export class ElevationProfileControl implements IControl {
private map?: M.Map;
private buttonContainer?: HTMLDivElement;
private toggleButton?: HTMLButtonElement;
private isProfileShown = false;
private iconSpan?: HTMLSpanElement;
private profileContainer?: HTMLDivElement;
private settings: ElevationProfileControlOptions;
private data: GeoJsonObject | null = null;
private elevationProfileChart?: ElevationProfile;
constructor(options: ElevationProfileControlOptions = {}) {
if (typeof window === "undefined")
throw new Error("This pluggin must be mounted client-side");
this.settings = { ...options };
}
toggleTheme(options: ElevationProfileControlOptions) {
this.settings = { ...this.settings, ...options };
this.elevationProfileChart?.toggleTheme(this.settings)
}
getContainer(): HTMLDivElement | undefined {
return this.profileContainer;
}
onAdd(map: M.Map): HTMLElement {
this.map = map;
this.buttonContainer = document.createElement("div");
if (this.settings.showButton === false) {
this.buttonContainer.style.setProperty("display", "none");
}
this.buttonContainer.classList.add(
"maplibregl-ctrl",
"maplibregl-ctrl-group"
);
this.toggleButton = document.createElement("button");
this.buttonContainer.appendChild(this.toggleButton);
this.iconSpan = document.createElement("i");
this.iconSpan.classList.add("fa", "fa-chart-line", "text-black");
this.toggleButton.appendChild(this.iconSpan);
this.toggleButton.addEventListener("click", this.toggleProfile.bind(this));
const mapContainer = map.getContainer();
const size = this.settings.size ?? "30%";
if (this.settings.container) {
const tmpContainer =
typeof this.settings.container === "string"
? document.getElementById(this.settings.container)
: this.settings.container;
if (!tmpContainer) throw new Error("The provided container is invalid");
this.profileContainer = tmpContainer as HTMLDivElement;
} else {
this.profileContainer = document.createElement("div");
this.profileContainer.style.setProperty("display", "none");
if (!this.settings.containerClass) {
this.profileContainer.classList.add(this.settings.backgroundColor ?? "white")
this.profileContainer.style.setProperty("position", "absolute");
if (this.settings.position === "bottom" || !this.settings.position) {
// To prevent clashing with MapTiler logo and attribution control
this.settings.paddingBottom = this.settings.paddingBottom ?? 35;
this.profileContainer.style.setProperty("width", "100%");
this.profileContainer.style.setProperty("height", size);
this.profileContainer.style.setProperty("bottom", "0");
} else if (this.settings.position === "top") {
this.profileContainer.style.setProperty("width", "100%");
this.profileContainer.style.setProperty("height", size);
this.profileContainer.style.setProperty("top", "0");
} else if (this.settings.position === "left") {
// To prevent clashing with MapTiler logo and attribution control
this.settings.paddingBottom = this.settings.paddingBottom ?? 35;
this.profileContainer.style.setProperty("width", size);
this.profileContainer.style.setProperty("height", "100%");
this.profileContainer.style.setProperty("left", "0");
} else if (this.settings.position === "right") {
// To prevent clashing with MapTiler logo and attribution control
this.settings.paddingBottom = this.settings.paddingBottom ?? 35;
this.profileContainer.style.setProperty("width", size);
this.profileContainer.style.setProperty("height", "100%");
this.profileContainer.style.setProperty("right", "0");
}
}
const waypointContainer = document.createElement("div")
waypointContainer.className = "absolute w-full"
waypointContainer.id = "waypoint-container"
this.profileContainer.append(waypointContainer);
mapContainer.appendChild(this.profileContainer);
}
if (this.settings.containerClass) {
this.profileContainer.classList.add(this.settings.containerClass);
}
this.elevationProfileChart = new ElevationProfile(
this.profileContainer,
this.settings
);
if (this.settings.visible) {
this.showProfile();
}
return this.buttonContainer;
}
private toggleProfile() {
if (!this.profileContainer) return;
if (this.isProfileShown) {
this.hideProfile();
} else {
this.showProfile();
}
}
showProfile() {
this.profileContainer?.style.setProperty("display", "inherit");
this.isProfileShown = true;
}
hideProfile() {
this.profileContainer?.style.setProperty("display", "none");
this.isProfileShown = false;
}
onRemove(): void {
// remove button
if (this.buttonContainer?.parentNode) {
this.buttonContainer.parentNode.removeChild(this.buttonContainer);
}
this.map = undefined;
this.buttonContainer = undefined;
this.toggleButton = undefined;
this.isProfileShown = false;
}
getDefaultPosition?: (() => ControlPosition) | undefined;
async setData(data: GeoJsonObject, waypoints?: Waypoint[]) {
if (!this.map || !this.elevationProfileChart) {
throw new Error(
"The Elevation Profile Control needs to be mounted on a map instance before setting any data."
);
}
this.data = data;
if (!this.data) return;
this.elevationProfileChart.setData(this.data, waypoints);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
import { haversineDistance } from "$lib/models/gpx/utils";
import type {
Position
} from "geojson";
export function haversineCumulatedDistanceWgs84(path: Position[]): number[] {
if (path.length < 2) {
return [];
}
let totalDistance = 0;
const distances: number[] = [0];
for (let i = 0; i < path.length - 1; i++) {
const [lon1, lat1] = path[i];
const [lon2, lat2] = path[i + 1];
totalDistance += haversineDistance(lat1, lon1, lat2, lon2);
distances.push(totalDistance);
}
return distances; // Array of cumulative distances in meters
}
export function smoothElevations(positions: Position[], windowSize: number): Position[] {
// Ensure windowSize is valid (at least 1)
if (windowSize < 1) throw new Error("Window size must be at least 1.");
// Create a new array with smoothed elevations
return positions.map((pos, i, arr) => {
const start = Math.max(0, i - Math.floor(windowSize / 2)); // Start index for the window
const end = Math.min(arr.length, i + Math.floor(windowSize / 2) + 1); // End index for the window
const segment = arr.slice(start, end); // Extract the positions in the window
// Calculate the weighted moving average of elevations
const weights = segment.map((_, idx) => idx + 1); // Increasing weights: 1, 2, 3...
const elevations = segment.map(p => p[2]); // Extract elevations
const weightedSum = elevations.reduce((sum, elevation, idx) => sum + elevation * weights[idx], 0);
const weightTotal = weights.reduce((sum, weight) => sum + weight, 0);
const smoothedElevation = weightedSum / weightTotal; // Weighted average elevation
// Return a new Position with the smoothed elevation
return [pos[0], pos[1], smoothedElevation] as Position;
});
}