Files
freemoto/app/web/static/route.js
Pedan c37ca4e8b4
Some checks failed
build-and-push / docker (push) Failing after 10m2s
feat(ui,roundtrip): modern neutral “Apple Glass” UI, scenic round trips, and isochrone guidance
- UI (neutral minimal, glass-like)
  - Rewrote app shell and control panel in [app/web/static/index.html](cci:7://file:///home/pedan/freemoto/app/web/static/index.html:0:0-0:0)
  - Modernized responsive styles in [app/web/static/styles.css](cci:7://file:///home/pedan/freemoto/app/web/static/styles.css:0:0-0:0)
  - Proper internal scrolling for control panel, suggestions, waypoints, and directions sheet
  - Improved dark mode, spacing, and visual polish

- Round Trip generation
  - Direction preference (N/E/S/W) with biased waypoint bearings ([route.js](cci:7://file:///home/pedan/freemoto/app/web/static/route.js:0:0-0:0))
  - Ferry avoidance by default on round trips; added strong ferry cost
  - Coastal safety: reverse-geocode water check and automatic inland adjustment for generated waypoints
  - New “Scenic optimizer” option: generate candidate loops, route them, score twistiness, pick the best
    - Heading-change/turn-density based scoring using decoded polyline

- Isochrone-guided loops
  - Backend: added `/isochrone` proxy in [app/web/main.go](cci:7://file:///home/pedan/freemoto/app/web/main.go:0:0-0:0) (derives Valhalla base from `VALHALLA_URL`)
  - Frontend: optional “Use isochrone guidance” + time (minutes); sample preferred sector of polygon

- Settings persistence and live updates
  - Direction, scenic, isochrone, and distance persist via localStorage
  - Changing options regenerates round trips when enabled

- Docker/Build
  - Confirmed multi-arch build (amd64/arm64) and push to git.ztsw.de/pedan/freemoto/freemoto-web:latest

Refs: [index.html](cci:7://file:///home/pedan/freemoto/app/web/static/index.html:0:0-0:0), [styles.css](cci:7://file:///home/pedan/freemoto/app/web/static/styles.css:0:0-0:0), [route.js](cci:7://file:///home/pedan/freemoto/app/web/static/route.js:0:0-0:0), [main.js](cci:7://file:///home/pedan/freemoto/app/web/static/main.js:0:0-0:0), [app/web/main.go](cci:7://file:///home/pedan/freemoto/app/web/main.go:0:0-0:0), [Dockerfile](cci:7://file:///home/pedan/freemoto/Dockerfile:0:0-0:0)
2025-09-19 14:45:21 +02:00

979 lines
44 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function() {
var avoidHighwaysCheckbox = document.getElementById('avoidHighways');
var useShortestCheckbox = document.getElementById('useShortest');
var avoidTollRoadsCheckbox = document.getElementById('avoidTollRoads');
var avoidFerriesCheckbox = document.getElementById('avoidFerries');
var avoidUnpavedCheckbox = document.getElementById('avoidUnpaved');
var points = [];
var markers = [];
var routePolyline = null;
var snapLines = [];
var originalPoints = [];
var lastRouteLatLngs = null; // concatenated
var lastRtePoints = []; // for GPX <rtept>
var waypointListEl = document.getElementById('waypointList');
var nextBanner = document.getElementById('nextManeuverBanner');
var voiceToggle = document.getElementById('voiceToggle');
var exportModeSel = document.getElementById('exportMode');
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;
waypointListEl.innerHTML = '';
points.forEach(function(p, idx){
var li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
var label = (idx === 0) ? 'Start' : (idx === points.length - 1 ? 'End' : `WP ${idx}`);
var coords = `${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}`;
li.innerHTML = `<span>${label}<br><small class="text-secondary">${coords}</small></span>`;
var btns = document.createElement('div');
btns.className = 'btn-group btn-group-sm';
var up = document.createElement('button'); up.className = 'btn btn-outline-secondary'; up.textContent = '↑'; up.disabled = (idx === 0);
var down = document.createElement('button'); down.className = 'btn btn-outline-secondary'; down.textContent = '↓'; down.disabled = (idx === points.length - 1);
var del = document.createElement('button'); del.className = 'btn btn-outline-danger'; del.textContent = '✕';
up.onclick = function(){ if (idx > 0) { swapWaypoints(idx, idx-1); } };
down.onclick = function(){ if (idx < points.length - 1) { swapWaypoints(idx, idx+1); } };
del.onclick = function(){ removeWaypoint(idx); };
btns.appendChild(up); btns.appendChild(down); btns.appendChild(del);
li.appendChild(btns);
waypointListEl.appendChild(li);
});
}
function swapWaypoints(i, j) {
var tmp = points[i]; points[i] = points[j]; points[j] = tmp;
var tmpo = originalPoints[i]; originalPoints[i] = originalPoints[j]; originalPoints[j] = tmpo;
var tmpm = markers[i]; markers[i] = markers[j]; markers[j] = tmpm;
renderWaypointList();
calculateRoute();
}
function removeWaypoint(i) {
if (markers[i]) { map.removeLayer(markers[i]); }
markers.splice(i,1);
points.splice(i,1);
originalPoints.splice(i,1);
renderWaypointList();
calculateRoute();
}
// Geo helpers for round trip generation
function toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; }
function destPoint(lat, lon, distanceMeters, bearingDeg) {
var R = 6371000; // meters
var br = toRad(bearingDeg);
var φ1 = toRad(lat), λ1 = toRad(lon);
var δ = distanceMeters / R;
var sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
var sinδ = Math.sin(δ), cosδ = Math.cos(δ);
var sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(br);
var φ2 = Math.asin(sinφ2);
var y = Math.sin(br) * sinδ * cosφ1;
var x = cosδ - sinφ1 * sinφ2;
var λ2 = λ1 + Math.atan2(y, x);
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;
// Approximate radius for a loop with 3 intermediate waypoints: perimeter ≈ 4 * chord ≈ distance
// Use radius r ≈ distance / (2π)
var meters = km * 1000;
var r = Math.max(500, meters / (2 * Math.PI));
// 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';
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;
}
// ---- 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;
if (points.length > 0) {
start = { lat: points[0].lat, lng: points[0].lng };
} else {
var sourceInput = document.getElementById('sourceInput');
var sLat = parseFloat(sourceInput && sourceInput.dataset.lat);
var sLon = parseFloat(sourceInput && sourceInput.dataset.lon);
if (!isNaN(sLat) && !isNaN(sLon)) {
start = { lat: sLat, lng: sLon };
} else {
// fallback: map center
var c = map.getCenter();
start = { lat: c.lat, lng: c.lng };
}
}
// Clear existing markers/lines
markers.forEach(function(m){ map.removeLayer(m); });
markers = [];
points = [];
originalPoints = [];
if (routePolyline) { map.removeLayer(routePolyline); routePolyline = null; }
if (snapLines && snapLines.length) { snapLines.forEach(function(sl){ map.removeLayer(sl); }); snapLines = []; }
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){
var m = ev.target; var newLL = m.getLatLng(); var i = markers.indexOf(m);
if (i >= 0) { points[i] = { lat: newLL.lat, lng: newLL.lng }; originalPoints[i] = { lat: newLL.lat, lng: newLL.lng }; renderWaypointList(); calculateRoute(); }
});
markers.push(marker);
points.push({ lat: p.lat, lng: p.lng });
originalPoints.push({ lat: p.lat, lng: p.lng });
});
renderWaypointList();
calculateRoute();
}
function calculateRoute() {
if (points.length >= 2) {
var moto = {};
// Avoid highways -> lower highway usage weight
if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) {
moto.use_highways = 0.0; // 0..1 (0 avoids, 1 prefers)
}
// Avoid ferries -> lower ferry usage weight
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) {
moto.use_ferry = 0.0; // 0..1
}
// Avoid unpaved -> exclude unpaved roads entirely
if (avoidUnpavedCheckbox && avoidUnpavedCheckbox.checked) {
moto.exclude_unpaved = true;
}
// Avoid tolls -> exclude tolls
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
moto.exclude_tolls = true;
}
// Twistiness slider from localStorage: 0 (straight/fast) .. 100 (very twisty)
try {
var twistVal = parseInt(localStorage.getItem('freemoto-twistiness') || '50', 10);
if (!isNaN(twistVal)) {
// Map 0..100 to highway preference 1.0..0.0 (inverse)
var hw = Math.max(0, Math.min(1, 1 - (twistVal / 100)));
// If user also checked avoidHighways, keep the stronger effect (min)
moto.use_highways = (typeof moto.use_highways === 'number') ? Math.min(moto.use_highways, hw) : hw;
}
} catch (_) {}
// Highway preference slider directly sets use_highways proportionally
try {
var hwp = parseInt(localStorage.getItem('freemoto-highways') || '50', 10);
if (!isNaN(hwp)) {
var hw2 = Math.max(0, Math.min(1, hwp / 100));
// Combine with existing (take max so user can prefer highways even if twistiness low)
moto.use_highways = (typeof moto.use_highways === 'number') ? Math.max(moto.use_highways, hw2) : hw2;
}
} 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 = {
locations: points.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
costing: "motorcycle",
costing_options: costing_options,
units: "kilometers"
};
if (useShortestCheckbox && useShortestCheckbox.checked) {
requestBody.shortest = true; // top-level shortest flag
}
fetch('/route', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody)
})
.then(response => response.json())
.then(data => {
var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
var infoCard = document.getElementById('routeInfoCard');
// Sum summaries across legs if needed
var totalLen = 0, totalTime = 0;
legs.forEach(function(leg){
if (leg && leg.summary) {
if (typeof leg.summary.length === 'number') totalLen += leg.summary.length;
if (typeof leg.summary.time === 'number') totalTime += leg.summary.time;
}
});
if (legs.length > 0) {
var distanceKm = (totalLen).toFixed(1);
var durationMin = Math.round(totalTime / 60);
var info = `<strong>Distance:</strong> ${distanceKm} km<br>
<strong>Estimated Time:</strong> ${durationMin} min<br>
<strong>Motorcycle Profile</strong>`;
infoCard.innerHTML = info;
infoCard.classList.remove('d-none');
// Update summary pill in app bar
var pill = document.getElementById('summaryPill');
if (pill) {
pill.textContent = `${distanceKm} km · ${durationMin} min`;
pill.classList.remove('d-none');
}
} else {
infoCard.innerHTML = `<strong>Route info unavailable.</strong>`;
infoCard.classList.remove('d-none');
console.log('Valhalla response:', data);
}
// Draw route across all legs
var latlngs = [];
var legsLatLngs = [];
legs.forEach(function(leg){
var lls = decodePolyline6(leg.shape || '');
legsLatLngs.push(lls);
if (latlngs.length && lls.length) {
// avoid duplicating the joint point
latlngs = latlngs.concat(lls.slice(1));
} else {
latlngs = latlngs.concat(lls);
}
});
if (routePolyline) {
map.removeLayer(routePolyline);
}
routePolyline = L.polyline(latlngs, { color: 'red', weight: 5}).addTo(map);
map.fitBounds(routePolyline.getBounds());
// Build directions bottom sheet and next maneuver banner/voice
try {
var directionsSheet = document.getElementById('directionsSheet');
var directionsList = document.getElementById('directionsList');
directionsList.innerHTML = '';
lastRouteLatLngs = latlngs;
lastRtePoints = [];
// Aggregate maneuvers from all legs and render
legs.forEach(function(leg, li){
var maneuvers = leg.maneuvers || [];
var lls = legsLatLngs[li] || [];
maneuvers.forEach(function(m) {
var liEl = document.createElement('li');
liEl.className = 'list-group-item d-flex justify-content-between align-items-center';
var text = m.instruction || m.street_names?.join(', ') || 'Continue';
var meta = [];
if (typeof m.length === 'number') meta.push((m.length).toFixed(1) + ' km');
if (typeof m.time === 'number') meta.push(Math.round(m.time / 60) + ' min');
liEl.innerHTML = `<span>${text}</span><small class="text-secondary ms-2">${meta.join(' · ')}</small>`;
directionsList.appendChild(liEl);
// Store rte point for GPX
var idx = (typeof m.begin_shape_index === 'number') ? m.begin_shape_index : 0;
idx = Math.max(0, Math.min(idx, lls.length - 1));
var coord = lls[idx] || lls[0];
if (coord) {
lastRtePoints.push({ lat: coord[0], lon: coord[1], name: text });
}
});
});
// Next maneuver banner (first item)
if (directionsList.children.length > 0) {
var first = directionsList.children[0].querySelector('span');
if (first && nextBanner) {
nextBanner.textContent = first.textContent;
nextBanner.classList.remove('d-none');
}
// Voice prompt
if (voiceToggle && voiceToggle.checked && 'speechSynthesis' in window) {
try {
var utter = new SpeechSynthesisUtterance(first.textContent);
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utter);
} catch (_) {}
}
} else if (nextBanner) {
nextBanner.classList.add('d-none');
}
if (directionsList.children.length > 0) {
directionsSheet.style.display = 'block';
// default to collapsed to avoid covering full map
directionsSheet.classList.add('collapsed');
// Toggle collapse on handle click
var handle = directionsSheet.querySelector('.handle');
if (handle) {
handle.onclick = function() {
directionsSheet.classList.toggle('collapsed');
};
// Drag-to-resize
var sheetCard = directionsSheet.querySelector('.sheet-card');
var sheetBody = directionsSheet.querySelector('.sheet-body');
var dragging = false;
var startY = 0;
var startHeight = 0;
var vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
function px(val) { return `${val}px`; }
function clamp(val, min, max) { return Math.max(min, Math.min(max, val)); }
function onMove(clientY) {
var dy = startY - clientY; // drag up to expand
var newHeight = clamp(startHeight + dy, 0.28 * vh, 0.8 * vh);
sheetCard.style.maxHeight = px(newHeight);
if (sheetBody) sheetBody.style.maxHeight = px(newHeight - 42);
directionsSheet.classList.remove('collapsed');
}
handle.addEventListener('mousedown', function(ev) {
dragging = true;
startY = ev.clientY;
startHeight = sheetCard.getBoundingClientRect().height;
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', function(ev) {
if (!dragging) return;
onMove(ev.clientY);
});
window.addEventListener('mouseup', function() {
if (dragging) {
dragging = false;
document.body.style.userSelect = '';
}
});
// Touch
handle.addEventListener('touchstart', function(ev) {
dragging = true;
startY = ev.touches[0].clientY;
startHeight = sheetCard.getBoundingClientRect().height;
}, {passive: true});
window.addEventListener('touchmove', function(ev) {
if (!dragging) return;
onMove(ev.touches[0].clientY);
}, {passive: true});
window.addEventListener('touchend', function() { dragging = false; });
}
}
} catch (e) {
console.warn('Failed to render directions sheet', e);
}
// Snap-to-roads visualization for all waypoints: snapped points are
// first of leg0, then last point of each leg i (for waypoint i)
try {
// Clear previous snap lines
if (snapLines && snapLines.length) {
snapLines.forEach(function(sl){ map.removeLayer(sl); });
snapLines = [];
}
var snapped = [];
if (legsLatLngs.length > 0) {
snapped[0] = legsLatLngs[0][0];
for (var i = 1; i < legsLatLngs.length; i++) {
var lseg = legsLatLngs[i];
if (lseg && lseg.length) {
snapped[i] = lseg[lseg.length - 1];
}
}
}
for (var j = 0; j < markers.length && j < snapped.length; j++) {
if (!snapped[j]) continue;
if (originalPoints[j]) {
var sl = L.polyline([originalPoints[j], snapped[j]], { color: '#0d6efd', weight: 2, dashArray: '6,6', opacity: 0.7 });
sl.addTo(map);
snapLines.push(sl);
}
markers[j].setLatLng(snapped[j]);
}
} catch (e) { console.warn('Snap-to-roads viz failed', e); }
});
}
}
// Visual pulse effect at a latlng
function pulseAt(latlng) {
try {
var pulse = L.circle(latlng, { radius: 5, color: '#0d6efd', weight: 2, opacity: 0.8, fillOpacity: 0.2 });
pulse.addTo(map);
var steps = 10;
var i = 0;
var interval = setInterval(function() {
i++;
pulse.setRadius(5 + i * 20);
pulse.setStyle({ opacity: Math.max(0, 0.8 - i * 0.08), fillOpacity: Math.max(0, 0.2 - i * 0.02) });
if (i >= steps) {
clearInterval(interval);
map.removeLayer(pulse);
}
}, 30);
} catch (_) {}
}
function placePoint(latlng) {
if (points.length >= 10) { alert('Maximum of 10 waypoints.'); return; }
var marker = L.marker(latlng, { draggable: true }).addTo(map);
markers.push(marker);
points.push(latlng);
originalPoints.push({ lat: latlng.lat, lng: latlng.lng });
marker.bindPopup(points.length === 1 ? 'Start' : 'End').openPopup();
marker.on('dragend', function(ev){
var m = ev.target;
var newLL = m.getLatLng();
var idx = markers.indexOf(m);
if (idx >= 0) {
points[idx] = { lat: newLL.lat, lng: newLL.lng };
originalPoints[idx] = { lat: newLL.lat, lng: newLL.lng };
renderWaypointList();
calculateRoute();
}
});
pulseAt(latlng);
if (points.length === 1) {
reverseGeocode(latlng.lat, latlng.lng, 'sourceInput');
} else if (points.length === 2) {
reverseGeocode(latlng.lat, latlng.lng, 'destInput');
}
calculateRoute();
renderWaypointList();
}
function isTouchDevice() {
return (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) || ('ontouchstart' in window);
}
var placeHandler = function(e) { placePoint(e.latlng); };
if (isTouchDevice()) {
map.on('click', placeHandler); // single tap on mobile
} else {
map.on('dblclick', placeHandler); // double click on desktop
}
// Listen for changes on all checkboxes
[
avoidHighwaysCheckbox,
useShortestCheckbox,
avoidTollRoadsCheckbox,
avoidFerriesCheckbox,
avoidUnpavedCheckbox
].forEach(function(checkbox) {
if (checkbox) {
checkbox.addEventListener('change', calculateRoute);
}
});
// Disable other checkboxes when "Use shortest route" is checked
useShortestCheckbox.addEventListener('change', function() {
var disable = useShortestCheckbox.checked;
[
avoidHighwaysCheckbox,
avoidTollRoadsCheckbox,
avoidFerriesCheckbox,
avoidUnpavedCheckbox
].forEach(function(cb) {
cb.disabled = disable;
});
});
// Adapted from Valhalla docs — polyline6 decoder for JS
function decodePolyline6(encoded) {
var coords = [];
var index = 0, lat = 0, lng = 0;
var shift = 0, result = 0, byte = null, latitude_change, longitude_change;
var factor = Math.pow(10, 6);
while (index < encoded.length) {
byte = shift = result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
latitude_change = (result & 1) ? ~(result >> 1) : (result >> 1);
shift = result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
longitude_change = (result & 1) ? ~(result >> 1) : (result >> 1);
lat += latitude_change;
lng += longitude_change;
coords.push([lat / factor, lng / factor]);
}
return coords;
}
// Remove Markers
function resetMarkers() {
markers.forEach(function(marker) {
map.removeLayer(marker);
});
markers = [];
points = [];
originalPoints = [];
if (routePolyline) {
map.removeLayer(routePolyline);
routePolyline = null;
}
if (snapLines && snapLines.length) {
snapLines.forEach(function(sl){ map.removeLayer(sl); });
snapLines = [];
}
if (waypointListEl) waypointListEl.innerHTML = '';
// Clear address fields
var sourceInput = document.getElementById('sourceInput');
var destInput = document.getElementById('destInput');
sourceInput.value = '';
destInput.value = '';
delete sourceInput.dataset.lat;
delete sourceInput.dataset.lon;
delete destInput.dataset.lat;
delete destInput.dataset.lon;
// Hide and clear directions
var directionsSheet = document.getElementById('directionsSheet');
var directionsList = document.getElementById('directionsList');
if (directionsSheet) directionsSheet.style.display = 'none';
if (directionsList) directionsList.innerHTML = '';
// Hide summary pill
var pill = document.getElementById('summaryPill');
if (pill) pill.classList.add('d-none');
}
// Make resetMarkers available globally
window.resetMarkers = resetMarkers;
// Plot Route button logic
document.getElementById('plotRouteBtn').addEventListener('click', function() {
var sourceInput = document.getElementById('sourceInput');
var destInput = document.getElementById('destInput');
var sourceLat = parseFloat(sourceInput.dataset.lat);
var sourceLon = parseFloat(sourceInput.dataset.lon);
var destLat = parseFloat(destInput.dataset.lat);
var destLon = parseFloat(destInput.dataset.lon);
if (!isNaN(sourceLat) && !isNaN(sourceLon) && !isNaN(destLat) && !isNaN(destLon)) {
// Remove old markers
markers.forEach(function(marker) {
map.removeLayer(marker);
});
markers = [];
points = [];
// Add new markers
var startMarker = L.marker([sourceLat, sourceLon]).addTo(map).bindPopup("Start").openPopup();
var endMarker = L.marker([destLat, destLon]).addTo(map).bindPopup("End").openPopup();
markers.push(startMarker, endMarker);
points.push({lat: sourceLat, lng: sourceLon}, {lat: destLat, lng: destLon});
calculateRoute();
} else {
alert("Please enter valid addresses for both start and destination.");
}
});
function reverseGeocode(lat, lon, inputId) {
fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`)
.then(response => response.json())
.then(data => {
var input = document.getElementById(inputId);
if (!input) return;
input.value = (data && (data.display_name || (data.address && (data.address.road || data.address.city)))) || `${lat}, ${lon}`;
input.dataset.lat = lat;
input.dataset.lon = lon;
})
.catch(() => {
var input = document.getElementById(inputId);
if (!input) return;
input.value = `${lat}, ${lon}`;
input.dataset.lat = lat;
input.dataset.lon = lon;
});
}
function exportRouteAsGPX(latlngs) {
if (!latlngs || latlngs.length === 0) {
alert("No route to export.");
return;
}
const now = new Date().toISOString();
// Build <rte> using Valhalla maneuvers (snap to their begin shape indices)
var rtepts = '';
if (Array.isArray(lastRtePoints) && lastRtePoints.length) {
rtepts = lastRtePoints.map(function(p){
var safe = (p.name || 'Step').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return ` <rtept lat="${p.lat}" lon="${p.lon}"><name>${safe}</name></rtept>`;
}).join('\n');
}
var mode = exportModeSel ? (exportModeSel.value || 'both') : 'both';
var parts = [];
parts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
parts.push(`<gpx version="1.1" creator="FreeMoto" xmlns="http://www.topografix.com/GPX/1/1">`);
parts.push(` <metadata>\n <name>FreeMoto Route</name>\n <time>${now}</time>\n </metadata>`);
if (mode === 'route' || mode === 'both') {
parts.push(` <rte>\n <name>FreeMoto Directions</name>`);
parts.push(rtepts);
parts.push(` </rte>`);
}
if (mode === 'track' || mode === 'both') {
parts.push(` <trk>\n <name>FreeMoto Route</name>\n <type>motorcycle</type>\n <trkseg>`);
parts.push(latlngs.map(pt => ` <trkpt lat="${pt[0]}" lon="${pt[1]}"><time>${now}</time></trkpt>`).join('\n'));
parts.push(` </trkseg>\n </trk>`);
}
parts.push(`</gpx>`);
let gpx = parts.join('\n');
let blob = new Blob([gpx], {type: 'application/gpx+xml'});
let url = URL.createObjectURL(blob);
// iOS workaround: open in new tab instead of triggering download
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/i)) {
window.open(url, '_blank');
} else {
let a = document.createElement('a');
a.href = url;
a.download = 'freemoto-route.gpx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Bind all GPX export buttons
document.querySelectorAll('.export-gpx').forEach(function(btn){
btn.addEventListener('click', function() {
if (routePolyline) {
let latlngs = routePolyline.getLatLngs().map(ll => [ll.lat, ll.lng]);
exportRouteAsGPX(latlngs);
} else {
alert('No route to export.');
}
});
});
// Close directions sheet
var closeBtn = document.getElementById('closeDirections');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
var sheet = document.getElementById('directionsSheet');
if (sheet) sheet.style.display = 'none';
});
}
// Also hide summary pill when sheet closed manually
var pill = document.getElementById('summaryPill');
if (closeBtn && pill) {
closeBtn.addEventListener('click', function(){ pill.classList.add('d-none'); });
}
// Expose recalc for twistiness changes
window.recalculateRoute = function(){ calculateRoute(); };
window.createRoundTrip = function(){ createRoundTrip(); };
});