diff --git a/README.md b/README.md index 0372ef6..4d7585e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/__init__.py b/app/__init__.py index 21533d9..841c796 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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) diff --git a/app/models.py b/app/models.py index b5a73dc..126a6bf 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/planner.py b/app/planner.py new file mode 100644 index 0000000..646e979 --- /dev/null +++ b/app/planner.py @@ -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("/") +@login_required +def detail(route_id): + return jsonify(serialise(owned(route_id, saved_only=True))) + + +@planner.post("//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("//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("//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"'}, + ) diff --git a/app/planner_engine.py b/app/planner_engine.py new file mode 100644 index 0000000..45e0fa5 --- /dev/null +++ b/app/planner_engine.py @@ -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 diff --git a/app/static/planner.css b/app/static/planner.css new file mode 100644 index 0000000..5c6328a --- /dev/null +++ b/app/static/planner.css @@ -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)} diff --git a/app/static/planner.js b/app/static/planner.js new file mode 100644 index 0000000..302698c --- /dev/null +++ b/app/static/planner.js @@ -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: '© OpenStreetMap'}).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(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index 95b0710..6a021d2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,6 +14,7 @@ sstride. Stats + Routes
{% if request.endpoint != 'web.profile' %}{{ icon('plus') }} Import run{% endif %}
{% else %} diff --git a/app/templates/planner.html b/app/templates/planner.html new file mode 100644 index 0000000..345599a --- /dev/null +++ b/app/templates/planner.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% block title %}Route planner · Stride{% endblock %} +{% block head %} + + +{% endblock %} +{% block content %} +
+
PLAN / COMPARE / RUN

Find your next route.

Your distance. Your hills. A route worth running.

+{% if not configured %}
Route generation is not connected yet. The site operator needs to configure ORS_API_KEY. You can still view and export your saved routes.
{% endif %} +
+
+ + +
+

Aim within ±5%; actual distance may vary.

+
+

Locations are sent to openrouteservice for walking-path routing. Hill and quality choices rank the returned candidates.

+
+

Select a start on the map, or enter coordinates.

+
+

Routing © openrouteservice · OpenStreetMap contributors. Mapped paths do not verify current access, traffic or lighting.

+ +

Your saved routes

Private to your account · most recent 100 shown

{% for route in saved_routes %}{% else %}

No saved routes yet.

{% endfor %}
+
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/docs/ROUTE_PLANNER.md b/docs/ROUTE_PLANNER.md new file mode 100644 index 0000000..7d5ab8f --- /dev/null +++ b/docs/ROUTE_PLANNER.md @@ -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 diff --git a/migrations/versions/a219de4f805b_planned_routes.py b/migrations/versions/a219de4f805b_planned_routes.py new file mode 100644 index 0000000..87eaad7 --- /dev/null +++ b/migrations/versions/a219de4f805b_planned_routes.py @@ -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") diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 0000000..bab2f97 --- /dev/null +++ b/tests/test_planner.py @@ -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 & 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