diff --git a/app/web/static/main.js b/app/web/static/main.js
index c811f29..8b189db 100644
--- a/app/web/static/main.js
+++ b/app/web/static/main.js
@@ -38,6 +38,10 @@ document.addEventListener('DOMContentLoaded', function() {
var roundTripKm = document.getElementById('roundTripKm');
var roundTripBtn = document.getElementById('roundTripBtn');
var roundTripNoRepeat = document.getElementById('roundTripNoRepeat');
+ var roundTripDir = document.getElementById('roundTripDir');
+ var roundTripScenic = document.getElementById('roundTripScenic');
+ var roundTripIsochrone = document.getElementById('roundTripIsochrone');
+ var isochroneMinutes = document.getElementById('isochroneMinutes');
if (zoomInBtn && zoomOutBtn) {
zoomInBtn.addEventListener('click', function() {
map.zoomIn();
@@ -156,6 +160,9 @@ document.addEventListener('DOMContentLoaded', function() {
roundTripKm.addEventListener('change', function(){
var v = parseInt(roundTripKm.value, 10);
if (!isNaN(v)) localStorage.setItem('freemoto-roundtrip-km', String(v));
+ if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
+ window.createRoundTrip();
+ }
});
}
if (roundTripBtn) {
@@ -174,6 +181,54 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
+ // Direction preference persistence and live update
+ if (roundTripDir) {
+ var savedDir = localStorage.getItem('freemoto-roundtrip-dir') || 'any';
+ roundTripDir.value = savedDir;
+ roundTripDir.addEventListener('change', function(){
+ localStorage.setItem('freemoto-roundtrip-dir', roundTripDir.value);
+ if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
+ // Regenerate the round trip to reflect new direction
+ window.createRoundTrip();
+ }
+ });
+ }
+
+ // Scenic optimizer toggle
+ if (roundTripScenic) {
+ var savedScenic = localStorage.getItem('freemoto-roundtrip-scenic') === '1';
+ roundTripScenic.checked = savedScenic;
+ roundTripScenic.addEventListener('change', function(){
+ localStorage.setItem('freemoto-roundtrip-scenic', roundTripScenic.checked ? '1' : '0');
+ if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
+ window.createRoundTrip();
+ }
+ });
+ }
+
+ // Isochrone toggle and minutes
+ if (roundTripIsochrone) {
+ var savedIso = localStorage.getItem('freemoto-roundtrip-isochrone') === '1';
+ roundTripIsochrone.checked = savedIso;
+ roundTripIsochrone.addEventListener('change', function(){
+ localStorage.setItem('freemoto-roundtrip-isochrone', roundTripIsochrone.checked ? '1' : '0');
+ if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
+ window.createRoundTrip();
+ }
+ });
+ }
+ if (isochroneMinutes) {
+ var savedMin = parseInt(localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
+ if (!isNaN(savedMin)) isochroneMinutes.value = savedMin;
+ isochroneMinutes.addEventListener('change', function(){
+ var v = parseInt(isochroneMinutes.value, 10);
+ if (!isNaN(v)) localStorage.setItem('freemoto-isochrone-minutes', String(v));
+ if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
+ window.createRoundTrip();
+ }
+ });
+ }
+
// Swap start/end like Google Maps
(function(){
var swapBtn = document.getElementById('swapBtn');
diff --git a/app/web/static/route.js b/app/web/static/route.js
index 1422337..98a770d 100644
--- a/app/web/static/route.js
+++ b/app/web/static/route.js
@@ -18,6 +18,10 @@ document.addEventListener('DOMContentLoaded', function() {
var roundTripToggle = document.getElementById('roundTripToggle');
var roundTripKmInput = document.getElementById('roundTripKm');
var roundTripBtn = document.getElementById('roundTripBtn');
+ var roundTripDirSel = document.getElementById('roundTripDir');
+ var roundTripScenic = document.getElementById('roundTripScenic');
+ var roundTripIsochrone = document.getElementById('roundTripIsochrone');
+ var isochroneMinutesInput = document.getElementById('isochroneMinutes');
function renderWaypointList() {
if (!waypointListEl) return;
@@ -77,6 +81,19 @@ document.addEventListener('DOMContentLoaded', function() {
return { lat: toDeg(φ2), lng: ((toDeg(λ2) + 540) % 360) - 180 };
}
+ function normalizeDeg(d){ return ((d % 360) + 360) % 360; }
+
+ function preferredBearing() {
+ var val = roundTripDirSel ? (roundTripDirSel.value || 'any') : 'any';
+ switch (val) {
+ case 'N': return 0;
+ case 'E': return 90;
+ case 'S': return 180;
+ case 'W': return 270;
+ default: return null; // any
+ }
+ }
+
function generateRoundTripWaypoints(start) {
var km = parseFloat(roundTripKmInput && roundTripKmInput.value ? roundTripKmInput.value : '100');
if (isNaN(km) || km < 5) km = 50;
@@ -84,19 +101,237 @@ document.addEventListener('DOMContentLoaded', function() {
// Use radius r ≈ distance / (2π)
var meters = km * 1000;
var r = Math.max(500, meters / (2 * Math.PI));
- // Bearings spaced around circle with a random offset to vary loops
- var base = (Math.random() * 360) | 0;
+ // If user provided a preferred direction, bias the bearings around that center.
+ var center = preferredBearing();
+ var bearings;
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
- var bearings = noRepeat
- ? [0, 72, 144, 216, 288].map(function(d){ return base + d; }) // 5 WPs ~ evenly spaced to reduce overlap
- : [45, 165, 285].map(function(d){ return base + d; });
- var wps = bearings.map(function(b){ return destPoint(start.lat, start.lng, r, b); });
+ if (center === null) {
+ // Any: keep previous behavior (random base, evenly spaced)
+ var base = (Math.random() * 360) | 0;
+ bearings = noRepeat
+ ? [0, 72, 144, 216, 288].map(function(d){ return base + d; })
+ : [45, 165, 285].map(function(d){ return base + d; });
+ } else {
+ // Directional preference: create an arc centered on preferred bearing
+ // Use 3 or 5 points depending on noRepeat, within +/- 60° spread
+ var offsets = noRepeat ? [-60, -30, 0, 30, 60] : [-30, 0, 30];
+ bearings = offsets.map(function(o){ return normalizeDeg(center + o); });
+ }
+
+ var wps = bearings.map(function(b, i){
+ // Slightly push the middle point further out to emphasize the preferred direction
+ var scale = 1.0;
+ if (center !== null) {
+ if ((noRepeat && (i === Math.floor(bearings.length/2))) || (!noRepeat && i === 1)) {
+ scale = 1.15; // middle point
+ } else {
+ scale = 1.0;
+ }
+ }
+ return destPoint(start.lat, start.lng, r * scale, b);
+ });
// Ensure endpoints list: start -> wps -> start
var routePts = [start].concat(wps).concat([start]);
return routePts;
}
- function createRoundTrip() {
+ // ---- Scenic optimizer helpers ----
+ function heading(a, b) {
+ var φ1 = toRad(a.lat), φ2 = toRad(b.lat);
+ var Δλ = toRad(b.lng - a.lng);
+ var y = Math.sin(Δλ) * Math.cos(φ2);
+ var x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
+ return normalizeDeg(toDeg(Math.atan2(y, x)));
+ }
+
+ function angleDiff(a, b) {
+ var d = Math.abs(a - b) % 360;
+ return d > 180 ? 360 - d : d;
+ }
+
+ function polylineHeadingDeltas(latlngs) {
+ var deltas = [];
+ for (var i = 1; i < latlngs.length - 1; i++) {
+ var h1 = heading({lat: latlngs[i-1][0], lng: latlngs[i-1][1]}, {lat: latlngs[i][0], lng: latlngs[i][1]});
+ var h2 = heading({lat: latlngs[i][0], lng: latlngs[i][1]}, {lat: latlngs[i+1][0], lng: latlngs[i+1][1]});
+ deltas.push(angleDiff(h1, h2));
+ }
+ return deltas;
+ }
+
+ function distanceKm(a, b) {
+ var R = 6371.0;
+ var dLat = toRad(b[0] - a[0]);
+ var dLng = toRad(b[1] - a[1]);
+ var s1 = Math.sin(dLat/2), s2 = Math.sin(dLng/2);
+ var aa = s1*s1 + Math.cos(toRad(a[0]))*Math.cos(toRad(b[0]))*s2*s2;
+ var c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1-aa));
+ return R * c;
+ }
+
+ function pathLengthKm(latlngs) {
+ var km = 0;
+ for (var i = 1; i < latlngs.length; i++) km += distanceKm(latlngs[i-1], latlngs[i]);
+ return km;
+ }
+
+ function scorePolyline(latlngs) {
+ if (!latlngs || latlngs.length < 3) return -1e9;
+ var km = pathLengthKm(latlngs);
+ if (km <= 0) return -1e9;
+ var deltas = polylineHeadingDeltas(latlngs);
+ var sumDelta = deltas.reduce((a,b)=>a+b, 0);
+ var turns = deltas.filter(d => d > 12).length; // count meaningful turns
+ var turnDensity = turns / km; // turns per km
+ var curvature = sumDelta / km; // degrees per km
+ // Penalize very straight long segments implicitly by lower curvature
+ // Compose score: weight curvature more than turn count
+ return curvature * 0.7 + turnDensity * 0.3;
+ }
+
+ async function routeAndScore(pointsList, requestOptions) {
+ var body = {
+ locations: pointsList.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
+ costing: "motorcycle",
+ costing_options: requestOptions.costing_options || {},
+ units: "kilometers"
+ };
+ if (requestOptions.shortest) body.shortest = true;
+ var resp = await fetch('/route', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
+ var data = await resp.json();
+ var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
+ var latlngs = [];
+ legs.forEach(function(leg){
+ var lls = decodePolyline6(leg.shape || '');
+ if (latlngs.length && lls.length) latlngs = latlngs.concat(lls.slice(1)); else latlngs = latlngs.concat(lls);
+ });
+ var score = scorePolyline(latlngs);
+ return {score: score, data: data, latlngs: latlngs};
+ }
+
+ function randomNormal() {
+ // Box-Muller
+ var u = 1 - Math.random();
+ var v = 1 - Math.random();
+ return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
+ }
+
+ function generateScenicCandidates(start) {
+ var basePts = generateRoundTripWaypoints(start);
+ var center = preferredBearing();
+ var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
+ var count = 6;
+ var candidates = [];
+ for (var i = 0; i < count; i++) {
+ var scale = 1 + (Math.random()*0.25 - 0.1); // -10%..+15%
+ var jitter = center === null ? 30 : 15; // tighter if centered
+ var pts = basePts.map(function(p, idx){
+ if (idx === 0 || idx === basePts.length - 1) return p;
+ // compute bearing from start to p and re-project with jitter and scale
+ var br = heading(start, p);
+ var j = randomNormal() * jitter;
+ var d = distanceKm([start.lat, start.lng], [p.lat, p.lng]) * 1000 * scale; // meters
+ return destPoint(start.lat, start.lng, d, br + j);
+ });
+ candidates.push(pts);
+ }
+ // Ensure base version is included as well
+ candidates.unshift(basePts);
+ return candidates;
+ }
+
+ // ---- Isochrone helpers ----
+ async function fetchIsochronePolygon(start, minutes, motoOptions) {
+ var body = {
+ locations: [{ lat: start.lat, lon: start.lng }],
+ costing: "motorcycle",
+ costing_options: { motorcycle: motoOptions || {} },
+ contours: [{ time: Math.max(5, Math.min(240, minutes)) }],
+ polygons: true,
+ denoise: 0.5,
+ generalize: 100
+ };
+ var resp = await fetch('/isochrone', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
+ var data = await resp.json();
+ // Expect GeoJSON FeatureCollection
+ try {
+ var coords = data.features[0].geometry.coordinates;
+ // Valhalla returns [ [ [lon,lat], ... ] ]
+ var ring = coords[0].map(function(c){ return { lat: c[1], lng: c[0] }; });
+ return ring;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function sampleIsochroneSector(ring, start, centerBearing) {
+ if (!ring || !ring.length) return [];
+ var sector = [];
+ var spread = 60; // +/- degrees
+ ring.forEach(function(p){
+ var br = heading(start, {lat: p.lat, lng: p.lng});
+ if (centerBearing === null) {
+ sector.push(p);
+ } else {
+ if (angleDiff(br, centerBearing) <= spread) sector.push(p);
+ }
+ });
+ if (sector.length < 3) {
+ // Fallback to full ring
+ sector = ring.slice();
+ }
+ // Pick 3 or 5 evenly spaced points from sector
+ var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
+ var n = noRepeat ? 5 : 3;
+ var step = Math.max(1, Math.floor(sector.length / (n + 1)));
+ var wps = [];
+ for (var i = 1; i <= n; i++) {
+ var idx = Math.min(sector.length - 1, i * step);
+ wps.push(sector[idx]);
+ }
+ return wps;
+ }
+
+ // Rudimentary land/water detection using backend reverse geocoding
+ async function isLikelyWater(lat, lon) {
+ try {
+ const resp = await fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`);
+ const data = await resp.json();
+ // Heuristics: if class/type indicates water or coastline, treat as water
+ const cls = (data && (data.class || (data.address && data.address.natural))) || '';
+ const typ = (data && (data.type || (data.address && (data.address.water || data.address.waterway)))) || '';
+ const name = (data && data.display_name) || '';
+ const hay = `${cls} ${typ} ${name}`.toLowerCase();
+ return /\b(water|sea|bay|ocean|coast|coastline|harbor|harbour|beach|lake|fjord|inlet|river|estuary)\b/.test(hay);
+ } catch (_) {
+ // On failure, be conservative and assume not water
+ return false;
+ }
+ }
+
+ async function adjustPointInland(p, start) {
+ // Try up to ~10 steps of 1km towards start to avoid placing on water
+ const steps = 10;
+ const stepMeters = 1000;
+ let cand = { lat: p.lat, lng: p.lng };
+ // Bearing from candidate to start (move inland towards start)
+ function bearing(from, to) {
+ const φ1 = toRad(from.lat), φ2 = toRad(to.lat);
+ const Δλ = toRad(to.lng - from.lng);
+ const y = Math.sin(Δλ) * Math.cos(φ2);
+ const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
+ return (toDeg(Math.atan2(y, x)) + 360) % 360;
+ }
+ for (let i = 0; i < steps; i++) {
+ const water = await isLikelyWater(cand.lat, cand.lng);
+ if (!water) return cand;
+ const brg = bearing(cand, start);
+ cand = destPoint(cand.lat, cand.lng, stepMeters, brg);
+ }
+ return cand;
+ }
+
+ async function createRoundTrip() {
if (!roundTripToggle || !roundTripToggle.checked) return;
// Determine start point: prefer first existing point, else sourceInput coords
var start = null;
@@ -123,8 +358,64 @@ document.addEventListener('DOMContentLoaded', function() {
if (routePolyline) { map.removeLayer(routePolyline); routePolyline = null; }
if (snapLines && snapLines.length) { snapLines.forEach(function(sl){ map.removeLayer(sl); }); snapLines = []; }
- var pts = generateRoundTripWaypoints(start);
- pts.forEach(function(p, idx){
+ var useIso = !!(roundTripIsochrone && roundTripIsochrone.checked);
+ var useScenic = !!(roundTripScenic && roundTripScenic.checked);
+
+ var pts;
+ if (useIso) {
+ // Build moto options to avoid ferries etc.
+ var moto = {};
+ if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) moto.use_ferry = 0.0;
+ moto.use_ferry = 0.0; // default for round trips
+ moto.ferry_cost = 1000;
+ var minutes = parseInt((isochroneMinutesInput && isochroneMinutesInput.value) || localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
+ var ring = await fetchIsochronePolygon(start, minutes, moto);
+ if (ring && ring.length) {
+ var center = preferredBearing();
+ var wps = sampleIsochroneSector(ring, start, center);
+ pts = [start].concat(wps).concat([start]);
+ }
+ }
+ if (!pts) {
+ pts = generateRoundTripWaypoints(start);
+ }
+ // If scenic optimizer is enabled, evaluate a few candidates and pick the twistiest
+ if (useScenic) {
+ try {
+ var requestOptions = { costing_options: { motorcycle: {} } };
+ // Avoid ferries strongly
+ requestOptions.costing_options.motorcycle.use_ferry = 0.0;
+ requestOptions.costing_options.motorcycle.ferry_cost = 1000;
+
+ var candidates = generateScenicCandidates(start);
+ // Replace the base candidate with our current pts at index 0
+ candidates[0] = pts;
+ var best = null;
+ // Evaluate sequentially to avoid hammering the backend
+ for (var ci = 0; ci < candidates.length; ci++) {
+ var cand = candidates[ci];
+ var result = await routeAndScore(cand, requestOptions);
+ if (!best || result.score > best.score) {
+ best = { idx: ci, score: result.score, pts: cand };
+ }
+ }
+ if (best) {
+ pts = best.pts;
+ }
+ } catch (e) { console.warn('Scenic optimizer failed', e); }
+ }
+
+ // Adjust intermediate points away from water before placing markers
+ var adjusted = await Promise.all(pts.map(async function(p, idx) {
+ if (idx === 0 || idx === pts.length - 1) return p; // keep start/end
+ try {
+ return await adjustPointInland(p, start);
+ } catch (_) {
+ return p;
+ }
+ }));
+
+ adjusted.forEach(function(p, idx){
var marker = L.marker(p, { draggable: true }).addTo(map);
marker.bindPopup(idx === 0 ? 'Start/End' : `WP ${idx}`).openPopup();
marker.on('dragend', function(ev){
@@ -180,6 +471,15 @@ document.addEventListener('DOMContentLoaded', function() {
}
} catch (_) {}
+ // For round trips, avoid ferries by default to prevent water crossings
+ try {
+ if (roundTripToggle && roundTripToggle.checked) {
+ moto.use_ferry = 0.0; // strongest avoidance
+ // Slightly increase ferry_cost as an extra safety
+ moto.ferry_cost = 1000;
+ }
+ } catch (_) {}
+
var costing_options = { motorcycle: moto };
var requestBody = {
diff --git a/app/web/static/styles.css b/app/web/static/styles.css
index fdb8d6a..991f34b 100644
--- a/app/web/static/styles.css
+++ b/app/web/static/styles.css
@@ -14,13 +14,10 @@
--shadow-2: 0 10px 30px rgba(2, 8, 20, 0.12);
}
-html, body {
- height: 100%;
-}
-
+html, body { height: 100%; }
body {
margin: 0;
- overflow: hidden;
+ overflow: hidden; /* map-focused app; internal sections handle their own scroll */
background: linear-gradient(180deg, #f1f5f9, #e2e8f0);
color: #0f172a;
}
@@ -66,6 +63,8 @@ body {
box-shadow: var(--shadow-2);
padding: 10px 12px;
transition: max-height 200ms ease, transform 200ms ease, padding 200ms ease;
+ overflow: auto; /* allow scroll when content grows */
+ max-height: 60vh; /* default for mobile */
}
.nav-panel .form-control,
.nav-panel .btn {
@@ -106,7 +105,7 @@ body {
.sheet-card {
border-radius: var(--panel-radius) var(--panel-radius) 0 0;
max-height: 60vh;
- overflow: hidden;
+ overflow: hidden; /* header fixed; body scrolls */
box-shadow: var(--shadow-2);
}
.sheet .handle {
@@ -124,10 +123,7 @@ body {
padding: 4px 8px;
border-bottom: 1px solid #eef2f6;
}
-.sheet-body {
- overflow: auto;
- max-height: calc(60vh - 42px);
-}
+.sheet-body { overflow: auto; max-height: calc(60vh - 42px); }
#directionsSheet { display: none; }
.sheet.collapsed .sheet-card { max-height: 28vh; }
.sheet.collapsed .sheet-body { max-height: calc(28vh - 42px); }
@@ -172,8 +168,8 @@ body {
left: 8px;
right: 8px;
bottom: 8px;
- max-height: 48vh;
- overflow: visible; /* allow suggestion dropdowns to overflow */
+ max-height: 56vh; /* slightly taller for more controls */
+ overflow: auto; /* scroll internally */
}
.nav-panel.collapsed {
max-height: 56px; /* show a small header */
@@ -185,16 +181,18 @@ body {
.section-title { font-size: 0.95rem; }
}
-/* Desktop layout refinements */
+/* Desktop/tablet layout refinements */
@media (min-width: 768px) {
.nav-panel {
top: 88px;
- bottom: auto;
+ bottom: 24px; /* allow taller panel while preserving map footer space */
left: 24px;
right: auto;
- width: 560px;
+ width: min(560px, 42vw);
border-radius: var(--panel-radius);
padding: 16px;
+ max-height: calc(100vh - 88px - 24px);
+ overflow: auto; /* scroll the panel content when needed */
}
.nav-panel .form-control,
.nav-panel .btn {
@@ -205,6 +203,18 @@ body {
}
}
+/* Make long lists scrollable inside the panel */
+.nav-panel .list-group {
+ max-height: 34vh;
+ overflow: auto;
+}
+
+/* Scrollbar styling for WebKit browsers */
+*::-webkit-scrollbar { width: 10px; height: 10px; }
+*::-webkit-scrollbar-thumb { background-color: rgba(100, 116, 139, 0.5); border-radius: 8px; }
+*::-webkit-scrollbar-thumb:hover { background-color: rgba(71, 85, 105, 0.7); }
+*::-webkit-scrollbar-track { background: transparent; }
+
/* Dark theme */
/* Improved dark mode for readability */
[data-theme="dark"] :root {