TreeTrack / static /map.js
RoyAalekh's picture
Fix UI blocking issue in map initialization by loading trees async
1ab2920
raw
history blame
50 kB
// 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