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:
2025-09-28 03:59:32 -04:00
parent a97b79ee37
commit fbb6953473
13 changed files with 1192 additions and 431 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ package-lock.json
roadcast/data.csv roadcast/data.csv
web/public/Crashes_in_DC.csv web/public/Crashes_in_DC.csv
ai/Crashes_in_DC.csv ai/Crashes_in_DC.csv
llm/INTEGRATION_GUIDE.md

View File

@@ -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! 🚀

View File

@@ -2,21 +2,25 @@ from flask import Flask, request, jsonify
from flask_cors import CORS from flask_cors import CORS
import sys import sys
import os import os
import re
import traceback import traceback
from datetime import datetime from datetime import datetime
import json import json
from bson import ObjectId 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__))) sys.path.append(os.path.dirname(os.path.dirname(__file__)))
# Import our existing modules from the same llm directory # Import our existing modules from the same llm directory
try: 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 ( from gemini_mongo_mateo import (
connect_to_mongodb, connect_to_mongodb,
get_crashes_within_radius_mongodb, get_crashes_within_radius_mongodb,
analyze_mongodb_crash_patterns, analyze_mongodb_crash_patterns,
get_current_weather get_current_weather,
) )
from gemini_reroute_mateo import SafeRouteAnalyzer, MONGO_URI from gemini_reroute_mateo import SafeRouteAnalyzer, MONGO_URI
print("✅ Successfully imported Python modules") print("✅ Successfully imported Python modules")
@@ -144,6 +148,22 @@ def analyze_crashes_endpoint():
crashes, lat, lon, radius_km, weather_summary 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 # Calculate some basic statistics
total_crashes = len(crashes) total_crashes = len(crashes)
avg_distance = sum(crash.get('distance_km', 0) for crash in crashes) / total_crashes if crashes else 0 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
View 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)

View File

