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
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
// Custom Route Logic
|
2026-01-11 17:16:59 -07:00
|
|
|
|
let apiKey = null;
|
2026-01-11 22:48:50 -07:00
|
|
|
|
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;
|
2026-01-11 22:48:50 -07:00
|
|
|
|
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();
|
2026-01-11 22:48:50 -07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
|
|
|
|
|
|
// ========================================
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-01-13 13:47:56 -07:00
|
|
|
|
document.getElementById('startKmlTripBtn').addEventListener('click', async () => {
|
2026-01-11 22:48:50 -07:00
|
|
|
|
if (!currentKmlFile || currentKmlPath.length === 0) return;
|
|
|
|
|
|
|
2026-01-13 13:47:56 -07:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-01-13 13:47:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-01-11 22:48:50 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
// 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
|
2026-01-11 22:48:50 -07:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 17:16:59 -07:00
|
|
|
|
function setupEventListeners() {
|
|
|
|
|
|
// Start button
|
2026-01-13 13:47:56 -07:00
|
|
|
|
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) {
|
2026-01-13 13:47:56 -07:00
|
|
|
|
// 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-13 13:47:56 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-01-13 13:47:56 -07:00
|
|
|
|
// 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
|
|
|
|
|
|
// 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);
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-01-13 22:17:12 -07:00
|
|
|
|
// 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
|
|
|
|
|
2026-01-13 22:17:12 -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
|
|
|
|
|
2026-01-13 22:17:12 -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
|
2026-01-13 22:17:12 -07:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
2026-01-11 22:48:50 -07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-11 22:48:50 -07:00
|
|
|
|
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) {
|
|
|
|
|
|
fetch('/api/trip', { method: 'POST' }).catch(console.error);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reset display steps
|
|
|
|
|
|
displayTripSteps = 0;
|
|
|
|
|
|
stateBuffer = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 22:48:50 -07:00
|
|
|
|
// Render Markers
|
|
|
|
|
|
renderTripMarkers(markers);
|
|
|
|
|
|
|
2026-01-11 17:16:59 -07:00
|
|
|
|
// Initial trip meter update
|
|
|
|
|
|
updateTripMeter(0);
|
|
|
|
|
|
}
|
2026-01-11 22:48:50 -07:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
|
|
// Report to backend
|
|
|
|
|
|
if (currentTripDetails) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch('/api/trip/complete', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
type: currentTripDetails.type,
|
|
|
|
|
|
route_name: currentTripDetails.route_name,
|
|
|
|
|
|
start_address: currentTripDetails.start_address || '',
|
|
|
|
|
|
end_address: currentTripDetails.end_address || '',
|
|
|
|
|
|
kml_filename: currentTripDetails.kml_filename || '',
|
|
|
|
|
|
kml_owner_id: currentTripDetails.kml_owner_id || '',
|
|
|
|
|
|
distance: currentTripDetails.distance
|
|
|
|
|
|
})
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("Failed to record trip completion:", err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createConfetti() {
|
|
|
|
|
|
const container = document.querySelector('.confetti-container');
|
|
|
|
|
|
if (!container) return;
|
|
|
|
|
|
const colors = ['#6366f1', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444'];
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
|
const confetti = document.createElement('div');
|
|
|
|
|
|
confetti.className = 'confetti';
|
|
|
|
|
|
confetti.style.left = Math.random() * 100 + '%';
|
|
|
|
|
|
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
|
|
|
|
|
|
confetti.style.width = Math.random() * 8 + 4 + 'px';
|
|
|
|
|
|
confetti.style.height = Math.random() * 8 + 4 + 'px';
|
|
|
|
|
|
confetti.style.opacity = Math.random();
|
|
|
|
|
|
confetti.style.borderRadius = '50%';
|
|
|
|
|
|
|
|
|
|
|
|
const duration = Math.random() * 3 + 2;
|
|
|
|
|
|
confetti.style.top = '-10px';
|
|
|
|
|
|
confetti.style.position = 'absolute';
|
|
|
|
|
|
confetti.style.animation = `confetti-fall ${duration}s linear forwards`;
|
|
|
|
|
|
confetti.style.animationDelay = Math.random() * 2 + 's';
|
|
|
|
|
|
|
|
|
|
|
|
container.appendChild(confetti);
|
|
|
|
|
|
|
|
|
|
|
|
// Remove after animation
|
|
|
|
|
|
setTimeout(() => confetti.remove(), (duration + 2) * 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startCompletedTrip(trip) {
|
|
|
|
|
|
console.log("Reviewing completed trip re-entry:", trip);
|
|
|
|
|
|
|
|
|
|
|
|
if (trip.trip_type === 'address') {
|
|
|
|
|
|
// Pre-fill setup overlay
|
|
|
|
|
|
document.getElementById('startLocationInput').value = trip.start_address;
|
|
|
|
|
|
document.getElementById('endLocationInput').value = trip.end_address;
|
|
|
|
|
|
|
|
|
|
|
|
// Hide profile, show setup
|
|
|
|
|
|
document.getElementById('user-profile-overlay').classList.remove('active');
|
|
|
|
|
|
showSetupOverlay();
|
|
|
|
|
|
|
|
|
|
|
|
// Update URL to home/setup state
|
|
|
|
|
|
updateURLState('home', null);
|
|
|
|
|
|
|
|
|
|
|
|
} else if (trip.trip_type === 'kml') {
|
|
|
|
|
|
// Open KML details with metadata from trip
|
|
|
|
|
|
// Note: HandleUserProfile now joins metadata info
|
|
|
|
|
|
const file = {
|
|
|
|
|
|
filename: trip.kml_filename,
|
|
|
|
|
|
user_id: trip.kml_owner_id,
|
|
|
|
|
|
display_name: trip.kml_display_name,
|
|
|
|
|
|
distance: trip.distance,
|
|
|
|
|
|
votes: trip.kml_votes,
|
|
|
|
|
|
description: trip.kml_description,
|
|
|
|
|
|
is_public: true
|
|
|
|
|
|
};
|
|
|
|
|
|
openKMLDetails(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|