/** * Live Location Viewer Application */ class LocationViewer { constructor() { this.map = null; this.marker = null; this.polyline = null; this.sessionId = null; this.encryptionKey = null; this.updateInterval = null; this.locationHistory = []; this.currentLocation = null; this.lastUpdateTimestamp = null; this.lastServerTimestamp = null; this.isActive = false; this.isFollowing = true; this.userMovedMap = false; this.lastNavigationMode = null; this.isInitialLoad = true; } /** * Initialize the application */ async init() { try { // Parse credentials from URL const pathParts = window.location.pathname.split('/'); const encodedCredentials = pathParts[pathParts.length - 1]; const credentials = LocationCrypto.parseCredentials(encodedCredentials); if (!credentials) { this.showError('Invalid share link'); return; } this.sessionId = credentials.sessionId; this.encryptionKey = credentials.encryptionKey; // Initialize map this.initMap(); // Load location history first await this.loadLocationHistory(); // Load latest location await this.loadLocation(); // Check session status await this.checkSessionStatus(); // Initial load is complete - now track user interactions setTimeout(() => { this.isInitialLoad = false; }, 1000); // Start polling for updates this.startPolling(); // Show info panel document.getElementById('infoPanel').style.display = 'block'; document.getElementById('loadingSpinner').style.display = 'none'; } catch (err) { console.error('Initialization error:', err); this.showError(err.message || 'Failed to initialize viewer'); } } /** * Initialize the Leaflet map */ initMap() { this.map = L.map('map').setView([0, 0], 2); // Use CartoDB Positron - free minimalist black and white tileset L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 20 }).addTo(this.map); // Initialize polyline for location history this.polyline = L.polyline([], { color: '#2196f3', weight: 3, opacity: 0.7 }).addTo(this.map); // Detect user map interaction (manual drag/zoom) this.map.on('dragstart', () => { if (!this.isInitialLoad) { this.userMovedMap = true; this.isFollowing = false; this.showFollowButton(); } }); this.map.on('zoomstart', () => { if (!this.isInitialLoad) { this.userMovedMap = true; this.isFollowing = false; this.showFollowButton(); } }); // Set up follow button const followButton = document.getElementById('followButton'); followButton.addEventListener('click', () => { this.isFollowing = true; this.userMovedMap = false; this.hideFollowButton(); if (this.currentLocation) { this.map.setView([this.currentLocation.lat, this.currentLocation.lon], 15); } }); } /** * Show follow button */ showFollowButton() { document.getElementById('followButton').classList.add('visible'); } /** * Hide follow button */ hideFollowButton() { document.getElementById('followButton').classList.remove('visible'); } /** * Load location history from server */ async loadLocationHistory(sinceTimestamp = null) { try { let url = `/api/sessions/${this.sessionId}/location/history?limit=100`; if (sinceTimestamp) { url += `&since=${sinceTimestamp}`; } const response = await fetch(url); if (!response.ok) { return; // History might not be available yet } const data = await response.json(); const newLocations = []; for (const update of data.updates) { try { const decrypted = await LocationCrypto.decryptPayload( update.encryptedPayload, this.encryptionKey ); // Check if this location is not already in history const isDuplicate = this.locationHistory.some(loc => loc.lat === decrypted.lat && loc.lon === decrypted.lon && loc.timestamp === decrypted.timestamp ); if (!isDuplicate) { newLocations.push({ lat: decrypted.lat, lon: decrypted.lon, timestamp: decrypted.timestamp }); } } catch (err) { console.error('Failed to decrypt historical location:', err); } } // Add new locations to history sorted by timestamp this.locationHistory.push(...newLocations); this.locationHistory.sort((a, b) => a.timestamp - b.timestamp); // Update polyline with historical data if (this.locationHistory.length > 0) { this.updatePolyline(); // Center map on latest location only on first load and if following if (!sinceTimestamp && this.isFollowing) { const latestLoc = this.locationHistory[this.locationHistory.length - 1]; this.map.setView([latestLoc.lat, latestLoc.lon], 15); } } } catch (err) { console.error('Load history error:', err); } } /** * Check session status */ async checkSessionStatus() { try { const response = await fetch(`/api/v1/session/${this.sessionId}`); if (!response.ok) { this.isActive = false; return; } const data = await response.json(); this.isActive = data.isActive; } catch (err) { console.error('Check session status error:', err); this.isActive = false; } } /** * Load location from server */ async loadLocation() { try { const response = await fetch(`/api/v1/location/${this.sessionId}/latest`); if (!response.ok) { if (response.status === 404) { throw new Error('No location data available yet. Waiting for updates...'); } throw new Error('Failed to load location'); } const data = await response.json(); const newTimestamp = data.timestamp; // Check for gap in updates (more than 30 seconds) if (this.lastServerTimestamp && (newTimestamp - this.lastServerTimestamp) > 30000) { console.log('Detected gap in updates, loading missing history...'); await this.loadLocationHistory(this.lastServerTimestamp); } this.lastUpdateTimestamp = data.timestamp; this.lastServerTimestamp = newTimestamp; await this.processLocationUpdate(data); // Update session status on each location update await this.checkSessionStatus(); } catch (err) { console.error('Load location error:', err); throw err; } } /** * Process an encrypted location update */ async processLocationUpdate(data) { try { // Decrypt the payload const decrypted = await LocationCrypto.decryptPayload( data.encryptedPayload, this.encryptionKey ); this.currentLocation = decrypted; // Update map this.updateMap(decrypted); // Update info panel this.updateInfoPanel(decrypted, data.timestamp); // Add to history only if it's not already there (avoid duplicates) const isDuplicate = this.locationHistory.some(loc => loc.lat === decrypted.lat && loc.lon === decrypted.lon && loc.timestamp === decrypted.timestamp ); if (!isDuplicate) { this.locationHistory.push({ lat: decrypted.lat, lon: decrypted.lon, timestamp: decrypted.timestamp }); // Update polyline only when we add a new point this.updatePolyline(); } } catch (err) { console.error('Failed to process location update:', err); // Don't throw - just skip this update } } /** * Update map with new location */ updateMap(location) { const latLng = [location.lat, location.lon]; if (!this.marker) { // Create marker const icon = L.divIcon({ className: 'location-marker', html: '
', iconSize: [26, 26], iconAnchor: [13, 13] }); this.marker = L.marker(latLng, { icon }).addTo(this.map); if (this.isFollowing) { this.map.setView(latLng, 15); } } else { // Update marker position this.marker.setLatLng(latLng); } // Add accuracy circle if available if (location.accuracy) { if (this.accuracyCircle) { this.accuracyCircle.setLatLng(latLng); this.accuracyCircle.setRadius(location.accuracy); } else { this.accuracyCircle = L.circle(latLng, { radius: location.accuracy, color: '#2196f3', fillColor: '#2196f3', fillOpacity: 0.1, weight: 1 }).addTo(this.map); } } // Pan to new location only if following if (this.isFollowing) { this.map.panTo(latLng); } } /** * Update polyline with location history */ updatePolyline() { const points = this.locationHistory.map(loc => [loc.lat, loc.lon]); this.polyline.setLatLngs(points); } /** * Update info panel */ updateInfoPanel(location, serverTimestamp) { // Determine if session is truly live (updated within last 30 seconds) const now = Date.now(); const timeSinceUpdate = now - serverTimestamp; const isLive = timeSinceUpdate < 30000; // Only show status if it's inactive AND there's old data OR session is explicitly closed let statusHtml = ''; if (!isLive && timeSinceUpdate > 60000) { statusHtml = `