// 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; // Custom Route Logic let apiKey = null; let currentUserID = null; // Store current user ID for ownership checks 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; currentUserID = user.fitbit_user_id || user.id; // Store ID console.log(`[KML] User Profile Updated. currentUserID: ${currentUserID}`); 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; nameEl.style.cursor = 'pointer'; if (!nameEl.hasListener) { nameEl.addEventListener('click', () => openUserProfile(currentUserID)); nameEl.hasListener = true; } } if (avatarEl) { avatarEl.src = user.avatarUrl || defaultAvatar; avatarEl.style.cursor = 'pointer'; avatarEl.onerror = () => { avatarEl.src = defaultAvatar; }; if (!avatarEl.hasListener) { avatarEl.addEventListener('click', () => openUserProfile(currentUserID)); avatarEl.hasListener = true; } } } 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() { setupUserMenu(); setupKMLDetailsListeners(); setupUserProfileListeners(); // 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 { // User Profile Link (new) if (!isOwnFiles) { const userBtn = document.getElementById(`user-${uniqueId}`); if (userBtn) { userBtn.addEventListener('click', (e) => { e.stopPropagation(); openUserProfile(file.user_id); }); } } // Open Details button const openBtn = document.getElementById(`open-${uniqueId}`); if (openBtn) { openBtn.addEventListener('click', () => { console.log(`[KML] 'Open' click for: ${file.filename}`); openKMLDetails(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); } } // ======================================== // KML Details Overlay Logic // ======================================== let previewMap; let previewPolyline; let currentKmlFile = null; let currentKmlPath = []; let currentKmlMarkers = []; function setupKMLDetailsListeners() { document.getElementById('closeKmlDetails').addEventListener('click', closeKMLDetails); document.getElementById('backToKmlListBtn').addEventListener('click', closeKMLDetails); // Just close overlay to reveal browser behind document.getElementById('startKmlTripBtn').addEventListener('click', async () => { if (!currentKmlFile || currentKmlPath.length === 0) return; // Check for existing trip if (localStorage.getItem(LOCATION_STORAGE)) { const confirmed = await showConfirm({ title: '⚠️ Start New Trip?', message: 'Starting a new trip will end your current one. Are you sure?', confirmText: 'Yes, Start New Trip', isDanger: true }); console.log('[KML] Confirmation result:', confirmed); if (!confirmed) { console.log('[KML] Start new trip aborted by user'); return; } } // Start the trip startFromCustomRoute(currentKmlPath, currentKmlFile.filename, false, currentKmlMarkers); // Close overlays closeKMLDetails(); document.getElementById('kml-browser-overlay').classList.remove('active'); }); // Description Editing document.getElementById('editDescriptionBtn').addEventListener('click', () => { document.getElementById('kmlDescriptionDisplay').classList.add('hidden'); document.getElementById('kmlDescriptionEditor').classList.remove('hidden'); document.getElementById('kmlDescriptionInput').value = currentKmlFile.description || ''; document.getElementById('editDescriptionBtn').classList.add('hidden'); }); document.getElementById('cancelDescriptionBtn').addEventListener('click', () => { document.getElementById('kmlDescriptionDisplay').classList.remove('hidden'); document.getElementById('kmlDescriptionEditor').classList.add('hidden'); document.getElementById('editDescriptionBtn').classList.remove('hidden'); }); document.getElementById('saveDescriptionBtn').addEventListener('click', async () => { const newDesc = document.getElementById('kmlDescriptionInput').value.trim(); try { const response = await fetch('/api/kml/edit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: currentKmlFile.filename, description: newDesc }) }); if (response.ok) { currentKmlFile.description = newDesc; document.getElementById('kmlDescriptionDisplay').textContent = newDesc || 'No description provided.'; document.getElementById('kmlDescriptionDisplay').classList.remove('hidden'); document.getElementById('kmlDescriptionEditor').classList.add('hidden'); document.getElementById('editDescriptionBtn').classList.remove('hidden'); loadKMLFiles(); // Refresh list to update cache/view if needed } else { alert('Failed to save description'); } } catch (err) { console.error('Save description failed:', err); alert('Failed to save description'); } }); } function closeKMLDetails() { document.getElementById('kml-details-overlay').classList.remove('active'); // Restore logic: if Profile is open, go back to profile URL, else Home if (document.getElementById('user-profile-overlay').classList.contains('active') && currentProfileUserId) { updateURLState('profile', { userId: currentProfileUserId }); } else { updateURLState('home', null); } } async function openKMLDetails(file) { currentKmlFile = file; const overlay = document.getElementById('kml-details-overlay'); // Update URL updateURLState('kml', { ownerId: file.user_id, filename: file.filename }); // Reset UI document.getElementById('kmlDetailsTitle').textContent = file.filename; document.getElementById('kmlDetailsDistance').textContent = `📏 ${file.distance.toFixed(2)} km`; document.getElementById('kmlDetailsAuthor').textContent = file.display_name ? `👤 ${file.display_name}` : ''; document.getElementById('kmlDetailsVotes').textContent = `👍 ${file.votes}`; document.getElementById('kmlDescriptionDisplay').textContent = file.description || 'No description provided.'; // Show/Hide Edit Button (only for owner) console.log(`[KML] Opening details. File Owner: ${file.user_id} (type: ${typeof file.user_id}), Current User: ${currentUserID} (type: ${typeof currentUserID})`); // Loose equality check in case of string vs number mismatch if (file.user_id == currentUserID) { document.getElementById('editDescriptionBtn').classList.remove('hidden'); } else { document.getElementById('editDescriptionBtn').classList.add('hidden'); } overlay.classList.add('active'); // Fetch and Parse KML try { const response = await fetch(`/api/kml/download?owner_id=${file.user_id}&filename=${encodeURIComponent(file.filename)}`); if (!response.ok) throw new Error('Failed to download KML'); const kmlText = await response.text(); const parsed = parseKMLData(kmlText); currentKmlPath = parsed.path; currentKmlMarkers = parsed.markers; // Render Map if (!previewMap) { previewMap = new google.maps.Map(document.getElementById('kmlPreviewMap'), { mapTypeId: google.maps.MapTypeId.ROADMAP, fullscreenControl: false, streetViewControl: false }); } // Draw Route if (previewPolyline) previewPolyline.setMap(null); previewPolyline = new google.maps.Polyline({ path: currentKmlPath, geodesic: true, strokeColor: '#FF0000', strokeOpacity: 0.8, strokeWeight: 4, map: previewMap }); const bounds = new google.maps.LatLngBounds(); currentKmlPath.forEach(pt => bounds.extend(pt)); previewMap.fitBounds(bounds); } catch (err) { console.error('Error loading KML details:', err); alert('Failed to load route details.'); } } function parseKMLData(kmlContent) { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(kmlContent, "text/xml"); const placemarks = xmlDoc.getElementsByTagName("Placemark"); let fullPath = []; let markers = []; for (let i = 0; i < placemarks.length; i++) { const pm = placemarks[i]; // Check for LineString const lineString = pm.getElementsByTagName("LineString")[0]; if (lineString) { const coordsText = lineString.getElementsByTagName("coordinates")[0].textContent.trim(); const points = parseCoordinates(coordsText); fullPath = fullPath.concat(points); } // Check for Point (Markers) const point = pm.getElementsByTagName("Point")[0]; if (point) { const coordsText = point.getElementsByTagName("coordinates")[0].textContent.trim(); const points = parseCoordinates(coordsText); if (points.length > 0) { const nameNode = pm.getElementsByTagName("name")[0]; const descNode = pm.getElementsByTagName("description")[0]; markers.push({ lat: points[0].lat, lng: points[0].lng, name: nameNode ? nameNode.textContent : 'Marker', description: descNode ? descNode.textContent : '' }); } } } return { path: fullPath, markers: markers }; } 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, locationData.customMarkers || []); } 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"); } hideSetupOverlay(); } } function showSetupOverlay() { const overlay = document.getElementById('setup-overlay'); const closeBtn = document.getElementById('closeSetupButton'); // Only show close button if there's an active trip if (localStorage.getItem(LOCATION_STORAGE)) { closeBtn.style.display = 'block'; } else { closeBtn.style.display = 'none'; } overlay.classList.add('active'); } function hideSetupOverlay() { document.getElementById('setup-overlay').classList.remove('active'); } function setupEventListeners() { // Start button document.getElementById('startButton').addEventListener('click', async () => { const startInput = document.getElementById('startLocationInput').value; const endInput = document.getElementById('endLocationInput').value; if (startInput && endInput) { // Check for existing trip if (localStorage.getItem(LOCATION_STORAGE)) { const confirmed = await showConfirm({ title: '⚠️ Start New Trip?', message: 'Starting a new trip will end your current one. Are you sure?', confirmText: 'Yes, Start New Trip', isDanger: true }); console.log('[Manual] Confirmation result:', confirmed); if (!confirmed) { console.log('[Manual] Start new trip aborted by user'); return; } } 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 Setup (No reset prompt here) document.getElementById('routeInfoGroup').addEventListener('click', () => { resetLocation(); // This now just opens the overlay }); // 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(); }); } // Close Setup Button const closeSetupBtn = document.getElementById('closeSetupButton'); if (closeSetupBtn) { closeSetupBtn.addEventListener('click', () => { hideSetupOverlay(); }); } } async function calculateAndStartRoute(startAddress, endAddress, isRestoring = false) { if (!window.google) return; // Reset celebration state immediately on start attempt window.hasCelebrated = false; // 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)); currentTripDetails = { type: 'address', route_name: `${leg.start_address} to ${leg.end_address}`, start_address: leg.start_address, end_address: leg.end_address, distance: routeTotalDistance / 1000 // In km }; // 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); hideSetupOverlay(); // 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; // Reset celebration state window.hasCelebrated = false; // 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(); // Check for Deep Links restoreStateFromURL(); console.log('Street View initialized at:', address); } function resetLocation() { // We don't remove LOCATION_STORAGE here anymore, so that the confirmation // dialog in the Start buttons correctly sees the active trip. // The storage will be overwritten when the NEW trip actually starts. showSetupOverlay(); 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; // State variables let currentTripDetails = null; window.hasCelebrated = false; 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 }); } // --- TRIP COMPLETION CHECK --- if (masterPath.length > 0 && routeTotalDistance > 0 && !window.hasCelebrated) { if (distanceTraveled >= routeTotalDistance) { window.hasCelebrated = true; triggerCelebration(distanceTraveled); } } 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(); } // ======================================== // User Profile Logic // ======================================== async function openUserProfile(userId) { if (!userId) return; const overlay = document.getElementById('user-profile-overlay'); // Reset UI document.getElementById('profileDisplayName').textContent = 'Loading...'; document.getElementById('profileFitbitID').textContent = '@...'; document.getElementById('profileAvatar').src = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png'; document.getElementById('profilePublicTripsList').innerHTML = '

