Spaces:
Runtime error
Runtime error
| // TreeTrack Enhanced Map with Authentication and Tree Management | |
| class TreeTrackMap { | |
| constructor() { | |
| this.map = null; | |
| this.tempMarker = null; | |
| this.selectedLocation = null; | |
| this.treeMarkers = []; | |
| this.markerClusterGroup = null; | |
| this.userLocation = null; | |
| this.isLocationSelected = false; | |
| // Authentication properties | |
| this.currentUser = null; | |
| this.authToken = null; | |
| this.init(); | |
| } | |
| async init() { | |
| console.log('TreeTrackMap initialization started'); | |
| // Check authentication first | |
| console.log('Checking authentication...'); | |
| if (!await this.checkAuthentication()) { | |
| console.log('Authentication failed, redirecting to login'); | |
| window.location.href = '/login'; | |
| return; | |
| } | |
| console.log('Authentication successful'); | |
| this.showLoading(); | |
| try { | |
| console.log('Initializing map...'); | |
| await this.initializeMap(); | |
| console.log('Setting up event listeners...'); | |
| this.setupEventListeners(); | |
| console.log('Setting up user interface...'); | |
| this.setupUserInterface(); | |
| // Load trees asynchronously without blocking UI | |
| console.log('Starting tree loading (async)...'); | |
| this.loadTrees().catch(error => { | |
| console.error('Tree loading failed:', error); | |
| this.showMessage('Failed to load trees', 'error'); | |
| }); | |
| setTimeout(() => { | |
| this.hideLoading(); | |
| this.showGestureHint(); | |
| console.log('Map initialization complete!'); | |
| }, 500); | |
| } catch (error) { | |
| console.error('Map initialization failed:', error); | |
| console.error('Error stack:', error.stack); | |
| this.showMessage('Failed to initialize map. Please refresh the page.', 'error'); | |
| this.hideLoading(); | |
| } | |
| } | |
| // Authentication methods | |
| async checkAuthentication() { | |
| console.log('Checking for auth token...'); | |
| const token = localStorage.getItem('auth_token'); | |
| if (!token) { | |
| console.log('No auth token found in localStorage'); | |
| return false; | |
| } | |
| console.log('Auth token found, validating...'); | |
| try { | |
| const response = await fetch('/api/auth/validate', { | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| } | |
| }); | |
| console.log('Auth validation response status:', response.status); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| this.currentUser = data.user; | |
| this.authToken = token; | |
| console.log('Authentication successful for user:', data.user.username); | |
| console.log('User permissions:', data.user.permissions); | |
| return true; | |
| } else { | |
| console.log('Token invalid, status:', response.status); | |
| // Token invalid, remove it | |
| localStorage.removeItem('auth_token'); | |
| localStorage.removeItem('user_info'); | |
| return false; | |
| } | |
| } catch (error) { | |
| console.error('Auth validation error:', error); | |
| return false; | |
| } | |
| } | |
| setupUserInterface() { | |
| // Add user info to header | |
| this.displayUserInfo(); | |
| // Add logout functionality | |
| this.addLogoutButton(); | |
| // Show welcome button only for demo users | |
| this.setupWelcomeButton(); | |
| // Setup instructions panel | |
| this.setupInstructionsPanel(); | |
| } | |
| displayUserInfo() { | |
| if (!this.currentUser) return; | |
| // Update existing user info elements in the HTML | |
| const userAvatar = document.getElementById('userAvatar'); | |
| const userName = document.getElementById('userName'); | |
| const userRole = document.getElementById('userRole'); | |
| if (userAvatar && this.currentUser.full_name) { | |
| userAvatar.textContent = this.currentUser.full_name.charAt(0).toUpperCase(); | |
| } | |
| if (userName) { | |
| userName.textContent = this.currentUser.full_name || this.currentUser.username || 'User'; | |
| } | |
| if (userRole) { | |
| userRole.textContent = this.currentUser.role || 'User'; | |
| } | |
| } | |
| addLogoutButton() { | |
| // Use existing logout button instead of creating a new one | |
| const existingLogoutBtn = document.getElementById('logoutBtn'); | |
| if (existingLogoutBtn) { | |
| existingLogoutBtn.addEventListener('click', () => this.logout()); | |
| } | |
| } | |
| isDemoUser() { | |
| if (!this.currentUser) return false; | |
| return this.currentUser.role === 'demo_user' || | |
| this.currentUser.username === 'demo_user' || | |
| (this.currentUser.permissions && this.currentUser.permissions.includes('demo_view')); | |
| } | |
| setupWelcomeButton() { | |
| const welcomeBtn = document.getElementById('welcomeBtn'); | |
| if (welcomeBtn && this.isDemoUser()) { | |
| welcomeBtn.style.display = 'inline-flex'; | |
| } | |
| } | |
| setupInstructionsPanel() { | |
| // Always show instructions panel (persistent like map controls) | |
| const instructionsPanel = document.getElementById('instructionsPanel'); | |
| const toggleBtn = document.getElementById('instructionsToggle'); | |
| if (instructionsPanel) { | |
| // Keep instructions visible (remove hidden class if present) | |
| instructionsPanel.classList.remove('hidden'); | |
| } | |
| if (toggleBtn) { | |
| // Hide toggle button initially since panel is visible | |
| toggleBtn.classList.add('hidden'); | |
| } | |
| // Setup close/toggle functionality for manual control | |
| const closeBtn = document.getElementById('closeInstructions'); | |
| if (closeBtn) { | |
| closeBtn.addEventListener('click', () => { | |
| this.toggleInstructions(); | |
| }); | |
| } | |
| // Setup toggle button functionality | |
| if (toggleBtn) { | |
| toggleBtn.addEventListener('click', () => { | |
| this.toggleInstructions(); | |
| }); | |
| } | |
| } | |
| toggleInstructions() { | |
| const instructionsPanel = document.getElementById('instructionsPanel'); | |
| const toggleBtn = document.getElementById('instructionsToggle'); | |
| if (instructionsPanel) { | |
| instructionsPanel.classList.toggle('hidden'); | |
| // Show/hide toggle button based on panel visibility | |
| if (toggleBtn) { | |
| if (instructionsPanel.classList.contains('hidden')) { | |
| // Panel is hidden, show toggle button | |
| toggleBtn.classList.remove('hidden'); | |
| } else { | |
| // Panel is visible, hide toggle button | |
| toggleBtn.classList.add('hidden'); | |
| } | |
| } | |
| } | |
| } | |
| hideInstructions() { | |
| const instructionsPanel = document.getElementById('instructionsPanel'); | |
| if (instructionsPanel) { | |
| instructionsPanel.classList.add('hidden'); | |
| } | |
| } | |
| showInstructions() { | |
| const instructionsPanel = document.getElementById('instructionsPanel'); | |
| if (instructionsPanel) { | |
| instructionsPanel.classList.remove('hidden'); | |
| } | |
| } | |
| async logout() { | |
| try { | |
| await fetch('/api/auth/logout', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${this.authToken}` | |
| } | |
| }); | |
| } catch (error) { | |
| console.error('Logout error:', error); | |
| } finally { | |
| localStorage.removeItem('auth_token'); | |
| localStorage.removeItem('user_info'); | |
| window.location.href = '/login'; | |
| } | |
| } | |
| // Enhanced API calls with authentication | |
| async authenticatedFetch(url, options = {}) { | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.authToken}`, | |
| ...options.headers | |
| }; | |
| const response = await fetch(url, { | |
| ...options, | |
| headers | |
| }); | |
| if (response.status === 401) { | |
| // Token expired or invalid | |
| localStorage.removeItem('auth_token'); | |
| localStorage.removeItem('user_info'); | |
| window.location.href = '/login'; | |
| return null; | |
| } | |
| return response; | |
| } | |
| // Permission checking methods | |
| canEditTree(createdBy) { | |
| if (!this.currentUser) return false; | |
| // Admin and system can edit any tree | |
| if (this.currentUser.permissions.includes('admin') || this.currentUser.permissions.includes('system')) { | |
| return true; | |
| } | |
| // Users can edit trees they created | |
| if (this.currentUser.permissions.includes('edit_own') && createdBy === this.currentUser.username) { | |
| return true; | |
| } | |
| // Users with delete permission can edit any tree | |
| if (this.currentUser.permissions.includes('delete')) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| canDeleteTree(createdBy) { | |
| if (!this.currentUser) return false; | |
| // Only admin and system can delete trees | |
| if (this.currentUser.permissions.includes('admin') || this.currentUser.permissions.includes('system')) { | |
| return true; | |
| } | |
| // Users with explicit delete permission | |
| if (this.currentUser.permissions.includes('delete')) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| async initializeMap() { | |
| console.log('Initializing map...'); | |
| // Center on Tezpur, Assam - the actual research area | |
| const tezpurLocation = [26.6340, 92.7840]; // Tezpur coordinates | |
| // Initialize map with clustering-optimized zoom level | |
| this.map = L.map('map', { | |
| center: tezpurLocation, | |
| zoom: 15, // Start with good clustering view (matches center view) | |
| maxZoom: 17, // Allow enough zoom for spiderfy to work properly | |
| minZoom: 10, // Set reasonable minimum zoom | |
| zoomControl: true, | |
| attributionControl: true, | |
| preferCanvas: true // Better performance for many markers | |
| }); | |
| // Add multiple beautiful tile layers with spiderfy-compatible zoom limits | |
| const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { | |
| attribution: 'Tiles © Esri — Source: Esri, Maxar, GeoEye, Earthstar Geographics, CNES/Airbus DS, USDA, AeroGRID, IGN, and the GIS User Community', | |
| maxZoom: 17, // Allow higher zoom for spiderfy functionality | |
| minZoom: 10 | |
| }); | |
| const terrainLayer = L.tileLayer('https://stamen-tiles-{s}.a.ssl.fastly.net/terrain/{z}/{x}/{y}{r}.png', { | |
| attribution: 'Map tiles by Stamen Design, CC BY 3.0 — Map data © OpenStreetMap contributors', | |
| maxZoom: 17, | |
| minZoom: 10 | |
| }); | |
| const streetLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| attribution: '© OpenStreetMap contributors', | |
| maxZoom: 17, | |
| minZoom: 10 | |
| }); | |
| // Use satellite as primary layer for Tezpur's natural beauty | |
| satelliteLayer.addTo(this.map); | |
| // Add layer control for users to switch | |
| const baseMaps = { | |
| "Satellite": satelliteLayer, | |
| "Terrain": terrainLayer, | |
| "Street Map": streetLayer | |
| }; | |
| L.control.layers(baseMaps).addTo(this.map); | |
| // Initialize marker clustering with clean spiderfy behavior (no blue polygons) | |
| this.markerClusterGroup = L.markerClusterGroup({ | |
| chunkedLoading: true, | |
| chunkInterval: 100, // Faster processing for single query | |
| chunkDelay: 25, // Reduced delay | |
| maxClusterRadius: 60, // Balanced radius for good clustering and spiderfy | |
| spiderfyOnMaxZoom: true, // Enable spiral/flower view on max zoom | |
| spiderfyDistanceMultiplier: 1.5, // Larger spiral spread | |
| showCoverageOnHover: false, // DISABLE blue polygon coverage - it's hideous! | |
| zoomToBoundsOnClick: false, // Disable auto-zoom, prefer spiderfy | |
| removeOutsideVisibleBounds: true, // Performance optimization | |
| animate: false, // Disable animations to prevent ghost trees | |
| animateAddingMarkers: false, // Disable marker addition animations | |
| disableClusteringAtZoom: 18, // Allow clustering until very high zoom | |
| // Improved spiderfy settings for smoother spiral view without ghosts | |
| spiderfyShapePositions: function(count, centerPt) { | |
| const positions = []; | |
| const angleStep = (2 * Math.PI) / count; | |
| const baseRadius = 45; // Base distance from center | |
| for (let i = 0; i < count; i++) { | |
| const angle = i * angleStep; | |
| const spiralRadius = baseRadius + (i * 3); // Tighter spiral to reduce ghosting | |
| positions.push({ | |
| x: centerPt.x + spiralRadius * Math.cos(angle), | |
| y: centerPt.y + spiralRadius * Math.sin(angle) | |
| }); | |
| } | |
| return positions; | |
| } | |
| }); | |
| this.map.addLayer(this.markerClusterGroup); | |
| // Map click handler for pin dropping | |
| this.map.on('click', (e) => { | |
| this.onMapClick(e); | |
| }); | |
| console.log('Map initialized successfully with clustering'); | |
| } | |
| setupEventListeners() { | |
| console.log('Setting up event listeners...'); | |
| // My Location button | |
| document.getElementById('myLocationBtn').addEventListener('click', () => { | |
| this.getCurrentLocation(); | |
| }); | |
| // Clear Pins button | |
| document.getElementById('clearPinsBtn').addEventListener('click', () => { | |
| this.clearTempMarker(); | |
| }); | |
| // Center Map button | |
| const centerMapBtn = document.getElementById('centerMapBtn'); | |
| if (centerMapBtn) { | |
| centerMapBtn.addEventListener('click', () => { | |
| this.centerMapToTrees(); | |
| }); | |
| } | |
| // Use Location button | |
| document.getElementById('useLocationBtn').addEventListener('click', () => { | |
| this.useSelectedLocation(); | |
| }); | |
| // Cancel button | |
| document.getElementById('cancelBtn').addEventListener('click', () => { | |
| this.cancelLocationSelection(); | |
| }); | |
| console.log('Event listeners setup complete'); | |
| } | |
| onMapClick(e) { | |
| console.log('Map clicked at:', e.latlng); | |
| // Remove existing temp marker first (before setting new location) | |
| if (this.tempMarker) { | |
| this.map.removeLayer(this.tempMarker); | |
| this.tempMarker = null; | |
| } | |
| // Now set the new location | |
| this.selectedLocation = e.latlng; | |
| this.isLocationSelected = true; | |
| // Create beautiful tree-shaped temp marker with red coloring for selection | |
| const tempColors = { | |
| canopy1: '#ef4444', // Red for temp marker | |
| canopy2: '#dc2626', | |
| canopy3: '#b91c1c', | |
| trunk: '#7f5a44', // Keep trunk natural | |
| shadow: 'rgba(185, 28, 28, 0.4)' | |
| }; | |
| const tempIcon = L.divIcon({ | |
| html: ` | |
| <div class="map-marker temp-marker" style="filter: drop-shadow(2px 3px 6px ${tempColors.shadow});"> | |
| <svg width="36" height="44" viewBox="0 0 36 44" fill="none" style="transition: transform 0.2s ease;"> | |
| <!-- Tree Shadow/Base --> | |
| <ellipse cx="18" cy="42" rx="7" ry="1.5" fill="${tempColors.shadow}" opacity="0.4"/> | |
| <!-- Tree Trunk --> | |
| <path d="M16 35 Q16 37 16.5 39 Q17 41 17.5 42 Q18 42.5 18 42.5 Q18 42.5 18.5 42 Q19 41 19.5 39 Q20 37 20 35 L20 29 Q19.8 28 19 27.5 Q18 27 18 27 Q18 27 17 27.5 Q16.2 28 16 29 Z" fill="${tempColors.trunk}" stroke="#6b4a39" stroke-width="0.4"/> | |
| <!-- Tree Trunk Texture --> | |
| <path d="M17 29 Q17 32 17 35" stroke="#5a3e32" stroke-width="0.2" opacity="0.6"/> | |
| <path d="M19 30 Q19 33 19 36" stroke="#5a3e32" stroke-width="0.2" opacity="0.6"/> | |
| <!-- Main Canopy (Back Layer) --> | |
| <circle cx="18" cy="20" r="10" fill="${tempColors.canopy3}" opacity="0.8"/> | |
| <!-- Secondary Canopy Clusters --> | |
| <circle cx="14" cy="18" r="7" fill="${tempColors.canopy2}" opacity="0.85"/> | |
| <circle cx="22" cy="17" r="6" fill="${tempColors.canopy2}" opacity="0.85"/> | |
| <circle cx="16" cy="14" r="5" fill="${tempColors.canopy1}" opacity="0.9"/> | |
| <circle cx="21" cy="22" r="5.5" fill="${tempColors.canopy2}" opacity="0.85"/> | |
| <!-- Top Canopy (Brightest) --> | |
| <circle cx="18" cy="16" r="6" fill="${tempColors.canopy1}"/> | |
| <!-- Highlight clusters for 3D effect --> | |
| <circle cx="15" cy="14" r="2.5" fill="#fca5a5" opacity="0.7"/> | |
| <circle cx="21" cy="18" r="2" fill="#fca5a5" opacity="0.6"/> | |
| <circle cx="16" cy="21" r="1.5" fill="#fca5a5" opacity="0.5"/> | |
| <!-- Small light spots --> | |
| <circle cx="13" cy="13" r="0.8" fill="#fee2e2" opacity="0.8"/> | |
| <circle cx="20" cy="15" r="0.6" fill="#fee2e2" opacity="0.9"/> | |
| <circle cx="23" cy="20" r="0.5" fill="#fee2e2" opacity="0.7"/> | |
| </svg> | |
| </div> | |
| `, | |
| className: 'custom-marker-icon tree-pin-temp realistic-tree-temp', | |
| iconSize: [36, 44], | |
| iconAnchor: [18, 42], | |
| popupAnchor: [0, -44] | |
| }); | |
| this.tempMarker = L.marker([e.latlng.lat, e.latlng.lng], { icon: tempIcon }).addTo(this.map); | |
| // Update coordinates display | |
| document.getElementById('latValue').textContent = e.latlng.lat.toFixed(6); | |
| document.getElementById('lngValue').textContent = e.latlng.lng.toFixed(6); | |
| // Show info panel | |
| this.showInfoPanel(); | |
| } | |
| clearTempMarker() { | |
| if (this.tempMarker) { | |
| this.map.removeLayer(this.tempMarker); | |
| this.tempMarker = null; | |
| } | |
| this.selectedLocation = null; | |
| this.isLocationSelected = false; | |
| this.hideInfoPanel(); | |
| } | |
| showInfoPanel() { | |
| const panel = document.getElementById('locationPanel'); | |
| if (panel) { | |
| panel.classList.add('active'); | |
| } | |
| } | |
| hideInfoPanel() { | |
| const panel = document.getElementById('locationPanel'); | |
| if (panel) { | |
| panel.classList.remove('active'); | |
| } | |
| } | |
| useSelectedLocation() { | |
| if (!this.selectedLocation || !this.isLocationSelected) { | |
| this.showMessage('No location selected. Please click on the map to drop a pin first.', 'error'); | |
| return; | |
| } | |
| try { | |
| // Store location for the form page | |
| const locationData = { | |
| lat: this.selectedLocation.lat, | |
| lng: this.selectedLocation.lng | |
| }; | |
| localStorage.setItem('selectedLocation', JSON.stringify(locationData)); | |
| // Clear any previous messages and show success | |
| const messageElement = document.getElementById('message'); | |
| if(messageElement) messageElement.classList.remove('show'); | |
| this.showMessage('Location saved! Redirecting to form...', 'success'); | |
| // Redirect after a short delay | |
| setTimeout(() => { | |
| window.location.href = '/'; | |
| }, 1500); | |
| } catch (error) { | |
| console.error('Error saving location:', error); | |
| this.showMessage('Error saving location. Please try again.', 'error'); | |
| } | |
| } | |
| cancelLocationSelection() { | |
| this.clearTempMarker(); | |
| this.showMessage('Selection cancelled', 'info'); | |
| } | |
| centerMapToTrees() { | |
| if (this.treeMarkers.length === 0) { | |
| this.showMessage('No trees to center on', 'info'); | |
| return; | |
| } | |
| // Fit map to show all trees with clustering view (like screenshot) | |
| const group = new L.featureGroup(this.treeMarkers); | |
| this.map.fitBounds(group.getBounds().pad(0.1), { maxZoom: 15 }); | |
| this.showMessage('Map centered on all trees', 'success'); | |
| } | |
| getCurrentLocation() { | |
| console.log('Getting current location...'); | |
| if (!navigator.geolocation) { | |
| this.showMessage('Geolocation not supported by this browser', 'error'); | |
| return; | |
| } | |
| const myLocationBtn = document.getElementById('myLocationBtn'); | |
| myLocationBtn.textContent = 'Getting...'; | |
| myLocationBtn.disabled = true; | |
| navigator.geolocation.getCurrentPosition( | |
| (position) => { | |
| console.log('Location found:', position.coords); | |
| const lat = position.coords.latitude; | |
| const lng = position.coords.longitude; | |
| this.userLocation = { lat, lng }; | |
| // Center map on user location | |
| this.map.setView([lat, lng], 16); | |
| // Add user location marker | |
| if (this.userLocationMarker) { | |
| this.map.removeLayer(this.userLocationMarker); | |
| } | |
| const userIcon = L.divIcon({ | |
| html: ` | |
| <div class="map-marker user-marker"> | |
| <svg width="24" height="32" viewBox="0 0 24 32" fill="none"> | |
| <path d="M12 0C5.37 0 0 5.37 0 12C0 21 12 32 12 32S24 21 24 12C24 5.37 18.63 0 12 0Z" fill="#3b82f6"/> | |
| <circle cx="12" cy="12" r="4" fill="white"/> | |
| </svg> | |
| </div> | |
| `, | |
| className: 'custom-marker-icon', | |
| iconSize: [24, 32], | |
| iconAnchor: [12, 32], | |
| popupAnchor: [0, -32] | |
| }); | |
| this.userLocationMarker = L.marker([lat, lng], { icon: userIcon }).addTo(this.map); | |
| // Add tooltip | |
| this.userLocationMarker.bindTooltip('Your Location', { | |
| permanent: false, | |
| direction: 'top', | |
| offset: [0, -10], | |
| className: 'tree-tooltip' | |
| }); | |
| this.showMessage('Location found successfully', 'success'); | |
| myLocationBtn.textContent = 'My Location'; | |
| myLocationBtn.disabled = false; | |
| }, | |
| (error) => { | |
| console.error('Geolocation error:', error); | |
| let errorMessage = 'Failed to get location'; | |
| switch (error.code) { | |
| case error.PERMISSION_DENIED: | |
| errorMessage = 'Location access denied by user'; | |
| break; | |
| case error.POSITION_UNAVAILABLE: | |
| errorMessage = 'Location information unavailable'; | |
| break; | |
| case error.TIMEOUT: | |
| errorMessage = 'Location request timed out'; | |
| break; | |
| } | |
| this.showMessage(errorMessage, 'error'); | |
| myLocationBtn.textContent = 'My Location'; | |
| myLocationBtn.disabled = false; | |
| }, | |
| { | |
| enableHighAccuracy: true, | |
| timeout: 10000, | |
| maximumAge: 60000 | |
| } | |
| ); | |
| } | |
| async loadTrees() { | |
| console.log('🗺️ Starting tree loading process (RLS-enabled backend)...'); | |
| try { | |
| // Clear existing tree markers | |
| console.log('Clearing existing tree markers...'); | |
| this.clearTreeMarkers(); | |
| // Check if we have auth token | |
| if (!this.authToken) { | |
| console.error('No auth token available for API calls'); | |
| this.showMessage('Authentication required to load trees', 'error'); | |
| return; | |
| } | |
| // Check for cached tree data first | |
| const cacheKey = `treetrack_trees_${this.currentUser.username}`; | |
| const cachedData = localStorage.getItem(cacheKey); | |
| const cacheTimestamp = localStorage.getItem(`${cacheKey}_timestamp`); | |
| // Use cached data if it's less than 30 minutes old | |
| const cacheAge = Date.now() - parseInt(cacheTimestamp || '0'); | |
| const maxCacheAge = 30 * 60 * 1000; // 30 minutes | |
| if (cachedData && cacheAge < maxCacheAge) { | |
| console.log('Using cached tree data (age: ' + Math.round(cacheAge / 1000) + 's)'); | |
| const allTrees = JSON.parse(cachedData); | |
| this.renderTreesToMap(allTrees); | |
| this.showMessage(`Loaded ${allTrees.length} trees from cache`, 'success'); | |
| return; | |
| } | |
| console.log('Cache miss or expired, loading fresh data...'); | |
| // Load trees in batches | |
| let allTrees = []; | |
| let offset = 0; | |
| const batchSize = 1000; // API batch size (Supabase limit) | |
| let hasMoreTrees = true; | |
| console.log('Starting batch loading process...'); | |
| const treeCountEl = document.getElementById('treeCount'); | |
| if (treeCountEl) { | |
| treeCountEl.textContent = 'Loading trees...'; | |
| } else { | |
| console.warn('treeCount element not found'); | |
| } | |
| while (hasMoreTrees && allTrees.length < 3000) { // Safety limit | |
| console.log(`Loading batch: offset=${offset}, limit=${batchSize}`); | |
| try { | |
| const response = await this.authenticatedFetch(`/api/trees?limit=${batchSize}&offset=${offset}`); | |
| if (!response) { | |
| console.error('Failed to fetch batch - no response'); | |
| break; | |
| } | |
| if (!response.ok) { | |
| console.error('API response not ok:', response.status, response.statusText); | |
| break; | |
| } | |
| const batchTrees = await response.json(); | |
| console.log(`Loaded batch: ${batchTrees.length} trees`); | |
| if (batchTrees.length === 0) { | |
| console.log('No more trees to load'); | |
| hasMoreTrees = false; | |
| break; | |
| } | |
| allTrees = allTrees.concat(batchTrees); | |
| offset += batchSize; | |
| // If we got less than the batch size, we've reached the end | |
| if (batchTrees.length < batchSize) { | |
| console.log(`Last batch loaded (${batchTrees.length} < ${batchSize})`); | |
| hasMoreTrees = false; | |
| } | |
| // Update progress | |
| if (treeCountEl) { | |
| treeCountEl.textContent = `${allTrees.length} trees loaded...`; | |
| } | |
| } catch (fetchError) { | |
| console.error('Error in batch fetch:', fetchError); | |
| break; | |
| } | |
| } | |
| console.log(`✅ Total trees loaded: ${allTrees.length} (via service_role backend)`); | |
| // Cache the fresh tree data | |
| localStorage.setItem(cacheKey, JSON.stringify(allTrees)); | |
| localStorage.setItem(`${cacheKey}_timestamp`, Date.now().toString()); | |
| console.log(`💾 Cached ${allTrees.length} trees to localStorage`); | |
| this.renderTreesToMap(allTrees); | |
| } catch (error) { | |
| console.error('Error loading trees:', error); | |
| this.showMessage('Failed to load trees. Please refresh the page.', 'error'); | |
| } | |
| } | |
| renderTreesToMap(allTrees) { | |
| // Filter out specific trees (e.g., ID 18 as requested) | |
| const filteredTrees = allTrees.filter(tree => { | |
| if (tree.id === 18) { | |
| console.log('Excluding tree ID 18 from display'); | |
| return false; | |
| } | |
| return true; | |
| }); | |
| console.log(`Total trees to display: ${filteredTrees.length}`); | |
| // Add tree markers with optimized batch processing | |
| let loadedCount = 0; | |
| const uiBatchSize = 100; // UI batch size for smooth rendering | |
| for (let i = 0; i < filteredTrees.length; i += uiBatchSize) { | |
| const batch = filteredTrees.slice(i, i + uiBatchSize); | |
| // Process batch with small delay to prevent UI blocking | |
| setTimeout(() => { | |
| batch.forEach((tree) => { | |
| try { | |
| this.addTreeToMap(tree); | |
| loadedCount++; | |
| } catch (error) { | |
| console.warn(`Failed to add marker for tree ${tree.id}:`, error); | |
| } | |
| }); | |
| // Update progress | |
| if (document.getElementById('treeCount')) { | |
| document.getElementById('treeCount').textContent = `${loadedCount} trees`; | |
| } | |
| // If this is the last batch, finalize | |
| if (i + uiBatchSize >= filteredTrees.length) { | |
| console.log(`Map loading complete: ${loadedCount} tree markers added`); | |
| // Auto-center on trees with clustering view | |
| setTimeout(() => { | |
| if (loadedCount > 0) { | |
| const group = new L.featureGroup(this.treeMarkers); | |
| const bounds = group.getBounds(); | |
| this.map.fitBounds(bounds.pad(0.1), { maxZoom: 15 }); | |
| console.log('Map auto-centered with clustering view'); | |
| } | |
| }, 100); | |
| } | |
| }, (i / uiBatchSize) * 50); // Stagger UI batches by 50ms | |
| } | |
| } | |
| addTreeToMap(tree) { | |
| // Use consistent forest green colors for all trees | |
| const colors = { | |
| canopy1: '#22c55e', // Forest green | |
| canopy2: '#16a34a', // Darker green | |
| canopy3: '#15803d', // Darkest green | |
| trunk: '#8b5a2b', // Brown trunk | |
| shadow: 'rgba(34, 197, 94, 0.3)' | |
| }; | |
| // Choose marker style - you can change this to test different options | |
| const markerStyle = 'realistic-tree'; // Options: 'realistic-tree', 'simple-circle', 'pin-style', 'geometric', 'minimalist', 'minimal-tree' | |
| let treeIcon; | |
| if (markerStyle === 'simple-circle') { | |
| treeIcon = L.divIcon({ | |
| html: `<div style="width: 20px; height: 20px; background: ${colors.canopy1}; border: 3px solid white; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,0.3);"></div>`, | |
| className: 'custom-marker-icon simple-circle', | |
| iconSize: [20, 20], | |
| iconAnchor: [10, 10], | |
| popupAnchor: [0, -10] | |
| }); | |
| } else if (markerStyle === 'pin-style') { | |
| treeIcon = L.divIcon({ | |
| html: ` | |
| <div class="map-marker pin-marker" style="color: ${colors.canopy1};"> | |
| <svg width="25" height="35" viewBox="0 0 25 35" fill="none"> | |
| <path d="M12.5 0C5.6 0 0 5.6 0 12.5C0 21.9 12.5 35 12.5 35C12.5 35 25 21.9 25 12.5C25 5.6 19.4 0 12.5 0Z" fill="${colors.canopy1}" stroke="white" stroke-width="2"/> | |
| <circle cx="12.5" cy="12.5" r="5" fill="white"/> | |
| <text x="12.5" y="17" text-anchor="middle" font-family="Arial" font-size="8" fill="${colors.canopy1}">T</text> | |
| </svg> | |
| </div> | |
| `, | |
| className: 'custom-marker-icon pin-style', | |
| iconSize: [25, 35], | |
| iconAnchor: [12, 35], | |
| popupAnchor: [0, -35] | |
| }); | |
| } else if (markerStyle === 'geometric') { | |
| treeIcon = L.divIcon({ | |
| html: ` | |
| <div class="map-marker geometric-marker"> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none"> | |
| <polygon points="12,2 22,20 2,20" fill="${colors.canopy1}" stroke="white" stroke-width="2"/> | |
| <circle cx="12" cy="16" r="2" fill="white"/> | |
| </svg> | |
| </div> | |
| `, | |
| className: 'custom-marker-icon geometric', | |
| iconSize: [24, 24], | |
| iconAnchor: [12, 24], | |
| popupAnchor: [0, -24] | |
| }); | |
| } else if (markerStyle === 'minimalist') { | |
| treeIcon = L.divIcon({ | |
| html: ` | |
| <div style="width: 16px; height: 16px; background: ${colors.canopy1}; border: 2px solid white; border-radius: 3px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); display: flex; align-items: center; justify-content: center; font-size: 8px; color: white; font-weight: bold;">T</div> | |
| `, | |
| className: 'custom-marker-icon minimalist', | |
| iconSize: [16, 16], | |
| iconAnchor: [8, 8], | |
| popupAnchor: [0, -8] | |
| }); | |
| } else if (markerStyle === 'minimal-tree') { | |
| treeIcon = L.divIcon({ | |
| html: ` | |
| <div class="map-marker minimal-tree-marker" style="filter: drop-shadow(0 2px 4px ${colors.shadow});"> | |
| <svg width="28" height="32" viewBox="0 0 28 32" fill="none"> | |
| <!-- Clean tree trunk --> | |
| <rect x="12" y="24" width="4" height="8" fill="${colors.trunk}" rx="0.5"/> | |
| <!-- Main canopy - single clean shape --> | |
| <path d="M14 2 C18.5 2, 22 6, 22 11 C22 16, 18.5 20, 14 20 C9.5 20, 6 16, 6 11 C6 6, 9.5 2, 14 2 Z" fill="${colors.canopy1}" stroke="white" stroke-width="1.5"/> | |
| <!-- Subtle canopy depth --> | |
| <path d="M14 4 C17 4, 19.5 7, 19.5 10.5 C19.5 14, 17 17, 14 17 C11 17, 8.5 14, 8.5 10.5 C8.5 7, 11 4, 14 4 Z" fill="${colors.canopy2}" opacity="0.6"/> | |
| <!-- Sharp highlight --> | |
| <circle cx="12" cy="9" r="2" fill="white" opacity="0.3"/> | |
| </svg> | |
| </div> | |
| `, | |
| className: 'custom-marker-icon minimal-tree', | |
| iconSize: [28, 32], | |
| iconAnchor: [14, 30], | |
| popupAnchor: [0, -32] | |
| }); | |
| } else { | |
| // Default realistic tree style | |
| treeIcon = L.divIcon({ | |
| html: ` | |
| <div class="map-marker tree-marker" style="filter: drop-shadow(2px 3px 6px ${colors.shadow});"> | |
| <svg width="40" height="48" viewBox="0 0 40 48" fill="none" style="transition: transform 0.2s ease;"> | |
| <!-- Tree Shadow/Base --> | |
| <ellipse cx="20" cy="46" rx="8" ry="2" fill="${colors.shadow}" opacity="0.4"/> | |
| <!-- Tree Trunk --> | |
| <path d="M17 38 Q17 40 17.5 42 Q18 44 19 45 Q19.5 45.5 20 45.5 Q20.5 45.5 21 45 Q22 44 22.5 42 Q23 40 23 38 L23 32 Q22.8 31 22 30.5 Q21 30 20 30 Q19 30 18 30.5 Q17.2 31 17 32 Z" fill="${colors.trunk}" stroke="#6b4a39" stroke-width="0.5"/> | |
| <!-- Tree Trunk Texture --> | |
| <path d="M18.5 32 Q18.5 35 18.5 38" stroke="#5a3e32" stroke-width="0.3" opacity="0.6"/> | |
| <path d="M21.5 33 Q21.5 36 21.5 39" stroke="#5a3e32" stroke-width="0.3" opacity="0.6"/> | |
| <!-- Main Canopy (Back Layer) --> | |
| <circle cx="20" cy="22" r="12" fill="${colors.canopy3}" opacity="0.8"/> | |
| <!-- Secondary Canopy Clusters --> | |
| <circle cx="15" cy="20" r="8" fill="${colors.canopy2}" opacity="0.85"/> | |
| <circle cx="25" cy="19" r="7" fill="${colors.canopy2}" opacity="0.85"/> | |
| <circle cx="18" cy="15" r="6" fill="${colors.canopy1}" opacity="0.9"/> | |
| <circle cx="23" cy="25" r="6.5" fill="${colors.canopy2}" opacity="0.85"/> | |
| <!-- Top Canopy (Brightest) --> | |
| <circle cx="20" cy="18" r="7" fill="${colors.canopy1}"/> | |
| <!-- Highlight clusters for 3D effect --> | |
| <circle cx="16" cy="15" r="3" fill="#a8b9a0" opacity="0.7"/> | |
| <circle cx="24" cy="20" r="2.5" fill="#a8b9a0" opacity="0.6"/> | |
| <circle cx="18" cy="24" r="2" fill="#a8b9a0" opacity="0.5"/> | |
| <!-- Small light spots --> | |
| <circle cx="14" cy="14" r="1" fill="#c0d4b2" opacity="0.8"/> | |
| <circle cx="22" cy="16" r="0.8" fill="#c0d4b2" opacity="0.9"/> | |
| <circle cx="26" cy="22" r="0.6" fill="#c0d4b2" opacity="0.7"/> | |
| <!-- Optional: Small leaves/details --> | |
| <path d="M12 18 Q11 17 11.5 19 Q12.5 20 13 19" fill="${colors.canopy1}" opacity="0.6"/> | |
| <path d="M28 25 Q29 24 28.5 26 Q27.5 27 27 26" fill="${colors.canopy1}" opacity="0.6"/> | |
| </svg> | |
| </div> | |
| `, | |
| className: 'custom-marker-icon tree-pin realistic-tree', | |
| iconSize: [40, 48], | |
| iconAnchor: [20, 46], | |
| popupAnchor: [0, -48] | |
| }); | |
| } | |
| const marker = L.marker([tree.latitude, tree.longitude], { icon: treeIcon }); | |
| // Add to cluster group instead of directly to map for better performance | |
| this.markerClusterGroup.addLayer(marker); | |
| // Enhanced tooltip | |
| const treeName = tree.scientific_name || tree.common_name || tree.local_name || 'Unknown Tree'; | |
| const tooltipContent = ` | |
| <div class="tree-tooltip-content"> | |
| <div class="tree-name">${treeName}</div> | |
| <div class="tree-details">ID: ${tree.id}${tree.tree_code ? ' | ' + tree.tree_code : ''}</div> | |
| ${tree.created_by ? `<div class="tree-creator">by ${tree.created_by}</div>` : ''} | |
| </div> | |
| `; | |
| marker.bindTooltip(tooltipContent, { | |
| permanent: false, | |
| direction: 'top', | |
| offset: [0, -10], | |
| className: 'tree-tooltip' | |
| }); | |
| // Enhanced popup with action buttons | |
| const canEdit = this.canEditTree(tree.created_by); | |
| const canDelete = this.canDeleteTree(tree.created_by); | |
| const popupContent = ` | |
| <div style="width: 300px; max-width: 90vw; font-family: 'Segoe UI', sans-serif; position: relative;"> | |
| <div style="padding: 16px;"> | |
| <div style="display: flex; justify-content: between; align-items: flex-start; margin-bottom: 16px;"> | |
| <div style="flex: 1;"> | |
| <h3 style="margin: 0 0 8px 0; color: #059669; font-size: 18px; font-weight: 600; line-height: 1.2; word-wrap: break-word;"> | |
| ${treeName} | |
| </h3> | |
| <div style="color: #6b7280; font-size: 13px;"> | |
| <strong>Tree ID:</strong> #${tree.id}${tree.tree_code ? ' (' + tree.tree_code + ')' : ''} | |
| </div> | |
| </div> | |
| </div> | |
| <div style="margin-bottom: 16px;"> | |
| <div style="display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; font-size: 13px;"> | |
| ${tree.local_name ? `<strong style="color: #374151;">Local:</strong><span style="word-wrap: break-word;">${tree.local_name}</span>` : ''} | |
| ${tree.scientific_name ? `<strong style="color: #374151;">Scientific:</strong><span style="word-wrap: break-word;"><em>${tree.scientific_name}</em></span>` : ''} | |
| ${tree.common_name ? `<strong style="color: #374151;">Common:</strong><span style="word-wrap: break-word;">${tree.common_name}</span>` : ''} | |
| <strong style="color: #374151;">Location:</strong><span style="font-family: monospace; font-size: 12px;">${tree.latitude.toFixed(4)}, ${tree.longitude.toFixed(4)}</span> | |
| ${tree.height ? `<strong style="color: #374151;">Height:</strong><span>${tree.height} ft</span>` : ''} | |
| ${tree.width ? `<strong style="color: #374151;">Girth:</strong><span>${tree.width} ft</span>` : ''} | |
| <strong style="color: #374151;">Added by:</strong><span>${tree.created_by || 'Unknown'}</span> | |
| <strong style="color: #374151;">Date:</strong><span>${new Date(tree.created_at).toLocaleDateString()}</span> | |
| </div> | |
| </div> | |
| ${tree.notes ? ` | |
| <div style="margin-bottom: 16px; padding: 12px; background: #f8fafc; border-radius: 6px; border-left: 4px solid #e2e8f0;"> | |
| <strong style="color: #374151; font-size: 13px; display: block; margin-bottom: 6px;">Notes:</strong> | |
| <div style="color: #6b7280; font-size: 12px; line-height: 1.4; word-wrap: break-word;"> | |
| ${tree.notes} | |
| </div> | |
| </div> | |
| ` : ''} | |
| ${canEdit || canDelete ? ` | |
| <div style="display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid #e5e7eb;"> | |
| ${canEdit ? ` | |
| <button onclick="mapApp.editTree(${tree.id})" | |
| style="flex: 1; background: #3b82f6; color: white; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.2s; min-height: 36px;" | |
| onmouseover="this.style.backgroundColor='#2563eb'; this.style.transform='translateY(-1px)';" | |
| onmouseout="this.style.backgroundColor='#3b82f6'; this.style.transform='translateY(0)';"> | |
| Edit | |
| </button> | |
| ` : ''} | |
| ${canDelete ? ` | |
| <button onclick="mapApp.deleteTree(${tree.id})" | |
| style="flex: 1; background: #ef4444; color: white; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.2s; min-height: 36px;" | |
| onmouseover="this.style.backgroundColor='#dc2626'; this.style.transform='translateY(-1px)';" | |
| onmouseout="this.style.backgroundColor='#ef4444'; this.style.transform='translateY(0)';"> | |
| Delete | |
| </button> | |
| ` : ''} | |
| </div> | |
| ` : ''} | |
| </div> | |
| </div> | |
| `; | |
| marker.bindPopup(popupContent, { | |
| maxWidth: 320, | |
| minWidth: 280, | |
| className: 'tree-popup', | |
| closeButton: true, | |
| autoClose: true, | |
| autoPan: true, | |
| closeOnEscapeKey: true | |
| }); | |
| this.treeMarkers.push(marker); | |
| } | |
| clearTreeMarkers() { | |
| // Clear clustered markers | |
| if (this.markerClusterGroup) { | |
| this.markerClusterGroup.clearLayers(); | |
| } | |
| // Clear individual markers array | |
| this.treeMarkers.forEach(marker => { | |
| this.map.removeLayer(marker); | |
| }); | |
| this.treeMarkers = []; | |
| } | |
| // Tree management methods | |
| async editTree(treeId) { | |
| try { | |
| // Check if user has permission to edit (basic check) | |
| if (!this.currentUser) { | |
| this.showMessage('Authentication required', 'error'); | |
| return; | |
| } | |
| // Store tree ID in localStorage for the form | |
| localStorage.setItem('editTreeId', treeId.toString()); | |
| this.showMessage('Redirecting to form for editing...', 'success'); | |
| // Redirect to form page immediately | |
| setTimeout(() => { | |
| window.location.href = '/'; | |
| }, 500); | |
| } catch (error) { | |
| console.error('Error setting up tree for edit:', error); | |
| this.showMessage('Error preparing tree for editing: ' + error.message, 'error'); | |
| } | |
| } | |
| async deleteTree(treeId) { | |
| if (!confirm(`Are you sure you want to delete Tree #${treeId}? This action cannot be undone.`)) { | |
| return; | |
| } | |
| try { | |
| const response = await this.authenticatedFetch(`/api/trees/${treeId}`, { | |
| method: 'DELETE' | |
| }); | |
| if (!response) return; | |
| if (response.ok) { | |
| this.showMessage(`Tree #${treeId} deleted successfully`, 'success'); | |
| // Reload trees to update the map | |
| setTimeout(() => { | |
| this.loadTrees(); | |
| }, 1000); | |
| // Close any open popups | |
| this.map.closePopup(); | |
| } else { | |
| const error = await response.json(); | |
| this.showMessage('Error deleting tree: ' + (error.detail || 'Unknown error'), 'error'); | |
| } | |
| } catch (error) { | |
| console.error('Error deleting tree:', error); | |
| this.showMessage('Network error: ' + error.message, 'error'); | |
| } | |
| } | |
| showLoading() { | |
| document.getElementById('loading').style.display = 'block'; | |
| } | |
| hideLoading() { | |
| document.getElementById('loading').style.display = 'none'; | |
| } | |
| showMessage(message, type = 'success') { | |
| const messageElement = document.getElementById('message'); | |
| messageElement.textContent = message; | |
| messageElement.className = `message ${type} show`; | |
| setTimeout(() => { | |
| messageElement.classList.remove('show'); | |
| }, 3000); | |
| } | |
| showGestureHint() { | |
| const hint = document.querySelector('.gesture-hint'); | |
| if (hint) { | |
| hint.style.display = 'block'; | |
| setTimeout(() => { | |
| hint.style.display = 'none'; | |
| }, 4000); | |
| } | |
| } | |
| } | |
| // Initialize map when DOM is loaded | |
| let mapApp; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| console.log('🗺️ DOM loaded, initializing TreeTrack Map...'); | |
| mapApp = new TreeTrackMap(); | |
| // Expose globally for popup button callbacks | |
| window.mapApp = mapApp; | |
| }); | |
| // Note: ES6 exports removed for traditional script loading | |