TreeTrack / static /map.js
RoyAalekh's picture
fix(edit): stop showing 'Edit cancelled' after successful update; show ft units on map popup
79b15bc
raw
history blame
31.4 kB
// TreeTrack Enhanced Map with Authentication and Tree Management
class TreeTrackMap {
constructor() {
this.map = null;
this.tempMarker = null;
this.selectedLocation = null;
this.treeMarkers = [];
this.userLocation = null;
this.isLocationSelected = false;
// Authentication properties
this.currentUser = null;
this.authToken = null;
this.init();
}
async init() {
// Check authentication first
if (!await this.checkAuthentication()) {
window.location.href = '/login';
return;
}
this.showLoading();
try {
await this.initializeMap();
this.setupEventListeners();
await this.loadTrees();
this.setupUserInterface();
setTimeout(() => {
this.hideLoading();
this.showGestureHint();
}, 1000);
} catch (error) {
console.error('Map initialization failed:', error);
this.showMessage('Failed to initialize map. Please refresh the page.', 'error');
this.hideLoading();
}
}
// Authentication methods
async checkAuthentication() {
const token = localStorage.getItem('auth_token');
if (!token) {
return false;
}
try {
const response = await fetch('/api/auth/validate', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
this.currentUser = data.user;
this.authToken = token;
return true;
} else {
// 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();
}
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());
}
}
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...');
// Default location (you can change this to your preferred location)
const defaultLocation = [26.2006, 92.9376]; // Guwahati, Assam
// Initialize map
this.map = L.map('map', {
center: defaultLocation,
zoom: 13,
zoomControl: true,
attributionControl: true
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
}).addTo(this.map);
// Map click handler for pin dropping
this.map.on('click', (e) => {
this.onMapClick(e);
});
console.log('Map initialized successfully');
}
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
const group = new L.featureGroup(this.treeMarkers);
this.map.fitBounds(group.getBounds().pad(0.1));
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('Loading trees...');
try {
const response = await this.authenticatedFetch('/api/trees?limit=1000');
if (!response) return;
const trees = await response.json();
console.log(`Loaded ${trees.length} trees`);
// Clear existing tree markers
this.clearTreeMarkers();
// Add tree markers
trees.forEach(tree => {
this.addTreeMarker(tree);
});
// Update tree count
document.getElementById('treeCount').textContent = trees.length;
if (trees.length > 0) {
// Fit map to show all trees with debounced execution
setTimeout(() => {
const group = new L.featureGroup(this.treeMarkers);
this.map.fitBounds(group.getBounds().pad(0.1));
}, 100);
}
} catch (error) {
console.error('Error loading trees:', error);
this.showMessage('Failed to load trees', 'error');
}
}
addTreeMarker(tree) {
// Determine tree health/status color
const getTreeColors = () => {
// Default healthy tree colors using our new soft palette
return {
canopy1: '#8ab070', // primary-500
canopy2: '#739b5a', // primary-600
canopy3: '#5d7f49', // primary-700
trunk: '#7f5a44', // accent-700
shadow: 'rgba(93, 127, 73, 0.3)'
};
};
const colors = getTreeColors();
const 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 }).addTo(this.map);
// 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 (no emojis)
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: 20px; padding-bottom: 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: #1e40af; 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() {
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();
});