Spaces:
Running
Running
Add immersive interactive map view with mobile optimization
Browse files- Created dedicated map.html with full-screen interactive map using Leaflet
- Implemented pin management: tap/click to drop pins, location auto-population
- Added mobile-optimized gestures: pinch zoom, pan, long press support
- Created floating controls and bottom info panel with smooth animations
- Added location sharing between map and form via localStorage
- Implemented PWA service worker for offline capabilities
- Added navigation between map and form views
- Mobile-responsive design with touch-friendly controls
- GPS location detection and user location marker
- Real-time tree markers with popups showing tree information
- Deep linking support with URL hash for location sharing
- Haptic feedback on mobile devices
- static/app.js +21 -0
- static/index.html +10 -2
- static/map.html +398 -0
- static/map.js +501 -0
- static/sw.js +159 -0
static/app.js
CHANGED
@@ -14,6 +14,7 @@ class TreeTrackApp {
|
|
14 |
this.loadFormOptions();
|
15 |
this.setupEventListeners();
|
16 |
this.loadTrees();
|
|
|
17 |
}
|
18 |
|
19 |
async loadFormOptions() {
|
@@ -94,6 +95,26 @@ class TreeTrackApp {
|
|
94 |
this.setupDragAndDrop();
|
95 |
}
|
96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
97 |
setupPhotoUploads() {
|
98 |
document.querySelectorAll('.photo-upload').forEach(upload => {
|
99 |
upload.addEventListener('click', (e) => {
|
|
|
14 |
this.loadFormOptions();
|
15 |
this.setupEventListeners();
|
16 |
this.loadTrees();
|
17 |
+
this.loadSelectedLocation();
|
18 |
}
|
19 |
|
20 |
async loadFormOptions() {
|
|
|
95 |
this.setupDragAndDrop();
|
96 |
}
|
97 |
|
98 |
+
loadSelectedLocation() {
|
99 |
+
// Load location from map selection
|
100 |
+
const selectedLocation = localStorage.getItem('selectedLocation');
|
101 |
+
if (selectedLocation) {
|
102 |
+
try {
|
103 |
+
const location = JSON.parse(selectedLocation);
|
104 |
+
document.getElementById('latitude').value = location.lat.toFixed(6);
|
105 |
+
document.getElementById('longitude').value = location.lng.toFixed(6);
|
106 |
+
|
107 |
+
// Clear the stored location
|
108 |
+
localStorage.removeItem('selectedLocation');
|
109 |
+
|
110 |
+
// Show success message
|
111 |
+
this.showMessage('Location loaded from map!', 'success');
|
112 |
+
} catch (error) {
|
113 |
+
console.error('Error loading selected location:', error);
|
114 |
+
}
|
115 |
+
}
|
116 |
+
}
|
117 |
+
|
118 |
setupPhotoUploads() {
|
119 |
document.querySelectorAll('.photo-upload').forEach(upload => {
|
120 |
upload.addEventListener('click', (e) => {
|
static/index.html
CHANGED
@@ -398,8 +398,13 @@
|
|
398 |
</head>
|
399 |
<body>
|
400 |
<div class="header">
|
401 |
-
<
|
402 |
-
|
|
|
|
|
|
|
|
|
|
|
403 |
</div>
|
404 |
|
405 |
<div class="container">
|
@@ -421,6 +426,9 @@
|
|
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 -->
|
|
|
398 |
</head>
|
399 |
<body>
|
400 |
<div class="header">
|
401 |
+
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
|
402 |
+
<div>
|
403 |
+
<h1 style="margin: 0; font-size: 2rem;">🌳 TreeTrack</h1>
|
404 |
+
<p style="margin: 5px 0 0 0; font-size: 1rem;">Field Research Tool</p>
|
405 |
+
</div>
|
406 |
+
<a href="/static/map.html" class="btn" style="background: rgba(255,255,255,0.2); color: white; text-decoration: none;">🗺️ Map View</a>
|
407 |
+
</div>
|
408 |
</div>
|
409 |
|
410 |
<div class="container">
|
|
|
426 |
<input type="number" id="longitude" step="0.0000001" min="-180" max="180" required>
|
427 |
</div>
|
428 |
</div>
|
429 |
+
<div class="form-group">
|
430 |
+
<a href="/static/map.html" class="btn btn-secondary" style="width: 100%; text-align: center; display: block; text-decoration: none;">🗺️ Select from Interactive Map</a>
|
431 |
+
</div>
|
432 |
</div>
|
433 |
|
434 |
<!-- Section 2: Identification -->
|
static/map.html
ADDED
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>🗺️ TreeTrack Map - Interactive Field View</title>
|
7 |
+
|
8 |
+
<!-- Leaflet CSS -->
|
9 |
+
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
10 |
+
|
11 |
+
<style>
|
12 |
+
* {
|
13 |
+
margin: 0;
|
14 |
+
padding: 0;
|
15 |
+
box-sizing: border-box;
|
16 |
+
}
|
17 |
+
|
18 |
+
body {
|
19 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
20 |
+
background: #1a1a1a;
|
21 |
+
color: white;
|
22 |
+
height: 100vh;
|
23 |
+
overflow: hidden;
|
24 |
+
}
|
25 |
+
|
26 |
+
.app-container {
|
27 |
+
height: 100vh;
|
28 |
+
display: flex;
|
29 |
+
flex-direction: column;
|
30 |
+
}
|
31 |
+
|
32 |
+
/* Header */
|
33 |
+
.header {
|
34 |
+
background: linear-gradient(135deg, #2c5530 0%, #1a3a1c 100%);
|
35 |
+
padding: 15px 20px;
|
36 |
+
display: flex;
|
37 |
+
justify-content: space-between;
|
38 |
+
align-items: center;
|
39 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
40 |
+
z-index: 1000;
|
41 |
+
}
|
42 |
+
|
43 |
+
.logo {
|
44 |
+
font-size: 1.5rem;
|
45 |
+
font-weight: bold;
|
46 |
+
display: flex;
|
47 |
+
align-items: center;
|
48 |
+
gap: 10px;
|
49 |
+
}
|
50 |
+
|
51 |
+
.header-actions {
|
52 |
+
display: flex;
|
53 |
+
gap: 10px;
|
54 |
+
align-items: center;
|
55 |
+
}
|
56 |
+
|
57 |
+
.btn {
|
58 |
+
padding: 8px 15px;
|
59 |
+
border: none;
|
60 |
+
border-radius: 20px;
|
61 |
+
cursor: pointer;
|
62 |
+
font-size: 14px;
|
63 |
+
font-weight: 600;
|
64 |
+
transition: all 0.3s ease;
|
65 |
+
text-decoration: none;
|
66 |
+
display: inline-flex;
|
67 |
+
align-items: center;
|
68 |
+
gap: 5px;
|
69 |
+
}
|
70 |
+
|
71 |
+
.btn-primary {
|
72 |
+
background: #4CAF50;
|
73 |
+
color: white;
|
74 |
+
}
|
75 |
+
|
76 |
+
.btn-primary:hover {
|
77 |
+
background: #45a049;
|
78 |
+
transform: translateY(-1px);
|
79 |
+
}
|
80 |
+
|
81 |
+
.btn-secondary {
|
82 |
+
background: rgba(255,255,255,0.2);
|
83 |
+
color: white;
|
84 |
+
backdrop-filter: blur(10px);
|
85 |
+
}
|
86 |
+
|
87 |
+
.btn-secondary:hover {
|
88 |
+
background: rgba(255,255,255,0.3);
|
89 |
+
}
|
90 |
+
|
91 |
+
/* Map Container */
|
92 |
+
.map-container {
|
93 |
+
flex: 1;
|
94 |
+
position: relative;
|
95 |
+
overflow: hidden;
|
96 |
+
}
|
97 |
+
|
98 |
+
#map {
|
99 |
+
width: 100%;
|
100 |
+
height: 100%;
|
101 |
+
z-index: 1;
|
102 |
+
}
|
103 |
+
|
104 |
+
/* Floating Controls */
|
105 |
+
.floating-controls {
|
106 |
+
position: absolute;
|
107 |
+
top: 20px;
|
108 |
+
right: 20px;
|
109 |
+
z-index: 1000;
|
110 |
+
display: flex;
|
111 |
+
flex-direction: column;
|
112 |
+
gap: 10px;
|
113 |
+
}
|
114 |
+
|
115 |
+
.control-panel {
|
116 |
+
background: rgba(0,0,0,0.8);
|
117 |
+
backdrop-filter: blur(10px);
|
118 |
+
border-radius: 15px;
|
119 |
+
padding: 15px;
|
120 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
121 |
+
border: 1px solid rgba(255,255,255,0.1);
|
122 |
+
}
|
123 |
+
|
124 |
+
.location-info {
|
125 |
+
position: absolute;
|
126 |
+
bottom: 20px;
|
127 |
+
left: 20px;
|
128 |
+
right: 20px;
|
129 |
+
z-index: 1000;
|
130 |
+
}
|
131 |
+
|
132 |
+
.info-panel {
|
133 |
+
background: rgba(0,0,0,0.9);
|
134 |
+
backdrop-filter: blur(15px);
|
135 |
+
border-radius: 15px;
|
136 |
+
padding: 20px;
|
137 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
138 |
+
border: 1px solid rgba(255,255,255,0.1);
|
139 |
+
transform: translateY(100%);
|
140 |
+
transition: transform 0.3s ease;
|
141 |
+
}
|
142 |
+
|
143 |
+
.info-panel.active {
|
144 |
+
transform: translateY(0);
|
145 |
+
}
|
146 |
+
|
147 |
+
.coordinates {
|
148 |
+
display: flex;
|
149 |
+
gap: 20px;
|
150 |
+
margin-bottom: 15px;
|
151 |
+
}
|
152 |
+
|
153 |
+
.coord-item {
|
154 |
+
flex: 1;
|
155 |
+
text-align: center;
|
156 |
+
}
|
157 |
+
|
158 |
+
.coord-label {
|
159 |
+
font-size: 12px;
|
160 |
+
opacity: 0.7;
|
161 |
+
margin-bottom: 5px;
|
162 |
+
}
|
163 |
+
|
164 |
+
.coord-value {
|
165 |
+
font-size: 18px;
|
166 |
+
font-weight: bold;
|
167 |
+
color: #4CAF50;
|
168 |
+
}
|
169 |
+
|
170 |
+
.quick-actions {
|
171 |
+
display: flex;
|
172 |
+
gap: 10px;
|
173 |
+
margin-top: 15px;
|
174 |
+
}
|
175 |
+
|
176 |
+
.quick-actions .btn {
|
177 |
+
flex: 1;
|
178 |
+
}
|
179 |
+
|
180 |
+
/* Tree Markers Info */
|
181 |
+
.tree-counter {
|
182 |
+
background: rgba(76, 175, 80, 0.9);
|
183 |
+
color: white;
|
184 |
+
padding: 10px 15px;
|
185 |
+
border-radius: 20px;
|
186 |
+
font-weight: bold;
|
187 |
+
display: flex;
|
188 |
+
align-items: center;
|
189 |
+
gap: 8px;
|
190 |
+
}
|
191 |
+
|
192 |
+
/* Mobile Optimizations */
|
193 |
+
@media (max-width: 768px) {
|
194 |
+
.header {
|
195 |
+
padding: 10px 15px;
|
196 |
+
}
|
197 |
+
|
198 |
+
.logo {
|
199 |
+
font-size: 1.3rem;
|
200 |
+
}
|
201 |
+
|
202 |
+
.floating-controls {
|
203 |
+
top: 10px;
|
204 |
+
right: 10px;
|
205 |
+
}
|
206 |
+
|
207 |
+
.control-panel {
|
208 |
+
padding: 10px;
|
209 |
+
}
|
210 |
+
|
211 |
+
.location-info {
|
212 |
+
bottom: 10px;
|
213 |
+
left: 10px;
|
214 |
+
right: 10px;
|
215 |
+
}
|
216 |
+
|
217 |
+
.coordinates {
|
218 |
+
flex-direction: column;
|
219 |
+
gap: 10px;
|
220 |
+
}
|
221 |
+
|
222 |
+
.quick-actions {
|
223 |
+
flex-direction: column;
|
224 |
+
}
|
225 |
+
|
226 |
+
.btn {
|
227 |
+
padding: 12px 20px;
|
228 |
+
font-size: 16px;
|
229 |
+
}
|
230 |
+
}
|
231 |
+
|
232 |
+
/* Custom Pin Styles */
|
233 |
+
.tree-pin {
|
234 |
+
background: #4CAF50;
|
235 |
+
border: 3px solid white;
|
236 |
+
border-radius: 50%;
|
237 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
238 |
+
}
|
239 |
+
|
240 |
+
.temp-pin {
|
241 |
+
background: #ff6b35;
|
242 |
+
border: 3px solid white;
|
243 |
+
border-radius: 50%;
|
244 |
+
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
245 |
+
animation: pulse 2s infinite;
|
246 |
+
}
|
247 |
+
|
248 |
+
@keyframes pulse {
|
249 |
+
0% { transform: scale(1); opacity: 1; }
|
250 |
+
50% { transform: scale(1.1); opacity: 0.7; }
|
251 |
+
100% { transform: scale(1); opacity: 1; }
|
252 |
+
}
|
253 |
+
|
254 |
+
/* Loading States */
|
255 |
+
.loading {
|
256 |
+
position: absolute;
|
257 |
+
top: 50%;
|
258 |
+
left: 50%;
|
259 |
+
transform: translate(-50%, -50%);
|
260 |
+
background: rgba(0,0,0,0.8);
|
261 |
+
padding: 20px 30px;
|
262 |
+
border-radius: 10px;
|
263 |
+
z-index: 2000;
|
264 |
+
}
|
265 |
+
|
266 |
+
.spinner {
|
267 |
+
border: 3px solid rgba(255,255,255,0.3);
|
268 |
+
border-top: 3px solid #4CAF50;
|
269 |
+
border-radius: 50%;
|
270 |
+
width: 30px;
|
271 |
+
height: 30px;
|
272 |
+
animation: spin 1s linear infinite;
|
273 |
+
margin: 0 auto 10px;
|
274 |
+
}
|
275 |
+
|
276 |
+
@keyframes spin {
|
277 |
+
0% { transform: rotate(0deg); }
|
278 |
+
100% { transform: rotate(360deg); }
|
279 |
+
}
|
280 |
+
|
281 |
+
/* Success/Error Messages */
|
282 |
+
.message {
|
283 |
+
position: absolute;
|
284 |
+
top: 80px;
|
285 |
+
left: 50%;
|
286 |
+
transform: translateX(-50%);
|
287 |
+
padding: 15px 25px;
|
288 |
+
border-radius: 25px;
|
289 |
+
font-weight: 600;
|
290 |
+
z-index: 1500;
|
291 |
+
opacity: 0;
|
292 |
+
transition: opacity 0.3s ease;
|
293 |
+
}
|
294 |
+
|
295 |
+
.message.show {
|
296 |
+
opacity: 1;
|
297 |
+
}
|
298 |
+
|
299 |
+
.message.success {
|
300 |
+
background: linear-gradient(45deg, #4CAF50, #45a049);
|
301 |
+
color: white;
|
302 |
+
}
|
303 |
+
|
304 |
+
.message.error {
|
305 |
+
background: linear-gradient(45deg, #f44336, #d32f2f);
|
306 |
+
color: white;
|
307 |
+
}
|
308 |
+
|
309 |
+
/* Gesture Instructions */
|
310 |
+
.gesture-hint {
|
311 |
+
position: absolute;
|
312 |
+
bottom: 120px;
|
313 |
+
left: 50%;
|
314 |
+
transform: translateX(-50%);
|
315 |
+
background: rgba(0,0,0,0.7);
|
316 |
+
color: white;
|
317 |
+
padding: 10px 20px;
|
318 |
+
border-radius: 20px;
|
319 |
+
font-size: 14px;
|
320 |
+
z-index: 1000;
|
321 |
+
animation: fadeInOut 4s ease-in-out;
|
322 |
+
}
|
323 |
+
|
324 |
+
@keyframes fadeInOut {
|
325 |
+
0%, 100% { opacity: 0; }
|
326 |
+
20%, 80% { opacity: 1; }
|
327 |
+
}
|
328 |
+
</style>
|
329 |
+
</head>
|
330 |
+
<body>
|
331 |
+
<div class="app-container">
|
332 |
+
<!-- Header -->
|
333 |
+
<div class="header">
|
334 |
+
<div class="logo">
|
335 |
+
🌳 TreeTrack Map
|
336 |
+
</div>
|
337 |
+
<div class="header-actions">
|
338 |
+
<div class="tree-counter">
|
339 |
+
<span>🌳</span>
|
340 |
+
<span id="treeCount">0</span>
|
341 |
+
</div>
|
342 |
+
<a href="/static/index.html" class="btn btn-secondary">📝 Add Tree</a>
|
343 |
+
</div>
|
344 |
+
</div>
|
345 |
+
|
346 |
+
<!-- Map Container -->
|
347 |
+
<div class="map-container">
|
348 |
+
<div id="map"></div>
|
349 |
+
|
350 |
+
<!-- Floating Controls -->
|
351 |
+
<div class="floating-controls">
|
352 |
+
<div class="control-panel">
|
353 |
+
<button id="myLocationBtn" class="btn btn-primary">📍 My Location</button>
|
354 |
+
<button id="clearPinsBtn" class="btn btn-secondary" style="margin-top: 8px;">🗑️ Clear Pins</button>
|
355 |
+
</div>
|
356 |
+
</div>
|
357 |
+
|
358 |
+
<!-- Location Info Panel -->
|
359 |
+
<div class="location-info">
|
360 |
+
<div class="info-panel" id="infoPanel">
|
361 |
+
<div class="coordinates">
|
362 |
+
<div class="coord-item">
|
363 |
+
<div class="coord-label">Latitude</div>
|
364 |
+
<div class="coord-value" id="latValue">--</div>
|
365 |
+
</div>
|
366 |
+
<div class="coord-item">
|
367 |
+
<div class="coord-label">Longitude</div>
|
368 |
+
<div class="coord-value" id="lngValue">--</div>
|
369 |
+
</div>
|
370 |
+
</div>
|
371 |
+
<div class="quick-actions">
|
372 |
+
<button id="useLocationBtn" class="btn btn-primary">✅ Use This Location</button>
|
373 |
+
<button id="cancelBtn" class="btn btn-secondary">❌ Cancel</button>
|
374 |
+
</div>
|
375 |
+
</div>
|
376 |
+
</div>
|
377 |
+
|
378 |
+
<!-- Loading -->
|
379 |
+
<div id="loading" class="loading" style="display: none;">
|
380 |
+
<div class="spinner"></div>
|
381 |
+
<div>Loading map...</div>
|
382 |
+
</div>
|
383 |
+
|
384 |
+
<!-- Messages -->
|
385 |
+
<div id="message" class="message"></div>
|
386 |
+
|
387 |
+
<!-- Gesture Hint -->
|
388 |
+
<div class="gesture-hint">
|
389 |
+
👆 Tap anywhere on map to drop a pin
|
390 |
+
</div>
|
391 |
+
</div>
|
392 |
+
</div>
|
393 |
+
|
394 |
+
<!-- Leaflet JS -->
|
395 |
+
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
396 |
+
<script src="/static/map.js"></script>
|
397 |
+
</body>
|
398 |
+
</html>
|
static/map.js
ADDED
@@ -0,0 +1,501 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// TreeTrack Map - Interactive Map with Pin Management
|
2 |
+
class TreeTrackMap {
|
3 |
+
constructor() {
|
4 |
+
this.map = null;
|
5 |
+
this.currentMarker = null;
|
6 |
+
this.treeMarkers = [];
|
7 |
+
this.userLocation = null;
|
8 |
+
this.selectedLocation = null;
|
9 |
+
|
10 |
+
this.init();
|
11 |
+
}
|
12 |
+
|
13 |
+
init() {
|
14 |
+
this.showLoading(true);
|
15 |
+
this.initializeMap();
|
16 |
+
this.setupEventListeners();
|
17 |
+
this.loadExistingTrees();
|
18 |
+
this.attemptGeolocation();
|
19 |
+
}
|
20 |
+
|
21 |
+
initializeMap() {
|
22 |
+
// Initialize Leaflet map
|
23 |
+
this.map = L.map('map', {
|
24 |
+
zoomControl: false,
|
25 |
+
attributionControl: false,
|
26 |
+
tap: true,
|
27 |
+
touchZoom: true,
|
28 |
+
doubleClickZoom: false // Disable double click to prevent conflicts
|
29 |
+
}).setView([26.2006, 92.9376], 13); // Default to Guwahati, Assam
|
30 |
+
|
31 |
+
// Add tile layer - Using OpenStreetMap with satellite option
|
32 |
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
33 |
+
attribution: '',
|
34 |
+
maxZoom: 19
|
35 |
+
}).addTo(this.map);
|
36 |
+
|
37 |
+
// Add custom zoom controls
|
38 |
+
L.control.zoom({
|
39 |
+
position: 'bottomleft'
|
40 |
+
}).addTo(this.map);
|
41 |
+
|
42 |
+
// Add scale
|
43 |
+
L.control.scale({
|
44 |
+
position: 'bottomleft',
|
45 |
+
metric: true,
|
46 |
+
imperial: false
|
47 |
+
}).addTo(this.map);
|
48 |
+
|
49 |
+
this.showLoading(false);
|
50 |
+
}
|
51 |
+
|
52 |
+
setupEventListeners() {
|
53 |
+
// Map click event for dropping pins
|
54 |
+
this.map.on('click', (e) => {
|
55 |
+
this.handleMapClick(e);
|
56 |
+
});
|
57 |
+
|
58 |
+
// Button event listeners
|
59 |
+
document.getElementById('myLocationBtn').addEventListener('click', () => {
|
60 |
+
this.goToMyLocation();
|
61 |
+
});
|
62 |
+
|
63 |
+
document.getElementById('clearPinsBtn').addEventListener('click', () => {
|
64 |
+
this.clearTemporaryPin();
|
65 |
+
});
|
66 |
+
|
67 |
+
document.getElementById('useLocationBtn').addEventListener('click', () => {
|
68 |
+
this.useSelectedLocation();
|
69 |
+
});
|
70 |
+
|
71 |
+
document.getElementById('cancelBtn').addEventListener('click', () => {
|
72 |
+
this.cancelLocationSelection();
|
73 |
+
});
|
74 |
+
|
75 |
+
// Handle browser back/forward
|
76 |
+
window.addEventListener('popstate', (e) => {
|
77 |
+
if (e.state && e.state.location) {
|
78 |
+
this.selectedLocation = e.state.location;
|
79 |
+
this.showLocationPanel(true);
|
80 |
+
}
|
81 |
+
});
|
82 |
+
}
|
83 |
+
|
84 |
+
handleMapClick(e) {
|
85 |
+
const lat = e.latlng.lat;
|
86 |
+
const lng = e.latlng.lng;
|
87 |
+
|
88 |
+
// Clear any existing temporary marker
|
89 |
+
this.clearTemporaryPin();
|
90 |
+
|
91 |
+
// Create new temporary marker
|
92 |
+
this.currentMarker = L.circleMarker([lat, lng], {
|
93 |
+
radius: 15,
|
94 |
+
fillColor: '#ff6b35',
|
95 |
+
color: 'white',
|
96 |
+
weight: 3,
|
97 |
+
opacity: 1,
|
98 |
+
fillOpacity: 0.8,
|
99 |
+
className: 'temp-pin'
|
100 |
+
}).addTo(this.map);
|
101 |
+
|
102 |
+
// Store selected location
|
103 |
+
this.selectedLocation = { lat, lng };
|
104 |
+
|
105 |
+
// Update info panel
|
106 |
+
this.updateLocationDisplay(lat, lng);
|
107 |
+
this.showLocationPanel(true);
|
108 |
+
|
109 |
+
// Add to browser history
|
110 |
+
history.pushState(
|
111 |
+
{ location: this.selectedLocation },
|
112 |
+
'',
|
113 |
+
`#lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`
|
114 |
+
);
|
115 |
+
|
116 |
+
// Haptic feedback on mobile
|
117 |
+
this.vibrate(50);
|
118 |
+
}
|
119 |
+
|
120 |
+
clearTemporaryPin() {
|
121 |
+
if (this.currentMarker) {
|
122 |
+
this.map.removeLayer(this.currentMarker);
|
123 |
+
this.currentMarker = null;
|
124 |
+
}
|
125 |
+
this.selectedLocation = null;
|
126 |
+
this.showLocationPanel(false);
|
127 |
+
|
128 |
+
// Clear URL hash
|
129 |
+
history.pushState('', document.title, window.location.pathname);
|
130 |
+
}
|
131 |
+
|
132 |
+
async loadExistingTrees() {
|
133 |
+
try {
|
134 |
+
const response = await fetch('/trees');
|
135 |
+
if (response.ok) {
|
136 |
+
const trees = await response.json();
|
137 |
+
this.displayTreeMarkers(trees);
|
138 |
+
this.updateTreeCounter(trees.length);
|
139 |
+
}
|
140 |
+
} catch (error) {
|
141 |
+
console.error('Error loading trees:', error);
|
142 |
+
}
|
143 |
+
}
|
144 |
+
|
145 |
+
displayTreeMarkers(trees) {
|
146 |
+
// Clear existing tree markers
|
147 |
+
this.treeMarkers.forEach(marker => {
|
148 |
+
this.map.removeLayer(marker);
|
149 |
+
});
|
150 |
+
this.treeMarkers = [];
|
151 |
+
|
152 |
+
// Add tree markers
|
153 |
+
trees.forEach(tree => {
|
154 |
+
if (tree.latitude && tree.longitude) {
|
155 |
+
const marker = L.circleMarker([tree.latitude, tree.longitude], {
|
156 |
+
radius: 12,
|
157 |
+
fillColor: '#4CAF50',
|
158 |
+
color: 'white',
|
159 |
+
weight: 3,
|
160 |
+
opacity: 1,
|
161 |
+
fillOpacity: 0.9,
|
162 |
+
className: 'tree-pin'
|
163 |
+
}).addTo(this.map);
|
164 |
+
|
165 |
+
// Create popup content
|
166 |
+
const popupContent = this.createTreePopup(tree);
|
167 |
+
marker.bindPopup(popupContent, {
|
168 |
+
maxWidth: 300,
|
169 |
+
className: 'tree-popup'
|
170 |
+
});
|
171 |
+
|
172 |
+
this.treeMarkers.push(marker);
|
173 |
+
}
|
174 |
+
});
|
175 |
+
}
|
176 |
+
|
177 |
+
createTreePopup(tree) {
|
178 |
+
return `
|
179 |
+
<div style="padding: 10px; font-family: 'Segoe UI', sans-serif;">
|
180 |
+
<h3 style="margin: 0 0 10px 0; color: #2c5530; font-size: 1.1rem;">
|
181 |
+
${tree.local_name || tree.common_name || 'Tree #' + tree.id}
|
182 |
+
</h3>
|
183 |
+
${tree.scientific_name ? `<p style="margin: 0 0 8px 0; font-style: italic; color: #666;">${tree.scientific_name}</p>` : ''}
|
184 |
+
${tree.tree_code ? `<p style="margin: 0 0 8px 0;"><strong>Code:</strong> ${tree.tree_code}</p>` : ''}
|
185 |
+
${tree.height ? `<p style="margin: 0 0 5px 0;"><strong>Height:</strong> ${tree.height}m</p>` : ''}
|
186 |
+
${tree.width ? `<p style="margin: 0 0 5px 0;"><strong>Girth:</strong> ${tree.width}cm</p>` : ''}
|
187 |
+
<div style="margin-top: 10px; font-size: 0.9rem; color: #888;">
|
188 |
+
📍 ${tree.latitude.toFixed(6)}, ${tree.longitude.toFixed(6)}
|
189 |
+
</div>
|
190 |
+
</div>
|
191 |
+
`;
|
192 |
+
}
|
193 |
+
|
194 |
+
updateTreeCounter(count) {
|
195 |
+
document.getElementById('treeCount').textContent = count;
|
196 |
+
}
|
197 |
+
|
198 |
+
updateLocationDisplay(lat, lng) {
|
199 |
+
document.getElementById('latValue').textContent = lat.toFixed(6);
|
200 |
+
document.getElementById('lngValue').textContent = lng.toFixed(6);
|
201 |
+
}
|
202 |
+
|
203 |
+
showLocationPanel(show) {
|
204 |
+
const panel = document.getElementById('infoPanel');
|
205 |
+
if (show) {
|
206 |
+
panel.classList.add('active');
|
207 |
+
} else {
|
208 |
+
panel.classList.remove('active');
|
209 |
+
}
|
210 |
+
}
|
211 |
+
|
212 |
+
useSelectedLocation() {
|
213 |
+
if (this.selectedLocation) {
|
214 |
+
// Store in localStorage for form access
|
215 |
+
localStorage.setItem('selectedLocation', JSON.stringify(this.selectedLocation));
|
216 |
+
|
217 |
+
this.showMessage('Location saved! Redirecting to form...', 'success');
|
218 |
+
|
219 |
+
// Redirect to form page after a short delay
|
220 |
+
setTimeout(() => {
|
221 |
+
window.location.href = '/static/index.html';
|
222 |
+
}, 1500);
|
223 |
+
}
|
224 |
+
}
|
225 |
+
|
226 |
+
cancelLocationSelection() {
|
227 |
+
this.clearTemporaryPin();
|
228 |
+
}
|
229 |
+
|
230 |
+
async attemptGeolocation() {
|
231 |
+
if ('geolocation' in navigator) {
|
232 |
+
try {
|
233 |
+
const position = await this.getCurrentPosition();
|
234 |
+
this.userLocation = {
|
235 |
+
lat: position.coords.latitude,
|
236 |
+
lng: position.coords.longitude
|
237 |
+
};
|
238 |
+
|
239 |
+
// Add user location marker
|
240 |
+
this.addUserLocationMarker();
|
241 |
+
|
242 |
+
} catch (error) {
|
243 |
+
console.log('Geolocation not available or denied');
|
244 |
+
}
|
245 |
+
}
|
246 |
+
}
|
247 |
+
|
248 |
+
getCurrentPosition() {
|
249 |
+
return new Promise((resolve, reject) => {
|
250 |
+
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
251 |
+
enableHighAccuracy: true,
|
252 |
+
timeout: 10000,
|
253 |
+
maximumAge: 300000 // 5 minutes
|
254 |
+
});
|
255 |
+
});
|
256 |
+
}
|
257 |
+
|
258 |
+
addUserLocationMarker() {
|
259 |
+
if (this.userLocation) {
|
260 |
+
// Add pulsing blue dot for user location
|
261 |
+
const userMarker = L.circleMarker([this.userLocation.lat, this.userLocation.lng], {
|
262 |
+
radius: 8,
|
263 |
+
fillColor: '#2196F3',
|
264 |
+
color: 'white',
|
265 |
+
weight: 2,
|
266 |
+
opacity: 1,
|
267 |
+
fillOpacity: 1
|
268 |
+
}).addTo(this.map);
|
269 |
+
|
270 |
+
userMarker.bindPopup('📍 Your Location', {
|
271 |
+
className: 'user-location-popup'
|
272 |
+
});
|
273 |
+
}
|
274 |
+
}
|
275 |
+
|
276 |
+
async goToMyLocation() {
|
277 |
+
this.showLoading(true);
|
278 |
+
|
279 |
+
try {
|
280 |
+
const position = await this.getCurrentPosition();
|
281 |
+
const lat = position.coords.latitude;
|
282 |
+
const lng = position.coords.longitude;
|
283 |
+
|
284 |
+
this.userLocation = { lat, lng };
|
285 |
+
|
286 |
+
// Animate to user location
|
287 |
+
this.map.flyTo([lat, lng], 16, {
|
288 |
+
animate: true,
|
289 |
+
duration: 1.5
|
290 |
+
});
|
291 |
+
|
292 |
+
// Update user location marker
|
293 |
+
this.addUserLocationMarker();
|
294 |
+
|
295 |
+
this.showMessage('Found your location!', 'success');
|
296 |
+
|
297 |
+
} catch (error) {
|
298 |
+
this.showMessage('Could not get your location. Please check permissions.', 'error');
|
299 |
+
} finally {
|
300 |
+
this.showLoading(false);
|
301 |
+
}
|
302 |
+
}
|
303 |
+
|
304 |
+
showMessage(text, type = 'success') {
|
305 |
+
const messageEl = document.getElementById('message');
|
306 |
+
messageEl.textContent = text;
|
307 |
+
messageEl.className = `message ${type} show`;
|
308 |
+
|
309 |
+
setTimeout(() => {
|
310 |
+
messageEl.classList.remove('show');
|
311 |
+
}, 3000);
|
312 |
+
}
|
313 |
+
|
314 |
+
showLoading(show) {
|
315 |
+
const loadingEl = document.getElementById('loading');
|
316 |
+
loadingEl.style.display = show ? 'block' : 'none';
|
317 |
+
}
|
318 |
+
|
319 |
+
vibrate(duration) {
|
320 |
+
if ('vibrate' in navigator) {
|
321 |
+
navigator.vibrate(duration);
|
322 |
+
}
|
323 |
+
}
|
324 |
+
}
|
325 |
+
|
326 |
+
// Enhanced Mobile Gestures and Touch Handling
|
327 |
+
class MobileEnhancements {
|
328 |
+
constructor(mapInstance) {
|
329 |
+
this.map = mapInstance;
|
330 |
+
this.initMobileFeatures();
|
331 |
+
}
|
332 |
+
|
333 |
+
initMobileFeatures() {
|
334 |
+
// Prevent iOS bounce/overscroll
|
335 |
+
document.addEventListener('touchmove', (e) => {
|
336 |
+
if (e.target === document.getElementById('map')) {
|
337 |
+
e.preventDefault();
|
338 |
+
}
|
339 |
+
}, { passive: false });
|
340 |
+
|
341 |
+
// Handle orientation change
|
342 |
+
window.addEventListener('orientationchange', () => {
|
343 |
+
setTimeout(() => {
|
344 |
+
this.map.map.invalidateSize();
|
345 |
+
}, 500);
|
346 |
+
});
|
347 |
+
|
348 |
+
// Long press for pin dropping (alternative to tap)
|
349 |
+
this.setupLongPressHandler();
|
350 |
+
|
351 |
+
// Swipe gestures for panels
|
352 |
+
this.setupSwipeGestures();
|
353 |
+
}
|
354 |
+
|
355 |
+
setupLongPressHandler() {
|
356 |
+
let longPressTimer;
|
357 |
+
let isLongPress = false;
|
358 |
+
|
359 |
+
this.map.map.on('mousedown touchstart', (e) => {
|
360 |
+
isLongPress = false;
|
361 |
+
longPressTimer = setTimeout(() => {
|
362 |
+
isLongPress = true;
|
363 |
+
this.handleLongPress(e);
|
364 |
+
}, 500); // 500ms long press
|
365 |
+
});
|
366 |
+
|
367 |
+
this.map.map.on('mouseup touchend mousemove touchmove', () => {
|
368 |
+
clearTimeout(longPressTimer);
|
369 |
+
});
|
370 |
+
}
|
371 |
+
|
372 |
+
handleLongPress(e) {
|
373 |
+
// Vibrate to indicate long press detected
|
374 |
+
this.map.vibrate([50, 50, 50]);
|
375 |
+
|
376 |
+
// Handle the long press as a pin drop
|
377 |
+
this.map.handleMapClick(e);
|
378 |
+
|
379 |
+
// Show different message for long press
|
380 |
+
this.map.showMessage('Pin dropped via long press!', 'success');
|
381 |
+
}
|
382 |
+
|
383 |
+
setupSwipeGestures() {
|
384 |
+
let startY = 0;
|
385 |
+
let startX = 0;
|
386 |
+
|
387 |
+
document.addEventListener('touchstart', (e) => {
|
388 |
+
startY = e.touches[0].clientY;
|
389 |
+
startX = e.touches[0].clientX;
|
390 |
+
});
|
391 |
+
|
392 |
+
document.addEventListener('touchend', (e) => {
|
393 |
+
if (!e.changedTouches[0]) return;
|
394 |
+
|
395 |
+
const endY = e.changedTouches[0].clientY;
|
396 |
+
const endX = e.changedTouches[0].clientX;
|
397 |
+
const diffY = startY - endY;
|
398 |
+
const diffX = startX - endX;
|
399 |
+
|
400 |
+
// Detect swipe up on info panel to dismiss
|
401 |
+
if (Math.abs(diffY) > Math.abs(diffX) && diffY > 50) {
|
402 |
+
const infoPanel = document.getElementById('infoPanel');
|
403 |
+
if (infoPanel.classList.contains('active')) {
|
404 |
+
this.map.cancelLocationSelection();
|
405 |
+
}
|
406 |
+
}
|
407 |
+
});
|
408 |
+
}
|
409 |
+
}
|
410 |
+
|
411 |
+
// Progressive Web App features
|
412 |
+
class PWAFeatures {
|
413 |
+
constructor() {
|
414 |
+
this.initPWA();
|
415 |
+
}
|
416 |
+
|
417 |
+
initPWA() {
|
418 |
+
// Service worker registration
|
419 |
+
if ('serviceWorker' in navigator) {
|
420 |
+
navigator.serviceWorker.register('/static/sw.js')
|
421 |
+
.then(registration => {
|
422 |
+
console.log('SW registered: ', registration);
|
423 |
+
})
|
424 |
+
.catch(registrationError => {
|
425 |
+
console.log('SW registration failed: ', registrationError);
|
426 |
+
});
|
427 |
+
}
|
428 |
+
|
429 |
+
// Install prompt
|
430 |
+
this.setupInstallPrompt();
|
431 |
+
}
|
432 |
+
|
433 |
+
setupInstallPrompt() {
|
434 |
+
let deferredPrompt;
|
435 |
+
|
436 |
+
window.addEventListener('beforeinstallprompt', (e) => {
|
437 |
+
e.preventDefault();
|
438 |
+
deferredPrompt = e;
|
439 |
+
|
440 |
+
// Show custom install button (you can add this to UI)
|
441 |
+
console.log('PWA install prompt available');
|
442 |
+
});
|
443 |
+
|
444 |
+
window.addEventListener('appinstalled', () => {
|
445 |
+
console.log('PWA was installed');
|
446 |
+
deferredPrompt = null;
|
447 |
+
});
|
448 |
+
}
|
449 |
+
}
|
450 |
+
|
451 |
+
// URL handling for deep linking
|
452 |
+
class URLHandler {
|
453 |
+
constructor(mapInstance) {
|
454 |
+
this.map = mapInstance;
|
455 |
+
this.handleInitialURL();
|
456 |
+
}
|
457 |
+
|
458 |
+
handleInitialURL() {
|
459 |
+
const hash = window.location.hash;
|
460 |
+
if (hash) {
|
461 |
+
const params = new URLSearchParams(hash.substring(1));
|
462 |
+
const lat = parseFloat(params.get('lat'));
|
463 |
+
const lng = parseFloat(params.get('lng'));
|
464 |
+
|
465 |
+
if (!isNaN(lat) && !isNaN(lng)) {
|
466 |
+
// Restore location from URL
|
467 |
+
setTimeout(() => {
|
468 |
+
this.map.handleMapClick({
|
469 |
+
latlng: { lat, lng }
|
470 |
+
});
|
471 |
+
this.map.map.setView([lat, lng], 16);
|
472 |
+
}, 1000);
|
473 |
+
}
|
474 |
+
}
|
475 |
+
}
|
476 |
+
}
|
477 |
+
|
478 |
+
// Initialize the application
|
479 |
+
document.addEventListener('DOMContentLoaded', () => {
|
480 |
+
// Main map instance
|
481 |
+
const treeMap = new TreeTrackMap();
|
482 |
+
|
483 |
+
// Mobile enhancements
|
484 |
+
const mobileEnhancements = new MobileEnhancements(treeMap);
|
485 |
+
|
486 |
+
// PWA features
|
487 |
+
const pwaFeatures = new PWAFeatures();
|
488 |
+
|
489 |
+
// URL handling
|
490 |
+
const urlHandler = new URLHandler(treeMap);
|
491 |
+
|
492 |
+
// Auto-refresh tree data every 30 seconds
|
493 |
+
setInterval(() => {
|
494 |
+
treeMap.loadExistingTrees();
|
495 |
+
}, 30000);
|
496 |
+
});
|
497 |
+
|
498 |
+
// Export for use in other modules
|
499 |
+
if (typeof module !== 'undefined' && module.exports) {
|
500 |
+
module.exports = { TreeTrackMap, MobileEnhancements, PWAFeatures };
|
501 |
+
}
|
static/sw.js
ADDED
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// TreeTrack Service Worker - PWA and Offline Support
|
2 |
+
const CACHE_NAME = 'treetrack-v1';
|
3 |
+
const urlsToCache = [
|
4 |
+
'/static/',
|
5 |
+
'/static/index.html',
|
6 |
+
'/static/map.html',
|
7 |
+
'/static/app.js',
|
8 |
+
'/static/map.js',
|
9 |
+
'https://unpkg.com/[email protected]/dist/leaflet.css',
|
10 |
+
'https://unpkg.com/[email protected]/dist/leaflet.js',
|
11 |
+
'https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;600;700&display=swap'
|
12 |
+
];
|
13 |
+
|
14 |
+
// Install event - cache resources
|
15 |
+
self.addEventListener('install', event => {
|
16 |
+
event.waitUntil(
|
17 |
+
caches.open(CACHE_NAME)
|
18 |
+
.then(cache => {
|
19 |
+
console.log('Opened cache');
|
20 |
+
return cache.addAll(urlsToCache.map(url => new Request(url, {cache: 'reload'})));
|
21 |
+
})
|
22 |
+
.catch(error => {
|
23 |
+
console.log('Cache install failed:', error);
|
24 |
+
})
|
25 |
+
);
|
26 |
+
});
|
27 |
+
|
28 |
+
// Fetch event - serve cached content when offline
|
29 |
+
self.addEventListener('fetch', event => {
|
30 |
+
// Skip non-GET requests
|
31 |
+
if (event.request.method !== 'GET') {
|
32 |
+
return;
|
33 |
+
}
|
34 |
+
|
35 |
+
// Skip requests to API endpoints - let them fail gracefully
|
36 |
+
if (event.request.url.includes('/api/') || event.request.url.includes('/trees')) {
|
37 |
+
return;
|
38 |
+
}
|
39 |
+
|
40 |
+
event.respondWith(
|
41 |
+
caches.match(event.request)
|
42 |
+
.then(response => {
|
43 |
+
// Cache hit - return response
|
44 |
+
if (response) {
|
45 |
+
return response;
|
46 |
+
}
|
47 |
+
|
48 |
+
return fetch(event.request).then(response => {
|
49 |
+
// Check if we received a valid response
|
50 |
+
if (!response || response.status !== 200 || response.type !== 'basic') {
|
51 |
+
return response;
|
52 |
+
}
|
53 |
+
|
54 |
+
// Clone the response
|
55 |
+
const responseToCache = response.clone();
|
56 |
+
|
57 |
+
caches.open(CACHE_NAME)
|
58 |
+
.then(cache => {
|
59 |
+
cache.put(event.request, responseToCache);
|
60 |
+
});
|
61 |
+
|
62 |
+
return response;
|
63 |
+
}).catch(() => {
|
64 |
+
// If fetch fails, try to return a cached fallback
|
65 |
+
if (event.request.destination === 'document') {
|
66 |
+
return caches.match('/static/index.html');
|
67 |
+
}
|
68 |
+
});
|
69 |
+
})
|
70 |
+
);
|
71 |
+
});
|
72 |
+
|
73 |
+
// Activate event - clean up old caches
|
74 |
+
self.addEventListener('activate', event => {
|
75 |
+
const cacheWhitelist = [CACHE_NAME];
|
76 |
+
|
77 |
+
event.waitUntil(
|
78 |
+
caches.keys().then(cacheNames => {
|
79 |
+
return Promise.all(
|
80 |
+
cacheNames.map(cacheName => {
|
81 |
+
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
82 |
+
return caches.delete(cacheName);
|
83 |
+
}
|
84 |
+
})
|
85 |
+
);
|
86 |
+
})
|
87 |
+
);
|
88 |
+
});
|
89 |
+
|
90 |
+
// Background sync for offline data submission
|
91 |
+
self.addEventListener('sync', event => {
|
92 |
+
if (event.tag === 'background-sync') {
|
93 |
+
event.waitUntil(doBackgroundSync());
|
94 |
+
}
|
95 |
+
});
|
96 |
+
|
97 |
+
async function doBackgroundSync() {
|
98 |
+
// Handle any queued tree submissions when back online
|
99 |
+
try {
|
100 |
+
const cache = await caches.open('offline-data');
|
101 |
+
const requests = await cache.keys();
|
102 |
+
|
103 |
+
for (const request of requests) {
|
104 |
+
if (request.url.includes('offline-tree-')) {
|
105 |
+
const response = await cache.match(request);
|
106 |
+
const treeData = await response.json();
|
107 |
+
|
108 |
+
// Try to submit the data
|
109 |
+
try {
|
110 |
+
const submitResponse = await fetch('/api/trees', {
|
111 |
+
method: 'POST',
|
112 |
+
headers: {
|
113 |
+
'Content-Type': 'application/json',
|
114 |
+
},
|
115 |
+
body: JSON.stringify(treeData)
|
116 |
+
});
|
117 |
+
|
118 |
+
if (submitResponse.ok) {
|
119 |
+
// Remove from cache if successful
|
120 |
+
await cache.delete(request);
|
121 |
+
console.log('Offline tree data synced successfully');
|
122 |
+
}
|
123 |
+
} catch (error) {
|
124 |
+
console.log('Failed to sync offline data:', error);
|
125 |
+
}
|
126 |
+
}
|
127 |
+
}
|
128 |
+
} catch (error) {
|
129 |
+
console.log('Background sync failed:', error);
|
130 |
+
}
|
131 |
+
}
|
132 |
+
|
133 |
+
// Push notifications (for future enhancement)
|
134 |
+
self.addEventListener('push', event => {
|
135 |
+
const options = {
|
136 |
+
body: event.data ? event.data.text() : 'New tree data available!',
|
137 |
+
icon: '/static/icon-192x192.png',
|
138 |
+
badge: '/static/badge-72x72.png',
|
139 |
+
tag: 'treetrack-notification',
|
140 |
+
data: {
|
141 |
+
url: '/static/map.html'
|
142 |
+
}
|
143 |
+
};
|
144 |
+
|
145 |
+
event.waitUntil(
|
146 |
+
self.registration.showNotification('TreeTrack', options)
|
147 |
+
);
|
148 |
+
});
|
149 |
+
|
150 |
+
// Handle notification clicks
|
151 |
+
self.addEventListener('notificationclick', event => {
|
152 |
+
event.notification.close();
|
153 |
+
|
154 |
+
if (event.notification.data && event.notification.data.url) {
|
155 |
+
event.waitUntil(
|
156 |
+
clients.openWindow(event.notification.data.url)
|
157 |
+
);
|
158 |
+
}
|
159 |
+
});
|