RoyAalekh commited on
Commit
6a5c5e3
Β·
1 Parent(s): 3b7a305

Complete TreeTrack Field Research Application with all 12 fields, file uploads, camera, audio recording, GPS, and mobile-responsive UI

Browse files
Files changed (4) hide show
  1. app.py +134 -2
  2. requirements.txt +1 -0
  3. static/app.js +341 -1069
  4. static/index.html +452 -273
app.py CHANGED
@@ -13,12 +13,14 @@ from pathlib import Path
13
  from typing import Any, Optional
14
 
15
  import uvicorn
16
- from fastapi import FastAPI, HTTPException, Request, status
17
  from fastapi.middleware.cors import CORSMiddleware
18
  from fastapi.middleware.trustedhost import TrustedHostMiddleware
19
- from fastapi.responses import HTMLResponse, JSONResponse
20
  from fastapi.staticfiles import StaticFiles
21
  from pydantic import BaseModel, Field, field_validator, model_validator
 
 
22
 
23
  from config import get_settings
24
 
@@ -636,6 +638,136 @@ async def delete_tree(tree_id: int):
636
  raise
637
 
638
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639
  @app.get("/api/stats", response_model=StatsResponse, tags=["Statistics"])
640
  async def get_stats():
641
  """Get comprehensive tree statistics"""
 
13
  from typing import Any, Optional
14
 
15
  import uvicorn
16
+ from fastapi import FastAPI, HTTPException, Request, status, File, UploadFile, Form
17
  from fastapi.middleware.cors import CORSMiddleware
18
  from fastapi.middleware.trustedhost import TrustedHostMiddleware
19
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
20
  from fastapi.staticfiles import StaticFiles
21
  from pydantic import BaseModel, Field, field_validator, model_validator
22
+ import uuid
23
+ import aiofiles
24
 
25
  from config import get_settings
26
 
 
638
  raise
639
 
640
 
641
+ # File Upload Endpoints
642
+ @app.post("/api/upload/image", tags=["Files"])
643
+ async def upload_image(
644
+ file: UploadFile = File(...),
645
+ category: str = Form(...)
646
+ ):
647
+ """Upload an image file for tree documentation"""
648
+ # Validate file type
649
+ if not file.content_type or not file.content_type.startswith('image/'):
650
+ raise HTTPException(
651
+ status_code=400,
652
+ detail="File must be an image"
653
+ )
654
+
655
+ # Validate category
656
+ valid_categories = ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
657
+ if category not in valid_categories:
658
+ raise HTTPException(
659
+ status_code=400,
660
+ detail=f"Category must be one of: {valid_categories}"
661
+ )
662
+
663
+ try:
664
+ # Generate unique filename
665
+ file_id = str(uuid.uuid4())
666
+ file_extension = Path(file.filename).suffix.lower()
667
+ filename = f"{file_id}{file_extension}"
668
+ file_path = Path("uploads/images") / filename
669
+
670
+ # Save file
671
+ async with aiofiles.open(file_path, 'wb') as f:
672
+ content = await file.read()
673
+ await f.write(content)
674
+
675
+ logger.info(f"Image uploaded: {filename}, category: {category}")
676
+
677
+ return {
678
+ "filename": filename,
679
+ "file_path": str(file_path),
680
+ "category": category,
681
+ "size": len(content),
682
+ "content_type": file.content_type
683
+ }
684
+
685
+ except Exception as e:
686
+ logger.error(f"Error uploading image: {e}")
687
+ raise HTTPException(status_code=500, detail="Failed to upload image")
688
+
689
+
690
+ @app.post("/api/upload/audio", tags=["Files"])
691
+ async def upload_audio(file: UploadFile = File(...)):
692
+ """Upload an audio file for storytelling"""
693
+ # Validate file type
694
+ if not file.content_type or not file.content_type.startswith('audio/'):
695
+ raise HTTPException(
696
+ status_code=400,
697
+ detail="File must be an audio file"
698
+ )
699
+
700
+ try:
701
+ # Generate unique filename
702
+ file_id = str(uuid.uuid4())
703
+ file_extension = Path(file.filename).suffix.lower()
704
+ filename = f"{file_id}{file_extension}"
705
+ file_path = Path("uploads/audio") / filename
706
+
707
+ # Save file
708
+ async with aiofiles.open(file_path, 'wb') as f:
709
+ content = await file.read()
710
+ await f.write(content)
711
+
712
+ logger.info(f"Audio uploaded: {filename}")
713
+
714
+ return {
715
+ "filename": filename,
716
+ "file_path": str(file_path),
717
+ "size": len(content),
718
+ "content_type": file.content_type
719
+ }
720
+
721
+ except Exception as e:
722
+ logger.error(f"Error uploading audio: {e}")
723
+ raise HTTPException(status_code=500, detail="Failed to upload audio")
724
+
725
+
726
+ @app.get("/api/files/{file_type}/{filename}", tags=["Files"])
727
+ async def get_file(file_type: str, filename: str):
728
+ """Serve uploaded files"""
729
+ if file_type not in ["images", "audio"]:
730
+ raise HTTPException(status_code=400, detail="Invalid file type")
731
+
732
+ file_path = Path(f"uploads/{file_type}/{filename}")
733
+
734
+ if not file_path.exists():
735
+ raise HTTPException(status_code=404, detail="File not found")
736
+
737
+ return FileResponse(file_path)
738
+
739
+
740
+ # Utility endpoints for form data
741
+ @app.get("/api/utilities", tags=["Data"])
742
+ async def get_utilities():
743
+ """Get list of valid utility options"""
744
+ return {
745
+ "utilities": [
746
+ "Religious", "Timber", "Biodiversity", "Hydrological benefit",
747
+ "Faunal interaction", "Food", "Medicine", "Shelter", "Cultural"
748
+ ]
749
+ }
750
+
751
+
752
+ @app.get("/api/phenology-stages", tags=["Data"])
753
+ async def get_phenology_stages():
754
+ """Get list of valid phenology stages"""
755
+ return {
756
+ "stages": [
757
+ "New leaves", "Old leaves", "Open flowers", "Fruiting",
758
+ "Ripe fruit", "Recent fruit drop", "Other"
759
+ ]
760
+ }
761
+
762
+
763
+ @app.get("/api/photo-categories", tags=["Data"])
764
+ async def get_photo_categories():
765
+ """Get list of valid photo categories"""
766
+ return {
767
+ "categories": ["Leaf", "Bark", "Fruit", "Seed", "Flower", "Full tree"]
768
+ }
769
+
770
+
771
  @app.get("/api/stats", response_model=StatsResponse, tags=["Statistics"])
772
  async def get_stats():
773
  """Get comprehensive tree statistics"""
requirements.txt CHANGED
@@ -5,3 +5,4 @@ python-multipart>=0.0.12
5
  pydantic>=2.10.0
6
  pydantic-settings>=2.6.0
7
  pandas>=2.3.1
 
 
5
  pydantic>=2.10.0
6
  pydantic-settings>=2.6.0
7
  pandas>=2.3.1
8
+ aiofiles>=24.1.0
static/app.js CHANGED
@@ -1,1159 +1,431 @@
1
- // Enhanced Tree Mapping Application JavaScript
2
- // Implements performance optimizations, error handling, and best practices
3
-
4
- class TreeMapApp {
5
  constructor() {
6
- this.map = null;
7
- this.trees = [];
8
- this.selectedTree = null;
9
- this.markers = [];
10
- this.tempMarker = null;
11
- this.markerCluster = null;
12
- this.debounceTimer = null;
13
- this.cache = new Map();
14
- this.retryAttempts = 3;
15
- this.requestTimeout = 10000;
16
-
17
- // Performance monitoring
18
- this.performanceMetrics = {
19
- apiCalls: 0,
20
- cacheHits: 0,
21
- errors: 0
22
- };
23
 
24
  this.init();
25
  }
26
-
27
- async init() {
28
- try {
29
- this.showLoadingState(true);
30
- await this.initMap();
31
- this.initEventListeners();
32
- await Promise.all([
33
- this.loadTrees(),
34
- this.loadStats()
35
- ]);
36
- this.updateTreeList();
37
- this.updateTreemap();
38
- this.showLoadingState(false);
39
- console.log('Application initialized successfully');
40
- } catch (error) {
41
- console.error('Failed to initialize application:', error);
42
- this.showError('Failed to initialize application. Please refresh the page.');
43
- this.showLoadingState(false);
44
- }
45
- }
46
-
47
- initMap() {
48
- return new Promise((resolve, reject) => {
49
- try {
50
- // Initialize Leaflet map with better default settings
51
- this.map = L.map('map', {
52
- zoomControl: true,
53
- attributionControl: true,
54
- maxZoom: 18,
55
- minZoom: 2
56
- }).setView([51.5074, -0.1278], 13);
57
-
58
- // Add OpenStreetMap tiles with error handling
59
- const tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
60
- attribution: 'Β© OpenStreetMap contributors',
61
- maxZoom: 18,
62
- errorTileUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgZmlsbD0iI2VlZSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1zaXplPSIxOCIgZmlsbD0iIzk5OSI+VGlsZSBub3QgZm91bmQ8L3RleHQ+PC9zdmc+'
63
- });
64
-
65
- tileLayer.on('tileerror', (e) => {
66
- console.warn('Tile loading error:', e);
67
- });
68
-
69
- tileLayer.addTo(this.map);
70
-
71
- // Add click handler for adding trees with debouncing
72
- this.map.on('click', this.debounce((e) => {
73
- this.handleMapClick(e);
74
- }, 300));
75
-
76
- // Add map event listeners for performance
77
- this.map.on('moveend', this.debounce(() => {
78
- this.onMapMoveEnd();
79
- }, 500));
80
-
81
- resolve();
82
- } catch (error) {
83
- reject(error);
84
- }
85
- });
86
  }
87
-
88
- handleMapClick(e) {
89
  try {
90
- // Remove previous temporary marker if it exists
91
- if (this.tempMarker) {
92
- this.map.removeLayer(this.tempMarker);
93
- }
94
-
95
- // Validate coordinates
96
- const lat = parseFloat(e.latlng.lat.toFixed(6));
97
- const lng = parseFloat(e.latlng.lng.toFixed(6));
98
-
99
- if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
100
- this.showError('Invalid coordinates selected');
101
- return;
102
- }
103
-
104
- // Set coordinates in form
105
- document.getElementById('latitude').value = lat;
106
- document.getElementById('longitude').value = lng;
107
-
108
- // Add temporary pin marker with animation
109
- const tempIcon = L.divIcon({
110
- className: 'temp-marker',
111
- html: `<div style="
112
- background-color: #ff6b6b;
113
- width: 24px;
114
- height: 24px;
115
- border-radius: 50%;
116
- border: 3px solid white;
117
- box-shadow: 0 2px 6px rgba(0,0,0,0.4);
118
- display: flex;
119
- align-items: center;
120
- justify-content: center;
121
- color: white;
122
- font-size: 14px;
123
- font-weight: bold;
124
- animation: pulse 1.5s infinite;
125
- ">πŸ“</div>`,
126
- iconSize: [30, 30],
127
- iconAnchor: [15, 15]
128
- });
129
-
130
- this.tempMarker = L.marker([lat, lng], { icon: tempIcon })
131
- .addTo(this.map)
132
- .bindPopup('Click "Add Tree" to save tree at this location')
133
- .openPopup();
134
-
135
  } catch (error) {
136
- console.error('Error handling map click:', error);
137
- this.showError('Error processing map click');
138
  }
