diff --git a/.gitignore b/.gitignore
index c11dd2c..e9c9d39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,4 @@ package-lock.json
roadcast/data.csv
web/public/Crashes_in_DC.csv
ai/Crashes_in_DC.csv
+llm/INTEGRATION_GUIDE.md
diff --git a/llm/api/INTEGRATION_GUIDE.md b/llm/api/INTEGRATION_GUIDE.md
deleted file mode 100644
index 7884b5d..0000000
--- a/llm/api/INTEGRATION_GUIDE.md
+++ /dev/null
@@ -1,354 +0,0 @@
-# Flask API Integration Guide for Next.js
-
-## ๐ Flask API Server
-
-Your Flask API server is now ready and running on **`http://localhost:5001`**
-
-### ๐ Available Endpoints
-
-| Method | Endpoint | Description |
-|--------|----------|-------------|
-| `GET` | `/api/health` | Health check endpoint |
-| `GET` | `/api/weather?lat=X&lon=Y` | Get weather conditions |
-| `POST` | `/api/analyze-crashes` | Analyze crash patterns at location |
-| `POST` | `/api/find-safe-route` | Find safest route between points |
-| `POST` | `/api/get-single-route` | Get single route with safety analysis |
-
----
-
-## ๐ฆ Starting the Server
-
-```bash
-cd /path/to/VTHacks13/llm
-python api/flask_server.py
-```
-
-The server will start on `http://localhost:5001` with the following services:
-- โ
MongoDB connection to crash database
-- โ
Route safety analysis
-- โ
Weather API integration (Open-Meteo)
-- โ
Gemini AI for safety recommendations
-
----
-
-## ๐ง Next.js Integration
-
-### 1. Install Dependencies
-
-```bash
-npm install axios # or use fetch API
-```
-
-### 2. Create API Client
-
-Create `lib/api-client.js`:
-
-```javascript
-const API_BASE_URL = 'http://localhost:5001/api';
-
-// Health Check
-export async function checkAPIHealth() {
- const response = await fetch(`${API_BASE_URL}/health`);
- return response.json();
-}
-
-// Weather API
-export async function getWeather(lat, lon) {
- const response = await fetch(`${API_BASE_URL}/weather?lat=${lat}&lon=${lon}`);
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.error || 'Failed to fetch weather');
- }
-
- return data;
-}
-
-// Crash Analysis
-export async function analyzeCrashes(lat, lon, radius = 1.0) {
- const response = await fetch(`${API_BASE_URL}/analyze-crashes`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ lat, lon, radius })
- });
-
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.error || 'Failed to analyze crashes');
- }
-
- return data;
-}
-
-// Safe Route Finding
-export async function findSafeRoute(startLat, startLon, endLat, endLon) {
- const response = await fetch(`${API_BASE_URL}/find-safe-route`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- start_lat: startLat,
- start_lon: startLon,
- end_lat: endLat,
- end_lon: endLon
- })
- });
-
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.error || 'Failed to find safe route');
- }
-
- return data;
-}
-
-// Single Route
-export async function getSingleRoute(startLat, startLon, endLat, endLon, profile = 'driving') {
- const response = await fetch(`${API_BASE_URL}/get-single-route`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- start_lat: startLat,
- start_lon: startLon,
- end_lat: endLat,
- end_lon: endLon,
- profile
- })
- });
-
- const data = await response.json();
-
- if (!response.ok) {
- throw new Error(data.error || 'Failed to get route');
- }
-
- return data;
-}
-```
-
-### 3. Example React Component
-
-Create `components/SafetyAnalysis.jsx`:
-
-```jsx
-import { useState } from 'react';
-import { analyzeCrashes, findSafeRoute, getWeather } from '../lib/api-client';
-
-export default function SafetyAnalysis() {
- const [loading, setLoading] = useState(false);
- const [results, setResults] = useState(null);
- const [error, setError] = useState(null);
-
- const handleAnalyze = async () => {
- setLoading(true);
- setError(null);
-
- try {
- // Example: Analyze crashes around Virginia Tech
- const lat = 37.2284;
- const lon = -80.4234;
- const radius = 2.0;
-
- // Get crash analysis
- const crashData = await analyzeCrashes(lat, lon, radius);
-
- // Get safe route (Virginia Tech to Downtown Blacksburg)
- const routeData = await findSafeRoute(lat, lon, 37.2297, -80.4139);
-
- // Get weather
- const weatherData = await getWeather(lat, lon);
-
- setResults({
- crashes: crashData,
- route: routeData,
- weather: weatherData
- });
-
- } catch (err) {
- setError(err.message);
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
-
Safety Analysis
-
-
- {loading ? 'Analyzing...' : 'Analyze Safety'}
-
-
- {error && (
-
- Error: {error}
-
- )}
-
- {results && (
-
- {/* Crash Analysis Results */}
-
-
Crash Analysis
-
Total crashes: {results.crashes.crash_summary.total_crashes}
-
Total casualties: {results.crashes.crash_summary.total_casualties}
-
Weather: {results.crashes.weather.summary}
-
-
- {/* Route Results */}
-
-
Safe Route
-
Distance: {results.route.recommended_route.distance_km.toFixed(1)} km
-
Duration: {results.route.recommended_route.duration_min.toFixed(0)} minutes
-
Crashes nearby: {results.route.recommended_route.crashes_nearby}
-
Safety score: {results.route.recommended_route.safety_score.toFixed(3)}
-
-
- {/* Weather Results */}
-
-
Current Weather
-
{results.weather.summary}
-
-
- )}
-
- );
-}
-```
-
-### 4. Mapbox Integration
-
-For route visualization:
-
-```jsx
-import { useEffect, useRef } from 'react';
-import mapboxgl from 'mapbox-gl';
-
-export default function RouteMap({ routeData }) {
- const mapContainer = useRef(null);
- const map = useRef(null);
-
- useEffect(() => {
- if (!routeData || map.current) return;
-
- map.current = new mapboxgl.Map({
- container: mapContainer.current,
- style: 'mapbox://styles/mapbox/streets-v12',
- center: [routeData.recommended_route.coordinates[0][0],
- routeData.recommended_route.coordinates[0][1]],
- zoom: 13
- });
-
- // Add route to map
- map.current.on('load', () => {
- map.current.addSource('route', {
- 'type': 'geojson',
- 'data': {
- 'type': 'Feature',
- 'properties': {},
- 'geometry': routeData.recommended_route.geometry
- }
- });
-
- map.current.addLayer({
- 'id': 'route',
- 'type': 'line',
- 'source': 'route',
- 'layout': {
- 'line-join': 'round',
- 'line-cap': 'round'
- },
- 'paint': {
- 'line-color': '#3887be',
- 'line-width': 5,
- 'line-opacity': 0.75
- }
- });
- });
- }, [routeData]);
-
- return
;
-}
-```
-
----
-
-## ๐ Response Formats
-
-### Crash Analysis Response
-```json
-{
- "success": true,
- "location": {"lat": 37.2284, "lon": -80.4234},
- "radius_km": 2.0,
- "crash_summary": {
- "total_crashes": 5,
- "avg_distance_km": 1.2,
- "severity_breakdown": {"Minor": 3, "Major": 2},
- "total_casualties": 8
- },
- "weather": {
- "summary": "Clear sky, precipitation 0.0mm/h, wind 5.2 km/h, day"
- },
- "safety_analysis": "AI-generated safety report..."
-}
-```
-
-### Safe Route Response
-```json
-{
- "success": true,
- "recommended_route": {
- "coordinates": [[lon, lat], [lon, lat], ...],
- "distance_km": 1.5,
- "duration_min": 4.2,
- "geometry": {...}, // GeoJSON for Mapbox
- "safety_score": 0.234,
- "crashes_nearby": 2
- },
- "safety_analysis": "AI-generated route safety report...",
- "weather_summary": "Current weather conditions...",
- "alternative_routes": [...]
-}
-```
-
----
-
-## ๐งช Testing the API
-
-Run the test script:
-```bash
-cd llm/api
-python test_api.py
-```
-
-Or test individual endpoints with curl:
-```bash
-# Health check
-curl http://localhost:5001/api/health
-
-# Weather
-curl "http://localhost:5001/api/weather?lat=37.2284&lon=-80.4234"
-
-# Crash analysis
-curl -X POST http://localhost:5001/api/analyze-crashes \
- -H "Content-Type: application/json" \
- -d '{"lat": 37.2284, "lon": -80.4234, "radius": 1.0}'
-```
-
----
-
-## ๐ฏ Next Steps
-
-1. **Start Flask Server**: `cd llm && python api/flask_server.py`
-2. **Test Endpoints**: Use the provided test scripts
-3. **Integrate with Next.js**: Use the API client code above
-4. **Add to Your Components**: Import and use the API functions
-5. **Visualize Routes**: Use Mapbox with the route coordinates
-
-Your Flask API is ready to bridge your Python AI/safety analysis with your Next.js frontend! ๐
\ No newline at end of file
diff --git a/llm/api/flask_server.py b/llm/flask_server.py
similarity index 91%
rename from llm/api/flask_server.py
rename to llm/flask_server.py
index 65ae553..9b2e373 100644
--- a/llm/api/flask_server.py
+++ b/llm/flask_server.py
@@ -2,21 +2,25 @@ from flask import Flask, request, jsonify
from flask_cors import CORS
import sys
import os
+import re
import traceback
from datetime import datetime
import json
from bson import ObjectId
-# Since we're now in llm/api/, we need to add the parent directory (llm) to Python path
+# Ensure repo root is on sys.path so local imports work whether this script
+# is run from the repo root or from inside the `llm/` folder.
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
# Import our existing modules from the same llm directory
try:
+ # These modules live in the `llm/` folder (not `llm/api/`), so import them
+ # as local modules when running this script directly.
from gemini_mongo_mateo import (
- connect_to_mongodb,
- get_crashes_within_radius_mongodb,
- analyze_mongodb_crash_patterns,
- get_current_weather
+ connect_to_mongodb,
+ get_crashes_within_radius_mongodb,
+ analyze_mongodb_crash_patterns,
+ get_current_weather,
)
from gemini_reroute_mateo import SafeRouteAnalyzer, MONGO_URI
print("โ
Successfully imported Python modules")
@@ -144,6 +148,22 @@ def analyze_crashes_endpoint():
crashes, lat, lon, radius_km, weather_summary
)
+ # Remove markdown formatting from safety analysis
+ if safety_analysis:
+ # Remove markdown headers (### or **)
+ safety_analysis = re.sub(r'#+\s*', '', safety_analysis)
+ # Remove bold formatting (**)
+ safety_analysis = re.sub(r'\*\*([^*]+)\*\*', r'\1', safety_analysis)
+ # Remove italic formatting (*)
+ safety_analysis = re.sub(r'\*([^*]+)\*', r'\1', safety_analysis)
+ # Remove bullet points and clean up spacing
+ safety_analysis = re.sub(r'^\s*[\*\-\โข]\s*', '', safety_analysis, flags=re.MULTILINE)
+ # Clean up multiple newlines
+ safety_analysis = re.sub(r'\n\s*\n', '\n\n', safety_analysis)
+ # Clean up extra spaces
+ safety_analysis = re.sub(r'\s+', ' ', safety_analysis)
+ safety_analysis = safety_analysis.strip()
+
# Calculate some basic statistics
total_crashes = len(crashes)
avg_distance = sum(crash.get('distance_km', 0) for crash in crashes) / total_crashes if crashes else 0
diff --git a/llm/api/requirements.txt b/llm/requirements.txt
similarity index 100%
rename from llm/api/requirements.txt
rename to llm/requirements.txt
diff --git a/llm/api/test_api.py b/llm/test_api.py
similarity index 100%
rename from llm/api/test_api.py
rename to llm/test_api.py
diff --git a/llm/api/test_crash_endpoint.py b/llm/test_crash_endpoint.py
similarity index 100%
rename from llm/api/test_crash_endpoint.py
rename to llm/test_crash_endpoint.py
diff --git a/llm/test_flask_endpoints.py b/llm/test_flask_endpoints.py
new file mode 100644
index 0000000..1338aeb
--- /dev/null
+++ b/llm/test_flask_endpoints.py
@@ -0,0 +1,285 @@
+"""
+Lightweight tests for llm/flask_server.py endpoints.
+This script injects fake local modules to avoid external network/DB calls,
+imports the flask app, and uses Flask's test_client to call endpoints.
+"""
+import sys
+import os
+import types
+import json
+import traceback
+
+# Prepare fake modules to avoid external dependencies (MongoDB, external APIs, LLMs)
+fake_mongo = types.ModuleType("gemini_mongo_mateo")
+
+def fake_connect_to_mongodb():
+ # Return a simple truthy object representing a connection/collection
+ return {"fake": "collection"}
+
+def fake_get_crashes_within_radius_mongodb(collection, lat, lon, radius_km):
+ return []
+
+def fake_analyze_mongodb_crash_patterns(crashes, lat, lon, radius_km, weather_summary=None):
+ return "No crash data available (fake)"
+
+def fake_get_current_weather(lat, lon):
+ return ({"temp": 20, "weather_code": 0}, "Clear sky")
+
+fake_mongo.connect_to_mongodb = fake_connect_to_mongodb
+fake_mongo.get_crashes_within_radius_mongodb = fake_get_crashes_within_radius_mongodb
+fake_mongo.analyze_mongodb_crash_patterns = fake_analyze_mongodb_crash_patterns
+fake_mongo.get_current_weather = fake_get_current_weather
+fake_mongo.MONGO_URI = "mongodb://fake"
+
+# Fake reroute module
+fake_reroute = types.ModuleType("gemini_reroute_mateo")
+
+class FakeSafeRouteAnalyzer:
+ def __init__(self, uri):
+ self.collection = {"fake": "collection"}
+
+ def find_safer_route(self, start_lat, start_lon, end_lat, end_lon):
+ return {
+ 'recommended_route': {
+ 'route_data': {
+ 'coordinates': [[start_lat, start_lon], [end_lat, end_lon]],
+ 'distance_km': 1.23,
+ 'duration_min': 4.5,
+ 'geometry': None
+ },
+ 'safety_analysis': {
+ 'average_safety_score': 0.0,
+ 'total_crashes_near_route': 0,
+ 'max_danger_score': 0.0
+ }
+ },
+ 'safety_report': 'All good (fake)',
+ 'alternative_routes': []
+ }
+
+ def get_route_from_mapbox(self, start_lat, start_lon, end_lat, end_lon, profile='driving'):
+ return {
+ 'success': True,
+ 'coordinates': [[start_lat, start_lon], [end_lat, end_lon]],
+ 'distance_km': 1.0,
+ 'duration_min': 3.0,
+ 'geometry': None
+ }
+
+ def analyze_route_safety(self, coordinates):
+ return {
+ 'total_crashes_near_route': 0,
+ 'average_safety_score': 0.0,
+ 'max_danger_score': 0.0,
+ 'safety_points': [],
+ 'crashes_data': [],
+ 'route_length_points': len(coordinates)
+ }
+
+ def get_current_weather(self, lat, lon):
+ return fake_get_current_weather(lat, lon)
+
+ def generate_safety_report_with_llm(self, safety_data, route_info, weather_summary=None):
+ return "Fake LLM report"
+
+fake_reroute.SafeRouteAnalyzer = FakeSafeRouteAnalyzer
+fake_reroute.MONGO_URI = "mongodb://fake"
+
+# Insert fake modules into sys.modules so importing flask_server uses them
+sys.modules['gemini_mongo_mateo'] = fake_mongo
+sys.modules['gemini_reroute_mateo'] = fake_reroute
+
+# Also provide package-style names in case flask_server tries them
+sys.modules['llm.gemini_mongo_mateo'] = fake_mongo
+sys.modules['llm.gemini_reroute_mateo'] = fake_reroute
+
+# If Flask isn't installed in the environment, provide a minimal fake
+# implementation sufficient for this test: Flask, request, jsonify and a
+# test_client that can call registered route handlers.
+if 'flask' not in sys.modules:
+ import types
+
+ flask_mod = types.ModuleType('flask')
+
+ class FakeRequest:
+ def __init__(self):
+ self.args = {}
+ self._json = None
+
+ def get_json(self, force=False):
+ return self._json
+
+ class FakeResponse:
+ def __init__(self, data, status_code=200):
+ self.data = data
+ self.status_code = status_code
+
+ def get_json(self):
+ # If data already a dict, return it; if string, try parse
+ if isinstance(self.data, dict):
+ return self.data
+ try:
+ return json.loads(self.data)
+ except Exception:
+ return None
+
+ class FakeApp:
+ def __init__(self, name=None):
+ self._routes = {}
+ self.request = FakeRequest()
+
+ def route(self, path, methods=None):
+ methods = methods or ['GET']
+ def decorator(fn):
+ self._routes[(path, tuple(sorted(methods)))] = fn
+ # Also store by path for simpler lookup
+ self._routes[path] = fn
+ return fn
+ return decorator
+
+ def test_client(self):
+ app = self
+ class Client:
+ def get(self, path):
+ # parse querystring
+ if '?' in path:
+ route, qs = path.split('?', 1)
+ params = {}
+ for pair in qs.split('&'):
+ if '=' in pair:
+ k, v = pair.split('=', 1)
+ params[k] = v
+ else:
+ route = path
+ params = {}
+ app.request.args = params
+ handler = app._routes.get(route)
+ if handler is None:
+ return FakeResponse({'error': 'not found'}, status_code=404)
+ try:
+ result = handler()
+ if isinstance(result, tuple):
+ body, code = result
+ return FakeResponse(body, status_code=code)
+ return FakeResponse(result, status_code=200)
+ except Exception as e:
+ return FakeResponse({'error': str(e)}, status_code=500)
+
+ def post(self, path, json=None):
+ app.request._json = json
+ handler = app._routes.get(path)
+ if handler is None:
+ return FakeResponse({'error': 'not found'}, status_code=404)
+ try:
+ result = handler()
+ if isinstance(result, tuple):
+ body, code = result
+ return FakeResponse(body, status_code=code)
+ return FakeResponse(result, status_code=200)
+ except Exception as e:
+ traceback.print_exc()
+ return FakeResponse({'error': str(e)}, status_code=500)
+
+ return Client()
+
+ def errorhandler(self, code):
+ def decorator(fn):
+ # store error handlers by code
+ if not hasattr(self, '_error_handlers'):
+ self._error_handlers = {}
+ self._error_handlers[code] = fn
+ return fn
+ return decorator
+
+ def fake_jsonify(obj):
+ return obj
+
+ # Populate module
+ flask_mod.Flask = FakeApp
+ flask_mod.request = FakeRequest()
+ flask_mod.jsonify = fake_jsonify
+
+ sys.modules['flask'] = flask_mod
+
+# Minimal flask_cors shim
+if 'flask_cors' not in sys.modules:
+ fc = types.ModuleType('flask_cors')
+ def fake_CORS(app):
+ return None
+ fc.CORS = fake_CORS
+ sys.modules['flask_cors'] = fc
+
+# Load flask_server module from file without executing its __main__ block
+import importlib.util
+
+this_dir = os.path.dirname(__file__)
+flask_file = os.path.join(this_dir, 'flask_server.py')
+
+spec = importlib.util.spec_from_file_location('flask_server', flask_file)
+flask_server = importlib.util.module_from_spec(spec)
+# Ensure the module sees the fake modules we inserted
+sys.modules['flask_server'] = flask_server
+try:
+ spec.loader.exec_module(flask_server)
+except Exception as e:
+ print('Failed to import flask_server:', e)
+ traceback.print_exc()
+ sys.exit(2)
+
+app = getattr(flask_server, 'app', None)
+if app is None:
+ print('flask_server.app not found')
+ sys.exit(3)
+
+client = app.test_client()
+
+results = {}
+
+# 1) Health
+r = client.get('/api/health')
+try:
+ results['health'] = {'status_code': r.status_code, 'json': r.get_json()}
+except Exception:
+ results['health'] = {'status_code': r.status_code, 'data': r.data.decode('utf-8')}
+
+# 2) Weather (valid coords)
+r = client.get('/api/weather?lat=38.9072&lon=-77.0369')
+results['weather_ok'] = {'status_code': r.status_code, 'json': r.get_json()}
+
+# 3) Weather (invalid coords)
+r = client.get('/api/weather')
+results['weather_invalid'] = {'status_code': r.status_code, 'json': r.get_json()}
+
+# 4) Analyze crashes (POST)
+payload = {'lat': 38.9072, 'lon': -77.0369, 'radius': 1.0}
+r = client.post('/api/analyze-crashes', json=payload)
+results['analyze_crashes'] = {'status_code': r.status_code, 'json': r.get_json()}
+
+# 5) Find safe route
+route_payload = {'start_lat': 38.9, 'start_lon': -77.0, 'end_lat': 38.95, 'end_lon': -77.04}
+r = client.post('/api/find-safe-route', json=route_payload)
+results['find_safe_route'] = {'status_code': r.status_code, 'json': r.get_json()}
+
+# 6) Get single route
+single_payload = {'start_lat': 38.9, 'start_lon': -77.0, 'end_lat': 38.95, 'end_lon': -77.04}
+r = client.post('/api/get-single-route', json=single_payload)
+results['get_single_route'] = {'status_code': r.status_code, 'json': r.get_json()}
+
+print('\n=== Endpoint test results ===')
+print(json.dumps(results, indent=2, default=str))
+
+# Summarize endpoints
+summary = {
+ '/api/health': 'GET - returns service + dependency health',
+ '/api/weather': 'GET - requires lat & lon query params; returns current weather from LLM module',
+ '/api/analyze-crashes': 'POST - requires JSON {lat, lon, radius}; returns crash summary, weather, LLM safety analysis',
+ '/api/find-safe-route': 'POST - requires JSON start_lat,start_lon,end_lat,end_lon; returns recommended route with safety analysis',
+ '/api/get-single-route': 'POST - similar to find-safe-route but returns single route + LLM safety report'
+}
+
+print('\n=== Endpoint summary ===')
+for path, desc in summary.items():
+ print(f"{path}: {desc}")
+
+# Exit code 0
+sys.exit(0)
diff --git a/web/src/app/components/MapView.tsx b/web/src/app/components/MapView.tsx
index b258ad3..6ae73a9 100644
--- a/web/src/app/components/MapView.tsx
+++ b/web/src/app/components/MapView.tsx
@@ -8,6 +8,7 @@ import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils';
import { useCrashData, UseCrashDataResult } from '../hooks/useCrashData';
import { CrashData } from '../api/crashes/route';
+import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi';
export type PopupData = {
lngLat: [number, number];
@@ -27,7 +28,12 @@ export type PopupData = {
propertyOnly: number;
};
crashes?: any[]; // Top 5 nearby crashes
- }
+ };
+ // New API data
+ weather?: WeatherData;
+ crashAnalysis?: CrashAnalysisData;
+ apiError?: string;
+ isLoadingApi?: boolean;
} | null;
interface MapViewProps {
diff --git a/web/src/app/components/PopupOverlay.tsx b/web/src/app/components/PopupOverlay.tsx
index dd2dad5..1a9c4da 100644
--- a/web/src/app/components/PopupOverlay.tsx
+++ b/web/src/app/components/PopupOverlay.tsx
@@ -3,6 +3,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import mapboxgl from 'mapbox-gl';
import type { PopupData } from './MapView';
+import { fetchWeatherData, fetchCrashAnalysis, type WeatherData, type CrashAnalysisData } from '../../lib/flaskApi';
interface Props {
popup: PopupData;
@@ -10,12 +11,64 @@ interface Props {
mapRef: React.MutableRefObject;
onClose: () => void;
autoDismissMs?: number; // Auto-dismiss timeout in milliseconds, default 5000 (5 seconds)
+ onOpenModal?: (data: { weather?: WeatherData; crashAnalysis?: CrashAnalysisData; coordinates?: [number, number] }) => void;
}
-export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, autoDismissMs = 5000 }: Props) {
+export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, autoDismissMs = 5000, onOpenModal }: Props) {
const [isHovered, setIsHovered] = useState(false);
const [timeLeft, setTimeLeft] = useState(autoDismissMs);
const [popupPosition, setPopupPosition] = useState({ left: 0, top: 0, transform: 'translate(-50%, -100%)', arrowPosition: 'bottom' });
+ const [aiDataLoaded, setAiDataLoaded] = useState(false);
+
+ // API data states
+ const [apiData, setApiData] = useState<{
+ weather?: WeatherData;
+ crashAnalysis?: CrashAnalysisData;
+ }>({});
+ const [apiLoading, setApiLoading] = useState(false);
+ const [apiError, setApiError] = useState(null);
+
+ // Fetch API data when popup opens
+ useEffect(() => {
+ if (!popup || !popupVisible) {
+ setApiData({});
+ setApiError(null);
+ setAiDataLoaded(false);
+ return;
+ }
+
+ const fetchApiData = async () => {
+ const [lat, lon] = [popup.lngLat[1], popup.lngLat[0]];
+
+ setApiLoading(true);
+ setApiError(null);
+ setAiDataLoaded(false);
+
+ try {
+ // Fetch both weather and crash analysis data
+ const [weatherData, crashAnalysisData] = await Promise.all([
+ fetchWeatherData(lat, lon),
+ fetchCrashAnalysis(lat, lon)
+ ]);
+
+ setApiData({
+ weather: weatherData,
+ crashAnalysis: crashAnalysisData,
+ });
+ setAiDataLoaded(true); // Mark AI data as loaded
+ } catch (error) {
+ setApiError(error instanceof Error ? error.message : 'Unknown error occurred');
+ setAiDataLoaded(true); // Still mark as "loaded" even if failed, so timer can start
+ } finally {
+ setApiLoading(false);
+ }
+ };
+
+ // Fetch API data with a small delay to avoid too many requests
+ const timeoutId = setTimeout(fetchApiData, 300);
+
+ return () => clearTimeout(timeoutId);
+ }, [popup, popupVisible]);
// Calculate smart popup positioning
const calculatePopupPosition = (clickPoint: mapboxgl.Point) => {
@@ -29,7 +82,14 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const popupWidth = 350; // max-width from styles
- const popupHeight = 200; // estimated height
+
+ // Estimate height based on content - larger when AI data is loaded
+ let popupHeight = 180; // base height for basic popup
+ if (apiData.weather || apiData.crashAnalysis) {
+ // Use a more conservative estimate - the AI content can be quite long
+ popupHeight = Math.min(500, viewportHeight * 0.75); // Cap at 75% of viewport height
+ }
+
const padding = 20; // padding from screen edges
let left = clickPoint.x;
@@ -54,22 +114,60 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
transform = 'translateX(-50%)';
}
- // Determine vertical position
- if (clickPoint.y - popupHeight - padding < 0) {
+ // Determine vertical position - prioritize keeping popup in viewport
+ const spaceAbove = clickPoint.y - padding;
+ const spaceBelow = viewportHeight - clickPoint.y - padding;
+
+ // Simple logic: try below first, then above, then force fit
+ if (spaceBelow >= popupHeight) {
// Position below cursor
- top = clickPoint.y + 10;
- transform += ' translateY(0%)';
- arrowPosition = arrowPosition === 'bottom' ? 'top' : arrowPosition;
+ top = clickPoint.y + 15;
+ arrowPosition = arrowPosition === 'right' || arrowPosition === 'left' ? arrowPosition : 'top';
+ } else if (spaceAbove >= popupHeight) {
+ // Position above cursor
+ top = clickPoint.y - popupHeight - 15;
+ arrowPosition = arrowPosition === 'right' || arrowPosition === 'left' ? arrowPosition : 'bottom';
} else {
- // Position above cursor (default)
- top = clickPoint.y - 10;
- transform += ' translateY(-100%)';
+ // Force fit - use the side with more space
+ if (spaceBelow > spaceAbove) {
+ top = Math.max(padding, viewportHeight - popupHeight - padding);
+ } else {
+ top = padding;
+ }
+ arrowPosition = arrowPosition === 'right' || arrowPosition === 'left' ? arrowPosition : 'none';
+ }
+
+ // Always use translateX for horizontal, no vertical transform complications
+ // The top position is already calculated to place the popup correctly
+
+ // Final bounds checking - be very aggressive about keeping popup in viewport
+ if (left < padding) left = padding;
+ if (left + popupWidth > viewportWidth - padding) left = viewportWidth - popupWidth - padding;
+
+ // Ensure popup stays within vertical bounds - no transform complications
+ if (top < padding) {
+ top = padding;
+ }
+ if (top + popupHeight > viewportHeight - padding) {
+ top = Math.max(padding, viewportHeight - popupHeight - padding);
+ }
+
+ // Debug logging to understand positioning issues
+ if (apiData.weather || apiData.crashAnalysis) {
+ console.log('Popup positioning debug:', {
+ clickPoint: { x: clickPoint.x, y: clickPoint.y },
+ viewport: { width: viewportWidth, height: viewportHeight },
+ popupHeight,
+ spaceAbove: clickPoint.y - padding,
+ spaceBelow: viewportHeight - clickPoint.y - padding,
+ finalPosition: { left, top, transform }
+ });
}
return { left, top, transform, arrowPosition };
};
- // Update popup position when popup data changes or map moves
+ // Update popup position when popup data changes, map moves, or AI data loads
useEffect(() => {
if (!popup || !mapRef.current) return;
@@ -91,12 +189,27 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
map.off('move', updatePosition);
map.off('zoom', updatePosition);
};
- }, [popup, mapRef]);
+ }, [popup, mapRef, apiData]); // Added apiData to dependencies
- // Auto-dismiss timer with progress
+ // Immediate repositioning when AI data loads (separate from map events)
useEffect(() => {
- if (!popup || !popupVisible || isHovered) {
- setTimeLeft(autoDismissMs); // Reset timer when hovered
+ if (!popup || !mapRef.current || !popupVisible) return;
+
+ // Small delay to ensure DOM has updated with new content
+ const timeoutId = setTimeout(() => {
+ const map = mapRef.current!;
+ const clickPoint = map.project(popup.lngLat as any);
+ const position = calculatePopupPosition(clickPoint);
+ setPopupPosition(position);
+ }, 50);
+
+ return () => clearTimeout(timeoutId);
+ }, [apiData.weather, apiData.crashAnalysis]); // Trigger specifically when AI data loads
+
+ // Auto-dismiss timer with progress - only starts after AI data is loaded
+ useEffect(() => {
+ if (!popup || !popupVisible || isHovered || !aiDataLoaded) {
+ setTimeLeft(autoDismissMs); // Reset timer when conditions aren't met
return;
}
@@ -114,7 +227,7 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
}, interval);
return () => clearInterval(timer);
- }, [popup, popupVisible, isHovered, onClose, autoDismissMs]);
+ }, [popup, popupVisible, isHovered, aiDataLoaded, onClose, autoDismissMs]);
if (!popup) return null;
const map = mapRef.current;
@@ -136,9 +249,20 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
-
- {/* Auto-dismiss progress bar */}
- {!isHovered && popupVisible && (
+
+ {/* Auto-dismiss progress bar - only show after AI data is loaded */}
+ {!isHovered && popupVisible && aiDataLoaded && (
)}
-
-
{popup.text ?? 'Details'}
-
{ onClose(); }} style={{ background: 'var(--surface-2)', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer', borderRadius: 4, color: 'var(--text-secondary)' }}>
- โ
-
-
+ {/* Scrollable content container */}
+
+
+
{popup.text ?? 'Details'}
+
{ onClose(); }} style={{ background: 'var(--surface-2)', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer', borderRadius: 4, color: 'var(--text-secondary)' }}>
+ โ
+
+
{typeof popup.mag !== 'undefined' &&
Magnitude: {popup.mag}
}
{popup.stats && popup.stats.count > 0 && (
@@ -215,7 +341,139 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
No crash data found within {popup.stats.radiusMeters || 500}m of this location
)}
+
+ {/* API Data Section */}
+ {(apiLoading || apiData.weather || apiData.crashAnalysis || apiError) && (
+
+ {apiLoading && (
+
+
+ Loading additional data...
+
+ )}
+
+ {apiError && (
+
+ โ ๏ธ {apiError}
+
+ )}
+
+ {/* Weather Data */}
+ {apiData.weather && (
+
+
+ ๐ค๏ธ Current Weather
+
+
+ {apiData.weather.summary && (
+
+ {apiData.weather.summary}
+
+ )}
+ {apiData.weather.description && (
+
Conditions: {apiData.weather.description}
+ )}
+ {apiData.weather.precipitation !== undefined && (
+
Precipitation: {apiData.weather.precipitation} mm/h
+ )}
+ {apiData.weather.windSpeed !== undefined && (
+
Wind Speed: {apiData.weather.windSpeed} km/h
+ )}
+ {apiData.weather.timeOfDay && (
+
Time of Day: {apiData.weather.timeOfDay}
+ )}
+
+
+ )}
+
+ {/* Crash Analysis */}
+ {apiData.crashAnalysis && (
+
+
+ ๐ AI Analysis
+
+
+ {apiData.crashAnalysis.riskLevel && (
+
+ Risk Level: {apiData.crashAnalysis.riskLevel.toUpperCase()}
+
+ )}
+ {apiData.crashAnalysis.recommendations && apiData.crashAnalysis.recommendations.length > 0 && (
+
+
Key Recommendations:
+
+ {apiData.crashAnalysis.recommendations.slice(0, 4).map((rec: string, i: number) => (
+
+ โข {rec}
+
+ ))}
+
+
+ )}
+
+ {/* View Details Button */}
+
+ onOpenModal?.({
+ weather: apiData.weather,
+ crashAnalysis: apiData.crashAnalysis,
+ coordinates: popup ? [popup.lngLat[0], popup.lngLat[1]] : undefined
+ })}
+ style={{
+ backgroundColor: 'var(--accent-primary)',
+ color: 'white',
+ border: 'none',
+ padding: '6px 12px',
+ borderRadius: 4,
+ fontSize: 11,
+ fontWeight: 600,
+ cursor: 'pointer',
+ width: '100%'
+ }}
+ onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-primary-hover)'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-primary)'}
+ >
+ ๐ View Full Analysis
+
+
+
+
+ )}
+
+ )}
+
{/* Close scrollable container */}
+
+ {/* Add CSS for spinner animation */}
+
);
}
diff --git a/web/src/app/components/SafetyAnalysisModal.tsx b/web/src/app/components/SafetyAnalysisModal.tsx
new file mode 100644
index 0000000..7ff879e
--- /dev/null
+++ b/web/src/app/components/SafetyAnalysisModal.tsx
@@ -0,0 +1,336 @@
+'use client';
+
+import React from 'react';
+import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi';
+
+interface SafetyAnalysisModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ weatherData?: WeatherData;
+ crashAnalysis?: CrashAnalysisData;
+ coordinates?: [number, number];
+}
+
+export default function SafetyAnalysisModal({
+ isOpen,
+ onClose,
+ weatherData,
+ crashAnalysis,
+ coordinates
+}: SafetyAnalysisModalProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Modal Header */}
+
+
+
+ ๐ Detailed Safety Analysis
+
+ {coordinates && (
+
+ Location: {coordinates[1].toFixed(5)}, {coordinates[0].toFixed(5)}
+
+ )}
+
+
e.currentTarget.style.backgroundColor = 'var(--panel-light)'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
+ >
+ ร
+
+
+
+ {/* Modal Content */}
+
+
+ {/* Weather Section */}
+ {weatherData && (
+
+
+ ๐ค๏ธ Weather Conditions
+
+
+ {weatherData.summary && (
+
+ {weatherData.summary}
+
+ )}
+
+ {weatherData.description && (
+
Conditions: {weatherData.description}
+ )}
+ {weatherData.temperature !== undefined && (
+
Temperature: {weatherData.temperature}ยฐC
+ )}
+ {weatherData.humidity !== undefined && (
+
Humidity: {weatherData.humidity}%
+ )}
+ {weatherData.windSpeed !== undefined && (
+
Wind Speed: {weatherData.windSpeed} km/h
+ )}
+ {weatherData.precipitation !== undefined && (
+
Precipitation: {weatherData.precipitation} mm/h
+ )}
+ {weatherData.visibility !== undefined && (
+
Visibility: {weatherData.visibility} km
+ )}
+ {weatherData.timeOfDay && (
+
Time of Day: {weatherData.timeOfDay}
+ )}
+
+
+
+ )}
+
+ {/* Crash Statistics */}
+ {crashAnalysis?.crashSummary && (
+
+
+ ๐ Crash Statistics
+
+
+
+
+ Total Crashes: {crashAnalysis.crashSummary.totalCrashes?.toLocaleString()}
+
+
+ Total Casualties: {crashAnalysis.crashSummary.totalCasualties?.toLocaleString()}
+
+
+
+ {crashAnalysis.crashSummary.severityBreakdown && (
+
+
Severity Breakdown:
+
+ {Object.entries(crashAnalysis.crashSummary.severityBreakdown).map(([severity, count]) => (
+
+
{severity}
+
{String(count)}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* Risk Assessment */}
+ {crashAnalysis?.riskLevel && (
+
+
+ โ ๏ธ Risk Assessment
+
+
+ Risk Level: {crashAnalysis.riskLevel.toUpperCase()}
+
+
+ )}
+
+ {/* Recommendations */}
+ {crashAnalysis?.recommendations && crashAnalysis.recommendations.length > 0 && (
+
+
+ ๐ก Safety Recommendations
+
+
+ {crashAnalysis.recommendations.map((rec: string, i: number) => (
+
+ {i + 1}. {rec}
+
+ ))}
+
+
+ )}
+
+ {/* Full Safety Analysis */}
+ {crashAnalysis?.safetyAnalysis && (
+
+
+ ๐ Complete Safety Analysis
+
+
+ {crashAnalysis.safetyAnalysis}
+
+
+ )}
+
+
+ {/* Modal Footer */}
+
+ e.currentTarget.style.backgroundColor = 'var(--accent-primary-hover)'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-primary)'}
+ >
+ Close
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/web/src/app/components/UnifiedControlPanel.tsx b/web/src/app/components/UnifiedControlPanel.tsx
index 59780e7..ccc3b35 100644
--- a/web/src/app/components/UnifiedControlPanel.tsx
+++ b/web/src/app/components/UnifiedControlPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import { UseCrashDataResult } from '../hooks/useCrashData';
interface UnifiedControlPanelProps {
@@ -39,38 +39,55 @@ export default function UnifiedControlPanel({
crashDataHook,
onDataLoaded
}: UnifiedControlPanelProps) {
- // Panel state management
- const [mainPanelOpen, setMainPanelOpen] = useState(() => {
- try {
- const v = typeof window !== 'undefined' ? window.localStorage.getItem('unified_panel_open') : null;
- return v === null ? true : v === '1';
- } catch (e) {
- return true;
- }
- });
+ // Panel open/closed state with localStorage persistence
+ const getInitialPanelState = () => {
+ // Always start with default values during SSR
+ return true;
+ };
- const [mapControlsOpen, setMapControlsOpen] = useState(() => {
- try {
- const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_controls_section_open') : null;
- return v === null ? true : v === '1';
- } catch (e) {
- return true;
- }
- });
+ const getInitialMapControlsState = () => {
+ // Always start with default values during SSR
+ return true;
+ };
- const [crashDataOpen, setCrashDataOpen] = useState(() => {
- try {
- const v = typeof window !== 'undefined' ? window.localStorage.getItem('crash_data_section_open') : null;
- return v === null ? true : v === '1';
- } catch (e) {
- return true;
- }
- });
+ const getInitialCrashDataState = () => {
+ // Always start with default values during SSR
+ return false;
+ };
- // Crash data state
+ const [isPanelOpen, setIsPanelOpen] = useState(getInitialPanelState);
+ const [isMapControlsSectionOpen, setIsMapControlsSectionOpen] = useState(getInitialMapControlsState);
+ const [isCrashDataSectionOpen, setIsCrashDataSectionOpen] = useState(getInitialCrashDataState);
+ const [isHydrated, setIsHydrated] = useState(false);
+
+ // Load localStorage values after hydration
+ useEffect(() => {
+ const panelValue = window.localStorage.getItem('unified_panel_open');
+ const mapControlsValue = window.localStorage.getItem('map_controls_section_open');
+ const crashDataValue = window.localStorage.getItem('crash_data_section_open');
+
+ if (panelValue !== null) {
+ setIsPanelOpen(panelValue === '1');
+ }
+ if (mapControlsValue !== null) {
+ setIsMapControlsSectionOpen(mapControlsValue === '1');
+ }
+ if (crashDataValue !== null) {
+ setIsCrashDataSectionOpen(crashDataValue === '1');
+ }
+
+ setIsHydrated(true);
+ }, []); // Crash data state
const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
- const currentYear = new Date().getFullYear().toString();
- const [selectedYear, setSelectedYear] = useState(yearFilter || currentYear);
+ const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch
+ const [selectedYear, setSelectedYear] = useState('2024'); // Default value
+
+ // Set actual current year and selected year after hydration
+ useEffect(() => {
+ const actualCurrentYear = new Date().getFullYear().toString();
+ setCurrentYear(actualCurrentYear);
+ setSelectedYear(yearFilter || actualCurrentYear);
+ }, [yearFilter]);
React.useEffect(() => {
if (onDataLoaded) {
@@ -87,21 +104,21 @@ export default function UnifiedControlPanel({
};
const toggleMainPanel = (next: boolean) => {
- setMainPanelOpen(next);
+ setIsPanelOpen(next);
try {
window.localStorage.setItem('unified_panel_open', next ? '1' : '0');
} catch (e) {}
};
const toggleMapControls = (next: boolean) => {
- setMapControlsOpen(next);
+ setIsMapControlsSectionOpen(next);
try {
window.localStorage.setItem('map_controls_section_open', next ? '1' : '0');
} catch (e) {}
};
const toggleCrashData = (next: boolean) => {
- setCrashDataOpen(next);
+ setIsCrashDataSectionOpen(next);
try {
window.localStorage.setItem('crash_data_section_open', next ? '1' : '0');
} catch (e) {}
@@ -162,9 +179,9 @@ export default function UnifiedControlPanel({
Control Panel
toggleMainPanel(!mainPanelOpen)}
+ aria-expanded={isPanelOpen}
+ aria-label={isPanelOpen ? 'Collapse panel' : 'Expand panel'}
+ onClick={() => toggleMainPanel(!isPanelOpen)}
style={{
borderRadius: 8,
padding: '8px 12px',
@@ -175,26 +192,26 @@ export default function UnifiedControlPanel({
cursor: 'pointer'
}}
>
- {mainPanelOpen ? 'โ' : '+'}
+ {isPanelOpen ? 'โ' : '+'}
- {mainPanelOpen && (
+ {isPanelOpen && (
<>
{/* Map Controls Section */}
Map Controls
toggleMapControls(!mapControlsOpen)}
+ onClick={() => toggleMapControls(!isMapControlsSectionOpen)}
style={toggleButtonStyle}
- aria-expanded={mapControlsOpen}
+ aria-expanded={isMapControlsSectionOpen}
>
- {mapControlsOpen ? 'โ' : '+'}
+ {isMapControlsSectionOpen ? 'โ' : '+'}
- {mapControlsOpen && (
+ {isMapControlsSectionOpen && (
Style
@@ -239,15 +256,15 @@ export default function UnifiedControlPanel({
Crash Data
toggleCrashData(!crashDataOpen)}
+ onClick={() => toggleCrashData(!isCrashDataSectionOpen)}
style={toggleButtonStyle}
- aria-expanded={crashDataOpen}
+ aria-expanded={isCrashDataSectionOpen}
>
- {crashDataOpen ? 'โ' : '+'}
+ {isCrashDataSectionOpen ? 'โ' : '+'}
- {crashDataOpen && (
+ {isCrashDataSectionOpen && (
{/* Crash Density Legend */}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
index 5472ffb..e235522 100644
--- a/web/src/app/page.tsx
+++ b/web/src/app/page.tsx
@@ -6,7 +6,9 @@ import UnifiedControlPanel from './components/UnifiedControlPanel';
import PopupOverlay from './components/PopupOverlay';
import MapNavigationControl from './components/MapNavigationControl';
import DirectionsSidebar from './components/DirectionsSidebar';
+import SafetyAnalysisModal from './components/SafetyAnalysisModal';
import { useCrashData } from './hooks/useCrashData';
+import { WeatherData, CrashAnalysisData } from '../lib/flaskApi';
export default function Home() {
const mapRef = useRef
(null);
@@ -21,6 +23,20 @@ export default function Home() {
const [popupVisible, setPopupVisible] = useState(false);
const [isMapPickingMode, setIsMapPickingMode] = useState(false);
+ // Modal state
+ const [modalOpen, setModalOpen] = useState(false);
+ const [modalData, setModalData] = useState<{
+ weather?: WeatherData;
+ crashAnalysis?: CrashAnalysisData;
+ coordinates?: [number, number];
+ }>({});
+
+ // Handle modal opening
+ const handleOpenModal = (data: { weather?: WeatherData; crashAnalysis?: CrashAnalysisData; coordinates?: [number, number] }) => {
+ setModalData(data);
+ setModalOpen(true);
+ };
+
// Shared crash data state - load all data for filtered year
const crashDataHook = useCrashData({ autoLoad: true });
@@ -70,8 +86,23 @@ export default function Home() {
{/* Native Mapbox navigation control (zoom + compass) */}
- { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
+ { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }}
+ onOpenModal={handleOpenModal}
+ />
+
+ {/* Safety Analysis Modal - Rendered at page level */}
+
setModalOpen(false)}
+ weatherData={modalData.weather}
+ crashAnalysis={modalData.crashAnalysis}
+ coordinates={modalData.coordinates}
+ />
);
}
\ No newline at end of file
diff --git a/web/src/lib/flaskApi.ts b/web/src/lib/flaskApi.ts
new file mode 100644
index 0000000..795dc1a
--- /dev/null
+++ b/web/src/lib/flaskApi.ts
@@ -0,0 +1,161 @@
+const FLASK_API_BASE = 'http://127.0.0.1:5001';
+
+export interface WeatherData {
+ temperature: number;
+ description: string;
+ humidity: number;
+ windSpeed: number;
+ precipitation?: number;
+ visibility?: number;
+ summary?: string;
+ timeOfDay?: string;
+}
+
+export interface CrashAnalysisData {
+ riskLevel: string;
+ crashSummary?: {
+ totalCrashes: number;
+ totalCasualties: number;
+ severityBreakdown: Record
;
+ };
+ recommendations: string[];
+ safetyAnalysis?: string;
+}
+
+export const fetchWeatherData = async (lat: number, lng: number): Promise => {
+ const response = await fetch(`${FLASK_API_BASE}/api/weather?lat=${lat}&lon=${lng}`);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch weather data');
+ }
+
+ const data = await response.json();
+ return transformWeatherData(data);
+};
+
+export const fetchCrashAnalysis = async (lat: number, lng: number): Promise => {
+ const response = await fetch(`${FLASK_API_BASE}/api/analyze-crashes`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ lat, lon: lng }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch crash analysis');
+ }
+
+ const data = await response.json();
+ return transformCrashAnalysis(data);
+};
+
+// Transform Flask weather API response to our WeatherData interface
+const transformWeatherData = (apiResponse: any): WeatherData => {
+ // Extract summary if available
+ let summary = '';
+ let timeOfDay = '';
+
+ if (apiResponse.summary) {
+ summary = apiResponse.summary;
+ // Extract time of day from summary
+ if (summary.includes('Time: ')) {
+ const timeMatch = summary.match(/Time: (\w+)/);
+ if (timeMatch) {
+ timeOfDay = timeMatch[1];
+ }
+ }
+ }
+
+ // Extract data from weather_data.current if available
+ const current = apiResponse.weather_data?.current;
+
+ return {
+ temperature: current?.temperature_2m || apiResponse.temperature || 0,
+ description: current?.weather_description || apiResponse.description || (summary.includes('Conditions: ') ? summary.split('Conditions: ')[1]?.split(' |')[0] || 'N/A' : 'N/A'),
+ humidity: current?.relative_humidity_2m || apiResponse.humidity || 0,
+ windSpeed: current?.wind_speed_10m || apiResponse.windSpeed || 0,
+ precipitation: current?.precipitation || apiResponse.precipitation || 0,
+ visibility: current?.visibility || apiResponse.visibility,
+ summary: summary,
+ timeOfDay: timeOfDay || (current?.is_day === 0 ? 'night' : current?.is_day === 1 ? 'day' : '')
+ };
+};
+
+// Transform Flask crash analysis API response to our CrashAnalysisData interface
+const transformCrashAnalysis = (apiResponse: any): CrashAnalysisData => {
+ const data = apiResponse;
+
+ // Extract risk level from safety analysis text
+ let riskLevel = 'unknown';
+ let recommendations: string[] = [];
+
+ if (data.safety_analysis) {
+ const safetyText = data.safety_analysis;
+ const safetyTextLower = safetyText.toLowerCase();
+
+ // Look for danger level assessment (now without markdown formatting)
+ const dangerLevelMatch = safetyText.match(/danger level assessment[:\s]*([^.\n]+)/i);
+ if (dangerLevelMatch) {
+ const level = dangerLevelMatch[1].trim().toLowerCase();
+ if (level.includes('very high') || level.includes('extreme')) {
+ riskLevel = 'high';
+ } else if (level.includes('high')) {
+ riskLevel = 'high';
+ } else if (level.includes('moderate') || level.includes('medium')) {
+ riskLevel = 'medium';
+ } else if (level.includes('low')) {
+ riskLevel = 'low';
+ }
+ } else {
+ // Fallback to searching for risk indicators in the text
+ if (safetyTextLower.includes('very high') || safetyTextLower.includes('extremely dangerous')) {
+ riskLevel = 'high';
+ } else if (safetyTextLower.includes('high risk') || safetyTextLower.includes('very dangerous')) {
+ riskLevel = 'high';
+ } else if (safetyTextLower.includes('moderate risk') || safetyTextLower.includes('medium risk')) {
+ riskLevel = 'medium';
+ } else if (safetyTextLower.includes('low risk') || safetyTextLower.includes('relatively safe')) {
+ riskLevel = 'low';
+ }
+ }
+
+ // Extract recommendations from safety analysis (now without markdown)
+ const recommendationsMatch = safetyText.match(/specific recommendations[^:]*:([\s\S]*?)(?=\n\n|\d+\.|$)/i);
+ if (recommendationsMatch) {
+ const recommendationsText = recommendationsMatch[1];
+ // Split by lines and filter for meaningful recommendations
+ const lines = recommendationsText.split('\n')
+ .map((line: string) => line.trim())
+ .filter((line: string) => line.length > 20 && !line.match(/^\d+\./))
+ .slice(0, 4);
+ recommendations = lines;
+ }
+
+ // If no specific recommendations section found, try to extract key sentences
+ if (recommendations.length === 0) {
+ const sentences = safetyText.split(/[.!?]/)
+ .map((sentence: string) => sentence.trim())
+ .filter((sentence: string) =>
+ sentence.length > 30 &&
+ (sentence.toLowerCase().includes('recommend') ||
+ sentence.toLowerCase().includes('should') ||
+ sentence.toLowerCase().includes('consider') ||
+ sentence.toLowerCase().includes('avoid'))
+ )
+ .slice(0, 3);
+ recommendations = sentences.map((s: string) => s + (s.endsWith('.') ? '' : '.'));
+ }
+ }
+
+ return {
+ riskLevel: riskLevel,
+ crashSummary: data.crash_summary ? {
+ totalCrashes: data.crash_summary.total_crashes || 0,
+ totalCasualties: data.crash_summary.total_casualties || 0,
+ severityBreakdown: data.crash_summary.severity_breakdown || {}
+ } : undefined,
+ recommendations: recommendations.slice(0, 5), // Limit to 5 recommendations
+ safetyAnalysis: data.safety_analysis || ''
+ };
+};
\ No newline at end of file