Files
pedestrian-simulator/frontend/app.js
Steven Polley eaea2c4edb
All checks were successful
pedestrian-simulator / build (push) Successful in 1m2s
move sorting to the database
2026-01-11 21:40:48 -07:00

1575 lines
52 KiB
JavaScript

// 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 `
<div class="kml-file-item">
<div class="kml-file-info">
<div class="kml-file-name">${escapeHtml(file.filename)}</div>
<div class="kml-file-meta">
<span>📏 ${file.distance.toFixed(2)} km</span>
${!isOwnFiles ? `<span>👤 ${escapeHtml(file.display_name)}</span>` : ''}
<span>${file.is_public ? '🌍 Public' : '🔒 Private'}</span>
</div>
</div>
<div class="kml-file-actions">
<button id="use-${uniqueId}" class="action-btn" title="Use This Route">▶️ Use</button>
${!isOwnFiles ? `
<button id="upvote-${uniqueId}" class="vote-btn" title="Upvote">
👍 <span class="vote-count">${file.votes}</span>
</button>
<button id="downvote-${uniqueId}" class="vote-btn" title="Downvote">👎</button>
` : `
<button id="privacy-${uniqueId}" class="action-btn">
${file.is_public ? '🔒 Make Private' : '🌍 Make Public'}
</button>
<button id="delete-${uniqueId}" class="action-btn danger">🗑️ Delete</button>
`}
</div>
</div>
`;
}
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<boolean>}
*/
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);
}