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
+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 %}