Add optional route description field
This commit is contained in:
@@ -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:
|
||||
|
||||
+42
-5
@@ -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()}
|
||||
/>
|
||||
<textarea
|
||||
class="save-input save-desc"
|
||||
placeholder="Description (optional)…"
|
||||
rows="3"
|
||||
bind:value={saveDesc}
|
||||
></textarea>
|
||||
<select class="save-select" bind:value={saveCollId}>
|
||||
<optgroup label="Personal">
|
||||
<option value="">No collection</option>
|
||||
@@ -1492,6 +1528,7 @@ ${trkpts}
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.save-input:focus { outline: none; border-color: var(--accent); }
|
||||
.save-desc { resize: vertical; font-family: inherit; line-height: 1.4; }
|
||||
.full-width { width: 100%; }
|
||||
|
||||
.gpx-label { display: block; text-align: center; cursor: pointer; }
|
||||
|
||||
Reference in New Issue
Block a user