Add running route planner with quality comparison and GPX export
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.planner-controls{display:grid;grid-template-columns:1.25fr 1fr 1fr .9fr;gap:24px;padding:24px}.planner-controls label,#save-route-form label{display:block;margin:12px 0 7px;font-size:13px}.planner-controls input:not([type=checkbox]),.planner-controls select,#route-name{width:100%;padding:11px;border:1px solid var(--line);border-radius:5px;background:var(--paper);color:var(--ink);font:inherit}.planner-controls input[type=checkbox]{width:auto;accent-color:var(--green)}.planner-controls .text-button{display:block;margin-top:8px}.planner-submit{display:flex;flex-direction:column;justify-content:flex-end;gap:14px}.planner-check{display:flex!important;align-items:center;gap:8px}.planner-controls [hidden],#route-planner [hidden]{display:none!important}#planner-status{margin:18px 0;min-height:24px}#planner-map{height:420px;z-index:0;border-radius:8px;background:var(--line)}.planner-table-wrap{overflow:auto;margin:24px 0}.planner-table{width:100%;border-collapse:collapse;text-align:left}.planner-table th{font-size:12px;color:var(--muted);font-weight:400}.planner-table td,.planner-table th{padding:15px 12px;border-bottom:1px solid var(--line);white-space:nowrap}.planner-table tr.selected{background:var(--white)}.planner-detail{display:grid;grid-template-columns:1fr 1fr;gap:30px;padding:24px}.planner-profile{height:180px;margin-top:20px}.planner-detail dl{margin:18px 0}.planner-detail dl div{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}.planner-detail dt{color:var(--muted)}.planner-detail dd{margin:0}.planner-save-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.planner-library{padding:24px;margin-top:24px}#saved-routes{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px}#planner-results button[aria-pressed=true]{border-color:var(--green)}#generate-routes:disabled{opacity:.5;cursor:wait}@media(max-width:950px){.planner-controls{grid-template-columns:1fr 1fr}}@media(max-width:680px){.planner-controls,.planner-detail{grid-template-columns:1fr;padding:18px;gap:16px}#planner-map{height:330px}.planner-controls input,.planner-controls select,#route-name{font-size:16px}}
|
||||
|
||||
.planner-controls,.planner-detail,.planner-library{background:var(--white);border:1px solid var(--line);border-radius:var(--radius)}.planner-table tr.selected{background:var(--white)}.planner-library .button{max-width:100%;white-space:normal;overflow-wrap:anywhere}.app-header .stats-nav{flex-shrink:0;color:var(--muted);font-size:13px}.app-header .stats-nav.active{color:var(--green)}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
const root = document.getElementById("route-planner");
|
||||
if (!root || !window.L) return;
|
||||
const el = id => document.getElementById(id);
|
||||
const base = root.dataset.base;
|
||||
const status = el("planner-status");
|
||||
const map = L.map("planner-map").setView([-33.8688, 151.2093], 12);
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
|
||||
const routeLayer = L.layerGroup().addTo(map);
|
||||
const markers = {};
|
||||
let picking = "start", routes = [], selected = null, chart = null;
|
||||
let generation = 0, busy = false;
|
||||
const colour = "#ff9268";
|
||||
const formatPct = value => value == null ? "Unknown" : `${value.toFixed(1)}%`;
|
||||
const formatAscent = value => value == null ? "Unknown" : `${Math.round(value)} m`;
|
||||
function coordinate(id) {
|
||||
const parts = el(id).value.split(",").map(v => v.trim());
|
||||
if (parts.length !== 2 || parts.some(v => v === "")) throw new Error("Enter latitude, longitude or select a point on the map.");
|
||||
const [lat, lon] = parts.map(Number);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 85 || Math.abs(lon) > 180) throw new Error("Coordinates are outside the supported range.");
|
||||
return [lon, lat];
|
||||
}
|
||||
function mark(kind, latlng) {
|
||||
if (markers[kind]) map.removeLayer(markers[kind]);
|
||||
markers[kind] = L.marker(latlng).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish");
|
||||
el(`plan-${kind}`).value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
|
||||
}
|
||||
function setMode() {
|
||||
const point = el("plan-mode").value === "point";
|
||||
el("finish-fields").hidden = !point; el("plan-end").required = point;
|
||||
if (!point) { picking = "start"; if (markers.end) {map.removeLayer(markers.end); delete markers.end;} }
|
||||
}
|
||||
el("plan-mode").onchange = setMode;
|
||||
el("pick-start").onclick = () => {picking = "start"; status.textContent = "Click the map to set your start.";};
|
||||
el("pick-end").onclick = () => {picking = "end"; status.textContent = "Click the map to set your finish.";};
|
||||
map.on("click", e => {mark(picking, e.latlng); status.textContent = `${picking === "start" ? "Start" : "Finish"} selected.`;
|
||||
if (picking === "start" && el("plan-mode").value === "point") picking = "end";});
|
||||
el("my-location").onclick = () => {
|
||||
if (!navigator.geolocation) {status.textContent = "Location is unavailable. Select a point on the map."; return;}
|
||||
navigator.geolocation.getCurrentPosition(position => {
|
||||
const p = L.latLng(position.coords.latitude, position.coords.longitude);
|
||||
mark("start", p); map.setView(p, 15); status.textContent = "Your location is set as the start.";
|
||||
}, () => {status.textContent = "Location could not be obtained. Select a point on the map.";}, {timeout: 10000});
|
||||
};
|
||||
async function api(path, data) {
|
||||
const options = data === undefined ? {} : {method: "POST", headers: {"Content-Type": "application/json", "X-CSRFToken": root.dataset.csrf}, body: JSON.stringify(data)};
|
||||
const response = await fetch(`${base}${path}`, options);
|
||||
if (response.redirected) throw new Error("Your session expired. Sign in again.");
|
||||
let payload;
|
||||
try {payload = await response.json();} catch (_) {throw new Error(response.status === 429 ? "Too many requests. Please wait before trying again." : "The request failed. Refresh the page and try again.");}
|
||||
if (!response.ok) throw new Error(payload.error || "The request failed.");
|
||||
return payload;
|
||||
}
|
||||
function cell(row, text) {const td = document.createElement("td"); td.textContent = text; row.append(td); return td;}
|
||||
function drawOptions() {
|
||||
el("route-options").replaceChildren();
|
||||
for (const route of routes) {
|
||||
const row = document.createElement("tr"); if (selected?.id === route.id) row.className = "selected";
|
||||
cell(row, route.saved ? route.title : route.label); cell(row, `${(route.distance_m / 1000).toFixed(2)} km`);
|
||||
cell(row, formatAscent(route.ascent_m)); cell(row, formatPct(route.quality.paths_pct)); cell(row, formatPct(route.quality.repeats_pct));
|
||||
const button = document.createElement("button"); button.type = "button"; button.className = "button secondary";
|
||||
button.textContent = selected?.id === route.id ? "Selected" : "View";
|
||||
button.setAttribute("aria-pressed", String(selected?.id === route.id)); button.onclick = () => show(route);
|
||||
cell(row, "").append(button); el("route-options").append(row);
|
||||
}
|
||||
}
|
||||
function show(route) {
|
||||
selected = route; el("planner-results").hidden = false; drawOptions(); routeLayer.clearLayers();
|
||||
const points = route.coordinates.map(c => [c[1], c[0]]);
|
||||
const line = L.polyline(points, {color: colour, weight: 5}).addTo(routeLayer);
|
||||
L.circleMarker(points[0], {radius: 7, color: "#70b992", fillOpacity: 1}).bindTooltip("Route start").addTo(routeLayer);
|
||||
L.circleMarker(points.at(-1), {radius: 5, color: colour, fillOpacity: 1}).bindTooltip("Route finish").addTo(routeLayer);
|
||||
map.fitBounds(line.getBounds(), {padding: [30, 30]});
|
||||
el("route-heading").textContent = route.saved ? route.title : route.label;
|
||||
el("route-name").value = route.saved ? route.title : `${(route.distance_m / 1000).toFixed(1)} km ${route.preferences.mode === "loop" ? "loop" : "run"}`;
|
||||
el("distance-status").textContent = `${route.within_target ? "Within" : "Outside"} the ±5% distance target · ${route.deviation_pct > 0 ? "+" : ""}${route.deviation_pct}% · total ascent ${formatAscent(route.ascent_m)}.`;
|
||||
const q = route.quality; el("quality-details").replaceChildren();
|
||||
for (const [name, value] of [["Paths, tracks and footways", q.paths_pct], ["Mapped road / street share", q.roads_pct], ["Paved surface", q.paved_pct], ["Unknown surface", q.unknown_surface_pct], ["Repeated distance (estimate)", q.repeats_pct]]) {
|
||||
const div = document.createElement("div"), dt = document.createElement("dt"), dd = document.createElement("dd");
|
||||
dt.textContent = name; dd.textContent = formatPct(value); div.append(dt, dd); el("quality-details").append(div);
|
||||
}
|
||||
if (chart) chart.destroy(); chart = null;
|
||||
el("route-profile").hidden = !route.profile.length;
|
||||
el("profile-status").textContent = route.profile.length ? "Estimated elevation from routing data." : "Elevation profile unavailable for this route.";
|
||||
if (route.profile.length && window.Chart) chart = new Chart(el("route-profile"), {type: "line", data: {datasets: [{data: route.profile.map(p => ({x: p[0] / 1000, y: p[1]})), borderColor: colour, pointRadius: 0, borderWidth: 2}]}, options: {animation: false, maintainAspectRatio: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", title: {display: true, text: "Distance (km)"}}, y: {title: {display: true, text: "Elevation (m)"}}}}});
|
||||
el("route-export").hidden = !route.saved; el("route-export").href = `${base}/${route.id}/gpx`;
|
||||
el("delete-route").hidden = !route.saved;
|
||||
}
|
||||
el("planner-form").onsubmit = async e => {
|
||||
e.preventDefault(); if (busy) return;
|
||||
try {
|
||||
const start = coordinate("plan-start"), mode = el("plan-mode").value;
|
||||
const data = {start, mode, end: mode === "point" ? coordinate("plan-end") : start, distance: Number(el("plan-distance").value), hill: el("plan-hill").value, surface: el("plan-surface").value, priority: el("plan-priority").value, avoid_repeats: el("plan-repeats").checked};
|
||||
busy = true; const token = ++generation; el("generate-routes").disabled = true; status.textContent = "Finding and comparing walking routes…";
|
||||
const result = await api("/generate", data); if (token !== generation) return;
|
||||
routes = result.routes; show(routes[0]); status.textContent = `${routes.length} distinct route${routes.length === 1 ? "" : "s"} found. Best match balances your distance, hill and quality preferences.${result.warnings.length ? " Some alternatives could not be generated." : ""}`;
|
||||
} catch (error) {status.textContent = error.message;} finally {busy = false; el("generate-routes").disabled = false;}
|
||||
};
|
||||
function savedButton(route) {
|
||||
el("empty-saved")?.remove(); let button = el("saved-routes").querySelector(`[data-saved="${route.id}"]`);
|
||||
if (!button) {button = document.createElement("button"); button.type = "button"; button.className = "button secondary"; button.dataset.saved = route.id; el("saved-routes").prepend(button);}
|
||||
button.textContent = route.title;
|
||||
}
|
||||
el("save-route-form").onsubmit = async e => {
|
||||
e.preventDefault(); if (!selected) return; const id = selected.id;
|
||||
const button = e.target.querySelector("button"); button.disabled = true;
|
||||
try {const route = await api(`/${id}/save`, {title: el("route-name").value}); savedButton(route);
|
||||
routes = routes.map(r => r.id === id ? route : r); if (selected?.id === id) show(route); status.textContent = "Route saved privately.";
|
||||
} catch (error) {status.textContent = error.message;} finally {button.disabled = false;}
|
||||
};
|
||||
el("saved-routes").onclick = async e => {
|
||||
const button = e.target.closest("[data-saved]"); if (!button) return;
|
||||
const token = ++generation;
|
||||
try {const route = await api(`/${button.dataset.saved}`); if (token !== generation) return;
|
||||
routes = [route]; show(route); status.textContent = "Saved route loaded.";
|
||||
} catch (error) {status.textContent = error.message;}
|
||||
};
|
||||
el("delete-route").onclick = async () => {
|
||||
if (!selected?.saved || !window.confirm("Delete this saved route?")) return;
|
||||
const id = selected.id; el("delete-route").disabled = true;
|
||||
try {await api(`/${id}/delete`, {}); el("saved-routes").querySelector(`[data-saved="${id}"]`)?.remove();
|
||||
routes = routes.filter(r => r.id !== id); if (selected?.id === id) {selected = null; routeLayer.clearLayers(); el("planner-results").hidden = true; if (chart) chart.destroy(); chart = null;}
|
||||
status.textContent = "Saved route deleted.";
|
||||
} catch (error) {status.textContent = error.message;} finally {el("delete-route").disabled = false;}
|
||||
};
|
||||
new ResizeObserver(() => map.invalidateSize()).observe(el("planner-map")); setMode();
|
||||
})();
|
||||
Reference in New Issue
Block a user