diff --git a/app/planner.py b/app/planner.py index 646e979..ea5fa1b 100644 --- a/app/planner.py +++ b/app/planner.py @@ -6,7 +6,7 @@ from flask_login import current_user, login_required from .extensions import db, limiter from .models import PlannedRoute -from .planner_engine import PlannerError, generate, validate +from .planner_engine import PlannerError, generate, search_locations, validate planner = Blueprint("planner", __name__, url_prefix="/routes") @@ -62,6 +62,19 @@ def candidates(): return jsonify(routes=[serialise(row) for row in rows], warnings=warnings) +@planner.post("/search") +@login_required +@limiter.limit("30 per minute; 600 per day") +def search(): + try: + locations = search_locations( + request.get_json(silent=True), current_app.config.get("ORS_API_KEY") + ) + except PlannerError as error: + return jsonify(error=str(error)), 400 + return jsonify(locations=locations) + + @planner.get("/") @login_required def detail(route_id): diff --git a/app/planner_engine.py b/app/planner_engine.py index 45e0fa5..c682a7c 100644 --- a/app/planner_engine.py +++ b/app/planner_engine.py @@ -5,6 +5,7 @@ import json import math from collections import Counter from urllib.error import HTTPError, URLError +from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -12,6 +13,59 @@ class PlannerError(ValueError): pass +def search_locations(data, key): + if not isinstance(data, dict) or not isinstance(data.get("text"), str): + raise PlannerError("Enter a place name or address.") + text = data["text"].strip() + if not 3 <= len(text) <= 200: + raise PlannerError("Use 3–200 characters to search for a location.") + if not key: + raise PlannerError("Location search is not connected yet.") + params = {"text": text, "size": 5} + if data.get("focus") is not None: + focus = point(data["focus"]) + params.update({"focus.point.lon": focus[0], "focus.point.lat": focus[1]}) + req = Request( + "https://api.openrouteservice.org/geocode/search?" + urlencode(params), + headers={"Authorization": key, "Accept": "application/json"}, + ) + try: + with urlopen(req, timeout=8) as response: + raw = response.read(1_000_001) + if len(raw) > 1_000_000: + raise PlannerError("Location search returned too much data. Try a more specific place.") + result = json.loads(raw) + except HTTPError as error: + message = ( + "Location search is busy. Try again shortly." + if error.code == 429 + else ("Location search is unavailable. You can still select a point on the map.") + ) + raise PlannerError(message) from None + except (URLError, TimeoutError, OSError, json.JSONDecodeError): + raise PlannerError( + "Location search could not connect. Try selecting a point on the map." + ) from None + if not isinstance(result, dict) or not isinstance(result.get("features"), list): + raise PlannerError("Location search returned an incomplete response.") + locations = [] + for feature in result["features"][:5]: + if not isinstance(feature, dict): + continue + geometry, props = feature.get("geometry"), feature.get("properties") + if not isinstance(geometry, dict) or not isinstance(props, dict): + continue + label = props.get("label") or props.get("name") + if geometry.get("type") != "Point" or not isinstance(label, str): + continue + try: + coordinates = point(geometry.get("coordinates")) + except PlannerError: + continue + locations.append({"label": label[:300], "coordinates": coordinates}) + return locations + + def number(value, low, high, label): if isinstance(value, bool): raise PlannerError(f"Choose a valid {label}.") diff --git a/app/static/planner.css b/app/static/planner.css index 5c6328a..c535faf 100644 --- a/app/static/planner.css +++ b/app/static/planner.css @@ -1,3 +1,84 @@ -.planner-controls{display:grid;grid-template-columns:1.25fr 1fr 1fr .9fr;gap:24px;padding:24px}.planner-controls label,#save-route-form label{display:block;margin:12px 0 7px;font-size:13px}.planner-controls input:not([type=checkbox]),.planner-controls select,#route-name{width:100%;padding:11px;border:1px solid var(--line);border-radius:5px;background:var(--paper);color:var(--ink);font:inherit}.planner-controls input[type=checkbox]{width:auto;accent-color:var(--green)}.planner-controls .text-button{display:block;margin-top:8px}.planner-submit{display:flex;flex-direction:column;justify-content:flex-end;gap:14px}.planner-check{display:flex!important;align-items:center;gap:8px}.planner-controls [hidden],#route-planner [hidden]{display:none!important}#planner-status{margin:18px 0;min-height:24px}#planner-map{height:420px;z-index:0;border-radius:8px;background:var(--line)}.planner-table-wrap{overflow:auto;margin:24px 0}.planner-table{width:100%;border-collapse:collapse;text-align:left}.planner-table th{font-size:12px;color:var(--muted);font-weight:400}.planner-table td,.planner-table th{padding:15px 12px;border-bottom:1px solid var(--line);white-space:nowrap}.planner-table tr.selected{background:var(--white)}.planner-detail{display:grid;grid-template-columns:1fr 1fr;gap:30px;padding:24px}.planner-profile{height:180px;margin-top:20px}.planner-detail dl{margin:18px 0}.planner-detail dl div{display:flex;justify-content:space-between;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}.planner-detail dt{color:var(--muted)}.planner-detail dd{margin:0}.planner-save-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}.planner-library{padding:24px;margin-top:24px}#saved-routes{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px}#planner-results button[aria-pressed=true]{border-color:var(--green)}#generate-routes:disabled{opacity:.5;cursor:wait}@media(max-width:950px){.planner-controls{grid-template-columns:1fr 1fr}}@media(max-width:680px){.planner-controls,.planner-detail{grid-template-columns:1fr;padding:18px;gap:16px}#planner-map{height:330px}.planner-controls input,.planner-controls select,#route-name{font-size:16px}} - -.planner-controls,.planner-detail,.planner-library{background:var(--white);border:1px solid var(--line);border-radius:var(--radius)}.planner-table tr.selected{background:var(--white)}.planner-library .button{max-width:100%;white-space:normal;overflow-wrap:anywhere}.app-header .stats-nav{flex-shrink:0;color:var(--muted);font-size:13px}.app-header .stats-nav.active{color:var(--green)} +.planner-stage { display:grid; grid-template-columns:310px minmax(0,1fr); gap:20px; align-items:stretch; } +.planner-controls,.planner-detail,.planner-library,.planner-map-panel { background:var(--white); border:1px solid var(--line); border-radius:12px; } +.planner-controls { padding:22px; } +.planner-section-heading { display:flex; justify-content:space-between; align-items:center; } +.planner-section-heading .icon { color:var(--green); } +.planner-controls label,#save-route-form label { display:block; margin:15px 0 7px; font-size:12px; font-weight:500; } +.planner-controls input:not([type=checkbox]),.planner-controls select,#route-name { width:100%; padding:12px; border:1px solid #3c4447; border-radius:7px; background:var(--paper); color:var(--ink); font:inherit; } +.planner-controls input:focus,.planner-controls select:focus,#route-name:focus { outline:2px solid var(--green); outline-offset:2px; } +.planner-controls input::placeholder { color:var(--muted); font-size:12px; } +#route-planner [hidden] { display:none!important; } +.planner-fields { display:grid; grid-template-columns:1fr 1.2fr; gap:12px; } +.field-unit { color:var(--muted); float:right; } +.planner-controls hr { border:0; border-top:1px solid var(--line); margin:20px 0 0; } +.planner-controls .field-help { margin:10px 0; } +.planner-check { display:flex!important; gap:8px; align-items:center; } +.planner-controls input[type=checkbox] { accent-color:var(--green); width:16px; height:16px; } +.planner-preferences { margin:18px 0; } +.planner-preferences summary,.planner-quality-notes summary { color:var(--muted); font-size:12px; cursor:pointer; } +.planner-preferences[open] summary { color:var(--ink); } +.planner-link { display:flex; align-items:center; gap:6px; background:none; padding:4px 0; color:var(--muted); font-size:11px; margin-top:8px; } +.planner-link:hover,.planner-link[aria-pressed=true] { color:var(--green); } +.planner-link .icon { width:14px; height:14px; } +.loop-note { margin-top:14px; padding:9px 12px; border-radius:6px; background:#27322d; color:#b8cfc0; font-size:11px; } +.location-letter { display:inline-grid; place-items:center; width:19px; height:19px; border-radius:50%; margin-right:8px; font-size:10px; font-weight:600; color:#131918; background:#83c0a1; } +.location-letter.end { background:var(--green); } +.location-search { position:relative; } +.location-hint { font-size:10px; color:var(--muted); margin-top:7px; overflow-wrap:anywhere; } +.location-results { position:absolute; left:0; right:0; top:calc(100% + 6px); z-index:1200; background:#242b2e; border:1px solid #485154; border-radius:8px; box-shadow:0 14px 30px #0006; padding:5px; } +.location-results button { display:block; width:100%; text-align:left; padding:11px 10px; border-radius:5px; color:var(--ink); background:transparent; font-size:12px; line-height:1.5; overflow-wrap:anywhere; } +.location-results button:hover,.location-results button[aria-selected=true] { background:#384144; } +.planner-provider-note,.planner-attribution { font-size:10px; color:var(--muted); line-height:1.7; margin-top:12px; } +#generate-routes { width:100%; gap:10px; margin-top:4px; } +#generate-routes:disabled { opacity:.55; cursor:wait; } +.planner-map-panel { min-width:0; overflow:hidden; display:flex; flex-direction:column; } +.planner-map-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:18px 20px; } +.planner-map-heading .eyebrow { margin-bottom:5px; } +.planner-map-heading p { font-size:12px; color:var(--muted); } +.planner-map-heading button { padding:9px 12px; flex-shrink:0; } +#planner-map { min-height:470px; flex:1; z-index:0; background:var(--line); } +.planner-map-footer { display:flex; justify-content:space-between; gap:14px; padding:13px 20px; font-size:10px; color:var(--muted); flex-wrap:wrap; } +.legend-dot { display:inline-block; width:7px; height:7px; border-radius:50%; margin:0 5px 0 10px; background:#83c0a1; } +.legend-dot:first-child { margin-left:0; } +.legend-dot.end { background:var(--green); } +#planner-status { margin:18px 0; font-size:13px; color:var(--muted); min-height:22px; } +.planner-pin { background:transparent; border:0; } +.planner-pin span { display:grid; place-items:center; width:30px; height:30px; border-radius:50% 50% 50% 0; transform:rotate(-45deg); background:#83c0a1; border:2px solid #f9f7f2; box-shadow:0 3px 10px #0008; color:#14251e; } +.planner-pin.end span { background:var(--green); color:#2c1912; } +.planner-pin b { transform:rotate(45deg); font:600 12px/1 system-ui; } +.planner-pin.loop span { border-radius:50%; } +.planner-pin.loop b { font-size:9px; } +.planner-results-heading { display:flex; align-items:center; justify-content:space-between; gap:14px; margin:24px 0 14px; } +.planner-options { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; margin-bottom:22px; } +.planner-option { background:var(--white); color:var(--ink); border:1px solid var(--line); border-radius:10px; padding:18px; text-align:left; } +.planner-option[aria-pressed=true] { border-color:var(--green); box-shadow:inset 0 0 0 1px var(--green); } +.planner-option-title { display:flex; justify-content:space-between; align-items:center; gap:10px; font-size:13px; } +.planner-option-tag { font-size:10px; color:var(--green); } +.planner-option-stats { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:16px; } +.planner-option-stats strong { display:block; font-size:18px; font-weight:500; letter-spacing:-.3px; } +.planner-option-stats small { display:block; font-size:10px; color:var(--muted); margin-top:3px; } +.planner-detail { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(0,1fr); gap:32px; padding:26px; } +.planner-metrics { display:flex; gap:30px; flex-wrap:wrap; margin:20px 0; } +.planner-metrics strong { font-size:25px; font-weight:500; display:block; } +.planner-metrics small { display:block; font-size:10px; color:var(--muted); } +.planner-profile { height:190px; margin-top:20px; } +.planner-distance-note { font-size:12px; color:var(--muted); margin-top:14px; } +.planner-detail dl { margin:18px 0; } +.planner-detail dl>div { display:grid; grid-template-columns:1fr auto; gap:8px 16px; padding:10px 0; border-bottom:1px solid var(--line); } +.planner-detail dt { color:var(--muted); font-size:12px; } +.planner-detail dd { margin:0; font-size:12px; } +.quality-track { grid-column:1/-1; height:3px; background:#30383b; border-radius:3px; overflow:hidden; } +.quality-track i { display:block; height:100%; background:#748b82; } +.planner-quality-notes { margin:14px 0; } +.planner-quality-notes p { margin-top:8px; } +.planner-save-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; } +.planner-library { padding:24px; margin-top:24px; } +.planner-library .planner-results-heading { margin-top:0; } +#saved-routes { display:flex; gap:12px; flex-wrap:wrap; margin-top:16px; } +#saved-routes .button { max-width:100%; white-space:normal; overflow-wrap:anywhere; } +#empty-saved { font-size:13px; color:var(--muted); padding:14px 0; } +.app-header .stats-nav { flex-shrink:0; color:var(--muted); font-size:13px; } +.app-header .stats-nav.active { color:var(--green); } +@media(max-width:1000px) { .planner-stage{grid-template-columns:280px minmax(0,1fr)} .planner-map-footer span:last-child{display:none} .planner-map-heading{flex-wrap:wrap} .planner-option-stats strong{font-size:16px} } +@media(max-width:760px) { .planner-stage{grid-template-columns:1fr} .planner-controls{padding:20px} .planner-options{grid-template-columns:1fr} .planner-option-stats{grid-template-columns:repeat(4,minmax(0,1fr))} .planner-detail{grid-template-columns:1fr;padding:20px} .planner-map-panel{min-height:400px} #planner-map{height:350px;min-height:350px;flex:auto} .planner-controls input,.planner-controls select,#route-name{font-size:16px} .planner-controls input::placeholder{font-size:14px} .planner-results-heading{align-items:start;flex-wrap:wrap} .location-hint{font-size:11px} .planner-library{padding:20px} } +@media(max-width:380px) { .planner-option-stats{grid-template-columns:1fr 1fr} .planner-map-heading{padding:16px} .planner-fields{grid-template-columns:1fr 1fr} } diff --git a/app/static/planner.js b/app/static/planner.js index 302698c..571b1b3 100644 --- a/app/static/planner.js +++ b/app/static/planner.js @@ -10,33 +10,68 @@ attribution: '© OpenStreetMap'}).addTo(map); const routeLayer = L.layerGroup().addTo(map); const markers = {}; + const chosen = {}, searches = {}; let picking = "start", routes = [], selected = null, chart = null; let generation = 0, busy = false; const colour = "#ff9268"; const formatPct = value => value == null ? "Unknown" : `${value.toFixed(1)}%`; const formatAscent = value => value == null ? "Unknown" : `${Math.round(value)} m`; function coordinate(id) { + const kind = id === "plan-start" ? "start" : "end"; + if (chosen[kind]?.label === el(id).value) return chosen[kind].coordinates; const parts = el(id).value.split(",").map(v => v.trim()); - if (parts.length !== 2 || parts.some(v => v === "")) throw new Error("Enter latitude, longitude or select a point on the map."); + if (parts.length !== 2 || parts.some(v => v === "")) throw new Error("Select a location from the search suggestions, or pick a point on the map."); const [lat, lon] = parts.map(Number); if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 85 || Math.abs(lon) > 180) throw new Error("Coordinates are outside the supported range."); return [lon, lat]; } - function mark(kind, latlng) { + function pin(kind, loop = false) { + return L.divIcon({className: `planner-pin ${kind}${loop ? " loop" : ""}`, + html: `${loop ? "S/F" : kind === "start" ? "S" : "F"}`, + iconSize: [30, 36], iconAnchor: [15, 34], tooltipAnchor: [0, -34]}); + } + function invalidate() { + generation++; routeLayer.clearLayers(); selected = null; el("planner-results").hidden = true; + for (const marker of Object.values(markers)) if (!map.hasLayer(marker)) marker.addTo(map); + } + function mark(kind, latlng, label, reset = true) { + if (reset) invalidate(); if (markers[kind]) map.removeLayer(markers[kind]); - markers[kind] = L.marker(latlng).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish"); - el(`plan-${kind}`).value = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`; + markers[kind] = L.marker(latlng, {icon: pin(kind), draggable: true, + title: `${kind === "start" ? "Start" : "Finish"} location — drag to move`}).addTo(map).bindTooltip(kind === "start" ? "Start" : "Finish"); + markers[kind].on("dragend", event => mark(kind, event.target.getLatLng())); + const coords = `${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`; + const name = label || coords; + chosen[kind] = {label: name, coordinates: [latlng.lng, latlng.lat]}; + el(`plan-${kind}`).value = name; + el(`${kind}-hint`).textContent = label ? coords : "Point selected · drag the pin to adjust."; + closeSearch(kind, true); + el("map-instruction").textContent = "Drag a pin to fine-tune your location."; } function setMode() { const point = el("plan-mode").value === "point"; el("finish-fields").hidden = !point; el("plan-end").required = point; + el("loop-note").hidden = point; if (!point) { picking = "start"; if (markers.end) {map.removeLayer(markers.end); delete markers.end;} } + else if (chosen.end && !markers.end) mark("end", L.latLng(chosen.end.coordinates[1], chosen.end.coordinates[0]), chosen.end.label, false); + setPicking("start", false); } - el("plan-mode").onchange = setMode; - el("pick-start").onclick = () => {picking = "start"; status.textContent = "Click the map to set your start.";}; - el("pick-end").onclick = () => {picking = "end"; status.textContent = "Click the map to set your finish.";}; + el("plan-mode").onchange = () => {invalidate(); setMode();}; + function setPicking(kind, announce = true) { + picking = kind; + for (const k of ["start", "end"]) el(`pick-${k}`).setAttribute("aria-pressed", String(k === kind)); + if (announce) {const message = `Tap the map to set your ${kind === "start" ? "start" : "finish"}.`; + status.textContent = message; el("map-instruction").textContent = message; + el("planner-map").scrollIntoView({behavior: "smooth", block: "nearest"});} + } + el("pick-start").onclick = () => setPicking("start"); + el("pick-end").onclick = () => setPicking("end"); map.on("click", e => {mark(picking, e.latlng); status.textContent = `${picking === "start" ? "Start" : "Finish"} selected.`; - if (picking === "start" && el("plan-mode").value === "point") picking = "end";}); + if (picking === "start" && el("plan-mode").value === "point") setPicking("end", false);}); + el("fit-map").onclick = () => { + if (selected) map.fitBounds(L.latLngBounds(selected.coordinates.map(c => [c[1], c[0]])), {padding: [35, 35]}); + else {const points = Object.values(markers).map(m => m.getLatLng()); if (points.length) map.fitBounds(L.latLngBounds(points), {padding: [35, 35], maxZoom: 16});} + }; el("my-location").onclick = () => { if (!navigator.geolocation) {status.textContent = "Location is unavailable. Select a point on the map."; return;} navigator.geolocation.getCurrentPosition(position => { @@ -44,47 +79,123 @@ mark("start", p); map.setView(p, 15); status.textContent = "Your location is set as the start."; }, () => {status.textContent = "Location could not be obtained. Select a point on the map.";}, {timeout: 10000}); }; - async function api(path, data) { + async function api(path, data, signal) { const options = data === undefined ? {} : {method: "POST", headers: {"Content-Type": "application/json", "X-CSRFToken": root.dataset.csrf}, body: JSON.stringify(data)}; - const response = await fetch(`${base}${path}`, options); + const response = await fetch(`${base}${path}`, {...options, signal}); if (response.redirected) throw new Error("Your session expired. Sign in again."); let payload; try {payload = await response.json();} catch (_) {throw new Error(response.status === 429 ? "Too many requests. Please wait before trying again." : "The request failed. Refresh the page and try again.");} if (!response.ok) throw new Error(payload.error || "The request failed."); return payload; } - function cell(row, text) {const td = document.createElement("td"); td.textContent = text; row.append(td); return td;} + function closeSearch(kind, cancel = false) { + el(`${kind}-results`).hidden = true; el(`plan-${kind}`).setAttribute("aria-expanded", "false"); + el(`plan-${kind}`).removeAttribute("aria-activedescendant"); + if (cancel && searches[kind]) {clearTimeout(searches[kind].timer); searches[kind].sequence++; searches[kind].controller?.abort(); + if (el(`${kind}-hint`).textContent.startsWith("Searching")) el(`${kind}-hint`).textContent = "Search a place, address, or pick on the map.";} + } + for (const kind of ["start", "end"]) { + const input = el(`plan-${kind}`), list = el(`${kind}-results`), hint = el(`${kind}-hint`); + const search = searches[kind] = {sequence: 0, items: [], active: -1}; + function highlight(index) { + search.active = index; + [...list.children].forEach((button, i) => button.setAttribute("aria-selected", String(i === index))); + if (index >= 0) input.setAttribute("aria-activedescendant", `${kind}-option-${index}`); + else input.removeAttribute("aria-activedescendant"); + } + function selectPlace(item) { + mark(kind, L.latLng(item.coordinates[1], item.coordinates[0]), item.label); + map.setView([item.coordinates[1], item.coordinates[0]], 15); + status.textContent = `${kind === "start" ? "Start" : "Finish"} selected: ${item.label}`; + } + input.addEventListener("input", () => { + invalidate(); delete chosen[kind]; if (markers[kind]) {map.removeLayer(markers[kind]); delete markers[kind];} + closeSearch(kind, true); search.items = []; highlight(-1); + hint.textContent = "Search a place, address, or enter latitude, longitude."; + const text = input.value.trim(); + if (text.length < 3 || /^[+-]?\d+(\.\d+)?\s*,\s*[+-]?\d+(\.\d+)?$/.test(text)) return; + search.timer = setTimeout(async () => { + const sequence = ++search.sequence; search.controller = new AbortController(); hint.textContent = "Searching nearby places…"; + try { + const centre = map.getCenter(); + const data = await api("/search", {text, focus: [centre.lng, centre.lat]}, search.controller.signal); + if (sequence !== search.sequence || input.value.trim() !== text) return; + search.items = data.locations; list.replaceChildren(); highlight(-1); + search.items.forEach((item, i) => { + const button = document.createElement("button"); button.type = "button"; button.id = `${kind}-option-${i}`; + button.setAttribute("role", "option"); button.setAttribute("aria-selected", "false"); + button.textContent = item.label; button.onclick = () => selectPlace(item); + list.append(button); + }); + const open = search.items.length > 0 && document.activeElement === input; + list.hidden = !open; input.setAttribute("aria-expanded", String(open)); + hint.textContent = search.items.length ? "Choose a place from the suggestions." : "No matches. Try adding a suburb or city, or pick on the map."; + } catch (error) {if (error.name !== "AbortError" && sequence === search.sequence) hint.textContent = error.message;} + }, 650); + }); + input.addEventListener("keydown", event => { + if (event.key === "Escape") {closeSearch(kind, true); return;} + if (!list.hidden && search.items.length && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) { + event.preventDefault(); + if (event.key === "Enter") selectPlace(search.items[Math.max(0, search.active)]); + else highlight((search.active + (event.key === "ArrowDown" ? 1 : -1) + search.items.length) % search.items.length); + } else if (event.key === "Enter" && !chosen[kind] && !input.value.includes(",")) { + event.preventDefault(); status.textContent = "Choose a search suggestion or pick a location on the map."; + } + }); + input.addEventListener("focus", () => {if (search.items.length && !chosen[kind]) {list.hidden = false; input.setAttribute("aria-expanded", "true");}}); + input.addEventListener("blur", () => setTimeout(() => {if (!list.contains(document.activeElement)) closeSearch(kind);}, 150)); + } + document.addEventListener("click", event => { + for (const kind of ["start", "end"]) if (!event.target.closest(`#plan-${kind}`) && !event.target.closest(`#${kind}-results`)) closeSearch(kind); + }); + function metric(parent, value, label) { + const div = document.createElement("div"), strong = document.createElement("strong"), small = document.createElement("small"); + strong.textContent = value; small.textContent = label; div.append(strong, small); parent.append(div); + } function drawOptions() { el("route-options").replaceChildren(); for (const route of routes) { - const row = document.createElement("tr"); if (selected?.id === route.id) row.className = "selected"; - cell(row, route.saved ? route.title : route.label); cell(row, `${(route.distance_m / 1000).toFixed(2)} km`); - cell(row, formatAscent(route.ascent_m)); cell(row, formatPct(route.quality.paths_pct)); cell(row, formatPct(route.quality.repeats_pct)); - const button = document.createElement("button"); button.type = "button"; button.className = "button secondary"; - button.textContent = selected?.id === route.id ? "Selected" : "View"; + const button = document.createElement("button"); button.type = "button"; button.className = "planner-option"; + const title = document.createElement("div"), name = document.createElement("span"), tag = document.createElement("span"); + title.className = "planner-option-title"; name.textContent = route.saved ? route.title : route.label; + tag.className = "planner-option-tag"; tag.textContent = selected?.id === route.id ? "Selected" : "View route"; + title.append(name, tag); button.append(title); + const stats = document.createElement("div"); stats.className = "planner-option-stats"; + metric(stats, `${(route.distance_m / 1000).toFixed(2)} km`, "Distance"); metric(stats, formatAscent(route.ascent_m), "Ascent"); + metric(stats, formatPct(route.quality.paths_pct), "Paths / tracks"); metric(stats, formatPct(route.quality.repeats_pct), "Repeated"); button.append(stats); button.setAttribute("aria-pressed", String(selected?.id === route.id)); button.onclick = () => show(route); - cell(row, "").append(button); el("route-options").append(row); + el("route-options").append(button); } } function show(route) { selected = route; el("planner-results").hidden = false; drawOptions(); routeLayer.clearLayers(); const points = route.coordinates.map(c => [c[1], c[0]]); const line = L.polyline(points, {color: colour, weight: 5}).addTo(routeLayer); - L.circleMarker(points[0], {radius: 7, color: "#70b992", fillOpacity: 1}).bindTooltip("Route start").addTo(routeLayer); - L.circleMarker(points.at(-1), {radius: 5, color: colour, fillOpacity: 1}).bindTooltip("Route finish").addTo(routeLayer); + for (const marker of Object.values(markers)) map.removeLayer(marker); + const loop = route.preferences.mode === "loop"; + L.marker(points[0], {icon: pin("start", loop), draggable: true, title: loop ? "Route start / finish — drag to move" : "Route start — drag to move"}) + .bindTooltip(loop ? "Start / finish" : "Start").on("dragend", e => mark("start", e.target.getLatLng())).addTo(routeLayer); + if (!loop) L.marker(points.at(-1), {icon: pin("end"), draggable: true, title: "Route finish — drag to move"}) + .bindTooltip("Finish").on("dragend", e => mark("end", e.target.getLatLng())).addTo(routeLayer); map.fitBounds(line.getBounds(), {padding: [30, 30]}); el("route-heading").textContent = route.saved ? route.title : route.label; el("route-name").value = route.saved ? route.title : `${(route.distance_m / 1000).toFixed(1)} km ${route.preferences.mode === "loop" ? "loop" : "run"}`; + el("route-metrics").replaceChildren(); metric(el("route-metrics"), `${(route.distance_m / 1000).toFixed(2)} km`, "Distance"); + metric(el("route-metrics"), formatAscent(route.ascent_m), "Ascent"); metric(el("route-metrics"), formatPct(route.quality.paths_pct), "Paths / tracks"); + el("map-instruction").textContent = `${route.saved ? route.title : route.label} · ${(route.distance_m / 1000).toFixed(2)} km`; el("distance-status").textContent = `${route.within_target ? "Within" : "Outside"} the ±5% distance target · ${route.deviation_pct > 0 ? "+" : ""}${route.deviation_pct}% · total ascent ${formatAscent(route.ascent_m)}.`; const q = route.quality; el("quality-details").replaceChildren(); for (const [name, value] of [["Paths, tracks and footways", q.paths_pct], ["Mapped road / street share", q.roads_pct], ["Paved surface", q.paved_pct], ["Unknown surface", q.unknown_surface_pct], ["Repeated distance (estimate)", q.repeats_pct]]) { const div = document.createElement("div"), dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = name; dd.textContent = formatPct(value); div.append(dt, dd); el("quality-details").append(div); + if (value != null) {const track = document.createElement("div"), fill = document.createElement("i"); track.className = "quality-track"; + track.setAttribute("aria-hidden", "true"); fill.style.width = `${Math.max(0, Math.min(100, value))}%`; track.append(fill); div.append(track);} } if (chart) chart.destroy(); chart = null; el("route-profile").hidden = !route.profile.length; el("profile-status").textContent = route.profile.length ? "Estimated elevation from routing data." : "Elevation profile unavailable for this route."; - if (route.profile.length && window.Chart) chart = new Chart(el("route-profile"), {type: "line", data: {datasets: [{data: route.profile.map(p => ({x: p[0] / 1000, y: p[1]})), borderColor: colour, pointRadius: 0, borderWidth: 2}]}, options: {animation: false, maintainAspectRatio: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", title: {display: true, text: "Distance (km)"}}, y: {title: {display: true, text: "Elevation (m)"}}}}}); + if (route.profile.length && window.Chart) chart = new Chart(el("route-profile"), {type: "line", data: {datasets: [{data: route.profile.map(p => ({x: p[0] / 1000, y: p[1]})), borderColor: colour, backgroundColor: "#ff926812", fill: true, pointRadius: 0, borderWidth: 2}]}, options: {animation: false, maintainAspectRatio: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", ticks: {color: "#a0a9ac"}, grid: {color: "#303638"}, title: {display: true, text: "Distance (km)", color: "#a0a9ac"}}, y: {ticks: {color: "#a0a9ac"}, grid: {color: "#303638"}, title: {display: true, text: "Elevation (m)", color: "#a0a9ac"}}}}}); el("route-export").hidden = !route.saved; el("route-export").href = `${base}/${route.id}/gpx`; el("delete-route").hidden = !route.saved; } @@ -92,6 +203,8 @@ e.preventDefault(); if (busy) return; try { const start = coordinate("plan-start"), mode = el("plan-mode").value; + if (!chosen.start) mark("start", L.latLng(start[1], start[0])); + if (mode === "point" && !chosen.end) {const end = coordinate("plan-end"); mark("end", L.latLng(end[1], end[0]));} const data = {start, mode, end: mode === "point" ? coordinate("plan-end") : start, distance: Number(el("plan-distance").value), hill: el("plan-hill").value, surface: el("plan-surface").value, priority: el("plan-priority").value, avoid_repeats: el("plan-repeats").checked}; busy = true; const token = ++generation; el("generate-routes").disabled = true; status.textContent = "Finding and comparing walking routes…"; const result = await api("/generate", data); if (token !== generation) return; @@ -114,6 +227,11 @@ const button = e.target.closest("[data-saved]"); if (!button) return; const token = ++generation; try {const route = await api(`/${button.dataset.saved}`); if (token !== generation) return; + const p = route.preferences; el("plan-mode").value = p.mode; setMode(); + el("plan-distance").value = p.distance / 1000; el("plan-hill").value = p.hill; el("plan-priority").value = p.priority; + el("plan-surface").value = p.surface; el("plan-repeats").checked = p.avoid_repeats; + mark("start", L.latLng(p.start[1], p.start[0]), undefined, false); + if (p.mode === "point") mark("end", L.latLng(p.end[1], p.end[0]), undefined, false); routes = [route]; show(route); status.textContent = "Saved route loaded."; } catch (error) {status.textContent = error.message;} }; diff --git a/app/templates/planner.html b/app/templates/planner.html index 345599a..616e1bf 100644 --- a/app/templates/planner.html +++ b/app/templates/planner.html @@ -1,30 +1,46 @@ {% extends 'base.html' %} +{% from '_ui.html' import icon %} {% block title %}Route planner · Stride{% endblock %} {% block head %} {% endblock %} +{% macro location(kind, label, letter) %} +
+ + +
Search, enter coordinates, or pick on the map.
+ +
+{% endmacro %} {% block content %}
-
PLAN / COMPARE / RUN

