130 lines
9.9 KiB
JavaScript
130 lines
9.9 KiB
JavaScript
"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();
|
|
})();
|