// Constants const METERS_PER_STEP = 0.762; // Average step length const SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes const API_KEY_STORAGE = 'pedestrian_simulator_apikey'; const LOCATION_STORAGE = 'pedestrian_simulator_location'; // Global state let map; let panorama; let directionsService; let masterPath = []; // Route coordinates let routeTotalDistance = 0; // Total route distance in meters let routeStartIndex = 0; // Where we are in the master path based on index let currentPathIndex = 0; // Minimap globals // Minimap globals let minimap; let minimapMarker; let minimapPolyline; // Main Map globals let mainMap; let mainMapMarker; let mainMapPolyline; let viewMode = 0; // 0: Street View, 1: Map View, 2: Satellite View let targetSyncTime = null; let idleAnimationId = null; // Pagination state let currentMyPage = 1; let currentPublicPage = 1; const KML_PAGE_LIMIT = 10; let apiKey = null; window.isGeneratingRoute = false; // Camera movement let baseHeading = 0; // Direction of travel let cameraTime = 0; // Time variable for smooth oscillations let lastFrameTime = null; // For calculating delta time let headingOscillationSpeed = 0.1; // How fast the head turns (radians per second) let headingRange = 80; // Max degrees to look left/right from center let pitchOscillationSpeed = 0.2; // Pitch movement speed let pitchRange = 8; // Max degrees to look up/down let basePitch = 0; // Neutral pitch (slightly down for natural walking view) // Initialize app window.addEventListener('load', async () => { // Check authentication status first const isAuthenticated = await checkAuth(); if (!isAuthenticated) { // Show login overlay document.getElementById('login-overlay').classList.add('active'); setupLoginHandler(); return; } // User is authenticated, proceed with normal init // Check for saved API key apiKey = localStorage.getItem(API_KEY_STORAGE); if (apiKey) { document.getElementById('apikey-overlay').classList.remove('active'); loadGoogleMapsAPI(); } else { setupApiKeyHandler(); } setupEventListeners(); setupKMLBrowser(); startStatusPolling(); }); // ======================================== // Authentication Functions // ======================================== async function checkAuth() { try { const response = await fetch('/api/status'); if (response.status === 401) { return false; } if (response.ok) { const data = await response.json(); updateUserProfile(data.user); return true; } return false; } catch (error) { console.error('Auth check failed:', error); return false; } } function updateUserProfile(user) { if (!user) return; const nameEl = document.getElementById('userName'); const avatarEl = document.getElementById('userAvatar'); const defaultAvatar = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png'; if (nameEl && user.displayName) nameEl.textContent = user.displayName; if (avatarEl) { avatarEl.src = user.avatarUrl || defaultAvatar; avatarEl.onerror = () => { avatarEl.src = defaultAvatar; }; } } function setupLoginHandler() { document.getElementById('fitbitLoginButton').addEventListener('click', () => { window.location.href = '/auth/fitbit'; }); } async function setupUserMenu() { // This function could fetch user info and display it // For now, the backend handles this via the Fitbit callback const userMenu = document.getElementById('user-menu'); document.getElementById('logoutButton').addEventListener('click', () => { window.location.href = '/auth/logout'; }); document.getElementById('kmlBrowserButton').addEventListener('click', () => { openKMLBrowser(); }); userMenu.style.display = 'flex'; } // ======================================== // KML Browser Functions // ======================================== function setupKMLBrowser() { // Setup user menu setupUserMenu(); // Tab switching document.querySelectorAll('.kml-tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; switchKMLTab(tabName); }); }); // Close button document.getElementById('closeKmlBrowser').addEventListener('click', () => { document.getElementById('kml-browser-overlay').classList.remove('active'); }); // Upload button document.getElementById('uploadKmlButton').addEventListener('click', () => { document.getElementById('kmlUploadInput').click(); }); document.getElementById('kmlUploadInput').addEventListener('change', handleKMLUpload); // Sort controls document.getElementById('myFilesSortSelect').addEventListener('change', () => { currentMyPage = 1; loadKMLFiles(); }); document.getElementById('publicFilesSortSelect').addEventListener('change', () => { currentPublicPage = 1; loadKMLFiles(); }); // Pagination buttons (My Files) document.getElementById('prevMyPage').addEventListener('click', () => { if (currentMyPage > 1) { currentMyPage--; loadKMLFiles(); } }); document.getElementById('nextMyPage').addEventListener('click', () => { currentMyPage++; loadKMLFiles(); }); // Pagination buttons (Public Files) document.getElementById('prevPublicPage').addEventListener('click', () => { if (currentPublicPage > 1) { currentPublicPage--; loadKMLFiles(); } }); document.getElementById('nextPublicPage').addEventListener('click', () => { currentPublicPage++; loadKMLFiles(); }); } function openKMLBrowser() { document.getElementById('kml-browser-overlay').classList.add('active'); loadKMLFiles(); } function switchKMLTab(tabName) { // Update tab buttons document.querySelectorAll('.kml-tab').forEach(t => t.classList.remove('active')); document.querySelector(`.kml-tab[data-tab="${tabName}"]`).classList.add('active'); // Update tab content document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); document.getElementById(`${tabName}-tab`).classList.add('active'); } async function handleKMLUpload(e) { const file = e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append('kml', file); try { const response = await fetch('/api/kml/upload', { method: 'POST', body: formData }); if (response.ok) { const data = await response.json(); alert(`KML file uploaded successfully! Distance: ${data.distance.toFixed(2)} km`); e.target.value = ''; // Reset input loadKMLFiles(); // Refresh list } else { const error = await response.text(); alert(`Upload failed: ${error}`); } } catch (error) { console.error('Upload error:', error); alert('Upload failed. Please try again.'); } } async function loadKMLFiles() { try { const mySort = document.getElementById('myFilesSortSelect').value; const publicSort = document.getElementById('publicFilesSortSelect').value; const params = new URLSearchParams({ my_page: currentMyPage, my_limit: KML_PAGE_LIMIT, my_sort_by: mySort, public_page: currentPublicPage, public_limit: KML_PAGE_LIMIT, public_sort_by: publicSort }); const response = await fetch(`/api/kml/list?${params.toString()}`); if (!response.ok) { console.error('Failed to load KML files'); return; } const data = await response.json(); // Render my files renderKMLFiles('myFilesList', data.my_files || [], true); // Update my pagination UI document.getElementById('myPageNum').textContent = `Page ${data.my_page}`; document.getElementById('prevMyPage').disabled = data.my_page <= 1; document.getElementById('nextMyPage').disabled = (data.my_page * KML_PAGE_LIMIT) >= data.my_total; // Render public files renderKMLFiles('publicFilesList', data.public_files || [], false); // Update public pagination UI document.getElementById('publicPageNum').textContent = `Page ${data.public_page}`; document.getElementById('prevPublicPage').disabled = data.public_page <= 1; document.getElementById('nextPublicPage').disabled = (data.public_page * KML_PAGE_LIMIT) >= data.public_total; } catch (error) { console.error('Error loading KML files:', error); } } function renderKMLFiles(listId, files, isOwnFiles) { const listElement = document.getElementById(listId); console.log(`[KML] Rendering ${files.length} files into ${listId}`); listElement.innerHTML = files.map((file, index) => createKMLFileHTML(file, isOwnFiles, listId, index)).join(''); // Attach event listeners files.forEach((file, index) => { const uniqueId = `${listId}-${index}`; try { // Use This Route button const useBtn = document.getElementById(`use-${uniqueId}`); if (useBtn) { useBtn.addEventListener('click', (e) => { console.log(`[KML] 'Use' click for: ${file.filename}`); handleUseRoute(file); }); } // Vote buttons (only for public files) if (!isOwnFiles) { const upvoteBtn = document.getElementById(`upvote-${uniqueId}`); const downvoteBtn = document.getElementById(`downvote-${uniqueId}`); if (upvoteBtn) upvoteBtn.addEventListener('click', () => handleVote(file, 1)); if (downvoteBtn) downvoteBtn.addEventListener('click', () => handleVote(file, -1)); } // Privacy toggle (only for own files) if (isOwnFiles) { const privacyBtn = document.getElementById(`privacy-${uniqueId}`); if (privacyBtn) { console.log(`[KML] Attaching Privacy listener for: ${file.filename}`); privacyBtn.addEventListener('click', () => { console.log(`[KML] 'Privacy' click for: ${file.filename}`); handlePrivacyToggle(file); }); } } // Delete button (only for own files) if (isOwnFiles) { const deleteBtn = document.getElementById(`delete-${uniqueId}`); if (deleteBtn) { console.log(`[KML] Attaching Delete listener for: ${file.filename}`); deleteBtn.addEventListener('click', (e) => { console.log(`[KML] 'Delete' click detected for: ${file.filename}`); handleDelete(file); }); } } } catch (err) { console.error(`[KML] Failed to attach listeners for ${file.filename}:`, err); } }); } function createKMLFileHTML(file, isOwnFiles, listId, index) { const uniqueId = `${listId}-${index}`; return `
${escapeHtml(file.filename)}
📏 ${file.distance.toFixed(2)} km ${!isOwnFiles ? `👤 ${escapeHtml(file.display_name)}` : ''} ${file.is_public ? '🌍 Public' : '🔒 Private'}
${!isOwnFiles ? ` ` : ` `}
`; } async function handleUseRoute(file) { try { const response = await fetch(`/api/kml/download?owner_id=${file.user_id}&filename=${encodeURIComponent(file.filename)}`); if (!response.ok) { alert('Failed to download KML file'); return; } const kmlContent = await response.text(); processKML(kmlContent, file.filename); // Close browser document.getElementById('kml-browser-overlay').classList.remove('active'); } catch (error) { console.error('Error selecting route:', error); alert('Failed to select route'); } } async function handleVote(file, voteValue) { try { const response = await fetch('/api/kml/vote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner_id: file.user_id, filename: file.filename, vote: voteValue }) }); if (response.ok) { loadKMLFiles(); // Refresh to show updated votes } else { alert('Failed to vote'); } } catch (error) { console.error('Vote error:', error); } } async function handlePrivacyToggle(file) { try { const response = await fetch('/api/kml/privacy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.filename }) }); if (response.ok) { loadKMLFiles(); // Refresh } else { alert('Failed to toggle privacy'); } } catch (error) { console.error('Privacy toggle error:', error); } } /** * Shows a custom confirmation overlay * @returns {Promise} */ function showConfirm({ title, message, confirmText = 'Yes', cancelText = 'Cancel', isDanger = false }) { return new Promise((resolve) => { const overlay = document.getElementById('confirm-overlay'); const titleEl = document.getElementById('confirmTitle'); const messageEl = document.getElementById('confirmMessage'); const confirmBtn = document.getElementById('generalConfirmButton'); const cancelBtn = document.getElementById('generalCancelButton'); titleEl.textContent = title; messageEl.textContent = message; confirmBtn.textContent = confirmText; cancelBtn.textContent = cancelText; if (isDanger) { confirmBtn.className = 'primary-btn danger'; } else { confirmBtn.className = 'primary-btn'; } const cleanup = () => { overlay.classList.remove('active'); confirmBtn.removeEventListener('click', onConfirm); cancelBtn.removeEventListener('click', onCancel); }; const onConfirm = () => { cleanup(); resolve(true); }; const onCancel = () => { cleanup(); resolve(false); }; confirmBtn.addEventListener('click', onConfirm); cancelBtn.addEventListener('click', onCancel); overlay.classList.add('active'); }); } async function handleDelete(file) { console.log('[KML] handleDelete function called for:', file.filename); const confirmed = await showConfirm({ title: '🗑️ Delete File', message: `Are you sure you want to delete "${file.filename}"? This cannot be undone.`, confirmText: 'Yes, Delete', isDanger: true }); if (!confirmed) { console.log('[KML] Delete cancelled by user'); return; } console.log('[KML] Proceeding with deletion of:', file.filename); try { const response = await fetch('/api/kml/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.filename }) }); if (response.ok) { loadKMLFiles(); // Refresh } else { alert('Failed to delete file'); } } catch (error) { console.error('Delete error:', error); } } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ======================================== // Original Functions // ======================================== function setupApiKeyHandler() { const saveBtn = document.getElementById('saveApiKeyButton'); const input = document.getElementById('apiKeyInput'); saveBtn.addEventListener('click', () => { const key = input.value.trim(); if (key) { apiKey = key; localStorage.setItem(API_KEY_STORAGE, key); document.getElementById('apikey-overlay').classList.remove('active'); loadGoogleMapsAPI(); } else { alert('Please enter a valid API key'); } }); input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { saveBtn.click(); } }); } function loadGoogleMapsAPI() { if (window.google && window.google.maps) { initializeMap(); return; } const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=geometry`; script.async = true; script.defer = true; script.onload = initializeMap; script.onerror = () => { alert('Failed to load Google Maps API. Please check your API key.'); localStorage.removeItem(API_KEY_STORAGE); location.reload(); }; document.head.appendChild(script); } function initializeMap() { // Check for saved location const savedLocation = localStorage.getItem(LOCATION_STORAGE); if (savedLocation) { const locationData = JSON.parse(savedLocation); const startLatLng = new google.maps.LatLng(locationData.startLat, locationData.startLng); // We need to reconstruct masterPath if possible, but we don't save the whole path. // For now, let's just re-calculate the route on load if we have start/end addresses if (locationData.isCustom && locationData.customPath) { // Restore custom route startFromCustomRoute(locationData.customPath, locationData.routeName, true); } else if (locationData.startAddress && locationData.endAddress) { document.getElementById('routeInfo').textContent = `${locationData.startAddress} ➝ ${locationData.endAddress}`; calculateAndStartRoute(locationData.startAddress, locationData.endAddress, true); // isRestoring = true } else { // Legacy fallback startFromLocation(startLatLng, locationData.address || "Saved Location"); } document.getElementById('setup-overlay').classList.remove('active'); } } function setupEventListeners() { // Start button document.getElementById('startButton').addEventListener('click', () => { const startInput = document.getElementById('startLocationInput').value; const endInput = document.getElementById('endLocationInput').value; if (startInput && endInput) { calculateAndStartRoute(startInput, endInput); } else { alert('Please enter both a start and destination location'); } }); // Enter key on location inputs const handleEnter = (e) => { if (e.key === 'Enter') { document.getElementById('startButton').click(); } }; document.getElementById('startLocationInput').addEventListener('keypress', handleEnter); document.getElementById('endLocationInput').addEventListener('keypress', handleEnter); // Route Info Click -> Open Reset Confirmation document.getElementById('routeInfoGroup').addEventListener('click', async () => { if (localStorage.getItem(LOCATION_STORAGE)) { const confirmed = await showConfirm({ title: '⚠️ Confirm Reset', message: 'Are you sure you want to reset your location? This will end your current trip.', confirmText: 'Yes, Reset', isDanger: true }); if (confirmed) { resetLocation(); } } }); // Space bar hotkey for refresh document.addEventListener('keydown', (e) => { // Ignore if typing in an input field if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { return; } // Only allow hotkeys when a trip is active if (!localStorage.getItem(LOCATION_STORAGE)) { return; } if (e.code === 'Space') { triggerRefresh(); } if (e.key.toLowerCase() === 'm') { cycleViewMode(); } if (e.key.toLowerCase() === 'd') { triggerDrain(); } }); // Refresh Button const refreshBtn = document.getElementById('refreshButton'); if (refreshBtn) { refreshBtn.addEventListener('click', () => { triggerRefresh(); }); } // Browse KML Files Button const browseKmlBtn = document.getElementById('browseKmlButton'); if (browseKmlBtn) { browseKmlBtn.addEventListener('click', () => { openKMLBrowser(); }); } } async function calculateAndStartRoute(startAddress, endAddress, isRestoring = false) { if (!window.google) return; // Use Directions Service to finding route if (!directionsService) { directionsService = new google.maps.DirectionsService(); } const request = { origin: startAddress, destination: endAddress, travelMode: google.maps.TravelMode.WALKING }; directionsService.route(request, (result, status) => { if (status === 'OK') { const route = result.routes[0]; const leg = route.legs[0]; // Extract full path masterPath = []; routeTotalDistance = 0; route.legs.forEach(leg => { routeTotalDistance += leg.distance.value; leg.steps.forEach(step => { step.path.forEach(point => { masterPath.push(point); }); }); }); const locationData = { startAddress: leg.start_address, endAddress: leg.end_address, startLat: leg.start_location.lat(), startLng: leg.start_location.lng() }; // Save state localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData)); // Initialize step reference // Only start a new trip on server if this is a fresh start, not a restore if (!isRestoring) { fetch('/api/trip', { method: 'POST' }).catch(console.error); } // Reset display steps for visual consistency displayTripSteps = 0; stateBuffer = []; startFromLocation(leg.start_location, locationData.startAddress); document.getElementById('setup-overlay').classList.remove('active'); // Update UI document.getElementById('routeInfo').textContent = `${leg.start_address} ➝ ${leg.end_address}`; // Draw on Minimap if (minimap) { if (minimapPolyline) minimapPolyline.setMap(null); minimapPolyline = new google.maps.Polyline({ path: masterPath, geodesic: true, strokeColor: '#6366f1', strokeOpacity: 1.0, strokeWeight: 4 }); minimapPolyline.setMap(minimap); // Fit bounds const bounds = new google.maps.LatLngBounds(); masterPath.forEach(pt => bounds.extend(pt)); minimap.fitBounds(bounds); } // Draw on Main Map if initialized if (mainMap) { if (mainMapPolyline) mainMapPolyline.setMap(null); mainMapPolyline = new google.maps.Polyline({ path: masterPath, geodesic: true, strokeColor: '#6366f1', strokeOpacity: 0.8, strokeWeight: 5 }); mainMapPolyline.setMap(mainMap); } // Initial trip meter update updateTripMeter(0); } else { alert('Could not calculate route: ' + status); } }); } function startFromLocation(locationLatLng, address) { const location = locationLatLng; // Initialize Street View panorama panorama = new google.maps.StreetViewPanorama( document.getElementById('panorama'), { position: location, pov: { heading: 0, pitch: 0 }, zoom: 1, disableDefaultUI: true, linksControl: false, clickToGo: false, scrollwheel: false, disableDoubleClickZoom: true, showRoadLabels: true, motionTracking: false, motionTrackingControl: false } ); // Initialize Minimap minimap = new google.maps.Map(document.getElementById('minimap'), { center: location, zoom: 14, disableDefaultUI: true, styles: [ { elementType: "geometry", stylers: [{ color: "#242f3e" }] }, { elementType: "labels.text.stroke", stylers: [{ color: "#242f3e" }] }, { elementType: "labels.text.fill", stylers: [{ color: "#746855" }] }, { featureType: "administrative.locality", elementType: "labels.text.fill", stylers: [{ color: "#d59563" }], }, { featureType: "poi", elementType: "labels.text.fill", stylers: [{ color: "#d59563" }], }, { featureType: "poi.park", elementType: "geometry", stylers: [{ color: "#263c3f" }], }, { featureType: "poi.park", elementType: "labels.text.fill", stylers: [{ color: "#6b9a76" }], }, { featureType: "road", elementType: "geometry", stylers: [{ color: "#38414e" }], }, { featureType: "road", elementType: "geometry.stroke", stylers: [{ color: "#212a37" }], }, { featureType: "road", elementType: "labels.text.fill", stylers: [{ color: "#9ca5b3" }], }, { featureType: "road.highway", elementType: "geometry", stylers: [{ color: "#746855" }], }, { featureType: "road.highway", elementType: "geometry.stroke", stylers: [{ color: "#1f2835" }], }, { featureType: "road.highway", elementType: "labels.text.fill", stylers: [{ color: "#f3d19c" }], }, { featureType: "water", elementType: "geometry", stylers: [{ color: "#17263c" }], }, { featureType: "water", elementType: "labels.text.fill", stylers: [{ color: "#515c6d" }], }, { featureType: "water", elementType: "labels.text.stroke", stylers: [{ color: "#17263c" }], }, ], }); minimapMarker = new google.maps.Marker({ position: location, map: minimap, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 7, fillColor: '#6366f1', fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 2, }, }); // Initialize directions service directionsService = new google.maps.DirectionsService(); // Start camera animation loop startCameraAnimation(); console.log('Street View initialized at:', address); } function resetLocation() { localStorage.removeItem(LOCATION_STORAGE); document.getElementById('setup-overlay').classList.add('active'); document.getElementById('startLocationInput').value = ''; document.getElementById('endLocationInput').value = ''; stopAnimation(); } // Rate limiting for refresh let lastRefreshTime = 0; const MIN_REFRESH_INTERVAL = 10000; // 10 seconds async function triggerRefresh() { const now = Date.now(); if (now - lastRefreshTime < MIN_REFRESH_INTERVAL) { console.log("Refresh rate limited"); return; } lastRefreshTime = now; // UI Feedback const refreshBtn = document.getElementById('refreshButton'); if (refreshBtn) refreshBtn.classList.add('spinning'); try { await fetch('/api/refresh', { method: 'POST' }); // Immediately fetch status to update view await fetchStatus(); } catch (err) { console.error("Refresh failed", err); } finally { if (refreshBtn) refreshBtn.classList.remove('spinning'); } } async function triggerDrain() { console.log("Triggering interpolation drain..."); try { await fetch('/api/drain', { method: 'POST' }); // Immediately fetch status to update view await fetchStatus(); } catch (err) { console.error("Drain trigger failed", err); } } // Step tracking // Using a 6-second delay buffer to ensure smooth interpolation between past known points. // This prevents overshooting/guessing. const INTERPOLATION_DELAY = 6000; // 6 seconds let stateBuffer = []; // Array of {t: timestamp, s: steps} let displayTripSteps = 0; // Current interpolated value // Fetch status from backend async function fetchStatus() { try { const response = await fetch('/api/status'); const data = await response.json(); const now = Date.now(); // Update target steps from server if (data.tripSteps !== undefined) { // Add to buffer stateBuffer.push({ t: now, s: data.tripSteps }); // Keep buffer size manageable (20 points is ~1.5 mins) if (stateBuffer.length > 20) { stateBuffer.shift(); } } // Update user info updateUserProfile(data.user); // Update next sync countdown const nextSyncTime = new Date(data.nextSyncTime); targetSyncTime = nextSyncTime; updateNextSyncCountdown(targetSyncTime); } catch (error) { console.error('Error fetching status:', error); } } function updateNextSyncCountdown(nextSyncTime) { const now = new Date(); const diff = nextSyncTime - now; if (diff <= 0) { document.getElementById('nextSync').textContent = 'Syncing...'; return; } const minutes = Math.floor(diff / 60000); const seconds = Math.floor((diff % 60000) / 1000); document.getElementById('nextSync').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; } function startStatusPolling() { fetchStatus(); setInterval(fetchStatus, 5000); // Poll every 5 seconds // Update countdown every second setInterval(() => { if (targetSyncTime) { updateNextSyncCountdown(targetSyncTime); } }, 1000); } function updateTripMeter(distanceTraveled) { const traveledKm = (distanceTraveled / 1000).toFixed(2); const totalKm = (routeTotalDistance / 1000).toFixed(2); document.getElementById('tripMeter').textContent = `${traveledKm} / ${totalKm} km`; } let streetViewService; function updatePositionAlongPath(distanceMeters) { if (!masterPath || masterPath.length < 2) return; // Iterate through path segments to find our current spot let distanceCovered = 0; for (let i = 0; i < masterPath.length - 1; i++) { const p1 = masterPath[i]; const p2 = masterPath[i + 1]; const segmentDist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2); if (distanceCovered + segmentDist >= distanceMeters) { // We are on this segment const remainingDist = distanceMeters - distanceCovered; const heading = google.maps.geometry.spherical.computeHeading(p1, p2); const targetPos = google.maps.geometry.spherical.computeOffset(p1, remainingDist, heading); // Look ahead to next point // Smoothly update baseHeading for idle animation // We set baseHeading here to ensure we face the direction of travel baseHeading = heading; window.isAutoUpdatingCamera = true; // Use StreetViewService to find the nearest valid panorama if (!streetViewService) { streetViewService = new google.maps.StreetViewService(); } // We use a radius of 50 meters to find a snap point const request = { location: targetPos, preference: google.maps.StreetViewPreference.NEAREST, radius: 50, source: google.maps.StreetViewSource.OUTDOOR }; streetViewService.getPanorama(request, (data, status) => { const now = Date.now(); // REGARDLESS of success or failure, we update the timestamp to prevent // immediate retries. This acts as our rate limit/backoff. window.lastPanoUpdate = now; if (status === 'OK') { const foundPanoId = data.location.pano; // Only update if we are moving to a new node to avoid slight jitters if (panorama.getPano() !== foundPanoId) { panorama.setPano(foundPanoId); // Update minimap to the ACTUAL location found, not just the theoretical one const foundLoc = data.location.latLng; if (minimapMarker) { minimapMarker.setPosition(foundLoc); if (minimap && minimap.getBounds() && !minimap.getBounds().contains(foundLoc)) { minimap.panTo(foundLoc); } } // Sync Main Map if it exists if (mainMapMarker) { mainMapMarker.setPosition(foundLoc); if (mainMap && mainMap.getBounds() && !mainMap.getBounds().contains(foundLoc)) { mainMap.panTo(foundLoc); } } } } else { console.log(`Street View lookup failed: ${status}. Throttling retry.`); } }); return; } distanceCovered += segmentDist; } // If we overshoot, just ensure we throttle window.lastPanoUpdate = Date.now(); } function startCameraAnimation() { if (idleAnimationId) { cancelAnimationFrame(idleAnimationId); } let lastStatsTime = 0; function animate(timestamp) { try { if (!panorama) { idleAnimationId = requestAnimationFrame(animate); return; } // Initialize time tracking if (!lastFrameTime) { lastFrameTime = timestamp; } const deltaTime = (timestamp - lastFrameTime) / 1000; lastFrameTime = timestamp; cameraTime += deltaTime; // Apply visual oscillation // We use a simplified version of applyCameraMovement logic here directly // --- STEP INTERPOLATION --- // Buffered Playback // Render the state at (now - delay) const renderTime = Date.now() - INTERPOLATION_DELAY; // Find the two points bounding renderTime let p0 = null; let p1 = null; // Iterate backlog to find p0 <= renderTime < p1 for (let i = 0; i < stateBuffer.length - 1; i++) { if (stateBuffer[i].t <= renderTime && stateBuffer[i + 1].t > renderTime) { p0 = stateBuffer[i]; p1 = stateBuffer[i + 1]; break; } } // If we found a span, interpolate if (p0 && p1) { const totalTime = p1.t - p0.t; const elapsed = renderTime - p0.t; const ratio = elapsed / totalTime; // Simple lerp displayTripSteps = p0.s + (p1.s - p0.s) * ratio; } else { // If we didn't find a span, we are either too old or too new. if (stateBuffer.length > 0) { if (renderTime < stateBuffer[0].t) { // We are waiting for buffer to fill (start of session) // Just show the first point displayTripSteps = stateBuffer[0].s; } else { // We ran out of future points (buffer underflow / lag) // Show the latest point displayTripSteps = stateBuffer[stateBuffer.length - 1].s; } } } // Update UI and Position document.getElementById('currentSteps').textContent = Math.round(displayTripSteps).toLocaleString(); // Calculate distance const distanceTraveled = displayTripSteps * METERS_PER_STEP; updateTripMeter(distanceTraveled); // Throttle Position Updates const lastDist = window.lastPositionUpdateDistance || 0; const lastTime = window.lastPanoUpdate || 0; const now = Date.now(); if (Math.abs(distanceTraveled - lastDist) > 10.0 && (now - lastTime) > 1000) { if (masterPath.length > 0 && panorama) { updatePositionAlongPath(distanceTraveled); window.lastPositionUpdateDistance = distanceTraveled; window.lastPanoUpdate = now; } } // --------------------------- // Primary left-right head turn (slow, wide) const headTurn1 = Math.sin(cameraTime * headingOscillationSpeed) * headingRange * 0.6; const headTurn2 = Math.sin(cameraTime * headingOscillationSpeed * 2.3) * headingRange * 0.25; const headTurn3 = Math.sin(cameraTime * headingOscillationSpeed * 0.4) * headingRange * 0.15; const headingOffset = headTurn1 + headTurn2 + headTurn3; // Pitch movements const pitchOscillation1 = Math.sin(cameraTime * pitchOscillationSpeed * 1.7) * pitchRange * 0.5; const pitchOscillation2 = Math.sin(cameraTime * pitchOscillationSpeed * 3.1) * pitchRange * 0.3; const pitchOscillation3 = Math.cos(cameraTime * pitchOscillationSpeed * 0.8) * pitchRange * 0.2; const finalPitch = basePitch + pitchOscillation1 + pitchOscillation2 + pitchOscillation3; const finalHeading = baseHeading + headingOffset; if (!isNaN(finalHeading) && !isNaN(finalPitch)) { panorama.setPov({ heading: finalHeading, pitch: finalPitch }); } idleAnimationId = requestAnimationFrame(animate); } catch (e) { console.error("Animation Loop Crash:", e); // Try to restart or stop? Better to stop to avoid infinite error loops cancelAnimationFrame(idleAnimationId); } } idleAnimationId = requestAnimationFrame(animate); } function stopAnimation() { if (idleAnimationId) { cancelAnimationFrame(idleAnimationId); idleAnimationId = null; } currentPathIndex = 0; cameraTime = 0; // Reset camera animation lastFrameTime = null; // Reset frame time tracking const animProgress = document.getElementById('animProgress'); if (animProgress) { animProgress.textContent = '0%'; } } function cycleViewMode() { // Don't cycle if no route is set yet if (!panorama || !masterPath || masterPath.length === 0) { console.log('Cannot cycle view mode: no route set'); return; } viewMode = (viewMode + 1) % 3; updateViewMode(); } function updateViewMode() { const panoDiv = document.getElementById('panorama'); const mainMapDiv = document.getElementById('main-map'); const minimapContainer = document.getElementById('minimap-container'); // State machine for transitions if (viewMode === 0) { // Switching TO Street View (from Satellite/Map) // Transition: Animate Main Map shrinking into Minimap // Show minimap immediately so we have a target minimapContainer.classList.remove('hidden-mode'); animateMapToMinimap(() => { panoDiv.classList.remove('hidden-mode'); // Fix Black Screen: Trigger resize when pano becomes visible google.maps.event.trigger(panorama, 'resize'); mainMapDiv.classList.add('hidden-mode'); // Ensure styles are cleaned up mainMapDiv.style.borderRadius = ''; // Fix Satellite Flash: Reset to roadmap so next time we open map it starts clean if (mainMap) { mainMap.setMapTypeId(google.maps.MapTypeId.ROADMAP); } }); } else { // Switching TO Map or Satellite // Initialize if first run if (!mainMap) { initializeMainMap(); // Force layout google.maps.event.trigger(mainMap, "resize"); } // Set Map Type const targetType = (viewMode === 1) ? google.maps.MapTypeId.ROADMAP : google.maps.MapTypeId.HYBRID; mainMap.setMapTypeId(targetType); if (mainMapDiv.classList.contains('hidden-mode')) { // Switching FROM Street View TO Map // Transition: Animate Main Map expanding from Minimap // Ensure Street View is hidden AFTER animation or immediately? // Better to hide SV immediately so we see map expanding over black/bg? // Or keep SV visible behind? // Let's keep SV visible for a moment? No, it might look glitchy. // Let's hide SV immediately to indicate mode switch, then expand map. panoDiv.classList.add('hidden-mode'); animateMapToFullscreen(() => { minimapContainer.classList.add('hidden-mode'); }); } else { // Just switching Map <-> Satellite (No animation needed, handled by map type change) panoDiv.classList.add('hidden-mode'); mainMapDiv.classList.remove('hidden-mode'); minimapContainer.classList.add('hidden-mode'); } // Sync Center if (panorama) { const currentPos = panorama.getPosition(); if (currentPos) { mainMap.setCenter(currentPos); if (mainMapMarker) mainMapMarker.setPosition(currentPos); } } } } function animateMapToFullscreen(onComplete) { const mainMapDiv = document.getElementById('main-map'); const minimapContainer = document.getElementById('minimap-container'); // 1. Measure Start State (Minimap) const rect = minimapContainer.getBoundingClientRect(); // 2. Set Initial State on Main Map (Match Minimap) mainMapDiv.style.transition = 'none'; // Disable transition for setup mainMapDiv.style.top = rect.top + 'px'; mainMapDiv.style.left = rect.left + 'px'; mainMapDiv.style.width = rect.width + 'px'; mainMapDiv.style.height = rect.height + 'px'; mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius; mainMapDiv.classList.remove('hidden-mode'); // 3. Force Reflow void mainMapDiv.offsetHeight; // 4. Set Target State (Fullscreen) // Re-enable transition defined in CSS mainMapDiv.style.transition = ''; mainMapDiv.style.top = '0'; mainMapDiv.style.left = '0'; mainMapDiv.style.width = '100%'; mainMapDiv.style.height = '100%'; mainMapDiv.style.borderRadius = '0'; // 5. Cleanup on End const handleTransitionEnd = () => { mainMapDiv.removeEventListener('transitionend', handleTransitionEnd); // Clear manual styles so CSS classes take over mainMapDiv.style.top = ''; mainMapDiv.style.left = ''; mainMapDiv.style.width = ''; mainMapDiv.style.height = ''; mainMapDiv.style.borderRadius = ''; google.maps.event.trigger(mainMap, "resize"); if (onComplete) onComplete(); }; mainMapDiv.addEventListener('transitionend', handleTransitionEnd); } function animateMapToMinimap(callback) { // No complex animation for now, just direct switch callback(); } function initializeMainMap() { mainMap = new google.maps.Map(document.getElementById('main-map'), { center: { lat: 0, lng: 0 }, zoom: 14, disableDefaultUI: false, mapTypeControl: false, streetViewControl: false, fullscreenControl: false }); // Sync marker mainMapMarker = new google.maps.Marker({ position: { lat: 0, lng: 0 }, map: mainMap, icon: { path: google.maps.SymbolPath.CIRCLE, scale: 7, fillColor: '#6366f1', fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 2, }, }); if (panorama) { const pos = panorama.getPosition(); if (pos) { mainMap.setCenter(pos); mainMapMarker.setPosition(pos); } } // Add polyline if path exists if (masterPath && masterPath.length > 0) { mainMapPolyline = new google.maps.Polyline({ path: masterPath, geodesic: true, strokeColor: '#6366f1', strokeOpacity: 0.8, strokeWeight: 5 }); mainMapPolyline.setMap(mainMap); } } // --- Custom Route Logic --- function processKML(kmlContent, fileName) { try { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(kmlContent, "text/xml"); const placemarks = xmlDoc.getElementsByTagName("Placemark"); let fullPath = []; // Find all LineStrings for (let i = 0; i < placemarks.length; i++) { const lineString = placemarks[i].getElementsByTagName("LineString")[0]; if (lineString) { const coordsText = lineString.getElementsByTagName("coordinates")[0].textContent.trim(); const points = parseCoordinates(coordsText); fullPath = fullPath.concat(points); } } // Fallback: Check for top-level LineString/LinearRing not in Placemark (rare but possible in fragments) if (fullPath.length === 0) { const lineStrings = xmlDoc.getElementsByTagName("LineString"); for (let i = 0; i < lineStrings.length; i++) { const coordsText = lineStrings[i].getElementsByTagName("coordinates")[0].textContent.trim(); const points = parseCoordinates(coordsText); fullPath = fullPath.concat(points); } } if (fullPath.length < 2) { alert("No route found in KML file. Make sure it contains a LineString/Path."); return; } startFromCustomRoute(fullPath, fileName); } catch (e) { console.error("Error parsing KML:", e); alert("Failed to parse KML file. Please ensure it is a valid Google Maps export."); } } function parseCoordinates(text) { // KML format: lon,lat,alt (space separated tuples) const rawPoints = text.split(/\s+/); const path = []; rawPoints.forEach(pt => { const parts = pt.split(','); if (parts.length >= 2) { // KML is Longitude, Latitude const lng = parseFloat(parts[0]); const lat = parseFloat(parts[1]); if (!isNaN(lat) && !isNaN(lng)) { path.push({ lat: lat, lng: lng }); } } }); return path; } function densifyPath(path, maxSegmentLength = 20) { if (!path || path.length < 2) return path; const densified = [path[0]]; for (let i = 0; i < path.length - 1; i++) { const p1 = path[i]; const p2 = path[i + 1]; // We use simple linear interpolation here as we are on a small scale // For more accuracy we could use spherical interpolation but at 20m linear is fine. const d = google.maps.geometry.spherical.computeDistanceBetween( new google.maps.LatLng(p1.lat, p1.lng), new google.maps.LatLng(p2.lat, p2.lng) ); if (d > maxSegmentLength) { const numPoints = Math.ceil(d / maxSegmentLength); for (let j = 1; j < numPoints; j++) { const ratio = j / numPoints; densified.push({ lat: p1.lat + (p2.lat - p1.lat) * ratio, lng: p1.lng + (p2.lng - p1.lng) * ratio }); } } densified.push(p2); } return densified; } function startFromCustomRoute(pathData, routeName, isRestoring = false) { if (!window.google) return; // Convert to Google Maps LatLng objects const densePath = densifyPath(pathData); masterPath = densePath.map(pt => new google.maps.LatLng(pt.lat, pt.lng)); // Calculate total distance routeTotalDistance = 0; for (let i = 0; i < masterPath.length - 1; i++) { routeTotalDistance += google.maps.geometry.spherical.computeDistanceBetween(masterPath[i], masterPath[i + 1]); } const startPoint = masterPath[0]; // Save state const locationData = { isCustom: true, customPath: pathData, // Save raw objects {lat, lng} for serialization routeName: routeName, startLat: startPoint.lat(), startLng: startPoint.lng(), address: routeName, startAddress: routeName, // Compatibility endAddress: routeName }; localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData)); // Initialize/Reset Server Trip if (!isRestoring) { fetch('/api/trip', { method: 'POST' }).catch(console.error); } // Reset display steps displayTripSteps = 0; stateBuffer = []; // Initialize Map View startFromLocation(startPoint, routeName); document.getElementById('setup-overlay').classList.remove('active'); // Update UI document.getElementById('routeInfo').textContent = `${routeName}`; // Draw on Minimap if (minimap) { if (minimapPolyline) minimapPolyline.setMap(null); minimapPolyline = new google.maps.Polyline({ path: masterPath, geodesic: true, strokeColor: '#6366f1', strokeOpacity: 1.0, strokeWeight: 4 }); minimapPolyline.setMap(minimap); // Fit bounds const bounds = new google.maps.LatLngBounds(); masterPath.forEach(pt => bounds.extend(pt)); minimap.fitBounds(bounds); } // Draw on Main Map if initialized if (mainMap) { if (mainMapPolyline) mainMapPolyline.setMap(null); mainMapPolyline = new google.maps.Polyline({ path: masterPath, geodesic: true, strokeColor: '#6366f1', strokeOpacity: 0.8, strokeWeight: 5 }); mainMapPolyline.setMap(mainMap); } // Initial trip meter update updateTripMeter(0); }