BIG WEB UPDATE
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
211
ai/main.py
Normal 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
116
ai/test_queries.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
25
web/bun.lock
25
web/bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
140
web/src/app/api/crashes/nearby/route.ts
Normal file
140
web/src/app/api/crashes/nearby/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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} <strong>Min:</strong> {popup.stats.min} <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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user