139
  }
140
-
141
- onMapMoveEnd() {
142
- // Optimize marker visibility based on zoom level
143
- const zoom = this.map.getZoom();
144
- const bounds = this.map.getBounds();
145
 
146
- // Hide/show markers based on zoom level for performance
147
- this.markers.forEach(marker => {
148
- const markerLatLng = marker.getLatLng();
149
- if (bounds.contains(markerLatLng)) {
150
- if (!this.map.hasLayer(marker)) {
151
- this.map.addLayer(marker);
152
- }
153
- } else if (zoom < 10) {
154
- if (this.map.hasLayer(marker)) {
155
- this.map.removeLayer(marker);
156
- }
157
- }
158
  });
159
  }
160
-
161
- initEventListeners() {
162
- // Tree form submission with validation
163
- const treeForm = document.getElementById('treeForm');
164
- if (treeForm) {
165
- treeForm.addEventListener('submit', (e) => {
166
- e.preventDefault();
167
- this.addTree();
168
- });
169
- }
170
-
171
- // Clear form button
172
- const clearBtn = document.getElementById('clearForm');
173
- if (clearBtn) {
174
- clearBtn.addEventListener('click', () => {
175
- this.clearForm();
176
- });
177
- }
 
 
178
 
179
- // Modal close
180
- const closeBtn = document.querySelector('.close');
181
- if (closeBtn) {
182
- closeBtn.addEventListener('click', () => {
183
- this.closeModal();
184
- });
185
- }
186
 
187
- // Edit tree button
188
- const editBtn = document.getElementById('editTree');
189
- if (editBtn) {
190
- editBtn.addEventListener('click', () => {
191
- this.editTree();
192
- });
193
- }
194
 
195
- // Delete tree button
196
- const deleteBtn = document.getElementById('deleteTree');
197
- if (deleteBtn) {
198
- deleteBtn.addEventListener('click', () => {
199
- this.deleteTree();
200
- });
201
- }
202
 
203
- // Close modal when clicking outside
204
- window.addEventListener('click', (e) => {
205
- const modal = document.getElementById('treeModal');
206
- if (e.target === modal) {
207
- this.closeModal();
208
- }
209
- });
210
 
211
- // Add keyboard shortcuts
212
- document.addEventListener('keydown', (e) => {
213
- if (e.key === 'Escape') {
214
- this.closeModal();
215
- }
216
- });
217
 
218
- // Add form validation listeners
219
- this.addFormValidationListeners();
220
  }
221
-
222
- addFormValidationListeners() {
223
- const inputs = document.querySelectorAll('#treeForm input, #treeForm select, #treeForm textarea');
224
- inputs.forEach(input => {
225
- input.addEventListener('blur', () => {
226
- this.validateField(input);
227
  });
228
-
229
- input.addEventListener('input', this.debounce(() => {
230
- this.validateField(input);
231
- }, 500));
232
  });
233
  }
234
-
235
- validateField(field) {
236
- const value = field.value.trim();
237
- let isValid = true;
238
- let errorMessage = '';
239
-
240
- // Clear previous error
241
- this.clearFieldError(field);
242
 
243
- switch (field.id) {
244
- case 'latitude':
245
- const lat = parseFloat(value);
246
- if (value && (isNaN(lat) || lat < -90 || lat > 90)) {
247
- isValid = false;
248
- errorMessage = 'Latitude must be between -90 and 90';
249
- }
250
- break;
251
-
252
- case 'longitude':
253
- const lng = parseFloat(value);
254
- if (value && (isNaN(lng) || lng < -180 || lng > 180)) {
255
- isValid = false;
256
- errorMessage = 'Longitude must be between -180 and 180';
257
- }
258
- break;
259
-
260
- case 'species':
261
- if (field.required && !value) {
262
- isValid = false;
263
- errorMessage = 'Species is required';
264
- } else if (value && !/^[A-Za-z\s\-\.]+$/.test(value)) {
265
- isValid = false;
266
- errorMessage = 'Species name contains invalid characters';
267
- }
268
- break;
269
-
270
- case 'height':
271
- const height = parseFloat(value);
272
- if (value && (isNaN(height) || height <= 0 || height > 200)) {
273
- isValid = false;
274
- errorMessage = 'Height must be between 0 and 200 meters';
275
- }
276
- break;
277
-
278
- case 'diameter':
279
- const diameter = parseFloat(value);
280
- if (value && (isNaN(diameter) || diameter <= 0 || diameter > 1000)) {
281
- isValid = false;
282
- errorMessage = 'Diameter must be between 0 and 1000 cm';
283
- }
284
- break;
285
-
286
- case 'age_estimate':
287
- const age = parseInt(value);
288
- if (value && (isNaN(age) || age <= 0 || age > 5000)) {
289
- isValid = false;
290
- errorMessage = 'Age must be between 0 and 5000 years';
291
- }
292
- break;
293
- case 'last_inspection':
294
- if (value && !this.isValidDate(value)) {
295
- isValid = false;
296
- errorMessage = 'Please enter a valid date (YYYY-MM-DD)';
297
- } else if (value && new Date(value) > new Date()) {
298
- isValid = false;
299
- errorMessage = 'Date cannot be in the future';
300
- }
301
- break;
302
- }
303
 
304
- if (!isValid) {
305
- this.showFieldError(field, errorMessage);
306
- }
307
 
308
- return isValid;
309
- }
310
-
311
- showFieldError(field, message) {
312
- field.classList.add('error');
313
- let errorDiv = field.parentNode.querySelector('.error-message');
314
- if (!errorDiv) {
315
- errorDiv = document.createElement('div');
316
- errorDiv.className = 'error-message';
317
- errorDiv.style.color = '#dc3545';
318
- errorDiv.style.fontSize = '0.875rem';
319
- errorDiv.style.marginTop = '0.25rem';
320
- field.parentNode.appendChild(errorDiv);
321
- }
322
- errorDiv.textContent = message;
323
- }
324
-
325
- clearFieldError(field) {
326
- field.classList.remove('error');
327
- const errorDiv = field.parentNode.querySelector('.error-message');
328
- if (errorDiv) {
329
- errorDiv.remove();
330
- }
331
  }
332
-
333
- isValidDate(dateString) {
334
- const regex = /^\d{4}-\d{2}-\d{2}$/;
335
- if (!regex.test(dateString)) return false;
336
-
337
- const date = new Date(dateString);
338
- const timestamp = date.getTime();
339
 
340
- if (typeof timestamp !== 'number' || Number.isNaN(timestamp)) {
341
- return false;
342
- }
 
 
 
343
 
344
- return dateString === date.toISOString().split('T')[0];
345
  }
346
-
347
- async loadTrees() {
348
- const cacheKey = 'trees_list';
 
 
 
 
349
 
350
- // Check cache first
351
- if (this.cache.has(cacheKey)) {
352
- const cached = this.cache.get(cacheKey);
353
- if (Date.now() - cached.timestamp < 30000) { // 30 second cache
354
- this.trees = cached.data;
355
- this.performanceMetrics.cacheHits++;
356
- this.updateMapMarkers();
357
- return;
358
  }
359
- }
360
 
361
- try {
362
- const response = await this.fetchWithRetry('/api/trees');
363
- this.trees = await response.json();
364
-
365
- // Cache the result
366
- this.cache.set(cacheKey, {
367
- data: this.trees,
368
- timestamp: Date.now()
369
- });
370
-
371
- this.updateMapMarkers();
372
- this.performanceMetrics.apiCalls++;
373
-
374
- } catch (error) {
375
- console.error('Error loading trees:', error);
376
- this.showError('Failed to load trees. Please try again.');
377
- this.performanceMetrics.errors++;
378
- }
379
  }
380
-
381
- async loadStats() {
382
- const cacheKey = 'stats';
383
-
384
- // Check cache first
385
- if (this.cache.has(cacheKey)) {
386
- const cached = this.cache.get(cacheKey);
387
- if (Date.now() - cached.timestamp < 60000) { // 1 minute cache
388
- this.updateStatsDisplay(cached.data);
389
- this.performanceMetrics.cacheHits++;
390
- return;
391
- }
392
- }
393
 
394
  try {
395
- const response = await this.fetchWithRetry('/api/stats');
396
- const stats = await response.json();
397
-
398
- // Cache the result
399
- this.cache.set(cacheKey, {
400
- data: stats,
401
- timestamp: Date.now()
402
  });
403
 
404
- this.updateStatsDisplay(stats);
405
- this.performanceMetrics.apiCalls++;
406
-
 
 
 
 
 
 
 
 
407
  } catch (error) {
408
- console.error('Error loading stats:', error);
409
- this.showError('Failed to load statistics.');
410
- this.performanceMetrics.errors++;
411
  }
412
  }
413
-
414
- updateStatsDisplay(stats) {
415
- const elements = {
416
- totalTrees: document.getElementById('totalTrees'),
417
- avgHeight: document.getElementById('avgHeight'),
418
- avgDiameter: document.getElementById('avgDiameter')
419
- };
420
-
421
- if (elements.totalTrees) elements.totalTrees.textContent = stats.total_trees || 0;
422
- if (elements.avgHeight) elements.avgHeight.textContent = stats.average_height || 0;
423
- if (elements.avgDiameter) elements.avgDiameter.textContent = stats.average_diameter || 0;
424
- }
425
-
426
- async fetchWithRetry(url, options = {}, retries = this.retryAttempts) {
427
- const controller = new AbortController();
428
- const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
429
-
430
- try {
431
- const response = await fetch(url, {
432
- ...options,
433
- signal: controller.signal
434
- });
435
 
436
- clearTimeout(timeoutId);
437
-
438
- // **CRITICAL: Don't throw error immediately for 422 - let caller handle it**
439
- if (!response.ok && response.status !== 422) {
440
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
441
- }
442
-
443
- return response;
444
-
445
- } catch (error) {
446
- clearTimeout(timeoutId);
447
-
448
- // **Don't retry on 422 validation errors**
449
- if (error.message && error.message.includes('422')) {
450
- throw error;
451
- }
452
-
453
- if (retries > 0 && !controller.signal.aborted) {
454
- console.warn(`Request failed, retrying... (${retries} attempts left)`);
455
- await this.delay(1000 * (this.retryAttempts - retries + 1));
456
- return this.fetchWithRetry(url, options, retries - 1);
457
- }
458
-
459
- throw error;
460
- }
461
- }
462
-
463
- updateMapMarkers() {
464
- // Clear existing markers efficiently
465
- this.markers.forEach(marker => {
466
- if (this.map.hasLayer(marker)) {
467
- this.map.removeLayer(marker);
468
- }
469
- });
470
- this.markers = [];
471
 
472
- // Create markers in batches for better performance
473
- const batchSize = 50;
474
- let currentBatch = 0;
475
-
476
- const processBatch = () => {
477
- const start = currentBatch * batchSize;
478
- const end = Math.min(start + batchSize, this.trees.length);
479
-
480
- for (let i = start; i < end; i++) {
481
- const tree = this.trees[i];
482
- try {
483
- const marker = this.createTreeMarker(tree);
484
- this.markers.push(marker);
485
- } catch (error) {
486
- console.error(`Error creating marker for tree ${tree.id}:`, error);
487
- }
488
- }
489
-
490
- currentBatch++;
491
-
492
- if (end < this.trees.length) {
493
- // Process next batch asynchronously
494
- setTimeout(processBatch, 10);
495
- } else {
496
- // All markers created, fit bounds if needed
497
- this.fitMapBounds();
498
  }
499
  };
500
 
501
- if (this.trees.length > 0) {
502
- processBatch();
503
- }
504
  }
