Add optional route description field
This commit is contained in:
@@ -158,6 +158,7 @@ class PlanBody(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
waypoints: list[dict]
|
waypoints: list[dict]
|
||||||
profile: str
|
profile: str
|
||||||
|
description: str | None = None
|
||||||
geojson: dict | None = None
|
geojson: dict | None = None
|
||||||
gpxTrack: dict | None = None # full GeoJSON of reference track, if any GPX segments used
|
gpxTrack: dict | None = None # full GeoJSON of reference track, if any GPX segments used
|
||||||
gpxColorIdx: int | None = None
|
gpxColorIdx: int | None = None
|
||||||
@@ -201,6 +202,8 @@ async def create_plan(body: PlanBody, user: User = Depends(require_auth)) -> JSO
|
|||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
if body.description:
|
||||||
|
data["description"] = body.description
|
||||||
if body.collection_id:
|
if body.collection_id:
|
||||||
data["collection_id"] = body.collection_id
|
data["collection_id"] = body.collection_id
|
||||||
if body.dist_km is not None:
|
if body.dist_km is not None:
|
||||||
@@ -241,6 +244,11 @@ async def update_plan(
|
|||||||
"profile": body.profile,
|
"profile": body.profile,
|
||||||
"updated_at": int(time.time()),
|
"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
|
# collection_id: non-null assigns, null unassigns
|
||||||
if body.collection_id:
|
if body.collection_id:
|
||||||
data["collection_id"] = 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,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}
|
}
|
||||||
|
if body.description:
|
||||||
|
data["description"] = body.description
|
||||||
if body.dist_km is not None:
|
if body.dist_km is not None:
|
||||||
data["dist_km"] = body.dist_km
|
data["dist_km"] = body.dist_km
|
||||||
if body.elevation_gain is not None:
|
if body.elevation_gain is not None:
|
||||||
@@ -399,6 +409,10 @@ async def update_shared(
|
|||||||
"profile": body.profile,
|
"profile": body.profile,
|
||||||
"updated_at": int(time.time()),
|
"updated_at": int(time.time()),
|
||||||
})
|
})
|
||||||
|
if body.description:
|
||||||
|
data["description"] = body.description
|
||||||
|
else:
|
||||||
|
data.pop("description", None)
|
||||||
if body.dist_km is not None:
|
if body.dist_km is not None:
|
||||||
data["dist_km"] = body.dist_km
|
data["dist_km"] = body.dist_km
|
||||||
if body.elevation_gain is not None:
|
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 activePlanId = $state(null); // id of currently loaded plan (null = unsaved)
|
||||||
let savePanel = $state(false); // show save form
|
let savePanel = $state(false); // show save form
|
||||||
let saveName = $state('');
|
let saveName = $state('');
|
||||||
|
let saveDesc = $state('');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
// Collections
|
// Collections
|
||||||
@@ -223,7 +224,10 @@
|
|||||||
// ── Waypoint management ────────────────────────────────────────────────────
|
// ── Waypoint management ────────────────────────────────────────────────────
|
||||||
function onMapClick(e) {
|
function onMapClick(e) {
|
||||||
if (hoveringGPX && gpxTrack) {
|
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;
|
activePlanId = null;
|
||||||
addWaypoint(snapped.lng, snapped.lat, { gpxSnapped: true, gpxFrac: snapped.frac });
|
addWaypoint(snapped.lng, snapped.lat, { gpxSnapped: true, gpxFrac: snapped.frac });
|
||||||
return;
|
return;
|
||||||
@@ -270,7 +274,10 @@
|
|||||||
if (gpxFeats.length > 0) {
|
if (gpxFeats.length > 0) {
|
||||||
hoveringGPX = true;
|
hoveringGPX = true;
|
||||||
map.getCanvas().style.cursor = 'crosshair';
|
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.borderColor = gpxColor;
|
||||||
gpxSnapEl.style.background = gpxColor + '40';
|
gpxSnapEl.style.background = gpxColor + '40';
|
||||||
gpxSnapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map);
|
gpxSnapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map);
|
||||||
@@ -297,6 +304,23 @@
|
|||||||
return { lng: bestLng, lat: bestLat, frac: bestFrac };
|
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) {
|
function insertionIndex(clickFrac, coords) {
|
||||||
const fracs = waypoints.map(wp => nearestOnLine(coords, [wp.lng, wp.lat]).frac);
|
const fracs = waypoints.map(wp => nearestOnLine(coords, [wp.lng, wp.lat]).frac);
|
||||||
for (let i = 0; i < fracs.length - 1; i++) {
|
for (let i = 0; i < fracs.length - 1; i++) {
|
||||||
@@ -473,9 +497,11 @@
|
|||||||
// Resolve each run (Brouter calls run in parallel)
|
// Resolve each run (Brouter calls run in parallel)
|
||||||
const results = await Promise.all(runs.map(async run => {
|
const results = await Promise.all(runs.map(async run => {
|
||||||
if (run.type === 'gpx') {
|
if (run.type === 'gpx') {
|
||||||
const snapA = nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.from].lng, waypoints[run.from].lat]);
|
const fracA = waypoints[run.from].gpxFrac
|
||||||
const snapB = nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.to].lng, waypoints[run.to].lat]);
|
?? nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.from].lng, waypoints[run.from].lat]).frac;
|
||||||
return { type: 'gpx', coords: sliceGPXCoords(gpxTrack.geometry.coordinates, snapA.frac, snapB.frac), props: {} };
|
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 {
|
} else {
|
||||||
const wps = waypoints.slice(run.from, run.to + 1);
|
const wps = waypoints.slice(run.from, run.to + 1);
|
||||||
const lonlats = wps.map(wp => `${wp.lng.toFixed(6)},${wp.lat.toFixed(6)}`).join('|');
|
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 hasGPXSegs = gpxTrack && waypoints.some(wp => wp.gpxSnapped);
|
||||||
const body = {
|
const body = {
|
||||||
name: saveName.trim(),
|
name: saveName.trim(),
|
||||||
|
description: saveDesc.trim() || null,
|
||||||
waypoints: waypoints.map(({ lng, lat, gpxSnapped, gpxFrac }) => ({ lng, lat, gpxSnapped: gpxSnapped ?? false, gpxFrac: gpxFrac ?? null })),
|
waypoints: waypoints.map(({ lng, lat, gpxSnapped, gpxFrac }) => ({ lng, lat, gpxSnapped: gpxSnapped ?? false, gpxFrac: gpxFrac ?? null })),
|
||||||
profile,
|
profile,
|
||||||
geojson: route ?? undefined,
|
geojson: route ?? undefined,
|
||||||
@@ -662,6 +689,7 @@ ${trkpts}
|
|||||||
profile = plan.profile ?? 'gravel';
|
profile = plan.profile ?? 'gravel';
|
||||||
activePlanId = plan.id;
|
activePlanId = plan.id;
|
||||||
saveName = plan.name;
|
saveName = plan.name;
|
||||||
|
saveDesc = plan.description ?? '';
|
||||||
saveCollId = plan.collection_id ?? '';
|
saveCollId = plan.collection_id ?? '';
|
||||||
saveNewColName = '';
|
saveNewColName = '';
|
||||||
const r = await fetch(`${API}/plans/${plan.id}`, { credentials: 'include' });
|
const r = await fetch(`${API}/plans/${plan.id}`, { credentials: 'include' });
|
||||||
@@ -720,6 +748,7 @@ ${trkpts}
|
|||||||
clearAll();
|
clearAll();
|
||||||
profile = plan.profile ?? 'gravel';
|
profile = plan.profile ?? 'gravel';
|
||||||
saveName = plan.name;
|
saveName = plan.name;
|
||||||
|
saveDesc = plan.description ?? '';
|
||||||
|
|
||||||
if (asCopy) {
|
if (asCopy) {
|
||||||
activePlanId = null;
|
activePlanId = null;
|
||||||
@@ -776,6 +805,7 @@ ${trkpts}
|
|||||||
activePlanShared = false;
|
activePlanShared = false;
|
||||||
saveCollId = '';
|
saveCollId = '';
|
||||||
saveName = '';
|
saveName = '';
|
||||||
|
saveDesc = '';
|
||||||
savePanel = false;
|
savePanel = false;
|
||||||
saveNewColName = '';
|
saveNewColName = '';
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1142,12 @@ ${trkpts}
|
|||||||
bind:value={saveName}
|
bind:value={saveName}
|
||||||
onkeydown={(e) => e.key === 'Enter' && saveCollId !== '__new__' && savePlan()}
|
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}>
|
<select class="save-select" bind:value={saveCollId}>
|
||||||
<optgroup label="Personal">
|
<optgroup label="Personal">
|
||||||
<option value="">No collection</option>
|
<option value="">No collection</option>
|
||||||
@@ -1492,6 +1528,7 @@ ${trkpts}
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.save-input:focus { outline: none; border-color: var(--accent); }
|
.save-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.save-desc { resize: vertical; font-family: inherit; line-height: 1.4; }
|
||||||
.full-width { width: 100%; }
|
.full-width { width: 100%; }
|
||||||
|
|
||||||
.gpx-label { display: block; text-align: center; cursor: pointer; }
|
.gpx-label { display: block; text-align: center; cursor: pointer; }
|
||||||
|
|||||||
Reference in New Issue
Block a user