Add location search and polish running route planner

This commit is contained in:
Peter Stockings
2026-10-03 10:01:16 +10:00
parent 52943cd05b
commit 5ede1a01d2
7 changed files with 420 additions and 43 deletions
+84 -3
View File
@@ -1,3 +1,84 @@
.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)}
.planner-stage { display:grid; grid-template-columns:310px minmax(0,1fr); gap:20px; align-items:stretch; }
.planner-controls,.planner-detail,.planner-library,.planner-map-panel { background:var(--white); border:1px solid var(--line); border-radius:12px; }
.planner-controls { padding:22px; }
.planner-section-heading { display:flex; justify-content:space-between; align-items:center; }
.planner-section-heading .icon { color:var(--green); }
.planner-controls label,#save-route-form label { display:block; margin:15px 0 7px; font-size:12px; font-weight:500; }
.planner-controls input:not([type=checkbox]),.planner-controls select,#route-name { width:100%; padding:12px; border:1px solid #3c4447; border-radius:7px; background:var(--paper); color:var(--ink); font:inherit; }
.planner-controls input:focus,.planner-controls select:focus,#route-name:focus { outline:2px solid var(--green); outline-offset:2px; }
.planner-controls input::placeholder { color:var(--muted); font-size:12px; }
#route-planner [hidden] { display:none!important; }
.planner-fields { display:grid; grid-template-columns:1fr 1.2fr; gap:12px; }
.field-unit { color:var(--muted); float:right; }
.planner-controls hr { border:0; border-top:1px solid var(--line); margin:20px 0 0; }
.planner-controls .field-help { margin:10px 0; }
.planner-check { display:flex!important; gap:8px; align-items:center; }
.planner-controls input[type=checkbox] { accent-color:var(--green); width:16px; height:16px; }
.planner-preferences { margin:18px 0; }
.planner-preferences summary,.planner-quality-notes summary { color:var(--muted); font-size:12px; cursor:pointer; }
.planner-preferences[open] summary { color:var(--ink); }
.planner-link { display:flex; align-items:center; gap:6px; background:none; padding:4px 0; color:var(--muted); font-size:11px; margin-top:8px; }
.planner-link:hover,.planner-link[aria-pressed=true] { color:var(--green); }
.planner-link .icon { width:14px; height:14px; }
.loop-note { margin-top:14px; padding:9px 12px; border-radius:6px; background:#27322d; color:#b8cfc0; font-size:11px; }
.location-letter { display:inline-grid; place-items:center; width:19px; height:19px; border-radius:50%; margin-right:8px; font-size:10px; font-weight:600; color:#131918; background:#83c0a1; }
.location-letter.end { background:var(--green); }
.location-search { position:relative; }
.location-hint { font-size:10px; color:var(--muted); margin-top:7px; overflow-wrap:anywhere; }
.location-results { position:absolute; left:0; right:0; top:calc(100% + 6px); z-index:1200; background:#242b2e; border:1px solid #485154; border-radius:8px; box-shadow:0 14px 30px #0006; padding:5px; }
.location-results button { display:block; width:100%; text-align:left; padding:11px 10px; border-radius:5px; color:var(--ink); background:transparent; font-size:12px; line-height:1.5; overflow-wrap:anywhere; }
.location-results button:hover,.location-results button[aria-selected=true] { background:#384144; }
.planner-provider-note,.planner-attribution { font-size:10px; color:var(--muted); line-height:1.7; margin-top:12px; }
#generate-routes { width:100%; gap:10px; margin-top:4px; }
#generate-routes:disabled { opacity:.55; cursor:wait; }
.planner-map-panel { min-width:0; overflow:hidden; display:flex; flex-direction:column; }
.planner-map-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:18px 20px; }
.planner-map-heading .eyebrow { margin-bottom:5px; }
.planner-map-heading p { font-size:12px; color:var(--muted); }
.planner-map-heading button { padding:9px 12px; flex-shrink:0; }
#planner-map { min-height:470px; flex:1; z-index:0; background:var(--line); }
.planner-map-footer { display:flex; justify-content:space-between; gap:14px; padding:13px 20px; font-size:10px; color:var(--muted); flex-wrap:wrap; }
.legend-dot { display:inline-block; width:7px; height:7px; border-radius:50%; margin:0 5px 0 10px; background:#83c0a1; }
.legend-dot:first-child { margin-left:0; }
.legend-dot.end { background:var(--green); }
#planner-status { margin:18px 0; font-size:13px; color:var(--muted); min-height:22px; }
.planner-pin { background:transparent; border:0; }
.planner-pin span { display:grid; place-items:center; width:30px; height:30px; border-radius:50% 50% 50% 0; transform:rotate(-45deg); background:#83c0a1; border:2px solid #f9f7f2; box-shadow:0 3px 10px #0008; color:#14251e; }
.planner-pin.end span { background:var(--green); color:#2c1912; }
.planner-pin b { transform:rotate(45deg); font:600 12px/1 system-ui; }
.planner-pin.loop span { border-radius:50%; }
.planner-pin.loop b { font-size:9px; }
.planner-results-heading { display:flex; align-items:center; justify-content:space-between; gap:14px; margin:24px 0 14px; }
.planner-options { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; margin-bottom:22px; }
.planner-option { background:var(--white); color:var(--ink); border:1px solid var(--line); border-radius:10px; padding:18px; text-align:left; }
.planner-option[aria-pressed=true] { border-color:var(--green); box-shadow:inset 0 0 0 1px var(--green); }
.planner-option-title { display:flex; justify-content:space-between; align-items:center; gap:10px; font-size:13px; }
.planner-option-tag { font-size:10px; color:var(--green); }
.planner-option-stats { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:16px; }
.planner-option-stats strong { display:block; font-size:18px; font-weight:500; letter-spacing:-.3px; }
.planner-option-stats small { display:block; font-size:10px; color:var(--muted); margin-top:3px; }
.planner-detail { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(0,1fr); gap:32px; padding:26px; }
.planner-metrics { display:flex; gap:30px; flex-wrap:wrap; margin:20px 0; }
.planner-metrics strong { font-size:25px; font-weight:500; display:block; }
.planner-metrics small { display:block; font-size:10px; color:var(--muted); }
.planner-profile { height:190px; margin-top:20px; }
.planner-distance-note { font-size:12px; color:var(--muted); margin-top:14px; }
.planner-detail dl { margin:18px 0; }
.planner-detail dl>div { display:grid; grid-template-columns:1fr auto; gap:8px 16px; padding:10px 0; border-bottom:1px solid var(--line); }
.planner-detail dt { color:var(--muted); font-size:12px; }
.planner-detail dd { margin:0; font-size:12px; }
.quality-track { grid-column:1/-1; height:3px; background:#30383b; border-radius:3px; overflow:hidden; }
.quality-track i { display:block; height:100%; background:#748b82; }
.planner-quality-notes { margin:14px 0; }
.planner-quality-notes p { margin-top:8px; }
.planner-save-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; }
.planner-library { padding:24px; margin-top:24px; }
.planner-library .planner-results-heading { margin-top:0; }
#saved-routes { display:flex; gap:12px; flex-wrap:wrap; margin-top:16px; }
#saved-routes .button { max-width:100%; white-space:normal; overflow-wrap:anywhere; }
#empty-saved { font-size:13px; color:var(--muted); padding:14px 0; }
.app-header .stats-nav { flex-shrink:0; color:var(--muted); font-size:13px; }
.app-header .stats-nav.active { color:var(--green); }
@media(max-width:1000px) { .planner-stage{grid-template-columns:280px minmax(0,1fr)} .planner-map-footer span:last-child{display:none} .planner-map-heading{flex-wrap:wrap} .planner-option-stats strong{font-size:16px} }
@media(max-width:760px) { .planner-stage{grid-template-columns:1fr} .planner-controls{padding:20px} .planner-options{grid-template-columns:1fr} .planner-option-stats{grid-template-columns:repeat(4,minmax(0,1fr))} .planner-detail{grid-template-columns:1fr;padding:20px} .planner-map-panel{min-height:400px} #planner-map{height:350px;min-height:350px;flex:auto} .planner-controls input,.planner-controls select,#route-name{font-size:16px} .planner-controls input::placeholder{font-size:14px} .planner-results-heading{align-items:start;flex-wrap:wrap} .location-hint{font-size:11px} .planner-library{padding:20px} }
@media(max-width:380px) { .planner-option-stats{grid-template-columns:1fr 1fr} .planner-map-heading{padding:16px} .planner-fields{grid-template-columns:1fr 1fr} }
+138 -20
View File
@@ -10,33 +10,68 @@
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("Enter latitude, longitude or select a point on the map.");
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 mark(kind, latlng) {
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).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish");
el(`plan-${kind}`).value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
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 = 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.";};
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") picking = "end";});
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 => {
@@ -44,47 +79,123 @@
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) {
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);
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 cell(row, text) {const td = document.createElement("td"); td.textContent = text; row.append(td); return td;}
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 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";
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);
cell(row, "").append(button); el("route-options").append(row);
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);
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);
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, 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)"}}}}});
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;
}
@@ -92,6 +203,8 @@
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;
@@ -114,6 +227,11 @@
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;}
};