Add running route planner with quality comparison and GPX export

This commit is contained in:
Peter Stockings
2026-10-03 00:33:41 +10:00
parent a06189849e
commit 52943cd05b
12 changed files with 899 additions and 0 deletions
+7
View File
@@ -80,6 +80,13 @@ Cards show metric definitions and usable-run coverage. Select a chart point or
group row to inspect contributing runs. Save named views in this browser, or
bookmark the results URL; relative queries follow the calendar as runs are added.
### Planning running routes
Open **Routes** to plan loops or point-to-point runs with a target distance,
hill difficulty and surface preference. Compare route maps, elevation profiles
and quality coverage; save routes privately and export GPX. Live generation
requires a server-side `ORS_API_KEY`. See [route planner setup and limitations](docs/ROUTE_PLANNER.md).
### Bringing in more activities
Use **Settings & imports -> Connect Garmin** to upload an exported session, then
+8
View File
@@ -26,9 +26,14 @@ def create_app(config=None):
LOCAL_SETUP=not production,
DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")),
TOKEN_ENCRYPTION_KEY=os.environ.get("TOKEN_ENCRYPTION_KEY"),
ORS_API_KEY=os.environ.get("ORS_API_KEY"),
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
)
app.config.update(config or {})
if not app.config.get("ORS_API_KEY") and not production:
routing_key = Path(app.instance_path) / "ors-api-key"
if routing_key.is_file():
app.config["ORS_API_KEY"] = routing_key.read_text(encoding="utf-8").strip()
if production:
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -64,6 +69,9 @@ def create_app(config=None):
from .routes import web
app.register_blueprint(web)
from .planner import planner
app.register_blueprint(planner)
from .sync import register_cli
register_cli(app)
+11
View File
@@ -90,6 +90,17 @@ class Activity(db.Model):
return f"{seconds // 60}:{seconds % 60:02d}"
class PlannedRoute(db.Model):
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
title = db.Column(db.String(160), nullable=False)
saved = db.Column(db.Boolean, nullable=False, default=False)
payload = db.Column(db.JSON, nullable=False)
created_at = db.Column(
db.DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
)
class Friendship(db.Model):
id = db.Column(db.Integer, primary_key=True)
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
+111
View File
@@ -0,0 +1,111 @@
from datetime import UTC, datetime, timedelta
from xml.etree.ElementTree import Element, SubElement, tostring
from flask import Blueprint, Response, abort, current_app, jsonify, render_template, request
from flask_login import current_user, login_required
from .extensions import db, limiter
from .models import PlannedRoute
from .planner_engine import PlannerError, generate, validate
planner = Blueprint("planner", __name__, url_prefix="/routes")
def owned(route_id, saved_only=False):
route = db.get_or_404(PlannedRoute, route_id)
if route.owner_id != current_user.id or (saved_only and not route.saved):
abort(404)
return route
def serialise(route):
return dict(id=route.id, title=route.title, saved=route.saved, **route.payload)
@planner.get("")
@login_required
def index():
saved = db.session.scalars(
db.select(PlannedRoute)
.where(PlannedRoute.owner_id == current_user.id, PlannedRoute.saved.is_(True))
.order_by(PlannedRoute.created_at.desc(), PlannedRoute.id.desc())
.limit(100)
).all()
return render_template(
"planner.html", configured=bool(current_app.config.get("ORS_API_KEY")), saved_routes=saved
)
@planner.post("/generate")
@login_required
@limiter.limit("6 per minute; 60 per day")
def candidates():
try:
preferences = validate(request.get_json(silent=True))
routes, warnings = generate(preferences, current_app.config.get("ORS_API_KEY"))
except PlannerError as error:
return jsonify(error=str(error)), 400
cutoff = datetime.now(UTC) - timedelta(days=1)
db.session.execute(
db.delete(PlannedRoute).where(
PlannedRoute.owner_id == current_user.id,
PlannedRoute.saved.is_(False),
PlannedRoute.created_at < cutoff,
)
)
rows = []
for route in routes:
row = PlannedRoute(owner_id=current_user.id, title=route["label"], payload=route)
db.session.add(row)
rows.append(row)
db.session.commit()
return jsonify(routes=[serialise(row) for row in rows], warnings=warnings)
@planner.get("/<int:route_id>")
@login_required
def detail(route_id):
return jsonify(serialise(owned(route_id, saved_only=True)))
@planner.post("/<int:route_id>/save")
@login_required
def save(route_id):
row = owned(route_id)
data = request.get_json(silent=True)
title = data.get("title") if isinstance(data, dict) else None
if not isinstance(title, str) or not 1 <= len(title.strip()) <= 160:
return jsonify(error="Name your route using 1–160 characters."), 400
row.title = title.strip()
row.saved = True
db.session.commit()
return jsonify(serialise(row))
@planner.post("/<int:route_id>/delete")
@login_required
def delete(route_id):
db.session.delete(owned(route_id, saved_only=True))
db.session.commit()
return jsonify(ok=True)
@planner.get("/<int:route_id>/gpx")
@login_required
def export(route_id):
row = owned(route_id, saved_only=True)
root = Element(
"gpx", {"version": "1.1", "creator": "Stride", "xmlns": "http://www.topografix.com/GPX/1/1"}
)
track = SubElement(root, "trk")
SubElement(track, "name").text = row.title
segment = SubElement(track, "trkseg")
for coordinate in row.payload["coordinates"]:
point = SubElement(segment, "trkpt", {"lat": str(coordinate[1]), "lon": str(coordinate[0])})
if len(coordinate) == 3:
SubElement(point, "ele").text = str(coordinate[2])
return Response(
tostring(root, encoding="utf-8", xml_declaration=True),
mimetype="application/gpx+xml",
headers={"Content-Disposition": f'attachment; filename="stride-route-{row.id}.gpx"'},
)
+271
View File
@@ -0,0 +1,271 @@
"""Bounded pedestrian route generation and explainable ranking, using ORS GeoJSON."""
import itertools
import json
import math
from collections import Counter
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
class PlannerError(ValueError):
pass
def number(value, low, high, label):
if isinstance(value, bool):
raise PlannerError(f"Choose a valid {label}.")
try:
value = float(value)
except (ValueError, TypeError):
raise PlannerError(f"Choose a valid {label}.") from None
if not math.isfinite(value) or not low <= value <= high:
raise PlannerError(f"{label.capitalize()} must be between {low} and {high}.")
return value
def point(value):
if not isinstance(value, list) or len(value) != 2:
raise PlannerError("Select a location on the map or enter latitude, longitude.")
return [number(value[0], -180, 180, "longitude"), number(value[1], -85, 85, "latitude")]
def validate(data):
if not isinstance(data, dict):
raise PlannerError("Supply route preferences.")
mode = data.get("mode", "loop")
hill = data.get("hill", "rolling")
surface = data.get("surface", "any")
priority = data.get("priority", "quality")
if not all(isinstance(value, str) for value in (mode, hill, surface, priority)):
raise PlannerError("Choose valid route preferences.")
if mode not in {"loop", "point"} or hill not in {"flat", "rolling", "hilly"}:
raise PlannerError("Choose a valid route type and hill difficulty.")
if surface not in {"any", "paved", "trails"} or priority not in {
"quality",
"distance",
"hills",
}:
raise PlannerError("Choose valid route preferences.")
start = point(data.get("start"))
end = start if mode == "loop" else point(data.get("end"))
distance = number(data.get("distance"), 1, 50, "distance (km)") * 1000
if not isinstance(data.get("avoid_repeats", True), bool):
raise PlannerError("Choose whether to minimise repeated sections.")
if mode == "point" and metres(start, end) > distance * 1.05:
raise PlannerError("Your target distance is shorter than the straight-line distance.")
return {
"mode": mode,
"hill": hill,
"surface": surface,
"priority": priority,
"start": start,
"end": end,
"distance": distance,
"avoid_repeats": bool(data.get("avoid_repeats", True)),
}
def metres(a, b):
lat1, lat2 = math.radians(a[1]), math.radians(b[1])
dlat, dlon = lat2 - lat1, math.radians(b[0] - a[0])
value = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
return 12742000 * math.asin(math.sqrt(min(1, value)))
def destination(origin, distance, bearing):
lat, lon = map(math.radians, [origin[1], origin[0]])
angle, bearing = distance / 6371000, math.radians(bearing)
target = math.asin(
math.sin(lat) * math.cos(angle) + math.cos(lat) * math.sin(angle) * math.cos(bearing)
)
longitude = lon + math.atan2(
math.sin(bearing) * math.sin(angle) * math.cos(lat),
math.cos(angle) - math.sin(lat) * math.sin(target),
)
return [(math.degrees(longitude) + 180) % 360 - 180, math.degrees(target)]
def provider_request(body, key):
request = Request(
"https://api.openrouteservice.org/v2/directions/foot-walking/geojson",
data=json.dumps(body).encode(),
headers={"Authorization": key, "Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=12) as response:
raw = response.read(4_000_001)
if len(raw) > 4_000_000:
raise PlannerError("Routing returned too much data. Try a shorter route.")
result = json.loads(raw)
if not isinstance(result, dict) or not isinstance(result.get("features"), list):
raise PlannerError("The routing service returned an incomplete response.")
return result
except HTTPError as error:
if error.code in (401, 403):
raise PlannerError(
"The routing service key was rejected. Contact the site operator."
) from None
if error.code == 429:
raise PlannerError("The routing service is busy. Please try again later.") from None
raise PlannerError("No route was found for these locations. Try nearby paths.") from None
except (URLError, TimeoutError, OSError, json.JSONDecodeError):
raise PlannerError("The routing service could not be reached. Please try again.") from None
def coverage(extras, name, codes, total):
if not isinstance(extras, dict) or not isinstance(extras.get(name, {}), dict):
raise PlannerError("The routing service returned invalid quality data.")
summary = extras.get(name, {}).get("summary", [])
if not summary:
return None
if not isinstance(summary, list) or any(not isinstance(row, dict) for row in summary):
raise PlannerError("The routing service returned invalid quality data.")
distance = sum(
number(row.get("distance", 0), 0, 200000, "quality distance")
for row in summary
if isinstance(row.get("value"), (int, float))
and not isinstance(row.get("value"), bool)
and row.get("value") in codes
)
return round(min(100, distance / total * 100), 1)
def analyse(feature, preferences):
if not isinstance(feature, dict) or not isinstance(feature.get("geometry"), dict):
raise PlannerError("The routing service returned invalid route data.")
geometry = feature.get("geometry", {})
if geometry.get("type") != "LineString":
raise PlannerError("The routing service returned an unsupported route.")
coordinates = geometry.get("coordinates", [])
if not isinstance(coordinates, list) or not 2 <= len(coordinates) <= 50000:
raise PlannerError("The routing service returned an incomplete route.")
clean = []
for coordinate in coordinates:
if not isinstance(coordinate, list) or len(coordinate) not in (2, 3):
raise PlannerError("The routing service returned invalid coordinates.")
xy = point(coordinate[:2])
if len(coordinate) == 3:
xy.append(number(coordinate[2], -12000, 12000, "elevation"))
clean.append(xy)
props = feature.get("properties", {})
if not isinstance(props, dict) or not isinstance(props.get("summary"), dict):
raise PlannerError("The routing service returned an incomplete summary.")
summary = props.get("summary", {})
distance = number(summary.get("distance"), 1, 200000, "route distance")
ascent = props.get("ascent", summary.get("ascent"))
ascent = number(ascent, 0, 20000, "ascent") if ascent is not None else None
extras = props.get("extras", {})
paths = coverage(extras, "waytype", {4, 5, 6, 7}, distance)
roads = coverage(extras, "waytype", {1, 2, 3}, distance)
paved = coverage(extras, "surface", {1, 3, 4, 5, 6, 14}, distance)
unknown = coverage(extras, "surface", {0}, distance)
if unknown is not None:
known = coverage(extras, "surface", set(range(1, 19)), distance)
unknown = round(max(unknown, 100 - (known or 0)), 1)
# Approximate repeated edges, irrespective of travel direction; not a traffic/safety score.
seen = Counter()
repeated = 0
travelled = 0
profile = [[0, clean[0][2]]] if len(clean[0]) == 3 else []
for a, b in itertools.pairwise(clean):
length = metres(a, b)
edge = tuple(sorted((tuple(round(x, 5) for x in a[:2]), tuple(round(x, 5) for x in b[:2]))))
if seen[edge]:
repeated += length
seen[edge] += 1
travelled += length
if len(b) == 3:
profile.append([travelled, b[2]])
if len(profile) != len(clean):
profile = []
elif travelled:
profile = [[round(p[0] / travelled * distance, 1), p[1]] for p in profile]
sampled_profile = profile[:: max(1, math.ceil(len(profile) / 600))]
if profile and sampled_profile[-1] != profile[-1]:
sampled_profile.append(profile[-1])
repeats = round(100 * repeated / travelled, 1) if travelled else 0
deviation = (distance - preferences["distance"]) / preferences["distance"] * 100
climb_target = {"flat": 3, "rolling": 12, "hilly": 25}[preferences["hill"]]
hill_penalty = abs(ascent / (distance / 1000) - climb_target) if ascent is not None else 25
quality_penalty = (roads if roads is not None else 50) / 5
quality_penalty += (unknown if unknown is not None else 100) / 10
if preferences["avoid_repeats"]:
quality_penalty += repeats / 3
if preferences["surface"] == "paved":
quality_penalty += (100 - paved if paved is not None else 100) / 5
if preferences["surface"] == "trails":
quality_penalty += (100 - paths if paths is not None else 100) / 5
score = abs(deviation) * (3 if preferences["priority"] == "distance" else 1)
score += hill_penalty * (2 if preferences["priority"] == "hills" else 0.5)
score += quality_penalty * (2 if preferences["priority"] == "quality" else 0.5)
return {
"coordinates": clean,
"distance_m": distance,
"ascent_m": ascent,
"profile": sampled_profile,
"quality": {
"paths_pct": paths,
"roads_pct": roads,
"paved_pct": paved,
"unknown_surface_pct": unknown,
"repeats_pct": repeats,
},
"deviation_pct": round(deviation, 1),
"within_target": abs(deviation) <= 5,
"preferences": preferences,
"rank_cost": score,
}
def generate(preferences, key):
if not key:
raise PlannerError("Route generation needs ORS_API_KEY configured by the site operator.")
routes, errors, signatures = [], [], set()
for index in range(3):
body = {
"elevation": True,
"instructions": False,
"extra_info": ["surface", "waytype"],
"coordinates": [preferences["start"]],
}
if preferences["mode"] == "loop":
body["options"] = {
"round_trip": {"length": preferences["distance"], "points": 3, "seed": index + 1}
}
else:
start, end = preferences["start"], preferences["end"]
body["coordinates"] = [start, end]
if index:
separation = metres(start, end)
midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
offset = (
math.sqrt(max(0, (preferences["distance"] / 2) ** 2 - (separation / 2) ** 2))
* 0.65
)
bearing = math.degrees(
math.atan2(
(end[0] - start[0]) * math.cos(math.radians(midpoint[1])), end[1] - start[1]
)
)
waypoint = destination(
midpoint, max(100, offset), bearing + (90 if index == 1 else -90)
)
body["coordinates"] = [start, waypoint, end]
try:
result = provider_request(body, key)
for feature in result.get("features", [])[:1]:
route = analyse(feature, preferences)
signature = json.dumps(route["coordinates"])
if signature not in signatures:
signatures.add(signature)
routes.append(route)
except PlannerError as error:
errors.append(str(error))
if not routes:
raise PlannerError(errors[0] if errors else "No routes were found. Try a different start.")
routes.sort(key=lambda route: route["rank_cost"])
for index, route in enumerate(routes):
route["label"] = "Best match" if index == 0 else f"Alternative {index + 1}"
return routes, errors
+3
View File
@@ -0,0 +1,3 @@
.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)}
+129
View File
@@ -0,0 +1,129 @@
"use strict";
(() => {
const root = document.getElementById("route-planner");
if (!root || !window.L) return;
const el = id => document.getElementById(id);
const base = root.dataset.base;
const status = el("planner-status");
const map = L.map("planner-map").setView([-33.8688, 151.2093], 12);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}).addTo(map);
const routeLayer = L.layerGroup().addTo(map);
const markers = {};
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 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.");
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) {
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)}`;
}
function setMode() {
const point = el("plan-mode").value === "point";
el("finish-fields").hidden = !point; el("plan-end").required = point;
if (!point) { picking = "start"; if (markers.end) {map.removeLayer(markers.end); delete markers.end;} }
}
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.";};
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";});
el("my-location").onclick = () => {
if (!navigator.geolocation) {status.textContent = "Location is unavailable. Select a point on the map."; return;}
navigator.geolocation.getCurrentPosition(position => {
const p = L.latLng(position.coords.latitude, position.coords.longitude);
mark("start", p); map.setView(p, 15); status.textContent = "Your location is set as the start.";
}, () => {status.textContent = "Location could not be obtained. Select a point on the map.";}, {timeout: 10000});
};
async function api(path, data) {
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);
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 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";
button.setAttribute("aria-pressed", String(selected?.id === route.id)); button.onclick = () => show(route);
cell(row, "").append(button); el("route-options").append(row);
}
}
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);
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("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 (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)"}}}}});
el("route-export").hidden = !route.saved; el("route-export").href = `${base}/${route.id}/gpx`;
el("delete-route").hidden = !route.saved;
}
el("planner-form").onsubmit = async e => {
e.preventDefault(); if (busy) return;
try {
const start = coordinate("plan-start"), mode = el("plan-mode").value;
const data = {start, mode, end: mode === "point" ? coordinate("plan-end") : start, distance: Number(el("plan-distance").value), hill: el("plan-hill").value, surface: el("plan-surface").value, priority: el("plan-priority").value, avoid_repeats: el("plan-repeats").checked};
busy = true; const token = ++generation; el("generate-routes").disabled = true; status.textContent = "Finding and comparing walking routes…";
const result = await api("/generate", data); if (token !== generation) return;
routes = result.routes; show(routes[0]); status.textContent = `${routes.length} distinct route${routes.length === 1 ? "" : "s"} found. Best match balances your distance, hill and quality preferences.${result.warnings.length ? " Some alternatives could not be generated." : ""}`;
} catch (error) {status.textContent = error.message;} finally {busy = false; el("generate-routes").disabled = false;}
};
function savedButton(route) {
el("empty-saved")?.remove(); let button = el("saved-routes").querySelector(`[data-saved="${route.id}"]`);
if (!button) {button = document.createElement("button"); button.type = "button"; button.className = "button secondary"; button.dataset.saved = route.id; el("saved-routes").prepend(button);}
button.textContent = route.title;
}
el("save-route-form").onsubmit = async e => {
e.preventDefault(); if (!selected) return; const id = selected.id;
const button = e.target.querySelector("button"); button.disabled = true;
try {const route = await api(`/${id}/save`, {title: el("route-name").value}); savedButton(route);
routes = routes.map(r => r.id === id ? route : r); if (selected?.id === id) show(route); status.textContent = "Route saved privately.";
} catch (error) {status.textContent = error.message;} finally {button.disabled = false;}
};
el("saved-routes").onclick = async e => {
const button = e.target.closest("[data-saved]"); if (!button) return;
const token = ++generation;
try {const route = await api(`/${button.dataset.saved}`); if (token !== generation) return;
routes = [route]; show(route); status.textContent = "Saved route loaded.";
} catch (error) {status.textContent = error.message;}
};
el("delete-route").onclick = async () => {
if (!selected?.saved || !window.confirm("Delete this saved route?")) return;
const id = selected.id; el("delete-route").disabled = true;
try {await api(`/${id}/delete`, {}); el("saved-routes").querySelector(`[data-saved="${id}"]`)?.remove();
routes = routes.filter(r => r.id !== id); if (selected?.id === id) {selected = null; routeLayer.clearLayers(); el("planner-results").hidden = true; if (chart) chart.destroy(); chart = null;}
status.textContent = "Saved route deleted.";
} catch (error) {status.textContent = error.message;} finally {el("delete-route").disabled = false;}
};
new ResizeObserver(() => map.invalidateSize()).observe(el("planner-map")); setMode();
})();
+1
View File
@@ -14,6 +14,7 @@
<a class="brand" href="{{ url_for('web.dashboard') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<nav class="header-nav" aria-label="Main navigation"><a class="{{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">Dashboard</a><a class="{{ 'active' if request.endpoint in ('web.friends', 'web.profile') }}" href="{{ url_for('web.friends') }}">Friends</a></nav>
<a class="stats-nav {{ 'active' if request.endpoint == 'web.stats' }}" href="{{ url_for('web.stats') }}">Stats</a>
<a class="stats-nav {{ 'active' if request.blueprint == 'planner' }}" href="{{ url_for('planner.index') }}">Routes</a>
<div class="header-actions">{% if request.endpoint != 'web.profile' %}<a class="button primary header-import" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import run</a>{% endif %}<details class="account-dropdown"><summary class="avatar" aria-label="Account menu" title="Account menu">{{ current_user.display_name[:1]|upper }}</summary><div class="account-dropdown-menu"><strong>{{ current_user.display_name }}</strong><a href="{{ url_for('web.settings') }}">Settings & Garmin sync</a><a href="{{ url_for('web.settings') }}">Import runs</a><form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">Sign out</button></form></div></details></div>
</header>
{% else %}
+30
View File
@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% 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 %}
{% 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>
{% 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>
</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>
<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>
</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 %}
+77
View File
@@ -0,0 +1,77 @@
# 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),
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.
## Configuration
Apply migrations with `uv run --locked flask --app app db upgrade`.
Set `ORS_API_KEY` in the web process environment and restart the web process.
For local development, the app also reads `instance/ors-api-key` when the environment
key is absent. This private file is ignored by Git; production only uses environment
configuration. Keep the file within your private Windows account/workspace.
For Dokku, configure `ORS_API_KEY` through your normal secret-management workflow.
The key stays server-side and is never included in rendered HTML or requests from
the browser. Obtain a key at https://openrouteservice.org/ and review the provider's
current quotas. The planner sends selected coordinates to the fixed ORS HTTPS
endpoint. Browser map tiles use the app's existing OpenStreetMap tile service.
Without a key, generation is disabled with a configuration notice; saved routes,
maps and export remain available. No synthetic routes are shown in the real app.
## Generation and ranking
The server makes at most three requests per generation, each with a 12-second
network timeout and a 4 MB response limit. Requests use `foot-walking`, elevation,
and `surface`/`waytype` extras. Loops use three deterministic round-trip seeds.
Point-to-point candidates include a direct route and two estimated side waypoints
to seek a longer route near the target. Duplicate geometries are removed. This
bounded search can return fewer than three routes and cannot guarantee a distance
or ascent target. The interface flags results outside ±5% distance.
Preferences rank candidates; they are not strict exclusions. Hill targets are
approximately 3, 12 or 25 metres ascent/km. The cost combines distance deviation,
ascent mismatch, mapped road share, missing surface coverage, optional repeated
edges and surface preference. The priority weights distance, hills or quality
more strongly. This is an explainable heuristic, not a safety or fitness score.
Quality percentages follow ORS documented waytype and surface IDs:
- Paths/tracks: path, track, cycleway and footway.
- Road share: state road, road and street; this does not measure traffic.
- Paved: paved, asphalt, concrete, legacy cobblestone, metal and paving stones.
- Missing surface data is unknown; absent extras do not become zero.
- Repeated distance estimates subsequent traversals of the same rounded geometry
edge in either direction. Different segmentation can miss repeated sections.
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.
## Persistence and validation
Candidates and saved routes are server-side and owner-scoped. Other users,
including friends, cannot view, mutate or export them. Save and delete use CSRF
protection. Unsaved candidates older than one day are cleaned on the owner's next
generation. The library lists the latest 100 saved routes. Generation is limited
to six requests/minute and 60/day using the existing limiter storage configuration.
Offline tests mock ORS and cover input validation, ranking, point-to-point and
round-trip request shapes, deduplication, missing attributes, repeated sections,
CSRF, ownership, save/delete/export and draft cleanup. Live ORS verification needs
a configured API key; mocked tests do not establish live provider availability.
Live local verification on 2026-10-03 used public Centennial Park test coordinates.
Loop and point-to-point routing each returned three distinct candidates; private
save, reload and GPX export succeeded in a disposable in-memory test database.
The best loop was 5,030.9 m with 61.5 m ascent and 90% mapped paths/footways.
The local browser rendered the real route, quality figures and elevation profile
without console errors. The suite passed 129 tests. Live provider category IDs
arrive as decimal numbers; a regression test covers that response format.
Provider references:
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/waytype
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/surface
@@ -0,0 +1,26 @@
"""Private route plans and generated candidates."""
import sqlalchemy as sa
from alembic import op
revision = "a219de4f805b"
down_revision = "09a7c863b521"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"planned_route",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False),
sa.Column("title", sa.String(160), nullable=False),
sa.Column("saved", sa.Boolean(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_planned_route_owner_id", "planned_route", ["owner_id"])
def downgrade():
op.drop_table("planned_route")
+225
View File
@@ -0,0 +1,225 @@
from copy import deepcopy
from datetime import UTC, datetime, timedelta
from xml.etree import ElementTree
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
@pytest.fixture
def app(tmp_path):
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"SECRET_KEY": "planner-test",
"WTF_CSRF_ENABLED": False,
"RATELIMIT_ENABLED": False,
"LOCAL_SETUP": False,
"ORS_API_KEY": "test-key",
"DATA_ROOT": str(tmp_path),
}
)
with app.app_context():
db.create_all()
for name in ("runner", "other"):
user = User(username=name, email=f"{name}@example.test", display_name=name)
user.set_password("a-long-test-password")
db.session.add(user)
db.session.commit()
yield app
def login(client, name="runner"):
client.post(
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
)
def preferences():
return {"start": [151, -33], "mode": "loop", "distance": 5, "hill": "rolling"}
def feature():
return {
"geometry": {
"type": "LineString",
"coordinates": [[151, -33, 10], [151.001, -33, 30], [151, -33, 10]],
},
"properties": {
"summary": {"distance": 5100},
"ascent": 20,
"extras": {
"waytype": {
"summary": [{"value": 7, "distance": 4080}, {"value": 3, "distance": 1020}]
},
"surface": {
"summary": [{"value": 3, "distance": 4590}, {"value": 0, "distance": 510}]
},
},
},
}
@pytest.mark.parametrize(
"change",
[
{"distance": float("nan")},
{"distance": 51},
{"start": [151, 91]},
{"start": [True, -33]},
{"hill": "invalid"},
{"mode": "point"},
{"surface": "invalid"},
{"distance": False},
{"mode": []},
{"avoid_repeats": "false"},
],
)
def test_validation(change):
with pytest.raises(PlannerError):
validate(preferences() | change)
def test_point_distance_rejected():
with pytest.raises(PlannerError, match="straight-line"):
validate(preferences() | {"mode": "point", "end": [152, -33]})
def test_quality_and_missing_data():
plan = analyse(feature(), validate(preferences()))
assert plan["quality"] == {
"paths_pct": 80,
"roads_pct": 20,
"paved_pct": 90,
"unknown_surface_pct": 10,
"repeats_pct": 50,
}
assert plan["within_target"]
assert len(plan["profile"]) == 3
assert plan["profile"][-1][0] == 5100
missing = feature()
missing["properties"].pop("extras")
missing["properties"].pop("ascent")
for coordinate in missing["geometry"]["coordinates"]:
coordinate.pop()
plan = analyse(missing, validate(preferences()))
assert plan["quality"]["paths_pct"] is None
assert plan["quality"]["unknown_surface_pct"] is None
assert plan["ascent_m"] is None
assert plan["profile"] == []
def test_provider_float_category_ids():
item = feature()
for extra in item["properties"]["extras"].values():
for row in extra["summary"]:
row["value"] = float(row["value"])
plan = analyse(item, validate(preferences()))
assert plan["quality"]["paths_pct"] == 80
assert plan["quality"]["roads_pct"] == 20
assert plan["quality"]["paved_pct"] == 90
assert plan["quality"]["unknown_surface_pct"] == 10
def test_ranking_and_provider_contract(monkeypatch):
calls = []
def fake(body, key):
calls.append(deepcopy(body))
item = feature()
item["properties"]["summary"]["distance"] = [7000, 5050, 5300][len(calls) - 1]
item["geometry"]["coordinates"][1][0] += len(calls) / 100
return {"features": [item]}
monkeypatch.setattr("app.planner_engine.provider_request", fake)
routes, errors = generate(validate(preferences() | {"priority": "distance"}), "test")
assert len(routes) == 3 and not errors
assert routes[0]["distance_m"] == 5050
assert [x["options"]["round_trip"]["seed"] for x in calls] == [1, 2, 3]
assert all(x["elevation"] and x["extra_info"] == ["surface", "waytype"] for x in calls)
def test_point_requests_and_duplicates(monkeypatch):
calls = []
def fake(body, key):
calls.append(body)
return {"features": [feature()]}
monkeypatch.setattr("app.planner_engine.provider_request", fake)
routes, _ = generate(validate(preferences() | {"mode": "point", "end": [151.01, -33]}), "test")
assert len(routes) == 1
assert [len(body["coordinates"]) for body in calls] == [2, 3, 3]
assert all("options" not in body for body in calls)
def test_generate_save_export_delete_and_ownership(app, monkeypatch):
monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]})
client = app.test_client()
assert client.get("/routes").status_code == 302
login(client)
assert client.get("/routes").status_code == 200
response = client.post("/routes/generate", json=preferences())
assert response.status_code == 200
route_id = response.json["routes"][0]["id"]
assert client.get(f"/routes/{route_id}/gpx").status_code == 404
assert (
client.post(f"/routes/{route_id}/save", json={"title": "Morning & hills"}).status_code
== 200
)
exported = client.get(f"/routes/{route_id}/gpx")
assert exported.status_code == 200
document = ElementTree.fromstring(exported.data)
ns = {"g": "http://www.topografix.com/GPX/1/1"}
assert document.find("g:trk/g:name", ns).text == "Morning & hills"
assert len(document.findall(".//g:trkpt", ns)) == 3
assert document.find(".//g:trkpt", ns).attrib == {"lat": "-33.0", "lon": "151.0"}
other = app.test_client()
login(other, "other")
for suffix in ("", "/gpx"):
assert other.get(f"/routes/{route_id}{suffix}").status_code == 404
for suffix in ("save", "delete"):
assert (
other.post(f"/routes/{route_id}/{suffix}", json={"title": "stolen"}).status_code == 404
)
assert b"Morning &amp; hills" not in other.get("/routes").data
assert client.post(f"/routes/{route_id}/delete", json={}).status_code == 200
assert client.get(f"/routes/{route_id}").status_code == 404
def test_configuration_failure_and_csrf(app):
app.config["ORS_API_KEY"] = None
client = app.test_client()
login(client)
assert b"not connected yet" in client.get("/routes").data
response = client.post("/routes/generate", json=preferences())
assert response.status_code == 400 and "ORS_API_KEY" in response.json["error"]
app.config["WTF_CSRF_ENABLED"] = True
assert client.post("/routes/generate", json=preferences()).status_code == 400
def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch):
monkeypatch.setattr("app.planner_engine.provider_request", lambda *_: {"features": [feature()]})
with app.app_context():
for saved in (False, True):
db.session.add(
PlannedRoute(
owner_id=1,
title="old",
saved=saved,
created_at=datetime.now(UTC) - timedelta(days=2),
payload={},
)
)
db.session.commit()
client = app.test_client()
login(client)
assert client.post("/routes/generate", json=preferences()).status_code == 200
with app.app_context():
assert db.session.get(PlannedRoute, 1) is None
assert db.session.get(PlannedRoute, 2).saved