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 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 = `${label}
${coords}
`; 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 = `Distance: ${distanceKm} km
Estimated Time: ${durationMin} min
Motorcycle Profile`; 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 = `Route info unavailable.`; 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 = `${text}${meta.join(' · ')}`; 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 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,'&').replace(//g,'>'); return ` ${safe}`; }).join('\n'); } var mode = exportModeSel ? (exportModeSel.value || 'both') : 'both'; var parts = []; parts.push(``); parts.push(``); parts.push(` \n FreeMoto Route\n \n `); if (mode === 'route' || mode === 'both') { parts.push(` \n FreeMoto Directions`); parts.push(rtepts); parts.push(` `); } if (mode === 'track' || mode === 'both') { parts.push(` \n FreeMoto Route\n motorcycle\n `); parts.push(latlngs.map(pt => ` `).join('\n')); parts.push(` \n `); } parts.push(``); 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(); }; });