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

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 { 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 {

View File

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

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";
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' }}>

View File

@@ -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
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 || ''
};
};