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

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ package-lock.json
.venv/
roadcast/data.csv
web/public/Crashes_in_DC.csv
ai/Crashes_in_DC.csv

211
ai/main.py Normal file
View File

@@ -0,0 +1,211 @@
import pandas as pd
from pymongo import MongoClient
from datetime import datetime
import os
from dotenv import load_dotenv
import numpy as np
# Load environment variables
load_dotenv('.env.local')
# MongoDB connection
MONGO_URI = os.getenv('MONGO_URI')
client = MongoClient(MONGO_URI)
db = client['crashes']
collection = db['crashes']
# Read CSV
print("Reading CSV file...")
df = pd.read_csv('Crashes_in_DC.csv')
print(f"Loaded {len(df)} crash records")
# Helper to calculate severity based on injury data
def calculate_severity(row):
# Count total injuries and fatalities
fatal_count = (
row.get('FATAL_BICYCLIST', 0) +
row.get('FATAL_DRIVER', 0) +
row.get('FATAL_PEDESTRIAN', 0) +
row.get('FATALPASSENGER', 0) +
row.get('FATALOTHER', 0)
)
major_injury_count = (
row.get('MAJORINJURIES_BICYCLIST', 0) +
row.get('MAJORINJURIES_DRIVER', 0) +
row.get('MAJORINJURIES_PEDESTRIAN', 0) +
row.get('MAJORINJURIESPASSENGER', 0) +
row.get('MAJORINJURIESOTHER', 0)
)
minor_injury_count = (
row.get('MINORINJURIES_BICYCLIST', 0) +
row.get('MINORINJURIES_DRIVER', 0) +
row.get('MINORINJURIES_PEDESTRIAN', 0) +
row.get('MINORINJURIESPASSENGER', 0) +
row.get('MINORINJURIESOTHER', 0)
)
if fatal_count > 0:
return "Fatal"
elif major_injury_count > 0:
return "Major Injury"
elif minor_injury_count > 0:
return "Minor Injury"
else:
return "Property Damage Only"
# Helper to convert row to MongoDB document
def row_to_doc(row):
# Handle missing coordinates
longitude = row.get('LONGITUDE')
latitude = row.get('LATITUDE')
# Skip records with invalid coordinates
if pd.isna(longitude) or pd.isna(latitude) or longitude == 0 or latitude == 0:
return None
# Parse date
report_date = None
if pd.notna(row.get('REPORTDATE')):
try:
report_date = pd.to_datetime(row['REPORTDATE'])
except:
report_date = None
# Build the document with GeoJSON location
doc = {
"crashId": str(row.get('CRIMEID', '')),
"ccn": str(row.get('CCN', '')),
"reportDate": report_date,
"location": {
"type": "Point",
"coordinates": [float(longitude), float(latitude)] # [longitude, latitude]
},
"address": str(row.get('ADDRESS', '')),
"severity": calculate_severity(row),
"ward": str(row.get('WARD', '')),
"vehicles": {
"total": int(row.get('TOTAL_VEHICLES', 0)),
"taxis": int(row.get('TOTAL_TAXIS', 0)),
"government": int(row.get('TOTAL_GOVERNMENT', 0))
},
"casualties": {
"bicyclists": {
"fatal": int(row.get('FATAL_BICYCLIST', 0)),
"major_injuries": int(row.get('MAJORINJURIES_BICYCLIST', 0)),
"minor_injuries": int(row.get('MINORINJURIES_BICYCLIST', 0)),
"unknown_injuries": int(row.get('UNKNOWNINJURIES_BICYCLIST', 0)),
"total": int(row.get('TOTAL_BICYCLES', 0))
},
"drivers": {
"fatal": int(row.get('FATAL_DRIVER', 0)),
"major_injuries": int(row.get('MAJORINJURIES_DRIVER', 0)),
"minor_injuries": int(row.get('MINORINJURIES_DRIVER', 0)),
"unknown_injuries": int(row.get('UNKNOWNINJURIES_DRIVER', 0))
},
"pedestrians": {
"fatal": int(row.get('FATAL_PEDESTRIAN', 0)),
"major_injuries": int(row.get('MAJORINJURIES_PEDESTRIAN', 0)),
"minor_injuries": int(row.get('MINORINJURIES_PEDESTRIAN', 0)),
"unknown_injuries": int(row.get('UNKNOWNINJURIES_PEDESTRIAN', 0)),
"total": int(row.get('TOTAL_PEDESTRIANS', 0))
},
"passengers": {
"fatal": int(row.get('FATALPASSENGER', 0)),
"major_injuries": int(row.get('MAJORINJURIESPASSENGER', 0)),
"minor_injuries": int(row.get('MINORINJURIESPASSENGER', 0)),
"unknown_injuries": int(row.get('UNKNOWNINJURIESPASSENGER', 0))
}
},
"circumstances": {
"speeding_involved": bool(row.get('SPEEDING_INVOLVED', False)),
"pedestrians_impaired": bool(row.get('PEDESTRIANSIMPAIRED', False)),
"bicyclists_impaired": bool(row.get('BICYCLISTSIMPAIRED', False)),
"drivers_impaired": bool(row.get('DRIVERSIMPAIRED', False))
},
"location_details": {
"nearest_intersection": str(row.get('NEARESTINTSTREETNAME', '')),
"off_intersection": bool(row.get('OFFINTERSECTION', False)),
"approach_direction": str(row.get('INTAPPROACHDIRECTION', ''))
}
}
return doc
# Convert all rows to documents
print("Converting data to MongoDB documents...")
docs = []
skipped_count = 0
for _, row in df.iterrows():
doc = row_to_doc(row)
if doc is not None:
docs.append(doc)
else:
skipped_count += 1
print(f"Converted {len(docs)} valid documents")
print(f"Skipped {skipped_count} records with invalid coordinates")
# Insert into MongoDB in batches
print("Inserting documents into MongoDB...")
batch_size = 1000
total_inserted = 0
for i in range(0, len(docs), batch_size):
batch = docs[i:i+batch_size]
try:
result = collection.insert_many(batch, ordered=False)
total_inserted += len(result.inserted_ids)
print(f"Inserted batch {i//batch_size + 1}/{(len(docs) + batch_size - 1)//batch_size} - Total: {total_inserted}")
except Exception as e:
print(f"Error inserting batch: {e}")
print(f"Successfully inserted {total_inserted} documents")
# Create 2dsphere index for geospatial queries
print("Creating 2dsphere index for geospatial queries...")
try:
collection.create_index([("location", "2dsphere")])
print("Successfully created 2dsphere index on 'location' field")
except Exception as e:
print(f"Error creating index: {e}")
# Create additional indexes for common queries
print("Creating additional indexes...")
try:
collection.create_index([("severity", 1)])
collection.create_index([("reportDate", 1)])
collection.create_index([("ward", 1)])
print("Successfully created additional indexes")
except Exception as e:
print(f"Error creating additional indexes: {e}")
print("Data import completed!")
# Sample geospatial query to test
print("\n--- Testing geospatial query ---")
try:
# Find crashes within 1000 meters of a point in DC
sample_point = [-77.0369, 38.9072] # Washington DC coordinates
nearby_crashes = collection.find({
"location": {
"$nearSphere": {
"$geometry": {
"type": "Point",
"coordinates": sample_point
},
"$maxDistance": 1000 # 1000 meters
}
}
}).limit(5)
print(f"Sample query: Found crashes within 1000m of {sample_point}:")
for crash in nearby_crashes:
print(f" - Crash ID: {crash['crashId']}, Address: {crash['address']}, Severity: {crash['severity']}")
except Exception as e:
print(f"Error running sample query: {e}")
client.close()

