diff --git a/web/src/app/components/DirectionsSidebar.tsx b/web/src/app/components/DirectionsSidebar.tsx index 554094c..f91d209 100644 --- a/web/src/app/components/DirectionsSidebar.tsx +++ b/web/src/app/components/DirectionsSidebar.tsx @@ -4,7 +4,8 @@ import React, { useEffect, useRef, useState } from "react"; import mapboxgl from "mapbox-gl"; import GeocodeInput from './GeocodeInput'; import { useCrashData } from '../hooks/useCrashData'; -import { calculateRouteCrashDensity, createRouteGradientStops } from '../lib/mapUtils'; +import { calculateRouteCrashDensity, createRouteGradientStops } from '../../lib/mapUtils'; +import { fetchSafeRoute, type SafeRouteData } from '../../lib/flaskApi'; interface Props { mapRef: React.MutableRefObject; @@ -16,6 +17,8 @@ interface Props { // Routing now uses geocoder-only selection inside the sidebar (no manual coordinate parsing) +// Routing now uses geocoder-only selection inside the sidebar (no manual coordinate parsing) + export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", onMapPickingModeChange, gradientRoutes = true, mapStyleChoice = 'dark' }: Props) { // Sidebar supports collapse via a hamburger button in the header const [collapsed, setCollapsed] = useState(false); @@ -32,6 +35,10 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", const [isDestMapPicking, setIsDestMapPicking] = useState(false); const [routes, setRoutes] = useState([]); const [selectedRouteIndex, setSelectedRouteIndex] = useState(0); + // Safe route functionality + const [safeRouteData, setSafeRouteData] = useState(null); + const [safeRouteLoading, setSafeRouteLoading] = useState(false); + const [showSafeRoute, setShowSafeRoute] = useState(false); // custom geocoder inputs + suggestions (we implement our own UI instead of the library) const originQueryRef = useRef(""); const destQueryRef = useRef(""); @@ -45,6 +52,26 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", const destInputRef = useRef(null); const mountedRef = useRef(true); + // Helper function to normalize safety score to 0-10 scale + const normalizeSafetyScore = (rawScore: number): number => { + console.log('Raw safety score:', rawScore); // Debug log + + // Based on the safety score calculation: + // - 0 = no crashes (perfectly safe) + // - 1-5 = low danger + // - 5-20 = moderate danger + // - 20+ = high danger + + if (rawScore === 0) return 10; // Perfect safety + + // Use logarithmic scale to handle wide range of scores + // Map common ranges: 0.1->9.5, 1->8, 5->6, 20->3, 50->1, 100+->0 + const safetyScore = Math.max(0, 10 - Math.log10(rawScore + 1) * 2.5); + + console.log('Normalized safety score:', safetyScore); // Debug log + return Math.max(0, Math.min(10, safetyScore)); + }; + useEffect(() => { return () => { mountedRef.current = false; }; }, []); @@ -495,6 +522,75 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", // Clear multiple routes state setRoutes([]); setSelectedRouteIndex(0); + // Clear safe route state + setSafeRouteData(null); + setShowSafeRoute(false); + // Remove safe route from map if it exists + if (map) { + try { + if (map.getLayer("safe-route-line")) map.removeLayer("safe-route-line"); + if (map.getSource("safe-route")) map.removeSource("safe-route"); + } catch (e) {} + } + } + + // Safe route functionality + async function handleGetSafeRoute() { + const map = mapRef.current; + if (!map) return; + const o = originCoord; + const d = destCoord; + if (!o || !d) { + alert('Please select both origin and destination using the location search boxes.'); + return; + } + + setSafeRouteLoading(true); + try { + const safeRouteResult = await fetchSafeRoute(o[1], o[0], d[1], d[0]); // API expects lat, lon + console.log('Safe route result:', safeRouteResult); + setSafeRouteData(safeRouteResult); + setShowSafeRoute(true); + + // If we have a recommended route, display it + if (safeRouteResult.recommended_route?.geometry?.coordinates) { + const safeRouteGeo: GeoJSON.Feature = { + type: "Feature", + properties: { type: "safe-route" }, + geometry: { + type: "LineString", + coordinates: safeRouteResult.recommended_route.geometry.coordinates + } + }; + + // Add safe route to map + if (!map.getSource("safe-route")) { + map.addSource("safe-route", { type: "geojson", data: safeRouteGeo }); + } else { + (map.getSource("safe-route") as mapboxgl.GeoJSONSource).setData(safeRouteGeo); + } + + if (!map.getLayer("safe-route-line")) { + map.addLayer({ + id: "safe-route-line", + type: "line", + source: "safe-route", + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#10b981", // green color for safe route + "line-width": 6, + "line-opacity": 0.8, + "line-dasharray": [1, 1] // subtle dashed line to differentiate + }, + }); + } + } + } catch (error) { + console.error('Error fetching safe route:', error); + alert('Failed to fetch safe route. Please try again.'); + } finally { + setSafeRouteLoading(false); + } } // re-add layers after style change @@ -683,8 +779,9 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
- - + + +
{/* Route Options */} @@ -787,6 +884,66 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", )} + {/* Safe Route Analysis */} + {showSafeRoute && safeRouteData && ( +
+
+ + + + AI Safe Route Analysis +
+ +
+
+
+ Safety Score: + = 7 + ? 'text-green-400' + : normalizeSafetyScore(safeRouteData.recommended_route.safety_score) >= 4 + ? 'text-yellow-400' + : 'text-red-400' + }`}> + {normalizeSafetyScore(safeRouteData.recommended_route.safety_score).toFixed(1)}/10 + +
+
+ Distance: + {safeRouteData.recommended_route.distance_km.toFixed(1)} km +
+
+ Duration: + {Math.round(safeRouteData.recommended_route.duration_min)} min +
+
+ Crashes Nearby: + {safeRouteData.recommended_route.crashes_nearby} +
+
+ + {safeRouteData.safety_analysis && ( +
+
Safety Analysis:
+
{safeRouteData.safety_analysis}
+
+ )} + + {safeRouteData.weather_summary && ( +
+
Weather Conditions:
+
{safeRouteData.weather_summary}
+
+ )} + +
+ + Green line shows AI-recommended safe route +
+
+
+ )} + {/* Route Safety Legend */} {(originCoord && destCoord) && (
diff --git a/web/src/app/components/MapView.tsx b/web/src/app/components/MapView.tsx index d554bb8..967b8b4 100644 --- a/web/src/app/components/MapView.tsx +++ b/web/src/app/components/MapView.tsx @@ -5,7 +5,7 @@ import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; -import { generateDCPoints, generateDCPointsWithAI, haversine, PointFeature, convertCrashDataToGeoJSON, convertCrashDataToGeoJSONWithAI } from '../lib/mapUtils'; +import { convertSyntheticDataToGeoJSON, haversine, PointFeature, convertCrashDataToGeoJSON } from '../../lib/mapUtils'; import { useCrashData, UseCrashDataResult } from '../hooks/useCrashData'; import { CrashData } from '../api/crashes/route'; import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi'; @@ -49,7 +49,6 @@ interface MapViewProps { crashData?: CrashData[]; // external crash data to use crashDataHook?: UseCrashDataResult; // the crash data hook from main page isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups) - useAIMagnitudes?: boolean; // whether to use AI-predicted crash magnitudes } export default function MapView({ @@ -64,8 +63,7 @@ export default function MapView({ useRealCrashData = true, crashData = [], crashDataHook, - isMapPickingMode = false, - useAIMagnitudes = true // Default to true to use AI predictions + isMapPickingMode = false }: MapViewProps) { const containerRef = useRef(null); const mapContainerRef = useRef(null); @@ -92,16 +90,11 @@ export default function MapView({ console.log('Converting crash data to GeoJSON...'); const processData = async () => { - setIsLoadingAIPredictions(useAIMagnitudes); + setIsLoadingAIPredictions(false); // No AI predictions anymore let geoJSONData: GeoJSON.FeatureCollection; - if (useAIMagnitudes) { - console.log('πŸ€– Using AI-enhanced crash data conversion...'); - geoJSONData = await convertCrashDataToGeoJSONWithAI(activeData); - } else { - console.log('πŸ“Š Using standard crash data conversion...'); - geoJSONData = convertCrashDataToGeoJSON(activeData); - } + console.log(`πŸ—ΊοΈ Processing crash data: traditional mode`); + geoJSONData = convertCrashDataToGeoJSON(activeData); dcDataRef.current = geoJSONData; setIsLoadingAIPredictions(false); @@ -120,7 +113,7 @@ export default function MapView({ processData().catch(console.error); } - }, [crashData, crashDataHook, useRealCrashData, useAIMagnitudes]); + }, [crashData, crashDataHook, useRealCrashData]); useEffect(() => { const el = containerRef.current; @@ -203,24 +196,13 @@ export default function MapView({ const initializeData = async () => { if (useRealCrashData && activeData.length > 0) { console.log('Using real crash data'); - if (useAIMagnitudes) { - setIsLoadingAIPredictions(true); - console.log('πŸ€– Using AI-enhanced real crash data...'); - dcDataRef.current = await convertCrashDataToGeoJSONWithAI(activeData); - setIsLoadingAIPredictions(false); - } else { - dcDataRef.current = convertCrashDataToGeoJSON(activeData); - } + setIsLoadingAIPredictions(false); + console.log(`πŸ—ΊοΈ Processing real crash data: traditional mode`); + dcDataRef.current = convertCrashDataToGeoJSON(activeData); + setIsLoadingAIPredictions(false); } else if (!useRealCrashData) { console.log('Using synthetic data'); - if (useAIMagnitudes) { - setIsLoadingAIPredictions(true); - console.log('πŸ€– Using AI-enhanced synthetic data...'); - dcDataRef.current = await generateDCPointsWithAI(900); - setIsLoadingAIPredictions(false); - } else { - dcDataRef.current = generateDCPoints(900); - } + dcDataRef.current = convertSyntheticDataToGeoJSON(38.9072, -77.0369, 900); } else { console.log('No data available yet, using empty data'); dcDataRef.current = { type: 'FeatureCollection' as const, features: [] }; diff --git a/web/src/app/components/UnifiedControlPanel.tsx b/web/src/app/components/UnifiedControlPanel.tsx index 6215d58..c421b37 100644 --- a/web/src/app/components/UnifiedControlPanel.tsx +++ b/web/src/app/components/UnifiedControlPanel.tsx @@ -1,8 +1,8 @@ "use client"; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import CrashDataControls from './CrashDataControls'; import { UseCrashDataResult } from '../hooks/useCrashData'; -import { getCircuitBreakerStatus } from '../../lib/crashMagnitudeApi'; interface UnifiedControlPanelProps { // Map controls props @@ -18,8 +18,6 @@ interface UnifiedControlPanelProps { onChangeIntensity: (v: number) => void; gradientRoutes: boolean; onToggleGradientRoutes: (v: boolean) => void; - useAIMagnitudes: boolean; - onToggleAIMagnitudes: (v: boolean) => void; // Crash data controls props crashDataHook: UseCrashDataResult; @@ -39,8 +37,6 @@ export default function UnifiedControlPanel({ onChangeIntensity, gradientRoutes, onToggleGradientRoutes, - useAIMagnitudes, - onToggleAIMagnitudes, crashDataHook, onDataLoaded }: UnifiedControlPanelProps) { @@ -64,7 +60,6 @@ export default function UnifiedControlPanel({ const [isMapControlsSectionOpen, setIsMapControlsSectionOpen] = useState(getInitialMapControlsState); const [isCrashDataSectionOpen, setIsCrashDataSectionOpen] = useState(getInitialCrashDataState); const [isHydrated, setIsHydrated] = useState(false); - const [aiApiStatus, setAiApiStatus] = useState<{ isOpen: boolean; failures: number }>({ isOpen: false, failures: 0 }); // Load localStorage values after hydration useEffect(() => { @@ -85,28 +80,6 @@ export default function UnifiedControlPanel({ setIsHydrated(true); }, []); - // Check AI API status when AI magnitudes are enabled - useEffect(() => { - if (useAIMagnitudes) { - const checkApiStatus = async () => { - try { - const status = await getCircuitBreakerStatus(); - setAiApiStatus(status); - } catch (error) { - console.error('Error checking API status:', error); - setAiApiStatus({ isOpen: true, failures: 1 }); - } - }; - - // Check immediately - checkApiStatus(); - - // Check every 30 seconds - const interval = setInterval(checkApiStatus, 30000); - return () => clearInterval(interval); - } - }, [useAIMagnitudes]); - // Crash data state const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook; const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch @@ -266,29 +239,6 @@ export default function UnifiedControlPanel({ onToggleGradientRoutes(e.target.checked)} />
-
- - onToggleAIMagnitudes(e.target.checked)} /> -
- - {useAIMagnitudes && ( -
- Uses AI to predict crash severity. Falls back to traditional calculation if API unavailable. -
- )} -
onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} /> diff --git a/web/src/app/components/mapUtils.ts b/web/src/app/components/mapUtils.ts deleted file mode 100644 index b3c1a7b..0000000 --- a/web/src/app/components/mapUtils.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type PointFeature = GeoJSON.Feature; - -export const haversine = (a: [number, number], b: [number, number]) => { - const toRad = (v: number) => v * Math.PI / 180; - const R = 6371000; // meters - const dLat = toRad(b[1] - a[1]); - const dLon = toRad(b[0] - a[0]); - const lat1 = toRad(a[1]); - const lat2 = toRad(b[1]); - const sinDLat = Math.sin(dLat/2); - const sinDLon = Math.sin(dLon/2); - const aH = sinDLat*sinDLat + sinDLon*sinDLon * Math.cos(lat1)*Math.cos(lat2); - const c = 2 * Math.atan2(Math.sqrt(aH), Math.sqrt(1-aH)); - return R * c; -}; - -export const generateDCPoints = (count = 500) => { - const center = { lon: -77.0369, lat: 38.9072 }; - const features: PointFeature[] = []; - - const randNormal = () => { - let u = 0, v = 0; - while (u === 0) u = Math.random(); - while (v === 0) v = Math.random(); - return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); - }; - - for (let i = 0; i < count; i++) { - const radius = Math.abs(randNormal()) * 0.02; - const angle = Math.random() * Math.PI * 2; - const lon = center.lon + Math.cos(angle) * radius; - const lat = center.lat + Math.sin(angle) * radius; - const mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); - features.push({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { mag } - }); - } - - return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; -}; diff --git a/web/src/app/lib/mapUtils.ts b/web/src/app/lib/mapUtils.ts deleted file mode 100644 index 560b669..0000000 --- a/web/src/app/lib/mapUtils.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { CrashData } from '../api/crashes/route'; -import { getCachedCrashMagnitude, CrashMagnitudePrediction } from '../../lib/crashMagnitudeApi'; - -export type PointFeature = GeoJSON.Feature; - -export const haversine = (a: [number, number], b: [number, number]) => { - const toRad = (v: number) => v * Math.PI / 180; - const R = 6371000; // meters - const dLat = toRad(b[1] - a[1]); - const dLon = toRad(b[0] - a[0]); - const lat1 = toRad(a[1]); - const lat2 = toRad(b[1]); - const sinDLat = Math.sin(dLat/2); - const sinDLon = Math.sin(dLon/2); - const aH = sinDLat*sinDLat + sinDLon*sinDLon * Math.cos(lat1)*Math.cos(lat2); - const c = 2 * Math.atan2(Math.sqrt(aH), Math.sqrt(1-aH)); - return R * c; -}; - -export const convertCrashDataToGeoJSON = (crashes: CrashData[]): GeoJSON.FeatureCollection => { - console.log('Converting crash data to GeoJSON:', crashes.length, 'crashes'); - console.log('Sample crash data:', crashes[0]); - - const features: PointFeature[] = crashes.map((crash) => { - // Calculate fallback severity score based on fatalities and major injuries - const fallbackSeverityScore = Math.max(1, - (crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 3 + - (crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 2 + - (crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles) - ); - - return { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [crash.longitude, crash.latitude] - }, - properties: { - mag: Math.min(6, fallbackSeverityScore), // Cap at 6 for consistent visualization - crashData: crash, - aiPredicted: false // Will be updated when AI prediction is available - } - }; - }); - - const geoJSON = { - type: 'FeatureCollection' as const, - features - }; - - console.log('Generated GeoJSON with', features.length, 'features'); - console.log('Sample feature:', features[0]); - - return geoJSON; -}; - -/** - * Enhanced version that fetches AI predictions for crash magnitudes - */ -export const convertCrashDataToGeoJSONWithAI = async (crashes: CrashData[]): Promise => { - console.log('πŸ€– Converting crash data to GeoJSON with AI predictions:', crashes.length, 'crashes'); - - // Start with the basic conversion - const baseGeoJSON = convertCrashDataToGeoJSON(crashes); - - // Limit concurrent API calls to avoid overwhelming the API - const BATCH_SIZE = 10; - const enhancedFeatures = [...baseGeoJSON.features]; - let successfulPredictions = 0; - - for (let i = 0; i < crashes.length; i += BATCH_SIZE) { - const batch = crashes.slice(i, i + BATCH_SIZE); - const batchPromises = batch.map(async (crash, batchIndex) => { - const featureIndex = i + batchIndex; - - try { - // Get AI prediction for this crash location - const prediction = await getCachedCrashMagnitude(crash.latitude, crash.longitude); - - if (prediction && typeof prediction.prediction === 'number') { - // Scale the roadcast API prediction to a reasonable range for visualization - // Roadcast returns values like 47, so we'll scale them to 1-10 range - let scaledMagnitude = prediction.prediction; - - // If the value seems to be in the roadcast range (typically 0-100), scale it down - if (prediction.prediction > 20) { - scaledMagnitude = Math.max(1, Math.min(10, Math.round(prediction.prediction / 10))); - } else { - scaledMagnitude = Math.max(1, Math.min(10, Math.round(prediction.prediction))); - } - - console.log(`🎯 Scaled magnitude from ${prediction.prediction} to ${scaledMagnitude}`); - - enhancedFeatures[featureIndex] = { - ...enhancedFeatures[featureIndex], - properties: { - ...enhancedFeatures[featureIndex].properties, - mag: scaledMagnitude, - aiPredicted: true - } - }; - - return true; // Success - } - } catch (error) { - console.warn(`⚠️ Failed to get AI prediction for crash ${featureIndex}:`, error); - } - - return false; // Failed - }); - - const results = await Promise.allSettled(batchPromises); - successfulPredictions += results.filter(r => r.status === 'fulfilled' && r.value === true).length; - - // Small delay between batches to be nice to the API - if (i + BATCH_SIZE < crashes.length) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - const enhancedGeoJSON = { - type: 'FeatureCollection' as const, - features: enhancedFeatures as PointFeature[] - }; - - console.log(`βœ… Enhanced GeoJSON with ${successfulPredictions}/${crashes.length} AI predictions`); - - return enhancedGeoJSON; -}; - -export const generateDCPoints = (count = 500) => { - const center = { lon: -77.0369, lat: 38.9072 }; - const features: PointFeature[] = []; - - const randNormal = () => { - let u = 0, v = 0; - while (u === 0) u = Math.random(); - while (v === 0) v = Math.random(); - return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); - }; - - for (let i = 0; i < count; i++) { - const radius = Math.abs(randNormal()) * 0.02; - const angle = Math.random() * Math.PI * 2; - const lon = center.lon + Math.cos(angle) * radius; - const lat = center.lat + Math.sin(angle) * radius; - const mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); - - // Create synthetic crash data for backward compatibility - const syntheticCrash: CrashData = { - id: `synthetic-${i}`, - latitude: lat, - longitude: lon, - reportDate: new Date().toISOString(), - address: `Synthetic Location ${i}`, - ward: 'Ward 1', - totalVehicles: Math.floor(Math.random() * 3) + 1, - totalPedestrians: Math.floor(Math.random() * 2), - totalBicycles: Math.floor(Math.random() * 2), - fatalDriver: 0, - fatalPedestrian: 0, - fatalBicyclist: 0, - majorInjuriesDriver: Math.floor(Math.random() * 2), - majorInjuriesPedestrian: 0, - majorInjuriesBicyclist: 0, - speedingInvolved: Math.floor(Math.random() * 2), - }; - - features.push({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { mag, crashData: syntheticCrash, aiPredicted: false } - }); - } - - return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; -}; - -/** - * Enhanced version of generateDCPoints that uses AI predictions - */ -export const generateDCPointsWithAI = async (count = 500) => { - const center = { lon: -77.0369, lat: 38.9072 }; - const features: PointFeature[] = []; - - const randNormal = () => { - let u = 0, v = 0; - while (u === 0) u = Math.random(); - while (v === 0) v = Math.random(); - return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); - }; - - // Generate locations first - const locations = []; - for (let i = 0; i < count; i++) { - const radius = Math.abs(randNormal()) * 0.02; - const angle = Math.random() * Math.PI * 2; - const lon = center.lon + Math.cos(angle) * radius; - const lat = center.lat + Math.sin(angle) * radius; - locations.push({ lon, lat, index: i }); - } - - // Get AI predictions in batches to avoid overwhelming the API - console.log(`πŸ€– Getting AI predictions for ${count} synthetic points...`); - const BATCH_SIZE = 20; - const predictions: (any | null)[] = new Array(count).fill(null); - let successfulPredictions = 0; - - for (let i = 0; i < locations.length; i += BATCH_SIZE) { - const batch = locations.slice(i, i + BATCH_SIZE); - const batchPromises = batch.map(async (location) => { - try { - const prediction = await getCachedCrashMagnitude(location.lat, location.lon); - return prediction; - } catch (error) { - console.warn(`⚠️ Failed to get AI prediction for synthetic point ${location.index}:`, error); - return null; - } - }); - - const batchResults = await Promise.allSettled(batchPromises); - batchResults.forEach((result, batchIndex) => { - const globalIndex = i + batchIndex; - if (result.status === 'fulfilled') { - predictions[globalIndex] = result.value; - if (result.value) successfulPredictions++; - } - }); - - // Small delay between batches - if (i + BATCH_SIZE < locations.length) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - } - - // Create features with AI predictions or fallback magnitudes - for (let i = 0; i < count; i++) { - const location = locations[i]; - const prediction = predictions[i]; - - // Use AI prediction if available, otherwise use random magnitude - let mag: number; - let aiPredicted = false; - - if (prediction && typeof prediction.prediction === 'number') { - // Scale the roadcast API prediction to a reasonable range for visualization - if (prediction.prediction > 20) { - mag = Math.max(1, Math.min(10, Math.round(prediction.prediction / 10))); - } else { - mag = Math.max(1, Math.min(10, Math.round(prediction.prediction))); - } - console.log(`🎯 Synthetic point scaled magnitude from ${prediction.prediction} to ${mag}`); - aiPredicted = true; - } else { - mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); - } - - // Create synthetic crash data for backward compatibility - const syntheticCrash: CrashData = { - id: `synthetic-${i}`, - latitude: location.lat, - longitude: location.lon, - reportDate: new Date().toISOString(), - address: `Synthetic Location ${i}`, - ward: 'Ward 1', - totalVehicles: Math.floor(Math.random() * 3) + 1, - totalPedestrians: Math.floor(Math.random() * 2), - totalBicycles: Math.floor(Math.random() * 2), - fatalDriver: 0, - fatalPedestrian: 0, - fatalBicyclist: 0, - majorInjuriesDriver: Math.floor(Math.random() * 2), - majorInjuriesPedestrian: 0, - majorInjuriesBicyclist: 0, - speedingInvolved: Math.floor(Math.random() * 2), - }; - - features.push({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [location.lon, location.lat] }, - properties: { mag, crashData: syntheticCrash, aiPredicted } - }); - } - - console.log(`βœ… Generated ${count} synthetic points with ${successfulPredictions} AI predictions`); - - return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; -}; - -// Calculate crash density along a route path -export const calculateRouteCrashDensity = ( - routeCoordinates: [number, number][], - crashData: CrashData[], - searchRadiusMeters: number = 100 -): number[] => { - if (!routeCoordinates || routeCoordinates.length === 0) return []; - - const densities: number[] = []; - - for (let i = 0; i < routeCoordinates.length; i++) { - const currentPoint = routeCoordinates[i]; - let crashCount = 0; - let severityScore = 0; - - // Count crashes within search radius of current point - for (const crash of crashData) { - const crashPoint: [number, number] = [crash.longitude, crash.latitude]; - const distance = haversine(currentPoint, crashPoint); - - if (distance <= searchRadiusMeters) { - crashCount++; - // Weight by severity - const severity = Math.max(1, - (crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 5 + - (crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 3 + - (crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles) - ); - severityScore += severity; - } - } - - // Normalize density score (0-1 range) - const density = Math.min(1, severityScore / 20); // Adjust divisor based on data - densities.push(density); - } - - return densities; -}; - -// Create gradient stops based on crash densities along route -export const createRouteGradientStops = (densities: number[]): any[] => { - if (!densities || densities.length === 0) { - // Default gradient: green to red - return [ - 'interpolate', - ['linear'], - ['line-progress'], - 0, 'green', - 1, 'red' - ]; - } - - const stops: any[] = ['interpolate', ['linear'], ['line-progress']]; - - for (let i = 0; i < densities.length; i++) { - const progress = i / (densities.length - 1); - const density = densities[i]; - - // Color based on crash density: green (safe) to red (dangerous) - let color: string; - if (density < 0.2) { - color = '#22c55e'; // green - } else if (density < 0.4) { - color = '#eab308'; // yellow - } else if (density < 0.6) { - color = '#f97316'; // orange - } else if (density < 0.8) { - color = '#dc2626'; // red - } else { - color = '#7f1d1d'; // dark red - } - - stops.push(progress, color); - } - - return stops; -}; diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 56f6cda..e235522 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -18,7 +18,6 @@ export default function Home() { const [heatRadius, setHeatRadius] = useState(16); const [heatIntensity, setHeatIntensity] = useState(1); const [gradientRoutes, setGradientRoutes] = useState(true); - const [useAIMagnitudes, setUseAIMagnitudes] = useState(true); // Default to true since roadcast API is reliable const [popup, setPopup] = useState(null); const [popupVisible, setPopupVisible] = useState(false); @@ -67,8 +66,6 @@ export default function Home() { onChangeIntensity={(v) => setHeatIntensity(v)} gradientRoutes={gradientRoutes} onToggleGradientRoutes={(v) => setGradientRoutes(v)} - useAIMagnitudes={useAIMagnitudes} - onToggleAIMagnitudes={(v) => setUseAIMagnitudes(v)} crashDataHook={crashDataHook} /> @@ -82,7 +79,6 @@ export default function Home() { crashData={crashDataHook.data} crashDataHook={crashDataHook} isMapPickingMode={isMapPickingMode} - useAIMagnitudes={useAIMagnitudes} onMapReady={(m) => { mapRef.current = m; }} onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }} /> diff --git a/web/src/lib/crashMagnitudeApi.ts b/web/src/lib/crashMagnitudeApi.ts deleted file mode 100644 index 7d1ceed..0000000 --- a/web/src/lib/crashMagnitudeApi.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * API service for crash magnitude prediction using roadcast model - */ - -export interface CrashMagnitudePrediction { - prediction: number; - confidence?: number; -} - -export interface CrashMagnitudeRequest { - source: { - lat: number; - lon: number; - }; - destination: { - lat: number; - lon: number; - }; -} - -export interface CrashMagnitudeResponse { - prediction: CrashMagnitudePrediction; - called_with: string; - diagnostics?: { - input_dim: number; - }; - index?: number; -} - -/** - * Get crash magnitude prediction from roadcast API - * Simplified version that always tries to get the prediction - */ -export async function getCrashMagnitudePrediction( - sourceLat: number, - sourceLon: number, - destLat: number, - destLon: number -): Promise { - - try { - const requestBody: CrashMagnitudeRequest = { - source: { - lat: sourceLat, - lon: sourceLon - }, - destination: { - lat: destLat, - lon: destLon - } - }; - - console.log('οΏ½ Requesting crash magnitude from roadcast API:', requestBody); - - // Create fetch options with timeout - const fetchOptions: RequestInit = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(requestBody), - }; - - // Add timeout if AbortSignal.timeout is supported - try { - if (typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal) { - fetchOptions.signal = AbortSignal.timeout(10000); // 10 second timeout - } - } catch (e) { - // AbortSignal.timeout not supported, continue without timeout - console.log('⚠️ AbortSignal.timeout not supported, continuing without timeout'); - } - - const response = await fetch('http://localhost:5000/predict', fetchOptions); - - if (!response.ok) { - console.error('❌ Roadcast API error:', response.status, response.statusText); - return null; - } - - const data: CrashMagnitudeResponse = await response.json(); - console.log('βœ… Roadcast magnitude prediction received:', data); - - // Handle roadcast API response format - // The roadcast API returns the magnitude in the 'index' field - if (data.index !== undefined) { - console.log('🎯 Using roadcast index as crash magnitude:', data.index); - return { - prediction: data.index, - confidence: 0.95 // High confidence for roadcast model - }; - } else if (data.prediction && typeof data.prediction === 'object' && data.prediction.prediction !== undefined) { - // Fallback: Response format: { prediction: { prediction: number } } - return data.prediction; - } else if (typeof data.prediction === 'number') { - // Fallback: Response format: { prediction: number } - return { prediction: data.prediction }; - } - - console.warn('⚠️ No usable magnitude data in roadcast API response:', data); - return null; - - } catch (error) { - - if (error instanceof Error) { - if (error.name === 'AbortError') { - console.warn('⏰ Crash magnitude API request timed out'); - } else if (error.message.includes('fetch')) { - console.warn('🌐 Network error accessing crash magnitude API:', error.message); - } else { - console.warn('❌ Error fetching crash magnitude prediction:', error.message); - } - } else { - console.warn('❌ Unknown error fetching crash magnitude prediction:', error); - } - return null; - } -} - -/** - * Get crash magnitude for a single point (using same point for source and destination) - */ -export async function getPointCrashMagnitude( - lat: number, - lon: number -): Promise { - return getCrashMagnitudePrediction(lat, lon, lat, lon); -} - -/** - * Batch get crash magnitude predictions for multiple locations - */ -export async function getBatchCrashMagnitudes( - locations: Array<{ lat: number; lon: number; id?: string }> -): Promise> { - const results = await Promise.allSettled( - locations.map(async (location) => { - const prediction = await getPointCrashMagnitude(location.lat, location.lon); - return { prediction, id: location.id }; - }) - ); - - return results.map((result, index) => { - if (result.status === 'fulfilled') { - return result.value; - } else { - console.error(`❌ Failed to get magnitude for location ${index}:`, result.reason); - return { prediction: null, id: locations[index].id }; - } - }); -} - -/** - * Cache for magnitude predictions to avoid repeated API calls - */ -const magnitudeCache = new Map(); -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes - -/** - * Status tracking for roadcast API (simplified - always available) - */ - -function isCircuitBreakerOpen(): boolean { - // Roadcast API is local and reliable, always return false - return false; -} - -function recordCircuitBreakerFailure(): void { - // Not needed for local roadcast API, but kept for compatibility -} - -function recordCircuitBreakerSuccess(): void { - // Not needed for local roadcast API, but kept for compatibility -} - -function getCacheKey(lat: number, lon: number): string { - return `${lat.toFixed(6)},${lon.toFixed(6)}`; -} - -/** - * Get cached crash magnitude or fetch if not available/expired - */ -export async function getCachedCrashMagnitude( - lat: number, - lon: number -): Promise { - const cacheKey = getCacheKey(lat, lon); - const cached = magnitudeCache.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { - console.log('πŸ“¦ Using cached magnitude prediction for:', cacheKey); - return cached.prediction; - } - - const prediction = await getPointCrashMagnitude(lat, lon); - - if (prediction) { - magnitudeCache.set(cacheKey, { - prediction, - timestamp: Date.now() - }); - } - - return prediction; -} - -/** - * Get current status of the roadcast API by testing connection - */ -export async function getCircuitBreakerStatus(): Promise<{ isOpen: boolean; failures: number; resetTime?: number }> { - try { - // Test the roadcast API with a simple request - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - const response = await fetch('http://localhost:5000/predict', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - source: { lat: 38.9, lon: -77.0 }, - destination: { lat: 38.91, lon: -77.01 } - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (response.ok) { - const data = await response.json(); - console.log('🟒 Roadcast API status check successful:', data.index); - return { - isOpen: false, // API is available - failures: 0, - resetTime: undefined - }; - } else { - console.log('πŸ”΄ Roadcast API status check failed:', response.status); - return { - isOpen: true, // API returned error - failures: 1, - resetTime: undefined - }; - } - } catch (error) { - console.log('πŸ”Œ Roadcast API unavailable:', error); - return { - isOpen: true, // API is unavailable - failures: 1, - resetTime: undefined - }; - } -} \ No newline at end of file diff --git a/web/src/lib/flaskApi.ts b/web/src/lib/flaskApi.ts index 795dc1a..6920ece 100644 --- a/web/src/lib/flaskApi.ts +++ b/web/src/lib/flaskApi.ts @@ -1,4 +1,4 @@ -const FLASK_API_BASE = 'http://127.0.0.1:5001'; +const FLASK_API_BASE = 'https://llm.sirblob.co'; export interface WeatherData { temperature: number; @@ -22,6 +22,33 @@ export interface CrashAnalysisData { safetyAnalysis?: string; } +export interface SafeRouteData { + success: boolean; + start_coordinates: { lat: number; lon: number }; + end_coordinates: { lat: number; lon: number }; + recommended_route: { + coordinates: [number, number][]; + distance_km: number; + duration_min: number; + geometry?: any; // GeoJSON for Mapbox + safety_score: number; + crashes_nearby: number; + max_danger_score: number; + }; + safety_analysis: string; + weather_summary?: string; + route_comparison?: any; + alternative_routes: Array<{ + route_id: string; + coordinates: [number, number][]; + distance_km: number; + duration_min: number; + geometry?: any; + safety_score: number; + crashes_nearby: number; + }>; +} + export const fetchWeatherData = async (lat: number, lng: number): Promise => { const response = await fetch(`${FLASK_API_BASE}/api/weather?lat=${lat}&lon=${lng}`); @@ -50,6 +77,38 @@ export const fetchCrashAnalysis = async (lat: number, lng: number): Promise => { + const response = await fetch(`${FLASK_API_BASE}/api/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, + }), + }); + + if (!response.ok) { + throw new Error('Failed to fetch safe route'); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Unknown error occurred'); + } + + return data; +}; + // Transform Flask weather API response to our WeatherData interface const transformWeatherData = (apiResponse: any): WeatherData => { // Extract summary if available @@ -86,7 +145,7 @@ const transformWeatherData = (apiResponse: any): WeatherData => { const transformCrashAnalysis = (apiResponse: any): CrashAnalysisData => { const data = apiResponse; - // Extract risk level from safety analysis text + // Extract risk level from safety analysis text with multiple fallback strategies let riskLevel = 'unknown'; let recommendations: string[] = []; @@ -94,10 +153,11 @@ const transformCrashAnalysis = (apiResponse: any): CrashAnalysisData => { const safetyText = data.safety_analysis; const safetyTextLower = safetyText.toLowerCase(); - // Look for danger level assessment (now without markdown formatting) + // Strategy 1: Look for explicit danger level assessment const dangerLevelMatch = safetyText.match(/danger level assessment[:\s]*([^.\n]+)/i); if (dangerLevelMatch) { const level = dangerLevelMatch[1].trim().toLowerCase(); + console.log('🎯 Found danger level assessment:', level); if (level.includes('very high') || level.includes('extreme')) { riskLevel = 'high'; } else if (level.includes('high')) { @@ -107,21 +167,48 @@ const transformCrashAnalysis = (apiResponse: any): CrashAnalysisData => { } 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'; + } + + // Strategy 2: Look for risk level patterns if first strategy failed + if (riskLevel === 'unknown') { + const riskPatterns = [ + { keywords: ['very high risk', 'extremely dangerous', 'very dangerous', 'extreme danger'], level: 'high' }, + { keywords: ['high risk', 'high danger', 'dangerous area', 'significant risk'], level: 'high' }, + { keywords: ['moderate risk', 'medium risk', 'moderate danger', 'moderately dangerous'], level: 'medium' }, + { keywords: ['low risk', 'relatively safe', 'low danger', 'minimal risk'], level: 'low' } + ]; + + for (const pattern of riskPatterns) { + if (pattern.keywords.some(keyword => safetyTextLower.includes(keyword))) { + riskLevel = pattern.level; + console.log('🎯 Found risk pattern:', pattern.keywords[0], 'β†’', pattern.level); + break; + } } } - // Extract recommendations from safety analysis (now without markdown) - const recommendationsMatch = safetyText.match(/specific recommendations[^:]*:([\s\S]*?)(?=\n\n|\d+\.|$)/i); + // Strategy 3: Fallback to crash data severity if text parsing fails + if (riskLevel === 'unknown' && data.crash_summary) { + const summary = data.crash_summary; + const totalCrashes = summary.total_crashes || 0; + const totalCasualties = summary.total_casualties || 0; + + if (totalCasualties > 10 || totalCrashes > 20) { + riskLevel = 'high'; + console.log('🎯 Fallback: High risk based on casualties/crashes', { totalCasualties, totalCrashes }); + } else if (totalCasualties > 3 || totalCrashes > 8) { + riskLevel = 'medium'; + console.log('🎯 Fallback: Medium risk based on casualties/crashes', { totalCasualties, totalCrashes }); + } else if (totalCrashes > 0) { + riskLevel = 'low'; + console.log('🎯 Fallback: Low risk based on crashes', { totalCrashes }); + } + } + + console.log('πŸ“Š Final risk level determination:', riskLevel); + + // Extract recommendations from safety analysis (more flexible approach) + const recommendationsMatch = safetyText.match(/(?:specific recommendations|recommendations)[^:]*:([\s\S]*?)(?=\n\n|\d+\.|$)/i); if (recommendationsMatch) { const recommendationsText = recommendationsMatch[1]; // Split by lines and filter for meaningful recommendations