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 .extensions import db, limiter
from .models import PlannedRoute 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") planner = Blueprint("planner", __name__, url_prefix="/routes")
@@ -62,6 +62,19 @@ def candidates():
return jsonify(routes=[serialise(row) for row in rows], warnings=warnings) 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>") @planner.get("/<int:route_id>")
@login_required @login_required
def detail(route_id): def detail(route_id):
+54
View File
@@ -5,6 +5,7 @@ import json
import math import math
from collections import Counter from collections import Counter
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -12,6 +13,59 @@ class PlannerError(ValueError):
pass 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): def number(value, low, high, label):
if isinstance(value, bool): if isinstance(value, bool):
raise PlannerError(f"Choose a valid {label}.") 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-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,.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-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); attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
const routeLayer = L.layerGroup().addTo(map); const routeLayer = L.layerGroup().addTo(map);
const markers = {}; const markers = {};
const chosen = {}, searches = {};
let picking = "start", routes = [], selected = null, chart = null; let picking = "start", routes = [], selected = null, chart = null;
let generation = 0, busy = false; let generation = 0, busy = false;
const colour = "#ff9268"; const colour = "#ff9268";
const formatPct = value => value == null ? "Unknown" : `${value.toFixed(1)}%`; const formatPct = value => value == null ? "Unknown" : `${value.toFixed(1)}%`;
const formatAscent = value => value == null ? "Unknown" : `${Math.round(value)} m`; const formatAscent = value => value == null ? "Unknown" : `${Math.round(value)} m`;
function coordinate(id) { 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()); 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); 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."); 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]; 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]); if (markers[kind]) map.removeLayer(markers[kind]);
markers[kind] = L.marker(latlng).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish"); markers[kind] = L.marker(latlng, {icon: pin(kind), draggable: true,
el(`plan-${kind}`).value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`; 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() { function setMode() {
const point = el("plan-mode").value === "point"; const point = el("plan-mode").value === "point";
el("finish-fields").hidden = !point; el("plan-end").required = 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;} } 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("plan-mode").onchange = () => {invalidate(); setMode();};
el("pick-start").onclick = () => {picking = "start"; status.textContent = "Click the map to set your start.";}; function setPicking(kind, announce = true) {
el("pick-end").onclick = () => {picking = "end"; status.textContent = "Click the map to set your finish.";}; 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.`; 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 = () => { el("my-location").onclick = () => {
if (!navigator.geolocation) {status.textContent = "Location is unavailable. Select a point on the map."; return;} if (!navigator.geolocation) {status.textContent = "Location is unavailable. Select a point on the map."; return;}
navigator.geolocation.getCurrentPosition(position => { navigator.geolocation.getCurrentPosition(position => {
@@ -44,47 +79,123 @@
mark("start", p); map.setView(p, 15); status.textContent = "Your location is set as the start."; 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}); }, () => {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 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."); if (response.redirected) throw new Error("Your session expired. Sign in again.");
let payload; 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.");} 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."); if (!response.ok) throw new Error(payload.error || "The request failed.");
return payload; 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() { function drawOptions() {
el("route-options").replaceChildren(); el("route-options").replaceChildren();
for (const route of routes) { for (const route of routes) {
const row = document.createElement("tr"); if (selected?.id === route.id) row.className = "selected"; const button = document.createElement("button"); button.type = "button"; button.className = "planner-option";
cell(row, route.saved ? route.title : route.label); cell(row, `${(route.distance_m / 1000).toFixed(2)} km`); const title = document.createElement("div"), name = document.createElement("span"), tag = document.createElement("span");
cell(row, formatAscent(route.ascent_m)); cell(row, formatPct(route.quality.paths_pct)); cell(row, formatPct(route.quality.repeats_pct)); title.className = "planner-option-title"; name.textContent = route.saved ? route.title : route.label;
const button = document.createElement("button"); button.type = "button"; button.className = "button secondary"; tag.className = "planner-option-tag"; tag.textContent = selected?.id === route.id ? "Selected" : "View route";
button.textContent = selected?.id === route.id ? "Selected" : "View"; 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); 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) { function show(route) {
selected = route; el("planner-results").hidden = false; drawOptions(); routeLayer.clearLayers(); selected = route; el("planner-results").hidden = false; drawOptions(); routeLayer.clearLayers();
const points = route.coordinates.map(c => [c[1], c[0]]); const points = route.coordinates.map(c => [c[1], c[0]]);
const line = L.polyline(points, {color: colour, weight: 5}).addTo(routeLayer); 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); for (const marker of Object.values(markers)) map.removeLayer(marker);
L.circleMarker(points.at(-1), {radius: 5, color: colour, fillOpacity: 1}).bindTooltip("Route finish").addTo(routeLayer); 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]}); map.fitBounds(line.getBounds(), {padding: [30, 30]});
el("route-heading").textContent = route.saved ? route.title : route.label; 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-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)}.`; 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(); 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]]) { 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"); 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); 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; if (chart) chart.destroy(); chart = null;
el("route-profile").hidden = !route.profile.length; 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."; 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("route-export").hidden = !route.saved; el("route-export").href = `${base}/${route.id}/gpx`;
el("delete-route").hidden = !route.saved; el("delete-route").hidden = !route.saved;
} }
@@ -92,6 +203,8 @@
e.preventDefault(); if (busy) return; e.preventDefault(); if (busy) return;
try { try {
const start = coordinate("plan-start"), mode = el("plan-mode").value; 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}; 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…"; 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; 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 button = e.target.closest("[data-saved]"); if (!button) return;
const token = ++generation; const token = ++generation;
try {const route = await api(`/${button.dataset.saved}`); if (token !== generation) return; 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."; routes = [route]; show(route); status.textContent = "Saved route loaded.";
} catch (error) {status.textContent = error.message;} } catch (error) {status.textContent = error.message;}
}; };
+32 -16
View File
@@ -1,30 +1,46 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% from '_ui.html' import icon %}
{% block title %}Route planner · Stride{% endblock %} {% block title %}Route planner · Stride{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">
<link rel="stylesheet" href="{{ url_for('static',filename='planner.css') }}"> <link rel="stylesheet" href="{{ url_for('static',filename='planner.css') }}">
{% endblock %} {% 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 %} {% block content %}
<div id="route-planner" data-csrf="{{ csrf_token() }}" data-base="{{ url_for('planner.index') }}"> <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 %} {% 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 class="planner-stage">
<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> <form id="planner-form" class="planner-controls">
<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 class="planner-section-heading"><span class="eyebrow">YOUR ROUTE</span>{{ icon('route') }}</div>
<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> <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>
<button type="button" class="text-button" id="my-location">Use my location</button></div> {{ location('start', 'Start location', 'S') }}
<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 id="finish-fields" hidden>{{ location('end', 'Finish location', 'F') }}</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 id="loop-note" class="loop-note">↻ Finish at your starting location</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> <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> </form>
<p id="planner-status" role="status" aria-live="polite">Select a start on the map, or enter coordinates.</p> <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 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> </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> </div>
{% endblock %} {% 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 %} {% 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 # Route planner
Open **Routes** in Stride. Select the start on the map, enter latitude/longitude, 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 hill difficulty, surface preference and ranking priority. Generated candidates show
their actual distance, ascent, route geometry, elevation profile and quality coverage. their actual distance, ascent, route geometry, elevation profile and quality coverage.
Save a named route privately, reopen it, download GPX, or delete it. 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. Crossing counts, lighting, current access and live traffic are unverified.
GPX includes the route and available elevation, without fictional timestamps. 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 ## Persistence and validation
+85 -1
View File
@@ -7,7 +7,7 @@ import pytest
from app import create_app from app import create_app
from app.extensions import db from app.extensions import db
from app.models import PlannedRoute, User 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 @pytest.fixture
@@ -223,3 +223,87 @@ def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch):
with app.app_context(): with app.app_context():
assert db.session.get(PlannedRoute, 1) is None assert db.session.get(PlannedRoute, 1) is None
assert db.session.get(PlannedRoute, 2).saved 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)