gauthamnairy commited on
Commit
c23dbb1
·
verified ·
1 Parent(s): 98a7edc

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +30 -0
  2. main.py +361 -0
  3. requirements.txt +7 -0
  4. static/index.html +640 -0
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Python runtime as a parent image
2
+ FROM python:3.10-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+
8
+ # Set work directory
9
+ WORKDIR /code
10
+
11
+ # Install system dependencies
12
+ RUN apt-get update && apt-get install -y \
13
+ build-essential \
14
+ gdal-bin \
15
+ libgdal-dev \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Install Python dependencies
19
+ COPY requirements.txt /code/
20
+ RUN pip install --upgrade pip
21
+ RUN pip install --no-cache-dir -r requirements.txt
22
+
23
+ # Copy project files
24
+ COPY . /code/
25
+
26
+ # Expose port (Hugging Face expects 7860 or 8000, but 7860 is default)
27
+ EXPOSE 7860
28
+
29
+ # Run the application
30
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from fastapi.staticfiles import StaticFiles
4
+ from pydantic import BaseModel
5
+ import os
6
+ import re
7
+ import time
8
+ import math
9
+ import logging
10
+ import requests
11
+ import rasterio
12
+ from typing import Optional, Tuple, Dict
13
+ import urllib3
14
+
15
+ # Disable SSL verification warnings for testing
16
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
17
+ os.environ["GDAL_HTTP_UNSAFESSL"] = "YES"
18
+
19
+ # Configure logging
20
+ logging.basicConfig(
21
+ level=logging.INFO,
22
+ format="%(asctime)s [%(levelname)s] %(message)s",
23
+ datefmt="%Y-%m-%d %H:%M:%S",
24
+ )
25
+
26
+ app = FastAPI(title="Terrain Analysis API", version="1.0.0")
27
+
28
+ # Enable CORS for frontend communication
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=["*"], # Configure appropriately for production
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ # Serve static files (frontend)
38
+ app.mount("/static", StaticFiles(directory="static"), name="static")
39
+
40
+ # Pydantic models for request/response
41
+ class LocationRequest(BaseModel):
42
+ input: str # Can be coordinates or place name
43
+
44
+ class TerrainResponse(BaseModel):
45
+ latitude: float
46
+ longitude: float
47
+ elevation: Optional[float] = None
48
+ terrain_type: str
49
+ success: bool
50
+ message: Optional[str] = None
51
+
52
+ # OpenTopoData configuration
53
+ DATASETS = [
54
+ {'name': 'nzdem8m', 'min_lat': -47.6, 'max_lat': -34.4, 'min_lon': 166.5, 'max_lon': 178.6, 'resolution': 8},
55
+ {'name': 'ned10m', 'min_lat': 19.5, 'max_lat': 49.4, 'min_lon': -160.3,'max_lon': -66.9, 'resolution': 10},
56
+ {'name': 'eudem25m', 'min_lat': 25.0, 'max_lat': 72.0, 'min_lon': -60.0, 'max_lon': 40.0, 'resolution': 25},
57
+ {'name': 'mapzen', 'min_lat': -90.0, 'max_lat': 90.0, 'min_lon': -180.0,'max_lon': 180.0, 'resolution': 30},
58
+ {'name': 'aster30m', 'min_lat': -90.0, 'max_lat': 90.0, 'min_lon': -180.0,'max_lon': 180.0, 'resolution': 30},
59
+ {'name': 'srtm30m', 'min_lat': -60.0, 'max_lat': 60.0, 'min_lon': -180.0,'max_lon': 180.0, 'resolution': 30},
60
+ {'name': 'srtm90m', 'min_lat': -60.0, 'max_lat': 60.0, 'min_lon': -180.0,'max_lon': 180.0, 'resolution': 90},
61
+ {'name': 'gebco2020', 'min_lat': -90.0, 'max_lat': 90.0, 'min_lon': -180.0,'max_lon': 180.0, 'resolution': 450},
62
+ {'name': 'etopo1', 'min_lat': -90.0, 'max_lat': 90.0, 'min_lon': -180.0,'max_lon': 180.0, 'resolution': 1800},
63
+ ]
64
+
65
+ # HTTP session with SSL verification disabled (for testing)
66
+ session = requests.Session()
67
+ session.verify = False
68
+
69
+ def select_dataset(lat: float, lon: float) -> str:
70
+ """Select the best available dataset for given coordinates"""
71
+ candidates = [
72
+ ds for ds in DATASETS
73
+ if ds['min_lat'] <= lat <= ds['max_lat'] and ds['min_lon'] <= lon <= ds['max_lon']
74
+ ]
75
+ if not candidates:
76
+ logging.debug(f"No regional dataset covers ({lat}, {lon}); falling back to 'mapzen'.")
77
+ return 'mapzen'
78
+ chosen = min(candidates, key=lambda x: x['resolution'])['name']
79
+ logging.debug(f"Selected OpenTopoData dataset '{chosen}' for ({lat}, {lon}).")
80
+ return chosen
81
+
82
+ def get_elevation_opentopo_single(lat: float, lon: float) -> Optional[float]:
83
+ """Fetch elevation for a single coordinate from OpenTopoData"""
84
+ dataset = select_dataset(lat, lon)
85
+ url = f"https://api.opentopodata.org/v1/{dataset}?locations={lat},{lon}"
86
+
87
+ try:
88
+ resp = session.get(url, timeout=10)
89
+ if resp.status_code == 200:
90
+ data = resp.json()
91
+ if data.get('status') == 'OK':
92
+ elev = data['results'][0].get('elevation')
93
+ if elev is not None:
94
+ logging.debug(f"Got elevation {elev} m from '{dataset}' for ({lat}, {lon}).")
95
+ return elev
96
+ else:
97
+ logging.warning(f"Null elevation from '{dataset}' for ({lat}, {lon}).")
98
+ else:
99
+ logging.warning(f"OpenTopoData status '{data.get('status')}' for '{dataset}' at ({lat}, {lon}).")
100
+ else:
101
+ logging.warning(f"HTTP {resp.status_code} from OpenTopoData for '{dataset}' at ({lat}, {lon}).")
102
+ except requests.RequestException as e:
103
+ logging.error(f"OpenTopoData request exception at ({lat}, {lon}): {e}")
104
+
105
+ # Fallback to 'mapzen' if primary dataset failed
106
+ if dataset != 'mapzen':
107
+ try:
108
+ url2 = f"https://api.opentopodata.org/v1/mapzen?locations={lat},{lon}"
109
+ resp2 = session.get(url2, timeout=10)
110
+ if resp2.status_code == 200:
111
+ data2 = resp2.json()
112
+ if data2.get('status') == 'OK':
113
+ elev2 = data2['results'][0].get('elevation')
114
+ if elev2 is not None:
115
+ logging.debug(f"Got elevation {elev2} m from fallback 'mapzen' for ({lat}, {lon}).")
116
+ return elev2
117
+ except requests.RequestException as e2:
118
+ logging.error(f"Fallback OpenTopoData request exception at ({lat}, {lon}): {e2}")
119
+
120
+ return None
121
+
122
+ def worldcover_tile(lat: float, lon: float) -> str:
123
+ """Compute ESA WorldCover 3°×3° tile ID for given coordinates"""
124
+ lat_floor = math.floor(lat / 3) * 3
125
+ lon_floor = math.floor(lon / 3) * 3
126
+
127
+ if lat_floor >= 0:
128
+ lat_label = f"N{int(lat_floor):02d}"
129
+ else:
130
+ lat_label = f"S{int(abs(lat_floor)):02d}"
131
+
132
+ if lon_floor >= 0:
133
+ lon_label = f"E{int(lon_floor):03d}"
134
+ else:
135
+ lon_label = f"W{int(abs(lon_floor)):03d}"
136
+
137
+ tile = lat_label + lon_label
138
+ logging.debug(f"Computed WorldCover tile '{tile}' for ({lat}, {lon}).")
139
+ return tile
140
+
141
+ def sample_worldcover(lat: float, lon: float, year: int = 2020, version: str = "v100") -> Optional[int]:
142
+ """Sample ESA WorldCover class code at given coordinates"""
143
+ tile = worldcover_tile(lat, lon)
144
+ url = (
145
+ f"https://esa-worldcover.s3.eu-central-1.amazonaws.com/"
146
+ f"{version}/{year}/map/ESA_WorldCover_10m_{year}_{version}_{tile}_Map.tif"
147
+ )
148
+
149
+ try:
150
+ with rasterio.Env():
151
+ with rasterio.open(url) as ds:
152
+ # Convert lat/lon to pixel coordinates
153
+ row, col = ds.index(lon, lat)
154
+ arr = ds.read(1, window=((row, row+1), (col, col+1)))
155
+ val = arr[0, 0]
156
+
157
+ if ds.nodata is not None and val == ds.nodata:
158
+ logging.warning(f"WorldCover nodata at ({lat}, {lon}) in tile {tile}.")
159
+ return None
160
+
161
+ logging.debug(f"Sampled WorldCover code {val} at ({lat}, {lon}) in tile {tile}.")
162
+ return int(val)
163
+ except Exception as e:
164
+ logging.error(f"Error sampling WorldCover tile {tile} at ({lat}, {lon}): {e}")
165
+ return None
166
+
167
+ def map_worldcover_to_terrain(code: int, lat: float, lon: float) -> str:
168
+ """Map ESA WorldCover class code to terrain names with geographic heuristics"""
169
+ if code == 10:
170
+ # Tree cover: Jungle in tropics, Woodland elsewhere
171
+ if -23.5 <= lat <= 23.5:
172
+ return "Jungle"
173
+ else:
174
+ return "Woodland"
175
+ elif code == 20:
176
+ return "Shrubland"
177
+ elif code == 30:
178
+ return "Grassland"
179
+ elif code == 40:
180
+ return "Farmland"
181
+ elif code == 50:
182
+ return "Urban"
183
+ elif code == 60:
184
+ return "Desert"
185
+ elif code == 70:
186
+ return "Arctic/Mountain"
187
+ elif code == 80:
188
+ return "Lake"
189
+ elif code == 90:
190
+ return "Swamp"
191
+ elif code == 95:
192
+ return "Swamp" # Mangroves
193
+ elif code == 100:
194
+ return "Tundra"
195
+ else:
196
+ return "Unknown"
197
+
198
+ def get_landcover_name(lat: float, lon: float) -> str:
199
+ """Get terrain name based on ESA WorldCover classification"""
200
+ code = sample_worldcover(lat, lon)
201
+ if code is None:
202
+ return "No Data"
203
+ return map_worldcover_to_terrain(code, lat, lon)
204
+
205
+ def parse_decimal(input_str: str) -> Optional[Tuple[float, float]]:
206
+ """Parse decimal degree coordinates from input string"""
207
+ float_pattern = r"[-+]?\d*\.\d+|\d+"
208
+ nums = re.findall(float_pattern, input_str)
209
+ if len(nums) >= 2:
210
+ try:
211
+ lat = float(nums[0])
212
+ lon = float(nums[1])
213
+ if -90 <= lat <= 90 and -180 <= lon <= 180:
214
+ return lat, lon
215
+ except ValueError:
216
+ return None
217
+ return None
218
+
219
+ def parse_dms(input_str: str) -> Optional[Tuple[float, float]]:
220
+ """Parse DMS (degrees-minutes-seconds) coordinates"""
221
+ dms_pattern = r"""(?P<deg>\d{1,3})[°\s]+(?P<min>\d{1,2})['\s]+(?P<sec>\d{1,2}(?:\.\d+)?)[\"\s]*?(?P<dir>[NSEW])"""
222
+ matches = re.finditer(dms_pattern, input_str, re.IGNORECASE | re.VERBOSE)
223
+ coords = []
224
+
225
+ for m in matches:
226
+ deg = float(m.group('deg'))
227
+ minute = float(m.group('min'))
228
+ sec = float(m.group('sec'))
229
+ direction = m.group('dir').upper()
230
+
231
+ # Convert to decimal degrees
232
+ dec = deg + minute/60.0 + sec/3600.0
233
+ if direction in ('S', 'W'):
234
+ dec = -dec
235
+ coords.append(dec)
236
+
237
+ if len(coords) >= 2:
238
+ lat, lon = coords[0], coords[1]
239
+ if -90 <= lat <= 90 and -180 <= lon <= 180:
240
+ return lat, lon
241
+ return None
242
+
243
+ def geocode_place(place: str) -> Optional[Tuple[float, float]]:
244
+ """Geocode place name using Nominatim API"""
245
+ url = "https://nominatim.openstreetmap.org/search"
246
+ params = {
247
+ 'q': place,
248
+ 'format': 'json',
249
+ 'limit': 1
250
+ }
251
+ headers = {
252
+ 'User-Agent': "terrain-tool/1.0 (contact@example.com)" # Update with your contact
253
+ }
254
+
255
+ try:
256
+ resp = requests.get(url, params=params, headers=headers, timeout=10, verify=False)
257
+ if resp.status_code == 200:
258
+ results = resp.json()
259
+ if results:
260
+ lat = float(results[0]['lat'])
261
+ lon = float(results[0]['lon'])
262
+ logging.info(f"Geocoded '{place}' to ({lat}, {lon})")
263
+ return lat, lon
264
+ else:
265
+ logging.warning(f"No geocoding result for '{place}'.")
266
+ else:
267
+ logging.warning(f"Nominatim HTTP {resp.status_code} for '{place}'.")
268
+ except requests.RequestException as e:
269
+ logging.error(f"Nominatim request exception for '{place}': {e}")
270
+
271
+ return None
272
+
273
+ def parse_location(input_str: str) -> Optional[Tuple[float, float]]:
274
+ """Parse location from various input formats or geocode place name"""
275
+ # Try decimal degrees first
276
+ decimal_coords = parse_decimal(input_str)
277
+ if decimal_coords:
278
+ logging.info(f"Parsed decimal degrees: {decimal_coords}")
279
+ return decimal_coords
280
+
281
+ # Try DMS format
282
+ dms_coords = parse_dms(input_str)
283
+ if dms_coords:
284
+ logging.info(f"Parsed DMS coordinates: {dms_coords}")
285
+ return dms_coords
286
+
287
+ # Fall back to geocoding as place name
288
+ geocoded = geocode_place(input_str)
289
+ if geocoded:
290
+ return geocoded
291
+
292
+ return None
293
+
294
+ def classify_terrain(lat: float, lon: float) -> Dict[str, any]:
295
+ """Classify terrain based on elevation and land cover data"""
296
+ elev = get_elevation_opentopo_single(lat, lon)
297
+
298
+ if elev is None:
299
+ logging.info(f"Elevation unavailable for ({lat}, {lon}); terrain unknown.")
300
+ return {"elevation": None, "terrain_type": "Unknown"}
301
+
302
+ if elev >= 0:
303
+ # Land: use WorldCover classification
304
+ terrain = get_landcover_name(lat, lon)
305
+ return {"elevation": elev, "terrain_type": terrain}
306
+ else:
307
+ # Water: classify by depth
308
+ depth = -elev
309
+ water_class = "Continental Shelf" if depth <= 200 else "Deep Ocean"
310
+ return {"elevation": elev, "terrain_type": water_class}
311
+
312
+ # API Endpoints
313
+ @app.get("/")
314
+ async def root():
315
+ """Serve the main application page"""
316
+ return {"message": "Terrain Analysis API is running. Visit /static/index.html for the web interface."}
317
+
318
+ @app.post("/api/analyze", response_model=TerrainResponse)
319
+ async def analyze_terrain(request: LocationRequest):
320
+ """Analyze terrain for given location input"""
321
+ try:
322
+ # Parse the input location
323
+ coordinates = parse_location(request.input.strip())
324
+
325
+ if coordinates is None:
326
+ raise HTTPException(
327
+ status_code=400,
328
+ detail="Could not parse coordinates or geocode the location. Please check your input format."
329
+ )
330
+
331
+ lat, lon = coordinates
332
+
333
+ # Classify the terrain
334
+ result = classify_terrain(lat, lon)
335
+
336
+ # Add rate limiting delay for OpenTopoData
337
+ time.sleep(1.1)
338
+
339
+ return TerrainResponse(
340
+ latitude=lat,
341
+ longitude=lon,
342
+ elevation=result["elevation"],
343
+ terrain_type=result["terrain_type"],
344
+ success=True,
345
+ message="Terrain analysis completed successfully"
346
+ )
347
+
348
+ except HTTPException:
349
+ raise
350
+ except Exception as e:
351
+ logging.error(f"Error analyzing terrain: {str(e)}")
352
+ raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
353
+
354
+ @app.get("/api/health")
355
+ async def health_check():
356
+ """Health check endpoint"""
357
+ return {"status": "healthy", "service": "Terrain Analysis API"}
358
+
359
+ if __name__ == "__main__":
360
+ import uvicorn
361
+ uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ requests
4
+ rasterio
5
+ pydantic
6
+ urllib3
7
+ python-multipart
static/index.html ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Terrain Analysis Explorer</title>
7
+
8
+ <!-- Esri ArcGIS Maps SDK for JavaScript -->
9
+ <link rel="stylesheet" href="https://js.arcgis.com/4.28/esri/themes/dark/main.css">
10
+ <script src="https://js.arcgis.com/4.28/"></script>
11
+
12
+ <style>
13
+ * {
14
+ margin: 0;
15
+ padding: 0;
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ body {
20
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
21
+ background: #1a1a1a;
22
+ color: #ffffff;
23
+ overflow: hidden;
24
+ }
25
+
26
+ .app-container {
27
+ display: flex;
28
+ height: 100vh;
29
+ width: 100vw;
30
+ }
31
+
32
+ /* Left Panel Styling */
33
+ .left-panel {
34
+ width: 350px;
35
+ background: linear-gradient(135deg, #2c2c2c 0%, #1a1a1a 100%);
36
+ padding: 20px;
37
+ display: flex;
38
+ flex-direction: column;
39
+ box-shadow: 2px 0 15px rgba(0, 0, 0, 0.3);
40
+ border-right: 1px solid #333;
41
+ }
42
+
43
+ .app-header {
44
+ margin-bottom: 30px;
45
+ }
46
+
47
+ .app-title {
48
+ font-size: 24px;
49
+ font-weight: 600;
50
+ color: #00d4ff;
51
+ margin-bottom: 8px;
52
+ background: linear-gradient(45deg, #00d4ff, #0099cc);
53
+ background-clip: text;
54
+ -webkit-background-clip: text;
55
+ -moz-background-clip: text;
56
+ -webkit-text-fill-color: transparent;
57
+ }
58
+
59
+ .app-subtitle {
60
+ font-size: 14px;
61
+ color: #aaa;
62
+ font-weight: 300;
63
+ }
64
+
65
+ /* Input Section */
66
+ .input-section {
67
+ margin-bottom: 30px;
68
+ }
69
+
70
+ .input-label {
71
+ display: block;
72
+ font-size: 14px;
73
+ font-weight: 500;
74
+ color: #ddd;
75
+ margin-bottom: 10px;
76
+ }
77
+
78
+ .location-input {
79
+ width: 100%;
80
+ padding: 12px 16px;
81
+ font-size: 14px;
82
+ background: rgba(255, 255, 255, 0.1);
83
+ border: 1px solid #444;
84
+ border-radius: 8px;
85
+ color: #fff;
86
+ transition: all 0.3s ease;
87
+ backdrop-filter: blur(10px);
88
+ }
89
+
90
+ .location-input:focus {
91
+ outline: none;
92
+ border-color: #00d4ff;
93
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.3);
94
+ background: rgba(255, 255, 255, 0.15);
95
+ }
96
+
97
+ .location-input::placeholder {
98
+ color: #888;
99
+ }
100
+
101
+ .analyze-btn {
102
+ width: 100%;
103
+ padding: 12px;
104
+ margin-top: 15px;
105
+ background: linear-gradient(135deg, #00d4ff 0%, #0099cc 100%);
106
+ color: white;
107
+ border: none;
108
+ border-radius: 8px;
109
+ font-size: 14px;
110
+ font-weight: 600;
111
+ cursor: pointer;
112
+ transition: all 0.3s ease;
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.5px;
115
+ }
116
+
117
+ .analyze-btn:hover {
118
+ transform: translateY(-2px);
119
+ box-shadow: 0 5px 15px rgba(0, 212, 255, 0.4);
120
+ }
121
+
122
+ .analyze-btn:disabled {
123
+ background: #555;
124
+ cursor: not-allowed;
125
+ transform: none;
126
+ box-shadow: none;
127
+ }
128
+
129
+ /* Results Section */
130
+ .results-section {
131
+ flex-grow: 1;
132
+ }
133
+
134
+ .results-title {
135
+ font-size: 18px;
136
+ font-weight: 600;
137
+ color: #fff;
138
+ margin-bottom: 20px;
139
+ padding-bottom: 10px;
140
+ border-bottom: 1px solid #333;
141
+ }
142
+
143
+ .result-item {
144
+ background: rgba(255, 255, 255, 0.05);
145
+ padding: 15px;
146
+ margin-bottom: 15px;
147
+ border-radius: 8px;
148
+ border-left: 4px solid #00d4ff;
149
+ backdrop-filter: blur(10px);
150
+ transition: all 0.3s ease;
151
+ }
152
+
153
+ .result-item:hover {
154
+ background: rgba(255, 255, 255, 0.08);
155
+ transform: translateX(5px);
156
+ }
157
+
158
+ .result-label {
159
+ font-size: 12px;
160
+ color: #aaa;
161
+ text-transform: uppercase;
162
+ letter-spacing: 0.5px;
163
+ margin-bottom: 5px;
164
+ }
165
+
166
+ .result-value {
167
+ font-size: 16px;
168
+ font-weight: 600;
169
+ color: #fff;
170
+ }
171
+
172
+ .coordinates {
173
+ font-family: 'Courier New', monospace;
174
+ font-size: 14px;
175
+ }
176
+
177
+ /* Map Container */
178
+ .map-container {
179
+ flex-grow: 1;
180
+ position: relative;
181
+ }
182
+
183
+ #mapView {
184
+ width: 100%;
185
+ height: 100%;
186
+ }
187
+
188
+ /* Loading Animation */
189
+ .loading {
190
+ display: none;
191
+ align-items: center;
192
+ justify-content: center;
193
+ gap: 10px;
194
+ color: #00d4ff;
195
+ }
196
+
197
+ .loading.active {
198
+ display: flex;
199
+ }
200
+
201
+ .spinner {
202
+ width: 20px;
203
+ height: 20px;
204
+ border: 2px solid #333;
205
+ border-top: 2px solid #00d4ff;
206
+ border-radius: 50%;
207
+ animation: spin 1s linear infinite;
208
+ }
209
+
210
+ @keyframes spin {
211
+ 0% { transform: rotate(0deg); }
212
+ 100% { transform: rotate(360deg); }
213
+ }
214
+
215
+ /* Error/Status Messages */
216
+ .message {
217
+ padding: 10px 15px;
218
+ margin: 15px 0;
219
+ border-radius: 6px;
220
+ font-size: 14px;
221
+ display: none;
222
+ }
223
+
224
+ .message.error {
225
+ background: rgba(255, 99, 99, 0.2);
226
+ border: 1px solid #ff6363;
227
+ color: #ff6363;
228
+ }
229
+
230
+ .message.success {
231
+ background: rgba(99, 255, 99, 0.2);
232
+ border: 1px solid #63ff63;
233
+ color: #63ff63;
234
+ }
235
+
236
+ .message.show {
237
+ display: block;
238
+ }
239
+
240
+ /* Helper Text */
241
+ .helper-text {
242
+ font-size: 12px;
243
+ color: #888;
244
+ margin-top: 10px;
245
+ line-height: 1.4;
246
+ }
247
+
248
+ /* Responsive Design */
249
+ @media (max-width: 768px) {
250
+ .app-container {
251
+ flex-direction: column;
252
+ }
253
+
254
+ .left-panel {
255
+ width: 100%;
256
+ height: auto;
257
+ max-height: 40vh;
258
+ overflow-y: auto;
259
+ }
260
+
261
+ .map-container {
262
+ height: 60vh;
263
+ }
264
+ }
265
+
266
+ /* Custom Scrollbar */
267
+ .left-panel::-webkit-scrollbar {
268
+ width: 6px;
269
+ }
270
+
271
+ .left-panel::-webkit-scrollbar-track {
272
+ background: rgba(255, 255, 255, 0.1);
273
+ border-radius: 3px;
274
+ }
275
+
276
+ .left-panel::-webkit-scrollbar-thumb {
277
+ background: #00d4ff;
278
+ border-radius: 3px;
279
+ }
280
+
281
+ .left-panel::-webkit-scrollbar-thumb:hover {
282
+ background: #0099cc;
283
+ }
284
+ </style>
285
+ </head>
286
+ <body>
287
+ <div class="app-container">
288
+ <!-- Left Panel for Input and Results -->
289
+ <div class="left-panel">
290
+ <div class="app-header">
291
+ <h1 class="app-title">Terrain Explorer</h1>
292
+ <p class="app-subtitle">Analyze elevation and land cover data worldwide</p>
293
+ </div>
294
+
295
+ <!-- Input Section -->
296
+ <div class="input-section">
297
+ <label class="input-label" for="locationInput">Enter Coordinates or Place Name</label>
298
+ <input
299
+ type="text"
300
+ id="locationInput"
301
+ class="location-input"
302
+ placeholder="e.g., 40.7128, -74.0060 or New York City"
303
+ >
304
+ <div class="helper-text">
305
+ Supported formats:<br>
306
+ • Decimal: 40.7128, -74.0060<br>
307
+ • DMS: 40°42'46"N 74°0'21"W<br>
308
+ • Place names: New York City
309
+ </div>
310
+ <button id="analyzeBtn" class="analyze-btn">Analyze Location</button>
311
+ </div>
312
+
313
+ <!-- Loading Indicator -->
314
+ <div id="loadingIndicator" class="loading">
315
+ <div class="spinner"></div>
316
+ <span>Analyzing terrain...</span>
317
+ </div>
318
+
319
+ <!-- Status Messages -->
320
+ <div id="statusMessage" class="message"></div>
321
+
322
+ <!-- Results Section -->
323
+ <div class="results-section">
324
+ <h2 class="results-title">Analysis Results</h2>
325
+
326
+ <div class="result-item">
327
+ <div class="result-label">Coordinates</div>
328
+ <div id="coordinatesResult" class="result-value coordinates">
329
+ Click "Analyze Location" to get started
330
+ </div>
331
+ </div>
332
+
333
+ <div class="result-item">
334
+ <div class="result-label">Elevation</div>
335
+ <div id="elevationResult" class="result-value">
336
+ --
337
+ </div>
338
+ </div>
339
+
340
+ <div class="result-item">
341
+ <div class="result-label">Terrain Type</div>
342
+ <div id="terrainResult" class="result-value">
343
+ --
344
+ </div>
345
+ </div>
346
+ </div>
347
+ </div>
348
+
349
+ <!-- Map Container -->
350
+ <div class="map-container">
351
+ <div id="mapView"></div>
352
+ </div>
353
+ </div>
354
+
355
+ <script>
356
+ // Global variables
357
+ let map, view, currentMarker;
358
+ const API_BASE_URL = window.location.origin;
359
+
360
+ // Initialize the map when the page loads
361
+ require([
362
+ "esri/Map",
363
+ "esri/views/MapView",
364
+ "esri/Graphic",
365
+ "esri/geometry/Point",
366
+ "esri/symbols/SimpleMarkerSymbol",
367
+ "esri/layers/ImageryTileLayer"
368
+ ], function(Map, MapView, Graphic, Point, SimpleMarkerSymbol, ImageryTileLayer) {
369
+
370
+ // Create imagery layer (satellite view)
371
+ const imageryLayer = new ImageryTileLayer({
372
+ url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer",
373
+ title: "World Imagery"
374
+ });
375
+
376
+ // Create the map with satellite imagery
377
+ map = new Map({
378
+ basemap: "satellite",
379
+ layers: [imageryLayer]
380
+ });
381
+
382
+ // Create the map view
383
+ view = new MapView({
384
+ container: "mapView",
385
+ map: map,
386
+ center: [0, 20], // Longitude, Latitude
387
+ zoom: 2,
388
+ ui: {
389
+ components: ["zoom", "compass"]
390
+ }
391
+ });
392
+
393
+ // Set up event listeners after map is loaded
394
+ view.when(() => {
395
+ console.log("Map loaded successfully");
396
+ setupEventListeners();
397
+ });
398
+
399
+ // Handle map click events to get coordinates
400
+ view.on("click", function(event) {
401
+ const lat = event.mapPoint.latitude;
402
+ const lon = event.mapPoint.longitude;
403
+
404
+ // Update input field with clicked coordinates
405
+ document.getElementById('locationInput').value = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
406
+
407
+ // Automatically analyze the clicked location
408
+ analyzeLocation();
409
+ });
410
+ });
411
+
412
+ function setupEventListeners() {
413
+ const analyzeBtn = document.getElementById('analyzeBtn');
414
+ const locationInput = document.getElementById('locationInput');
415
+
416
+ // Set up button click handler
417
+ analyzeBtn.addEventListener('click', analyzeLocation);
418
+
419
+ // Set up Enter key handler for input field
420
+ locationInput.addEventListener('keypress', function(event) {
421
+ if (event.key === 'Enter') {
422
+ analyzeLocation();
423
+ }
424
+ });
425
+ }
426
+
427
+ async function analyzeLocation() {
428
+ const locationInput = document.getElementById('locationInput');
429
+ const inputValue = locationInput.value.trim();
430
+
431
+ if (!inputValue) {
432
+ showMessage('Please enter a location or coordinates', 'error');
433
+ return;
434
+ }
435
+
436
+ // Show loading state
437
+ setLoadingState(true);
438
+ clearMessage();
439
+
440
+ try {
441
+ // Make API request to backend
442
+ const response = await fetch(`${API_BASE_URL}/api/analyze`, {
443
+ method: 'POST',
444
+ headers: {
445
+ 'Content-Type': 'application/json',
446
+ },
447
+ body: JSON.stringify({
448
+ input: inputValue
449
+ })
450
+ });
451
+
452
+ if (!response.ok) {
453
+ const errorData = await response.json();
454
+ throw new Error(errorData.detail || 'Failed to analyze location');
455
+ }
456
+
457
+ const data = await response.json();
458
+
459
+ // Update results display
460
+ updateResults(data);
461
+
462
+ // Update map with marker
463
+ updateMapMarker(data.latitude, data.longitude);
464
+
465
+ // Show success message
466
+ showMessage('Analysis completed successfully!', 'success');
467
+
468
+ } catch (error) {
469
+ console.error('Error analyzing location:', error);
470
+ showMessage(`Error: ${error.message}`, 'error');
471
+ } finally {
472
+ setLoadingState(false);
473
+ }
474
+ }
475
+
476
+ function updateResults(data) {
477
+ // Update coordinates display
478
+ const coordsElement = document.getElementById('coordinatesResult');
479
+ coordsElement.textContent = `${data.latitude.toFixed(6)}, ${data.longitude.toFixed(6)}`;
480
+
481
+ // Update elevation display
482
+ const elevationElement = document.getElementById('elevationResult');
483
+ const elevationValue = data.elevation !== null ? `${data.elevation.toFixed(1)} m` : 'No data available';
484
+ elevationElement.textContent = elevationValue;
485
+
486
+ // Update terrain type display
487
+ const terrainElement = document.getElementById('terrainResult');
488
+ terrainElement.textContent = data.terrain_type || 'Unknown';
489
+ }
490
+
491
+ function updateMapMarker(latitude, longitude) {
492
+ require([
493
+ "esri/Graphic",
494
+ "esri/geometry/Point",
495
+ "esri/symbols/SimpleMarkerSymbol"
496
+ ], function(Graphic, Point, SimpleMarkerSymbol) {
497
+
498
+ // Remove existing marker if present
499
+ if (currentMarker) {
500
+ view.graphics.remove(currentMarker);
501
+ }
502
+
503
+ // Create new marker
504
+ const point = new Point({
505
+ longitude: longitude,
506
+ latitude: latitude
507
+ });
508
+
509
+ const markerSymbol = new SimpleMarkerSymbol({
510
+ color: [0, 212, 255],
511
+ size: 12,
512
+ outline: {
513
+ color: [255, 255, 255],
514
+ width: 2
515
+ }
516
+ });
517
+
518
+ currentMarker = new Graphic({
519
+ geometry: point,
520
+ symbol: markerSymbol
521
+ });
522
+
523
+ // Add marker to map
524
+ view.graphics.add(currentMarker);
525
+
526
+ // Center map on the marker
527
+ view.goTo({
528
+ center: [longitude, latitude],
529
+ zoom: 10
530
+ }, {
531
+ duration: 2000
532
+ });
533
+ });
534
+ }
535
+
536
+ function setLoadingState(isLoading) {
537
+ const loadingIndicator = document.getElementById('loadingIndicator');
538
+ const analyzeBtn = document.getElementById('analyzeBtn');
539
+
540
+ if (isLoading) {
541
+ loadingIndicator.classList.add('active');
542
+ analyzeBtn.disabled = true;
543
+ analyzeBtn.textContent = 'Analyzing...';
544
+ } else {
545
+ loadingIndicator.classList.remove('active');
546
+ analyzeBtn.disabled = false;
547
+ analyzeBtn.textContent = 'Analyze Location';
548
+ }
549
+ }
550
+
551
+ function showMessage(message, type) {
552
+ const messageElement = document.getElementById('statusMessage');
553
+ messageElement.textContent = message;
554
+ messageElement.className = `message ${type} show`;
555
+
556
+ // Auto-hide success messages after 3 seconds
557
+ if (type === 'success') {
558
+ setTimeout(() => {
559
+ clearMessage();
560
+ }, 3000);
561
+ }
562
+ }
563
+
564
+ function clearMessage() {
565
+ const messageElement = document.getElementById('statusMessage');
566
+ messageElement.className = 'message';
567
+ }
568
+
569
+ // Example locations for demonstration
570
+ const exampleLocations = [
571
+ { name: 'Mount Everest', coords: '27.9881, 86.9250' },
572
+ { name: 'Death Valley', coords: '36.5323, -117.0143' },
573
+ { name: 'Amazon Rainforest', coords: '-3.4653, -62.2159' },
574
+ { name: 'Sahara Desert', coords: '23.8859, 11.0167' },
575
+ { name: 'New York City', coords: '40.7128, -74.0060' }
576
+ ];
577
+
578
+ // Add some sample locations to help users get started
579
+ function addExampleLocations() {
580
+ const inputSection = document.querySelector('.input-section');
581
+ const examplesDiv = document.createElement('div');
582
+ examplesDiv.className = 'examples-section';
583
+ examplesDiv.innerHTML = `
584
+ <div class="helper-text" style="margin-top: 20px; margin-bottom: 10px;">
585
+ <strong>Try these examples:</strong>
586
+ </div>
587
+ `;
588
+
589
+ exampleLocations.forEach(location => {
590
+ const exampleBtn = document.createElement('button');
591
+ exampleBtn.className = 'example-btn';
592
+ exampleBtn.textContent = location.name;
593
+ exampleBtn.style.cssText = `
594
+ display: inline-block;
595
+ margin: 2px 5px 2px 0;
596
+ padding: 4px 8px;
597
+ background: rgba(0, 212, 255, 0.2);
598
+ border: 1px solid rgba(0, 212, 255, 0.5);
599
+ border-radius: 4px;
600
+ color: #00d4ff;
601
+ font-size: 11px;
602
+ cursor: pointer;
603
+ transition: all 0.3s ease;
604
+ `;
605
+
606
+ exampleBtn.addEventListener('click', () => {
607
+ document.getElementById('locationInput').value = location.coords;
608
+ analyzeLocation();
609
+ });
610
+
611
+ exampleBtn.addEventListener('mouseenter', () => {
612
+ exampleBtn.style.background = 'rgba(0, 212, 255, 0.3)';
613
+ });
614
+
615
+ exampleBtn.addEventListener('mouseleave', () => {
616
+ exampleBtn.style.background = 'rgba(0, 212, 255, 0.2)';
617
+ });
618
+
619
+ examplesDiv.appendChild(exampleBtn);
620
+ });
621
+
622
+ inputSection.appendChild(examplesDiv);
623
+ }
624
+
625
+ // Initialize example locations when page loads
626
+ document.addEventListener('DOMContentLoaded', () => {
627
+ addExampleLocations();
628
+ });
629
+
630
+ // Handle window resize to maintain map responsiveness
631
+ window.addEventListener('resize', () => {
632
+ if (view) {
633
+ view.when(() => {
634
+ view.resize();
635
+ });
636
+ }
637
+ });
638
+ </script>
639
+ </body>
640
+ </html>