diff --git a/server/server.py b/server/server.py
index 01fda5c..c0faa79 100644
--- a/server/server.py
+++ b/server/server.py
@@ -158,6 +158,7 @@ class PlanBody(BaseModel):
name: str
waypoints: list[dict]
profile: str
+ description: str | None = None
geojson: dict | None = None
gpxTrack: dict | None = None # full GeoJSON of reference track, if any GPX segments used
gpxColorIdx: int | None = None
@@ -201,6 +202,8 @@ async def create_plan(body: PlanBody, user: User = Depends(require_auth)) -> JSO
"created_at": now,
"updated_at": now,
}
+ if body.description:
+ data["description"] = body.description
if body.collection_id:
data["collection_id"] = body.collection_id
if body.dist_km is not None:
@@ -241,6 +244,11 @@ async def update_plan(
"profile": body.profile,
"updated_at": int(time.time()),
})
+ # description: non-null sets, null clears
+ if body.description:
+ data["description"] = body.description
+ else:
+ data.pop("description", None)
# collection_id: non-null assigns, null unassigns
if body.collection_id:
data["collection_id"] = body.collection_id
@@ -363,6 +371,8 @@ async def create_shared(body: PlanBody, user: User = Depends(require_auth)) -> J
"created_at": now,
"updated_at": now,
}
+ if body.description:
+ data["description"] = body.description
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
@@ -399,6 +409,10 @@ async def update_shared(
"profile": body.profile,
"updated_at": int(time.time()),
})
+ if body.description:
+ data["description"] = body.description
+ else:
+ data.pop("description", None)
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
diff --git a/src/Planner.svelte b/src/Planner.svelte
index d06a302..2bd1b2d 100644
--- a/src/Planner.svelte
+++ b/src/Planner.svelte
@@ -22,6 +22,7 @@
let activePlanId = $state(null); // id of currently loaded plan (null = unsaved)
let savePanel = $state(false); // show save form
let saveName = $state('');
+ let saveDesc = $state('');
let saving = $state(false);
// Collections
@@ -223,7 +224,10 @@
// ── Waypoint management ────────────────────────────────────────────────────
function onMapClick(e) {
if (hoveringGPX && gpxTrack) {
- const snapped = nearestOnLine(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]);
+ const prev = lastGpxFrac();
+ const snapped = prev != null
+ ? nearestOnLineFrom(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat], prev)
+ : nearestOnLine(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]);
activePlanId = null;
addWaypoint(snapped.lng, snapped.lat, { gpxSnapped: true, gpxFrac: snapped.frac });
return;
@@ -270,7 +274,10 @@
if (gpxFeats.length > 0) {
hoveringGPX = true;
map.getCanvas().style.cursor = 'crosshair';
- const snapped = nearestOnLine(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]);
+ const prev = lastGpxFrac();
+ const snapped = prev != null
+ ? nearestOnLineFrom(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat], prev)
+ : nearestOnLine(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]);
gpxSnapEl.style.borderColor = gpxColor;
gpxSnapEl.style.background = gpxColor + '40';
gpxSnapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map);
@@ -297,6 +304,23 @@
return { lng: bestLng, lat: bestLat, frac: bestFrac };
}
+ // Like nearestOnLine but only searches from fromFrac onwards, so the result
+ // is always forward along the track relative to the previous gpxSnapped waypoint.
+ function nearestOnLineFrom(coords, point, fromFrac) {
+ const start = Math.max(0, Math.floor(fromFrac));
+ const sub = coords.slice(start);
+ const result = nearestOnLine(sub, point);
+ return { ...result, frac: result.frac + start };
+ }
+
+ // Returns the gpxFrac of the last gpxSnapped waypoint, or null if none.
+ function lastGpxFrac() {
+ for (let i = waypoints.length - 1; i >= 0; i--) {
+ if (waypoints[i].gpxSnapped && waypoints[i].gpxFrac != null) return waypoints[i].gpxFrac;
+ }
+ return null;
+ }
+
function insertionIndex(clickFrac, coords) {
const fracs = waypoints.map(wp => nearestOnLine(coords, [wp.lng, wp.lat]).frac);
for (let i = 0; i < fracs.length - 1; i++) {
@@ -473,9 +497,11 @@
// Resolve each run (Brouter calls run in parallel)
const results = await Promise.all(runs.map(async run => {
if (run.type === 'gpx') {
- const snapA = nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.from].lng, waypoints[run.from].lat]);
- const snapB = nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.to].lng, waypoints[run.to].lat]);
- return { type: 'gpx', coords: sliceGPXCoords(gpxTrack.geometry.coordinates, snapA.frac, snapB.frac), props: {} };
+ const fracA = waypoints[run.from].gpxFrac
+ ?? nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.from].lng, waypoints[run.from].lat]).frac;
+ const fracB = waypoints[run.to].gpxFrac
+ ?? nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.to].lng, waypoints[run.to].lat]).frac;
+ return { type: 'gpx', coords: sliceGPXCoords(gpxTrack.geometry.coordinates, fracA, fracB), props: {} };
} else {
const wps = waypoints.slice(run.from, run.to + 1);
const lonlats = wps.map(wp => `${wp.lng.toFixed(6)},${wp.lat.toFixed(6)}`).join('|');
@@ -604,6 +630,7 @@ ${trkpts}
const hasGPXSegs = gpxTrack && waypoints.some(wp => wp.gpxSnapped);
const body = {
name: saveName.trim(),
+ description: saveDesc.trim() || null,
waypoints: waypoints.map(({ lng, lat, gpxSnapped, gpxFrac }) => ({ lng, lat, gpxSnapped: gpxSnapped ?? false, gpxFrac: gpxFrac ?? null })),
profile,
geojson: route ?? undefined,
@@ -662,6 +689,7 @@ ${trkpts}
profile = plan.profile ?? 'gravel';
activePlanId = plan.id;
saveName = plan.name;
+ saveDesc = plan.description ?? '';
saveCollId = plan.collection_id ?? '';
saveNewColName = '';
const r = await fetch(`${API}/plans/${plan.id}`, { credentials: 'include' });
@@ -720,6 +748,7 @@ ${trkpts}
clearAll();
profile = plan.profile ?? 'gravel';
saveName = plan.name;
+ saveDesc = plan.description ?? '';
if (asCopy) {
activePlanId = null;
@@ -776,6 +805,7 @@ ${trkpts}
activePlanShared = false;
saveCollId = '';
saveName = '';
+ saveDesc = '';
savePanel = false;
saveNewColName = '';
}
@@ -1112,6 +1142,12 @@ ${trkpts}
bind:value={saveName}
onkeydown={(e) => e.key === 'Enter' && saveCollId !== '__new__' && savePlan()}
/>
+