@@ -8,6 +8,7 @@ import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils'; import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils';
import { useCrashData, UseCrashDataResult } from '../hooks/useCrashData'; import { useCrashData, UseCrashDataResult } from '../hooks/useCrashData';
import { CrashData } from '../api/crashes/route'; import { CrashData } from '../api/crashes/route';
import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi';
export type PopupData = { export type PopupData = {
lngLat: [number, number]; lngLat: [number, number];
@@ -27,7 +28,12 @@ export type PopupData = {
propertyOnly: number; propertyOnly: number;
}; };
crashes?: any[]; // Top 5 nearby crashes crashes?: any[]; // Top 5 nearby crashes
} };
// New API data
weather?: WeatherData;
crashAnalysis?: CrashAnalysisData;
apiError?: string;
isLoadingApi?: boolean;
} | null; } | null;
interface MapViewProps { interface MapViewProps {

View File

@@ -3,6 +3,7 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import type { PopupData } from './MapView'; import type { PopupData } from './MapView';
import { fetchWeatherData, fetchCrashAnalysis, type WeatherData, type CrashAnalysisData } from '../../lib/flaskApi';
interface Props { interface Props {
popup: PopupData; popup: PopupData;
@@ -10,12 +11,64 @@ interface Props {
mapRef: React.MutableRefObject<mapboxgl.Map | null>; mapRef: React.MutableRefObject<mapboxgl.Map | null>;
onClose: () => void; onClose: () => void;
autoDismissMs?: number; // Auto-dismiss timeout in milliseconds, default 5000 (5 seconds) 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 [isHovered, setIsHovered] = useState(false);
const [timeLeft, setTimeLeft] = useState(autoDismissMs); const [timeLeft, setTimeLeft] = useState(autoDismissMs);
const [popupPosition, setPopupPosition] = useState({ left: 0, top: 0, transform: 'translate(-50%, -100%)', arrowPosition: 'bottom' }); 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 // Calculate smart popup positioning
const calculatePopupPosition = (clickPoint: mapboxgl.Point) => { const calculatePopupPosition = (clickPoint: mapboxgl.Point) => {
@@ -29,7 +82,14 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const popupWidth = 350; // max-width from styles 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 const padding = 20; // padding from screen edges
let left = clickPoint.x; let left = clickPoint.x;
@@ -54,22 +114,60 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
transform = 'translateX(-50%)'; transform = 'translateX(-50%)';
} }
// Determine vertical position // Determine vertical position - prioritize keeping popup in viewport
if (clickPoint.y - popupHeight - padding < 0) { 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 // Position below cursor
top = clickPoint.y + 10; top = clickPoint.y + 15;
transform += ' translateY(0%)'; arrowPosition = arrowPosition === 'right' || arrowPosition === 'left' ? arrowPosition : 'top';
arrowPosition = arrowPosition === 'bottom' ? 'top' : arrowPosition; } else if (spaceAbove >= popupHeight) {
// Position above cursor
top = clickPoint.y - popupHeight - 15;
arrowPosition = arrowPosition === 'right' || arrowPosition === 'left' ? arrowPosition : 'bottom';
} else { } else {
// Position above cursor (default) // Force fit - use the side with more space
top = clickPoint.y - 10; if (spaceBelow > spaceAbove) {
transform += ' translateY(-100%)'; 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 }; 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(() => { useEffect(() => {
if (!popup || !mapRef.current) return; if (!popup || !mapRef.current) return;
@@ -91,12 +189,27 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
map.off('move', updatePosition); map.off('move', updatePosition);
map.off('zoom', 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(() => { useEffect(() => {
if (!popup || !popupVisible || isHovered) { if (!popup || !mapRef.current || !popupVisible) return;
setTimeLeft(autoDismissMs); // Reset timer when hovered
// 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; return;
} }
@@ -114,7 +227,7 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
}, interval); }, interval);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [popup, popupVisible, isHovered, onClose, autoDismissMs]); }, [popup, popupVisible, isHovered, aiDataLoaded, onClose, autoDismissMs]);
if (!popup) return null; if (!popup) return null;
const map = mapRef.current; const map = mapRef.current;
@@ -136,9 +249,20 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, aut
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} 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' }}> <div className="mapbox-popup-inner" style={{
{/* Auto-dismiss progress bar */} background: 'var(--surface-1)',
{!isHovered && popupVisible && ( 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 <div
style={{ style={{
position: 'absolute', 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 }}> {/* Scrollable content container */}
<div style={{ fontWeight: 700, fontSize: 14 }}>{popup.text ?? 'Details'}</div> <div style={{ maxHeight: 'calc(75vh - 40px)', overflowY: 'auto' }}>
<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)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>{popup.text ?? 'Details'}</div>
</button> <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)' }}>
</div>
</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>} {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 && ( {popup.stats && popup.stats.count > 0 && (
<div style={{ marginTop: 6, fontSize: 13 }}> <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 No crash data found within {popup.stats.radiusMeters || 500}m of this location
</div> </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> </div>
{/* Add CSS for spinner animation */}
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div> </div>
); );
} }

View 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>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { UseCrashDataResult } from '../hooks/useCrashData'; import { UseCrashDataResult } from '../hooks/useCrashData';
interface UnifiedControlPanelProps { interface UnifiedControlPanelProps {
@@ -39,38 +39,55 @@ export default function UnifiedControlPanel({
crashDataHook, crashDataHook,
onDataLoaded onDataLoaded
}: UnifiedControlPanelProps) { }: UnifiedControlPanelProps) {
// Panel state management // Panel open/closed state with localStorage persistence
const [mainPanelOpen, setMainPanelOpen] = useState<boolean>(() => { const getInitialPanelState = () => {
try { // Always start with default values during SSR
const v = typeof window !== 'undefined' ? window.localStorage.getItem('unified_panel_open') : null; return true;
return v === null ? true : v === '1'; };
} catch (e) {
return true;
}
});
const [mapControlsOpen, setMapControlsOpen] = useState<boolean>(() => { const getInitialMapControlsState = () => {
try { // Always start with default values during SSR
const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_controls_section_open') : null; return true;
return v === null ? true : v === '1'; };
} catch (e) {
return true;
}
});
const [crashDataOpen, setCrashDataOpen] = useState<boolean>(() => { const getInitialCrashDataState = () => {
try { // Always start with default values during SSR
const v = typeof window !== 'undefined' ? window.localStorage.getItem('crash_data_section_open') : null; return false;
return v === null ? true : v === '1'; };
} catch (e) {
return true;
}
});
// 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 { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
const currentYear = new Date().getFullYear().toString(); const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch
const [selectedYear, setSelectedYear] = useState<string>(yearFilter || currentYear); 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(() => { React.useEffect(() => {
if (onDataLoaded) { if (onDataLoaded) {
@@ -87,21 +104,21 @@ export default function UnifiedControlPanel({
}; };
const toggleMainPanel = (next: boolean) => { const toggleMainPanel = (next: boolean) => {
setMainPanelOpen(next); setIsPanelOpen(next);
try { try {
window.localStorage.setItem('unified_panel_open', next ? '1' : '0'); window.localStorage.setItem('unified_panel_open', next ? '1' : '0');
} catch (e) {} } catch (e) {}
}; };
const toggleMapControls = (next: boolean) => { const toggleMapControls = (next: boolean) => {
setMapControlsOpen(next); setIsMapControlsSectionOpen(next);
try { try {
window.localStorage.setItem('map_controls_section_open', next ? '1' : '0'); window.localStorage.setItem('map_controls_section_open', next ? '1' : '0');
} catch (e) {} } catch (e) {}
}; };
const toggleCrashData = (next: boolean) => { const toggleCrashData = (next: boolean) => {
setCrashDataOpen(next); setIsCrashDataSectionOpen(next);
try { try {
window.localStorage.setItem('crash_data_section_open', next ? '1' : '0'); window.localStorage.setItem('crash_data_section_open', next ? '1' : '0');
} catch (e) {} } catch (e) {}
@@ -162,9 +179,9 @@ export default function UnifiedControlPanel({
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ fontWeight: 700, fontSize: '16px', color: '#f9fafb' }}>Control Panel</div> <div style={{ fontWeight: 700, fontSize: '16px', color: '#f9fafb' }}>Control Panel</div>
<button <button
aria-expanded={mainPanelOpen} aria-expanded={isPanelOpen}
aria-label={mainPanelOpen ? 'Collapse panel' : 'Expand panel'} aria-label={isPanelOpen ? 'Collapse panel' : 'Expand panel'}
onClick={() => toggleMainPanel(!mainPanelOpen)} onClick={() => toggleMainPanel(!isPanelOpen)}
style={{ style={{
borderRadius: 8, borderRadius: 8,
padding: '8px 12px', padding: '8px 12px',
@@ -175,26 +192,26 @@ export default function UnifiedControlPanel({
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
{mainPanelOpen ? '' : '+'} {isPanelOpen ? '' : '+'}
</button> </button>
</div> </div>
{mainPanelOpen && ( {isPanelOpen && (
<> <>
{/* Map Controls Section */} {/* Map Controls Section */}
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<div style={sectionHeaderStyle}> <div style={sectionHeaderStyle}>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Map Controls</div> <div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Map Controls</div>
<button <button
onClick={() => toggleMapControls(!mapControlsOpen)} onClick={() => toggleMapControls(!isMapControlsSectionOpen)}
style={toggleButtonStyle} style={toggleButtonStyle}
aria-expanded={mapControlsOpen} aria-expanded={isMapControlsSectionOpen}
> >
{mapControlsOpen ? '' : '+'} {isMapControlsSectionOpen ? '' : '+'}
</button> </button>
</div> </div>
{mapControlsOpen && ( {isMapControlsSectionOpen && (
<div style={{ paddingLeft: '8px' }}> <div style={{ paddingLeft: '8px' }}>
<div className="mc-row"> <div className="mc-row">
<label className="mc-label">Style</label> <label className="mc-label">Style</label>
@@ -239,15 +256,15 @@ export default function UnifiedControlPanel({
<div style={sectionHeaderStyle}> <div style={sectionHeaderStyle}>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Crash Data</div> <div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Crash Data</div>
<button <button
onClick={() => toggleCrashData(!crashDataOpen)} onClick={() => toggleCrashData(!isCrashDataSectionOpen)}
style={toggleButtonStyle} style={toggleButtonStyle}
aria-expanded={crashDataOpen} aria-expanded={isCrashDataSectionOpen}
> >
{crashDataOpen ? '' : '+'} {isCrashDataSectionOpen ? '' : '+'}
</button> </button>
</div> </div>
{crashDataOpen && ( {isCrashDataSectionOpen && (
<div style={{ paddingLeft: '8px' }}> <div style={{ paddingLeft: '8px' }}>
{/* Crash Density Legend */} {/* Crash Density Legend */}
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>

View File

@@ -6,7 +6,9 @@ import UnifiedControlPanel from './components/UnifiedControlPanel';
import PopupOverlay from './components/PopupOverlay'; import PopupOverlay from './components/PopupOverlay';
import MapNavigationControl from './components/MapNavigationControl'; import MapNavigationControl from './components/MapNavigationControl';
import DirectionsSidebar from './components/DirectionsSidebar'; import DirectionsSidebar from './components/DirectionsSidebar';
import SafetyAnalysisModal from './components/SafetyAnalysisModal';
import { useCrashData } from './hooks/useCrashData'; import { useCrashData } from './hooks/useCrashData';
import { WeatherData, CrashAnalysisData } from '../lib/flaskApi';
export default function Home() { export default function Home() {
const mapRef = useRef<any>(null); const mapRef = useRef<any>(null);
@@ -21,6 +23,20 @@ export default function Home() {
const [popupVisible, setPopupVisible] = useState(false); const [popupVisible, setPopupVisible] = useState(false);
const [isMapPickingMode, setIsMapPickingMode] = 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 // Shared crash data state - load all data for filtered year
const crashDataHook = useCrashData({ autoLoad: true }); const crashDataHook = useCrashData({ autoLoad: true });
@@ -70,8 +86,23 @@ export default function Home() {
{/* Native Mapbox navigation control (zoom + compass) */} {/* Native Mapbox navigation control (zoom + compass) */}
<MapNavigationControl mapRef={mapRef} position="top-right" /> <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> </div>
{/* Safety Analysis Modal - Rendered at page level */}
<SafetyAnalysisModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
weatherData={modalData.weather}
crashAnalysis={modalData.crashAnalysis}
coordinates={modalData.coordinates}
/>
</div> </div>
); );
} }

161
web/src/lib/flaskApi.ts Normal file
View 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 || ''
};
};