BIG WEB UPDATE

This commit is contained in:
2025-09-27 22:45:52 -04:00
parent 6bdd8f0fe3
commit f1073ef3df
17 changed files with 1818 additions and 244 deletions

View File

@@ -0,0 +1,140 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient } from 'mongodb';
// MongoDB connection (reuse from main route)
let client: MongoClient | null = null;
async function getMongoClient(): Promise<MongoClient> {
if (!client) {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI environment variable is not set');
}
client = new MongoClient(uri);
await client.connect();
}
return client;
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const lng = parseFloat(searchParams.get('lng') || '0');
const lat = parseFloat(searchParams.get('lat') || '0');
const radius = parseInt(searchParams.get('radius') || '1000'); // Default 1km radius
const limit = Math.min(1000, Math.max(1, parseInt(searchParams.get('limit') || '50')));
if (!lng || !lat) {
return NextResponse.json(
{ error: 'longitude (lng) and latitude (lat) parameters are required' },
{ status: 400 }
);
}
const mongoClient = await getMongoClient();
const db = mongoClient.db(process.env.DATABASE_NAME || 'crashes');
const collection = db.collection(process.env.COLLECTION_NAME || 'crashes');
// Create date filter for 2020 onwards - only show recent crash data
const dateFrom2020 = new Date('2020-01-01T00:00:00.000Z');
// Perform geospatial query using $nearSphere with null data filtering and date filter
const crashes = await collection.find(
{
location: {
$nearSphere: {
$geometry: {
type: "Point",
coordinates: [lng, lat]
},
$maxDistance: radius
}
},
// Additional filters to exclude null/invalid data and only include 2020+
'location.coordinates': { $exists: true, $ne: null, $size: 2 },
'location.coordinates.0': { $ne: null, $type: 'number' },
'location.coordinates.1': { $ne: null, $type: 'number' },
crashId: { $exists: true, $nin: [null, ''] },
reportDate: { $gte: dateFrom2020 }
},
{
projection: {
_id: 1,
crashId: 1,
'location.coordinates': 1,
reportDate: 1,
address: 1,
ward: 1,
severity: 1,
'vehicles.total': 1,
'casualties.pedestrians.total': 1,
'casualties.bicyclists.total': 1,
'casualties.drivers.fatal': 1,
'casualties.pedestrians.fatal': 1,
'casualties.bicyclists.fatal': 1,
'casualties.drivers.major_injuries': 1,
'casualties.pedestrians.major_injuries': 1,
'casualties.bicyclists.major_injuries': 1,
'circumstances.speeding_involved': 1
}
}
)
.limit(limit)
.toArray();
// Transform MongoDB documents to a more frontend-friendly format
const transformedData = crashes
.map((doc: any) => {
// Skip documents with invalid coordinates
const coords = doc.location?.coordinates;
if (!coords || !Array.isArray(coords) || coords.length !== 2) {
return null;
}
const lng = coords[0];
const lat = coords[1];
if (typeof lng !== 'number' || typeof lat !== 'number' ||
lng === 0 || lat === 0 || isNaN(lng) || isNaN(lat)) {
return null;
}
return {
id: doc.crashId || doc._id.toString(),
latitude: lat,
longitude: lng,
reportDate: doc.reportDate ? new Date(doc.reportDate).toISOString() : '',
address: doc.address || '',
ward: doc.ward || '',
severity: doc.severity || 'Unknown',
totalVehicles: doc.vehicles?.total || 0,
totalPedestrians: doc.casualties?.pedestrians?.total || 0,
totalBicycles: doc.casualties?.bicyclists?.total || 0,
fatalDriver: doc.casualties?.drivers?.fatal || 0,
fatalPedestrian: doc.casualties?.pedestrians?.fatal || 0,
fatalBicyclist: doc.casualties?.bicyclists?.fatal || 0,
majorInjuriesDriver: doc.casualties?.drivers?.major_injuries || 0,
majorInjuriesPedestrian: doc.casualties?.pedestrians?.major_injuries || 0,
majorInjuriesBicyclist: doc.casualties?.bicyclists?.major_injuries || 0,
speedingInvolved: doc.circumstances?.speeding_involved ? 1 : 0,
};
})
.filter((crash): crash is NonNullable<typeof crash> => crash !== null); // Filter out null entries
return NextResponse.json({
data: transformedData,
query: {
center: [lng, lat],
radiusMeters: radius,
resultsCount: transformedData.length
}
});
} catch (error) {
console.error('Error performing geospatial query:', error);
return NextResponse.json(
{ error: 'Failed to perform geospatial query' },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import csv from 'csv-parser';
import { MongoClient } from 'mongodb';
export type CrashData = {
id: string;
@@ -34,68 +32,125 @@ export type CrashResponse = {
};
};
const CSV_FILE_PATH = path.join(process.cwd(), 'public', 'Crashes_in_DC.csv');
// MongoDB connection
let client: MongoClient | null = null;
// Cache to store parsed CSV data
let csvCache: CrashData[] | null = null;
let csvCacheTimestamp = 0;
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
async function loadCsvData(): Promise<CrashData[]> {
const now = Date.now();
// Return cached data if it's still valid
if (csvCache && (now - csvCacheTimestamp) < CACHE_TTL) {
return csvCache;
}
return new Promise((resolve, reject) => {
const results: CrashData[] = [];
if (!fs.existsSync(CSV_FILE_PATH)) {
reject(new Error('CSV file not found'));
return;
async function getMongoClient(): Promise<MongoClient> {
if (!client) {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error('MONGODB_URI environment variable is not set');
}
client = new MongoClient(uri);
await client.connect();
}
return client;
}
fs.createReadStream(CSV_FILE_PATH)
.pipe(csv())
.on('data', (row: any) => {
// Parse the CSV row and extract relevant fields
const latitude = parseFloat(row.LATITUDE);
const longitude = parseFloat(row.LONGITUDE);
// Only include rows with valid coordinates
if (!isNaN(latitude) && !isNaN(longitude) && latitude && longitude) {
results.push({
id: row.OBJECTID || row.CRIMEID || `crash-${results.length}`,
latitude,
longitude,
reportDate: row.REPORTDATE || '',
address: row.ADDRESS || '',
ward: row.WARD || '',
totalVehicles: parseInt(row.TOTAL_VEHICLES) || 0,
totalPedestrians: parseInt(row.TOTAL_PEDESTRIANS) || 0,
totalBicycles: parseInt(row.TOTAL_BICYCLES) || 0,
fatalDriver: parseInt(row.FATAL_DRIVER) || 0,
fatalPedestrian: parseInt(row.FATAL_PEDESTRIAN) || 0,
fatalBicyclist: parseInt(row.FATAL_BICYCLIST) || 0,
majorInjuriesDriver: parseInt(row.MAJORINJURIES_DRIVER) || 0,
majorInjuriesPedestrian: parseInt(row.MAJORINJURIES_PEDESTRIAN) || 0,
majorInjuriesBicyclist: parseInt(row.MAJORINJURIES_BICYCLIST) || 0,
speedingInvolved: parseInt(row.SPEEDING_INVOLVED) || 0,
});
async function loadCrashData(page: number, limit: number, yearFilter?: string): Promise<{ data: CrashData[]; total: number }> {
try {
const mongoClient = await getMongoClient();
const db = mongoClient.db(process.env.DATABASE_NAME || 'crashes');
const collection = db.collection(process.env.COLLECTION_NAME || 'crashes');
// Build date filter
let dateFilter: any = { $gte: new Date('2020-01-01T00:00:00.000Z') };
if (yearFilter) {
const year = parseInt(yearFilter);
if (!isNaN(year)) {
dateFilter = {
$gte: new Date(`${year}-01-01T00:00:00.000Z`),
$lt: new Date(`${year + 1}-01-01T00:00:00.000Z`)
};
}
}
// Base query for valid records
const baseQuery = {
'location.coordinates': { $exists: true, $ne: null, $size: 2 },
'location.coordinates.0': { $ne: null, $type: 'number' },
'location.coordinates.1': { $ne: null, $type: 'number' },
crashId: { $exists: true, $nin: [null, ''] },
reportDate: dateFilter
};
// Get total count for pagination
const total = await collection.countDocuments(baseQuery);
// Calculate skip value
const skip = (page - 1) * limit;
// Query MongoDB with pagination
const crashes = await collection.find(baseQuery,
{
projection: {
_id: 1,
crashId: 1,
'location.coordinates': 1,
reportDate: 1,
address: 1,
ward: 1,
'vehicles.total': 1,
'casualties.pedestrians.total': 1,
'casualties.bicyclists.total': 1,
'casualties.drivers.fatal': 1,
'casualties.pedestrians.fatal': 1,
'casualties.bicyclists.fatal': 1,
'casualties.drivers.major_injuries': 1,
'casualties.pedestrians.major_injuries': 1,
'casualties.bicyclists.major_injuries': 1,
'circumstances.speeding_involved': 1
}
}
)
.skip(skip)
.limit(limit)
.toArray();
// Transform MongoDB documents to CrashData format
const transformedData: CrashData[] = crashes
.map((doc: any) => {
// Skip documents with invalid coordinates
const coords = doc.location?.coordinates;
if (!coords || !Array.isArray(coords) || coords.length !== 2) {
return null;
}
const lng = coords[0];
const lat = coords[1];
if (typeof lng !== 'number' || typeof lat !== 'number' ||
lng === 0 || lat === 0 || isNaN(lng) || isNaN(lat)) {
return null;
}
return {
id: doc.crashId || doc._id.toString(),
latitude: lat,
longitude: lng,
reportDate: doc.reportDate ? new Date(doc.reportDate).toISOString() : '',
address: doc.address || '',
ward: doc.ward || '',
totalVehicles: doc.vehicles?.total || 0,
totalPedestrians: doc.casualties?.pedestrians?.total || 0,
totalBicycles: doc.casualties?.bicyclists?.total || 0,
fatalDriver: doc.casualties?.drivers?.fatal || 0,
fatalPedestrian: doc.casualties?.pedestrians?.fatal || 0,
fatalBicyclist: doc.casualties?.bicyclists?.fatal || 0,
majorInjuriesDriver: doc.casualties?.drivers?.major_injuries || 0,
majorInjuriesPedestrian: doc.casualties?.pedestrians?.major_injuries || 0,
majorInjuriesBicyclist: doc.casualties?.bicyclists?.major_injuries || 0,
speedingInvolved: doc.circumstances?.speeding_involved ? 1 : 0,
};
})
.on('end', () => {
// Update cache
csvCache = results;
csvCacheTimestamp = now;
resolve(results);
})
.on('error', (error: any) => {
reject(error);
});
});
.filter((crash): crash is CrashData => crash !== null); // Filter out null entries
return { data: transformedData, total };
} catch (error) {
console.error('Error loading crash data from MongoDB:', error);
throw error;
}
}
export async function GET(request: NextRequest) {
@@ -103,18 +158,13 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = Math.max(1, parseInt(searchParams.get('page') || '1'));
const limit = Math.min(10000, Math.max(1, parseInt(searchParams.get('limit') || '100')));
const year = searchParams.get('year') || undefined;
// Load CSV data
const allCrashes = await loadCsvData();
// Load crash data from MongoDB
const { data: pageData, total } = await loadCrashData(page, limit, year);
// Calculate pagination
const total = allCrashes.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
// Get the page data
const pageData = allCrashes.slice(startIndex, endIndex);
const response: CrashResponse = {
data: pageData,
@@ -132,7 +182,7 @@ export async function GET(request: NextRequest) {
} catch (error) {
console.error('Error loading crash data:', error);
return NextResponse.json(
{ error: 'Failed to load crash data' },
{ error: 'Failed to load crash data from database' },
{ status: 500 }
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import React from 'react';
import React, { useState } from 'react';
import { UseCrashDataResult } from '../hooks/useCrashData';
interface CrashDataControlsProps {
@@ -9,7 +9,36 @@ interface CrashDataControlsProps {
}
export default function CrashDataControls({ crashDataHook, onDataLoaded }: CrashDataControlsProps) {
const { data, loading, error, pagination, loadMore, refresh } = crashDataHook;
const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
const currentYear = new Date().getFullYear().toString();
const [selectedYear, setSelectedYear] = useState<string>(yearFilter || currentYear);
React.useEffect(() => {
if (onDataLoaded) {
onDataLoaded(data.length);
}
}, [data.length, onDataLoaded]);
// Get available years (current year and previous 5 years)
const getAvailableYears = () => {
const currentYear = new Date().getFullYear();
const years: string[] = [];
// Add current year and previous 5 years
for (let year = currentYear; year >= currentYear - 5; year--) {
years.push(year.toString());
}
return years;
};
const handleYearChange = (year: string) => {
setSelectedYear(year);
const filterYear = year === 'all' ? null : year;
if (setYearFilter) {
setYearFilter(filterYear);
}
};
React.useEffect(() => {
if (onDataLoaded) {
@@ -20,22 +49,51 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
return (
<div style={{
position: 'absolute',
top: '10px',
right: '10px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
bottom: '320px', // Position above the map controls panel with some margin
right: '12px', // Align with map controls panel
backgroundColor: 'rgba(26, 26, 26, 0.95)', // Match the map controls styling more closely
color: 'white',
padding: '12px',
borderRadius: '6px',
borderRadius: '10px', // Match map controls border radius
zIndex: 30,
fontSize: '14px',
minWidth: '200px'
fontSize: '13px', // Match map controls font size
width: '240px', // Match map controls width
backdropFilter: 'blur(8px)', // Match map controls backdrop filter
border: '1px solid rgba(64, 64, 64, 0.5)', // Add subtle border
boxShadow: '0 6px 18px rgba(0,0,0,0.15)' // Match map controls shadow
}}>
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>
<div style={{ marginBottom: '8px', fontWeight: 700, fontSize: '14px' }}>
Crash Data Status
</div>
{/* Year Filter */}
<div style={{ marginBottom: '8px' }}>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#ccc' }}>
Filter by Year:
</label>
<select
value={selectedYear}
onChange={(e) => handleYearChange(e.target.value)}
style={{
backgroundColor: 'rgba(64, 64, 64, 0.8)',
color: 'white',
border: '1px solid rgba(128, 128, 128, 0.5)',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '12px',
width: '100%',
cursor: 'pointer'
}}
>
{getAvailableYears().map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
<div style={{ marginBottom: '6px' }}>
Loaded: {data.length.toLocaleString()} crashes
{yearFilter && ` (${yearFilter})`}
</div>
{pagination && (
@@ -64,13 +122,14 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
onClick={loadMore}
disabled={loading}
style={{
backgroundColor: loading ? '#666' : '#007acc',
backgroundColor: loading ? 'rgba(102, 102, 102, 0.8)' : 'rgba(0, 122, 204, 0.9)',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
padding: '6px 12px',
borderRadius: '6px',
fontSize: '12px',
cursor: loading ? 'not-allowed' : 'pointer'
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s ease'
}}
>
Load More
@@ -81,13 +140,14 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
onClick={refresh}
disabled={loading}
style={{
backgroundColor: loading ? '#666' : '#28a745',
backgroundColor: loading ? 'rgba(102, 102, 102, 0.8)' : 'rgba(40, 167, 69, 0.9)',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
padding: '6px 12px',
borderRadius: '6px',
fontSize: '12px',
cursor: loading ? 'not-allowed' : 'pointer'
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s ease'
}}
>
Refresh

View File

@@ -3,6 +3,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';
interface Props {
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
@@ -20,6 +22,13 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
const [originCoord, setOriginCoord] = useState<[number, number] | null>(null);
const [destCoord, setDestCoord] = useState<[number, number] | null>(null);
const [loading, setLoading] = useState(false);
const [alternateRoute, setAlternateRoute] = useState<any>(null);
const [rerouteInfo, setRerouteInfo] = useState<any>(null);
const crashDataHook = useCrashData({ autoLoad: true, limit: 10000 });
const [isOriginMapPicking, setIsOriginMapPicking] = useState(false);
const [isDestMapPicking, setIsDestMapPicking] = useState(false);
const [routes, setRoutes] = useState<any[]>([]);
const [selectedRouteIndex, setSelectedRouteIndex] = useState(0);
// custom geocoder inputs + suggestions (we implement our own UI instead of the library)
const originQueryRef = useRef<string>("");
const destQueryRef = useRef<string>("");
@@ -37,6 +46,45 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
return () => { mountedRef.current = false; };
}, []);
// Handle map clicks for point selection
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const handleMapClick = (e: mapboxgl.MapMouseEvent) => {
const { lng, lat } = e.lngLat;
if (isOriginMapPicking) {
setOriginCoord([lng, lat]);
setOriginText(`Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
setOriginQuery(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
setIsOriginMapPicking(false);
// Center map on selected point
map.easeTo({ center: [lng, lat], zoom: 14 });
} else if (isDestMapPicking) {
setDestCoord([lng, lat]);
setDestText(`Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
setDestQuery(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
setIsDestMapPicking(false);
// Center map on selected point
map.easeTo({ center: [lng, lat], zoom: 14 });
}
};
if (isOriginMapPicking || isDestMapPicking) {
map.on('click', handleMapClick);
// Change cursor to crosshair when in picking mode
map.getCanvas().style.cursor = 'crosshair';
} else {
map.getCanvas().style.cursor = '';
}
return () => {
map.off('click', handleMapClick);
map.getCanvas().style.cursor = '';
};
}, [isOriginMapPicking, isDestMapPicking, mapRef]);
// We'll implement our own geocoder fetcher and suggestion UI.
const fetchSuggestions = async (q: string) => {
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || mapboxgl.accessToken || (typeof window !== 'undefined' ? (window as any).NEXT_PUBLIC_MAPBOX_TOKEN : undefined);
@@ -106,6 +154,9 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
try {
if (map.getLayer("directions-line")) map.removeLayer("directions-line");
} catch (e) {}
try {
if (map.getLayer("directions-line-outline")) map.removeLayer("directions-line-outline");
} catch (e) {}
try {
if (map.getLayer("directions-points")) map.removeLayer("directions-points");
} catch (e) {}
@@ -115,6 +166,25 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
try {
if (map.getSource("directions-points-src")) map.removeSource("directions-points-src");
} catch (e) {}
// Remove alternate route layers/sources
try {
if (map.getLayer("alternate-route-line")) map.removeLayer("alternate-route-line");
} catch (e) {}
try {
if (map.getSource("alternate-route")) map.removeSource("alternate-route");
} catch (e) {}
// Remove multiple route layers/sources and their outlines
for (let i = 1; i < 3; i++) { // Back to 2 routes (indices 1-2)
try {
if (map.getLayer(`route-line-${i}`)) map.removeLayer(`route-line-${i}`);
} catch (e) {}
try {
if (map.getLayer(`route-line-${i}-outline`)) map.removeLayer(`route-line-${i}-outline`);
} catch (e) {}
try {
if (map.getSource(`route-${i}`)) map.removeSource(`route-${i}`);
} catch (e) {}
}
}
async function fetchRoute(o: [number, number], d: [number, number]) {
@@ -124,13 +194,140 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
return null;
}
const coords = `${o[0]},${o[1]};${d[0]},${d[1]}`;
const url = `https://api.mapbox.com/directions/v5/${profile}/${coords}?geometries=geojson&overview=full&steps=false&access_token=${accessToken}`;
const url = `https://api.mapbox.com/directions/v5/${profile}/${coords}?geometries=geojson&overview=full&steps=false&alternatives=true&access_token=${accessToken}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Directions API error: ${res.status}`);
const data = await res.json();
return data;
}
// Function to render multiple routes with different styles
function renderMultipleRoutes(map: mapboxgl.Map, routes: any[], selectedIndex: number) {
const routeColors = ['#2563eb', '#dc2626']; // blue, red
const routeWidths = [6, 4]; // selected route is thicker
const routeOpacities = [0.95, 0.7]; // selected route is more opaque
routes.forEach((route, index) => {
const sourceId = index === 0 ? 'directions-route' : `route-${index}`;
const layerId = index === 0 ? 'directions-line' : `route-line-${index}`;
const isSelected = index === selectedIndex;
const geo: GeoJSON.Feature<GeoJSON.Geometry> = {
type: "Feature",
properties: { routeIndex: index },
geometry: route.geometry
};
// Add or update source
if (!map.getSource(sourceId)) {
map.addSource(sourceId, { type: "geojson", data: geo, lineMetrics: true });
} else {
(map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData(geo);
}
// Add layer if it doesn't exist
if (!map.getLayer(layerId)) {
map.addLayer({
id: layerId,
type: "line",
source: sourceId,
layout: { "line-join": "round", "line-cap": "round" },
paint: {
"line-color": routeColors[index] || routeColors[0],
"line-width": 4,
"line-opacity": 0.7
},
});
// Add click handler for route selection
map.on('click', layerId, () => {
setSelectedRouteIndex(index);
renderMultipleRoutes(map, routes, index);
});
// Change cursor on hover
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = '';
});
}
// Apply crash density gradient to all routes if crash data is available
if (crashDataHook.data.length > 0) {
const routeCoordinates = (route.geometry as any).coordinates as [number, number][];
const crashDensities = calculateRouteCrashDensity(routeCoordinates, crashDataHook.data, 150);
const gradientStops = createRouteGradientStops(crashDensities);
map.setPaintProperty(layerId, 'line-gradient', gradientStops as [string, ...any[]]);
map.setPaintProperty(layerId, 'line-color', undefined); // Remove solid color when using gradient
map.setPaintProperty(layerId, 'line-width', isSelected ? routeWidths[0] : routeWidths[1]);
map.setPaintProperty(layerId, 'line-opacity', isSelected ? routeOpacities[0] : routeOpacities[1]);
} else {
// Apply solid color styling when no crash data
map.setPaintProperty(layerId, 'line-gradient', undefined); // Remove gradient
map.setPaintProperty(layerId, 'line-color', routeColors[index] || routeColors[0]);
map.setPaintProperty(layerId, 'line-width', isSelected ? routeWidths[0] : routeWidths[1]);
map.setPaintProperty(layerId, 'line-opacity', isSelected ? routeOpacities[0] : routeOpacities[1]);
}
// Add blue outline for selected route
const outlineLayerId = `${layerId}-outline`;
if (isSelected) {
// Add outline layer if it doesn't exist
if (!map.getLayer(outlineLayerId)) {
map.addLayer({
id: outlineLayerId,
type: "line",
source: sourceId,
layout: { "line-join": "round", "line-cap": "round" },
paint: {
"line-color": "#2563eb", // Blue outline
"line-width": routeWidths[0] + 4, // Thicker than the main line
"line-opacity": 0.8
},
}, layerId); // Add below the main route line
} else {
// Update existing outline
map.setPaintProperty(outlineLayerId, 'line-width', routeWidths[0] + 4);
map.setPaintProperty(outlineLayerId, 'line-opacity', 0.8);
}
} else {
// Remove outline for non-selected routes
if (map.getLayer(outlineLayerId)) {
map.removeLayer(outlineLayerId);
}
}
});
}
// Function to call the predict endpoint
async function callPredictEndpoint(source: [number, number], destination: [number, number]) {
try {
const response = await fetch('http://127.0.0.1:5000/predict', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: { lat: source[1], lon: source[0] },
destination: { lat: destination[1], lon: destination[0] }
})
});
if (!response.ok) {
throw new Error(`Predict endpoint error: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.warn('Predict endpoint call failed:', error);
return null;
}
}
async function handleGetRoute() {
const map = mapRef.current;
if (!map) return;
@@ -143,32 +340,64 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
setLoading(true);
try {
// Call predict endpoint first to check if rerouting is needed
const predictResult = await callPredictEndpoint(o, d);
console.log('Predict endpoint result:', predictResult);
const data = await fetchRoute(o, d);
if (!data || !data.routes || data.routes.length === 0) {
alert("No route found");
return;
}
const route = data.routes[0];
const geo: GeoJSON.Feature<GeoJSON.Geometry> = { type: "Feature", properties: {}, geometry: route.geometry };
// Store all available routes
const allRoutes = data.routes;
setRoutes(allRoutes);
setSelectedRouteIndex(0);
removeRouteFromMap(map);
// add route source and line layer
if (!map.getSource("directions-route")) {
map.addSource("directions-route", { type: "geojson", data: geo });
// Check if rerouting is needed based on predict endpoint (using the first route)
let shouldShowAlternate = false;
if (predictResult && predictResult.reroute_needed && predictResult.path) {
shouldShowAlternate = true;
setRerouteInfo(predictResult);
// Create alternate route using the predicted path
const alternatePath = predictResult.path.map((coord: [number, number]) => [coord[1], coord[0]]);
const alternateGeo: GeoJSON.Feature<GeoJSON.Geometry> = {
type: "Feature",
properties: { type: "alternate" },
geometry: { type: "LineString", coordinates: alternatePath }
};
// Add alternate route source and layer
if (!map.getSource("alternate-route")) {
map.addSource("alternate-route", { type: "geojson", data: alternateGeo });
} else {
(map.getSource("alternate-route") as mapboxgl.GeoJSONSource).setData(alternateGeo);
}
if (!map.getLayer("alternate-route-line")) {
map.addLayer({
id: "alternate-route-line",
type: "line",
source: "alternate-route",
layout: { "line-join": "round", "line-cap": "round" },
paint: { "line-color": "#22c55e", "line-width": 5, "line-opacity": 0.8, "line-dasharray": [2, 2] },
});
}
} else {
(map.getSource("directions-route") as mapboxgl.GeoJSONSource).setData(geo);
}
if (!map.getLayer("directions-line")) {
map.addLayer({
id: "directions-line",
type: "line",
source: "directions-route",
layout: { "line-join": "round", "line-cap": "round" },
paint: { "line-color": "#ff7e5f", "line-width": 6, "line-opacity": 0.95 },
});
setRerouteInfo(null);
// Remove alternate route if it exists
try {
if (map.getLayer("alternate-route-line")) map.removeLayer("alternate-route-line");
if (map.getSource("alternate-route")) map.removeSource("alternate-route");
} catch (e) {}
}
// Render all routes with different styles
renderMultipleRoutes(map, allRoutes, 0);
// add origin/dest points
const pts: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection",
@@ -196,9 +425,10 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
});
}
// zoom to route bounds
// zoom to route bounds (using the selected route)
try {
const coords = (route.geometry as any).coordinates as [number, number][];
const selectedRoute = allRoutes[selectedRouteIndex];
const coords = (selectedRoute.geometry as any).coordinates as [number, number][];
const b = new mapboxgl.LngLatBounds(coords[0], coords[0]);
for (let i = 1; i < coords.length; i++) b.extend(coords[i] as any);
map.fitBounds(b as any, { padding: 60 });
@@ -219,9 +449,21 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
setDestCoord(null);
setOriginText("");
setDestText("");
// clear suggestions and inputs
setOriginSuggestions([]);
setDestSuggestions([]);
// Clear GeocodeInput queries
setOriginQuery("");
setDestQuery("");
// clear suggestions and inputs
setOriginSuggestions([]);
setDestSuggestions([]);
// Clear alternate route state
setAlternateRoute(null);
setRerouteInfo(null);
// Clear map picking states
setIsOriginMapPicking(false);
setIsDestMapPicking(false);
// Clear multiple routes state
setRoutes([]);
setSelectedRouteIndex(0);
}
// re-add layers after style change
@@ -230,19 +472,11 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
if (!map) return;
function onStyleData() {
if (!map) return;
// if a route source exists, we need to re-add the layers
if (map.getSource("directions-route")) {
// re-add line layer if missing
if (!map.getLayer("directions-line")) {
map.addLayer({
id: "directions-line",
type: "line",
source: "directions-route",
layout: { "line-join": "round", "line-cap": "round" },
paint: { "line-color": "#ff7e5f", "line-width": 6, "line-opacity": 0.95 },
});
}
// Re-render all routes if they exist
if (routes.length > 0) {
renderMultipleRoutes(map, routes, selectedRouteIndex);
}
// Re-add points layer
if (map.getSource("directions-points-src") && !map.getLayer("directions-points")) {
map.addLayer({
id: "directions-points",
@@ -256,10 +490,20 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
},
});
}
// Re-add alternate route layer if it exists
if (map.getSource("alternate-route") && !map.getLayer("alternate-route-line")) {
map.addLayer({
id: "alternate-route-line",
type: "line",
source: "alternate-route",
layout: { "line-join": "round", "line-cap": "round" },
paint: { "line-color": "#22c55e", "line-width": 5, "line-opacity": 0.8, "line-dasharray": [2, 2] },
});
}
}
map.on("styledata", onStyleData);
return () => { map.off("styledata", onStyleData); };
}, [mapRef]);
}, [mapRef, routes, selectedRouteIndex]);
// resize map when sidebar collapses/expands so map fills freed space
useEffect(() => {
@@ -294,7 +538,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
ref={containerRef}
role="region"
aria-label="Directions sidebar"
className={`relative flex flex-col z-40 ${collapsed ? 'w-11 h-11 self-start m-3 rounded-full overflow-hidden bg-transparent' : 'w-[340px] h-full bg-[#111214] rounded-tr-lg rounded-br-lg'}`}
className={`relative flex flex-col z-40 ${collapsed ? 'w-11 h-11 self-start m-3 rounded-full overflow-hidden bg-transparent' : 'w-[340px] h-full bg-[#1a1a1a] rounded-tr-lg rounded-br-lg border-r border-[#2a2a2a]'}`}
>
{/* Toggle */}
<button
@@ -302,8 +546,8 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
onClick={() => setCollapsed((s) => !s)}
title={collapsed ? 'Expand directions' : 'Minimize directions'}
className={collapsed
? 'w-full h-full rounded-full bg-white text-black/85 flex items-center justify-center shadow-md border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/60'
: 'absolute top-3 right-3 -m-1 p-1 w-9 h-9 rounded-md bg-white/5 text-white border border-black/10 flex items-center justify-center hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/60 z-50 pointer-events-auto'
? 'w-full h-full rounded-full bg-[#f5f5f5] text-[#1f2937] flex items-center justify-center shadow-md border border-[#e0e0e0] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#9ca3af]'
: 'absolute top-3 right-3 -m-1 p-1 w-9 h-9 rounded-md bg-[#2a2a2a] text-[#d1d5db] border border-[#404040] flex items-center justify-center hover:bg-[#363636] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#9ca3af] z-50 pointer-events-auto'
}
>
{/* increase hit area with an inner svg and ensure cursor is pointer */}
@@ -317,47 +561,236 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
{/* Content — render only when expanded to avoid any collapsed 'strip' */}
<div className={`flex flex-col flex-1 p-4 overflow-auto ${collapsed ? 'hidden' : ''}`}>
<div className="flex items-center justify-between mb-3 sticky top-2 z-10">
<strong className="text-sm">Directions</strong>
<strong className="text-sm text-[#f5f5f5]">Directions</strong>
</div>
<div className="flex flex-col gap-3 directions-sidebar-geocoder">
<div className="flex items-start gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0 pt-2">Origin</label>
<label className="text-sm w-20 flex-shrink-0 pt-2 text-[#d1d5db]">Origin</label>
<div className="flex-1 min-w-0">
<div className="p-1">
<GeocodeInput
mapRef={mapRef}
placeholder="Search origin"
placeholder="Search origin or use Submit to pick on map"
value={originQuery}
onChange={(v) => { setOriginQuery(v); setOriginText(''); }}
onSelect={(f) => { const c = f.center; if (c && c.length === 2) { setOriginCoord([c[0], c[1]]); setOriginText(f.place_name || ''); setOriginQuery(f.place_name || ''); try { const m = mapRef.current; if (m) m.easeTo({ center: c, zoom: 14 }); } catch(e){} } }}
onSelect={(f) => {
const c = f.center;
if (c && c.length === 2) {
setOriginCoord([c[0], c[1]]);
setOriginText(f.place_name || '');
setOriginQuery(f.place_name || '');
try {
const m = mapRef.current;
if (m) m.easeTo({ center: c, zoom: 14 });
} catch(e){}
}
}}
onMapPick={() => {
setIsOriginMapPicking(!isOriginMapPicking);
setIsDestMapPicking(false); // Cancel dest picking if active
}}
isMapPickingMode={isOriginMapPicking}
/>
</div>
<div className="mt-2 text-xs text-gray-400 truncate">{originText}</div>
<div className="mt-2 text-xs text-[#a1a1aa] truncate">{originText}</div>
</div>
</div>
<div className="flex items-start gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0 pt-2">Destination</label>
<label className="text-sm w-20 flex-shrink-0 pt-2 text-[#d1d5db]">Destination</label>
<div className="flex-1 min-w-0">
<div className="p-1">
<GeocodeInput
mapRef={mapRef}
placeholder="Search destination"
placeholder="Search destination or use Submit to pick on map"
value={destQuery}
onChange={(v) => { setDestQuery(v); setDestText(''); }}
onSelect={(f) => { const c = f.center; if (c && c.length === 2) { setDestCoord([c[0], c[1]]); setDestText(f.place_name || ''); setDestQuery(f.place_name || ''); try { const m = mapRef.current; if (m) m.easeTo({ center: c, zoom: 14 }); } catch(e){} } }}
onSelect={(f) => {
const c = f.center;
if (c && c.length === 2) {
setDestCoord([c[0], c[1]]);
setDestText(f.place_name || '');
setDestQuery(f.place_name || '');
try {
const m = mapRef.current;
if (m) m.easeTo({ center: c, zoom: 14 });
} catch(e){}
}
}}
onMapPick={() => {
setIsDestMapPicking(!isDestMapPicking);
setIsOriginMapPicking(false); // Cancel origin picking if active
}}
isMapPickingMode={isDestMapPicking}
/>
</div>
<div className="mt-2 text-xs text-gray-400 truncate">{destText}</div>
<div className="mt-2 text-xs text-[#a1a1aa] truncate">{destText}</div>
</div>
</div>
<div className="flex gap-2 mt-2">
<button onClick={handleGetRoute} disabled={loading} className="flex-1 px-4 py-2 rounded-lg bg-gradient-to-r from-[#ff7e5f] to-[#ffb199] text-white shadow-md">{loading ? 'Routing…' : 'Get Route'}</button>
<button onClick={handleClear} className="px-4 py-2 rounded-lg border border-black/10 bg-transparent text-sm">Clear</button>
<button onClick={handleGetRoute} disabled={loading} className="flex-1 px-4 py-2 rounded-lg bg-[#2563eb] hover:bg-[#1d4ed8] text-white shadow-md disabled:opacity-60 transition-colors">{loading ? 'Routing…' : 'Get Route'}</button>
<button onClick={handleClear} className="px-4 py-2 rounded-lg border border-[#404040] bg-[#2a2a2a] text-sm text-[#d1d5db] hover:bg-[#363636]">Clear</button>
</div>
{/* Route Options */}
{routes.length > 1 && (
<div className="mt-4 p-3 rounded-lg bg-[#2a2a2a] border border-[#404040]">
<div className="flex items-center gap-2 mb-3">
<svg className="w-4 h-4 text-[#d1d5db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m-6 3l6-3" />
</svg>
<span className="text-sm font-medium text-[#f5f5f5]">Route Options ({routes.length})</span>
</div>
<div className="space-y-2">
{routes.map((route, index) => {
const isSelected = index === selectedRouteIndex;
const colors = ['#2563eb', '#dc2626']; // blue, red
const labels = ['Route 1 (Fastest)', 'Route 2 (Alternative)'];
const duration = Math.round(route.duration / 60);
const distance = Math.round(route.distance / 1000 * 10) / 10;
return (
<button
key={index}
onClick={() => {
setSelectedRouteIndex(index);
if (mapRef.current) {
renderMultipleRoutes(mapRef.current, routes, index);
}
}}
className={`w-full text-left p-2 rounded-md border transition-colors ${
isSelected
? 'border-[#2563eb] bg-[#1e40af]/20'
: 'border-[#404040] bg-[#1a1a1a] hover:bg-[#363636]'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: colors[index] || colors[0] }}
></div>
<span className="text-sm font-medium text-[#f5f5f5]">
{labels[index] || `Route ${index + 1}`}
</span>
</div>
{isSelected && (
<svg className="w-4 h-4 text-[#2563eb]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="mt-1 text-xs text-[#9ca3af]">
{duration} min {distance} km
</div>
</button>
);
})}
</div>
<p className="text-xs text-[#a1a1aa] mt-2">
Click on a route to select it or click directly on the map
</p>
</div>
)}
{/* Show reroute information if available */}
{rerouteInfo && (
<div className="mt-4 p-3 rounded-lg bg-[#065f46] border border-[#10b981]">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-[#10b981]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-[#ecfdf5]">
{rerouteInfo.reroute_needed ? 'Safer Route Available' : 'Current Route is Optimal'}
</span>
</div>
{rerouteInfo.reroute_needed && rerouteInfo.risk_improvement && (
<p className="text-xs text-[#a7f3d0]">
Risk reduction: {rerouteInfo.risk_improvement.toFixed(2)} points
</p>
)}
{rerouteInfo.reason && (
<p className="text-xs text-[#a7f3d0]">
{rerouteInfo.reason === 'no_lower_risk_found' ? 'No safer alternatives found' : rerouteInfo.reason}
</p>
)}
{rerouteInfo.reroute_needed && (
<div className="mt-2 text-xs text-[#a7f3d0]">
<span className="inline-block w-3 h-0.5 bg-[#22c55e] mr-2"></span>
Green dashed line shows safer route
</div>
)}
</div>
)}
{/* Route Safety Legend */}
{(originCoord && destCoord) && (
<div className="mt-4 p-3 rounded-lg bg-[#2a2a2a] border border-[#404040]">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-[#d1d5db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-[#f5f5f5]">Route Safety Legend</span>
</div>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<div className="w-4 h-1 bg-[#22c55e] rounded"></div>
<span className="text-[#d1d5db]">Low crash risk</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-1 bg-[#eab308] rounded"></div>
<span className="text-[#d1d5db]">Moderate risk</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-1 bg-[#f97316] rounded"></div>
<span className="text-[#d1d5db]">High risk</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-1 bg-[#dc2626] rounded"></div>
<span className="text-[#d1d5db]">Very high risk</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-1 bg-[#7f1d1d] rounded"></div>
<span className="text-[#d1d5db]">Extreme risk</span>
</div>
</div>
<p className="text-xs text-[#a1a1aa] mt-2">
Colors based on historical crash data within 150m of route
</p>
</div>
)}
{/* Map picking mode indicator */}
{(isOriginMapPicking || isDestMapPicking) && (
<div className="mt-4 p-3 rounded-lg bg-[#1e40af] border border-[#3b82f6]">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-[#93c5fd]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div>
<p className="text-sm font-medium text-[#dbeafe]">
Map Picking Mode Active
</p>
<p className="text-xs text-[#93c5fd]">
{isOriginMapPicking ? "Click anywhere on the map to set your origin location" : "Click anywhere on the map to set your destination location"}
</p>
<button
onClick={() => {
setIsOriginMapPicking(false);
setIsDestMapPicking(false);
}}
className="mt-2 text-xs text-[#93c5fd] hover:text-[#dbeafe] underline"
>
Cancel map picking
</button>
</div>
</div>
</div>
)}
{/* pick-on-map mode removed; sidebar uses geocoder-only inputs */}
</div>
</div>

View File

@@ -9,18 +9,44 @@ interface Props {
value?: string;
onChange?: (v: string) => void;
onSelect: (feature: any) => void;
onMapPick?: () => void; // New prop for map picking mode
isMapPickingMode?: boolean; // Whether currently in map picking mode
}
export default function GeocodeInput({ mapRef, placeholder = 'Search', value = '', onChange, onSelect }: Props) {
export default function GeocodeInput({
mapRef,
placeholder = 'Search location or enter coordinates...',
value = '',
onChange,
onSelect,
onMapPick,
isMapPickingMode = false
}: Props) {
const [query, setQuery] = useState<string>(value);
const [suggestions, setSuggestions] = useState<any[]>([]);
const [showDropdown, setShowDropdown] = useState<boolean>(false);
const timer = useRef<number | null>(null);
const mounted = useRef(true);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
return () => { mounted.current = false; };
}, []);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
useEffect(() => {
if (value !== query) setQuery(value);
}, [value]);
@@ -29,12 +55,81 @@ export default function GeocodeInput({ mapRef, placeholder = 'Search', value = '
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || mapboxgl.accessToken || undefined;
if (!token) return [];
if (!q || q.trim().length === 0) return [];
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json?autocomplete=true&limit=6&types=place,locality,address,region,poi&access_token=${token}`;
// Check if the query looks like coordinates (lat,lng or lng,lat)
const coordinatePattern = /^(-?\d+\.?\d*),?\s*(-?\d+\.?\d*)$/;
const coordMatch = q.trim().match(coordinatePattern);
if (coordMatch) {
const [, first, second] = coordMatch;
const num1 = parseFloat(first);
const num2 = parseFloat(second);
// Determine which is lat and which is lng based on typical ranges
// Latitude: -90 to 90, Longitude: -180 to 180
// For DC area: lat around 38-39, lng around -77
let lat, lng;
if (Math.abs(num1) <= 90 && Math.abs(num2) <= 180) {
// Check if first number looks like latitude for DC area
if (num1 >= 38 && num1 <= 39 && num2 >= -78 && num2 <= -76) {
lat = num1;
lng = num2;
} else if (num2 >= 38 && num2 <= 39 && num1 >= -78 && num1 <= -76) {
lat = num2;
lng = num1;
} else {
// Default assumption: first is lat, second is lng
lat = num1;
lng = num2;
}
// Validate coordinates are in reasonable ranges
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
// Create a synthetic feature for coordinates
return [{
center: [lng, lat],
place_name: `${lat}, ${lng}`,
text: `${lat}, ${lng}`,
properties: {
isCoordinate: true
},
geometry: {
type: 'Point',
coordinates: [lng, lat]
}
}];
}
}
}
// Washington DC area bounding box: SW corner (-77.25, 38.80), NE corner (-76.90, 39.05)
const dcBounds = '-77.25,38.80,-76.90,39.05';
// Add proximity to center of DC for better ranking
const dcCenter = '-77.0369,38.9072'; // Washington DC coordinates
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json?` +
`autocomplete=true&limit=6&types=place,locality,address,region,poi&` +
`bbox=${dcBounds}&proximity=${dcCenter}&` +
`country=US&access_token=${token}`;
try {
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
return data.features || [];
// Additional client-side filtering to ensure results are in DC area
const dcAreaFeatures = (data.features || []).filter((feature: any) => {
const coords = feature.center;
if (!coords || coords.length !== 2) return false;
const [lng, lat] = coords;
// Check if coordinates are within DC metropolitan area bounds
return lng >= -77.25 && lng <= -76.90 && lat >= 38.80 && lat <= 39.05;
});
return dcAreaFeatures;
} catch (e) {
return [];
}
@@ -42,33 +137,105 @@ export default function GeocodeInput({ mapRef, placeholder = 'Search', value = '
useEffect(() => {
if (timer.current) window.clearTimeout(timer.current);
if (!query) { setSuggestions([]); return; }
if (!query) {
setSuggestions([]);
setShowDropdown(false);
return;
}
timer.current = window.setTimeout(async () => {
const feats = await fetchSuggestions(query);
if (mounted.current) setSuggestions(feats);
if (mounted.current) {
setSuggestions(feats);
setShowDropdown(feats.length > 0);
}
}, 250) as unknown as number;
return () => { if (timer.current) window.clearTimeout(timer.current); };
}, [query]);
return (
<div className="relative">
<input
type="text"
className="w-full bg-transparent text-white placeholder-gray-400 rounded-md"
placeholder={placeholder}
value={query}
onChange={(e) => { setQuery(e.target.value); onChange && onChange(e.target.value); }}
/>
{suggestions.length > 0 && (
<div className="absolute left-0 right-0 mt-1 bg-[#0b0b0c] border border-black/20 rounded-md overflow-hidden custom-suggestions">
<div className="relative" ref={containerRef}>
{/* Search bar container matching the design */}
<div className="flex items-center bg-[#2a2a2a] border border-[#404040] rounded-lg overflow-hidden">
{/* Input field */}
<input
type="text"
className="flex-1 bg-transparent text-[#f5f5f5] placeholder-[#9ca3af] py-3 px-4 focus:outline-none"
placeholder={isMapPickingMode ? "Click on map to select location..." : placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
onChange && onChange(e.target.value);
}}
onFocus={() => {
if (!isMapPickingMode && suggestions.length > 0) {
setShowDropdown(true);
}
}}
disabled={isMapPickingMode}
/>
{/* Pin button */}
<button
onClick={() => {
if (onMapPick) {
onMapPick();
}
}}
className="px-4 py-3 bg-[#f5f5f5] text-[#1f2937] hover:bg-[#e5e7eb] focus:outline-none focus:ring-2 focus:ring-[#9ca3af] focus:ring-offset-2 focus:ring-offset-[#2a2a2a] transition-colors"
title={isMapPickingMode ? "Cancel map picking" : "Pick point on map"}
>
{isMapPickingMode ? (
// X icon for cancel
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
// Pin icon
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
</button>
</div>
{/* Suggestions dropdown */}
{!isMapPickingMode && showDropdown && suggestions.length > 0 && (
<div className="absolute left-0 right-0 mt-1 bg-[#2a2a2a] border border-[#404040] rounded-lg shadow-lg overflow-hidden z-50 max-h-64 overflow-y-auto">
{suggestions.map((f: any, i: number) => (
<button key={f.id || i} className="w-full text-left px-3 py-2 hover:bg-white/5" onClick={() => { onSelect(f); setSuggestions([]); }}>
<div className="font-medium">{f.text}</div>
{f.place_name && <div className="text-xs text-gray-400">{f.place_name.replace(f.text, '').replace(/^,\s*/, '')}</div>}
<button
key={f.id || i}
className="w-full text-left px-4 py-3 hover:bg-[#3a3a3a] border-b border-[#404040] last:border-b-0"
onClick={() => {
onSelect(f);
setSuggestions([]);
setShowDropdown(false);
setQuery(f.place_name || f.text);
}}
>
<div className="font-medium text-[#f5f5f5]">{f.text}</div>
{f.place_name && (
<div className="text-xs text-[#9ca3af] mt-1">
{f.place_name.replace(f.text, '').replace(/^,\s*/, '')}
{!f.place_name.toLowerCase().includes('washington') && !f.place_name.toLowerCase().includes('dc') &&
<span className="ml-1 text-[#60a5fa]"> Washington DC Area</span>
}
</div>
)}
</button>
))}
</div>
)}
{/* Map picking mode indicator */}
{isMapPickingMode && (
<div className="absolute left-0 right-0 mt-1 bg-[#065f46] border border-[#10b981] rounded-lg p-3 z-50">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-[#10b981] rounded-full animate-pulse"></div>
<span className="text-sm text-[#ecfdf5]">Click anywhere on the map to select a location</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -14,7 +14,20 @@ export type PopupData = {
mag?: number;
text?: string;
crashData?: CrashData;
stats?: { count: number; avg?: number; min?: number; max?: number; radiusMeters?: number }
stats?: {
count: number;
avg?: number;
min?: number;
max?: number;
radiusMeters?: number;
severityCounts?: {
fatal: number;
majorInjury: number;
minorInjury: number;
propertyOnly: number;
};
crashes?: any[]; // Top 5 nearby crashes
}
} | null;
interface MapViewProps {
@@ -216,18 +229,74 @@ export default function MapView({
dcDataRef.current = { type: 'FeatureCollection' as const, features: [] };
}
const computeNearbyStats = (center: [number, number], radiusMeters = 500) => {
const data = dcDataRef.current;
if (!data) return { count: 0 };
const mags: number[] = [];
for (const f of data.features as PointFeature[]) {
const coord = f.geometry.coordinates as [number, number];
const d = haversine(center, coord);
if (d <= radiusMeters) mags.push(f.properties.mag);
const computeNearbyStats = async (center: [number, number], radiusMeters = 300) => {
try {
const [lng, lat] = center;
const response = await fetch(`/api/crashes/nearby?lng=${lng}&lat=${lat}&radius=${radiusMeters}&limit=1000`);
if (!response.ok) {
console.warn('Failed to fetch nearby crash data:', response.status);
return { count: 0 };
}
const data = await response.json();
const crashes = data.data || [];
// Filter out any null or invalid crash data on client side
const validCrashes = crashes.filter((crash: any) =>
crash &&
crash.id &&
typeof crash.latitude === 'number' &&
typeof crash.longitude === 'number' &&
!isNaN(crash.latitude) &&
!isNaN(crash.longitude) &&
crash.latitude !== 0 &&
crash.longitude !== 0
);
if (validCrashes.length === 0) {
return { count: 0, radiusMeters };
}
// Calculate severity statistics from MongoDB data
const severityValues = validCrashes.map((crash: any) => {
// Convert severity to numeric value for stats
switch (crash.severity) {
case 'Fatal': return 6;
case 'Major Injury': return 4;
case 'Minor Injury': return 2;
case 'Property Damage Only': return 1;
default: return 1;
}
});
// Calculate statistics
const sum = severityValues.reduce((s: number, x: number) => s + x, 0);
const avg = +(sum / severityValues.length).toFixed(2);
const min = Math.min(...severityValues);
const max = Math.max(...severityValues);
// Count by severity type
const severityCounts = {
fatal: validCrashes.filter((c: any) => c.severity === 'Fatal').length,
majorInjury: validCrashes.filter((c: any) => c.severity === 'Major Injury').length,
minorInjury: validCrashes.filter((c: any) => c.severity === 'Minor Injury').length,
propertyOnly: validCrashes.filter((c: any) => c.severity === 'Property Damage Only').length
};
return {
count: validCrashes.length,
avg,
min,
max,
radiusMeters,
severityCounts,
crashes: validCrashes.slice(0, 5) // Include first 5 crashes for detailed info
};
} catch (error) {
console.error('Error computing nearby stats:', error);
return { count: 0 };
}
if (mags.length === 0) return { count: 0 };
const sum = mags.reduce((s, x) => s + x, 0);
return { count: mags.length, avg: +(sum / mags.length).toFixed(2), min: Math.min(...mags), max: Math.max(...mags), radiusMeters };
};
const addDataAndLayers = () => {
@@ -305,51 +374,74 @@ export default function MapView({
// ensure map is fit to DC bounds initially
try { map.fitBounds(dcBounds, { padding: 20 }); } catch (e) { /* ignore if fitBounds fails */ }
map.on('click', 'dc-point', (e) => {
map.on('click', 'dc-point', async (e) => {
const feature = e.features && e.features[0];
if (!feature) return;
const coords = (feature.geometry as any).coordinates.slice() as [number, number];
// Validate coordinates
if (!coords || coords.length !== 2 ||
typeof coords[0] !== 'number' || typeof coords[1] !== 'number' ||
isNaN(coords[0]) || isNaN(coords[1]) ||
coords[0] === 0 || coords[1] === 0) {
console.warn('Invalid coordinates for crash point:', coords);
return;
}
const mag = feature.properties ? feature.properties.mag : undefined;
const crashData = feature.properties ? feature.properties.crashData : undefined;
const stats = computeNearbyStats(coords, 500);
const stats = await computeNearbyStats(coords, 300);
let text = `Severity: ${mag ?? 'N/A'}`;
if (crashData) {
if (crashData && crashData.address) {
text = `Crash Report
Date: ${new Date(crashData.reportDate).toLocaleDateString()}
Date: ${crashData.reportDate ? new Date(crashData.reportDate).toLocaleDateString() : 'Unknown'}
Address: ${crashData.address}
Vehicles: ${crashData.totalVehicles} | Pedestrians: ${crashData.totalPedestrians} | Bicycles: ${crashData.totalBicycles}
Fatalities: ${crashData.fatalDriver + crashData.fatalPedestrian + crashData.fatalBicyclist}
Major Injuries: ${crashData.majorInjuriesDriver + crashData.majorInjuriesPedestrian + crashData.majorInjuriesBicyclist}`;
Vehicles: ${crashData.totalVehicles || 0} | Pedestrians: ${crashData.totalPedestrians || 0} | Bicycles: ${crashData.totalBicycles || 0}
Fatalities: ${(crashData.fatalDriver || 0) + (crashData.fatalPedestrian || 0) + (crashData.fatalBicyclist || 0)}
Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjuriesPedestrian || 0) + (crashData.majorInjuriesBicyclist || 0)}`;
}
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
});
map.on('click', 'dc-heat', (e) => {
map.on('click', 'dc-heat', async (e) => {
const p = e.point;
const bbox = [[p.x - 6, p.y - 6], [p.x + 6, p.y + 6]] as [mapboxgl.PointLike, mapboxgl.PointLike];
const nearby = map.queryRenderedFeatures(bbox, { layers: ['dc-point'] });
if (nearby && nearby.length > 0) {
const f = nearby[0];
const coords = (f.geometry as any).coordinates.slice() as [number, number];
// Validate coordinates
if (!coords || coords.length !== 2 ||
typeof coords[0] !== 'number' || typeof coords[1] !== 'number' ||
isNaN(coords[0]) || isNaN(coords[1]) ||
coords[0] === 0 || coords[1] === 0) {
console.warn('Invalid coordinates for heat map click:', coords);
const stats = await computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 300);
if (onPopupCreate) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual crash reports and details', stats });
return;
}
const mag = f.properties ? f.properties.mag : undefined;
const crashData = f.properties ? f.properties.crashData : undefined;
const stats = computeNearbyStats(coords, 500);
const stats = await computeNearbyStats(coords, 300);
let text = `Severity: ${mag ?? 'N/A'}`;
if (crashData) {
if (crashData && crashData.address) {
text = `Crash Report
Date: ${new Date(crashData.reportDate).toLocaleDateString()}
Date: ${crashData.reportDate ? new Date(crashData.reportDate).toLocaleDateString() : 'Unknown'}
Address: ${crashData.address}
Vehicles: ${crashData.totalVehicles} | Pedestrians: ${crashData.totalPedestrians} | Bicycles: ${crashData.totalBicycles}
Fatalities: ${crashData.fatalDriver + crashData.fatalPedestrian + crashData.fatalBicyclist}
Major Injuries: ${crashData.majorInjuriesDriver + crashData.majorInjuriesPedestrian + crashData.majorInjuriesBicyclist}`;
Vehicles: ${crashData.totalVehicles || 0} | Pedestrians: ${crashData.totalPedestrians || 0} | Bicycles: ${crashData.totalBicycles || 0}
Fatalities: ${(crashData.fatalDriver || 0) + (crashData.fatalPedestrian || 0) + (crashData.fatalBicyclist || 0)}
Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjuriesPedestrian || 0) + (crashData.majorInjuriesBicyclist || 0)}`;
}
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
} else {
const stats = computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 500);
const stats = await computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 300);
if (onPopupCreate) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual crash reports and details', stats });
}
});
@@ -358,6 +450,85 @@ Major Injuries: ${crashData.majorInjuriesDriver + crashData.majorInjuriesPedestr
map.on('mouseleave', 'dc-point', () => map.getCanvas().style.cursor = '');
map.on('mouseenter', 'dc-heat', () => map.getCanvas().style.cursor = 'pointer');
map.on('mouseleave', 'dc-heat', () => map.getCanvas().style.cursor = '');
// Double-click handlers for enhanced nearby statistics
map.on('dblclick', 'dc-point', async (e) => {
e.preventDefault(); // Prevent default map zoom behavior
const feature = e.features && e.features[0];
if (!feature) return;
const coords = (feature.geometry as any).coordinates.slice() as [number, number];
// Validate coordinates
if (!coords || coords.length !== 2 ||
typeof coords[0] !== 'number' || typeof coords[1] !== 'number' ||
isNaN(coords[0]) || isNaN(coords[1]) ||
coords[0] === 0 || coords[1] === 0) {
console.warn('Invalid coordinates for crash point double-click:', coords);
return;
}
// Get more comprehensive stats with larger radius for double-click
const stats = await computeNearbyStats(coords, 500); // 500m radius for double-click
const crashData = feature.properties ? feature.properties.crashData : undefined;
let detailedText = 'Nearby Crash Analysis';
if (crashData && crashData.address) {
detailedText = `Detailed Analysis - ${crashData.address}`;
}
if (onPopupCreate) onPopupCreate({
lngLat: coords,
crashData,
text: detailedText,
stats
});
});
// Double-click on heatmap areas
map.on('dblclick', 'dc-heat', async (e) => {
e.preventDefault(); // Prevent default map zoom behavior
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
// Get comprehensive stats for the clicked location
const stats = await computeNearbyStats(coords, 500); // 500m radius
if (onPopupCreate) onPopupCreate({
lngLat: coords,
text: 'Area Crash Analysis',
stats
});
});
// General map double-click for any location
map.on('dblclick', async (e) => {
// Only trigger if not clicking on a feature
const features = map.queryRenderedFeatures(e.point, { layers: ['dc-point', 'dc-heat'] });
if (features.length > 0) return; // Already handled by feature-specific handlers
e.preventDefault(); // Prevent default map zoom behavior
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
// Get stats for any location on the map
const stats = await computeNearbyStats(coords, 400); // 400m radius for general clicks
if (stats.count > 0) {
if (onPopupCreate) onPopupCreate({
lngLat: coords,
text: 'Location Analysis',
stats
});
} else {
if (onPopupCreate) onPopupCreate({
lngLat: coords,
text: 'No crashes found in this area',
stats: { count: 0, radiusMeters: 800 }
});
}
});
});
map.on('styledata', () => {

View File

@@ -25,18 +25,72 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose }: P
className={`custom-popup ${popupVisible ? 'visible' : ''}`}
style={{ position: 'absolute', left: Math.round(p.x), top: Math.round(p.y), transform: 'translate(-50%, -100%)', pointerEvents: popupVisible ? 'auto' : 'none' }}
>
<div className="mapbox-popup-inner" style={{ background: 'var(--background)', color: 'var(--foreground)', padding: 8, borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.25)', border: '1px solid rgba(0,0,0,0.08)', minWidth: 180 }}>
<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 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<div style={{ fontWeight: 700 }}>{popup.text ?? 'Details'}</div>
<button aria-label="Close popup" onClick={() => { onClose(); }} style={{ background: 'transparent', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer' }}>
<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 }}><strong>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 && (
<div style={{ marginTop: 6, fontSize: 13 }}>
<div><strong>Nearby points:</strong> {popup.stats.count} (within {popup.stats.radiusMeters}m)</div>
<div><strong>Avg:</strong> {popup.stats.avg} &nbsp; <strong>Min:</strong> {popup.stats.min} &nbsp; <strong>Max:</strong> {popup.stats.max}</div>
<div style={{ fontWeight: 600, color: '#0066cc', marginBottom: 4 }}>
📍 {popup.stats.count} crashes within {popup.stats.radiusMeters}m radius
</div>
{popup.stats.avg !== undefined && (
<div style={{ marginBottom: 4, color: 'var(--text-secondary)' }}>
<strong style={{ color: 'var(--text-primary)' }}>Severity Score:</strong> Avg {popup.stats.avg} (Min: {popup.stats.min}, Max: {popup.stats.max})
</div>
)}
{popup.stats.severityCounts && (
<div style={{ marginTop: 6 }}>
<div style={{ fontWeight: 600, marginBottom: 2, color: 'var(--text-primary)' }}>Severity Breakdown:</div>
<div style={{ marginLeft: 8, fontSize: 12, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, color: 'var(--text-secondary)' }}>
{popup.stats.severityCounts.fatal > 0 && <div>🔴 Fatal: {popup.stats.severityCounts.fatal}</div>}
{popup.stats.severityCounts.majorInjury > 0 && <div>🟠 Major: {popup.stats.severityCounts.majorInjury}</div>}
{popup.stats.severityCounts.minorInjury > 0 && <div>🟡 Minor: {popup.stats.severityCounts.minorInjury}</div>}
{popup.stats.severityCounts.propertyOnly > 0 && <div> Property: {popup.stats.severityCounts.propertyOnly}</div>}
</div>
</div>
)}
{popup.stats.crashes && popup.stats.crashes.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 4, color: 'var(--text-primary)' }}>Recent nearby incidents:</div>
<div style={{ marginLeft: 8, fontSize: 11, maxHeight: 150, overflowY: 'auto', border: '1px solid var(--border-2)', borderRadius: 4, padding: 4, background: 'var(--surface-2)' }}>
{popup.stats.crashes.slice(0, 5)
.filter(crash => crash && crash.severity && crash.address) // Filter out null/invalid crashes
.map((crash, i) => (
<div key={crash.id || i} style={{ marginTop: i > 0 ? 6 : 0, padding: 4, borderLeft: '2px solid var(--border-3)', paddingLeft: 6, backgroundColor: i % 2 === 0 ? 'var(--surface-3)' : 'transparent' }}>
<div style={{ fontWeight: 600, fontSize: 12, color: crash.severity === 'Fatal' ? '#dc3545' : crash.severity === 'Major Injury' ? '#fd7e14' : crash.severity === 'Minor Injury' ? '#ffc107' : 'var(--text-tertiary)' }}>
{crash.severity}
</div>
<div style={{ marginTop: 1, lineHeight: 1.3, color: 'var(--text-primary)' }}>{crash.address}</div>
<div style={{ color: 'var(--text-muted)', marginTop: 1 }}>
{crash.reportDate ? new Date(crash.reportDate).toLocaleDateString() : 'Date unknown'}
</div>
{(crash.totalVehicles > 0 || crash.totalPedestrians > 0 || crash.totalBicycles > 0) && (
<div style={{ color: 'var(--text-muted)', fontSize: 10, marginTop: 1 }}>
{crash.totalVehicles > 0 && `🚗${crash.totalVehicles} `}
{crash.totalPedestrians > 0 && `🚶${crash.totalPedestrians} `}
{crash.totalBicycles > 0 && `🚴${crash.totalBicycles} `}
</div>
)}
</div>
))}
</div>
{popup.stats.crashes.length > 5 && (
<div style={{ textAlign: 'center', marginTop: 4, fontSize: 11, color: 'var(--text-muted)' }}>
... and {popup.stats.crashes.length - 5} more crashes in this area
</div>
)}
</div>
)}
</div>
)}
{popup.stats && popup.stats.count === 0 && (
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center', padding: 8, backgroundColor: 'var(--surface-2)', borderRadius: 4 }}>
No crash data found within {popup.stats.radiusMeters || 500}m of this location
</div>
)}
</div>

View File

@@ -7,8 +7,19 @@
@source '../../node_modules/@skeletonlabs/skeleton-react/dist';
:root {
--background: #ffffff;
--foreground: #171717;
/* Light mode sophisticated grey palette */
--background: #fafafa;
--foreground: #1a1a1a;
--surface-1: #ffffff;
--surface-2: #f5f5f5;
--surface-3: #eeeeee;
--border-1: #e0e0e0;
--border-2: #d1d5db;
--border-3: #9ca3af;
--text-primary: #1f2937;
--text-secondary: #4b5563;
--text-tertiary: #6b7280;
--text-muted: #9ca3af;
}
@theme inline {
@@ -20,8 +31,19 @@
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
/* Dark mode sophisticated black/grey palette */
--background: #0f0f0f;
--foreground: #e5e5e5;
--surface-1: #1a1a1a;
--surface-2: #2a2a2a;
--surface-3: #363636;
--border-1: #2a2a2a;
--border-2: #404040;
--border-3: #525252;
--text-primary: #f5f5f5;
--text-secondary: #d1d5db;
--text-tertiary: #a1a1aa;
--text-muted: #71717a;
}
}
@@ -31,27 +53,27 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
/* Map control theming that follows Skeleton/Tailwind color variables */
/* Map control theming that follows sophisticated grey palette */
.map-control {
position: absolute;
bottom: 50px;
right: 12px;
z-index: 2;
background: rgba(255,255,255,0.04);
color: var(--foreground);
background: var(--surface-1);
color: var(--text-primary);
padding: 12px;
border-radius: 10px;
font-size: 13px;
width: 240px;
backdrop-filter: blur(8px);
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 6px 18px rgba(0,0,0,0.35);
border: 1px solid var(--border-1);
box-shadow: 0 6px 18px rgba(0,0,0,0.15);
}
.map-select {
background: transparent;
color: var(--foreground);
border: 1px solid rgba(0,0,0,0.08);
background: var(--surface-2);
color: var(--text-primary);
border: 1px solid var(--border-2);
padding: 4px 6px;
border-radius: 4px;
}
@@ -74,7 +96,7 @@ body {
.mc-label {
flex: 1;
font-size: 13px;
color: var(--foreground);
color: var(--text-secondary);
}
.mc-range {
@@ -82,7 +104,7 @@ body {
-webkit-appearance: none;
appearance: none;
height: 6px;
background: rgba(0,0,0,0.12);
background: var(--surface-3);
border-radius: 6px;
}
.mc-range::-webkit-slider-thumb {
@@ -90,8 +112,8 @@ body {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--foreground);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
background: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Option styling: native dropdowns sometimes ignore inherited styles; try to explicitly set colors */
@@ -111,9 +133,9 @@ body {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--background);
color: var(--foreground);
border: 1px solid rgba(0,0,0,0.06);
background: var(--surface-1);
color: var(--text-primary);
border: 1px solid var(--border-1);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -122,20 +144,31 @@ body {
}
@media (prefers-color-scheme: dark) {
.map-control { background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.06); box-shadow: 0 8px 24px rgba(0,0,0,0.6); }
.map-select { border: 1px solid rgba(255,255,255,0.08); }
.zoom-btn { background: rgba(255,255,255,0.06); color: var(--foreground); border: 1px solid rgba(255,255,255,0.06); }
.map-control {
background: var(--surface-1);
border: 1px solid var(--border-1);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.map-select {
background: var(--surface-2);
border: 1px solid var(--border-2);
}
.zoom-btn {
background: var(--surface-2);
color: var(--text-primary);
border: 1px solid var(--border-2);
}
}
/* enhance label and control spacing */
.map-control .mc-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.map-control .mc-label { flex: 1; font-size: 13px; color: var(--foreground); }
.map-control .mc-title { margin-bottom: 8px; font-weight: 700; color: var(--foreground); }
.map-control .mc-label { flex: 1; font-size: 13px; color: var(--text-secondary); }
.map-control .mc-title { margin-bottom: 8px; font-weight: 700; color: var(--text-primary); }
/* style sliders */
.map-control input[type="range"] { appearance: none; height: 6px; background: rgba(0,0,0,0.12); border-radius: 6px; }
.map-control input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4); cursor: pointer; }
.map-control input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; cursor: pointer; }
.map-control input[type="range"] { appearance: none; height: 6px; background: var(--surface-3); border-radius: 6px; }
.map-control input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.2); cursor: pointer; }
.map-control input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--text-primary); cursor: pointer; }
/* compact checkbox */
.map-control input[type="checkbox"] { width: 16px; height: 16px; }
@@ -144,9 +177,9 @@ body {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--background);
color: var(--foreground);
border: 1px solid rgba(0,0,0,0.06);
background: var(--surface-1);
color: var(--text-primary);
border: 1px solid var(--border-1);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -155,28 +188,32 @@ body {
font-size: 18px;
line-height: 1;
}
.zoom-btn:hover { transform: translateY(-1px); opacity: 0.95; }
.zoom-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
background: var(--surface-2);
}
/* Mapbox popup theming to match site theme */
/* Mapbox popup theming to match sophisticated palette */
.mapboxgl-popup {
--popup-bg: var(--background);
--popup-fg: var(--foreground);
--popup-bg: var(--surface-1);
--popup-fg: var(--text-primary);
}
.mapboxgl-popup-content {
background: var(--popup-bg) !important;
color: var(--popup-fg) !important;
border-radius: 8px !important;
padding: 8px !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.35) !important;
border: 1px solid rgba(0,0,0,0.08) !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.15) !important;
border: 1px solid var(--border-1) !important;
font-size: 13px;
}
.mapboxgl-popup-tip {
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.1));
}
.mapboxgl-popup-close-button {
color: var(--popup-fg) !important;
opacity: 0.9;
opacity: 0.7;
}
.mapboxgl-popup a { color: inherit; }
@@ -188,8 +225,8 @@ body {
z-index: 50;
}
.custom-popup.visible { opacity: 1; transform: translateY(-10px) scale(1); }
.mapbox-popup-inner button[aria-label="Close popup"] { border-radius: 6px; padding: 6px; background: rgba(0,0,0,0.04); }
.mapbox-popup-inner button[aria-label="Close popup"]:focus { outline: 2px solid rgba(0,0,0,0.12); }
.mapbox-popup-inner button[aria-label="Close popup"] { border-radius: 6px; padding: 6px; background: var(--surface-2); }
.mapbox-popup-inner button[aria-label="Close popup"]:focus { outline: 2px solid var(--border-3); }
.mapbox-popup-inner button[aria-label="Close popup"] { cursor: pointer; }
/* Directions sidebar (left-side, full-height, collapsible) */
@@ -202,16 +239,16 @@ body {
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder input[type="text"],
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--input {
background: #0f1112; /* match sidebar */
color: #e6eef8; /* light text */
border: 1px solid rgba(255,255,255,0.06);
background: var(--surface-2);
color: var(--text-primary);
border: 1px solid var(--border-2);
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
box-shadow: none;
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder input[type="text"]::placeholder,
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--input::placeholder {
color: rgba(230,238,248,0.5);
color: var(--text-muted);
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder input[type="text"]:focus,
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--input:focus {
@@ -221,9 +258,9 @@ body {
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder--suggestions,
.directions-sidebar-geocoder .suggestions {
background: #0b0b0c; /* dropdown bg */
color: #e6eef8;
border: 1px solid rgba(255,255,255,0.04);
background: var(--surface-1);
color: var(--text-primary);
border: 1px solid var(--border-1);
border-radius: 0.5rem;
overflow: hidden;
}
@@ -235,14 +272,14 @@ body {
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder--suggestions .suggestion:hover,
.directions-sidebar-geocoder .suggestions .suggestion:hover {
background: rgba(255,255,255,0.02);
background: var(--surface-2);
}
/* custom inline geocoder input and dropdown styling */
.directions-sidebar-geocoder input[type="text"] {
background: #0f1112;
color: #e6eef8;
border: 1px solid rgba(255,255,255,0.06);
background: var(--surface-2);
color: var(--text-primary);
border: 1px solid var(--border-2);
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
width: 100%;
@@ -253,7 +290,9 @@ body {
text-align: left;
padding: 0.5rem 0.75rem;
}
.directions-sidebar-geocoder .custom-suggestions button:hover { background: rgba(255,255,255,0.03); }
.directions-sidebar-geocoder .custom-suggestions button:hover {
background: var(--surface-3);
}
/* hide the magnifying/search icon inside the embedded geocoder input */
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon {

View File

@@ -4,6 +4,7 @@ import { CrashData, CrashResponse } from '../api/crashes/route';
export interface UseCrashDataOptions {
autoLoad?: boolean;
limit?: number;
yearFilter?: string | null;
}
export interface UseCrashDataResult {
@@ -11,25 +12,38 @@ export interface UseCrashDataResult {
loading: boolean;
error: string | null;
pagination: CrashResponse['pagination'] | null;
yearFilter: string | null;
loadPage: (page: number) => Promise<void>;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
setYearFilter: (year: string | null) => void;
}
export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataResult {
const { autoLoad = true, limit = 100 } = options;
const currentYear = new Date().getFullYear().toString();
const { autoLoad = true, limit = 100, yearFilter: initialYearFilter = currentYear } = options;
const [data, setData] = useState<CrashData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<CrashResponse['pagination'] | null>(null);
const [yearFilter, setYearFilterState] = useState<string | null>(initialYearFilter);
const fetchCrashData = useCallback(async (page: number, append: boolean = false) => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/crashes?page=${page}&limit=${limit}`);
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (yearFilter) {
params.append('year', yearFilter);
}
const response = await fetch(`/api/crashes?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch crash data: ${response.statusText}`);
@@ -51,7 +65,7 @@ export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataRes
} finally {
setLoading(false);
}
}, [limit]);
}, [limit, yearFilter]);
const loadPage = useCallback((page: number) => {
return fetchCrashData(page, false);
@@ -68,20 +82,29 @@ export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataRes
return fetchCrashData(1, false);
}, [fetchCrashData]);
// Auto-load first page on mount
const setYearFilter = useCallback((year: string | null) => {
setYearFilterState(year);
// Refresh data when year filter changes
setData([]);
setPagination(null);
}, []);
// Auto-load first page on mount or when year filter changes
useEffect(() => {
if (autoLoad) {
loadPage(1);
}
}, [autoLoad, loadPage]);
}, [autoLoad, loadPage, yearFilter]);
return {
data,
loading,
error,
pagination,
yearFilter,
loadPage,
loadMore,
refresh,
setYearFilter,
};
}

View File

@@ -99,3 +99,82 @@ export const generateDCPoints = (count = 500) => {
return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection<GeoJSON.Geometry>;
};
// 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;
};

View File

@@ -13,9 +13,9 @@ import { useCrashData } from './hooks/useCrashData';
export default function Home() {
const mapRef = useRef<any>(null);
const [heatVisible, setHeatVisible] = useState(true);
const [pointsVisible, setPointsVisible] = useState(true);
const [pointsVisible, setPointsVisible] = useState(false);
const [mapStyleChoice, setMapStyleChoice] = useState<'dark' | 'streets'>('dark');
const [heatRadius, setHeatRadius] = useState(30);
const [heatRadius, setHeatRadius] = useState(16);
const [heatIntensity, setHeatIntensity] = useState(1);
const [panelOpen, setPanelOpen] = useState<boolean>(() => {
try { const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_panel_open') : null; return v === null ? true : v === '1'; } catch (e) { return true; }