Loading...

'; overlay.classList.add('active'); // Update URL updateURLState('profile', { userId: userId }); try { // 1. Fetch User Info & Completed Trips const userResp = await fetch(`/api/user/profile?user_id=${userId}`); if (!userResp.ok) throw new Error('User not found'); const profileData = await userResp.json(); const user = profileData.user; document.getElementById('profileDisplayName').textContent = user.display_name || 'Unknown User'; document.getElementById('profileFitbitID').textContent = `@${user.fitbit_user_id}`; document.getElementById('profileAvatar').src = user.avatar_url || 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png'; // Render Completed Trips const completedListEl = document.getElementById('profileCompletedTripsList'); if (profileData.completed_trips && profileData.completed_trips.length > 0) { completedListEl.innerHTML = profileData.completed_trips.map((trip, index) => `
${escapeHtml(trip.route_name)}
📏 ${trip.distance.toFixed(2)} km 📅 ${new Date(trip.completed_at).toLocaleDateString()}
`).join(''); // Attach listeners for completed trips profileData.completed_trips.forEach((trip, index) => { document.getElementById(`start-completed-${index}`).addEventListener('click', (e) => { e.stopPropagation(); // Avoid triggering potential row clicks startCompletedTrip(trip); }); }); } else { completedListEl.innerHTML = '

No completed walks yet.

'; } // 2. Fetch Public KMLs currentProfileUserId = userId; currentProfilePage = 1; loadProfilePublicFiles(); } catch (err) { console.error("Error loading profile:", err); document.getElementById('profileDisplayName').textContent = 'Error'; document.getElementById('profilePublicTripsList').innerHTML = '

Failed to load user profile.

'; document.getElementById('profileCompletedTripsList').innerHTML = '

Error loading profile.

'; } } let currentProfileUserId = null; let currentProfilePage = 1; async function loadProfilePublicFiles() { if (!currentProfileUserId) return; try { const params = new URLSearchParams({ public_page: currentProfilePage, public_limit: 10, public_sort_by: 'date', target_user_id: currentProfileUserId }); const response = await fetch(`/api/kml/list?${params.toString()}`); if (!response.ok) throw new Error('Failed to load walks'); const data = await response.json(); // Render Public Files const listEl = document.getElementById('profilePublicTripsList'); if (data.public_files && data.public_files.length > 0) { // We reuse renderKMLFiles logic but targeting a specific list // Note: renderKMLFiles attaches listeners based on listId pattern // kml-file-item needs to be generated. // Let's manually reuse the generator string for simplicity or refactor? // createKMLFileHTML is available. listEl.innerHTML = data.public_files.map((file, index) => createKMLFileHTML(file, false, 'profilePublicTripsList', index) ).join(''); // Attach listeners manually effectively data.public_files.forEach((file, index) => { const uniqueId = `profilePublicTripsList-${index}`; // Open Details const openBtn = document.getElementById(`open-${uniqueId}`); if (openBtn) { openBtn.addEventListener('click', () => { openKMLDetails(file); }); } // Vote buttons // Note: Voting on other people's files is allowed 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)); }); } else { listEl.innerHTML = '

No public walks shared yet.

'; } // Update Pagination document.getElementById('profilePageNum').textContent = data.public_page; document.getElementById('prevProfilePage').disabled = data.public_page <= 1; document.getElementById('nextProfilePage').disabled = (data.public_page * 10) >= data.public_total; } catch (err) { console.error("Error loading profile walks:", err); document.getElementById('profilePublicTripsList').innerHTML = '

Error loading walks.

'; } } function setupUserProfileListeners() { document.getElementById('closeUserProfile').addEventListener('click', () => { document.getElementById('user-profile-overlay').classList.remove('active'); updateURLState('home', null); }); document.getElementById('prevProfilePage').addEventListener('click', () => { if (currentProfilePage > 1) { currentProfilePage--; loadProfilePublicFiles(); } }); document.getElementById('nextProfilePage').addEventListener('click', () => { currentProfilePage++; loadProfilePublicFiles(); }); // Profile Tabs document.querySelectorAll('.profile-tab').forEach(tab => { tab.addEventListener('click', () => { const tabName = tab.dataset.tab; // Update tab buttons document.querySelectorAll('.profile-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); // Update panes document.querySelectorAll('.profile-pane').forEach(p => p.classList.remove('active')); document.getElementById(`${tabName}-pane`).classList.add('active'); }); }); // Close Celebration document.getElementById('closeCelebration').addEventListener('click', () => { document.getElementById('celebration-overlay').classList.remove('active'); }); } // ======================================== // Deep Linking & URL State // ======================================== function updateURLState(type, data) { const url = new URL(window.location); // Clear existing params to keep URL clean, or merge? // Let's clear relevant ones. url.searchParams.delete('profile'); url.searchParams.delete('kml_owner'); url.searchParams.delete('kml_file'); if (type === 'profile') { url.searchParams.set('profile', data.userId); } else if (type === 'kml') { url.searchParams.set('kml_owner', data.ownerId); url.searchParams.set('kml_file', data.filename); } // Push state window.history.pushState({ type, data }, '', url); } async function restoreStateFromURL() { const params = new URLSearchParams(window.location.search); const profileId = params.get('profile'); const kmlOwner = params.get('kml_owner'); const kmlFile = params.get('kml_file'); if (profileId) { openUserProfile(profileId); } else if (kmlOwner && kmlFile) { // We need to fetch the file metadata to open details // Or we can fetch download URL directly? openKMLDetails expects a file object. // Let's construct a minimal file object and try to open details // Note: details overlay might need more info (votes etc), so maybe we should fetch metadata? // We don't have a direct metadata endpoint by filename + owner. // We can search the list? Or add an endpoint. // For now, let's try to find it in the public/my list logic? No that's inefficient. // Let's just create a dummy object with enough info to fetch the KML content // Details overlay needs: filename, user_id, display_name, distance, votes, description. // We can fetch details via download? No, download is just the KML XML. // Strategy: Open details with skeletons, then (optional) fetch fresh metadata if we had an endpoint. // Since we don't have a single-KML-metadata endpoint, we might miss some stats like votes count. // But we can enable the functionality to view the Route. // Better: Just fetch the KML download, parse it, and show it. // We might show "Loading stats..." for metadata we don't have. // Actually, let's simulate a file object with what we have. const dummyFile = { filename: kmlFile, user_id: kmlOwner, display_name: 'Unknown (Deep Link)', // We don't know the name yet distance: 0, // Will be calculated from KML votes: '?', description: 'Loaded via link.', is_public: true // Assumed }; openKMLDetails(dummyFile); } } window.addEventListener('popstate', (event) => { // Handle back button if (event.state) { const { type, data } = event.state; if (type === 'profile') { openUserProfile(data.userId); } else if (type === 'kml') { // For KML deep links via back button // logic similar to restore restoreStateFromURL(); } else { // Home/Default document.querySelectorAll('.overlay').forEach(el => el.classList.remove('active')); } } else { // Initial state or cleared state // Re-check URL? restoreStateFromURL(); } }); 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 SV and Minimap immediately so they are visible behind the shrinking map panoDiv.classList.remove('hidden-mode'); minimapContainer.classList.remove('hidden-mode'); // Trigger resize early to ensure pano is rendered correctly during reveal google.maps.event.trigger(panorama, 'resize'); animateMapToMinimap(() => { mainMapDiv.classList.add('hidden-mode'); // Ensure styles are cleaned up mainMapDiv.style.top = ''; mainMapDiv.style.left = ''; mainMapDiv.style.width = ''; mainMapDiv.style.height = ''; 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; // Optimization: Only set map type if visible or strictly needed to avoid pre-load flash? // If we adjust the order, we can set type AFTER making it visible? No, that causes visual switch. // We set it here. The key is resetting it when LEAVING mode 2 (above). mainMap.setMapTypeId(targetType); if (mainMapDiv.classList.contains('hidden-mode')) { // Switching FROM Street View TO Map // Keep SV visible behind for clean transition animateMapToFullscreen(() => { minimapContainer.classList.add('hidden-mode'); // Ensure map is interactive google.maps.event.trigger(mainMap, "resize"); }); } else { // Just switching Map <-> Satellite (No animation needed) 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) // Minimap might be hidden, so we need to measure where it WOULD be or force it visible for a split second? // It should be visible in Street View mode. const rect = minimapContainer.getBoundingClientRect(); // 2. Set Initial State on Main Map (Match Minimap) mainMapDiv.style.transition = 'none'; 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; // Ensure it's above everything mainMapDiv.style.zIndex = '9999'; mainMapDiv.classList.remove('hidden-mode'); // 3. Force Reflow void mainMapDiv.offsetHeight; // 4. Set Target State (Fullscreen) mainMapDiv.style.transition = ''; // Restore CSS transition mainMapDiv.style.top = '0'; mainMapDiv.style.left = '0'; mainMapDiv.style.width = '100%'; mainMapDiv.style.height = '100%'; mainMapDiv.style.borderRadius = '0'; // 5. Cleanup const handleTransitionEnd = () => { mainMapDiv.removeEventListener('transitionend', handleTransitionEnd); mainMapDiv.style.top = ''; mainMapDiv.style.left = ''; mainMapDiv.style.width = ''; mainMapDiv.style.height = ''; mainMapDiv.style.borderRadius = ''; mainMapDiv.style.zIndex = ''; google.maps.event.trigger(mainMap, "resize"); if (onComplete) onComplete(); }; mainMapDiv.addEventListener('transitionend', handleTransitionEnd); } function animateMapToMinimap(onComplete) { const mainMapDiv = document.getElementById('main-map'); const minimapContainer = document.getElementById('minimap-container'); // Ensure Minimap is logically visible so we can measure it (though visually we might hide it under map) minimapContainer.classList.remove('hidden-mode'); const rect = minimapContainer.getBoundingClientRect(); // 1. Start State: Fullscreen (already is) mainMapDiv.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)'; mainMapDiv.style.zIndex = '9999'; // Stay on top // 2. Target State: Match Minimap position // We need to set these explicitly to trigger transition 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; // 3. Cleanup after animation const handleTransitionEnd = () => { mainMapDiv.removeEventListener('transitionend', handleTransitionEnd); mainMapDiv.style.transition = ''; mainMapDiv.style.zIndex = ''; if (onComplete) onComplete(); }; mainMapDiv.addEventListener('transitionend', handleTransitionEnd); } 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); } // Restore Markers const savedLocation = localStorage.getItem(LOCATION_STORAGE); if (savedLocation) { const locationData = JSON.parse(savedLocation); if (locationData.customMarkers && locationData.customMarkers.length > 0) { renderTripMarkers(locationData.customMarkers); } } } // --- 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, markers = []) { 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 customMarkers: markers, // Save markers routeName: routeName, startLat: startPoint.lat(), startLng: startPoint.lng(), address: routeName, startAddress: routeName, // Compatibility endAddress: routeName }; localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData)); // Reset celebration state window.hasCelebrated = false; currentTripDetails = { type: 'kml', route_name: routeName, kml_filename: locationData.routeName, kml_owner_id: currentUserID, distance: routeTotalDistance / 1000 // In km }; // 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); hideSetupOverlay(); // 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); } // Render Markers renderTripMarkers(markers); // Initial trip meter update updateTripMeter(0); } let tripMarkers = []; // Global array to hold marker instances function renderTripMarkers(markers) { // Clear existing markers tripMarkers.forEach(m => m.setMap(null)); tripMarkers = []; if (!markers || markers.length === 0) return; markers.forEach(m => { const position = new google.maps.LatLng(m.lat, m.lng); const title = m.name || 'Marker'; // 1. Marker for Maps (Main + Minimap) // Note: Google Maps markers can only belong to one map at a time. // We might need duplicate markers if we want them on both maps + panorama? // Actually, for Panorama we use setMap(panorama). // Let's create one marker for the Main Map/Minimap (whichever is visible logic? or just Main Map) // If we want it on Minimap too, we need a second marker. // Marker for Minimap if (minimap) { const mmMarker = new google.maps.Marker({ position: position, map: minimap, title: title, icon: { url: 'http://maps.google.com/mapfiles/ms/icons/blue-dot.png', scaledSize: new google.maps.Size(32, 32) } }); tripMarkers.push(mmMarker); } // Marker for Main Map if (mainMap) { const mMapMarker = new google.maps.Marker({ position: position, map: mainMap, title: title, icon: { url: 'http://maps.google.com/mapfiles/ms/icons/blue-dot.png', scaledSize: new google.maps.Size(32, 32) } }); tripMarkers.push(mMapMarker); } // Marker for Street View // Street View markers need to be added to the panorama if (panorama) { const svMarker = new google.maps.Marker({ position: position, map: panorama, // This puts it in the 3D world title: title, icon: { url: 'http://maps.google.com/mapfiles/ms/icons/yellow-dot.png', // Yellow for SV visibility scaledSize: new google.maps.Size(48, 48) // Slightly larger } // We could add info windows here if needed }); // Add info window for description if (m.description) { const infoWindow = new google.maps.InfoWindow({ content: `
${escapeHtml(title)}
${escapeHtml(m.description)}
` }); svMarker.addListener('click', () => { infoWindow.open(panorama, svMarker); }); } tripMarkers.push(svMarker); } }); } // --- Celebration & Trip Re-entry Helpers --- async function triggerCelebration(finalDistance) { console.log("🎉 Celebration Triggered!"); const overlay = document.getElementById('celebration-overlay'); const routeNameEl = document.getElementById('finishRouteName'); const distanceEl = document.getElementById('finishDistance'); if (currentTripDetails) { routeNameEl.textContent = currentTripDetails.route_name; currentTripDetails.distance = finalDistance / 1000; // Store in km } distanceEl.textContent = `${(finalDistance / 1000).toFixed(2)} km`; overlay.classList.add('active'); createConfetti(); // Report to backend if (currentTripDetails) { try { await fetch('/api/trip/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: currentTripDetails.type, route_name: currentTripDetails.route_name, start_address: currentTripDetails.start_address || '', end_address: currentTripDetails.end_address || '', kml_filename: currentTripDetails.kml_filename || '', kml_owner_id: currentTripDetails.kml_owner_id || '', distance: currentTripDetails.distance }) }); } catch (err) { console.error("Failed to record trip completion:", err); } } } function createConfetti() { const container = document.querySelector('.confetti-container'); if (!container) return; const colors = ['#6366f1', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444']; for (let i = 0; i < 100; i++) { const confetti = document.createElement('div'); confetti.className = 'confetti'; confetti.style.left = Math.random() * 100 + '%'; confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]; confetti.style.width = Math.random() * 8 + 4 + 'px'; confetti.style.height = Math.random() * 8 + 4 + 'px'; confetti.style.opacity = Math.random(); confetti.style.borderRadius = '50%'; const duration = Math.random() * 3 + 2; confetti.style.top = '-10px'; confetti.style.position = 'absolute'; confetti.style.animation = `confetti-fall ${duration}s linear forwards`; confetti.style.animationDelay = Math.random() * 2 + 's'; container.appendChild(confetti); // Remove after animation setTimeout(() => confetti.remove(), (duration + 2) * 1000); } } function startCompletedTrip(trip) { console.log("Reviewing completed trip re-entry:", trip); if (trip.trip_type === 'address') { // Pre-fill setup overlay document.getElementById('startLocationInput').value = trip.start_address; document.getElementById('endLocationInput').value = trip.end_address; // Hide profile, show setup document.getElementById('user-profile-overlay').classList.remove('active'); showSetupOverlay(); // Update URL to home/setup state updateURLState('home', null); } else if (trip.trip_type === 'kml') { // Open KML details with metadata from trip // Note: HandleUserProfile now joins metadata info const file = { filename: trip.kml_filename, user_id: trip.kml_owner_id, display_name: trip.kml_display_name, distance: trip.distance, votes: trip.kml_votes, description: trip.kml_description, is_public: true }; openKMLDetails(file); } }