"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: '© OpenStreetMap'}).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: `${loop ? "S/F" : kind === "start" ? "S" : "F"}`, 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(); })();