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()} /> +