505
-
506
- fitMapBounds() {
507
- if (this.markers.length > 0) {
508
- try {
509
- const group = new L.featureGroup(this.markers);
510
- const bounds = group.getBounds();
 
 
 
 
 
 
 
 
511
 
512
- if (bounds.isValid()) {
513
- this.map.fitBounds(bounds.pad(0.1), {
514
- maxZoom: 15,
515
- animate: true,
516
- duration: 1
517
- });
518
- }
519
- } catch (error) {
520
- console.error('Error fitting map bounds:', error);
521
  }
 
 
 
522
  }
523
  }
524
-
525
- createTreeMarker(tree) {
526
- // Validate tree data
527
- if (!tree.latitude || !tree.longitude ||
528
- tree.latitude < -90 || tree.latitude > 90 ||
529
- tree.longitude < -180 || tree.longitude > 180) {
530
- throw new Error('Invalid tree coordinates');
531
- }
532
-
533
- // Color based on health status
534
- const healthColors = {
535
- 'Excellent': '#28a745',
536
- 'Good': '#28a745',
537
- 'Fair': '#ffc107',
538
- 'Poor': '#dc3545',
539
- 'Dead': '#6c757d'
540
- };
541
-
542
- const color = healthColors[tree.health_status] || '#28a745';
543
-
544
- // Create optimized custom icon
545
- const icon = L.divIcon({
546
- className: 'tree-marker',
547
- html: `<div style="
548
- background-color: ${color};
549
- width: 20px;
550
- height: 20px;
551
- border-radius: 50%;
552
- border: 2px solid white;
553
- box-shadow: 0 2px 4px rgba(0,0,0,0.3);
554
- display: flex;
555
- align-items: center;
556
- justify-content: center;
557
- color: white;
558
- font-size: 12px;
559
- font-weight: bold;
560
- ">🌳</div>`,
561
- iconSize: [24, 24],
562
- iconAnchor: [12, 12]
563
- });
564
-
565
- const marker = L.marker([tree.latitude, tree.longitude], { icon })
566
- .addTo(this.map)
567
- .on('click', () => this.showTreeDetails(tree));
568
-
569
- // Create popup content efficiently
570
- const popupContent = this.createPopupContent(tree);
571
- marker.bindPopup(popupContent, {
572
- maxWidth: 250,
573
- closeButton: true
574
- });
575
-
576
- return marker;
577
- }
578
-
579
- createPopupContent(tree) {
580
- const safeText = (text) => text ? this.escapeHtml(text) : '';
581
-
582
- return `
583
- <div style="min-width: 200px;">
584
- <h4>${safeText(tree.species)}</h4>
585
- ${tree.common_name ? `<p><strong>Common:</strong> ${safeText(tree.common_name)}</p>` : ''}
586
- <p><strong>Health:</strong> <span class="health-${tree.health_status.toLowerCase()}">${safeText(tree.health_status)}</span></p>
587
- ${tree.height ? `<p><strong>Height:</strong> ${tree.height}m</p>` : ''}
588
- ${tree.diameter ? `<p><strong>Diameter:</strong> ${tree.diameter}cm</p>` : ''}
589
- <p><em>Click for more details</em></p>
590
- </div>
591
- `;
592
- }
593
-
594
- escapeHtml(text) {
595
- const div = document.createElement('div');
596
- div.textContent = text;
597
- return div.innerHTML;
598
- }
599
-
600
- updateTreeList() {
601
- const treeList = document.getElementById('treeList');
602
- if (!treeList) return;
603
-
604
- // Clear existing content
605
- treeList.innerHTML = '';
606
-
607
- // Show recent trees (limit for performance)
608
- const recentTrees = this.trees.slice(0, 10);
609
-
610
- if (recentTrees.length === 0) {
611
- treeList.innerHTML = '<p style="text-align: center; color: #666; padding: 1rem;">No trees found</p>';
612
- return;
613
  }
614
-
615
- const fragment = document.createDocumentFragment();
616
-
617
- recentTrees.forEach(tree => {
618
- const treeItem = document.createElement('div');
619
- treeItem.className = 'tree-item';
620
- treeItem.innerHTML = `
621
- <div class="tree-species">${this.escapeHtml(tree.species)}</div>
622
- <div class="tree-details">
623
- ${tree.common_name ? this.escapeHtml(tree.common_name) : 'No common name'} β€’
624
- <span class="health-${tree.health_status.toLowerCase()}">${this.escapeHtml(tree.health_status)}</span>
625
- </div>
626
- `;
627
-
628
- treeItem.addEventListener('click', () => {
629
- this.selectTree(tree);
630
- this.showTreeDetails(tree);
631
- });
632
-
633
- fragment.appendChild(treeItem);
634
- });
635
-
636
- treeList.appendChild(fragment);
637
  }
638
-
639
- updateTreemap() {
640
- const treemapElement = document.getElementById('treemap');
641
- if (!treemapElement) return;
642
-
643
- if (this.trees.length === 0) {
644
- treemapElement.innerHTML = '<p style="text-align: center; color: #666; padding: 2rem;">No trees to display</p>';
645
- return;
646
- }
647
-
648
  try {
649
- // Prepare data for treemap
650
- const speciesCount = {};
651
- this.trees.forEach(tree => {
652
- const species = tree.species || 'Unknown';
653
- speciesCount[species] = (speciesCount[species] || 0) + 1;
654
- });
655
-
656
- const data = [{
657
- type: "treemap",
658
- labels: Object.keys(speciesCount),
659
- values: Object.values(speciesCount),
660
- parents: Array(Object.keys(speciesCount).length).fill(""),
661
- textinfo: "label+value",
662
- hovertemplate: '<b>%{label}</b><br>Count: %{value}<extra></extra>',
663
- marker: {
664
- colorscale: 'Greens',
665
- showscale: false
666
- }
667
- }];
668
 
669
- const layout = {
670
- margin: { t: 0, l: 0, r: 0, b: 0 },
671
- font: { size: 12 }
672
  };
673
 
674
- const config = {
675
- displayModeBar: false,
676
- responsive: true
 
 
 
 
 
 
677
  };
678
 
679
- Plotly.newPlot('treemap', data, layout, config);
 
 
 
 
 
 
 
 
680
 
681
  } catch (error) {
682
- console.error('Error creating treemap:', error);
683
- treemapElement.innerHTML = '<p style="text-align: center; color: #dc3545; padding: 2rem;">Error loading treemap</p>';
684
  }
685
  }
686
-
687
- selectTree(tree) {
688
- // Remove previous selection
689
- document.querySelectorAll('.tree-item').forEach(item => {
690
- item.classList.remove('selected');
691
- });
692
-
693
- // Add selection to clicked item
694
- if (event && event.currentTarget) {
695
- event.currentTarget.classList.add('selected');
696
- }
697
-
698
- this.selectedTree = tree;
699
-
700
- // Center map on selected tree with smooth animation
701
- this.map.setView([tree.latitude, tree.longitude], 16, {
702
- animate: true,
703
- duration: 1
704
- });
705
- }
706
-
707
- showTreeDetails(tree) {
708
- const modal = document.getElementById('treeModal');
709
- const details = document.getElementById('treeDetails');
710
-
711
- if (!modal || !details) return;
712
-
713
- const safeText = (text) => text ? this.escapeHtml(text) : '';
714
-
715
- details.innerHTML = `
716
- <div style="line-height: 1.6;">
717
- <p><strong>Species:</strong> ${safeText(tree.species)}</p>
718
- ${tree.common_name ? `<p><strong>Common Name:</strong> ${safeText(tree.common_name)}</p>` : ''}
719
- <p><strong>Location:</strong> ${tree.latitude.toFixed(6)}, ${tree.longitude.toFixed(6)}</p>
720
- ${tree.height ? `<p><strong>Height:</strong> ${tree.height} meters</p>` : ''}
721
- ${tree.diameter ? `<p><strong>Diameter:</strong> ${tree.diameter} cm</p>` : ''}
722
- <p><strong>Health Status:</strong> <span class="health-${tree.health_status.toLowerCase()}">${safeText(tree.health_status)}</span></p>
723
- ${tree.age_estimate ? `<p><strong>Age Estimate:</strong> ${tree.age_estimate} years</p>` : ''}
724
- ${tree.last_inspection ? `<p><strong>Last Inspection:</strong> ${tree.last_inspection}</p>` : ''}
725
- ${tree.notes ? `<p><strong>Notes:</strong> ${safeText(tree.notes)}</p>` : ''}
726
- <p><strong>Added:</strong> ${new Date(tree.timestamp).toLocaleDateString()}</p>
727
- </div>
728
- `;
729
-
730
- this.selectedTree = tree;
731
- modal.style.display = 'block';
732
-
733
- // Focus management for accessibility
734
- const closeBtn = modal.querySelector('.close');
735
- if (closeBtn) closeBtn.focus();
736
- }
737
-
738
- closeModal() {
739
- const modal = document.getElementById('treeModal');
740
- if (modal) {
741
- modal.style.display = 'none';
742
  }
743
- this.selectedTree = null;
744
  }
