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

- - - - {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'}
- -
+ {/* Scrollable content container */} +
+
+
{popup.text ?? 'Details'}
+ +
{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 */} +
+ +
+
+
+ )} +
+ )} +
{/* 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)} +
+ )} +
+ +
+ + {/* 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 */} +
+ +
+
+
+ ); +} \ 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
- {mainPanelOpen && ( + {isPanelOpen && ( <> {/* Map Controls Section */}
Map Controls
- {mapControlsOpen && ( + {isMapControlsSectionOpen && (
@@ -239,15 +256,15 @@ export default function UnifiedControlPanel({
Crash Data
- {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