Add Flask API endpoints and testing scripts for safety analysis
- Created requirements.txt for Flask and related libraries. - Implemented test_api.py to validate API endpoints including health check, weather data retrieval, crash analysis, route finding, and single route fetching. - Developed test_crash_endpoint.py for focused testing on crash analysis endpoint. - Added test_flask_endpoints.py for lightweight tests using Flask's test client with mocked dependencies. - Introduced SafetyAnalysisModal component in the frontend for displaying detailed safety analysis results. - Implemented flaskApi.ts to handle API requests for weather data and crash analysis, including data transformation to match frontend interfaces.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Safety Analysis</h2>
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Analyze Safety'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && (
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Crash Analysis Results */}
|
||||
<div className="p-4 bg-gray-100 rounded">
|
||||
<h3 className="font-bold text-lg">Crash Analysis</h3>
|
||||
<p>Total crashes: {results.crashes.crash_summary.total_crashes}</p>
|
||||
<p>Total casualties: {results.crashes.crash_summary.total_casualties}</p>
|
||||
<p>Weather: {results.crashes.weather.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Route Results */}
|
||||
<div className="p-4 bg-blue-100 rounded">
|
||||
<h3 className="font-bold text-lg">Safe Route</h3>
|
||||
<p>Distance: {results.route.recommended_route.distance_km.toFixed(1)} km</p>
|
||||
<p>Duration: {results.route.recommended_route.duration_min.toFixed(0)} minutes</p>
|
||||
<p>Crashes nearby: {results.route.recommended_route.crashes_nearby}</p>
|
||||
<p>Safety score: {results.route.recommended_route.safety_score.toFixed(3)}</p>
|
||||
</div>
|
||||
|
||||
{/* Weather Results */}
|
||||
<div className="p-4 bg-green-100 rounded">
|
||||
<h3 className="font-bold text-lg">Current Weather</h3>
|
||||
<p>{results.weather.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <div ref={mapContainer} className="w-full h-96" />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 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! 🚀
|
||||
@@ -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
|
||||
285
llm/test_flask_endpoints.py
Normal file
285
llm/test_flask_endpoints.py
Normal file
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<mapboxgl.Map | null>;
|
||||
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<string | null>(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)}
|
||||
>
|
||||
<div className="mapbox-popup-inner" style={{ background: 'var(--surface-1)', color: 'var(--text-primary)', padding: 8, borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', border: '1px solid var(--border-1)', minWidth: 200, maxWidth: 350, position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Auto-dismiss progress bar */}
|
||||
{!isHovered && popupVisible && (
|
||||
<div className="mapbox-popup-inner" style={{
|
||||
background: 'var(--surface-1)',
|
||||
color: 'var(--text-primary)',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-1)',
|
||||
minWidth: 200,
|
||||
maxWidth: 350,
|
||||
maxHeight: '75vh', // Prevent popup from being too tall
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* Auto-dismiss progress bar - only show after AI data is loaded */}
|
||||
{!isHovered && popupVisible && aiDataLoaded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -153,12 +277,14 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14 }}>{popup.text ?? 'Details'}</div>
|
||||
<button aria-label="Close popup" onClick={() => { onClose(); }} style={{ background: 'var(--surface-2)', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer', borderRadius: 4, color: 'var(--text-secondary)' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/* Scrollable content container */}
|
||||
<div style={{ maxHeight: 'calc(75vh - 40px)', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14 }}>{popup.text ?? 'Details'}</div>
|
||||
<button aria-label="Close popup" onClick={() => { onClose(); }} style={{ background: 'var(--surface-2)', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer', borderRadius: 4, color: 'var(--text-secondary)' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{typeof popup.mag !== 'undefined' && <div style={{ marginTop: 6, color: 'var(--text-secondary)' }}><strong style={{ color: 'var(--text-primary)' }}>Magnitude:</strong> {popup.mag}</div>}
|
||||
{popup.stats && popup.stats.count > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: 13 }}>
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Data Section */}
|
||||
{(apiLoading || apiData.weather || apiData.crashAnalysis || apiError) && (
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-2)', paddingTop: 8 }}>
|
||||
{apiLoading && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
<div style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
border: '2px solid var(--border-3)',
|
||||
borderTop: '2px solid var(--text-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
Loading additional data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiError && (
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#dc3545',
|
||||
backgroundColor: '#ffeaea',
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
border: '1px solid #f5c6cb'
|
||||
}}>
|
||||
⚠️ {apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weather Data */}
|
||||
{apiData.weather && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, color: 'var(--text-primary)', fontSize: 13 }}>
|
||||
🌤️ Current Weather
|
||||
</div>
|
||||
<div style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{apiData.weather.summary && (
|
||||
<div style={{ marginBottom: 4, fontStyle: 'italic' }}>
|
||||
{apiData.weather.summary}
|
||||
</div>
|
||||
)}
|
||||
{apiData.weather.description && (
|
||||
<div>Conditions: {apiData.weather.description}</div>
|
||||
)}
|
||||
{apiData.weather.precipitation !== undefined && (
|
||||
<div>Precipitation: {apiData.weather.precipitation} mm/h</div>
|
||||
)}
|
||||
{apiData.weather.windSpeed !== undefined && (
|
||||
<div>Wind Speed: {apiData.weather.windSpeed} km/h</div>
|
||||
)}
|
||||
{apiData.weather.timeOfDay && (
|
||||
<div>Time of Day: {apiData.weather.timeOfDay}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crash Analysis */}
|
||||
{apiData.crashAnalysis && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4, color: 'var(--text-primary)', fontSize: 13 }}>
|
||||
📊 AI Analysis
|
||||
</div>
|
||||
<div style={{ marginLeft: 8, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{apiData.crashAnalysis.riskLevel && (
|
||||
<div style={{
|
||||
marginBottom: 6,
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
backgroundColor: apiData.crashAnalysis.riskLevel === 'high' ? '#ffeaea' :
|
||||
apiData.crashAnalysis.riskLevel === 'medium' ? '#fff3cd' : '#d4edda',
|
||||
color: apiData.crashAnalysis.riskLevel === 'high' ? '#721c24' :
|
||||
apiData.crashAnalysis.riskLevel === 'medium' ? '#856404' : '#155724',
|
||||
fontWeight: 600
|
||||
}}>
|
||||
Risk Level: {apiData.crashAnalysis.riskLevel.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{apiData.crashAnalysis.recommendations && apiData.crashAnalysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 3, fontSize: 12 }}>Key Recommendations:</div>
|
||||
<div style={{ fontSize: 11, maxHeight: 120, overflowY: 'auto' }}>
|
||||
{apiData.crashAnalysis.recommendations.slice(0, 4).map((rec: string, i: number) => (
|
||||
<div key={i} style={{ marginBottom: 3, lineHeight: 1.3 }}>
|
||||
• {rec}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Details Button */}
|
||||
<div style={{ marginTop: 8, textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div> {/* Close scrollable container */}
|
||||
</div>
|
||||
|
||||
{/* Add CSS for spinner animation */}
|
||||
<style jsx>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
336
web/src/app/components/SafetyAnalysisModal.tsx
Normal file
336
web/src/app/components/SafetyAnalysisModal.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10000,
|
||||
padding: '20px'
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--panel-lightest)',
|
||||
borderRadius: 12,
|
||||
maxWidth: '800px',
|
||||
maxHeight: '80vh',
|
||||
width: '100%',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid var(--panel-border)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
padding: '20px 24px 16px',
|
||||
borderBottom: '1px solid var(--panel-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)'
|
||||
}}>
|
||||
📊 Detailed Safety Analysis
|
||||
</h2>
|
||||
{coordinates && (
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: 4
|
||||
}}>
|
||||
Location: {coordinates[1].toFixed(5)}, {coordinates[0].toFixed(5)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: 24,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-secondary)',
|
||||
padding: 4,
|
||||
borderRadius: 4
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--panel-light)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
|
||||
{/* Weather Section */}
|
||||
{weatherData && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
margin: '0 0 12px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
🌤️ Weather Conditions
|
||||
</h3>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--panel-light)',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
{weatherData.summary && (
|
||||
<div style={{
|
||||
fontStyle: 'italic',
|
||||
marginBottom: 12,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 15
|
||||
}}>
|
||||
{weatherData.summary}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
||||
{weatherData.description && (
|
||||
<div><strong>Conditions:</strong> {weatherData.description}</div>
|
||||
)}
|
||||
{weatherData.temperature !== undefined && (
|
||||
<div><strong>Temperature:</strong> {weatherData.temperature}°C</div>
|
||||
)}
|
||||
{weatherData.humidity !== undefined && (
|
||||
<div><strong>Humidity:</strong> {weatherData.humidity}%</div>
|
||||
)}
|
||||
{weatherData.windSpeed !== undefined && (
|
||||
<div><strong>Wind Speed:</strong> {weatherData.windSpeed} km/h</div>
|
||||
)}
|
||||
{weatherData.precipitation !== undefined && (
|
||||
<div><strong>Precipitation:</strong> {weatherData.precipitation} mm/h</div>
|
||||
)}
|
||||
{weatherData.visibility !== undefined && (
|
||||
<div><strong>Visibility:</strong> {weatherData.visibility} km</div>
|
||||
)}
|
||||
{weatherData.timeOfDay && (
|
||||
<div><strong>Time of Day:</strong> {weatherData.timeOfDay}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crash Statistics */}
|
||||
{crashAnalysis?.crashSummary && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
margin: '0 0 12px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
🚗 Crash Statistics
|
||||
</h3>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--panel-light)',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
<div>
|
||||
<strong>Total Crashes:</strong> {crashAnalysis.crashSummary.totalCrashes?.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Total Casualties:</strong> {crashAnalysis.crashSummary.totalCasualties?.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{crashAnalysis.crashSummary.severityBreakdown && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Severity Breakdown:</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8 }}>
|
||||
{Object.entries(crashAnalysis.crashSummary.severityBreakdown).map(([severity, count]) => (
|
||||
<div key={severity} style={{
|
||||
padding: 8,
|
||||
backgroundColor: 'var(--panel-lightest)',
|
||||
borderRadius: 4,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: 12, opacity: 0.8 }}>{severity}</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{String(count)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Assessment */}
|
||||
{crashAnalysis?.riskLevel && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
margin: '0 0 12px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
⚠️ Risk Assessment
|
||||
</h3>
|
||||
<div style={{
|
||||
backgroundColor: crashAnalysis.riskLevel === 'high' ? '#ffeaea' :
|
||||
crashAnalysis.riskLevel === 'medium' ? '#fff3cd' : '#d4edda',
|
||||
color: crashAnalysis.riskLevel === 'high' ? '#721c24' :
|
||||
crashAnalysis.riskLevel === 'medium' ? '#856404' : '#155724',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Risk Level: {crashAnalysis.riskLevel.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{crashAnalysis?.recommendations && crashAnalysis.recommendations.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h3 style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
margin: '0 0 12px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
💡 Safety Recommendations
|
||||
</h3>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--panel-light)',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
{crashAnalysis.recommendations.map((rec: string, i: number) => (
|
||||
<div key={i} style={{
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
backgroundColor: 'var(--panel-lightest)',
|
||||
borderRadius: 6,
|
||||
borderLeft: '3px solid var(--accent-primary)'
|
||||
}}>
|
||||
<strong>{i + 1}.</strong> {rec}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Safety Analysis */}
|
||||
{crashAnalysis?.safetyAnalysis && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h3 style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
margin: '0 0 12px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
📋 Complete Safety Analysis
|
||||
</h3>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--panel-light)',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text-secondary)',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace'
|
||||
}}>
|
||||
{crashAnalysis.safetyAnalysis}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
padding: '16px 24px 20px',
|
||||
borderTop: '1px solid var(--panel-border)',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-primary)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-primary-hover)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-primary)'}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<boolean>(() => {
|
||||
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<boolean>(() => {
|
||||
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<boolean>(() => {
|
||||
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<string>(yearFilter || currentYear);
|
||||
const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch
|
||||
const [selectedYear, setSelectedYear] = useState<string>('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({
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: '16px', color: '#f9fafb' }}>Control Panel</div>
|
||||
<button
|
||||
aria-expanded={mainPanelOpen}
|
||||
aria-label={mainPanelOpen ? 'Collapse panel' : 'Expand panel'}
|
||||
onClick={() => 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 ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mainPanelOpen && (
|
||||
{isPanelOpen && (
|
||||
<>
|
||||
{/* Map Controls Section */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Map Controls</div>
|
||||
<button
|
||||
onClick={() => toggleMapControls(!mapControlsOpen)}
|
||||
onClick={() => toggleMapControls(!isMapControlsSectionOpen)}
|
||||
style={toggleButtonStyle}
|
||||
aria-expanded={mapControlsOpen}
|
||||
aria-expanded={isMapControlsSectionOpen}
|
||||
>
|
||||
{mapControlsOpen ? '−' : '+'}
|
||||
{isMapControlsSectionOpen ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mapControlsOpen && (
|
||||
{isMapControlsSectionOpen && (
|
||||
<div style={{ paddingLeft: '8px' }}>
|
||||
<div className="mc-row">
|
||||
<label className="mc-label">Style</label>
|
||||
@@ -239,15 +256,15 @@ export default function UnifiedControlPanel({
|
||||
<div style={sectionHeaderStyle}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Crash Data</div>
|
||||
<button
|
||||
onClick={() => toggleCrashData(!crashDataOpen)}
|
||||
onClick={() => toggleCrashData(!isCrashDataSectionOpen)}
|
||||
style={toggleButtonStyle}
|
||||
aria-expanded={crashDataOpen}
|
||||
aria-expanded={isCrashDataSectionOpen}
|
||||
>
|
||||
{crashDataOpen ? '−' : '+'}
|
||||
{isCrashDataSectionOpen ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{crashDataOpen && (
|
||||
{isCrashDataSectionOpen && (
|
||||
<div style={{ paddingLeft: '8px' }}>
|
||||
{/* Crash Density Legend */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
|
||||
@@ -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<any>(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) */}
|
||||
<MapNavigationControl mapRef={mapRef} position="top-right" />
|
||||
|
||||
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
|
||||
<PopupOverlay
|
||||
popup={popup}
|
||||
popupVisible={popupVisible}
|
||||
mapRef={mapRef}
|
||||
onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }}
|
||||
onOpenModal={handleOpenModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Safety Analysis Modal - Rendered at page level */}
|
||||
<SafetyAnalysisModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
weatherData={modalData.weather}
|
||||
crashAnalysis={modalData.crashAnalysis}
|
||||
coordinates={modalData.coordinates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
web/src/lib/flaskApi.ts
Normal file
161
web/src/lib/flaskApi.ts
Normal file
@@ -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<string, number>;
|
||||
};
|
||||
recommendations: string[];
|
||||
safetyAnalysis?: string;
|
||||
}
|
||||
|
||||
export const fetchWeatherData = async (lat: number, lng: number): Promise<WeatherData> => {
|
||||
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<CrashAnalysisData> => {
|
||||
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 || ''
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user