Files
Stride/app/static/planner.js
T

248 lines
19 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
const routeLayer = L.layerGroup().addTo(map);
const markers = {};
const chosen = {}, searches = {};
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 kind = id === "plan-start" ? "start" : "end";
if (chosen[kind]?.label === el(id).value) return chosen[kind].coordinates;
const parts = el(id).value.split(",").map(v => v.trim());
if (parts.length !== 2 || parts.some(v => v === "")) throw new Error("Select a location from the search suggestions, or pick 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 pin(kind, loop = false) {
return L.divIcon({className: `planner-pin ${kind}${loop ? " loop" : ""}`,
html: `<span><b>${loop ? "S/F" : kind === "start" ? "S" : "F"}</b></span>`,
iconSize: [30, 36], iconAnchor: [15, 34], tooltipAnchor: [0, -34]});
}
function invalidate() {
generation++; routeLayer.clearLayers(); selected = null; el("planner-results").hidden = true;
for (const marker of Object.values(markers)) if (!map.hasLayer(marker)) marker.addTo(map);
}
function mark(kind, latlng, label, reset = true) {
if (reset) invalidate();
if (markers[kind]) map.removeLayer(markers[kind]);
markers[kind] = L.marker(latlng, {icon: pin(kind), draggable: true,
title: `${kind === "start" ? "Start" : "Finish"} location — drag to move`}).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish");
markers[kind].on("dragend", event => mark(kind, event.target.getLatLng()));
const coords = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
const name = label || coords;
chosen[kind] = {label: name, coordinates: [latlng.lng, latlng.lat]};
el(`plan-${kind}`).value = name;
el(`${kind}-hint`).textContent = label ? coords : "Point selected · drag the pin to adjust.";
closeSearch(kind, true);
el("map-instruction").textContent = "Drag a pin to fine-tune your location.";
}
function setMode() {
const point = el("plan-mode").value === "point";
el("finish-fields").hidden = !point; el("plan-end").required = point;
el("loop-note").hidden = point;
if (!point) { picking = "start"; if (markers.end) {map.removeLayer(markers.end); delete markers.end;} }
else if (chosen.end && !markers.end) mark("end", L.latLng(chosen.end.coordinates[1], chosen.end.coordinates[0]), chosen.end.label, false);
setPicking("start", false);
}
el("plan-mode").onchange = () => {invalidate(); setMode();};
function setPicking(kind, announce = true) {
picking = kind;
for (const k of ["start", "end"]) el(`pick-${k}`).setAttribute("aria-pressed", String(k === kind));
if (announce) {const message = `Tap the map to set your ${kind === "start" ? "start" : "finish"}.`;
status.textContent = message; el("map-instruction").textContent = message;
el("planner-map").scrollIntoView({behavior: "smooth", block: "nearest"});}
}
el("pick-start").onclick = () => setPicking("start");
el("pick-end").onclick = () => setPicking("end");
map.on("click", e => {mark(picking, e.latlng); status.textContent = `${picking === "start" ? "Start" : "Finish"} selected.`;
if (picking === "start" && el("plan-mode").value === "point") setPicking("end", false);});
el("fit-map").onclick = () => {
if (selected) map.fitBounds(L.latLngBounds(selected.coordinates.map(c => [c[1], c[0]])), {padding: [35, 35]});
else {const points = Object.values(markers).map(m => m.getLatLng()); if (points.length) map.fitBounds(L.latLngBounds(points), {padding: [35, 35], maxZoom: 16});}
};
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, signal) {
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, signal});
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 closeSearch(kind, cancel = false) {
el(`${kind}-results`).hidden = true; el(`plan-${kind}`).setAttribute("aria-expanded", "false");
el(`plan-${kind}`).removeAttribute("aria-activedescendant");
if (cancel && searches[kind]) {clearTimeout(searches[kind].timer); searches[kind].sequence++; searches[kind].controller?.abort();
if (el(`${kind}-hint`).textContent.startsWith("Searching")) el(`${kind}-hint`).textContent = "Search a place, address, or pick on the map.";}
}
for (const kind of ["start", "end"]) {
const input = el(`plan-${kind}`), list = el(`${kind}-results`), hint = el(`${kind}-hint`);
const search = searches[kind] = {sequence: 0, items: [], active: -1};
function highlight(index) {
search.active = index;
[...list.children].forEach((button, i) => button.setAttribute("aria-selected", String(i === index)));
if (index >= 0) input.setAttribute("aria-activedescendant", `${kind}-option-${index}`);
else input.removeAttribute("aria-activedescendant");
}
function selectPlace(item) {
mark(kind, L.latLng(item.coordinates[1], item.coordinates[0]), item.label);
map.setView([item.coordinates[1], item.coordinates[0]], 15);
status.textContent = `${kind === "start" ? "Start" : "Finish"} selected: ${item.label}`;
}
input.addEventListener("input", () => {
invalidate(); delete chosen[kind]; if (markers[kind]) {map.removeLayer(markers[kind]); delete markers[kind];}
closeSearch(kind, true); search.items = []; highlight(-1);
hint.textContent = "Search a place, address, or enter latitude, longitude.";
const text = input.value.trim();
if (text.length < 3 || /^[+-]?\d+(\.\d+)?\s*,\s*[+-]?\d+(\.\d+)?$/.test(text)) return;
search.timer = setTimeout(async () => {
const sequence = ++search.sequence; search.controller = new AbortController(); hint.textContent = "Searching nearby places…";
try {
const centre = map.getCenter();
const data = await api("/search", {text, focus: [centre.lng, centre.lat]}, search.controller.signal);
if (sequence !== search.sequence || input.value.trim() !== text) return;
search.items = data.locations; list.replaceChildren(); highlight(-1);
search.items.forEach((item, i) => {
const button = document.createElement("button"); button.type = "button"; button.id = `${kind}-option-${i}`;
button.setAttribute("role", "option"); button.setAttribute("aria-selected", "false");
button.textContent = item.label; button.onclick = () => selectPlace(item);
list.append(button);
});
const open = search.items.length > 0 && document.activeElement === input;
list.hidden = !open; input.setAttribute("aria-expanded", String(open));
hint.textContent = search.items.length ? "Choose a place from the suggestions." : "No matches. Try adding a suburb or city, or pick on the map.";
} catch (error) {if (error.name !== "AbortError" && sequence === search.sequence) hint.textContent = error.message;}
}, 650);
});
input.addEventListener("keydown", event => {
if (event.key === "Escape") {closeSearch(kind, true); return;}
if (!list.hidden && search.items.length && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) {
event.preventDefault();
if (event.key === "Enter") selectPlace(search.items[Math.max(0, search.active)]);
else highlight((search.active + (event.key === "ArrowDown" ? 1 : -1) + search.items.length) % search.items.length);
} else if (event.key === "Enter" && !chosen[kind] && !input.value.includes(",")) {
event.preventDefault(); status.textContent = "Choose a search suggestion or pick a location on the map.";
}
});
input.addEventListener("focus", () => {if (search.items.length && !chosen[kind]) {list.hidden = false; input.setAttribute("aria-expanded", "true");}});
input.addEventListener("blur", () => setTimeout(() => {if (!list.contains(document.activeElement)) closeSearch(kind);}, 150));
}
document.addEventListener("click", event => {
for (const kind of ["start", "end"]) if (!event.target.closest(`#plan-${kind}`) && !event.target.closest(`#${kind}-results`)) closeSearch(kind);
});
function metric(parent, value, label) {
const div = document.createElement("div"), strong = document.createElement("strong"), small = document.createElement("small");
strong.textContent = value; small.textContent = label; div.append(strong, small); parent.append(div);
}
function drawOptions() {
el("route-options").replaceChildren();
for (const route of routes) {
const button = document.createElement("button"); button.type = "button"; button.className = "planner-option";
const title = document.createElement("div"), name = document.createElement("span"), tag = document.createElement("span");
title.className = "planner-option-title"; name.textContent = route.saved ? route.title : route.label;
tag.className = "planner-option-tag"; tag.textContent = selected?.id === route.id ? "Selected" : "View route";
title.append(name, tag); button.append(title);
const stats = document.createElement("div"); stats.className = "planner-option-stats";
metric(stats, `${(route.distance_m / 1000).toFixed(2)} km`, "Distance"); metric(stats, formatAscent(route.ascent_m), "Ascent");
metric(stats, formatPct(route.quality.paths_pct), "Paths / tracks"); metric(stats, formatPct(route.quality.repeats_pct), "Repeated"); button.append(stats);
button.setAttribute("aria-pressed", String(selected?.id === route.id)); button.onclick = () => show(route);
el("route-options").append(button);
}
}
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);
for (const marker of Object.values(markers)) map.removeLayer(marker);
const loop = route.preferences.mode === "loop";
L.marker(points[0], {icon: pin("start", loop), draggable: true, title: loop ? "Route start / finish — drag to move" : "Route start — drag to move"})
.bindTooltip(loop ? "Start / finish" : "Start").on("dragend", e => mark("start", e.target.getLatLng())).addTo(routeLayer);
if (!loop) L.marker(points.at(-1), {icon: pin("end"), draggable: true, title: "Route finish — drag to move"})
.bindTooltip("Finish").on("dragend", e => mark("end", e.target.getLatLng())).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("route-metrics").replaceChildren(); metric(el("route-metrics"), `${(route.distance_m / 1000).toFixed(2)} km`, "Distance");
metric(el("route-metrics"), formatAscent(route.ascent_m), "Ascent"); metric(el("route-metrics"), formatPct(route.quality.paths_pct), "Paths / tracks");
el("map-instruction").textContent = `${route.saved ? route.title : route.label} · ${(route.distance_m / 1000).toFixed(2)} km`;
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 (value != null) {const track = document.createElement("div"), fill = document.createElement("i"); track.className = "quality-track";
track.setAttribute("aria-hidden", "true"); fill.style.width = `${Math.max(0, Math.min(100, value))}%`; track.append(fill); div.append(track);}
}
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, backgroundColor: "#ff926812", fill: true, pointRadius: 0, borderWidth: 2}]}, options: {animation: false, maintainAspectRatio: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", ticks: {color: "#a0a9ac"}, grid: {color: "#303638"}, title: {display: true, text: "Distance (km)", color: "#a0a9ac"}}, y: {ticks: {color: "#a0a9ac"}, grid: {color: "#303638"}, title: {display: true, text: "Elevation (m)", color: "#a0a9ac"}}}}});
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;
if (!chosen.start) mark("start", L.latLng(start[1], start[0]));
if (mode === "point" && !chosen.end) {const end = coordinate("plan-end"); mark("end", L.latLng(end[1], end[0]));}
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;
const p = route.preferences; el("plan-mode").value = p.mode; setMode();
el("plan-distance").value = p.distance / 1000; el("plan-hill").value = p.hill; el("plan-priority").value = p.priority;
el("plan-surface").value = p.surface; el("plan-repeats").checked = p.avoid_repeats;
mark("start", L.latLng(p.start[1], p.start[0]), undefined, false);
if (p.mode === "point") mark("end", L.latLng(p.end[1], p.end[0]), undefined, false);
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();
})();