';
}
}
function setupUserProfileListeners() {
document.getElementById('closeUserProfile').addEventListener('click', () => {
document.getElementById('user-profile-overlay').classList.remove('active');
updateURLState('home', null);
});
document.getElementById('prevProfilePage').addEventListener('click', () => {
if (currentProfilePage > 1) {
currentProfilePage--;
loadProfilePublicFiles();
}
});
document.getElementById('nextProfilePage').addEventListener('click', () => {
currentProfilePage++;
loadProfilePublicFiles();
});
// Profile Tabs
document.querySelectorAll('.profile-tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
// Update tab buttons
document.querySelectorAll('.profile-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Update panes
document.querySelectorAll('.profile-pane').forEach(p => p.classList.remove('active'));
document.getElementById(`${tabName}-pane`).classList.add('active');
});
});
// Close Celebration
document.getElementById('closeCelebration').addEventListener('click', () => {
document.getElementById('celebration-overlay').classList.remove('active');
});
}
// ========================================
// Deep Linking & URL State
// ========================================
function updateURLState(type, data) {
const url = new URL(window.location);
// Clear existing params to keep URL clean, or merge?
// Let's clear relevant ones.
url.searchParams.delete('profile');
url.searchParams.delete('kml_owner');
url.searchParams.delete('kml_file');
if (type === 'profile') {
url.searchParams.set('profile', data.userId);
} else if (type === 'kml') {
url.searchParams.set('kml_owner', data.ownerId);
url.searchParams.set('kml_file', data.filename);
}
// Push state
window.history.pushState({ type, data }, '', url);
}
async function restoreStateFromURL() {
const params = new URLSearchParams(window.location.search);
const profileId = params.get('profile');
const kmlOwner = params.get('kml_owner');
const kmlFile = params.get('kml_file');
if (profileId) {
openUserProfile(profileId);
} else if (kmlOwner && kmlFile) {
// We need to fetch the file metadata to open details
// Or we can fetch download URL directly? openKMLDetails expects a file object.
// Let's construct a minimal file object and try to open details
// Note: details overlay might need more info (votes etc), so maybe we should fetch metadata?
// We don't have a direct metadata endpoint by filename + owner.
// We can search the list? Or add an endpoint.
// For now, let's try to find it in the public/my list logic? No that's inefficient.
// Let's just create a dummy object with enough info to fetch the KML content
// Details overlay needs: filename, user_id, display_name, distance, votes, description.
// We can fetch details via download? No, download is just the KML XML.
// Strategy: Open details with skeletons, then (optional) fetch fresh metadata if we had an endpoint.
// Since we don't have a single-KML-metadata endpoint, we might miss some stats like votes count.
// But we can enable the functionality to view the Route.
// Better: Just fetch the KML download, parse it, and show it.
// We might show "Loading stats..." for metadata we don't have.
// Actually, let's simulate a file object with what we have.
const dummyFile = {
filename: kmlFile,
user_id: kmlOwner,
display_name: 'Unknown (Deep Link)', // We don't know the name yet
distance: 0, // Will be calculated from KML
votes: '?',
description: 'Loaded via link.',
is_public: true // Assumed
};
openKMLDetails(dummyFile);
}
}
window.addEventListener('popstate', (event) => {
// Handle back button
if (event.state) {
const { type, data } = event.state;
if (type === 'profile') {
openUserProfile(data.userId);
} else if (type === 'kml') {
// For KML deep links via back button
// logic similar to restore
restoreStateFromURL();
} else {
// Home/Default
document.querySelectorAll('.overlay').forEach(el => el.classList.remove('active'));
}
} else {
// Initial state or cleared state
// Re-check URL?
restoreStateFromURL();
}
});
function updateViewMode() {
const panoDiv = document.getElementById('panorama');
const mainMapDiv = document.getElementById('main-map');
const minimapContainer = document.getElementById('minimap-container');
// State machine for transitions
if (viewMode === 0) {
// Switching TO Street View (from Satellite/Map)
// Transition: Animate Main Map shrinking into Minimap
// Show SV and Minimap immediately so they are visible behind the shrinking map
panoDiv.classList.remove('hidden-mode');
minimapContainer.classList.remove('hidden-mode');
// Trigger resize early to ensure pano is rendered correctly during reveal
google.maps.event.trigger(panorama, 'resize');
animateMapToMinimap(() => {
mainMapDiv.classList.add('hidden-mode');
// Ensure styles are cleaned up
mainMapDiv.style.top = '';
mainMapDiv.style.left = '';
mainMapDiv.style.width = '';
mainMapDiv.style.height = '';
mainMapDiv.style.borderRadius = '';
// Fix Satellite Flash: Reset to roadmap so next time we open map it starts clean
if (mainMap) {
mainMap.setMapTypeId(google.maps.MapTypeId.ROADMAP);
}
});
} else {
// Switching TO Map or Satellite
// Initialize if first run
if (!mainMap) {
initializeMainMap();
// Force layout
google.maps.event.trigger(mainMap, "resize");
}
// Set Map Type
const targetType = (viewMode === 1) ? google.maps.MapTypeId.ROADMAP : google.maps.MapTypeId.HYBRID;
// Optimization: Only set map type if visible or strictly needed to avoid pre-load flash?
// If we adjust the order, we can set type AFTER making it visible? No, that causes visual switch.
// We set it here. The key is resetting it when LEAVING mode 2 (above).
mainMap.setMapTypeId(targetType);
if (mainMapDiv.classList.contains('hidden-mode')) {
// Switching FROM Street View TO Map
// Keep SV visible behind for clean transition
animateMapToFullscreen(() => {
minimapContainer.classList.add('hidden-mode');
// Ensure map is interactive
google.maps.event.trigger(mainMap, "resize");
});
} else {
// Just switching Map <-> Satellite (No animation needed)
mainMapDiv.classList.remove('hidden-mode');
minimapContainer.classList.add('hidden-mode');
}
// Sync Center
if (panorama) {
const currentPos = panorama.getPosition();
if (currentPos) {
mainMap.setCenter(currentPos);
if (mainMapMarker) mainMapMarker.setPosition(currentPos);
}
}
}
}
function animateMapToFullscreen(onComplete) {
const mainMapDiv = document.getElementById('main-map');
const minimapContainer = document.getElementById('minimap-container');
// 1. Measure Start State (Minimap)
// Minimap might be hidden, so we need to measure where it WOULD be or force it visible for a split second?
// It should be visible in Street View mode.
const rect = minimapContainer.getBoundingClientRect();
// 2. Set Initial State on Main Map (Match Minimap)
mainMapDiv.style.transition = 'none';
mainMapDiv.style.top = rect.top + 'px';
mainMapDiv.style.left = rect.left + 'px';
mainMapDiv.style.width = rect.width + 'px';
mainMapDiv.style.height = rect.height + 'px';
mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius;
// Ensure it's above everything
mainMapDiv.style.zIndex = '9999';
mainMapDiv.classList.remove('hidden-mode');
// 3. Force Reflow
void mainMapDiv.offsetHeight;
// 4. Set Target State (Fullscreen)
mainMapDiv.style.transition = ''; // Restore CSS transition
mainMapDiv.style.top = '0';
mainMapDiv.style.left = '0';
mainMapDiv.style.width = '100%';
mainMapDiv.style.height = '100%';
mainMapDiv.style.borderRadius = '0';
// 5. Cleanup
const handleTransitionEnd = () => {
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
mainMapDiv.style.top = '';
mainMapDiv.style.left = '';
mainMapDiv.style.width = '';
mainMapDiv.style.height = '';
mainMapDiv.style.borderRadius = '';
mainMapDiv.style.zIndex = '';
google.maps.event.trigger(mainMap, "resize");
if (onComplete) onComplete();
};
mainMapDiv.addEventListener('transitionend', handleTransitionEnd);
}
function animateMapToMinimap(onComplete) {
const mainMapDiv = document.getElementById('main-map');
const minimapContainer = document.getElementById('minimap-container');
// Ensure Minimap is logically visible so we can measure it (though visually we might hide it under map)
minimapContainer.classList.remove('hidden-mode');
const rect = minimapContainer.getBoundingClientRect();
// 1. Start State: Fullscreen (already is)
mainMapDiv.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)';
mainMapDiv.style.zIndex = '9999'; // Stay on top
// 2. Target State: Match Minimap position
// We need to set these explicitly to trigger transition
mainMapDiv.style.top = rect.top + 'px';
mainMapDiv.style.left = rect.left + 'px';
mainMapDiv.style.width = rect.width + 'px';
mainMapDiv.style.height = rect.height + 'px';
mainMapDiv.style.borderRadius = getComputedStyle(minimapContainer).borderRadius;
// 3. Cleanup after animation
const handleTransitionEnd = () => {
mainMapDiv.removeEventListener('transitionend', handleTransitionEnd);
mainMapDiv.style.transition = '';
mainMapDiv.style.zIndex = '';
if (onComplete) onComplete();
};
mainMapDiv.addEventListener('transitionend', handleTransitionEnd);
}
function initializeMainMap() {
mainMap = new google.maps.Map(document.getElementById('main-map'), {
center: { lat: 0, lng: 0 },
zoom: 14,
disableDefaultUI: false,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false
});
// Sync marker
mainMapMarker = new google.maps.Marker({
position: { lat: 0, lng: 0 },
map: mainMap,
icon: {
path: google.maps.SymbolPath.CIRCLE,
scale: 7,
fillColor: '#6366f1',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 2,
},
});
if (panorama) {
const pos = panorama.getPosition();
if (pos) {
mainMap.setCenter(pos);
mainMapMarker.setPosition(pos);
}
}
// Add polyline if path exists
if (masterPath && masterPath.length > 0) {
mainMapPolyline = new google.maps.Polyline({
path: masterPath,
geodesic: true,
strokeColor: '#6366f1',
strokeOpacity: 0.8,
strokeWeight: 5
});
mainMapPolyline.setMap(mainMap);
}
// Restore Markers
const savedLocation = localStorage.getItem(LOCATION_STORAGE);
if (savedLocation) {
const locationData = JSON.parse(savedLocation);
if (locationData.customMarkers && locationData.customMarkers.length > 0) {
renderTripMarkers(locationData.customMarkers);
}
}
}
// --- Custom Route Logic ---
function processKML(kmlContent, fileName) {
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(kmlContent, "text/xml");
const placemarks = xmlDoc.getElementsByTagName("Placemark");
let fullPath = [];
// Find all LineStrings
for (let i = 0; i < placemarks.length; i++) {
const lineString = placemarks[i].getElementsByTagName("LineString")[0];
if (lineString) {
const coordsText = lineString.getElementsByTagName("coordinates")[0].textContent.trim();
const points = parseCoordinates(coordsText);
fullPath = fullPath.concat(points);
}
}
// Fallback: Check for top-level LineString/LinearRing not in Placemark (rare but possible in fragments)
if (fullPath.length === 0) {
const lineStrings = xmlDoc.getElementsByTagName("LineString");
for (let i = 0; i < lineStrings.length; i++) {
const coordsText = lineStrings[i].getElementsByTagName("coordinates")[0].textContent.trim();
const points = parseCoordinates(coordsText);
fullPath = fullPath.concat(points);
}
}
if (fullPath.length < 2) {
alert("No route found in KML file. Make sure it contains a LineString/Path.");
return;
}
startFromCustomRoute(fullPath, fileName);
} catch (e) {
console.error("Error parsing KML:", e);
alert("Failed to parse KML file. Please ensure it is a valid Google Maps export.");
}
}
function parseCoordinates(text) {
// KML format: lon,lat,alt (space separated tuples)
const rawPoints = text.split(/\s+/);
const path = [];
rawPoints.forEach(pt => {
const parts = pt.split(',');
if (parts.length >= 2) {
// KML is Longitude, Latitude
const lng = parseFloat(parts[0]);
const lat = parseFloat(parts[1]);
if (!isNaN(lat) && !isNaN(lng)) {
path.push({ lat: lat, lng: lng });
}
}
});
return path;
}
function densifyPath(path, maxSegmentLength = 20) {
if (!path || path.length < 2) return path;
const densified = [path[0]];
for (let i = 0; i < path.length - 1; i++) {
const p1 = path[i];
const p2 = path[i + 1];
// We use simple linear interpolation here as we are on a small scale
// For more accuracy we could use spherical interpolation but at 20m linear is fine.
const d = google.maps.geometry.spherical.computeDistanceBetween(
new google.maps.LatLng(p1.lat, p1.lng),
new google.maps.LatLng(p2.lat, p2.lng)
);
if (d > maxSegmentLength) {
const numPoints = Math.ceil(d / maxSegmentLength);
for (let j = 1; j < numPoints; j++) {
const ratio = j / numPoints;
densified.push({
lat: p1.lat + (p2.lat - p1.lat) * ratio,
lng: p1.lng + (p2.lng - p1.lng) * ratio
});
}
}
densified.push(p2);
}
return densified;
}
function startFromCustomRoute(pathData, routeName, isRestoring = false, markers = []) {
if (!window.google) return;
// Convert to Google Maps LatLng objects
const densePath = densifyPath(pathData);
masterPath = densePath.map(pt => new google.maps.LatLng(pt.lat, pt.lng));
// Calculate total distance
routeTotalDistance = 0;
for (let i = 0; i < masterPath.length - 1; i++) {
routeTotalDistance += google.maps.geometry.spherical.computeDistanceBetween(masterPath[i], masterPath[i + 1]);
}
const startPoint = masterPath[0];
// Save state
const locationData = {
isCustom: true,
customPath: pathData, // Save raw objects {lat, lng} for serialization
customMarkers: markers, // Save markers
routeName: routeName,
startLat: startPoint.lat(),
startLng: startPoint.lng(),
address: routeName,
startAddress: routeName, // Compatibility
endAddress: routeName
};
localStorage.setItem(LOCATION_STORAGE, JSON.stringify(locationData));
// Reset celebration state
window.hasCelebrated = false;
currentTripDetails = {
type: 'kml',
route_name: routeName,
kml_filename: locationData.routeName,
kml_owner_id: currentUserID,
distance: routeTotalDistance / 1000 // In km
};
// Initialize/Reset Server Trip
if (!isRestoring) {
fetch('/api/trip', { method: 'POST' }).catch(console.error);
}
// Reset display steps
displayTripSteps = 0;
stateBuffer = [];
// Initialize Map View
startFromLocation(startPoint, routeName);
hideSetupOverlay();
// Update UI
document.getElementById('routeInfo').textContent = `${routeName}`;
// Draw on Minimap
if (minimap) {
if (minimapPolyline) minimapPolyline.setMap(null);
minimapPolyline = new google.maps.Polyline({
path: masterPath,
geodesic: true,
strokeColor: '#6366f1',
strokeOpacity: 1.0,
strokeWeight: 4
});
minimapPolyline.setMap(minimap);
// Fit bounds
const bounds = new google.maps.LatLngBounds();
masterPath.forEach(pt => bounds.extend(pt));
minimap.fitBounds(bounds);
}
// Draw on Main Map if initialized
if (mainMap) {
if (mainMapPolyline) mainMapPolyline.setMap(null);
mainMapPolyline = new google.maps.Polyline({
path: masterPath,
geodesic: true,
strokeColor: '#6366f1',
strokeOpacity: 0.8,
strokeWeight: 5
});
mainMapPolyline.setMap(mainMap);
}
// Render Markers
renderTripMarkers(markers);
// Initial trip meter update
updateTripMeter(0);
}
let tripMarkers = []; // Global array to hold marker instances
function renderTripMarkers(markers) {
// Clear existing markers
tripMarkers.forEach(m => m.setMap(null));
tripMarkers = [];
if (!markers || markers.length === 0) return;
markers.forEach(m => {
const position = new google.maps.LatLng(m.lat, m.lng);
const title = m.name || 'Marker';
// 1. Marker for Maps (Main + Minimap)
// Note: Google Maps markers can only belong to one map at a time.
// We might need duplicate markers if we want them on both maps + panorama?
// Actually, for Panorama we use setMap(panorama).
// Let's create one marker for the Main Map/Minimap (whichever is visible logic? or just Main Map)
// If we want it on Minimap too, we need a second marker.
// Marker for Minimap
if (minimap) {
const mmMarker = new google.maps.Marker({
position: position,
map: minimap,
title: title,
icon: {
url: 'http://maps.google.com/mapfiles/ms/icons/blue-dot.png',
scaledSize: new google.maps.Size(32, 32)
}
});
tripMarkers.push(mmMarker);
}
// Marker for Main Map
if (mainMap) {
const mMapMarker = new google.maps.Marker({
position: position,
map: mainMap,
title: title,
icon: {
url: 'http://maps.google.com/mapfiles/ms/icons/blue-dot.png',
scaledSize: new google.maps.Size(32, 32)
}
});
tripMarkers.push(mMapMarker);
}
// Marker for Street View
// Street View markers need to be added to the panorama
if (panorama) {
const svMarker = new google.maps.Marker({
position: position,
map: panorama, // This puts it in the 3D world
title: title,
icon: {
url: 'http://maps.google.com/mapfiles/ms/icons/yellow-dot.png', // Yellow for SV visibility
scaledSize: new google.maps.Size(48, 48) // Slightly larger
}
// We could add info windows here if needed
});
// Add info window for description
if (m.description) {
const infoWindow = new google.maps.InfoWindow({
content: `