Files

2451 lines
86 KiB
JavaScript
Raw Permalink Normal View History

2026-01-11 17:16:59 -07:00
// 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;
2026-01-11 21:40:48 -07:00
// Pagination state
let currentMyPage = 1;
let currentPublicPage = 1;
const KML_PAGE_LIMIT = 10;
2026-01-11 17:16:59 -07:00
// Custom Route Logic
2026-01-11 17:16:59 -07:00
let apiKey = null;
let currentUserID = null; // Store current user ID for ownership checks
2026-01-11 17:16:59 -07:00
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}`);
2026-01-11 17:16:59 -07:00
const nameEl = document.getElementById('userName');
const avatarEl = document.getElementById('userAvatar');
const defaultAvatar = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png';
2026-01-14 16:55:49 -07:00
if (nameEl && user.displayName) {
nameEl.textContent = user.displayName;
nameEl.style.cursor = 'pointer';
if (!nameEl.hasListener) {
nameEl.addEventListener('click', () => openUserProfile(currentUserID));
nameEl.hasListener = true;
}
}
2026-01-11 17:16:59 -07:00
if (avatarEl) {
avatarEl.src = user.avatarUrl || defaultAvatar;
2026-01-14 16:55:49 -07:00
avatarEl.style.cursor = 'pointer';
2026-01-11 17:16:59 -07:00
avatarEl.onerror = () => { avatarEl.src = defaultAvatar; };
2026-01-14 16:55:49 -07:00
if (!avatarEl.hasListener) {
avatarEl.addEventListener('click', () => openUserProfile(currentUserID));
avatarEl.hasListener = true;
}
2026-01-11 17:16:59 -07:00
}
}
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();
2026-01-13 14:03:34 -07:00
setupUserProfileListeners();
2026-01-11 17:16:59 -07:00
// 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
2026-01-11 21:40:48 -07:00
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();
});
2026-01-11 17:16:59 -07:00
}
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 {
2026-01-11 21:40:48 -07:00
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()}`);
2026-01-11 17:16:59 -07:00
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);
2026-01-11 21:40:48 -07:00
// 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;
2026-01-11 17:16:59 -07:00
// Render public files
renderKMLFiles('publicFilesList', data.public_files || [], false);
2026-01-11 21:40:48 -07:00
// 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;
2026-01-11 17:16:59 -07:00
} 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 {
2026-01-13 13:10:55 -07:00
// 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);
2026-01-11 17:16:59 -07:00
});
}
// 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>
2026-01-13 13:10:55 -07:00
${!isOwnFiles ? `<span id="user-${uniqueId}" class="clickable-author" title="View Profile" style="cursor: pointer; text-decoration: underline;">👤 ${escapeHtml(file.display_name)}</span>` : ''}
2026-01-11 17:16:59 -07:00
<span>${file.is_public ? '🌍 Public' : '🔒 Private'}</span>
</div>
</div>
<div class="kml-file-actions">
2026-01-14 16:55:49 -07:00
<button id="open-${uniqueId}" class="primary-btn small" title="Start this trip"> Start Trip</button>
2026-01-11 17:16:59 -07:00
${!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);
}
}
// ========================================
// 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
});
2026-01-13 14:03:34 -07:00
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');
2026-01-13 13:10:55 -07:00
// 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');
2026-01-13 13:10:55 -07:00
// 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 };
}
2026-01-11 17:16:59 -07:00
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 || []);
2026-01-11 17:16:59 -07:00
} 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");
}
2026-01-13 14:09:53 -07:00
hideSetupOverlay();
2026-01-11 17:16:59 -07:00
}
}
2026-01-13 14:09:53 -07:00
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');
// Resume animation if a trip is active
if (localStorage.getItem(LOCATION_STORAGE)) {
startCameraAnimation();
}
2026-01-13 14:09:53 -07:00
}
2026-01-11 17:16:59 -07:00
function setupEventListeners() {
// Start button
document.getElementById('startButton').addEventListener('click', async () => {
2026-01-11 17:16:59 -07:00
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
});
2026-01-13 14:03:34 -07:00
console.log('[Manual] Confirmation result:', confirmed);
if (!confirmed) {
console.log('[Manual] Start new trip aborted by user');
return;
}
}
2026-01-11 17:16:59 -07:00
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
2026-01-11 17:16:59 -07:00
});
// 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();
});
}
2026-01-13 14:09:53 -07:00
// Close Setup Button
const closeSetupBtn = document.getElementById('closeSetupButton');
if (closeSetupBtn) {
closeSetupBtn.addEventListener('click', () => {
hideSetupOverlay();
});
}
2026-01-11 17:16:59 -07:00
}
async function calculateAndStartRoute(startAddress, endAddress, isRestoring = false) {
if (!window.google) return;
2026-01-14 16:55:49 -07:00
// Reset celebration state immediately on start attempt
window.hasCelebrated = false;
2026-01-11 17:16:59 -07:00
// 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));
2026-01-14 16:55:49 -07:00
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
};
2026-01-11 17:16:59 -07:00
// Initialize step reference
// Initialize/Reset Server Trip
2026-01-11 17:16:59 -07:00
// Only start a new trip on server if this is a fresh start, not a restore
if (!isRestoring) {
const tripMetadata = {
trip_type: 'address',
route_name: `${leg.start_address} to ${leg.end_address}`,
start_address: leg.start_address,
end_address: leg.end_address,
total_distance: routeTotalDistance / 1000 // In km
};
fetch('/api/trip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripMetadata)
}).catch(console.error);
2026-01-11 17:16:59 -07:00
}
// Reset display steps for visual consistency
displayTripSteps = 0;
stateBuffer = [];
window.lastPositionUpdateDistance = 0;
window.lastPanoUpdate = 0;
2026-01-11 17:16:59 -07:00
startFromLocation(leg.start_location, locationData.startAddress);
2026-01-13 14:09:53 -07:00
hideSetupOverlay();
2026-01-11 17:16:59 -07:00
// 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;
2026-01-14 16:55:49 -07:00
// Reset celebration state
window.hasCelebrated = false;
2026-01-11 17:16:59 -07:00
// 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();
2026-01-13 13:10:55 -07:00
// Check for Deep Links
restoreStateFromURL();
2026-01-11 17:16:59 -07:00
console.log('Street View initialized at:', address);
}
function resetLocation() {
2026-01-13 14:03:34 -07:00
// 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.
2026-01-13 14:09:53 -07:00
showSetupOverlay();
2026-01-11 17:16:59 -07:00
document.getElementById('startLocationInput').value = '';
document.getElementById('endLocationInput').value = '';
// stopAnimation(); // Don't stop animation just because we opened the setup overlay
2026-01-11 17:16:59 -07:00
}
// 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.
2026-01-14 16:55:49 -07:00
const INTERPOLATION_DELAY = 6000;
// State variables
let currentTripDetails = null;
window.hasCelebrated = false;
let stateBuffer = [];
// Array of {t: timestamp, s: steps}
2026-01-11 17:16:59 -07:00
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();
// Server-Side Trip Synchronization
if (data.activeTrip) {
// A trip is active on the server
// Check if we need to sync/resume this trip locally
const localLocation = localStorage.getItem(LOCATION_STORAGE);
let needsResync = false;
if (!localLocation) {
needsResync = true;
console.log("[Sync] No local trip found. Syncing from server.");
} else {
// We have a local trip, check if it's the SAME trip
try {
const localData = JSON.parse(localLocation);
if (localData.routeName !== data.activeTrip.route_name) {
needsResync = true;
console.log("[Sync] Local trip route mismatch. Syncing from server.");
}
} catch (e) {
needsResync = true;
}
}
if (needsResync) {
syncActiveTrip(data.activeTrip);
}
// Update target steps from server
if (data.tripSteps !== undefined) {
// Safety: Only accept step updates if they are for the current trip
if (needsResync) {
stateBuffer = [];
displayTripSteps = 0;
}
stateBuffer.push({ t: now, s: data.tripSteps });
2026-01-11 17:16:59 -07:00
// Keep buffer size manageable (20 points is ~1.5 mins)
if (stateBuffer.length > 20) {
stateBuffer.shift();
}
}
} else {
// No trip active on the server
if (localStorage.getItem(LOCATION_STORAGE)) {
console.log("[Sync] No trip on server. Clearing local trip.");
localStorage.removeItem(LOCATION_STORAGE);
if (!document.getElementById('celebration-overlay').classList.contains('active')) {
document.getElementById('routeInfo').textContent = 'Not Started';
document.getElementById('currentSteps').textContent = '0';
document.getElementById('tripMeter').textContent = '0.0 / 0.0 km';
masterPath = [];
routeTotalDistance = 0;
displayTripSteps = 0;
stateBuffer = [];
}
2026-01-11 17:16:59 -07:00
}
}
// 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);
}
}
async function syncActiveTrip(activeTrip) {
console.log("[Sync] Restoring trip from server:", activeTrip.route_name);
if (activeTrip.trip_type === 'address') {
calculateAndStartRoute(activeTrip.start_address, activeTrip.end_address, true);
} else if (activeTrip.trip_type === 'kml') {
try {
const response = await fetch(`/api/kml/download?owner_id=${activeTrip.kml_owner_id || currentUserID}&filename=${encodeURIComponent(activeTrip.route_name)}`);
if (response.ok) {
const kmlContent = await response.text();
processKML(kmlContent, activeTrip.route_name);
}
} catch (err) {
console.error("[Sync] Failed to download KML for sync:", err);
}
}
}
2026-01-11 17:16:59 -07:00
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
});
}
2026-01-14 16:55:49 -07:00
// --- TRIP COMPLETION CHECK ---
if (masterPath.length > 0 && routeTotalDistance > 0 && !window.hasCelebrated) {
if (distanceTraveled >= routeTotalDistance) {
window.hasCelebrated = true;
triggerCelebration(distanceTraveled);
}
}
2026-01-11 17:16:59 -07:00
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);
}
2026-01-13 13:19:05 -07:00
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();
}
2026-01-13 13:10:55 -07:00
// ========================================
// 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 = '<p class="empty-message">Loading...</p>';
overlay.classList.add('active');
// Update URL
updateURLState('profile', { userId: userId });
try {
2026-01-14 16:55:49 -07:00
// 1. Fetch User Info & Completed Trips
2026-01-13 13:10:55 -07:00
const userResp = await fetch(`/api/user/profile?user_id=${userId}`);
if (!userResp.ok) throw new Error('User not found');
2026-01-14 16:55:49 -07:00
const profileData = await userResp.json();
const user = profileData.user;
2026-01-13 13:10:55 -07:00
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';
2026-01-14 16:55:49 -07:00
// 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) => `
<div class="kml-file-item">
<div class="kml-file-info">
<div class="kml-file-name">${escapeHtml(trip.route_name)}</div>
<div class="kml-file-meta">
<span>📏 ${trip.distance.toFixed(2)} km</span>
<span class="trip-date">📅 ${new Date(trip.completed_at).toLocaleDateString()}</span>
</div>
</div>
<div class="kml-file-actions">
<button id="start-completed-${index}" class="primary-btn small-btn"> Start Trip</button>
</div>
</div>
`).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 = '<p class="empty-message">No completed walks yet.</p>';
}
2026-01-13 13:10:55 -07:00
// 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 = '<p class="empty-message">Failed to load user profile.</p>';
2026-01-14 16:55:49 -07:00
document.getElementById('profileCompletedTripsList').innerHTML = '<p class="empty-message">Error loading profile.</p>';
2026-01-13 13:10:55 -07:00
}
}
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 = '<p class="empty-message">No public walks shared yet.</p>';
}
// 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 = '<p class="empty-message">Error loading walks.</p>';
2026-01-11 17:16:59 -07:00
}
2026-01-13 13:10:55 -07:00
}
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();
});
2026-01-14 16:55:49 -07:00
// 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');
});
2026-01-13 13:10:55 -07:00
}
// ========================================
// 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);
2026-01-11 17:16:59 -07:00
}
2026-01-13 13:10:55 -07:00
// Push state
window.history.pushState({ type, data }, '', url);
2026-01-11 17:16:59 -07:00
}
2026-01-13 13:10:55 -07:00
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);
2026-01-11 17:16:59 -07:00
}
}
2026-01-13 13:10:55 -07:00
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();
}
});
2026-01-11 17:16:59 -07:00
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');
2026-01-11 17:16:59 -07:00
// Trigger resize early to ensure pano is rendered correctly during reveal
google.maps.event.trigger(panorama, 'resize');
2026-01-11 17:16:59 -07:00
animateMapToMinimap(() => {
2026-01-11 17:16:59 -07:00
mainMapDiv.classList.add('hidden-mode');
// Ensure styles are cleaned up
2026-01-13 13:10:55 -07:00
mainMapDiv.style.top = '';
mainMapDiv.style.left = '';
mainMapDiv.style.width = '';
mainMapDiv.style.height = '';
2026-01-11 17:16:59 -07:00
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;
2026-01-13 13:10:55 -07:00
// 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).
2026-01-11 17:16:59 -07:00
mainMap.setMapTypeId(targetType);
if (mainMapDiv.classList.contains('hidden-mode')) {
// Switching FROM Street View TO Map
// Keep SV visible behind for clean transition
2026-01-11 17:16:59 -07:00
animateMapToFullscreen(() => {
minimapContainer.classList.add('hidden-mode');
2026-01-13 13:10:55 -07:00
// Ensure map is interactive
google.maps.event.trigger(mainMap, "resize");
2026-01-11 17:16:59 -07:00
});
} else {
2026-01-13 13:10:55 -07:00
// Just switching Map <-> Satellite (No animation needed)
2026-01-11 17:16:59 -07:00
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)
2026-01-13 13:10:55 -07:00
// 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.
2026-01-11 17:16:59 -07:00
const rect = minimapContainer.getBoundingClientRect();
// 2. Set Initial State on Main Map (Match Minimap)
2026-01-13 13:10:55 -07:00
mainMapDiv.style.transition = 'none';
2026-01-11 17:16:59 -07:00
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;
2026-01-13 13:10:55 -07:00
// Ensure it's above everything
mainMapDiv.style.zIndex = '9999';
2026-01-11 17:16:59 -07:00
mainMapDiv.classList.remove('hidden-mode');
// 3. Force Reflow
void mainMapDiv.offsetHeight;
// 4. Set Target State (Fullscreen)
2026-01-13 13:10:55 -07:00
mainMapDiv.style.transition = ''; // Restore CSS transition
2026-01-11 17:16:59 -07:00
mainMapDiv.style.top = '0';
mainMapDiv.style.left = '0';
mainMapDiv.style.width = '100%';
mainMapDiv.style.height = '100%';
mainMapDiv.style.borderRadius = '0';
2026-01-13 13:10:55 -07:00
// 5. Cleanup
2026-01-11 17:16:59 -07:00
const handleTransitionEnd = () => {
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
mainMapDiv.style.top = '';
mainMapDiv.style.left = '';
mainMapDiv.style.width = '';
mainMapDiv.style.height = '';
mainMapDiv.style.borderRadius = '';
2026-01-13 13:10:55 -07:00
mainMapDiv.style.zIndex = '';
2026-01-11 17:16:59 -07:00
google.maps.event.trigger(mainMap, "resize");
if (onComplete) onComplete();
};
mainMapDiv.addEventListener('transitionend', handleTransitionEnd);
}
2026-01-13 13:10:55 -07:00
function animateMapToMinimap(onComplete) {
const mainMapDiv = document.getElementById('main-map');
const minimapContainer = document.getElementById('minimap-container');
2026-01-11 17:16:59 -07:00
2026-01-13 13:10:55 -07:00
// 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();
2026-01-11 17:16:59 -07:00
2026-01-13 13:10:55 -07:00
// 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);
2026-01-11 17:16:59 -07:00
}
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);
}
}
2026-01-11 17:16:59 -07:00
}
// --- 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 = []) {
2026-01-11 17:16:59 -07:00
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
2026-01-11 17:16:59 -07:00
routeName: routeName,
startLat: startPoint.lat(),
startLng: startPoint.lng(),
address: routeName,
startAddress: routeName, // Compatibility
endAddress: routeName
};
localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData));
2026-01-14 16:55:49 -07:00
// 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
};
2026-01-11 17:16:59 -07:00
// Initialize/Reset Server Trip
if (!isRestoring) {
const tripMetadata = {
trip_type: 'kml',
route_name: routeName,
kml_id: currentKmlFile ? currentKmlFile.id : null,
total_distance: routeTotalDistance / 1000 // In km
};
fetch('/api/trip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripMetadata)
}).catch(console.error);
2026-01-11 17:16:59 -07:00
}
// Reset display steps
displayTripSteps = 0;
stateBuffer = [];
window.lastPositionUpdateDistance = 0;
window.lastPanoUpdate = 0;
2026-01-11 17:16:59 -07:00
// Initialize Map View
startFromLocation(startPoint, routeName);
2026-01-13 14:09:53 -07:00
hideSetupOverlay();
2026-01-11 17:16:59 -07:00
// 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);
2026-01-11 17:16:59 -07:00
// 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: `<div><strong>${escapeHtml(title)}</strong><br>${escapeHtml(m.description)}</div>`
});
svMarker.addListener('click', () => {
infoWindow.open(panorama, svMarker);
});
}
tripMarkers.push(svMarker);
}
});
}
2026-01-14 16:55:49 -07:00
// --- 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();
// Clean up local state
localStorage.removeItem(LOCATION_STORAGE);
currentTripDetails = null;
masterPath = [];
routeTotalDistance = 0;
displayTripSteps = 0;
stateBuffer = [];
// Update UI to reflect reset
document.getElementById('routeInfo').textContent = 'Not Started';
document.getElementById('currentSteps').textContent = '0';
document.getElementById('tripMeter').textContent = '0.0 / 0.0 km';
// We keep window.hasCelebrated = true to prevent immediate re-trigger
// but starting a new trip will reset it.
2026-01-14 16:55:49 -07:00
}
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);
}
}