745
-
746
- editTree() {
747
- if (!this.selectedTree) return;
748
-
749
- try {
750
- // Populate form with selected tree data
751
- const tree = this.selectedTree;
752
- const fields = {
753
- 'latitude': tree.latitude,
754
- 'longitude': tree.longitude,
755
- 'species': tree.species,
756
- 'common_name': tree.common_name || '',
757
- 'height': tree.height || '',
758
- 'diameter': tree.diameter || '',
759
- 'health_status': tree.health_status,
760
- 'age_estimate': tree.age_estimate || '',
761
- 'last_inspection': tree.last_inspection || '',
762
- 'notes': tree.notes || ''
763
- };
764
 
765
- Object.entries(fields).forEach(([fieldId, value]) => {
766
- const element = document.getElementById(fieldId);
767
- if (element) {
768
- element.value = value;
769
- this.clearFieldError(element);
 
 
 
 
 
770
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
771
  });
772
 
773
- // Change form button to update mode
774
- const submitBtn = document.querySelector('#treeForm button[type="submit"]');
775
- if (submitBtn) {
776
- submitBtn.textContent = 'Update Tree';
777
- submitBtn.dataset.mode = 'edit';
778
- submitBtn.dataset.treeId = tree.id;
 
 
779
  }
780
-
781
- this.closeModal();
782
-
783
  } catch (error) {
784
- console.error('Error editing tree:', error);
785
- this.showError('Error loading tree data for editing');
786
  }
787
  }
788
-
789
- async deleteTree() {
790
- if (!this.selectedTree) return;
 
 
791
 
792
- const confirmed = await this.showConfirmDialog(
793
- 'Delete Tree',
794
- 'Are you sure you want to delete this tree? This action cannot be undone.'
795
- );
 
796
 
797
- if (!confirmed) return;
 
 
 
798
 
 
 
 
 
 
 
 
799
  try {
800
- this.showLoadingState(true);
 
801
 
802
- const response = await this.fetchWithRetry(`/api/trees/${this.selectedTree.id}`, {
803
- method: 'DELETE'
804
- });
805
 
806
- if (response.ok) {
807
- // Clear cache
808
- this.cache.clear();
809
-
810
- await Promise.all([
811
- this.loadTrees(),
812
- this.loadStats()
813
- ]);
814
-
815
- this.updateTreeList();
816
- this.updateTreemap();
817
- this.closeModal();
818
- this.showSuccess('Tree deleted successfully');
819
- } else {
820
- throw new Error('Failed to delete tree');
821
  }
822
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  } catch (error) {
824
- console.error('Error deleting tree:', error);
825
- this.showError('Failed to delete tree. Please try again.');
826
- } finally {
827
- this.showLoadingState(false);
828
- }
829
- }
830
-
831
- async addTree() {
832
- // Validate form first
833
- const form = document.getElementById('treeForm');
834
- if (!form) {
835
- console.error('Tree form not found');
836
- return;
837
- }
838
-
839
- const inputs = form.querySelectorAll('input, select, textarea');
840
- let isValid = true;
841
-
842
- inputs.forEach(input => {
843
- if (!this.validateField(input)) {
844
- isValid = false;
845
- }
846
- });
847
-
848
- if (!isValid) {
849
- this.showError('Please fix the validation errors before submitting');
850
- return;
851
- }
852
-
853
- let formData;
854
- try {
855
- formData = this.getFormData();
856
- const jsonPayload = JSON.stringify(formData, null, 2);
857
- console.log('JSON payload:', jsonPayload);
858
- console.log('JSON payload length:', jsonPayload.length);
859
- console.log('==========================');
860
-
861
- } catch (error) {
862
- console.error('=== FORM DATA ERROR ===');
863
- console.error('Error in getFormData:', error);
864
- console.error('Error stack:', error.stack);
865
- console.error('======================');
866
- this.showError(error.message);
867
- return;
868
- }
869
-
870
- const submitBtn = document.querySelector('#treeForm button[type="submit"]');
871
- const isEdit = submitBtn && submitBtn.dataset.mode === 'edit';
872
-
873
- try {
874
- this.showLoadingState(true);
875
-
876
- const url = isEdit ? `/api/trees/${submitBtn.dataset.treeId}` : '/api/trees';
877
- const method = isEdit ? 'PUT' : 'POST';
878
-
879
- console.log('=== REQUEST DEBUG ===');
880
- console.log('URL:', url);
881
- console.log('Method:', method);
882
- console.log('Headers:', { 'Content-Type': 'application/json' });
883
- console.log('Body:', JSON.stringify(formData));
884
- console.log('====================');
885
-
886
- const response = await this.fetchWithRetry(url, {
887
- method: method,
888
- headers: {
889
- 'Content-Type': 'application/json'
890
- },
891
- body: JSON.stringify(formData)
892
- });
893
-
894
- console.log('=== RESPONSE DEBUG ===');
895
- console.log('Response status:', response.status);
896
- console.log('Response ok:', response.ok);
897
- console.log('Response headers:', [...response.headers.entries()]);
898
- console.log('======================');
899
-
900
- if (response.ok) {
901
- console.log('=== SUCCESS ===');
902
- console.log('Tree created successfully');
903
- console.log('===============');
904
-
905
- // Clear cache to force refresh
906
- this.cache.clear();
907
-
908
- await Promise.all([
909
- this.loadTrees(),
910
- this.loadStats()
911
- ]);
912
-
913
- this.updateTreeList();
914
- this.updateTreemap();
915
- this.clearForm();
916
-
917
- // Remove temporary marker if it exists
918
- if (this.tempMarker) {
919
- this.map.removeLayer(this.tempMarker);
920
- this.tempMarker = null;
921
- }
922
-
923
- // Reset form button
924
- if (submitBtn) {
925
- submitBtn.textContent = 'Add Tree';
926
- submitBtn.dataset.mode = '';
927
- delete submitBtn.dataset.treeId;
928
- }
929
-
930
- this.showSuccess(`Tree ${isEdit ? 'updated' : 'added'} successfully`);
931
-
932
- } else {
933
- let errorData;
934
- try {
935
- errorData = await response.json();
936
- } catch (jsonError) {
937
- console.error('Failed to parse error response as JSON:', jsonError);
938
- errorData = {};
939
- }
940
-
941
- // **CRITICAL ERROR DEBUG**
942
- console.error('=== SERVER ERROR RESPONSE ===');
943
- console.error('Status:', response.status);
944
- console.error('Status text:', response.statusText);
945
- console.error('Response headers:', [...response.headers.entries()]);
946
- console.error('Error data:', errorData);
947
- console.error('Error detail:', errorData.detail);
948
- console.error('=============================');
949
-
950
- // Try to get response text if JSON parsing failed
951
- if (!errorData.detail) {
952
- try {
953
- const responseText = await response.text();
954
- console.error('Raw response text:', responseText);
955
- } catch (textError) {
956
- console.error('Failed to get response text:', textError);
957
- }
958
- }
959
-
960
- throw new Error(errorData.detail || `Failed to ${isEdit ? 'update' : 'add'} tree (${response.status})`);
961
  }
962
-
963
- } catch (error) {
964
- console.error('=== NETWORK/REQUEST ERROR ===');
965
- console.error('Error type:', error.constructor.name);
966
- console.error('Error message:', error.message);
967
- console.error('Error stack:', error.stack);
968
- console.error('=============================');
969
-
970
- this.showError(error.message || `Failed to ${isEdit ? 'update' : 'add'} tree`);
971
- } finally {
972
- this.showLoadingState(false);
973
- }
974
- }
975
-
976
- getFormData() {
977
- const getValue = (id) => {
978
- const element = document.getElementById(id);
979
- return element ? element.value.trim() : '';
980
- };
981
-
982
- const getNumericValue = (id) => {
983
- const value = getValue(id);
984
- return value ? parseFloat(value) : null;
985
- };
986
-
987
- const getIntegerValue = (id) => {
988
- const value = getValue(id);
989
- return value ? parseInt(value) : null;
990
- };
991
-
992
- // Start with only required fields
993
- const formData = {
994
- latitude: getNumericValue('latitude'),
995
- longitude: getNumericValue('longitude'),
996
- species: getValue('species'),
997
- health_status: getValue('health_status')
998
- };
999
-
1000
- // **CRITICAL: Only add optional fields if they have actual values**
1001
- const commonName = getValue('common_name');
1002
- if (commonName) {
1003
- formData.common_name = commonName;
1004
- }
1005
-
1006
- const height = getNumericValue('height');
1007
- if (height !== null && !isNaN(height)) {
1008
- formData.height = height;
1009
- }
1010
-
1011
- const diameter = getNumericValue('diameter');
1012
- if (diameter !== null && !isNaN(diameter)) {
1013
- formData.diameter = diameter;
1014
  }
1015
 
1016
- const ageEstimate = getIntegerValue('age_estimate');
1017
- if (ageEstimate !== null && !isNaN(ageEstimate)) {
1018
- formData.age_estimate = ageEstimate;
1019
- }
1020
-
1021
- const lastInspection = getValue('last_inspection');
1022
- if (lastInspection) {
1023
- formData.last_inspection = lastInspection;
1024
- }
1025
-
1026
- const notes = getValue('notes');
1027
- if (notes) {
1028
- formData.notes = notes;
1029
- }
1030
-
1031
- // **CRITICAL FIX: Remove any phantom fields that shouldn't exist**
1032
- // This handles browser autofill adding removed fields
1033
- if (formData.hasOwnProperty('planted_date')) {
1034
- delete formData.planted_date;
1035
- console.warn('Removed phantom planted_date field from payload');
1036
- }
1037
-
1038
- return formData;
1039
- }
1040
-
1041
- clearForm() {
1042
- const form = document.getElementById('treeForm');
1043
- if (!form) return;
1044
-
1045
- form.reset();
1046
 
1047
- // Clear all field errors
1048
- const inputs = form.querySelectorAll('input, select, textarea');
1049
- inputs.forEach(input => {
1050
- this.clearFieldError(input);
1051
- });
1052
-
1053
- // Reset form button
1054
- const submitBtn = form.querySelector('button[type="submit"]');
1055
- if (submitBtn) {
1056
- submitBtn.textContent = 'Add Tree';
1057
- submitBtn.dataset.mode = '';
1058
- delete submitBtn.dataset.treeId;
1059
- }
1060
-
1061
- // Set default values
1062
- const healthStatus = document.getElementById('health_status');
1063
- if (healthStatus) {
1064
- healthStatus.value = 'Good';
1065
- }
1066
- }
1067
-
1068
- // Utility functions
1069
- debounce(func, wait) {
1070
- return function executedFunction(...args) {
1071
- const later = () => {
1072
- clearTimeout(this.debounceTimer);
1073
- func(...args);
1074
- };
1075
- clearTimeout(this.debounceTimer);
1076
- this.debounceTimer = setTimeout(later, wait);
1077
- };
1078
- }
1079
-
1080
- delay(ms) {
1081
- return new Promise(resolve => setTimeout(resolve, ms));
1082
- }
1083
-
1084
- showLoadingState(show) {
1085
- // You can implement a loading spinner here
1086
- const body = document.body;
1087
- if (show) {
1088
- body.style.cursor = 'wait';
1089
- } else {
1090
- body.style.cursor = 'default';
1091
- }
1092
- }
1093
-
1094
- showError(message) {
1095
- console.error(message);
1096
- // You can implement a toast notification system here
1097
- alert(`Error: ${message}`);
1098
- }
1099
-
1100
- showSuccess(message) {
1101
- console.log(message);
1102
- // You can implement a toast notification system here
1103
- // For now, we'll use a simple alert
1104
- // alert(message);
1105
- }
1106
-
1107
- showConfirmDialog(title, message) {
1108
- return new Promise((resolve) => {
1109
- const result = confirm(`${title}\n\n${message}`);
1110
- resolve(result);
1111
- });
1112
- }
1113
-
1114
- // Performance monitoring
1115
- getPerformanceMetrics() {
1116
- return {
1117
- ...this.performanceMetrics,
1118
- cacheSize: this.cache.size,
1119
- markersCount: this.markers.length,
1120
- treesCount: this.trees.length
1121
- };
1122
- }
1123
-
1124
- logPerformanceMetrics() {
1125
- console.log('Performance Metrics:', this.getPerformanceMetrics());
1126
  }
1127
  }
