Maps Update

This commit is contained in:
2025-09-27 17:36:07 -04:00
parent 4ab8fa6173
commit 2471610d80
14 changed files with 1060 additions and 113 deletions

View File

@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import csv from 'csv-parser';
export type CrashData = {
id: string;
latitude: number;
longitude: number;
reportDate: string;
address: string;
ward: string;
totalVehicles: number;
totalPedestrians: number;
totalBicycles: number;
fatalDriver: number;
fatalPedestrian: number;
fatalBicyclist: number;
majorInjuriesDriver: number;
majorInjuriesPedestrian: number;
majorInjuriesBicyclist: number;
speedingInvolved: number;
};
export type CrashResponse = {
data: CrashData[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
};
const CSV_FILE_PATH = path.join(process.cwd(), 'public', 'Crashes_in_DC.csv');
// 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;
}
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,
});
}
})
.on('end', () => {
// Update cache
csvCache = results;
csvCacheTimestamp = now;
resolve(results);
})
.on('error', (error: any) => {
reject(error);
});
});
}
export async function GET(request: NextRequest) {
try {
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')));
// Load CSV data
const allCrashes = await loadCsvData();
// 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,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
},
};
return NextResponse.json(response);
} catch (error) {
console.error('Error loading crash data:', error);
return NextResponse.json(
{ error: 'Failed to load crash data' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,98 @@
"use client";
import React from 'react';
import { UseCrashDataResult } from '../hooks/useCrashData';
interface CrashDataControlsProps {
crashDataHook: UseCrashDataResult;
onDataLoaded?: (dataCount: number) => void;
}
export default function CrashDataControls({ crashDataHook, onDataLoaded }: CrashDataControlsProps) {
const { data, loading, error, pagination, loadMore, refresh } = crashDataHook;
React.useEffect(() => {
if (onDataLoaded) {
onDataLoaded(data.length);
}
}, [data.length, onDataLoaded]);
return (
<div style={{
position: 'absolute',
top: '10px',
right: '10px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '12px',
borderRadius: '6px',
zIndex: 30,
fontSize: '14px',
minWidth: '200px'
}}>
<div style={{ marginBottom: '8px', fontWeight: 'bold' }}>
Crash Data Status
</div>
<div style={{ marginBottom: '6px' }}>
Loaded: {data.length.toLocaleString()} crashes
</div>
{pagination && (
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
Page {pagination.page} of {pagination.totalPages}
<br />
Total: {pagination.total.toLocaleString()} crashes
</div>
)}
{loading && (
<div style={{ marginBottom: '8px', color: '#ffff99' }}>
Loading...
</div>
)}
{error && (
<div style={{ marginBottom: '8px', color: '#ff6666', fontSize: '12px' }}>
Error: {error}
</div>
)}
<div style={{ display: 'flex', gap: '8px' }}>
{pagination?.hasNext && (
<button
onClick={loadMore}
disabled={loading}
style={{
backgroundColor: loading ? '#666' : '#007acc',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
Load More
</button>
)}
<button
onClick={refresh}
disabled={loading}
style={{
backgroundColor: loading ? '#666' : '#28a745',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
Refresh
</button>
</div>
</div>
);
}

View File

@@ -2,28 +2,14 @@
import React, { useEffect, useRef, useState } from "react";
import mapboxgl from "mapbox-gl";
import GeocodeInput from './GeocodeInput';
interface Props {
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
profile?: "mapbox/driving" | "mapbox/walking" | "mapbox/cycling";
}
function parseLngLat(value: string): [number, number] | null {
// Accept formats like: "lng,lat" or "lat,lng" if clearly parseable
if (!value) return null;
const parts = value.split(",").map((s) => s.trim());
if (parts.length !== 2) return null;
const a = Number(parts[0]);
const b = Number(parts[1]);
if (Number.isFinite(a) && Number.isFinite(b)) {
// Heuristic: if abs(a) > 90 then assume it's lng,lat
if (Math.abs(a) > 90) return [a, b];
if (Math.abs(b) > 90) return [b, a];
// otherwise assume input is lng,lat
return [a, b];
}
return null;
}
// Routing now uses geocoder-only selection inside the sidebar (no manual coordinate parsing)
export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }: Props) {
// Sidebar supports collapse via a hamburger button in the header
@@ -34,33 +20,86 @@ 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 [pickMode, setPickMode] = useState<"origin" | "dest" | null>(null);
// custom geocoder inputs + suggestions (we implement our own UI instead of the library)
const originQueryRef = useRef<string>("");
const destQueryRef = useRef<string>("");
const [originQuery, setOriginQuery] = useState("");
const [destQuery, setDestQuery] = useState("");
const [originSuggestions, setOriginSuggestions] = useState<any[]>([]);
const [destSuggestions, setDestSuggestions] = useState<any[]>([]);
const originTimer = useRef<number | null>(null);
const destTimer = useRef<number | null>(null);
const originInputRef = useRef<HTMLDivElement | null>(null);
const destInputRef = useRef<HTMLDivElement | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
return () => { mountedRef.current = false; };
}, []);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
function onMapClick(e: mapboxgl.MapMouseEvent) {
if (!pickMode) return;
const lngLat: [number, number] = [e.lngLat.lng, e.lngLat.lat];
if (pickMode === "origin") {
setOriginCoord(lngLat);
setOriginText(`${lngLat[0].toFixed(5)}, ${lngLat[1].toFixed(5)}`);
} else {
setDestCoord(lngLat);
setDestText(`${lngLat[0].toFixed(5)}, ${lngLat[1].toFixed(5)}`);
}
setPickMode(null);
// 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);
if (!token) {
console.warn('[DirectionsSidebar] Mapbox token missing; suggestions disabled');
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}`;
try {
console.debug('[DirectionsSidebar] fetchSuggestions url=', url);
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
const feats = data.features || [];
console.debug('[DirectionsSidebar] fetchSuggestions results=', feats.length);
return feats;
} catch (e) {
console.warn('[DirectionsSidebar] fetchSuggestions error', e);
return [];
}
};
map.on("click", onMapClick as any);
return () => { if (map) map.off("click", onMapClick as any); };
}, [mapRef, pickMode]);
// debounce origin query
useEffect(() => {
if (originTimer.current) window.clearTimeout(originTimer.current);
if (!originQuery) {
setOriginSuggestions([]);
return;
}
originTimer.current = window.setTimeout(async () => {
const features = await fetchSuggestions(originQuery);
if (mountedRef.current) setOriginSuggestions(features);
}, 250) as unknown as number;
return () => { if (originTimer.current) window.clearTimeout(originTimer.current); };
}, [originQuery]);
// debounce dest query
useEffect(() => {
if (destTimer.current) window.clearTimeout(destTimer.current);
if (!destQuery) {
setDestSuggestions([]);
return;
}
destTimer.current = window.setTimeout(async () => {
const features = await fetchSuggestions(destQuery);
if (mountedRef.current) setDestSuggestions(features);
}, 250) as unknown as number;
return () => { if (destTimer.current) window.clearTimeout(destTimer.current); };
}, [destQuery]);
// when collapsed toggles, mount or unmount geocoder controls to keep DOM stable
useEffect(() => {
// if expanded, ensure the geocoder instances are attached to their containers
if (!collapsed) {
// nothing to mount for the custom inputs — they are regular DOM inputs rendered below
} else {
// nothing to clear: we are managing suggestions via state
}
}, [collapsed]);
// note: we no longer listen for map-level geocoder results here because
// the sidebar now embeds its own two geocoder controls and captures results directly.
// helper: remove existing route layers/sources
function removeRouteFromMap(map: mapboxgl.Map) {
@@ -95,13 +134,10 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
async function handleGetRoute() {
const map = mapRef.current;
if (!map) return;
let o = originCoord;
let d = destCoord;
// if coords not set but text parsable
if (!o) o = parseLngLat(originText);
if (!d) d = parseLngLat(destText);
const o = originCoord;
const d = destCoord;
if (!o || !d) {
alert("Please provide origin and destination coordinates or pick them on the map (click 'Pick on map').\nFormat: lng,lat");
alert('Please select both origin and destination using the location search boxes.');
return;
}
@@ -183,6 +219,9 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
setDestCoord(null);
setOriginText("");
setDestText("");
// clear suggestions and inputs
setOriginSuggestions([]);
setDestSuggestions([]);
}
// re-add layers after style change
@@ -276,33 +315,42 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
</button>
{/* Content — render only when expanded to avoid any collapsed 'strip' */}
{!collapsed && (
<div className="flex flex-col flex-1 p-4 overflow-auto">
<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>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0">Origin</label>
<input
className="flex-1 min-w-0 px-3 py-2 rounded-md border border-black/10 bg-transparent text-sm"
value={originText}
onChange={(e) => setOriginText(e.target.value)}
placeholder="lng,lat"
/>
<button className="ml-2 px-3 py-1 rounded-md bg-white/5 text-sm flex-shrink-0" onClick={() => setPickMode('origin')}>Pick</button>
<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>
<div className="flex-1 min-w-0">
<div className="p-1">
<GeocodeInput
mapRef={mapRef}
placeholder="Search origin"
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){} } }}
/>
</div>
<div className="mt-2 text-xs text-gray-400 truncate">{originText}</div>
</div>
</div>
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0">Destination</label>
<input
className="flex-1 min-w-0 px-3 py-2 rounded-md border border-black/10 bg-transparent text-sm"
value={destText}
onChange={(e) => setDestText(e.target.value)}
placeholder="lng,lat"
/>
<button className="ml-2 px-3 py-1 rounded-md bg-white/5 text-sm flex-shrink-0" onClick={() => setPickMode('dest')}>Pick</button>
<div className="flex items-start gap-2 min-w-0">
<label className="text-sm w-20 flex-shrink-0 pt-2">Destination</label>
<div className="flex-1 min-w-0">
<div className="p-1">
<GeocodeInput
mapRef={mapRef}
placeholder="Search destination"
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){} } }}
/>
</div>
<div className="mt-2 text-xs text-gray-400 truncate">{destText}</div>
</div>
</div>
<div className="flex gap-2 mt-2">
@@ -310,10 +358,9 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
<button onClick={handleClear} className="px-4 py-2 rounded-lg border border-black/10 bg-transparent text-sm">Clear</button>
</div>
{pickMode && <div className="text-sm">Click on the map to set {pickMode}.</div>}
{/* pick-on-map mode removed; sidebar uses geocoder-only inputs */}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import mapboxgl from "mapbox-gl";
interface Props {
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
placeholder?: string;
value?: string;
onChange?: (v: string) => void;
onSelect: (feature: any) => void;
}
export default function GeocodeInput({ mapRef, placeholder = 'Search', value = '', onChange, onSelect }: Props) {
const [query, setQuery] = useState<string>(value);
const [suggestions, setSuggestions] = useState<any[]>([]);
const timer = useRef<number | null>(null);
const mounted = useRef(true);
useEffect(() => {
return () => { mounted.current = false; };
}, []);
useEffect(() => {
if (value !== query) setQuery(value);
}, [value]);
const fetchSuggestions = async (q: string) => {
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}`;
try {
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
return data.features || [];
} catch (e) {
return [];
}
};
useEffect(() => {
if (timer.current) window.clearTimeout(timer.current);
if (!query) { setSuggestions([]); return; }
timer.current = window.setTimeout(async () => {
const feats = await fetchSuggestions(query);
if (mounted.current) setSuggestions(feats);
}, 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">
{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>
))}
</div>
)}
</div>
);
}

View File

@@ -6,21 +6,26 @@ export default function Legend() {
return (
<div style={{ position: 'absolute', left: 12, bottom: 12, zIndex: 12 }}>
<div style={{ background: 'var(--background)', color: 'var(--foreground)', padding: 8, borderRadius: 8, boxShadow: '0 6px 18px rgba(0,0,0,0.12)', border: '1px solid rgba(0,0,0,0.06)', fontSize: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 6 }}>Density legend</div>
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 6 }}>Crash Density</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<div style={{ width: 18, height: 12, background: 'rgba(0,120,48,0.0)', border: '1px solid rgba(0,0,0,0.06)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(34,139,34,0.8)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(154,205,50,0.9)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,215,0,0.95)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,140,0,0.95)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(215,25,28,1)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(0,0,0,0)', border: '1px solid rgba(0,0,0,0.06)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,255,0,0.7)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,165,0,0.8)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,69,0,0.9)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,0,0,0.95)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(139,0,0,1)' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 11 }}>Low</span>
<span style={{ fontSize: 11, fontWeight: 700 }}>High</span>
</div>
</div>
<div style={{ marginTop: 8, paddingTop: 6, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<div style={{ fontSize: 11, color: 'var(--foreground-muted)' }}>
Real DC crash data (2010+)
</div>
</div>
</div>
</div>
);

View File

@@ -3,9 +3,19 @@
import React, { useEffect, useRef, useState } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { generateDCPoints, haversine, PointFeature } from '../lib/mapUtils';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils';
import { useCrashData } from '../hooks/useCrashData';
import { CrashData } from '../api/crashes/route';
export type PopupData = { lngLat: [number, number]; mag?: number; text?: string; stats?: { count: number; avg?: number; min?: number; max?: number; radiusMeters?: number } } | null;
export type PopupData = {
lngLat: [number, number];
mag?: number;
text?: string;
crashData?: CrashData;
stats?: { count: number; avg?: number; min?: number; max?: number; radiusMeters?: number }
} | null;
interface MapViewProps {
mapStyleChoice: 'dark' | 'streets';
@@ -15,15 +25,109 @@ interface MapViewProps {
pointsVisible: boolean;
onMapReady?: (map: mapboxgl.Map) => void;
onPopupCreate?: (p: PopupData) => void; // fires when user clicks features and we want to show popup
onGeocoderResult?: (lngLat: [number, number]) => void;
useRealCrashData?: boolean; // whether to use real crash data or synthetic data
crashData?: CrashData[]; // external crash data to use
}
export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, heatVisible, pointsVisible, onMapReady, onPopupCreate }: MapViewProps) {
export default function MapView({
mapStyleChoice,
heatRadius,
heatIntensity,
heatVisible,
pointsVisible,
onMapReady,
onPopupCreate,
onGeocoderResult,
useRealCrashData = true,
crashData = []
}: MapViewProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
const styleChoiceRef = useRef<'dark' | 'streets'>(mapStyleChoice);
const [size, setSize] = useState({ width: 0, height: 0 });
const dcDataRef = useRef<GeoJSON.FeatureCollection | null>(null);
const crashDataHook = useCrashData({ autoLoad: false, limit: 10000 }); // Don't auto-load if external data provided
// Update map data when crash data is loaded
useEffect(() => {
const activeData = crashData.length > 0 ? crashData : crashDataHook.data;
console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', crashDataHook.data.length);
if (useRealCrashData && activeData.length > 0) {
console.log('Converting crash data to GeoJSON...');
dcDataRef.current = convertCrashDataToGeoJSON(activeData);
// Update the map source if map is ready
const map = mapRef.current;
if (map && map.isStyleLoaded()) {
console.log('Updating map source with new data...');
if (map.getSource('dc-quakes')) {
(map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current);
} else {
console.log('Source not found, calling addDataAndLayers');
// Call the inner function manually - we need to recreate it here
if (dcDataRef.current) {
console.log('Adding data and layers, data has', dcDataRef.current.features.length, 'features');
if (!map.getSource('dc-quakes')) {
console.log('Creating new source');
map.addSource('dc-quakes', { type: 'geojson', data: dcDataRef.current });
}
// Add layers if they don't exist
if (!map.getLayer('dc-heat')) {
map.addLayer({
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', maxzoom: 15,
paint: {
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
'heatmap-intensity': heatIntensity,
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(0,0,0,0)',
0.2, 'rgba(255,255,0,0.7)',
0.4, 'rgba(255,165,0,0.8)',
0.6, 'rgba(255,69,0,0.9)',
0.8, 'rgba(255,0,0,0.95)',
1, 'rgba(139,0,0,1)'
],
'heatmap-radius': heatRadius,
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 12, 0.8]
}
});
}
if (!map.getLayer('dc-point')) {
map.addLayer({
id: 'dc-point', type: 'circle', source: 'dc-quakes', minzoom: 12,
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 10],
'circle-color': [
'interpolate',
['linear'],
['get', 'mag'],
1, styleChoiceRef.current === 'dark' ? '#ffff99' : '#ffa500',
3, styleChoiceRef.current === 'dark' ? '#ff6666' : '#ff4500',
6, styleChoiceRef.current === 'dark' ? '#ff0000' : '#8b0000'
] as any,
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0.7, 14, 0.9],
'circle-stroke-width': 1,
'circle-stroke-color': styleChoiceRef.current === 'dark' ? '#ffffff' : '#000000'
}
});
}
// Update layer visibility
if (map.getLayer('dc-heat')) {
map.setLayoutProperty('dc-heat', 'visibility', heatVisible ? 'visible' : 'none');
}
if (map.getLayer('dc-point')) {
map.setLayoutProperty('dc-point', 'visibility', pointsVisible ? 'visible' : 'none');
}
}
}
} else {
console.log('Map style not loaded yet');
}
}
}, [useRealCrashData, crashDataHook.data, crashData, heatRadius, heatIntensity, heatVisible, pointsVisible]);
useEffect(() => {
const el = containerRef.current;
@@ -56,8 +160,19 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
styleChoiceRef.current = mapStyleChoice;
// if the dc-point layer exists, update its circle-color to match the style
if (map.getLayer && map.getLayer('dc-point')) {
const color = mapStyleChoice === 'dark' ? '#ffffff' : '#000000';
try { map.setPaintProperty('dc-point', 'circle-color', color); } catch (e) {}
const colorExpression = [
'interpolate',
['linear'],
['get', 'mag'],
1, mapStyleChoice === 'dark' ? '#ffff99' : '#ffa500',
3, mapStyleChoice === 'dark' ? '#ff6666' : '#ff4500',
6, mapStyleChoice === 'dark' ? '#ff0000' : '#8b0000'
] as any;
const strokeColor = mapStyleChoice === 'dark' ? '#ffffff' : '#000000';
try {
map.setPaintProperty('dc-point', 'circle-color', colorExpression);
map.setPaintProperty('dc-point', 'circle-stroke-color', strokeColor);
} catch (e) {}
}
}, [mapStyleChoice]);
@@ -84,7 +199,22 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
mapRef.current = new mapboxgl.Map({ container: mapEl, style: styleUrl, center: [-77.0369, 38.9072], zoom: 11, maxBounds: dcBounds });
const map = mapRef.current;
if (!dcDataRef.current) dcDataRef.current = generateDCPoints(900);
// NOTE: geocoder control intentionally removed from map-level UI.
// The sidebar provides embedded geocoder inputs; keeping both leads to duplicate controls.
// Initialize data based on preference
const activeData = crashData.length > 0 ? crashData : crashDataHook.data;
console.log('Initializing map data, activeData length:', activeData.length);
if (useRealCrashData && activeData.length > 0) {
console.log('Using real crash data');
dcDataRef.current = convertCrashDataToGeoJSON(activeData);
} else if (!useRealCrashData) {
console.log('Using synthetic data');
dcDataRef.current = generateDCPoints(900);
} else {
console.log('No data available yet, using empty data');
dcDataRef.current = { type: 'FeatureCollection' as const, features: [] };
}
const computeNearbyStats = (center: [number, number], radiusMeters = 500) => {
const data = dcDataRef.current;
@@ -101,11 +231,18 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
};
const addDataAndLayers = () => {
if (!map || !dcDataRef.current) return;
if (!map || !dcDataRef.current) {
console.log('addDataAndLayers: map or data not ready', !!map, !!dcDataRef.current);
return;
}
console.log('Adding data and layers, data has', dcDataRef.current.features.length, 'features');
if (!map.getSource('dc-quakes')) {
console.log('Creating new source');
map.addSource('dc-quakes', { type: 'geojson', data: dcDataRef.current });
} else {
console.log('Updating existing source');
(map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current);
}
@@ -115,7 +252,17 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
paint: {
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
'heatmap-intensity': heatIntensity,
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0,120,48,0)', 0.2, 'rgba(34,139,34,0.8)', 0.4, 'rgba(154,205,50,0.9)', 0.6, 'rgba(255,215,0,0.95)', 0.8, 'rgba(255,140,0,0.95)', 1, 'rgba(215,25,28,1)'],
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(0,0,0,0)',
0.2, 'rgba(255,255,0,0.7)',
0.4, 'rgba(255,165,0,0.8)',
0.6, 'rgba(255,69,0,0.9)',
0.8, 'rgba(255,0,0,0.95)',
1, 'rgba(139,0,0,1)'
],
'heatmap-radius': heatRadius,
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 12, 0.8]
}
@@ -126,9 +273,18 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
map.addLayer({
id: 'dc-point', type: 'circle', source: 'dc-quakes', minzoom: 12,
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 2, 6, 8],
'circle-color': styleChoiceRef.current === 'dark' ? '#ffffff' : '#a9a9a9',
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0, 14, 1]
'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 10],
'circle-color': [
'interpolate',
['linear'],
['get', 'mag'],
1, styleChoiceRef.current === 'dark' ? '#ffff99' : '#ffa500',
3, styleChoiceRef.current === 'dark' ? '#ff6666' : '#ff4500',
6, styleChoiceRef.current === 'dark' ? '#ff0000' : '#8b0000'
],
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0.7, 14, 0.9],
'circle-stroke-width': 1,
'circle-stroke-color': styleChoiceRef.current === 'dark' ? '#ffffff' : '#000000'
}
});
}
@@ -144,6 +300,7 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
};
map.on('load', () => {
console.log('Map loaded, adding initial data and layers');
addDataAndLayers();
// ensure map is fit to DC bounds initially
try { map.fitBounds(dcBounds, { padding: 20 }); } catch (e) { /* ignore if fitBounds fails */ }
@@ -153,8 +310,20 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
if (!feature) return;
const coords = (feature.geometry as any).coordinates.slice() as [number, number];
const mag = feature.properties ? feature.properties.mag : undefined;
const crashData = feature.properties ? feature.properties.crashData : undefined;
const stats = computeNearbyStats(coords, 500);
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, text: `Magnitude: ${mag ?? 'N/A'}`, stats });
let text = `Severity: ${mag ?? 'N/A'}`;
if (crashData) {
text = `Crash Report
Date: ${new Date(crashData.reportDate).toLocaleDateString()}
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}`;
}
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
});
map.on('click', 'dc-heat', (e) => {
@@ -165,11 +334,23 @@ export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, hea
const f = nearby[0];
const coords = (f.geometry as any).coordinates.slice() as [number, number];
const mag = f.properties ? f.properties.mag : undefined;
const crashData = f.properties ? f.properties.crashData : undefined;
const stats = computeNearbyStats(coords, 500);
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, text: `Magnitude: ${mag ?? 'N/A'}`, stats });
let text = `Severity: ${mag ?? 'N/A'}`;
if (crashData) {
text = `Crash Report
Date: ${new Date(crashData.reportDate).toLocaleDateString()}
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}`;
}
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
} else {
const stats = computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 500);
if (onPopupCreate) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual points and details', stats });
if (onPopupCreate) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual crash reports and details', stats });
}
});

