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
+14 -1
View File
@@ -6,7 +6,7 @@ from flask_login import current_user, login_required
from .extensions import db, limiter
from .models import PlannedRoute
from .planner_engine import PlannerError, generate, validate
from .planner_engine import PlannerError, generate, search_locations, validate
planner = Blueprint("planner", __name__, url_prefix="/routes")
@@ -62,6 +62,19 @@ def candidates():
return jsonify(routes=[serialise(row) for row in rows], warnings=warnings)
@planner.post("/search")
@login_required
@limiter.limit("30 per minute; 600 per day")
def search():
try:
locations = search_locations(
request.get_json(silent=True), current_app.config.get("ORS_API_KEY")
)
except PlannerError as error:
return jsonify(error=str(error)), 400
return jsonify(locations=locations)
@planner.get("/<int:route_id>")
@login_required
def detail(route_id):
+54
View File
@@ -5,6 +5,7 @@ import json
import math
from collections import Counter
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@@ -12,6 +13,59 @@ class PlannerError(ValueError):
pass
def search_locations(data, key):
if not isinstance(data, dict) or not isinstance(data.get("text"), str):
raise PlannerError("Enter a place name or address.")
text = data["text"].strip()
if not 3 <= len(text) <= 200:
raise PlannerError("Use 3–200 characters to search for a location.")
if not key:
raise PlannerError("Location search is not connected yet.")
params = {"text": text, "size": 5}
if data.get("focus") is not None:
focus = point(data["focus"])
params.update({"focus.point.lon": focus[0], "focus.point.lat": focus[1]})
req = Request(
"https://api.openrouteservice.org/geocode/search?" + urlencode(params),
headers={"Authorization": key, "Accept": "application/json"},
)
try:
with urlopen(req, timeout=8) as response:
raw = response.read(1_000_001)
if len(raw) > 1_000_000:
raise PlannerError("Location search returned too much data. Try a more specific place.")
result = json.loads(raw)
except HTTPError as error:
message = (
"Location search is busy. Try again shortly."
if error.code == 429
else ("Location search is unavailable. You can still select a point on the map.")
)
raise PlannerError(message) from None
except (URLError, TimeoutError, OSError, json.JSONDecodeError):
raise PlannerError(
"Location search could not connect. Try selecting a point on the map."
) from None
if not isinstance(result, dict) or not isinstance(result.get("features"), list):
raise PlannerError("Location search returned an incomplete response.")
locations = []
for feature in result["features"][:5]:
if not isinstance(feature, dict):
continue
geometry, props = feature.get("geometry"), feature.get("properties")
if not isinstance(geometry, dict) or not isinstance(props, dict):
continue
label = props.get("label") or props.get("name")
if geometry.get("type") != "Point" or not isinstance(label, str):
continue
try:
coordinates = point(geometry.get("coordinates"))
except PlannerError:
continue
locations.append({"label": label[:300], "coordinates": coordinates})
return locations
def number(value, low, high, label):
if isinstance(value, bool):
raise PlannerError(f"Choose a valid {label}.")
+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;}
};
+32 -16
View File
@@ -1,30 +1,46 @@
{% extends 'base.html' %}
{% from '_ui.html' import icon %}
{% block title %}Route planner · Stride{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">
<link rel="stylesheet" href="{{ url_for('static',filename='planner.css') }}">
{% endblock %}
{% macro location(kind, label, letter) %}
<div class="planner-location">
<label for="plan-{{ kind }}"><span class="location-letter {{ kind }}">{{ letter }}</span>{{ label }}</label>
<div class="location-search"><input id="plan-{{ kind }}" placeholder="Search a place or address" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ kind }}-results" aria-describedby="{{ kind }}-hint" {{ 'required' if kind == 'start' }}><div id="{{ kind }}-results" class="location-results" role="listbox" aria-label="{{ label }} suggestions" hidden></div></div>
<div id="{{ kind }}-hint" class="location-hint" aria-live="polite">Search, enter coordinates, or pick on the map.</div>
<button type="button" class="planner-link" id="pick-{{ kind }}">{{ icon('route') }} Pick {{ 'start' if kind == 'start' else 'finish' }} on map</button>
</div>
{% endmacro %}
{% block content %}
<div id="route-planner" data-csrf="{{ csrf_token() }}" data-base="{{ url_for('planner.index') }}">
<div class="page-heading"><div><span class="eyebrow">PLAN / COMPARE / RUN</span><h1>Find your next route.</h1><p>Your distance. Your hills. A route worth running.</p></div></div>
<div class="page-heading"><div><span class="eyebrow">EXPLORE YOUR NEXT RUN</span><h1>A good run starts here.</h1><p>Find a route that fits your distance, terrain and favourite places.</p></div><a class="button secondary" href="#saved-route-library">{{ icon('route') }} Saved routes</a></div>
{% if not configured %}<div class="flash">Route generation is not connected yet. The site operator needs to configure ORS_API_KEY. You can still view and export your saved routes.</div>{% endif %}
<form id="planner-form" class="card planner-controls">
<div><label for="plan-mode">Route type</label><select id="plan-mode"><option value="loop">Loop · finish at start</option><option value="point">Point to point</option></select>
<label for="plan-start">Start · latitude, longitude</label><input id="plan-start" placeholder="Select on the map" required autocomplete="off"><button type="button" class="text-button" id="pick-start">Pick start on map</button>
<div id="finish-fields" hidden><label for="plan-end">Finish · latitude, longitude</label><input id="plan-end" placeholder="Select on the map" autocomplete="off"><button type="button" class="text-button" id="pick-end">Pick finish on map</button></div>
<button type="button" class="text-button" id="my-location">Use my location</button></div>
<div><label for="plan-distance">Target distance (km)</label><input id="plan-distance" type="number" min="1" max="50" step="0.1" value="15" required><p class="field-help">Aim within ±5%; actual distance may vary.</p><label for="plan-hill">Hill difficulty</label><select id="plan-hill"><option value="flat">Mostly flat · ~3 m ascent/km</option><option value="rolling" selected>Rolling · ~12 m ascent/km</option><option value="hilly">Hilly · ~25 m ascent/km</option></select></div>
<div><label for="plan-priority">Prioritise</label><select id="plan-priority"><option value="quality">Route quality</option><option value="distance">Closest distance</option><option value="hills">Hill match</option></select><label for="plan-surface">Surface preference</label><select id="plan-surface"><option value="any">Any surface</option><option value="paved">Mostly paved</option><option value="trails">Paths and tracks</option></select><label class="planner-check"><input id="plan-repeats" type="checkbox" checked> Minimise repeated sections</label></div>
<div class="planner-submit"><button class="button primary" id="generate-routes" {% if not configured %}disabled{% endif %}>Find routes</button><p class="field-help">Locations are sent to openrouteservice for walking-path routing. Hill and quality choices rank the returned candidates.</p></div>
<div class="planner-stage">
<form id="planner-form" class="planner-controls">
<div class="planner-section-heading"><span class="eyebrow">YOUR ROUTE</span>{{ icon('route') }}</div>
<label for="plan-mode">Route type</label><select id="plan-mode"><option value="loop">Loop · back to the start</option><option value="point">Point to point</option></select>
{{ location('start', 'Start location', 'S') }}
<div id="finish-fields" hidden>{{ location('end', 'Finish location', 'F') }}</div>
<div id="loop-note" class="loop-note">↻ Finish at your starting location</div>
<button type="button" class="planner-link" id="my-location">{{ icon('profile') }} Use my current location</button>
<hr>
<div class="planner-fields"><div><label for="plan-distance">Distance <span class="field-unit">km</span></label><input id="plan-distance" type="number" min="1" max="50" step="0.1" value="15" required></div><div><label for="plan-hill">Hills</label><select id="plan-hill"><option value="flat">Mostly flat</option><option value="rolling" selected>Rolling</option><option value="hilly">Hill challenge</option></select></div></div>
<p class="field-help">Distance target ±5% · hill preferences guide the match.</p>
<details class="planner-preferences"><summary>Route preferences</summary><label for="plan-priority">Prioritise</label><select id="plan-priority"><option value="quality">Route quality</option><option value="distance">Closest distance</option><option value="hills">Hill match</option></select><label for="plan-surface">Surface</label><select id="plan-surface"><option value="any">Any surface</option><option value="paved">Mostly paved</option><option value="trails">Paths and tracks</option></select><label class="planner-check"><input id="plan-repeats" type="checkbox" checked> Minimise repeated sections</label></details>
<button class="button primary" id="generate-routes" {% if not configured %}disabled{% endif %}>{{ icon('run') }} Find my routes</button>
<p class="planner-provider-note">Place searches and locations use openrouteservice.</p>
</form>
<p id="planner-status" role="status" aria-live="polite">Select a start on the map, or enter coordinates.</p>
<div id="planner-map" aria-label="Route planner map"></div>
<p class="field-help">Routing © openrouteservice · OpenStreetMap contributors. Mapped paths do not verify current access, traffic or lighting.</p>
<div id="planner-results" hidden>
<div class="planner-table-wrap"><table class="planner-table"><thead><tr><th>Suggested route</th><th>Distance</th><th>Ascent</th><th>Paths / tracks</th><th>Repeated</th><th></th></tr></thead><tbody id="route-options"></tbody></table></div>
<div class="planner-detail card"><div><h2 id="route-heading">Route details</h2><div class="planner-profile"><canvas id="route-profile" aria-label="Elevation by distance" role="img"></canvas></div><p id="profile-status" class="field-help"></p><p id="distance-status"></p></div><div><h2>Route quality</h2><dl id="quality-details"></dl><p class="field-help">Repetition is estimated from matching recorded geometry edges. Road share describes mapped way types, not traffic levels. Missing attributes remain unknown.</p><p class="field-help">Crossings, lighting and current access: unverified.</p><form id="save-route-form"><label for="route-name">Route name</label><input id="route-name" maxlength="160" required><div class="planner-save-actions"><button class="button primary">Save route</button><a id="route-export" class="button secondary" hidden>Export GPX</a><button type="button" id="delete-route" class="button secondary" hidden>Delete</button></div></form></div></div>
<div class="planner-map-panel"><div class="planner-map-heading"><div><span class="eyebrow">ROUTE PREVIEW</span><p id="map-instruction">Search a place or tap the map to set your start.</p></div><button class="button secondary" type="button" id="fit-map">Fit locations</button></div><div id="planner-map" aria-label="Route planner map"></div><div class="planner-map-footer"><span><i class="legend-dot start"></i> Start <i class="legend-dot end"></i> Finish</span><span>Walking paths · elevation · surface</span></div></div>
</div>
<section class="card planner-library"><h2>Your saved routes</h2><p class="field-help">Private to your account · most recent 100 shown</p><div id="saved-routes">{% for route in saved_routes %}<button type="button" class="button secondary" data-saved="{{ route.id }}">{{ route.title }}</button>{% else %}<p id="empty-saved">No saved routes yet.</p>{% endfor %}</div></section>
<p id="planner-status" role="status" aria-live="polite">Choose your starting point to begin.</p>
<div id="planner-results" hidden>
<div class="planner-results-heading"><h2>Your route options</h2><span class="field-help">Select a route to explore its details</span></div><div id="route-options" class="planner-options" aria-label="Suggested routes"></div>
<div class="planner-detail"><div><span class="eyebrow">SELECTED ROUTE</span><h2 id="route-heading">Route details</h2><div id="route-metrics" class="planner-metrics"></div><div class="planner-profile"><canvas id="route-profile" aria-label="Elevation by distance" role="img"></canvas></div><p id="profile-status" class="field-help"></p><p id="distance-status" class="planner-distance-note"></p></div><div><span class="eyebrow">KNOW YOUR ROUTE</span><h2>Quality & terrain</h2><dl id="quality-details"></dl><details class="planner-quality-notes"><summary>About these estimates</summary><p class="field-help">Repetition uses matching geometry edges. Road share describes mapped way types, not traffic. Missing attributes stay unknown. Crossings, lighting and current access are unverified.</p></details><form id="save-route-form"><label for="route-name">Route name</label><input id="route-name" maxlength="160" required><div class="planner-save-actions"><button class="button primary">Save route</button><a id="route-export" class="button secondary" hidden>Export GPX</a><button type="button" id="delete-route" class="button secondary" hidden>Delete</button></div></form></div></div>
</div>
<section id="saved-route-library" class="planner-library"><div class="planner-results-heading"><div><span class="eyebrow">READY FOR ANOTHER DAY</span><h2>Your saved routes</h2></div><span class="field-help">Private to you · latest 100</span></div><div id="saved-routes">{% for route in saved_routes %}<button type="button" class="button secondary" data-saved="{{ route.id }}">{{ route.title }}</button>{% else %}<p id="empty-saved">Your next favourite route belongs here. Plan a run and save it for later.</p>{% endfor %}</div></section>
<p class="planner-attribution">Routing & search © openrouteservice · OpenStreetMap contributors. Check current path access before heading out.</p>
</div>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='planner.js') }}"></script>{% endblock %}
+13 -2
View File
@@ -1,7 +1,10 @@
# Route planner
Open **Routes** in Stride. Select the start on the map, enter latitude/longitude,
or use your location. Choose a loop or select a separate finish, distance (1–50 km),
search by typing a place/address and choosing a suggestion, or use your location.
Both start and finish support search with arrow-key/Enter selection. Suggestions
are biased towards the current map view, without restricting the search region.
Choose a loop or select a separate finish, distance (1–50 km),
hill difficulty, surface preference and ranking priority. Generated candidates show
their actual distance, ascent, route geometry, elevation profile and quality coverage.
Save a named route privately, reopen it, download GPX, or delete it.
@@ -48,7 +51,15 @@ Quality percentages follow ORS documented waytype and surface IDs:
Crossing counts, lighting, current access and live traffic are unverified.
GPX includes the route and available elevation, without fictional timestamps.
No direct Garmin course upload or place-name search is included in this version.
No direct Garmin course upload is included in this version.
Place searches use the same server-side ORS key and a fixed geocoding endpoint.
Typing waits 650 ms before searching; old responses are discarded when the query
changes. Queries are bounded to 3–200 characters with at most five results and an
eight-second provider timeout. The authenticated POST endpoint requires CSRF and
is limited to 30 searches/minute and 600/day. Provider labels are inserted as text.
Start/finish pins use self-contained CSS markers, so no external marker images
are required. Selected pins can be dragged to adjust their location.
## Persistence and validation
+85 -1
View File
@@ -7,7 +7,7 @@ import pytest
from app import create_app
from app.extensions import db
from app.models import PlannedRoute, User
from app.planner_engine import PlannerError, analyse, generate, validate
from app.planner_engine import PlannerError, analyse, generate, search_locations, validate
@pytest.fixture
@@ -223,3 +223,87 @@ def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch):
with app.app_context():
assert db.session.get(PlannedRoute, 1) is None
assert db.session.get(PlannedRoute, 2).saved
def test_location_search_contract_and_invalid_results(monkeypatch):
import json
from urllib.parse import parse_qs, urlsplit
calls = []
class Reply:
def __enter__(self):
return self
def __exit__(self, *_):
pass
def read(self, limit):
assert limit == 1_000_001
return json.dumps(
{
"features": [
{
"geometry": {"type": "Point", "coordinates": [151.2, -33.9]},
"properties": {"label": "Park & gardens, Sydney"},
},
{
"geometry": {"type": "Point", "coordinates": [0, 99]},
"properties": {"label": "Invalid"},
},
{"geometry": {"type": "LineString", "coordinates": []}, "properties": {}},
]
}
).encode()
def fake(req, timeout):
calls.append(req)
assert timeout == 8
return Reply()
monkeypatch.setattr("app.planner_engine.urlopen", fake)
locations = search_locations(
{"text": "Park & gardens", "focus": [151, -33]}, "private-test-key"
)
assert locations == [{"label": "Park & gardens, Sydney", "coordinates": [151.2, -33.9]}]
query = parse_qs(urlsplit(calls[0].full_url).query)
assert query["text"] == ["Park & gardens"]
assert query["size"] == ["5"] and query["focus.point.lon"] == ["151.0"]
assert "private-test-key" not in calls[0].full_url
assert calls[0].get_header("Authorization") == "private-test-key"
@pytest.mark.parametrize(
"data", [None, {}, {"text": "ab"}, {"text": "x" * 201}, {"text": "Sydney", "focus": [0, 99]}]
)
def test_location_search_validation(data):
with pytest.raises(PlannerError):
search_locations(data, "test")
def test_location_search_login_configuration_and_csrf(app, monkeypatch):
monkeypatch.setattr(
"app.planner.search_locations",
lambda data, key: [{"label": "Public park", "coordinates": [151, -33]}],
)
client = app.test_client()
assert client.post("/routes/search", json={"text": "Sydney"}).status_code == 302
login(client)
response = client.post("/routes/search", json={"text": "Sydney"})
assert response.status_code == 200 and response.json["locations"][0]["label"] == "Public park"
app.config["WTF_CSRF_ENABLED"] = True
assert client.post("/routes/search", json={"text": "Sydney"}).status_code == 400
with pytest.raises(PlannerError, match="not connected"):
search_locations({"text": "Sydney"}, None)
def test_location_search_failure_is_actionable(monkeypatch):
from urllib.error import URLError
def fail(*_, **__):
raise URLError("private provider internals")
monkeypatch.setattr("app.planner_engine.urlopen", fail)
with pytest.raises(PlannerError, match="selecting a point on the map") as error:
search_locations({"text": "Sydney"}, "private-key")
assert "private" not in str(error.value)