1128
 
1129
- // Initialize the application when the page loads
 
1130
  document.addEventListener('DOMContentLoaded', () => {
1131
- try {
1132
- window.treeMapApp = new TreeMapApp();
1133
-
1134
- // Log performance metrics every 30 seconds in development
1135
- if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
1136
- setInterval(() => {
1137
- window.treeMapApp.logPerformanceMetrics();
1138
- }, 30000);
1139
- }
1140
-
1141
- } catch (error) {
1142
- console.error('Failed to initialize TreeMapApp:', error);
1143
- alert('Failed to initialize the application. Please refresh the page.');
1144
- }
1145
- });
1146
-
1147
- // Handle page visibility changes for performance
1148
- document.addEventListener('visibilitychange', () => {
1149
- if (window.treeMapApp) {
1150
- if (document.hidden) {
1151
- // Page is hidden, pause expensive operations
1152
- console.log('Page hidden, pausing operations');
1153
- } else {
1154
- // Page is visible, resume operations
1155
- console.log('Page visible, resuming operations');
1156
- // Optionally refresh data if it's been a while
1157
- }
1158
- }
1159
  });
 
1
+ // TreeTrack Enhanced JavaScript - Comprehensive Field Research Tool
2
+ class TreeTrackApp {
 
 
3
  constructor() {
4
+ this.uploadedPhotos = {};
5
+ this.audioFile = null;
6
+ this.mediaRecorder = null;
7
+ this.audioChunks = [];
8
+ this.isRecording = false;
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  this.init();
11
  }
12
+
13
+ init() {
14
+ this.loadFormOptions();
15
+ this.setupEventListeners();
16
+ this.loadTrees();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  }
18
+
19
+ async loadFormOptions() {
20
  try {
21
+ // Load utility options
22
+ const utilityResponse = await fetch('/api/utilities');
23
+ const utilityData = await utilityResponse.json();
24
+ this.renderMultiSelect('utilityOptions', utilityData.utilities);
25
+
26
+ // Load phenology stages
27
+ const phenologyResponse = await fetch('/api/phenology-stages');
28
+ const phenologyData = await phenologyResponse.json();
29
+ this.renderMultiSelect('phenologyOptions', phenologyData.stages);
30
+
31
+ // Load photo categories
32
+ const categoriesResponse = await fetch('/api/photo-categories');
33
+ const categoriesData = await categoriesResponse.json();
34
+ this.renderPhotoCategories(categoriesData.categories);
35
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  } catch (error) {
37
+ console.error('Error loading form options:', error);
 
38
  }
39
  }
40
+
41
+ renderMultiSelect(containerId, options) {
42
+ const container = document.getElementById(containerId);
43
+ container.innerHTML = '';
 
44
 
45
+ options.forEach(option => {
46
+ const label = document.createElement('label');
47
+ label.innerHTML = `
48
+ <input type="checkbox" value="${option}"> ${option}
49
+ `;
50
+ container.appendChild(label);
 
 
 
 
 
 
51
  });
52
  }
53
+
54
+ renderPhotoCategories(categories) {
55
+ const container = document.getElementById('photoCategories');
56
+ container.innerHTML = '';
57
+
58
+ categories.forEach(category => {
59
+ const categoryDiv = document.createElement('div');
60
+ categoryDiv.className = 'photo-category';
61
+ categoryDiv.innerHTML = `
62
+ <div>
63
+ <label>${category}</label>
64
+ <div class="file-upload photo-upload" data-category="${category}">
65
+ πŸ“· Click to upload ${category} photo or use camera
66
+ </div>
67
+ <div class="uploaded-file" id="photo-${category}" style="display: none;"></div>
68
+ </div>
69
+ <button type="button" class="btn btn-small" onclick="app.capturePhoto('${category}')">πŸ“Έ Camera</button>
70
+ `;
71
+ container.appendChild(categoryDiv);
72
+ });
73
 
74
+ this.setupPhotoUploads();
75
+ }
76
+
77
+ setupEventListeners() {
78
+ // Form submission
79
+ document.getElementById('treeForm').addEventListener('submit', (e) => this.handleSubmit(e));
 
80
 
81
+ // Reset form
82
+ document.getElementById('resetForm').addEventListener('click', () => this.resetForm());
 
 
 
 
 
83
 
84
+ // GPS location
85
+ document.getElementById('getLocation').addEventListener('click', () => this.getCurrentLocation());
 
 
 
 
 
86
 
87
+ // Audio recording
88
+ document.getElementById('recordBtn').addEventListener('click', () => this.toggleRecording());
 
 
 
 
 
89
 
90
+ // Audio file upload
91
+ document.getElementById('audioUpload').addEventListener('click', () => this.selectAudioFile());
 
 
 
 
92
 
93
+ // Drag and drop for audio
94
+ this.setupDragAndDrop();
95
  }
96
+
97
+ setupPhotoUploads() {
98
+ document.querySelectorAll('.photo-upload').forEach(upload => {
99
+ upload.addEventListener('click', (e) => {
100
+ const category = e.target.getAttribute('data-category');
101
+ this.selectPhotoFile(category);
102
  });
 
 
 
 
103
  });
104
  }
105
+
106
+ setupDragAndDrop() {
107
+ const audioUpload = document.getElementById('audioUpload');
 
 
 
 
 
108
 
109
+ audioUpload.addEventListener('dragover', (e) => {
110
+ e.preventDefault();
111
+ audioUpload.classList.add('dragover');
112
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ audioUpload.addEventListener('dragleave', () => {
115
+ audioUpload.classList.remove('dragover');
116
+ });
117
 
118
+ audioUpload.addEventListener('drop', (e) => {
119
+ e.preventDefault();
120
+ audioUpload.classList.remove('dragover');
121
+
122
+ const files = e.dataTransfer.files;
123
+ if (files.length > 0 && files[0].type.startsWith('audio/')) {
124
+ this.uploadAudioFile(files[0]);
125
+ }
126
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  }
128
+
129
+ async selectPhotoFile(category) {
130
+ const input = document.createElement('input');
131
+ input.type = 'file';
132
+ input.accept = 'image/*';
133
+ input.capture = 'environment'; // Use rear camera if available
 
134
 
135
+ input.onchange = (e) => {
136
+ const file = e.target.files[0];
137
+ if (file) {
138
+ this.uploadPhotoFile(file, category);
139
+ }
140
+ };
141
 
142
+ input.click();
143
  }
144
+
145
+ async capturePhoto(category) {
146
+ // For mobile devices, this will trigger the camera
147
+ const input = document.createElement('input');
148
+ input.type = 'file';
149
+ input.accept = 'image/*';
150
+ input.capture = 'environment';
151
 
152
+ input.onchange = (e) => {
153
+ const file = e.target.files[0];
154
+ if (file) {
155
+ this.uploadPhotoFile(file, category);
 
 
 
 
156
  }
157
+ };
158
 
159
+ input.click();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  }
161
+
162
+ async uploadPhotoFile(file, category) {
163
+ const formData = new FormData();
164
+ formData.append('file', file);
165
+ formData.append('category', category);
 
 
 
 
 
 
 
 
166
 
167
  try {
168
+ const response = await fetch('/api/upload/image', {
169
+ method: 'POST',
170
+ body: formData
 
 
 
 
171
  });
172
 
173
+ if (response.ok) {
174
+ const result = await response.json();
175
+ this.uploadedPhotos[category] = result.filename;
176
+
177
+ // Update UI
178
+ const resultDiv = document.getElementById(`photo-${category}`);
179
+ resultDiv.style.display = 'block';
180
+ resultDiv.innerHTML = `βœ… ${file.name} uploaded successfully`;
181
+ } else {
182
+ throw new Error('Upload failed');
183
+ }
184
  } catch (error) {
185
+ console.error('Error uploading photo:', error);
186
+ this.showMessage('Error uploading photo: ' + error.message, 'error');
 
187
  }
188
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
+ async selectAudioFile() {
191
+ const input = document.createElement('input');
192
+ input.type = 'file';
193
+ input.accept = 'audio/*';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
+ input.onchange = (e) => {
196
+ const file = e.target.files[0];
197
+ if (file) {
198
+ this.uploadAudioFile(file);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  }
200
  };
201
 
202
+ input.click();
 
 
203
  }
204
+
205
+ async uploadAudioFile(file) {
206
+ const formData = new FormData();
207
+ formData.append('file', file);
208
+
209
+ try {
210
+ const response = await fetch('/api/upload/audio', {
211
+ method: 'POST',
212
+ body: formData
213
+ });
214
+
215
+ if (response.ok) {
216
+ const result = await response.json();
217
+ this.audioFile = result.filename;
218
 
219
+ // Update UI
220
+ const resultDiv = document.getElementById('audioUploadResult');
221
+ resultDiv.innerHTML = `<div class="uploaded-file">βœ… ${file.name} uploaded successfully</div>`;
222
+ } else {
223
+ throw new Error('Upload failed');
 
 
 
 
224
  }
225
+ } catch (error) {
226
+ console.error('Error uploading audio:', error);
227
+ this.showMessage('Error uploading audio: ' + error.message, 'error');
228
  }
229
  }
230
+
231
+ async toggleRecording() {
232
+ if (!this.isRecording) {
233
+ await this.startRecording();
234
+ } else {
235
+ this.stopRecording();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  }
238
+
239
+ async startRecording() {
 
 
 
 
 
 
 
 
240
  try {
241
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
242
+ this.mediaRecorder = new MediaRecorder(stream);
243
+ this.audioChunks = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
+ this.mediaRecorder.ondataavailable = (event) => {
246
+ this.audioChunks.push(event.data);
 
247
  };
248
 
249
+ this.mediaRecorder.onstop = async () => {
250
+ const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
251
+ const audioFile = new File([audioBlob], 'recording.wav', { type: 'audio/wav' });
252
+ await this.uploadAudioFile(audioFile);
253
+
254
+ // Show playback
255
+ const audioElement = document.getElementById('audioPlayback');
256
+ audioElement.src = URL.createObjectURL(audioBlob);
257
+ audioElement.classList.remove('hidden');
258
  };
259
 
260
+ this.mediaRecorder.start();
261
+ this.isRecording = true;
262
+
263
+ // Update UI
264
+ const recordBtn = document.getElementById('recordBtn');
265
+ const status = document.getElementById('recordingStatus');
266
+ recordBtn.classList.add('recording');
267
+ recordBtn.innerHTML = '⏹️';
268
+ status.textContent = 'Recording... Click to stop';
269
 
270
  } catch (error) {
271
+ console.error('Error starting recording:', error);
272
+ this.showMessage('Error accessing microphone: ' + error.message, 'error');
273
  }
274
  }
275
+
276
+ stopRecording() {
277
+ if (this.mediaRecorder && this.isRecording) {
278
+ this.mediaRecorder.stop();
279
+ this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
280
+ this.isRecording = false;
281
+
282
+ // Update UI
283
+ const recordBtn = document.getElementById('recordBtn');
284
+ const status = document.getElementById('recordingStatus');
285
+ recordBtn.classList.remove('recording');
286
+ recordBtn.innerHTML = '🎀';
287
+ status.textContent = 'Recording saved!';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
 
289
  }
290
+
291
+ getCurrentLocation() {
292
+ if (navigator.geolocation) {
293
+ document.getElementById('getLocation').textContent = 'πŸ”„ Getting...';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
 
295
+ navigator.geolocation.getCurrentPosition(
296
+ (position) => {
297
+ document.getElementById('latitude').value = position.coords.latitude.toFixed(7);
298
+ document.getElementById('longitude').value = position.coords.longitude.toFixed(7);
299
+ document.getElementById('getLocation').textContent = 'πŸ“ GPS';
300
+ this.showMessage('Location retrieved successfully!', 'success');
301
+ },
302
+ (error) => {
303
+ document.getElementById('getLocation').textContent = 'πŸ“ GPS';
304
+ this.showMessage('Error getting location: ' + error.message, 'error');
305
  }
306
+ );
307
+ } else {
308
+ this.showMessage('Geolocation is not supported by this browser.', 'error');
309
+ }
310
+ }
311
+
312
+ getSelectedValues(containerId) {
313
+ const container = document.getElementById(containerId);
314
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked');
315
+ return Array.from(checkboxes).map(cb => cb.value);
316
+ }
317
+
318
+ async handleSubmit(e) {
319
+ e.preventDefault();
320
+
321
+ const treeData = {
322
+ latitude: parseFloat(document.getElementById('latitude').value),
323
+ longitude: parseFloat(document.getElementById('longitude').value),
324
+ local_name: document.getElementById('localName').value || null,
325
+ scientific_name: document.getElementById('scientificName').value || null,
326
+ common_name: document.getElementById('commonName').value || null,
327
+ tree_code: document.getElementById('treeCode').value || null,
328
+ height: document.getElementById('height').value ? parseFloat(document.getElementById('height').value) : null,
329
+ width: document.getElementById('width').value ? parseFloat(document.getElementById('width').value) : null,
330
+ utility: this.getSelectedValues('utilityOptions'),
331
+ phenology_stages: this.getSelectedValues('phenologyOptions'),
332
+ storytelling_text: document.getElementById('storytellingText').value || null,
333
+ storytelling_audio: this.audioFile,
334
+ photographs: Object.keys(this.uploadedPhotos).length > 0 ? this.uploadedPhotos : null,
335
+ notes: document.getElementById('notes').value || null
336
+ };
337
+
338
+ try {
339
+ const response = await fetch('/api/trees', {
340
+ method: 'POST',
341
+ headers: {
342
+ 'Content-Type': 'application/json',
343
+ },
344
+ body: JSON.stringify(treeData)
345
  });
346
 
347
+ if (response.ok) {
348
+ const result = await response.json();
349
+ this.showMessage(`Tree record saved successfully! ID: ${result.id}`, 'success');
350
+ this.resetForm();
351
+ this.loadTrees(); // Refresh the tree list
352
+ } else {
353
+ const error = await response.json();
354
+ this.showMessage('Error saving tree: ' + (error.detail || 'Unknown error'), 'error');
355
  }
 
 
 
356
  } catch (error) {
357
+ console.error('Error submitting form:', error);
358
+ this.showMessage('Network error: ' + error.message, 'error');
359
  }
360
  }
361
+
362
+ resetForm() {
363
+ document.getElementById('treeForm').reset();
364
+ this.uploadedPhotos = {};
365
+ this.audioFile = null;
366
 
367
+ // Clear uploaded file indicators
368
+ document.querySelectorAll('.uploaded-file').forEach(el => {
369
+ el.style.display = 'none';
370
+ el.innerHTML = '';
371
+ });
372
 
373
+ // Reset audio controls
374
+ const audioElement = document.getElementById('audioPlayback');
375
+ audioElement.classList.add('hidden');
376
+ audioElement.src = '';
377
 
378
+ document.getElementById('recordingStatus').textContent = 'Click to start recording';
379
+ document.getElementById('audioUploadResult').innerHTML = '';
380
+
381
+ this.showMessage('Form reset successfully!', 'success');
382
+ }
383
+
384
+ async loadTrees() {
385
  try {
386
+ const response = await fetch('/api/trees?limit=20');
387
+ const trees = await response.json();
388
 
389
+ const treeList = document.getElementById('treeList');
 
 
390
 
391
+ if (trees.length === 0) {
392
+ treeList.innerHTML = '<div class="loading">No trees recorded yet</div>';
393
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
394
  }
395
 
396
+ treeList.innerHTML = trees.map(tree => `
397
+ <div class="tree-item">
398
+ <div class="tree-id">Tree #${tree.id}</div>
399
+ <div class="tree-info">
400
+ ${tree.scientific_name || tree.common_name || tree.local_name || 'Unnamed'}
401
+ <br>πŸ“ ${tree.latitude.toFixed(4)}, ${tree.longitude.toFixed(4)}
402
+ ${tree.tree_code ? '<br>🏷️ ' + tree.tree_code : ''}
403
+ <br>πŸ“… ${new Date(tree.timestamp).toLocaleDateString()}
404
+ </div>
405
+ </div>
406
+ `).join('');
407
+
408
  } catch (error) {
409
+ console.error('Error loading trees:', error);
410
+ document.getElementById('treeList').innerHTML = '<div class="loading">Error loading trees</div>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  }
413
 
414
+ showMessage(message, type) {
415
+ const messageDiv = document.getElementById('message');
416
+ messageDiv.className = type === 'error' ? 'error-message' : 'success-message';
417
+ messageDiv.textContent = message;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
 
419
+ // Auto-hide after 5 seconds
420
+ setTimeout(() => {
421
+ messageDiv.textContent = '';
422
+ messageDiv.className = '';
423
+ }, 5000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  }
425
  }
426
 
427
+ // Initialize the app when the page loads
428
+ let app;
429
  document.addEventListener('DOMContentLoaded', () => {
430
+ app = new TreeTrackApp();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  });
static/index.html CHANGED
@@ -3,360 +3,539 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title> Tree Mapping Application</title>
7
-
8
- <!-- Leaflet CSS -->
9
- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
10
-
11
- <!-- Plotly.js -->
12
- <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
13
-
14
- <!-- Custom CSS -->
15
  <style>
16
  * {
17
  margin: 0;
18
  padding: 0;
19
  box-sizing: border-box;
20
  }
21
-
22
  body {
23
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
24
- background-color: #f5f5f5;
25
  color: #333;
 
 
 
26
  }
27
-
28
  .header {
29
- background: linear-gradient(135deg, #2d5a27, #4a7c59);
30
- color: white;
31
- padding: 1rem;
32
  text-align: center;
33
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 
 
 
 
 
 
 
 
34
  }
35
-
 
 
 
 
 
36
  .container {
 
 
37
  display: grid;
38
- grid-template-columns: 300px 1fr 300px;
39
- gap: 1rem;
40
- padding: 1rem;
41
- height: calc(100vh - 80px);
 
 
 
 
42
  }
43
-
44
- .sidebar {
45
  background: white;
46
- border-radius: 8px;
47
- padding: 1rem;
48
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 
49
  overflow-y: auto;
50
  }
51
-
52
- .main-content {
53
  background: white;
54
- border-radius: 8px;
55
- padding: 1rem;
56
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
57
- display: flex;
58
- flex-direction: column;
59
  }
60
-
61
- #map {
62
- flex: 1;
63
- border-radius: 8px;
64
- min-height: 400px;
 
 
 
 
 
 
 
 
 
 
 
 
65
  }
66
-
67
  .form-group {
68
- margin-bottom: 1rem;
69
  }
70
-
71
- .form-group label {
 
 
 
 
 
 
 
 
 
 
 
 
72
  display: block;
73
- margin-bottom: 0.5rem;
74
  font-weight: 600;
75
- color: #2d5a27;
 
76
  }
77
-
78
- .form-group input,
79
- .form-group select,
80
- .form-group textarea {
81
  width: 100%;
82
- padding: 0.5rem;
83
- border: 1px solid #ddd;
84
- border-radius: 4px;
85
- font-size: 14px;
 
 
86
  }
87
-
88
- .form-group textarea {
 
 
 
 
 
 
89
  resize: vertical;
90
- min-height: 60px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  }
92
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  .btn {
94
- background: #4a7c59;
95
- color: white;
96
  border: none;
97
- padding: 0.75rem 1rem;
98
- border-radius: 4px;
99
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  font-size: 14px;
101
- transition: background-color 0.3s;
102
  }
103
-
104
- .btn:hover {
105
- background: #2d5a27;
 
 
106
  }
107
-
108
- .btn-danger {
109
- background: #dc3545;
110
  }
111
-
112
- .btn-danger:hover {
113
- background: #c82333;
114
- }
115
-
116
- .stats-card {
117
- background: #f8f9fa;
118
- padding: 1rem;
119
- border-radius: 4px;
120
- margin-bottom: 1rem;
121
- border-left: 4px solid #4a7c59;
122
- }
123
-
124
- .stats-number {
125
- font-size: 2rem;
126
- font-weight: bold;
127
- color: #2d5a27;
128
  }
129
-
130
- .stats-label {
131
- font-size: 0.9rem;
132
- color: #666;
 
 
 
 
 
 
 
 
133
  }
134
-
 
 
 
 
 
 
135
  .tree-list {
136
- max-height: 300px;
137
  overflow-y: auto;
 
138
  }
139
-
140
  .tree-item {
141
- padding: 0.5rem;
142
- border-bottom: 1px solid #eee;
143
- cursor: pointer;
144
- transition: background-color 0.3s;
 
 
145
  }
146
-
147
  .tree-item:hover {
148
- background-color: #f8f9fa;
149
- }
150
-
151
- .tree-item.selected {
152
- background-color: #e8f5e8;
153
- border-left: 3px solid #4a7c59;
154
  }
155
-
156
- .tree-species {
157
- font-weight: 600;
158
- color: #2d5a27;
 
159
  }
160
-
161
- .tree-details {
 
162
  font-size: 0.9rem;
 
 
 
 
 
 
163
  color: #666;
164
  }
165
-
166
- .health-good { color: #28a745; }
167
- .health-fair { color: #ffc107; }
168
- .health-poor { color: #dc3545; }
169
- .health-dead { color: #6c757d; }
170
-
171
- #treemap {
172
- height: 300px;
173
- margin-top: 1rem;
174
- }
175
-
176
- .modal {
177
- display: none;
178
- position: fixed;
179
- z-index: 1000;
180
- left: 0;
181
- top: 0;
182
- width: 100%;
183
- height: 100%;
184
- background-color: rgba(0,0,0,0.5);
185
- }
186
-
187
- .modal-content {
188
- background-color: white;
189
- margin: 5% auto;
190
- padding: 2rem;
191
- border-radius: 8px;
192
- width: 90%;
193
- max-width: 500px;
194
- max-height: 80vh;
195
- overflow-y: auto;
196
  }
197
-
198
- .close {
199
- color: #aaa;
200
- float: right;
201
- font-size: 28px;
202
- font-weight: bold;
 
 
 
203
  cursor: pointer;
204
  }
205
-
206
- .close:hover {
207
- color: #000;
 
208
  }
209
-
210
  @keyframes pulse {
211
- 0% {
212
- transform: scale(1);
213
- opacity: 1;
214
- }
215
- 50% {
216
- transform: scale(1.1);
217
- opacity: 0.8;
218
- }
219
- 100% {
220
- transform: scale(1);
221
- opacity: 1;
222
- }
223
  }
224
-
225
- @media (max-width: 1024px) {
226
- .container {
227
- grid-template-columns: 1fr;
228
- grid-template-rows: auto auto auto;
229
- }
230
-
231
- .sidebar {
232
- order: 2;
 
 
 
 
 
 
 
 
233
  }
234
 
235
- .main-content {
236
- order: 1;
237
  }
238
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  </style>
240
  </head>
241
  <body>
242
  <div class="header">
243
- <h1>🌳 Tree Mapping Application</h1>
244
- <p>Track, map, and manage urban forest data</p>
245
  </div>
246
-
247
  <div class="container">
248
- <!-- Left Sidebar - Tree Entry Form -->
249
- <div class="sidebar">
250
- <h3>Add New Tree</h3>
251
  <form id="treeForm">
252
- <div class="form-group">
253
- <label for="latitude">Latitude</label>
254
- <input type="number" id="latitude" step="0.000001" required>
255
- </div>
256
-
257
- <div class="form-group">
258
- <label for="longitude">Longitude</label>
259
- <input type="number" id="longitude" step="0.000001" required>
 
 
 
 
 
 
 
 
260
  </div>
261
-
262
- <div class="form-group">
263
- <label for="species">Species (Scientific Name)</label>
264
- <input type="text" id="species" required placeholder="e.g., Quercus robur">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  </div>
266
-
267
- <div class="form-group">
268
- <label for="common_name">Common Name</label>
269
- <input type="text" id="common_name" placeholder="e.g., English Oak">
 
 
 
 
 
 
 
 
 
 
270
  </div>
271
-
272
- <div class="form-group">
273
- <label for="height">Height (meters)</label>
274
- <input type="number" id="height" step="0.1" min="0">
 
 
 
 
 
 
275
  </div>
276
-
277
- <div class="form-group">
278
- <label for="diameter">Diameter (cm)</label>
279
- <input type="number" id="diameter" step="0.1" min="0">
 
 
 
 
 
 
280
  </div>
281
-
282
- <div class="form-group">
283
- <label for="health_status">Health Status</label>
284
- <select id="health_status">
285
- <option value="Excellent">Excellent</option>
286
- <option value="Good" selected>Good</option>
287
- <option value="Fair">Fair</option>
288
- <option value="Poor">Poor</option>
289
- <option value="Dead">Dead</option>
290
- </select>
291
  </div>
292
-
293
- <div class="form-group">
294
- <label for="age_estimate">Age Estimate (years)</label>
295
- <input type="number" id="age_estimate" min="0">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  </div>
297
-
298
-
299
- <div class="form-group">
300
- <label for="last_inspection">Last Inspection</label>
301
- <input type="date" id="last_inspection">
 
 
 
302
  </div>
303
-
304
- <div class="form-group">
305
- <label for="notes">Notes</label>
306
- <textarea id="notes" placeholder="Additional observations..."></textarea>
307
  </div>
308
-
309
- <button type="submit" class="btn">Add Tree</button>
310
- <button type="button" id="clearForm" class="btn" style="background: #6c757d; margin-left: 0.5rem;">Clear</button>
311
  </form>
 
 
312
  </div>
313
-
314
- <!-- Main Content - Map -->
315
- <div class="main-content">
316
- <h3>Tree Locations Map</h3>
317
- <div id="map"></div>
318
- </div>
319
-
320
- <!-- Right Sidebar - Statistics and Tree List -->
321
- <div class="sidebar">
322
- <h3>Statistics</h3>
323
- <div id="stats">
324
- <div class="stats-card">
325
- <div class="stats-number" id="totalTrees">0</div>
326
- <div class="stats-label">Total Trees</div>
327
- </div>
328
- <div class="stats-card">
329
- <div class="stats-number" id="avgHeight">0</div>
330
- <div class="stats-label">Avg Height (m)</div>
331
- </div>
332
- <div class="stats-card">
333
- <div class="stats-number" id="avgDiameter">0</div>
334
- <div class="stats-label">Avg Diameter (cm)</div>
335
- </div>
336
  </div>
337
-
338
- <div id="treemap"></div>
339
-
340
- <h3>Recent Trees</h3>
341
- <div id="treeList" class="tree-list"></div>
342
- </div>
343
- </div>
344
-
345
- <!-- Tree Details Modal -->
346
- <div id="treeModal" class="modal">
347
- <div class="modal-content">
348
- <span class="close">&times;</span>
349
- <h3>Tree Details</h3>
350
- <div id="treeDetails"></div>
351
- <button id="editTree" class="btn">Edit</button>
352
- <button id="deleteTree" class="btn btn-danger" style="margin-left: 0.5rem;">Delete</button>
353
  </div>
354
  </div>
355
-
356
- <!-- Leaflet JS -->
357
- <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
358
-
359
- <!-- Custom JavaScript -->
360
- <script src="/static/app.js"></script>
361
  </body>
362
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>🌳 TreeTrack - Field Research Tool</title>
 
 
 
 
 
 
 
 
7
  <style>
8
  * {
9
  margin: 0;
10
  padding: 0;
11
  box-sizing: border-box;
12
  }
13
+
14
  body {
15
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ line-height: 1.6;
17
  color: #333;
18
+ background: linear-gradient(135deg, #2c5530 0%, #1a3a1c 100%);
19
+ min-height: 100vh;
20
+ padding: 10px;
21
  }
22
+
23
  .header {
 
 
 
24
  text-align: center;
25
+ color: white;
26
+ margin-bottom: 20px;
27
+ padding: 20px;
28
+ }
29
+
30
+ .header h1 {
31
+ font-size: 2.5rem;
32
+ margin-bottom: 10px;
33
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
34
  }
35
+
36
+ .header p {
37
+ font-size: 1.1rem;
38
+ opacity: 0.9;
39
+ }
40
+
41
  .container {
42
+ max-width: 1200px;
43
+ margin: 0 auto;
44
  display: grid;
45
+ grid-template-columns: 1fr;
46
+ gap: 20px;
47
+ }
48
+
49
+ @media (min-width: 768px) {
50
+ .container {
51
+ grid-template-columns: 1fr 400px;
52
+ }
53
  }
54
+
55
+ .form-container {
56
  background: white;
57
+ border-radius: 15px;
58
+ padding: 25px;
59
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
60
+ max-height: 80vh;
61
  overflow-y: auto;
62
  }
63
+
64
+ .map-container {
65
  background: white;
66
+ border-radius: 15px;
67
+ padding: 20px;
68
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
69
+ height: fit-content;
 
70
  }
71
+
72
+ .form-section {
73
+ margin-bottom: 30px;
74
+ border: 1px solid #e0e0e0;
75
+ border-radius: 10px;
76
+ padding: 20px;
77
+ }
78
+
79
+ .section-title {
80
+ font-size: 1.3rem;
81
+ color: #2c5530;
82
+ margin-bottom: 15px;
83
+ padding-bottom: 8px;
84
+ border-bottom: 2px solid #e8f5e8;
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 10px;
88
  }
89
+
90
  .form-group {
91
+ margin-bottom: 20px;
92
  }
93
+
94
+ .form-row {
95
+ display: grid;
96
+ grid-template-columns: 1fr;
97
+ gap: 15px;
98
+ }
99
+
100
+ @media (min-width: 600px) {
101
+ .form-row {
102
+ grid-template-columns: 1fr 1fr;
103
+ }
104
+ }
105
+
106
+ label {
107
  display: block;
108
+ margin-bottom: 5px;
109
  font-weight: 600;
110
+ color: #2c5530;
111
+ font-size: 0.95rem;
112
  }
113
+
114
+ input, textarea, select {
 
 
115
  width: 100%;
116
+ padding: 12px 15px;
117
+ border: 2px solid #e0e0e0;
118
+ border-radius: 8px;
119
+ font-size: 16px;
120
+ transition: all 0.3s ease;
121
+ background: white;
122
  }
123
+
124
+ input:focus, textarea:focus, select:focus {
125
+ outline: none;
126
+ border-color: #2c5530;
127
+ box-shadow: 0 0 0 3px rgba(44, 85, 48, 0.1);
128
+ }
129
+
130
+ textarea {
131
  resize: vertical;
132
+ min-height: 100px;
133
+ }
134
+
135
+ .multi-select {
136
+ border: 2px solid #e0e0e0;
137
+ border-radius: 8px;
138
+ padding: 10px;
139
+ max-height: 120px;
140
+ overflow-y: auto;
141
+ }
142
+
143
+ .multi-select label {
144
+ display: flex;
145
+ align-items: center;
146
+ margin-bottom: 8px;
147
+ font-weight: normal;
148
+ cursor: pointer;
149
+ padding: 5px;
150
+ border-radius: 5px;
151
+ transition: background-color 0.2s;
152
  }
153
+
154
+ .multi-select label:hover {
155
+ background-color: #f5f5f5;
156
+ }
157
+
158
+ .multi-select input[type="checkbox"] {
159
+ width: auto;
160
+ margin-right: 10px;
161
+ }
162
+
163
+ .file-upload {
164
+ border: 2px dashed #ccc;
165
+ border-radius: 8px;
166
+ padding: 20px;
167
+ text-align: center;
168
+ cursor: pointer;
169
+ transition: all 0.3s ease;
170
+ background: #fafafa;
171
+ margin-top: 10px;
172
+ }
173
+
174
+ .file-upload:hover {
175
+ border-color: #2c5530;
176
+ background: #f0f7f0;
177
+ }
178
+
179
+ .file-upload.dragover {
180
+ border-color: #2c5530;
181
+ background: #e8f5e8;
182
+ }
183
+
184
+ .photo-category {
185
+ display: grid;
186
+ grid-template-columns: 1fr auto;
187
+ gap: 10px;
188
+ align-items: center;
189
+ margin-bottom: 15px;
190
+ padding: 10px;
191
+ border: 1px solid #e0e0e0;
192
+ border-radius: 8px;
193
+ }
194
+
195
  .btn {
196
+ padding: 12px 25px;
 
197
  border: none;
198
+ border-radius: 8px;
 
199
  cursor: pointer;
200
+ font-size: 16px;
201
+ font-weight: 600;
202
+ transition: all 0.3s ease;
203
+ text-transform: uppercase;
204
+ letter-spacing: 0.5px;
205
+ }
206
+
207
+ .btn-primary {
208
+ background: linear-gradient(45deg, #2c5530, #4a7c59);
209
+ color: white;
210
+ box-shadow: 0 4px 15px rgba(44, 85, 48, 0.3);
211
+ }
212
+
213
+ .btn-primary:hover {
214
+ transform: translateY(-2px);
215
+ box-shadow: 0 6px 20px rgba(44, 85, 48, 0.4);
216
+ }
217
+
218
+ .btn-secondary {
219
+ background: #6c757d;
220
+ color: white;
221
+ }
222
+
223
+ .btn-secondary:hover {
224
+ background: #545b62;
225
+ }
226
+
227
+ .btn-small {
228
+ padding: 8px 15px;
229
  font-size: 14px;
 
230
  }
231
+
232
+ .gps-btn {
233
+ background: #17a2b8;
234
+ color: white;
235
+ margin-left: 10px;
236
  }
237
+
238
+ .gps-btn:hover {
239
+ background: #138496;
240
  }
241
+
242
+ .current-location {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
246
  }
247
+
248
+ .success-message, .error-message {
249
+ padding: 15px;
250
+ border-radius: 8px;
251
+ margin: 15px 0;
252
+ font-weight: 600;
253
+ }
254
+
255
+ .success-message {
256
+ background: #d4edda;
257
+ color: #155724;
258
+ border: 1px solid #c3e6cb;
259
  }
260
+
261
+ .error-message {
262
+ background: #f8d7da;
263
+ color: #721c24;
264
+ border: 1px solid #f5c6cb;
265
+ }
266
+
267
  .tree-list {
268
+ max-height: 400px;
269
  overflow-y: auto;
270
+ margin-top: 20px;
271
  }
272
+
273
  .tree-item {
274
+ padding: 15px;
275
+ border: 1px solid #e0e0e0;
276
+ border-radius: 8px;
277
+ margin-bottom: 10px;
278
+ background: white;
279
+ transition: all 0.3s ease;
280
  }
281
+
282
  .tree-item:hover {
283
+ box-shadow: 0 4px 15px rgba(0,0,0,0.1);
284
+ transform: translateY(-2px);
 
 
 
 
285
  }
286
+
287
+ .tree-id {
288
+ font-weight: bold;
289
+ color: #2c5530;
290
+ font-size: 1.1rem;
291
  }
292
+
293
+ .tree-info {
294
+ color: #666;
295
  font-size: 0.9rem;
296
+ margin-top: 5px;
297
+ }
298
+
299
+ .loading {
300
+ text-align: center;
301
+ padding: 20px;
302
  color: #666;
303
  }
304
+
305
+ .audio-controls {
306
+ display: flex;
307
+ gap: 10px;
308
+ align-items: center;
309
+ margin-top: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  }
311
+
312
+ .record-btn {
313
+ background: #dc3545;
314
+ color: white;
315
+ border: none;
316
+ border-radius: 50%;
317
+ width: 50px;
318
+ height: 50px;
319
+ font-size: 18px;
320
  cursor: pointer;
321
  }
322
+
323
+ .record-btn.recording {
324
+ background: #28a745;
325
+ animation: pulse 1s infinite;
326
  }
327
+
328
  @keyframes pulse {
329
+ 0% { transform: scale(1); }
330
+ 50% { transform: scale(1.1); }
331
+ 100% { transform: scale(1); }
 
 
 
 
 
 
 
 
 
332
  }
333
+
334
+ .hidden {
335
+ display: none;
336
+ }
337
+
338
+ .form-actions {
339
+ display: flex;
340
+ gap: 15px;
341
+ justify-content: center;
342
+ margin-top: 30px;
343
+ padding-top: 20px;
344
+ border-top: 1px solid #e0e0e0;
345
+ }
346
+
347
+ @media (max-width: 600px) {
348
+ .form-actions {
349
+ flex-direction: column;
350
  }
351
 
352
+ .btn {
353
+ width: 100%;
354
  }
355
  }
356
+
357
+ /* Auto-complete styling */
358
+ .autocomplete-container {
359
+ position: relative;
360
+ }
361
+
362
+ .autocomplete-list {
363
+ position: absolute;
364
+ top: 100%;
365
+ left: 0;
366
+ right: 0;
367
+ background: white;
368
+ border: 1px solid #ccc;
369
+ border-radius: 0 0 8px 8px;
370
+ max-height: 200px;
371
+ overflow-y: auto;
372
+ z-index: 1000;
373
+ display: none;
374
+ }
375
+
376
+ .autocomplete-item {
377
+ padding: 10px 15px;
378
+ cursor: pointer;
379
+ border-bottom: 1px solid #eee;
380
+ }
381
+
382
+ .autocomplete-item:hover {
383
+ background: #f5f5f5;
384
+ }
385
+
386
+ .autocomplete-item.selected {
387
+ background: #e8f5e8;
388
+ }
389
+
390
+ .uploaded-file {
391
+ margin-top: 10px;
392
+ padding: 10px;
393
+ background: #e8f5e8;
394
+ border-radius: 5px;
395
+ font-size: 14px;
396
+ }
397
  </style>
398
  </head>
399
  <body>
400
  <div class="header">
401
+ <h1>🌳 TreeTrack</h1>
402
+ <p>Comprehensive Field Research & Documentation Tool</p>
403
  </div>
404
+
405
  <div class="container">
406
+ <div class="form-container">
 
 
407
  <form id="treeForm">
408
+ <!-- Section 1: Location -->
409
+ <div class="form-section">
410
+ <h3 class="section-title">πŸ“ Location</h3>
411
+ <div class="form-row">
412
+ <div class="form-group">
413
+ <label for="latitude">Latitude *</label>
414
+ <div class="current-location">
415
+ <input type="number" id="latitude" step="0.0000001" min="-90" max="90" required>
416
+ <button type="button" id="getLocation" class="btn btn-small gps-btn">πŸ“ GPS</button>
417
+ </div>
418
+ </div>
419
+ <div class="form-group">
420
+ <label for="longitude">Longitude *</label>
421
+ <input type="number" id="longitude" step="0.0000001" min="-180" max="180" required>
422
+ </div>
423
+ </div>
424
  </div>
425
+
426
+ <!-- Section 2: Identification -->
427
+ <div class="form-section">
428
+ <h3 class="section-title">🏷️ Tree Identification</h3>
429
+ <div class="form-group">
430
+ <label for="localName">Local Name (Assamese)</label>
431
+ <input type="text" id="localName" placeholder="Enter local Assamese name">
432
+ </div>
433
+ <div class="form-group">
434
+ <label for="scientificName">Scientific Name</label>
435
+ <input type="text" id="scientificName" placeholder="e.g., Ficus benghalensis">
436
+ </div>
437
+ <div class="form-group">
438
+ <label for="commonName">Common Name</label>
439
+ <input type="text" id="commonName" placeholder="e.g., Banyan Tree">
440
+ </div>
441
+ <div class="form-group">
442
+ <label for="treeCode">Tree Code</label>
443
+ <input type="text" id="treeCode" placeholder="e.g., C.A, A-G1" maxlength="20">
444
+ </div>
445
  </div>
446
+
447
+ <!-- Section 3: Measurements -->
448
+ <div class="form-section">
449
+ <h3 class="section-title">πŸ“ Physical Measurements</h3>
450
+ <div class="form-row">
451
+ <div class="form-group">
452
+ <label for="height">Height (meters)</label>
453
+ <input type="number" id="height" step="0.1" min="0" max="200" placeholder="15.5">
454
+ </div>
455
+ <div class="form-group">
456
+ <label for="width">Width/Girth (cm)</label>
457
+ <input type="number" id="width" step="0.1" min="0" max="2000" placeholder="45.2">
458
+ </div>
459
+ </div>
460
  </div>
461
+
462
+ <!-- Section 4: Utility -->
463
+ <div class="form-section">
464
+ <h3 class="section-title">🌍 Ecological & Cultural Utility</h3>
465
+ <div class="form-group">
466
+ <label>Select applicable utilities:</label>
467
+ <div id="utilityOptions" class="multi-select">
468
+ <!-- Options loaded dynamically -->
469
+ </div>
470
+ </div>
471
  </div>
472
+
473
+ <!-- Section 5: Phenology -->
474
+ <div class="form-section">
475
+ <h3 class="section-title">🌱 Phenology Tracker</h3>
476
+ <div class="form-group">
477
+ <label>Current development stages:</label>
478
+ <div id="phenologyOptions" class="multi-select">
479
+ <!-- Options loaded dynamically -->
480
+ </div>
481
+ </div>
482
  </div>
483
+
484
+ <!-- Section 6: Photography -->
485
+ <div class="form-section">
486
+ <h3 class="section-title">πŸ“Έ Photography</h3>
487
+ <div id="photoCategories">
488
+ <!-- Photo categories loaded dynamically -->
489
+ </div>
 
 
 
490
  </div>
491
+
492
+ <!-- Section 7: Storytelling -->
493
+ <div class="form-section">
494
+ <h3 class="section-title">πŸ“œ Storytelling</h3>
495
+ <div class="form-group">
496
+ <label for="storytellingText">Stories, Histories & Narratives</label>
497
+ <textarea id="storytellingText" placeholder="Share any stories, historical context, or cultural significance..." maxlength="5000"></textarea>
498
+ </div>
499
+ <div class="form-group">
500
+ <label>Audio Recording</label>
501
+ <div class="audio-controls">
502
+ <button type="button" id="recordBtn" class="record-btn" title="Record Audio">🎀</button>
503
+ <span id="recordingStatus">Click to start recording</span>
504
+ <audio id="audioPlayback" controls class="hidden"></audio>
505
+ </div>
506
+ <div class="file-upload" id="audioUpload">
507
+ πŸ“Ž Click to upload audio file or drag and drop
508
+ </div>
509
+ <div id="audioUploadResult"></div>
510
+ </div>
511
  </div>
512
+
513
+ <!-- Section 8: Notes -->
514
+ <div class="form-section">
515
+ <h3 class="section-title">πŸ“ Additional Notes</h3>
516
+ <div class="form-group">
517
+ <label for="notes">Field Observations</label>
518
+ <textarea id="notes" placeholder="Any additional observations, notes, or remarks..." maxlength="2000"></textarea>
519
+ </div>
520
  </div>
521
+
522
+ <div class="form-actions">
523
+ <button type="submit" class="btn btn-primary">🌳 Save Tree Record</button>
524
+ <button type="button" id="resetForm" class="btn btn-secondary">πŸ”„ Reset Form</button>
525
  </div>
 
 
 
526
  </form>
527
+
528
+ <div id="message"></div>
529
  </div>
530
+
531
+ <div class="map-container">
532
+ <h3>πŸ“Š Recent Trees</h3>
533
+ <div id="treeList" class="tree-list">
534
+ <div class="loading">Loading trees...</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  </div>
537
  </div>
538
+
539
+ <script src="app.js"></script>
 
 
 
 
540
  </body>
541
  </html>