adds route editing capabilities

This commit is contained in:
Christian Beutel
2025-01-11 22:43:09 +01:00
parent a19a96f524
commit 35f12f1816
12 changed files with 469 additions and 181 deletions

View File

@@ -1,6 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
import type { Settings } from "$lib/models/settings";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { findStartAndEndPoints } from "$lib/util/geojson_util"; import { findStartAndEndPoints } from "$lib/util/geojson_util";
import { toGeoJson } from "$lib/util/gpx_util"; import { toGeoJson } from "$lib/util/gpx_util";
@@ -10,17 +13,16 @@
FontawesomeMarker, FontawesomeMarker,
} from "$lib/util/maplibre_util"; } from "$lib/util/maplibre_util";
import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control"; import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control";
import { FullscreenControl } from "$lib/vendor/maplibre-fullscreen/fullscreen-control";
import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule";
import { StyleSwitcherControl } from "$lib/vendor/maplibre-style-switcher/style-switcher-control"; import { StyleSwitcherControl } from "$lib/vendor/maplibre-style-switcher/style-switcher-control";
import type { GeoJSON } from "geojson"; import type { GeoJSON } from "geojson";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { createEventDispatcher, onDestroy, onMount } from "svelte"; import { createEventDispatcher, onDestroy, onMount } from "svelte";
import MaplibreGraticule from "$lib/vendor/maplibre-graticule/maplibre-graticule";
import { FullscreenControl } from "$lib/vendor/maplibre-fullscreen/fullscreen-control";
import type { Settings } from "$lib/models/settings";
import directionCaret from "$lib/assets/svgs/caret-right-solid.svg";
export let trails: Trail[] = []; export let trails: Trail[] = [];
export let waypoints: Waypoint[] = [];
export let markers: M.Marker[] = []; export let markers: M.Marker[] = [];
export let map: M.Map | null = null; export let map: M.Map | null = null;
export let drawing: boolean = false; export let drawing: boolean = false;
@@ -39,7 +41,7 @@
undefined; undefined;
export let mapOptions: Partial<M.MapOptions> | undefined = undefined; export let mapOptions: Partial<M.MapOptions> | undefined = undefined;
export let activeTrail: number = 0; export let activeTrail: number | null = null;
export let minZoom: number = 0; export let minZoom: number = 0;
let mapContainer: HTMLDivElement; let mapContainer: HTMLDivElement;
@@ -56,11 +58,19 @@
listener: { listener: {
onEnter: ((e: M.MapMouseEvent) => void) | null; onEnter: ((e: M.MapMouseEvent) => void) | null;
onLeave: ((e: M.MapMouseEvent) => void) | null; onLeave: ((e: M.MapMouseEvent) => void) | null;
onClick: ((e: M.MapMouseEvent) => void) | null; onMouseUp: ((e: M.MapMouseEvent) => void) | null;
onMouseDown: ((e: M.MapMouseEvent) => void) | null;
onMouseMove: ((e: M.MapMouseEvent) => void) | null;
}; };
} }
> = {}; > = {};
let elevationMarker: FontawesomeMarker;
let draggingSegment: number | null = null;
let hoveringTrail: boolean = false;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const trailColors = [ const trailColors = [
@@ -79,7 +89,19 @@
$: data = getData(trails); $: data = getData(trails);
$: if (data && map) { $: if (data && map) {
initMap(); initMap(map.loaded());
}
$: if (activeTrail !== null && trails[activeTrail] !== undefined) {
if (
!drawing &&
fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined)
) {
focusTrail(trails[activeTrail]);
}
} else if (activeTrail === null && trails.length) {
unFocusTrail();
} }
$: if ($theme == "dark") { $: if ($theme == "dark") {
@@ -99,14 +121,9 @@
} }
$: if (drawing && map) { $: if (drawing && map) {
map.getCanvas().style.cursor = "crosshair"; startDrawing();
} else if (!drawing && map) { } else if (!drawing && map) {
map.getCanvas().style.cursor = "inherit"; stopDrawing();
addStartEndMarkers(
trails[activeTrail],
trails[activeTrail]?.id ?? activeTrail.toString(),
data?.at(activeTrail),
);
} }
$: if (showGrid) { $: if (showGrid) {
@@ -133,6 +150,11 @@
} }
} }
$: if (waypoints) {
showWaypoints();
refreshElevationProfile();
}
function getData(trails: Trail[]) { function getData(trails: Trail[]) {
if (!trails.length) { if (!trails.length) {
return []; return [];
@@ -157,27 +179,25 @@
return r; return r;
} }
function initMap() { function initMap(mapLoaded: boolean) {
if (!map) { if (!map) {
return; return;
} }
if (data[activeTrail] && showElevation) { refreshElevationProfile();
epc?.setData( if (showElevation) {
data[activeTrail]!,
trails.at(activeTrail)!.expand?.waypoints,
);
epc?.showProfile(); epc?.showProfile();
} }
trails.forEach((t, i) => { trails.forEach((t, i) => {
const layerId = t.id ?? i.toString(); const layerId = t.id!;
addTrailLayer(t, layerId, i, data[i]); addTrailLayer(t, layerId, i, data[i]);
}); });
Object.keys(layers).forEach((layerId) => { Object.keys(layers).forEach((layerId) => {
const isStillVisible = trails.some((t) => t.id === layerId); const isStillVisible = trails.some((t) => t.id === layerId);
if (!isStillVisible) { if (!isStillVisible) {
removeCaretLayer();
removeTrailLayer(layerId); removeTrailLayer(layerId);
} }
}); });
@@ -186,8 +206,18 @@
!drawing && !drawing &&
fitBounds !== "off" && fitBounds !== "off" &&
data.some((d) => d.bbox !== undefined) data.some((d) => d.bbox !== undefined)
) { ) {
flyToBounds(fitBounds == "animate"); if (activeTrail !== null && mapLoaded) {
focusTrail(trails[activeTrail]);
} else {
flyToBounds();
}
}
}
export function refreshElevationProfile() {
if (activeTrail !== null && data[activeTrail]) {
epc?.setData(data[activeTrail]!, waypoints);
} }
} }
@@ -209,17 +239,18 @@
return new M.LngLatBounds([minX, minY, maxX, maxY]); return new M.LngLatBounds([minX, minY, maxX, maxY]);
} }
function flyToBounds(animate: boolean = true) { function flyToBounds() {
const bounds = data[activeTrail] const bounds =
? (data[activeTrail].bbox as M.LngLatBoundsLike) activeTrail !== null && data[activeTrail]
: getBounds(); ? (data[activeTrail].bbox as M.LngLatBoundsLike)
: getBounds();
if (!bounds) { if (!bounds || !map) {
return; return;
} }
map!.fitBounds(bounds, { map!.fitBounds(bounds, {
animate: animate, animate: fitBounds == "animate",
padding: { padding: {
top: 16, top: 16,
left: 16, left: 16,
@@ -250,7 +281,8 @@
map?.off("mouseenter", id, layers[id].listener.onEnter!); map?.off("mouseenter", id, layers[id].listener.onEnter!);
map?.off("mouseleave", id, layers[id].listener.onLeave!); map?.off("mouseleave", id, layers[id].listener.onLeave!);
map?.off("click", id, layers[id].listener.onClick!); map?.off("mouseup", id, layers[id].listener.onMouseUp!);
map?.off("mousemove", id, layers[id].listener.onMouseMove!);
delete layers[id]; delete layers[id];
} }
@@ -263,9 +295,11 @@
source: null, source: null,
layer: null, layer: null,
listener: { listener: {
onClick: null, onMouseUp: null,
onMouseDown: null,
onEnter: null, onEnter: null,
onLeave: null, onLeave: null,
onMouseMove: null,
}, },
}; };
} }
@@ -290,10 +324,6 @@
data: geojson, data: geojson,
}); });
layers[id].source = map.getSource(id) as M.GeoJSONSource; layers[id].source = map.getSource(id) as M.GeoJSONSource;
// map.addSource("trail-source", {
// type: "vector",
// url: `http://localhost:8080/data/out.json`,
// });
} catch (e) { } catch (e) {
return; return;
} }
@@ -313,28 +343,21 @@
}, },
}); });
layers[id].layer = map.getLayer(id) as M.LineLayerSpecification; layers[id].layer = map.getLayer(id) as M.LineLayerSpecification;
layers[id].listener.onEnter = (e) => highlightTrail(id); layers[id].listener.onEnter = (e) =>
highlightTrail(id, trails[activeTrail ?? -1]?.id == id);
layers[id].listener.onLeave = (e) => unHighlightTrail(id); layers[id].listener.onLeave = (e) => unHighlightTrail(id);
layers[id].listener.onClick = (e) => focusTrail(trail, e); layers[id].listener.onMouseUp = (e) => {
activeTrail = trails.indexOf(trail);
};
layers[id].listener.onMouseMove =
moveElevationMarkerToCursorPosition;
layers[id].listener.onMouseDown = (e) => handDragStart(e, id);
map.on("mouseenter", id, layers[id].listener.onEnter); map.on("mouseenter", id, layers[id].listener.onEnter);
map.on("mouseleave", id, layers[id].listener.onLeave); map.on("mouseleave", id, layers[id].listener.onLeave);
map.on("click", id, layers[id].listener.onClick); map.on("mouseup", id, layers[id].listener.onMouseUp);
map.on("mousemove", id, layers[id].listener.onMouseMove);
// map.addLayer({ map.on("mousedown", id, layers[id].listener.onMouseDown);
// id: "trail-layer",
// type: "line",
// source: "trail-source",
// "source-layer": "Herzogstand", // Replace with the actual layer name in the .mbtiles file
// layout: {
// "line-join": "round",
// "line-cap": "round",
// },
// paint: {
// "line-color": "#648ad5",
// "line-width": 5,
// },
// });
} }
if (!drawing) { if (!drawing) {
@@ -342,10 +365,47 @@
} }
} }
function moveElevationMarkerToCursorPosition(e: M.MapMouseEvent) {
elevationMarker.setLngLat(e.lngLat);
}
function handDragStart(e: M.MapMouseEvent, id: string) {
if (
!drawing ||
(e.originalEvent.target as HTMLElement | null)?.classList.contains(
"route-anchor",
)
) {
return;
}
e.preventDefault();
const features = map?.queryRenderedFeatures(e.point, {
layers: [id],
});
const segmentId = features?.at(0)?.properties.segmentId;
if (segmentId !== null) {
draggingSegment = segmentId;
}
map?.on("mousemove", moveElevationMarkerToCursorPosition);
map?.once("mouseup", handleDragEnd);
}
function handleDragEnd(e: M.MapMouseEvent) {
map?.off("mousemove", moveElevationMarkerToCursorPosition);
dispatch("segmentDragEnd", { segment: draggingSegment, event: e });
draggingSegment = null;
}
function addCaretLayer(id?: string) { function addCaretLayer(id?: string) {
if (!map || !id) { if (!map || !id) {
return; return;
} }
if (map.getLayer("direction-carets")) {
removeCaretLayer();
}
map.addLayer({ map.addLayer({
id: "direction-carets", id: "direction-carets",
type: "symbol", type: "symbol",
@@ -382,46 +442,63 @@
map.removeLayer("direction-carets"); map.removeLayer("direction-carets");
} }
export function highlightTrail(id: string) { export function highlightTrail(
id: string,
showElevationMarker: boolean = false,
) {
if (showElevationMarker) {
elevationMarker.setOpacity("1");
}
map?.setPaintProperty(id, "line-width", 7); map?.setPaintProperty(id, "line-width", 7);
hoveringTrail = true;
// map?.setPaintProperty(id, "line-color", "#2766e3"); // map?.setPaintProperty(id, "line-color", "#2766e3");
} }
export function unHighlightTrail(id: string) { export function unHighlightTrail(id: string | undefined) {
if (!id || draggingSegment !== null) {
return;
}
elevationMarker.setOpacity("0");
hoveringTrail = false;
map?.setPaintProperty(id, "line-width", 5); map?.setPaintProperty(id, "line-width", 5);
// map?.setPaintProperty(id, "line-color", "#648ad5"); // map?.setPaintProperty(id, "line-color", "#648ad5");
} }
export function focusTrail(trail: Trail, e?: M.MapMouseEvent) { function focusTrail(trail: Trail) {
const currentlyFocussedTrail = trails[activeTrail];
if (currentlyFocussedTrail) { activeTrail = trails.findIndex(t => t.id == trail.id);
unFocusTrail(currentlyFocussedTrail); if (activeTrail < 0) {
} activeTrail = null;
e?.preventDefault();
dispatch("select", trail);
const index = trails.findIndex((t) => t.id == trail.id);
if (index == -1) {
return; return;
} }
activeTrail = index;
const currentlyFocussedTrail = trails[activeTrail];
highlightTrail(trail.id!); if (currentlyFocussedTrail && currentlyFocussedTrail != trail) {
if (data[activeTrail] && showElevation) { unFocusTrail(currentlyFocussedTrail);
epc?.setData( }
data[activeTrail]!, dispatch("select", trail);
trails.at(activeTrail)!.expand?.waypoints,
); try {
epc?.showProfile(); highlightTrail(trail.id!);
refreshElevationProfile();
if (showElevation) {
epc?.showProfile();
}
showWaypoints();
addCaretLayer(trail.id!);
flyToBounds();
} catch (e) {
console.warn(e)
} }
showWaypoints();
addCaretLayer(trail.id!);
flyToBounds();
} }
export function unFocusTrail(trail: Trail) { function unFocusTrail(trail?: Trail) {
dispatch("unselect", trail); if (trail) {
activeTrail = -1; dispatch("unselect", trail);
unHighlightTrail(trail.id!); unHighlightTrail(trail.id!);
}
activeTrail = null;
flyToBounds(); flyToBounds();
if (showElevation) { if (showElevation) {
@@ -431,12 +508,38 @@
removeCaretLayer(); removeCaretLayer();
} }
function startDrawing() {
if (!map) {
return;
}
activeTrail ??= 0;
map.getCanvas().style.cursor = "crosshair";
if (trails[activeTrail]) {
removeStartEndMarkers(trails[activeTrail].id);
}
}
function stopDrawing() {
if (!map) {
return;
}
map.getCanvas().style.cursor = "inherit";
if (activeTrail !== null && trails[activeTrail]) {
addStartEndMarkers(
trails[activeTrail],
trails[activeTrail].id,
data?.at(activeTrail),
);
}
}
function addStartEndMarkers( function addStartEndMarkers(
trail: Trail, trail: Trail,
id: string, id: string | undefined,
geojson: GeoJSON | null | undefined, geojson: GeoJSON | null | undefined,
) { ) {
if (!map || !trail) { if (!map || !trail || !id) {
return; return;
} }
createEmptyLayer(id); createEmptyLayer(id);
@@ -470,12 +573,22 @@
{ icon: "fa fa-flag-checkered" }, { icon: "fa fa-flag-checkered" },
{}, {},
); );
layers[id].endMarker.setLngLat(startEndPoint[1] as M.LngLatLike); layers[id].endMarker.setLngLat(
startEndPoint[startEndPoint.length - 1] as M.LngLatLike,
);
if (map.getZoom() > minZoom) { if (map.getZoom() > minZoom) {
layers[id].endMarker.addTo(map); layers[id].endMarker.addTo(map);
} }
} }
function removeStartEndMarkers(id: string | undefined) {
if (!id) {
return;
}
layers[id].startMarker?.remove();
layers[id].endMarker?.remove();
}
export function togglePopup(id: string, currentState?: boolean) { export function togglePopup(id: string, currentState?: boolean) {
if ( if (
currentState !== undefined && currentState !== undefined &&
@@ -490,7 +603,9 @@
if (!map) { if (!map) {
return; return;
} }
for (const waypoint of trails[activeTrail]?.expand?.waypoints ?? []) {
hideWaypoints();
for (const waypoint of waypoints) {
const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd); const marker = createMarkerFromWaypoint(waypoint, onMarkerDragEnd);
marker.addTo(map); marker.addTo(map);
markers.push(marker); markers.push(marker);
@@ -570,7 +685,7 @@
}; };
map = new M.Map(finalMapOptions); map = new M.Map(finalMapOptions);
const elevationMarker = new FontawesomeMarker( elevationMarker = new FontawesomeMarker(
{ {
id: "elevation-marker", id: "elevation-marker",
icon: "fa-regular fa-circle", icon: "fa-regular fa-circle",
@@ -655,10 +770,6 @@
} }
map.on("styledata", () => { map.on("styledata", () => {
trails.forEach((t, i) => {
addTrailLayer(t, t.id ?? i.toString(), i, data?.at(i));
});
if (showTerrain) { if (showTerrain) {
try { try {
if ( if (
@@ -695,7 +806,7 @@
map.on("zoom", (e) => { map.on("zoom", (e) => {
const zoom = e.target.getZoom(); const zoom = e.target.getZoom();
Object.values(layers).forEach((l) => { Object.values(layers).forEach((l) => {
if (zoom > minZoom && map) { if (zoom > minZoom && map && !drawing) {
l.endMarker?.addTo(map); l.endMarker?.addTo(map);
} else { } else {
l.endMarker?.remove(); l.endMarker?.remove();
@@ -706,11 +817,14 @@
}); });
map.on("click", (e) => { map.on("click", (e) => {
if (hoveringTrail && drawing) {
return;
}
dispatch("click", e); dispatch("click", e);
}); });
map.on("load", () => { map.on("load", () => {
addCaretLayer(trails[activeTrail]?.id); initMap(true);
dispatch("init", map); dispatch("init", map);
}); });

View File

@@ -429,6 +429,8 @@
<div class="relative h-72 rounded-xl overflow-hidden"> <div class="relative h-72 rounded-xl overflow-hidden">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={[trail]} trails={[trail]}
activeTrail={0}
waypoints={trail.expand?.waypoints}
showElevation={false} showElevation={false}
showStyleSwitcher={false} showStyleSwitcher={false}
showFullscreen={true} showFullscreen={true}

View File

@@ -68,13 +68,14 @@ export async function calculateRouteBetween(startLat: number, startLon: number,
return waypoints return waypoints
} }
export async function appendToRoute(waypoints: Waypoint[]) { export async function insertIntoRoute(waypoints: Waypoint[], index?: number) {
const segment = new TrackSegment({ trkpt: [] }) const segment = new TrackSegment({ trkpt: waypoints })
for (const wpt of waypoints) { if (index) {
segment.trkpt!.push(wpt) route.trk?.at(0)?.trkseg?.splice(index, 0, segment);
} else {
route.trk?.at(0)?.trkseg?.push(segment);
} }
route.trk?.at(0)?.trkseg?.push(segment);
} }
export async function editRoute(index: number, waypoints: Waypoint[]) { export async function editRoute(index: number, waypoints: Waypoint[]) {

View File

@@ -24,13 +24,13 @@ export function bbox(
result[3] = coord[1]; result[3] = coord[1];
} }
}); });
return result; return result;
} }
export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] { export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] {
const startEndPoints: Position[] = []; const startEndPoints: Position[] = [];
// Check if it's a FeatureCollection // Check if it's a FeatureCollection
if ((geojson as any).features) { if ((geojson as any).features) {
(geojson as any).features.forEach((feature: any) => { (geojson as any).features.forEach((feature: any) => {
@@ -50,6 +50,43 @@ export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] {
return startEndPoints; return startEndPoints;
} }
export function splitMultiLineStringToLineStrings(geojson: GeoJsonObject): FeatureCollection {
const features: Feature[] = [];
(geojson as any).features.forEach((feature: any) => {
if (feature.geometry.type === "MultiLineString") {
feature.geometry.coordinates.forEach((lineString: any, lineIndex: number) => {
features.push({
type: "Feature",
geometry: {
type: "LineString",
coordinates: lineString,
},
properties: {
...feature.properties,
featureId: features.length,
segmentId: lineIndex,
},
});
});
} else if (feature.geometry.type === "LineString") {
features.push({
...feature,
properties: {
...feature.properties,
featureId: features.length,
segmentId: 0
},
});
}
});
return {
type: "FeatureCollection",
features: features,
};
}
function extractStartAndEndPointsFromGeometry(geometry: any, startEndPoints: Position[]) { function extractStartAndEndPointsFromGeometry(geometry: any, startEndPoints: Position[]) {
if (geometry.type === "LineString") { if (geometry.type === "LineString") {
const coords = geometry.coordinates as number[][]; const coords = geometry.coordinates as number[][];

View File

@@ -13,7 +13,7 @@ import GPXWaypoint from "$lib/models/gpx/waypoint";
import EasyFit from "$lib/vendor/easy-fit/easy-fit"; import EasyFit from "$lib/vendor/easy-fit/easy-fit";
import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson'; import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
import * as xmldom from 'xmldom'; import * as xmldom from 'xmldom';
import { bbox } from "./geojson_util"; import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util";
export async function gpx2trail(gpxString: string, fallbackName?: string) { export async function gpx2trail(gpxString: string, fallbackName?: string) {
@@ -130,7 +130,7 @@ export async function fromFile(file: File | Blob) {
}); });
} }
return {gpxData, gpxFile}; return { gpxData, gpxFile };
} }
export function fromKML(kmlData: string) { export function fromKML(kmlData: string) {
@@ -317,9 +317,10 @@ export function isFITFile(buffer: ArrayBuffer) {
export function toGeoJson(gpxData: string) { export function toGeoJson(gpxData: string) {
const parser = browser ? new DOMParser() : new xmldom.DOMParser(); const parser = browser ? new DOMParser() : new xmldom.DOMParser();
const geojson = gpx( let geojson = gpx(
parser.parseFromString(gpxData, "text/xml"), parser.parseFromString(gpxData, "text/xml"),
) as GeoJSON; ) as GeoJSON;
geojson = splitMultiLineStringToLineStrings(geojson);
geojson.bbox = bbox(geojson) geojson.bbox = bbox(geojson)
return geojson return geojson
} }

View File

@@ -1,14 +1,15 @@
import type { Trail } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint";
import M from "maplibre-gl";
import { getFileURL } from "./file_util";
import { formatDistance, formatElevation, formatTimeHHMM } from "./format_util";
import { get } from "svelte/store";
import { _ } from "svelte-i18n";
import { haversineDistance } from "$lib/models/gpx/utils";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg"; import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg"; import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import { haversineDistance } from "$lib/models/gpx/utils";
import type { Trail } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint";
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import M from "maplibre-gl";
import { _ } from "svelte-i18n";
import { get } from "svelte/store";
import { getFileURL } from "./file_util";
import { formatDistance, formatElevation, formatTimeHHMM } from "./format_util";
import { icons } from "./icon_util";
export class FontawesomeMarker extends M.Marker { export class FontawesomeMarker extends M.Marker {
constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string, id?: string }, markerOptions?: M.MarkerOptions) { constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string, id?: string }, markerOptions?: M.MarkerOptions) {
@@ -34,13 +35,32 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker
color: "#6b7280" color: "#6b7280"
}) })
const popup = new M.Popup({ offset: 25, closeButton: false }).setHTML(
"<b>" + const content = document.createElement("div");
waypoint.name +
"</b>" + const spanElement = document.createElement("span");
(waypoint.description && waypoint.description.length > 0 const iconElement = document.createElement("i");
? "<br>" + waypoint.description const iconName = icons.includes(waypoint.icon ?? "") ? waypoint.icon : "circle";
: ""), iconElement.classList.add("fa", `fa-${iconName}`)
spanElement.appendChild(iconElement);
const nameElement = document.createElement("b");
nameElement.textContent = waypoint.name ?? "-";
if(waypoint.name?.length) {
nameElement.classList.add("ml-2")
}
spanElement.appendChild(nameElement);
content.appendChild(spanElement);
if (waypoint.description && waypoint.description.length > 0) {
const descriptionElement = document.createElement("p");
descriptionElement.textContent = waypoint.description;
content.appendChild(descriptionElement);
}
const popup = new M.Popup({ offset: 25, closeButton: false }).setDOMContent(
content
); );
marker marker
.setLngLat([waypoint.lon, waypoint.lat]) .setLngLat([waypoint.lon, waypoint.lat])
@@ -53,10 +73,11 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker
return marker; return marker;
} }
export function createAnchorMarker(lat: number, lon: number, index: number, onDeleteClick: () => void, onDragEnd: (event: M.Marker) => void): FontawesomeMarker { export function createAnchorMarker(lat: number, lon: number, index: number,
onDeleteClick: () => void, onDragStart: (event: Event) => void, onDragEnd: (event: Event) => void): FontawesomeMarker {
const anchorElement = document.createElement("span") const anchorElement = document.createElement("span")
anchorElement.className = "cursor-pointer rounded-full w-6 h-6 border border-black text-center bg-background-inverse text-content-inverse" anchorElement.className = "route-anchor cursor-pointer rounded-full w-6 h-6 border border-black text-center bg-background-inverse text-content-inverse"
anchorElement.textContent = "" + index anchorElement.textContent = "" + index
const marker = new M.Marker( const marker = new M.Marker(
{ {
@@ -69,12 +90,14 @@ export function createAnchorMarker(lat: number, lon: number, index: number, onDe
const deleteButton = document.createElement("button"); const deleteButton = document.createElement("button");
deleteButton.className = "fa fa-trash text-red-500 rounded-full aspect-square h-8 text-lg"; deleteButton.className = "fa fa-trash text-red-500 rounded-full aspect-square h-8 text-lg";
deleteButton.addEventListener("click", onDeleteClick) deleteButton.addEventListener("click", onDeleteClick)
const popup = new M.Popup({}) const popup = new M.Popup({ closeButton: false })
popup.setDOMContent(deleteButton) popup.setDOMContent(deleteButton)
marker.setPopup(popup); marker.setPopup(popup);
marker.on("dragstart", onDragStart);
marker.on("dragend", onDragEnd); marker.on("dragend", onDragEnd);
marker.getElement().addEventListener("click", (e) => { marker.getElement().addEventListener("click", (e) => {
e.preventDefault()
e.stopPropagation(); e.stopPropagation();
marker.togglePopup(); marker.togglePopup();
}) })
@@ -86,8 +109,8 @@ export function createPopupFromTrail(trail: Trail) {
const thumbnail = trail.photos.length const thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0]) ? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
: get(theme) === "light" : get(theme) === "light"
? emptyStateTrailLight ? emptyStateTrailLight
: emptyStateTrailDark; : emptyStateTrailDark;
const popup = new M.Popup({ maxWidth: "320px" }); const popup = new M.Popup({ maxWidth: "320px" });
popup.setHTML( popup.setHTML(
`<a href="/map/trail/${trail.id}" data-sveltekit-preload-data="off"> `<a href="/map/trail/${trail.id}" data-sveltekit-preload-data="off">

View File

@@ -696,7 +696,7 @@ export class ElevationProfile {
wpDiv.style.top = `8px`; // Position horizontally wpDiv.style.top = `8px`; // Position horizontally
// Add custom HTML content (e.g., icon + label) // Add custom HTML content (e.g., icon + label)
wpDiv.innerHTML = `<div class="tooltip" data-title="${this.waypoints[index].name ?? "?"}"><i class="fa fa-${this.waypoints.at(index)?.icon ?? 'circle'}"></i></div>`; wpDiv.innerHTML = `<div class="tooltip" data-title="${this.waypoints[index]?.name ?? "?"}"><i class="fa fa-${this.waypoints.at(index)?.icon ?? 'circle'}"></i></div>`;
waypointContainer.appendChild(wpDiv); // Add to container waypointContainer.appendChild(wpDiv); // Add to container
}); });
@@ -864,7 +864,7 @@ export class ElevationProfile {
async setData(data: GeoJsonObject, waypoints?: Waypoint[]) { async setData(data: GeoJsonObject, waypoints?: Waypoint[]) {
// Concatenates the positions that may come from multiple LineStrings or MultiLineString // Concatenates the positions that may come from multiple LineStrings or MultiLineString
const { positions, times } = geoJsonObjectToPositionsAndTimes(data); const { positions, times } = geoJsonObjectToPositionsAndTimes(data);
this.times = times; this.times = times;
this.elevatedPositions = smoothElevations(positions, Math.ceil(positions.length / 100)); this.elevatedPositions = smoothElevations(positions, Math.ceil(positions.length / 100));
@@ -896,7 +896,7 @@ export class ElevationProfile {
this.cumulatedDPlus = []; this.cumulatedDPlus = [];
this.grade = []; this.grade = [];
this.waypoints = waypoints ?? []; this.waypoints = waypoints ?? [];
// this.waypointPositions = []; this.waypointPositions = [];
let cumulatedDPlus = 0; let cumulatedDPlus = 0;
let cumulatedTime = 0; let cumulatedTime = 0;

View File

@@ -53,8 +53,6 @@
: null; : null;
let selectedTrail: Trail | null = null; let selectedTrail: Trail | null = null;
let activeTrailIndex: number = -1;
let loading: boolean = false; let loading: boolean = false;
let loadingNextPage: boolean = false; let loadingNextPage: boolean = false;
let filterExpanded: boolean = false; let filterExpanded: boolean = false;
@@ -63,6 +61,12 @@
let userQuery = ""; let userQuery = "";
$: selectedTrailIndex = selectedTrail
? (selectedList?.expand?.trails?.indexOf(selectedTrail) ?? null)
: null;
$: selectedTrailWaypoints = selectedTrail?.expand?.waypoints;
onMount(() => { onMount(() => {
if ($page.url.searchParams.get("list") && selectedList) { if ($page.url.searchParams.get("list") && selectedList) {
setCurrentList(selectedList); setCurrentList(selectedList);
@@ -121,7 +125,6 @@
loadAllListsOnNextBack = false; loadAllListsOnNextBack = false;
} }
if (selectedTrail) { if (selectedTrail) {
mapWithElevation.unFocusTrail(selectedTrail);
selectedTrail = null; selectedTrail = null;
} else if (selectedList) { } else if (selectedList) {
selectedList = null; selectedList = null;
@@ -135,7 +138,6 @@
function selectTrail(trail: Trail) { function selectTrail(trail: Trail) {
selectedTrail = trail; selectedTrail = trail;
mapWithElevation.focusTrail(trail);
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
} }
@@ -353,14 +355,15 @@
<div id="trail-map" class:hidden={!showMap}> <div id="trail-map" class:hidden={!showMap}>
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={selectedList?.expand?.trails ?? []} trails={selectedList?.expand?.trails ?? []}
waypoints={selectedTrailWaypoints}
bind:map bind:map
bind:this={mapWithElevation} bind:this={mapWithElevation}
bind:markers bind:markers
activeTrail={selectedTrailIndex}
fitBounds="animate" fitBounds="animate"
on:select={(e) => { on:select={(e) => {
selectedTrail = e.detail; selectedTrail = e.detail;
}} }}
bind:activeTrail={activeTrailIndex}
showInfoPopup={true} showInfoPopup={true}
showTerrain={true} showTerrain={true}
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>

View File

@@ -19,6 +19,8 @@
<div id="trail-details" class="sticky top-[62px]"> <div id="trail-details" class="sticky top-[62px]">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={[$trail]} trails={[$trail]}
waypoints={$trail.expand?.waypoints}
activeTrail={0}
bind:markers bind:markers
showTerrain={true} showTerrain={true}
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>

View File

@@ -516,6 +516,8 @@
<div class="basis-full"> <div class="basis-full">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={[$trail]} trails={[$trail]}
waypoints={$trail.expand?.waypoints}
activeTrail={0}
on:zoom={(e) => updateScale(e.detail)} on:zoom={(e) => updateScale(e.detail)}
bind:map bind:map
{showGrid} {showGrid}

View File

@@ -80,7 +80,7 @@
const file = files[i]; const file = files[i];
try { try {
uploadProgress.set((progress += 100 / (files.length * 2))); uploadProgress.set((progress += 100 / (files.length * 2)));
await trails_upload(file); // await trails_upload(file);
} catch (e) { } catch (e) {
errorsThrown += 1; errorsThrown += 1;
show_toast({ show_toast({
@@ -98,14 +98,7 @@
uploading = false; uploading = false;
jsConfetti.addConfetti({ jsConfetti.addConfetti({
confettiRadius: 4, confettiRadius: 4,
confettiColors: [ emojis: ['🍃', '🍁'],
"#F4C842",
"#3C9D9B",
"#D1C4E9",
"#FF6F61",
"#A9D9C1",
"#F2F2F2",
],
}); });
}, 500); }, 500);

View File

@@ -37,11 +37,11 @@
} from "$lib/stores/trail_store.js"; } from "$lib/stores/trail_store.js";
import { import {
anchors, anchors,
appendToRoute,
calculateRouteBetween, calculateRouteBetween,
clearRoute, clearRoute,
deleteFromRoute, deleteFromRoute,
editRoute, editRoute,
insertIntoRoute,
route, route,
setRoute, setRoute,
} from "$lib/stores/valhalla_store"; } from "$lib/stores/valhalla_store";
@@ -54,6 +54,9 @@
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import { fromFile, gpx2trail } from "$lib/util/gpx_util"; import { fromFile, gpx2trail } from "$lib/util/gpx_util";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import { theme } from "$lib/stores/theme_store.js";
import { import {
createAnchorMarker, createAnchorMarker,
createMarkerFromWaypoint, createMarkerFromWaypoint,
@@ -67,9 +70,6 @@
import { backInOut } from "svelte/easing"; import { backInOut } from "svelte/easing";
import { scale } from "svelte/transition"; import { scale } from "svelte/transition";
import { z } from "zod"; import { z } from "zod";
import emptyStateTrailDark from "$lib/assets/svgs/empty_states/empty_state_trail_dark.svg";
import emptyStateTrailLight from "$lib/assets/svgs/empty_states/empty_state_trail_light.svg";
import { theme } from "$lib/stores/theme_store.js";
export let data; export let data;
@@ -94,6 +94,7 @@
let drawingActive = false; let drawingActive = false;
let overwriteGPX = false; let overwriteGPX = false;
let draggingMarker = false;
const ClientTrailCreateSchema = TrailCreateSchema.extend({ const ClientTrailCreateSchema = TrailCreateSchema.extend({
expand: z expand: z
@@ -171,7 +172,7 @@
?.trkpt?.at(0)?.$.lon; ?.trkpt?.at(0)?.$.lon;
} }
if (!form.id) { if ($page.params.id === "new") {
const createdTrail = await trails_create( const createdTrail = await trails_create(
form as Trail, form as Trail,
photoFiles, photoFiles,
@@ -213,6 +214,19 @@
if ($formData.expand!.gpx_data) { if ($formData.expand!.gpx_data) {
const gpx = await GPX.parse($formData.expand!.gpx_data); const gpx = await GPX.parse($formData.expand!.gpx_data);
if (!(gpx instanceof Error)) { if (!(gpx instanceof Error)) {
if (gpx.rte && !gpx.trk) {
gpx.trk = [
{
trkseg: [
{
trkpt: gpx.rte?.at(0)?.rtept,
},
],
},
];
gpx.rte = undefined;
}
setRoute(gpx); setRoute(gpx);
initRouteAnchors(gpx); initRouteAnchors(gpx);
} }
@@ -245,7 +259,7 @@
const prevId = $formData.id; const prevId = $formData.id;
const parseResult = await gpx2trail(gpxData, selectedFile.name); const parseResult = await gpx2trail(gpxData, selectedFile.name);
setFields(parseResult.trail); setFields(parseResult.trail);
$formData.id = prevId; $formData.id = prevId ?? cryptoRandomString({ length: 15 });
$formData.expand!.gpx_data = gpxData; $formData.expand!.gpx_data = gpxData;
setFields( setFields(
"category", "category",
@@ -273,12 +287,20 @@
$formData.expand!.summit_logs.push(log); $formData.expand!.summit_logs.push(log);
if (parseResult.gpx.rte?.length && !parseResult.gpx.trk) {
parseResult.gpx.trk = [
{
trkseg: [
{
trkpt: parseResult.gpx.rte?.at(0)?.rtept,
},
],
},
];
parseResult.gpx.rte = undefined;
}
setRoute(parseResult.gpx); setRoute(parseResult.gpx);
initRouteAnchors(parseResult.gpx); initRouteAnchors(parseResult.gpx);
for (const waypoint of $formData.expand!.waypoints) {
saveWaypoint(waypoint);
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -329,12 +351,18 @@
const points = segment.trkpt ?? []; const points = segment.trkpt ?? [];
if (points.length > 0) { if (points.length > 0) {
addAnchor(points[0].$.lat!, points[0].$.lon!, false); addAnchor(
points[0].$.lat!,
points[0].$.lon!,
anchors.length,
false,
);
} }
if (i == segments.length - 1) { if (i == segments.length - 1) {
addAnchor( addAnchor(
points[points.length - 1].$.lat!, points[points.length - 1].$.lat!,
points[points.length - 1].$.lon!, points[points.length - 1].$.lon!,
anchors.length,
false, false,
); );
} }
@@ -369,6 +397,7 @@
$formData.expand!.waypoints.splice(index, 1); $formData.expand!.waypoints.splice(index, 1);
$formData.waypoints.splice(index, 1); $formData.waypoints.splice(index, 1);
$formData.expand!.waypoints = $formData.expand!.waypoints; $formData.expand!.waypoints = $formData.expand!.waypoints;
// updateTrailOnMap();
} }
function saveWaypoint(savedWaypoint: Waypoint) { function saveWaypoint(savedWaypoint: Waypoint) {
@@ -377,7 +406,6 @@
); );
if (editedWaypointIndex >= 0) { if (editedWaypointIndex >= 0) {
$formData.expand!.waypoints[editedWaypointIndex].marker?.remove();
$formData.expand!.waypoints[editedWaypointIndex] = savedWaypoint; $formData.expand!.waypoints[editedWaypointIndex] = savedWaypoint;
} else { } else {
savedWaypoint.id = cryptoRandomString({ length: 15 }); savedWaypoint.id = cryptoRandomString({ length: 15 });
@@ -385,11 +413,9 @@
...$formData.expand!.waypoints, ...$formData.expand!.waypoints,
savedWaypoint, savedWaypoint,
]; ];
}
const marker = createMarkerFromWaypoint(savedWaypoint, moveMarker);
marker.addTo(map); // updateTrailOnMap();
savedWaypoint.marker = marker; }
} }
function moveMarker(marker: M.Marker, wpId?: string) { function moveMarker(marker: M.Marker, wpId?: string) {
@@ -405,6 +431,7 @@
editableWaypoint.lat = position.lat; editableWaypoint.lat = position.lat;
editableWaypoint.lon = position.lng; editableWaypoint.lon = position.lng;
$formData.expand!.waypoints = [...$formData.expand!.waypoints]; $formData.expand!.waypoints = [...$formData.expand!.waypoints];
// updateTrailOnMap();
} }
function beforeSummitLogModalOpen() { function beforeSummitLogModalOpen() {
@@ -493,7 +520,7 @@
} }
const anchorCount = anchors.length; const anchorCount = anchors.length;
if (anchorCount == 0) { if (anchorCount == 0) {
addAnchor(e.lngLat.lat, e.lngLat.lng); addAnchor(e.lngLat.lat, e.lngLat.lng, anchors.length);
} else { } else {
const previousAnchor = anchors[anchorCount - 1]; const previousAnchor = anchors[anchorCount - 1];
try { try {
@@ -505,8 +532,8 @@
selectedModeOfTransport, selectedModeOfTransport,
autoRouting, autoRouting,
); );
appendToRoute(routeWaypoints); insertIntoRoute(routeWaypoints);
addAnchor(e.lngLat.lat, e.lngLat.lng); addAnchor(e.lngLat.lat, e.lngLat.lng, anchors.length);
updateTrailWithRouteData(); updateTrailWithRouteData();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -519,7 +546,12 @@
} }
} }
function addAnchor(lat: number, lon: number, addtoMap: boolean = true) { function addAnchor(
lat: number,
lon: number,
index: number,
addtoMap: boolean = true,
) {
const anchor: ValhallaAnchor = { const anchor: ValhallaAnchor = {
id: cryptoRandomString({ length: 15 }), id: cryptoRandomString({ length: 15 }),
lat: lat, lat: lat,
@@ -528,10 +560,13 @@
const marker = createAnchorMarker( const marker = createAnchorMarker(
lat, lat,
lon, lon,
anchors.length + 1, index + 1,
() => { () => {
removeAnchor(anchors.findIndex((a) => a.id == anchor.id)); removeAnchor(anchors.findIndex((a) => a.id == anchor.id));
}, },
(e) => {
draggingMarker = true;
},
(_) => { (_) => {
if (!drawingActive) { if (!drawingActive) {
return; return;
@@ -540,13 +575,16 @@
anchor.lat = position.lat; anchor.lat = position.lat;
anchor.lon = position.lng; anchor.lon = position.lng;
recalculateRoute(anchors.findIndex((a) => a.id == anchor.id)); recalculateRoute(anchors.findIndex((a) => a.id == anchor.id));
draggingMarker = false;
}, },
); );
if (addtoMap) { if (addtoMap) {
marker.addTo(map); marker.addTo(map);
} }
anchor.marker = marker; anchor.marker = marker;
anchors.push(anchor); anchors.splice(index, 0, anchor);
return anchor;
} }
function removeAnchor(anchorIndex: number) { function removeAnchor(anchorIndex: number) {
@@ -583,21 +621,75 @@
} }
let nextRouteSegment; let nextRouteSegment;
let previousRouteSegment; let previousRouteSegment;
if (anchorIndex < anchors.length - 1) { try {
const nextAnchor = anchors[anchorIndex + 1]; if (anchorIndex < anchors.length - 1) {
const nextAnchor = anchors[anchorIndex + 1];
nextRouteSegment = await calculateRouteBetween( nextRouteSegment = await calculateRouteBetween(
anchor.lat, anchor.lat,
anchor.lon, anchor.lon,
nextAnchor.lat, nextAnchor.lat,
nextAnchor.lon, nextAnchor.lon,
selectedModeOfTransport, selectedModeOfTransport,
autoRouting, autoRouting,
); );
}
if (anchorIndex > 0) {
const previousAnchor = anchors[anchorIndex - 1];
previousRouteSegment = await calculateRouteBetween(
previousAnchor.lat,
previousAnchor.lon,
anchor.lat,
anchor.lon,
selectedModeOfTransport,
autoRouting,
);
}
if (nextRouteSegment) {
editRoute(anchorIndex, nextRouteSegment);
}
if (previousRouteSegment) {
editRoute(anchorIndex - 1, previousRouteSegment);
}
updateTrailWithRouteData();
} catch (e) {
console.error(e);
show_toast({
text: "Error calculating route",
icon: "close",
type: "error",
});
} }
if (anchorIndex > 0) { }
const previousAnchor = anchors[anchorIndex - 1];
previousRouteSegment = await calculateRouteBetween( async function handleSegmentDragEnd(data: {
segment: number;
event: M.MapMouseEvent;
}) {
if (draggingMarker) {
return;
}
const anchor = addAnchor(
data.event.lngLat.lat,
data.event.lngLat.lng,
data.segment + 1,
);
for (let i = data.segment + 2; i < anchors.length; i++) {
const anchor = anchors[i];
const markerIcon = anchor.marker?.getElement();
if (markerIcon) {
const markerText = markerIcon.textContent ?? "0";
const markerIndex = parseInt(markerText);
markerIcon.textContent = markerIndex + 1 + "";
}
}
const previousAnchor = anchors[data.segment];
const nextAnchor = anchors[data.segment + 2];
try {
const previousRouteSegment = await calculateRouteBetween(
previousAnchor.lat, previousAnchor.lat,
previousAnchor.lon, previousAnchor.lon,
anchor.lat, anchor.lat,
@@ -605,14 +697,26 @@
selectedModeOfTransport, selectedModeOfTransport,
autoRouting, autoRouting,
); );
const nextRouteSegment = await calculateRouteBetween(
anchor.lat,
anchor.lon,
nextAnchor.lat,
nextAnchor.lon,
selectedModeOfTransport,
autoRouting,
);
editRoute(data.segment, previousRouteSegment);
insertIntoRoute(nextRouteSegment, data.segment + 1);
updateTrailWithRouteData();
} catch (e) {
console.error(e);
show_toast({
text: "Error calculating route",
icon: "close",
type: "error",
});
} }
if (nextRouteSegment) {
editRoute(anchorIndex, nextRouteSegment);
}
if (previousRouteSegment) {
editRoute(anchorIndex - 1, previousRouteSegment);
}
updateTrailWithRouteData();
} }
function updateTrailWithRouteData() { function updateTrailWithRouteData() {
@@ -623,10 +727,13 @@
$formData.elevation_gain = totals.elevationGain; $formData.elevation_gain = totals.elevationGain;
$formData.elevation_loss = totals.elevationLoss; $formData.elevation_loss = totals.elevationLoss;
$formData.expand!.gpx_data = route.toString(); $formData.expand!.gpx_data = route.toString();
if (!$formData.id) {
$formData.id = cryptoRandomString({ length: 15 });
}
} }
function updateTrailOnMap() { function updateTrailOnMap() {
mapTrail = [{ ...($formData as Trail) }]; mapTrail = [$formData as Trail];
} }
</script> </script>
@@ -902,11 +1009,14 @@
<div id="trail-map"> <div id="trail-map">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={mapTrail} trails={mapTrail}
waypoints={$formData.expand?.waypoints}
drawing={drawingActive} drawing={drawingActive}
showTerrain={true} showTerrain={true}
onMarkerDragEnd={moveMarker} onMarkerDragEnd={moveMarker}
activeTrail={0}
bind:map bind:map
on:click={(e) => handleMapClick(e.detail)} on:click={(e) => handleMapClick(e.detail)}
on:segmentDragEnd={(e) => handleSegmentDragEnd(e.detail)}
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>
</div> </div>
</div> </div>