Find your next route.

Your distance. Your hills. A route worth running.

+
EXPLORE YOUR NEXT RUN

A good run starts here.

Find a route that fits your distance, terrain and favourite places.

{{ icon('route') }} Saved routes
{% 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.

+
+ +
YOUR ROUTE{{ icon('route') }}
+ + {{ location('start', 'Start location', 'S') }} + +
↻ Finish at your starting location
+ +
+
+

Distance target ±5% · hill preferences guide the match.

+
Route preferences
+ +

Place searches and locations use openrouteservice.

-

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

Choose your starting point to begin.

+ +
READY FOR ANOTHER DAY

Your saved routes

Private to you · latest 100
{% for route in saved_routes %}{% else %}

Your next favourite route belongs here. Plan a run and save it for later.

{% endfor %}
+

Routing & search © openrouteservice · OpenStreetMap contributors. Check current path access before heading out.

{% endblock %} {% block scripts %}{% endblock %} diff --git a/docs/ROUTE_PLANNER.md b/docs/ROUTE_PLANNER.md index 7d5ab8f..27778f7 100644 --- a/docs/ROUTE_PLANNER.md +++ b/docs/ROUTE_PLANNER.md @@ -1,7 +1,10 @@ # Route planner Open **Routes** in Stride. Select the start on the map, enter latitude/longitude, -or use your location. Choose a loop or select a separate finish, distance (1–50 km), +search by typing a place/address and choosing a suggestion, or use your location. +Both start and finish support search with arrow-key/Enter selection. Suggestions +are biased towards the current map view, without restricting the search region. +Choose a loop or select a separate finish, distance (1–50 km), hill difficulty, surface preference and ranking priority. Generated candidates show their actual distance, ascent, route geometry, elevation profile and quality coverage. Save a named route privately, reopen it, download GPX, or delete it. @@ -48,7 +51,15 @@ Quality percentages follow ORS documented waytype and surface IDs: Crossing counts, lighting, current access and live traffic are unverified. GPX includes the route and available elevation, without fictional timestamps. -No direct Garmin course upload or place-name search is included in this version. +No direct Garmin course upload is included in this version. + +Place searches use the same server-side ORS key and a fixed geocoding endpoint. +Typing waits 650 ms before searching; old responses are discarded when the query +changes. Queries are bounded to 3–200 characters with at most five results and an +eight-second provider timeout. The authenticated POST endpoint requires CSRF and +is limited to 30 searches/minute and 600/day. Provider labels are inserted as text. +Start/finish pins use self-contained CSS markers, so no external marker images +are required. Selected pins can be dragged to adjust their location. ## Persistence and validation diff --git a/tests/test_planner.py b/tests/test_planner.py index bab2f97..3b0cd30 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -7,7 +7,7 @@ import pytest from app import create_app from app.extensions import db from app.models import PlannedRoute, User -from app.planner_engine import PlannerError, analyse, generate, validate +from app.planner_engine import PlannerError, analyse, generate, search_locations, validate @pytest.fixture @@ -223,3 +223,87 @@ def test_old_candidates_cleanup_and_saved_preservation(app, monkeypatch): with app.app_context(): assert db.session.get(PlannedRoute, 1) is None assert db.session.get(PlannedRoute, 2).saved + + +def test_location_search_contract_and_invalid_results(monkeypatch): + import json + from urllib.parse import parse_qs, urlsplit + + calls = [] + + class Reply: + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def read(self, limit): + assert limit == 1_000_001 + return json.dumps( + { + "features": [ + { + "geometry": {"type": "Point", "coordinates": [151.2, -33.9]}, + "properties": {"label": "Park & gardens, Sydney"}, + }, + { + "geometry": {"type": "Point", "coordinates": [0, 99]}, + "properties": {"label": "Invalid"}, + }, + {"geometry": {"type": "LineString", "coordinates": []}, "properties": {}}, + ] + } + ).encode() + + def fake(req, timeout): + calls.append(req) + assert timeout == 8 + return Reply() + + monkeypatch.setattr("app.planner_engine.urlopen", fake) + locations = search_locations( + {"text": "Park & gardens", "focus": [151, -33]}, "private-test-key" + ) + assert locations == [{"label": "Park & gardens, Sydney", "coordinates": [151.2, -33.9]}] + query = parse_qs(urlsplit(calls[0].full_url).query) + assert query["text"] == ["Park & gardens"] + assert query["size"] == ["5"] and query["focus.point.lon"] == ["151.0"] + assert "private-test-key" not in calls[0].full_url + assert calls[0].get_header("Authorization") == "private-test-key" + + +@pytest.mark.parametrize( + "data", [None, {}, {"text": "ab"}, {"text": "x" * 201}, {"text": "Sydney", "focus": [0, 99]}] +) +def test_location_search_validation(data): + with pytest.raises(PlannerError): + search_locations(data, "test") + + +def test_location_search_login_configuration_and_csrf(app, monkeypatch): + monkeypatch.setattr( + "app.planner.search_locations", + lambda data, key: [{"label": "Public park", "coordinates": [151, -33]}], + ) + client = app.test_client() + assert client.post("/routes/search", json={"text": "Sydney"}).status_code == 302 + login(client) + response = client.post("/routes/search", json={"text": "Sydney"}) + assert response.status_code == 200 and response.json["locations"][0]["label"] == "Public park" + app.config["WTF_CSRF_ENABLED"] = True + assert client.post("/routes/search", json={"text": "Sydney"}).status_code == 400 + with pytest.raises(PlannerError, match="not connected"): + search_locations({"text": "Sydney"}, None) + + +def test_location_search_failure_is_actionable(monkeypatch): + from urllib.error import URLError + + def fail(*_, **__): + raise URLError("private provider internals") + + monkeypatch.setattr("app.planner_engine.urlopen", fail) + with pytest.raises(PlannerError, match="selecting a point on the map") as error: + search_locations({"text": "Sydney"}, "private-key") + assert "private" not in str(error.value)