View File

@@ -194,3 +194,105 @@ body {
/* Directions sidebar (left-side, full-height, collapsible) */
/* Directions sidebar styling is now handled via Tailwind classes in the component. Legacy CSS removed. */
/* MapboxGeocoder styling override scoped to the directions sidebar */
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder {
background: transparent;
padding: 0;
}
.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);
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);
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder input[type="text"]:focus,
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--input:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(255, 126, 95, 0.12);
border-color: rgba(255,126,95,0.6);
}
.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);
border-radius: 0.5rem;
overflow: hidden;
}
.directions-sidebar-geocoder .custom-suggestions { z-index: 60; }
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder--suggestions .suggestion,
.directions-sidebar-geocoder .suggestions .suggestion {
padding: 0.5rem 0.75rem;
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder--suggestions .suggestion:hover,
.directions-sidebar-geocoder .suggestions .suggestion:hover {
background: rgba(255,255,255,0.02);
}
/* 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);
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
width: 100%;
box-sizing: border-box;
}
.directions-sidebar-geocoder .custom-suggestions button {
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
}
.directions-sidebar-geocoder .custom-suggestions button:hover { background: rgba(255,255,255,0.03); }
/* hide the magnifying/search icon inside the embedded geocoder input */
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon {
display: none !important;
}
/* make the geocoder control and input expand to the container width */
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder {
width: 100%;
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--input {
width: 100% !important;
}
/* Strong overrides to ensure the geocoder input fits into the sidebar layout */
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
box-sizing: border-box !important;
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder input[type="text"],
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--input {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
padding: 0.5rem 0.85rem !important;
font-size: 0.95rem !important;
line-height: 1.2 !important;
margin: 0 !important;
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon,
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--button {
/* hide icon and buttons that overlap the input; keep clear if needed */
display: none !important;
}
.directions-sidebar-geocoder .mapboxgl-ctrl-geocoder--suggestions {
width: calc(100% - 0px) !important;
box-sizing: border-box !important;
}

View File

@@ -0,0 +1,87 @@
import { useState, useEffect, useCallback } from 'react';
import { CrashData, CrashResponse } from '../api/crashes/route';
export interface UseCrashDataOptions {
autoLoad?: boolean;
limit?: number;
}
export interface UseCrashDataResult {
data: CrashData[];
loading: boolean;
error: string | null;
pagination: CrashResponse['pagination'] | null;
loadPage: (page: number) => Promise<void>;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
}
export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataResult {
const { autoLoad = true, limit = 100 } = 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 fetchCrashData = useCallback(async (page: number, append: boolean = false) => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/crashes?page=${page}&limit=${limit}`);
if (!response.ok) {
throw new Error(`Failed to fetch crash data: ${response.statusText}`);
}
const result: CrashResponse = await response.json();
if (append) {
setData(prevData => [...prevData, ...result.data]);
} else {
setData(result.data);
}
setPagination(result.pagination);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch crash data';
setError(errorMessage);
console.error('Error fetching crash data:', err);
} finally {
setLoading(false);
}
}, [limit]);
const loadPage = useCallback((page: number) => {
return fetchCrashData(page, false);
}, [fetchCrashData]);
const loadMore = useCallback(() => {
if (!pagination || !pagination.hasNext || loading) {
return Promise.resolve();
}
return fetchCrashData(pagination.page + 1, true);
}, [pagination, loading, fetchCrashData]);
const refresh = useCallback(() => {
return fetchCrashData(1, false);
}, [fetchCrashData]);
// Auto-load first page on mount
useEffect(() => {
if (autoLoad) {
loadPage(1);
}
}, [autoLoad, loadPage]);
return {
data,
loading,
error,
pagination,
loadPage,
loadMore,
refresh,
};
}

View File

@@ -1,4 +1,6 @@
export type PointFeature = GeoJSON.Feature<GeoJSON.Point, { mag: number }>;
import { CrashData } from '../api/crashes/route';
export type PointFeature = GeoJSON.Feature<GeoJSON.Point, { mag: number; crashData: CrashData }>;
export const haversine = (a: [number, number], b: [number, number]) => {
const toRad = (v: number) => v * Math.PI / 180;
@@ -14,6 +16,42 @@ export const haversine = (a: [number, number], b: [number, number]) => {
return R * c;
};
export const convertCrashDataToGeoJSON = (crashes: CrashData[]): GeoJSON.FeatureCollection => {
console.log('Converting crash data to GeoJSON:', crashes.length, 'crashes');
console.log('Sample crash data:', crashes[0]);
const features: PointFeature[] = crashes.map((crash) => {
// Calculate severity score based on fatalities and major injuries
const severityScore = Math.max(1,
(crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 3 +
(crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 2 +
(crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles)
);
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [crash.longitude, crash.latitude]
},
properties: {
mag: Math.min(6, severityScore), // Cap at 6 for consistent visualization
crashData: crash
}
};
});
const geoJSON = {
type: 'FeatureCollection' as const,
features
};
console.log('Generated GeoJSON with', features.length, 'features');
console.log('Sample feature:', features[0]);
return geoJSON;
};
export const generateDCPoints = (count = 500) => {
const center = { lon: -77.0369, lat: 38.9072 };
const features: PointFeature[] = [];
@@ -31,7 +69,32 @@ export const generateDCPoints = (count = 500) => {
const lon = center.lon + Math.cos(angle) * radius;
const lat = center.lat + Math.sin(angle) * radius;
const mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6));
features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] }, properties: { mag } });
// Create synthetic crash data for backward compatibility
const syntheticCrash: CrashData = {
id: `synthetic-${i}`,
latitude: lat,
longitude: lon,
reportDate: new Date().toISOString(),
address: `Synthetic Location ${i}`,
ward: 'Ward 1',
totalVehicles: Math.floor(Math.random() * 3) + 1,
totalPedestrians: Math.floor(Math.random() * 2),
totalBicycles: Math.floor(Math.random() * 2),
fatalDriver: 0,
fatalPedestrian: 0,
fatalBicyclist: 0,
majorInjuriesDriver: Math.floor(Math.random() * 2),
majorInjuriesPedestrian: 0,
majorInjuriesBicyclist: 0,
speedingInvolved: Math.floor(Math.random() * 2),
};
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { mag, crashData: syntheticCrash }
});
}
return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection<GeoJSON.Geometry>;

View File

@@ -7,6 +7,8 @@ import PopupOverlay from './components/PopupOverlay';
import Legend from './components/Legend';
import MapNavigationControl from './components/MapNavigationControl';
import DirectionsSidebar from './components/DirectionsSidebar';
import CrashDataControls from './components/CrashDataControls';
import { useCrashData } from './hooks/useCrashData';
export default function Home() {
const mapRef = useRef<any>(null);
@@ -20,44 +22,53 @@ export default function Home() {
});
const [popup, setPopup] = useState<PopupData>(null);
const [popupVisible, setPopupVisible] = useState(false);
// Shared crash data state
const crashDataHook = useCrashData({ autoLoad: true, limit: 10000 });
return (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'row' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<DirectionsSidebar mapRef={mapRef} profile="mapbox/driving" />
</div>
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
{/* Render sidebar as an overlay inside the map container so collapsing doesn't shift layout */}
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', zIndex: 40, pointerEvents: 'auto' }}>
<DirectionsSidebar mapRef={mapRef} profile="mapbox/driving" />
</div>
<ControlsPanel
panelOpen={panelOpen}
onTogglePanel={(next) => { setPanelOpen(next); try { window.localStorage.setItem('map_panel_open', next ? '1' : '0'); } catch (e) {} }}
mapStyleChoice={mapStyleChoice}
onChangeStyle={(v) => setMapStyleChoice(v)}
heatVisible={heatVisible}
onToggleHeat={(v) => setHeatVisible(v)}
pointsVisible={pointsVisible}
onTogglePoints={(v) => setPointsVisible(v)}
heatRadius={heatRadius}
onChangeRadius={(v) => setHeatRadius(v)}
heatIntensity={heatIntensity}
onChangeIntensity={(v) => setHeatIntensity(v)}
/>
panelOpen={panelOpen}
onTogglePanel={(next) => { setPanelOpen(next); try { window.localStorage.setItem('map_panel_open', next ? '1' : '0'); } catch (e) {} }}
mapStyleChoice={mapStyleChoice}
onChangeStyle={(v) => setMapStyleChoice(v)}
heatVisible={heatVisible}
onToggleHeat={(v) => setHeatVisible(v)}
pointsVisible={pointsVisible}
onTogglePoints={(v) => setPointsVisible(v)}
heatRadius={heatRadius}
onChangeRadius={(v) => setHeatRadius(v)}
heatIntensity={heatIntensity}
onChangeIntensity={(v) => setHeatIntensity(v)}
/>
<MapView
mapStyleChoice={mapStyleChoice}
heatRadius={heatRadius}
heatIntensity={heatIntensity}
heatVisible={heatVisible}
pointsVisible={pointsVisible}
onMapReady={(m) => { mapRef.current = m; }}
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
/>
mapStyleChoice={mapStyleChoice}
heatRadius={heatRadius}
heatIntensity={heatIntensity}
heatVisible={heatVisible}
pointsVisible={pointsVisible}
useRealCrashData={true}
crashData={crashDataHook.data}
onMapReady={(m) => { mapRef.current = m; }}
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
/>
{/* Native Mapbox navigation control (zoom + compass) */}
<MapNavigationControl mapRef={mapRef} position="top-right" />
{/* Crash data loading controls */}
<CrashDataControls crashDataHook={crashDataHook} />
<Legend />
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
</div>
</div>
);
}
}

19
web/src/types/mapbox-gl-geocoder.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
declare module '@mapbox/mapbox-gl-geocoder' {
import mapboxgl from 'mapbox-gl';
interface GeocoderOptions {
accessToken?: string;
mapboxgl?: typeof mapboxgl;
placeholder?: string;
bbox?: number[];
proximity?: { longitude: number; latitude: number };
countries?: string | string[];
types?: string | string[];
minLength?: number;
}
class MapboxGeocoder {
constructor(options?: GeocoderOptions);
on(event: string, cb: (ev: any) => void): this;
off(event: string, cb: (ev: any) => void): this;
}
export default MapboxGeocoder;
}