116
ai/test_queries.py Normal file
View File

@@ -0,0 +1,116 @@
import os
from pymongo import MongoClient
from dotenv import load_dotenv
# Load environment variables
load_dotenv('.env.local')
# MongoDB connection
MONGO_URI = os.getenv('MONGO_URI')
client = MongoClient(MONGO_URI)
db = client['crashes']
collection = db['crashes']
print("=== MongoDB Geospatial Query Examples ===\n")
# 1. Count total documents
print("1. Total crash records in database:")
total_count = collection.count_documents({})
print(f" {total_count} crash records\n")
# 2. Find crashes within a radius (near the White House)
print("2. Crashes within 500 meters of the White House:")
white_house = [-77.0365, 38.8977]
nearby_crashes = list(collection.find({
"location": {
"$nearSphere": {
"$geometry": {
"type": "Point",
"coordinates": white_house
},
"$maxDistance": 500 # 500 meters
}
}
}).limit(5))
for crash in nearby_crashes:
print(f" - {crash['crashId']}: {crash['address']} (Severity: {crash['severity']})")
print()
# 3. Find crashes within a bounding box (downtown DC area)
print("3. Crashes within downtown DC bounding box:")
downtown_crashes = list(collection.find({
"location": {
"$geoWithin": {
"$box": [
[-77.05, 38.88], # Southwest corner
[-77.01, 38.92] # Northeast corner
]
}
}
}).limit(5))
for crash in downtown_crashes:
print(f" - {crash['crashId']}: {crash['address']} (Ward: {crash['ward']})")
print()
# 4. Aggregation with geoNear for fatal crashes
print("4. Fatal crashes near Capitol Hill (within 1km):")
capitol_hill = [-77.0090, 38.8899]
fatal_nearby = list(collection.aggregate([
{
"$geoNear": {
"near": {
"type": "Point",
"coordinates": capitol_hill
},
"distanceField": "distance",
"maxDistance": 1000,
"query": {"severity": "Fatal"},
"spherical": True
}
},
{"$limit": 3}
]))
for crash in fatal_nearby:
distance_m = round(crash['distance'])
print(f" - {crash['crashId']}: {crash['address']} ({distance_m}m away)")
print()
# 5. Count crashes by severity within a specific area
print("5. Crash severity breakdown in Ward 1:")
severity_breakdown = list(collection.aggregate([
{"$match": {"ward": "Ward 1"}},
{"$group": {"_id": "$severity", "count": {"$sum": 1}}},
{"$sort": {"count": -1}}
]))
for item in severity_breakdown:
print(f" - {item['_id']}: {item['count']} crashes")
print()
# 6. Find crashes involving speeding within a polygon area
print("6. Speeding-involved crashes near DuPont Circle:")
dupont_circle = [-77.0436, 38.9094]
speeding_crashes = list(collection.find({
"location": {
"$nearSphere": {
"$geometry": {
"type": "Point",
"coordinates": dupont_circle
},
"$maxDistance": 800
}
},
"circumstances.speeding_involved": True
}).limit(3))
for crash in speeding_crashes:
print(f" - {crash['crashId']}: {crash['address']}")
print(f" Vehicles: {crash['vehicles']['total']}, Severity: {crash['severity']}")
print()
print("=== Geospatial queries completed successfully! ===")
client.close()

