add gpx editing
This commit is contained in:
parent
da5a068224
commit
b8ee4b4cc0
5 changed files with 173 additions and 16 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1590,7 +1590,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Cargo.toml
|
# Cargo.toml
|
||||||
[package]
|
[package]
|
||||||
name = "rs_maps"
|
name = "rs_maps"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const state = {
|
||||||
colour: DEFAULT_COLOUR,
|
colour: DEFAULT_COLOUR,
|
||||||
searchPin: null, // transient pin for the current search hit
|
searchPin: null, // transient pin for the current search hit
|
||||||
draw: { active: false, points: [], markers: [], line: null, guide: null,
|
draw: { active: false, points: [], markers: [], line: null, guide: null,
|
||||||
timer: null, seq: 0, snapped: null },
|
timer: null, seq: 0, snapped: null, editing: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ───────────────────────────── api ───────────────────────────── */
|
/* ───────────────────────────── api ───────────────────────────── */
|
||||||
|
|
@ -479,7 +479,7 @@ function drawReset() {
|
||||||
if (state.draw.line) state.map.removeLayer(state.draw.line);
|
if (state.draw.line) state.map.removeLayer(state.draw.line);
|
||||||
if (state.draw.guide) state.map.removeLayer(state.draw.guide);
|
if (state.draw.guide) state.map.removeLayer(state.draw.guide);
|
||||||
state.draw = { active: false, points: [], markers: [], line: null, guide: null,
|
state.draw = { active: false, points: [], markers: [], line: null, guide: null,
|
||||||
timer: null, seq: 0, snapped: null };
|
timer: null, seq: 0, snapped: null, editing: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSetActive(on) {
|
function drawSetActive(on) {
|
||||||
|
|
@ -489,6 +489,7 @@ function drawSetActive(on) {
|
||||||
$('#draw-panel').hidden = !on;
|
$('#draw-panel').hidden = !on;
|
||||||
$('#draw-start').textContent = on ? 'Drawing…' : 'New route';
|
$('#draw-start').textContent = on ? 'Drawing…' : 'New route';
|
||||||
$('#draw-start').disabled = on;
|
$('#draw-start').disabled = on;
|
||||||
|
if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking';
|
||||||
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
|
state.map.getContainer().style.cursor = on ? 'crosshair' : '';
|
||||||
if (on) drawUpdate();
|
if (on) drawUpdate();
|
||||||
}
|
}
|
||||||
|
|
@ -624,7 +625,10 @@ $('#draw-start').addEventListener('click', () => {
|
||||||
toast('Click the map to add waypoints');
|
toast('Click the map to add waypoints');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#draw-cancel').addEventListener('click', () => drawSetActive(false));
|
$('#draw-cancel').addEventListener('click', () => {
|
||||||
|
if (state.draw.editing && !confirm('Discard changes to this route?')) return;
|
||||||
|
drawSetActive(false);
|
||||||
|
});
|
||||||
$('#draw-undo').addEventListener('click', undoWaypoint);
|
$('#draw-undo').addEventListener('click', undoWaypoint);
|
||||||
|
|
||||||
$('#draw-save').addEventListener('click', async () => {
|
$('#draw-save').addEventListener('click', async () => {
|
||||||
|
|
@ -642,10 +646,18 @@ $('#draw-save').addEventListener('click', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
name,
|
name,
|
||||||
waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })),
|
waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })),
|
||||||
|
// The backend replaces in place only when this matches the new name.
|
||||||
|
// Rename the file and the original is left alone, as a copy.
|
||||||
|
replace: state.draw.editing || null,
|
||||||
};
|
};
|
||||||
const result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
|
const result = await api('api/routes/calculate', { method: 'POST', ...json(body) });
|
||||||
|
|
||||||
toast(`Saved as ${result.filename}`);
|
const renamed = state.draw.editing && result.filename !== state.draw.editing;
|
||||||
|
toast(
|
||||||
|
!state.draw.editing ? `Saved as ${result.filename}`
|
||||||
|
: renamed ? `Saved as ${result.filename} — ${state.draw.editing} kept`
|
||||||
|
: `${result.filename} updated`
|
||||||
|
);
|
||||||
drawSetActive(false);
|
drawSetActive(false);
|
||||||
await loadGpxList();
|
await loadGpxList();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -1052,10 +1064,56 @@ async function loadGpxList() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
li.appendChild(show);
|
li.appendChild(show);
|
||||||
|
|
||||||
|
const edit = document.createElement('button');
|
||||||
|
edit.className = 'link';
|
||||||
|
edit.textContent = 'Edit';
|
||||||
|
edit.addEventListener('click', () => editRoute(f.name));
|
||||||
|
li.appendChild(edit);
|
||||||
|
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reopens a saved route for editing. Only routes created here can be edited:
|
||||||
|
they carry their original <wpt> waypoints alongside the dense snapped track.
|
||||||
|
A GPX recorded on a device has no such waypoints, and a few thousand track
|
||||||
|
points can't be reduced back to the handful someone actually clicked. */
|
||||||
|
async function editRoute(filename) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/gpx/file/' + encodeURIComponent(filename), {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Could not read that file.');
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(await res.text(), 'application/xml');
|
||||||
|
const wpts = [...doc.getElementsByTagName('wpt')]
|
||||||
|
.map((w) => ({
|
||||||
|
lat: parseFloat(w.getAttribute('lat')),
|
||||||
|
lng: parseFloat(w.getAttribute('lon')),
|
||||||
|
}))
|
||||||
|
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng));
|
||||||
|
|
||||||
|
if (wpts.length < 2) {
|
||||||
|
toast('That file has no editable waypoints — only routes drawn here can be edited', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSetActive(true);
|
||||||
|
state.draw.editing = filename;
|
||||||
|
// Strip the extension: it's re-added on save, and showing it invites
|
||||||
|
// someone to delete it by accident.
|
||||||
|
$('#draw-name').value = filename.replace(/\.gpx$/i, '');
|
||||||
|
$('#draw-hint').textContent = `Editing ${filename}`;
|
||||||
|
|
||||||
|
wpts.forEach((p) => addWaypoint(L.latLng(p.lat, p.lng)));
|
||||||
|
state.map.fitBounds(L.latLngBounds(wpts.map((p) => [p.lat, p.lng])), { padding: [40, 40] });
|
||||||
|
toast('Drag points to adjust, then save');
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message || 'Could not open that route', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$('#gpx-local').addEventListener('change', async (e) => {
|
$('#gpx-local').addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
|
||||||
33
src/gpx.rs
33
src/gpx.rs
|
|
@ -107,6 +107,39 @@ pub fn reserve(dir: &StdPath, name: &str) -> AppResult<(std::fs::File, String)>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces an existing file's contents atomically: write a sibling temp file,
|
||||||
|
/// then rename over the target. A reader that opens the file mid-save sees
|
||||||
|
/// either the old contents or the new, never a partial write.
|
||||||
|
pub async fn overwrite(dir: &StdPath, name: &str, bytes: &[u8]) -> AppResult<()> {
|
||||||
|
validate_filename(name)?;
|
||||||
|
|
||||||
|
// Must exist already — this path is for editing, not creating.
|
||||||
|
let target = resolve_existing(dir, name)?;
|
||||||
|
|
||||||
|
// Same directory, so the rename stays on one filesystem and is atomic.
|
||||||
|
let temp = dir.join(format!(".{name}.tmp"));
|
||||||
|
|
||||||
|
let write = async {
|
||||||
|
let mut file = tokio::fs::File::create(&temp).await?;
|
||||||
|
file.write_all(bytes).await?;
|
||||||
|
file.flush().await?;
|
||||||
|
// Durable before the rename, so a crash can't leave an empty file in place.
|
||||||
|
file.sync_all().await?;
|
||||||
|
Ok::<_, std::io::Error>(())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = write.await {
|
||||||
|
let _ = tokio::fs::remove_file(&temp).await;
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = tokio::fs::rename(&temp, &target).await {
|
||||||
|
let _ = tokio::fs::remove_file(&temp).await;
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- handlers
|
// ---------------------------------------------------------------- handlers
|
||||||
|
|
||||||
async fn list(
|
async fn list(
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ pub struct CalculateRequest {
|
||||||
pub waypoints: Vec<Waypoint>,
|
pub waypoints: Vec<Waypoint>,
|
||||||
/// Desired filename. ".gpx" is appended if absent.
|
/// Desired filename. ".gpx" is appended if absent.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// When editing: the file this route came from. If it matches the target
|
||||||
|
/// name the file is replaced in place; if the user renamed it, a new file is
|
||||||
|
/// written and the original left alone.
|
||||||
|
#[serde(default)]
|
||||||
|
pub replace: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -94,6 +99,38 @@ fn lonlats(waypoints: &[Waypoint]) -> String {
|
||||||
.join("|")
|
.join("|")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stored GPX carries the clicked waypoints as `<wpt>` elements alongside
|
||||||
|
/// the dense snapped `<trk>`. Without them a saved route can't be reopened for
|
||||||
|
/// editing — several thousand track points can't be reduced back to the handful
|
||||||
|
/// of points the user actually placed. `<wpt>` is standard GPX, so other tools
|
||||||
|
/// simply show them as markers.
|
||||||
|
fn embed_waypoints(gpx: &str, waypoints: &[Waypoint]) -> String {
|
||||||
|
let block: String = waypoints
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
format!(
|
||||||
|
" <wpt lat=\"{:.6}\" lon=\"{:.6}\"><type>rs_maps:via</type></wpt>\n",
|
||||||
|
p.lat, p.lon
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Insert immediately after the opening <gpx …> tag; the schema requires
|
||||||
|
// wpt elements to precede trk.
|
||||||
|
match gpx.find("<gpx").and_then(|start| gpx[start..].find('>').map(|o| start + o + 1)) {
|
||||||
|
Some(after_open) => {
|
||||||
|
let mut out = String::with_capacity(gpx.len() + block.len() + 1);
|
||||||
|
out.push_str(&gpx[..after_open]);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(&block);
|
||||||
|
out.push_str(&gpx[after_open..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
// Shouldn't happen — looks_like_gpx already ran — but never lose the route.
|
||||||
|
None => gpx.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cheap sanity check on the response body. BRouter reports routing failures
|
/// Cheap sanity check on the response body. BRouter reports routing failures
|
||||||
/// ("operation not supported", "position not mapped in existing datafile") as
|
/// ("operation not supported", "position not mapped in existing datafile") as
|
||||||
/// 200 with a plain-text body, so status alone isn't enough.
|
/// 200 with a plain-text body, so status alone isn't enough.
|
||||||
|
|
@ -239,17 +276,35 @@ async fn calculate(
|
||||||
return Err(routing_failure(&body));
|
return Err(routing_failure(&body));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same atomic create_new reservation the upload path uses, so a name clash
|
let body = embed_waypoints(&body, &request.waypoints);
|
||||||
// becomes route-1.gpx rather than silently overwriting.
|
|
||||||
let (file, written_name) = gpx::reserve(&state.config.gpx_dir, &filename)?;
|
|
||||||
let mut file = tokio::fs::File::from_std(file);
|
|
||||||
|
|
||||||
if let Err(e) = file.write_all(body.as_bytes()).await {
|
// Replacing in place only when the user kept the name. A rename leaves the
|
||||||
// Don't leave a zero-byte stub behind on a failed write.
|
// original untouched and takes the normal collision-avoiding path.
|
||||||
let _ = tokio::fs::remove_file(state.config.gpx_dir.join(&written_name)).await;
|
let replacing = request
|
||||||
return Err(e.into());
|
.replace
|
||||||
}
|
.as_deref()
|
||||||
file.flush().await?;
|
.map(str::trim)
|
||||||
|
.filter(|original| !original.is_empty())
|
||||||
|
.filter(|original| original.eq_ignore_ascii_case(&filename));
|
||||||
|
|
||||||
|
let written_name = if let Some(original) = replacing {
|
||||||
|
gpx::validate_filename(original)?;
|
||||||
|
gpx::overwrite(&state.config.gpx_dir, original, body.as_bytes()).await?;
|
||||||
|
original.to_string()
|
||||||
|
} else {
|
||||||
|
// Same atomic create_new reservation the upload path uses, so a name
|
||||||
|
// clash becomes route-1.gpx rather than silently overwriting.
|
||||||
|
let (file, written_name) = gpx::reserve(&state.config.gpx_dir, &filename)?;
|
||||||
|
let mut file = tokio::fs::File::from_std(file);
|
||||||
|
|
||||||
|
if let Err(e) = file.write_all(body.as_bytes()).await {
|
||||||
|
// Don't leave a zero-byte stub behind on a failed write.
|
||||||
|
let _ = tokio::fs::remove_file(state.config.gpx_dir.join(&written_name)).await;
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
file.flush().await?;
|
||||||
|
written_name
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(file = %written_name, points = request.waypoints.len(), "route saved");
|
tracing::info!(file = %written_name, points = request.waypoints.len(), "route saved");
|
||||||
|
|
||||||
|
|
@ -298,6 +353,17 @@ mod tests {
|
||||||
assert!(validate(&request).is_err());
|
assert!(validate(&request).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn waypoints_are_embedded_before_the_track() {
|
||||||
|
let gpx = r#"<?xml version="1.0"?><gpx version="1.1"><trk><name>x</name></trk></gpx>"#;
|
||||||
|
let out = embed_waypoints(gpx, &[wp(53.5, -2.2), wp(53.6, -2.3)]);
|
||||||
|
|
||||||
|
assert_eq!(out.matches("<wpt").count(), 2);
|
||||||
|
// Schema requires wpt before trk.
|
||||||
|
assert!(out.find("<wpt").unwrap() < out.find("<trk").unwrap());
|
||||||
|
assert!(out.contains(r#"lat="53.500000""#));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_geojson_and_flips_coordinate_order() {
|
fn parses_geojson_and_flips_coordinate_order() {
|
||||||
let body = r#"{"features":[{"geometry":{"coordinates":
|
let body = r#"{"features":[{"geometry":{"coordinates":
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue