diff --git a/.gitignore b/.gitignore index 4f339bb..c11dd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ package-lock.json .venv/ roadcast/data.csv web/public/Crashes_in_DC.csv +ai/Crashes_in_DC.csv diff --git a/ai/main.py b/ai/main.py new file mode 100644 index 0000000..97069eb --- /dev/null +++ b/ai/main.py @@ -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() \ No newline at end of file diff --git a/ai/test_queries.py b/ai/test_queries.py new file mode 100644 index 0000000..3fccb36 --- /dev/null +++ b/ai/test_queries.py @@ -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() \ No newline at end of file diff --git a/roadcast/app.py b/roadcast/app.py index acbde40..9b991ec 100644 --- a/roadcast/app.py +++ b/roadcast/app.py @@ -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 diff --git a/web/bun.lock b/web/bun.lock index aa8818d..d5f209a 100644 --- a/web/bun.lock +++ b/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=="], diff --git a/web/package.json b/web/package.json index 374edff..f8bf3f1 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/app/api/crashes/nearby/route.ts b/web/src/app/api/crashes/nearby/route.ts new file mode 100644 index 0000000..686e175 --- /dev/null +++ b/web/src/app/api/crashes/nearby/route.ts @@ -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 { + 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 => 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 } + ); + } +} \ No newline at end of file diff --git a/web/src/app/api/crashes/route.ts b/web/src/app/api/crashes/route.ts index 9872ba6..d56a7c1 100644 --- a/web/src/app/api/crashes/route.ts +++ b/web/src/app/api/crashes/route.ts @@ -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 { - 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 { + 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 } ); } diff --git a/web/src/app/components/CrashDataControls.tsx b/web/src/app/components/CrashDataControls.tsx index 19cb65b..d358636 100644 --- a/web/src/app/components/CrashDataControls.tsx +++ b/web/src/app/components/CrashDataControls.tsx @@ -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(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 (
-
+
Crash Data Status
+ {/* Year Filter */} +
+ + +
+
Loaded: {data.length.toLocaleString()} crashes + {yearFilter && ` (${yearFilter})`}
{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 diff --git a/web/src/app/components/DirectionsSidebar.tsx b/web/src/app/components/DirectionsSidebar.tsx index 589a531..d578676 100644 --- a/web/src/app/components/DirectionsSidebar.tsx +++ b/web/src/app/components/DirectionsSidebar.tsx @@ -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; @@ -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(null); + const [rerouteInfo, setRerouteInfo] = useState(null); + const crashDataHook = useCrashData({ autoLoad: true, limit: 10000 }); + const [isOriginMapPicking, setIsOriginMapPicking] = useState(false); + const [isDestMapPicking, setIsDestMapPicking] = useState(false); + const [routes, setRoutes] = useState([]); + const [selectedRouteIndex, setSelectedRouteIndex] = useState(0); // custom geocoder inputs + suggestions (we implement our own UI instead of the library) const originQueryRef = useRef(""); const destQueryRef = useRef(""); @@ -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 = { + 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 = { 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 = { + 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 = { 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 */} - + +
+ {/* Route Options */} + {routes.length > 1 && ( +
+
+ + + + Route Options ({routes.length}) +
+
+ {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 ( + + ); + })} +
+

+ Click on a route to select it or click directly on the map +

+
+ )} + + {/* Show reroute information if available */} + {rerouteInfo && ( +
+
+ + + + + {rerouteInfo.reroute_needed ? 'Safer Route Available' : 'Current Route is Optimal'} + +
+ {rerouteInfo.reroute_needed && rerouteInfo.risk_improvement && ( +

+ Risk reduction: {rerouteInfo.risk_improvement.toFixed(2)} points +

+ )} + {rerouteInfo.reason && ( +

+ {rerouteInfo.reason === 'no_lower_risk_found' ? 'No safer alternatives found' : rerouteInfo.reason} +

+ )} + {rerouteInfo.reroute_needed && ( +
+ + Green dashed line shows safer route +
+ )} +
+ )} + + {/* Route Safety Legend */} + {(originCoord && destCoord) && ( +
+
+ + + + Route Safety Legend +
+
+
+
+ Low crash risk +
+
+
+ Moderate risk +
+
+
+ High risk +
+
+
+ Very high risk +
+
+
+ Extreme risk +
+
+

+ Colors based on historical crash data within 150m of route +

+
+ )} + + {/* Map picking mode indicator */} + {(isOriginMapPicking || isDestMapPicking) && ( +
+
+ + + + +
+

+ Map Picking Mode Active +

+

+ {isOriginMapPicking ? "Click anywhere on the map to set your origin location" : "Click anywhere on the map to set your destination location"} +

+ +
+
+
+ )} + {/* pick-on-map mode removed; sidebar uses geocoder-only inputs */}
diff --git a/web/src/app/components/GeocodeInput.tsx b/web/src/app/components/GeocodeInput.tsx index db2de9e..8cb2706 100644 --- a/web/src/app/components/GeocodeInput.tsx +++ b/web/src/app/components/GeocodeInput.tsx @@ -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(value); const [suggestions, setSuggestions] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); const timer = useRef(null); const mounted = useRef(true); + const containerRef = useRef(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 ( -
- { setQuery(e.target.value); onChange && onChange(e.target.value); }} - /> - {suggestions.length > 0 && ( -
+
+ {/* Search bar container matching the design */} +
+ {/* Input field */} + { + setQuery(e.target.value); + onChange && onChange(e.target.value); + }} + onFocus={() => { + if (!isMapPickingMode && suggestions.length > 0) { + setShowDropdown(true); + } + }} + disabled={isMapPickingMode} + /> + + {/* Pin button */} + +
+ + {/* Suggestions dropdown */} + {!isMapPickingMode && showDropdown && suggestions.length > 0 && ( +
{suggestions.map((f: any, i: number) => ( - ))}
)} + + {/* Map picking mode indicator */} + {isMapPickingMode && ( +
+
+
+ Click anywhere on the map to select a location +
+
+ )}
); } diff --git a/web/src/app/components/MapView.tsx b/web/src/app/components/MapView.tsx index 686192b..c25b80a 100644 --- a/web/src/app/components/MapView.tsx +++ b/web/src/app/components/MapView.tsx @@ -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', () => { diff --git a/web/src/app/components/PopupOverlay.tsx b/web/src/app/components/PopupOverlay.tsx index 32142f4..af1579b 100644 --- a/web/src/app/components/PopupOverlay.tsx +++ b/web/src/app/components/PopupOverlay.tsx @@ -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' }} > -
+
-
{popup.text ?? 'Details'}
-
- {typeof popup.mag !== 'undefined' &&
Magnitude: {popup.mag}
} + {typeof popup.mag !== 'undefined' &&
Magnitude: {popup.mag}
} {popup.stats && popup.stats.count > 0 && (
-
Nearby points: {popup.stats.count} (within {popup.stats.radiusMeters}m)
-
Avg: {popup.stats.avg}   Min: {popup.stats.min}   Max: {popup.stats.max}
+
+ 📍 {popup.stats.count} crashes within {popup.stats.radiusMeters}m radius +
+ {popup.stats.avg !== undefined && ( +
+ Severity Score: Avg {popup.stats.avg} (Min: {popup.stats.min}, Max: {popup.stats.max}) +
+ )} + {popup.stats.severityCounts && ( +
+
Severity Breakdown:
+
+ {popup.stats.severityCounts.fatal > 0 &&
🔴 Fatal: {popup.stats.severityCounts.fatal}
} + {popup.stats.severityCounts.majorInjury > 0 &&
🟠 Major: {popup.stats.severityCounts.majorInjury}
} + {popup.stats.severityCounts.minorInjury > 0 &&
🟡 Minor: {popup.stats.severityCounts.minorInjury}
} + {popup.stats.severityCounts.propertyOnly > 0 &&
⚪ Property: {popup.stats.severityCounts.propertyOnly}
} +
+
+ )} + {popup.stats.crashes && popup.stats.crashes.length > 0 && ( +
+
Recent nearby incidents:
+
+ {popup.stats.crashes.slice(0, 5) + .filter(crash => crash && crash.severity && crash.address) // Filter out null/invalid crashes + .map((crash, i) => ( +
0 ? 6 : 0, padding: 4, borderLeft: '2px solid var(--border-3)', paddingLeft: 6, backgroundColor: i % 2 === 0 ? 'var(--surface-3)' : 'transparent' }}> +
+ {crash.severity} +
+
{crash.address}
+
+ {crash.reportDate ? new Date(crash.reportDate).toLocaleDateString() : 'Date unknown'} +
+ {(crash.totalVehicles > 0 || crash.totalPedestrians > 0 || crash.totalBicycles > 0) && ( +
+ {crash.totalVehicles > 0 && `🚗${crash.totalVehicles} `} + {crash.totalPedestrians > 0 && `🚶${crash.totalPedestrians} `} + {crash.totalBicycles > 0 && `🚴${crash.totalBicycles} `} +
+ )} +
+ ))} +
+ {popup.stats.crashes.length > 5 && ( +
+ ... and {popup.stats.crashes.length - 5} more crashes in this area +
+ )} +
+ )} +
+ )} + {popup.stats && popup.stats.count === 0 && ( +
+ No crash data found within {popup.stats.radiusMeters || 500}m of this location
)}
diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 7418466..e5d9ba4 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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 { diff --git a/web/src/app/hooks/useCrashData.ts b/web/src/app/hooks/useCrashData.ts index 2bd880b..5b9963c 100644 --- a/web/src/app/hooks/useCrashData.ts +++ b/web/src/app/hooks/useCrashData.ts @@ -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; loadMore: () => Promise; refresh: () => Promise; + 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([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [pagination, setPagination] = useState(null); + const [yearFilter, setYearFilterState] = useState(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, }; } \ No newline at end of file diff --git a/web/src/app/lib/mapUtils.ts b/web/src/app/lib/mapUtils.ts index 285e444..768dc35 100644 --- a/web/src/app/lib/mapUtils.ts +++ b/web/src/app/lib/mapUtils.ts @@ -99,3 +99,82 @@ export const generateDCPoints = (count = 500) => { return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; }; + +// Calculate crash density along a route path +export const calculateRouteCrashDensity = ( + routeCoordinates: [number, number][], + crashData: CrashData[], + searchRadiusMeters: number = 100 +): number[] => { + if (!routeCoordinates || routeCoordinates.length === 0) return []; + + const densities: number[] = []; + + for (let i = 0; i < routeCoordinates.length; i++) { + const currentPoint = routeCoordinates[i]; + let crashCount = 0; + let severityScore = 0; + + // Count crashes within search radius of current point + for (const crash of crashData) { + const crashPoint: [number, number] = [crash.longitude, crash.latitude]; + const distance = haversine(currentPoint, crashPoint); + + if (distance <= searchRadiusMeters) { + crashCount++; + // Weight by severity + const severity = Math.max(1, + (crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 5 + + (crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 3 + + (crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles) + ); + severityScore += severity; + } + } + + // Normalize density score (0-1 range) + const density = Math.min(1, severityScore / 20); // Adjust divisor based on data + densities.push(density); + } + + return densities; +}; + +// Create gradient stops based on crash densities along route +export const createRouteGradientStops = (densities: number[]): any[] => { + if (!densities || densities.length === 0) { + // Default gradient: green to red + return [ + 'interpolate', + ['linear'], + ['line-progress'], + 0, 'green', + 1, 'red' + ]; + } + + const stops: any[] = ['interpolate', ['linear'], ['line-progress']]; + + for (let i = 0; i < densities.length; i++) { + const progress = i / (densities.length - 1); + const density = densities[i]; + + // Color based on crash density: green (safe) to red (dangerous) + let color: string; + if (density < 0.2) { + color = '#22c55e'; // green + } else if (density < 0.4) { + color = '#eab308'; // yellow + } else if (density < 0.6) { + color = '#f97316'; // orange + } else if (density < 0.8) { + color = '#dc2626'; // red + } else { + color = '#7f1d1d'; // dark red + } + + stops.push(progress, color); + } + + return stops; +}; diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 9614776..04d5f20 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -13,9 +13,9 @@ import { useCrashData } from './hooks/useCrashData'; export default function Home() { const mapRef = useRef(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(() => { try { const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_panel_open') : null; return v === null ? true : v === '1'; } catch (e) { return true; }