248 lines
19 KiB
JavaScript
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: '© <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();
|
|
})();
|