View File

@@ -1,4 +1,8 @@
from flask import Flask, request, jsonify
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
app = Flask(__name__)
import os

View File

@@ -9,6 +9,7 @@
"@types/mapbox-gl": "^3.4.1",
"csv-parser": "^3.2.0",
"mapbox-gl": "^3.15.0",
"mongodb": "^6.20.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
@@ -123,6 +124,8 @@
"@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@19.3.3", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^0.0.1", "json-stringify-pretty-compact": "^3.0.0", "minimist": "^1.2.8", "rw": "^1.3.3", "sort-object": "^3.0.3" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw=="],
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.1", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg=="],
"@next/env": ["@next/env@15.5.4", "", {}, "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA=="],
@@ -211,6 +214,10 @@
"@types/supercluster": ["@types/supercluster@7.1.3", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="],
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
"@types/whatwg-url": ["@types/whatwg-url@11.0.5", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ=="],
"@vis.gl/react-mapbox": ["@vis.gl/react-mapbox@8.0.4", "", { "peerDependencies": { "mapbox-gl": ">=3.5.0", "react": ">=16.3.0", "react-dom": ">=16.3.0" }, "optionalPeers": ["mapbox-gl"] }, "sha512-NFk0vsWcNzSs0YCsVdt2100Zli9QWR+pje8DacpLkkGEAXFaJsFtI1oKD0Hatiate4/iAIW39SQHhgfhbeEPfQ=="],
"@vis.gl/react-maplibre": ["@vis.gl/react-maplibre@8.0.4", "", { "dependencies": { "@maplibre/maplibre-gl-style-spec": "^19.2.1" }, "peerDependencies": { "maplibre-gl": ">=4.0.0", "react": ">=16.3.0", "react-dom": ">=16.3.0" }, "optionalPeers": ["maplibre-gl"] }, "sha512-HwZyfLjEu+y1mUFvwDAkVxinGm8fEegaWN+O8np/WZ2Sqe5Lv6OXFpV6GWz9LOEvBYMbGuGk1FQdejo+4HCJ5w=="],
@@ -277,6 +284,8 @@
"base-64": ["base-64@0.1.0", "", {}, "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA=="],
"bson": ["bson@6.10.4", "", {}, "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng=="],
"bytewise": ["bytewise@1.1.0", "", { "dependencies": { "bytewise-core": "^1.2.2", "typewise": "^1.0.3" } }, "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ=="],
"bytewise-core": ["bytewise-core@1.2.3", "", { "dependencies": { "typewise-core": "^1.2" } }, "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA=="],
@@ -467,6 +476,8 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
"meow": ["meow@9.0.0", "", { "dependencies": { "@types/minimist": "^1.2.0", "camelcase-keys": "^6.2.2", "decamelize": "^1.2.0", "decamelize-keys": "^1.1.0", "hard-rejection": "^2.1.0", "minimist-options": "4.1.0", "normalize-package-data": "^3.0.0", "read-pkg-up": "^7.0.1", "redent": "^3.0.0", "trim-newlines": "^3.0.0", "type-fest": "^0.18.0", "yargs-parser": "^20.2.3" } }, "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
@@ -487,6 +498,10 @@
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mongodb": ["mongodb@6.20.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^6.10.4", "mongodb-connection-string-url": "^3.0.2" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.188.0", "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=6.0.0 <7", "snappy": "^7.3.2", "socks": "^2.7.1" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ=="],
"mongodb-connection-string-url": ["mongodb-connection-string-url@3.0.2", "", { "dependencies": { "@types/whatwg-url": "^11.0.2", "whatwg-url": "^14.1.0 || ^13.0.0" } }, "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA=="],
"murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -527,6 +542,8 @@
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
"quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="],
@@ -577,6 +594,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
"spdx-correct": ["spdx-correct@3.2.0", "", { "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA=="],
"spdx-exceptions": ["spdx-exceptions@2.5.0", "", {}, "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w=="],
@@ -609,6 +628,8 @@
"tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="],
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"trim-newlines": ["trim-newlines@3.0.1", "", {}, "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -631,6 +652,10 @@
"validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],

View File

@@ -13,6 +13,7 @@
"@types/mapbox-gl": "^3.4.1",
"csv-parser": "^3.2.0",
"mapbox-gl": "^3.15.0",
"mongodb": "^6.20.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",

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