Merge branch 'main' of github.com:SirBlobby/VTHacks13
This commit is contained in:
@@ -62,6 +62,26 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
border: '1px solid rgba(64, 64, 64, 0.5)', // Add subtle border
|
border: '1px solid rgba(64, 64, 64, 0.5)', // Add subtle border
|
||||||
boxShadow: '0 6px 18px rgba(0,0,0,0.15)' // Match map controls shadow
|
boxShadow: '0 6px 18px rgba(0,0,0,0.15)' // Match map controls shadow
|
||||||
}}>
|
}}>
|
||||||
|
{/* Crash Density Legend */}
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 700, marginBottom: '8px' }}>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,0,0,0)', border: '1px solid rgba(128, 128, 128, 0.5)' }} />
|
||||||
|
<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, color: '#ccc' }}>Low</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#ccc' }}>High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: '1px solid rgba(64, 64, 64, 0.5)', marginTop: '8px', paddingTop: '8px' }}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '8px', fontWeight: 700, fontSize: '14px' }}>
|
<div style={{ marginBottom: '8px', fontWeight: 700, fontSize: '14px' }}>
|
||||||
Crash Data Status
|
Crash Data Status
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +116,7 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
{yearFilter && ` (${yearFilter})`}
|
{yearFilter && ` (${yearFilter})`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && (
|
{pagination && !yearFilter && (
|
||||||
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
<br />
|
<br />
|
||||||
@@ -104,6 +124,12 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{pagination && yearFilter && (
|
||||||
|
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
||||||
|
All crashes for {yearFilter} loaded
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ marginBottom: '8px', color: '#ffff99' }}>
|
<div style={{ marginBottom: '8px', color: '#ffff99' }}>
|
||||||
Loading...
|
Loading...
|
||||||
@@ -117,7 +143,7 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
{pagination?.hasNext && (
|
{pagination?.hasNext && !yearFilter && (
|
||||||
<button
|
<button
|
||||||
onClick={loadMore}
|
onClick={loadMore}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { calculateRouteCrashDensity, createRouteGradientStops } from '../lib/map
|
|||||||
interface Props {
|
interface Props {
|
||||||
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
|
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
|
||||||
profile?: "mapbox/driving" | "mapbox/walking" | "mapbox/cycling";
|
profile?: "mapbox/driving" | "mapbox/walking" | "mapbox/cycling";
|
||||||
|
onMapPickingModeChange?: (isActive: boolean) => void; // callback when map picking mode changes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routing now uses geocoder-only selection inside the sidebar (no manual coordinate parsing)
|
// Routing now uses geocoder-only selection inside the sidebar (no manual coordinate parsing)
|
||||||
|
|
||||||
export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }: Props) {
|
export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", onMapPickingModeChange }: Props) {
|
||||||
// Sidebar supports collapse via a hamburger button in the header
|
// Sidebar supports collapse via a hamburger button in the header
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -46,6 +47,13 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
|
|||||||
return () => { mountedRef.current = false; };
|
return () => { mountedRef.current = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Notify parent when map picking mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onMapPickingModeChange) {
|
||||||
|
onMapPickingModeChange(isOriginMapPicking || isDestMapPicking);
|
||||||
|
}
|
||||||
|
}, [isOriginMapPicking, isDestMapPicking, onMapPickingModeChange]);
|
||||||
|
|
||||||
// Handle map clicks for point selection
|
// Handle map clicks for point selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -55,19 +63,31 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
|
|||||||
const { lng, lat } = e.lngLat;
|
const { lng, lat } = e.lngLat;
|
||||||
|
|
||||||
if (isOriginMapPicking) {
|
if (isOriginMapPicking) {
|
||||||
|
// Prevent other map click handlers from running
|
||||||
|
if (e.originalEvent) {
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
e.originalEvent.stopImmediatePropagation();
|
||||||
|
}
|
||||||
setOriginCoord([lng, lat]);
|
setOriginCoord([lng, lat]);
|
||||||
setOriginText(`Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
setOriginText(`Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
||||||
setOriginQuery(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
setOriginQuery(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
||||||
setIsOriginMapPicking(false);
|
setIsOriginMapPicking(false);
|
||||||
// Center map on selected point
|
// Center map on selected point
|
||||||
map.easeTo({ center: [lng, lat], zoom: 14 });
|
map.easeTo({ center: [lng, lat], zoom: 14 });
|
||||||
|
return;
|
||||||
} else if (isDestMapPicking) {
|
} else if (isDestMapPicking) {
|
||||||
|
// Prevent other map click handlers from running
|
||||||
|
if (e.originalEvent) {
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
e.originalEvent.stopImmediatePropagation();
|
||||||
|
}
|
||||||
setDestCoord([lng, lat]);
|
setDestCoord([lng, lat]);
|
||||||
setDestText(`Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
setDestText(`Selected: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
||||||
setDestQuery(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
setDestQuery(`${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
||||||
setIsDestMapPicking(false);
|
setIsDestMapPicking(false);
|
||||||
// Center map on selected point
|
// Center map on selected point
|
||||||
map.easeTo({ center: [lng, lat], zoom: 14 });
|
map.easeTo({ center: [lng, lat], zoom: 14 });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import React from 'react';
|
|||||||
|
|
||||||
export default function Legend() {
|
export default function Legend() {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'absolute', left: 12, bottom: 12, zIndex: 12 }}>
|
<div style={{ position: 'absolute', bottom: '580px', right: '12px', zIndex: 30 }}>
|
||||||
<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={{
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 6 }}>Crash Density</div>
|
backgroundColor: 'rgba(26, 26, 26, 0.95)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
boxShadow: '0 6px 18px rgba(0,0,0,0.15)',
|
||||||
|
border: '1px solid rgba(64, 64, 64, 0.5)',
|
||||||
|
fontSize: '13px',
|
||||||
|
width: '240px',
|
||||||
|
backdropFilter: 'blur(8px)'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 700, marginBottom: '8px' }}>Crash Density</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<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(0,0,0,0)', border: '1px solid rgba(128, 128, 128, 0.5)' }} />
|
||||||
<div style={{ width: 18, height: 12, background: 'rgba(255,255,0,0.7)' }} />
|
<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,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,69,0,0.9)' }} />
|
||||||
@@ -17,13 +27,13 @@ export default function Legend() {
|
|||||||
<div style={{ width: 18, height: 12, background: 'rgba(139,0,0,1)' }} />
|
<div style={{ width: 18, height: 12, background: 'rgba(139,0,0,1)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<span style={{ fontSize: 11 }}>Low</span>
|
<span style={{ fontSize: 11, color: '#ccc' }}>Low</span>
|
||||||
<span style={{ fontSize: 11, fontWeight: 700 }}>High</span>
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#ccc' }}>High</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 8, paddingTop: 6, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
<div style={{ marginTop: 8, paddingTop: 6, borderTop: '1px solid rgba(64, 64, 64, 0.5)' }}>
|
||||||
<div style={{ fontSize: 11, color: 'var(--foreground-muted)' }}>
|
<div style={{ fontSize: 11, color: '#9ca3af' }}>
|
||||||
Real DC crash data (2010+)
|
Real DC crash data (2020+)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'mapbox-gl/dist/mapbox-gl.css';
|
|||||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||||
import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils';
|
import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils';
|
||||||
import { useCrashData } from '../hooks/useCrashData';
|
import { useCrashData, UseCrashDataResult } from '../hooks/useCrashData';
|
||||||
import { CrashData } from '../api/crashes/route';
|
import { CrashData } from '../api/crashes/route';
|
||||||
|
|
||||||
export type PopupData = {
|
export type PopupData = {
|
||||||
@@ -41,6 +41,8 @@ interface MapViewProps {
|
|||||||
onGeocoderResult?: (lngLat: [number, number]) => void;
|
onGeocoderResult?: (lngLat: [number, number]) => void;
|
||||||
useRealCrashData?: boolean; // whether to use real crash data or synthetic data
|
useRealCrashData?: boolean; // whether to use real crash data or synthetic data
|
||||||
crashData?: CrashData[]; // external crash data to use
|
crashData?: CrashData[]; // external crash data to use
|
||||||
|
crashDataHook?: UseCrashDataResult; // the crash data hook from main page
|
||||||
|
isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
@@ -53,20 +55,29 @@ export default function MapView({
|
|||||||
onPopupCreate,
|
onPopupCreate,
|
||||||
onGeocoderResult,
|
onGeocoderResult,
|
||||||
useRealCrashData = true,
|
useRealCrashData = true,
|
||||||
crashData = []
|
crashData = [],
|
||||||
|
crashDataHook,
|
||||||
|
isMapPickingMode = false
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||||
const styleChoiceRef = useRef<'dark' | 'streets'>(mapStyleChoice);
|
const styleChoiceRef = useRef<'dark' | 'streets'>(mapStyleChoice);
|
||||||
|
const isMapPickingModeRef = useRef<boolean>(isMapPickingMode);
|
||||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
const dcDataRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
const dcDataRef = useRef<GeoJSON.FeatureCollection | null>(null);
|
||||||
const crashDataHook = useCrashData({ autoLoad: false, limit: 10000 }); // Don't auto-load if external data provided
|
const internalCrashDataHook = useCrashData({ autoLoad: false, limit: 10000 }); // Don't auto-load if external data provided
|
||||||
|
|
||||||
|
// Update isMapPickingMode ref when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
isMapPickingModeRef.current = isMapPickingMode;
|
||||||
|
}, [isMapPickingMode]);
|
||||||
|
|
||||||
// Update map data when crash data is loaded
|
// Update map data when crash data is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeData = crashData.length > 0 ? crashData : crashDataHook.data;
|
const currentCrashDataHook = crashDataHook || internalCrashDataHook;
|
||||||
console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', crashDataHook.data.length);
|
const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data;
|
||||||
|
console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', currentCrashDataHook.data.length);
|
||||||
if (useRealCrashData && activeData.length > 0) {
|
if (useRealCrashData && activeData.length > 0) {
|
||||||
console.log('Converting crash data to GeoJSON...');
|
console.log('Converting crash data to GeoJSON...');
|
||||||
dcDataRef.current = convertCrashDataToGeoJSON(activeData);
|
dcDataRef.current = convertCrashDataToGeoJSON(activeData);
|
||||||
@@ -88,7 +99,7 @@ export default function MapView({
|
|||||||
// Add layers if they don't exist
|
// Add layers if they don't exist
|
||||||
if (!map.getLayer('dc-heat')) {
|
if (!map.getLayer('dc-heat')) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', maxzoom: 15,
|
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes',
|
||||||
paint: {
|
paint: {
|
||||||
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
|
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
|
||||||
'heatmap-intensity': heatIntensity,
|
'heatmap-intensity': heatIntensity,
|
||||||
@@ -140,7 +151,7 @@ export default function MapView({
|
|||||||
console.log('Map style not loaded yet');
|
console.log('Map style not loaded yet');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [useRealCrashData, crashDataHook.data, crashData, heatRadius, heatIntensity, heatVisible, pointsVisible]);
|
}, [useRealCrashData, crashDataHook?.data, crashData, heatRadius, heatIntensity, heatVisible, pointsVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -216,7 +227,8 @@ export default function MapView({
|
|||||||
// The sidebar provides embedded geocoder inputs; keeping both leads to duplicate controls.
|
// The sidebar provides embedded geocoder inputs; keeping both leads to duplicate controls.
|
||||||
|
|
||||||
// Initialize data based on preference
|
// Initialize data based on preference
|
||||||
const activeData = crashData.length > 0 ? crashData : crashDataHook.data;
|
const currentCrashDataHook = crashDataHook || internalCrashDataHook;
|
||||||
|
const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data;
|
||||||
console.log('Initializing map data, activeData length:', activeData.length);
|
console.log('Initializing map data, activeData length:', activeData.length);
|
||||||
if (useRealCrashData && activeData.length > 0) {
|
if (useRealCrashData && activeData.length > 0) {
|
||||||
console.log('Using real crash data');
|
console.log('Using real crash data');
|
||||||
@@ -232,11 +244,61 @@ export default function MapView({
|
|||||||
const computeNearbyStats = async (center: [number, number], radiusMeters = 300) => {
|
const computeNearbyStats = async (center: [number, number], radiusMeters = 300) => {
|
||||||
try {
|
try {
|
||||||
const [lng, lat] = center;
|
const [lng, lat] = center;
|
||||||
|
|
||||||
|
// Use the already filtered crash data instead of making a new API call
|
||||||
|
// Check both crashData prop and crashDataHook data
|
||||||
|
const currentCrashDataHook = crashDataHook || internalCrashDataHook;
|
||||||
|
const activeData = (crashData && crashData.length > 0) ? crashData :
|
||||||
|
(currentCrashDataHook.data && currentCrashDataHook.data.length > 0) ? currentCrashDataHook.data :
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (activeData && activeData.length > 0) {
|
||||||
|
|
||||||
|
// Filter crashes within the radius using Haversine formula
|
||||||
|
const nearbyCrashes = activeData.filter((crash: any) => {
|
||||||
|
if (!crash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for different possible property names for coordinates
|
||||||
|
const crashLat = crash.latitude || crash.lat;
|
||||||
|
const crashLng = crash.longitude || crash.lng || crash.lon;
|
||||||
|
|
||||||
|
if (typeof crashLat !== 'number' || typeof crashLng !== 'number') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = calculateDistance(lat, lng, crashLat, crashLng);
|
||||||
|
return distance <= radiusMeters;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nearbyCrashes.length === 0) {
|
||||||
|
return { count: 0, radiusMeters };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count by severity type using filtered data
|
||||||
|
const severityCounts = {
|
||||||
|
fatal: nearbyCrashes.filter((c: any) => c.severity === 'Fatal').length,
|
||||||
|
majorInjury: nearbyCrashes.filter((c: any) => c.severity === 'Major Injury').length,
|
||||||
|
minorInjury: nearbyCrashes.filter((c: any) => c.severity === 'Minor Injury').length,
|
||||||
|
propertyOnly: nearbyCrashes.filter((c: any) => c.severity === 'Property Damage Only').length
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: nearbyCrashes.length,
|
||||||
|
radiusMeters,
|
||||||
|
severityCounts,
|
||||||
|
crashes: nearbyCrashes.slice(0, 5)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no client-side data available, fall back to API call (but this should not happen now)
|
||||||
|
console.log('FALLBACK: No filtered data available, using API call');
|
||||||
const response = await fetch(`/api/crashes/nearby?lng=${lng}&lat=${lat}&radius=${radiusMeters}&limit=1000`);
|
const response = await fetch(`/api/crashes/nearby?lng=${lng}&lat=${lat}&radius=${radiusMeters}&limit=1000`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn('Failed to fetch nearby crash data:', response.status);
|
console.warn('Failed to fetch nearby crash data:', response.status);
|
||||||
return { count: 0 };
|
return { count: 0, radiusMeters };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -258,24 +320,6 @@ export default function MapView({
|
|||||||
return { count: 0, radiusMeters };
|
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
|
// Count by severity type
|
||||||
const severityCounts = {
|
const severityCounts = {
|
||||||
fatal: validCrashes.filter((c: any) => c.severity === 'Fatal').length,
|
fatal: validCrashes.filter((c: any) => c.severity === 'Fatal').length,
|
||||||
@@ -286,12 +330,9 @@ export default function MapView({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
count: validCrashes.length,
|
count: validCrashes.length,
|
||||||
avg,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
radiusMeters,
|
radiusMeters,
|
||||||
severityCounts,
|
severityCounts,
|
||||||
crashes: validCrashes.slice(0, 5) // Include first 5 crashes for detailed info
|
crashes: validCrashes.slice(0, 5)
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error computing nearby stats:', error);
|
console.error('Error computing nearby stats:', error);
|
||||||
@@ -299,6 +340,22 @@ export default function MapView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to calculate distance between two coordinates using Haversine formula
|
||||||
|
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
|
||||||
|
const R = 6371e3; // Earth's radius in meters
|
||||||
|
const φ1 = lat1 * Math.PI / 180;
|
||||||
|
const φ2 = lat2 * Math.PI / 180;
|
||||||
|
const Δφ = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const Δλ = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
|
||||||
|
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
|
||||||
|
Math.cos(φ1) * Math.cos(φ2) *
|
||||||
|
Math.sin(Δλ/2) * Math.sin(Δλ/2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||||
|
|
||||||
|
return R * c; // Distance in meters
|
||||||
|
};
|
||||||
|
|
||||||
const addDataAndLayers = () => {
|
const addDataAndLayers = () => {
|
||||||
if (!map || !dcDataRef.current) {
|
if (!map || !dcDataRef.current) {
|
||||||
console.log('addDataAndLayers: map or data not ready', !!map, !!dcDataRef.current);
|
console.log('addDataAndLayers: map or data not ready', !!map, !!dcDataRef.current);
|
||||||
@@ -317,7 +374,7 @@ export default function MapView({
|
|||||||
|
|
||||||
if (!map.getLayer('dc-heat')) {
|
if (!map.getLayer('dc-heat')) {
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', maxzoom: 15,
|
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes',
|
||||||
paint: {
|
paint: {
|
||||||
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
|
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
|
||||||
'heatmap-intensity': heatIntensity,
|
'heatmap-intensity': heatIntensity,
|
||||||
@@ -375,6 +432,10 @@ export default function MapView({
|
|||||||
try { map.fitBounds(dcBounds, { padding: 20 }); } catch (e) { /* ignore if fitBounds fails */ }
|
try { map.fitBounds(dcBounds, { padding: 20 }); } catch (e) { /* ignore if fitBounds fails */ }
|
||||||
|
|
||||||
map.on('click', 'dc-point', async (e) => {
|
map.on('click', 'dc-point', async (e) => {
|
||||||
|
if (isMapPickingModeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const feature = e.features && e.features[0];
|
const feature = e.features && e.features[0];
|
||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
|
|
||||||
@@ -403,10 +464,14 @@ Fatalities: ${(crashData.fatalDriver || 0) + (crashData.fatalPedestrian || 0) +
|
|||||||
Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjuriesPedestrian || 0) + (crashData.majorInjuriesBicyclist || 0)}`;
|
Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjuriesPedestrian || 0) + (crashData.majorInjuriesBicyclist || 0)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
|
if (onPopupCreate && !isMapPickingMode) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on('click', 'dc-heat', async (e) => {
|
map.on('click', 'dc-heat', async (e) => {
|
||||||
|
if (isMapPickingModeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const p = e.point;
|
const p = e.point;
|
||||||
const bbox = [[p.x - 6, p.y - 6], [p.x + 6, p.y + 6]] as [mapboxgl.PointLike, mapboxgl.PointLike];
|
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'] });
|
const nearby = map.queryRenderedFeatures(bbox, { layers: ['dc-point'] });
|
||||||
@@ -421,7 +486,7 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
|||||||
coords[0] === 0 || coords[1] === 0) {
|
coords[0] === 0 || coords[1] === 0) {
|
||||||
console.warn('Invalid coordinates for heat map click:', coords);
|
console.warn('Invalid coordinates for heat map click:', coords);
|
||||||
const stats = await computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 300);
|
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 });
|
if (onPopupCreate && !isMapPickingMode) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual crash reports and details', stats });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,10 +504,10 @@ Fatalities: ${(crashData.fatalDriver || 0) + (crashData.fatalPedestrian || 0) +
|
|||||||
Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjuriesPedestrian || 0) + (crashData.majorInjuriesBicyclist || 0)}`;
|
Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjuriesPedestrian || 0) + (crashData.majorInjuriesBicyclist || 0)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
|
if (onPopupCreate && !isMapPickingMode) onPopupCreate({ lngLat: coords, mag, crashData, text, stats });
|
||||||
} else {
|
} else {
|
||||||
const stats = await computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 300);
|
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 });
|
if (onPopupCreate && !isMapPickingMode) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual crash reports and details', stats });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -478,7 +543,7 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
|||||||
detailedText = `Detailed Analysis - ${crashData.address}`;
|
detailedText = `Detailed Analysis - ${crashData.address}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onPopupCreate) onPopupCreate({
|
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
|
||||||
lngLat: coords,
|
lngLat: coords,
|
||||||
crashData,
|
crashData,
|
||||||
text: detailedText,
|
text: detailedText,
|
||||||
@@ -495,15 +560,49 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
|||||||
// Get comprehensive stats for the clicked location
|
// Get comprehensive stats for the clicked location
|
||||||
const stats = await computeNearbyStats(coords, 500); // 500m radius
|
const stats = await computeNearbyStats(coords, 500); // 500m radius
|
||||||
|
|
||||||
if (onPopupCreate) onPopupCreate({
|
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
|
||||||
lngLat: coords,
|
lngLat: coords,
|
||||||
text: 'Area Crash Analysis',
|
text: 'Area Crash Analysis',
|
||||||
stats
|
stats
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// General map click for any location (not just double-click)
|
||||||
|
map.on('click', async (e) => {
|
||||||
|
// Skip if in map picking mode
|
||||||
|
if (isMapPickingModeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
|
||||||
|
// Get stats for any location on the map
|
||||||
|
const stats = await computeNearbyStats(coords, 300); // 300m 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: 600 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// General map double-click for any location
|
// General map double-click for any location
|
||||||
map.on('dblclick', async (e) => {
|
map.on('dblclick', async (e) => {
|
||||||
|
// Skip if in map picking mode
|
||||||
|
if (isMapPickingModeRef.current) return;
|
||||||
|
|
||||||
// Only trigger if not clicking on a feature
|
// Only trigger if not clicking on a feature
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: ['dc-point', 'dc-heat'] });
|
const features = map.queryRenderedFeatures(e.point, { layers: ['dc-point', 'dc-heat'] });
|
||||||
if (features.length > 0) return; // Already handled by feature-specific handlers
|
if (features.length > 0) return; // Already handled by feature-specific handlers
|
||||||
@@ -547,6 +646,8 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// update visibility & paint when props change
|
// update visibility & paint when props change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import type { PopupData } from './MapView';
|
import type { PopupData } from './MapView';
|
||||||
|
|
||||||
@@ -9,23 +9,150 @@ interface Props {
|
|||||||
popupVisible: boolean;
|
popupVisible: boolean;
|
||||||
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
|
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
autoDismissMs?: number; // Auto-dismiss timeout in milliseconds, default 5000 (5 seconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PopupOverlay({ popup, popupVisible, mapRef, onClose }: Props) {
|
export default function PopupOverlay({ popup, popupVisible, mapRef, onClose, autoDismissMs = 5000 }: Props) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [timeLeft, setTimeLeft] = useState(autoDismissMs);
|
||||||
|
const [popupPosition, setPopupPosition] = useState({ left: 0, top: 0, transform: 'translate(-50%, -100%)', arrowPosition: 'bottom' });
|
||||||
|
|
||||||
|
// Calculate smart popup positioning
|
||||||
|
const calculatePopupPosition = (clickPoint: mapboxgl.Point) => {
|
||||||
|
if (typeof window === 'undefined') return {
|
||||||
|
left: clickPoint.x,
|
||||||
|
top: clickPoint.y,
|
||||||
|
transform: 'translate(-50%, -100%)',
|
||||||
|
arrowPosition: 'bottom'
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const popupWidth = 350; // max-width from styles
|
||||||
|
const popupHeight = 200; // estimated height
|
||||||
|
const padding = 20; // padding from screen edges
|
||||||
|
|
||||||
|
let left = clickPoint.x;
|
||||||
|
let top = clickPoint.y;
|
||||||
|
let transform = '';
|
||||||
|
let arrowPosition = 'bottom'; // where the arrow points (bottom = popup is above click)
|
||||||
|
|
||||||
|
// Determine horizontal position
|
||||||
|
if (clickPoint.x + popupWidth / 2 + padding > viewportWidth) {
|
||||||
|
// Position to the left of cursor
|
||||||
|
left = clickPoint.x - 10;
|
||||||
|
transform = 'translateX(-100%)';
|
||||||
|
arrowPosition = 'right';
|
||||||
|
} else if (clickPoint.x - popupWidth / 2 < padding) {
|
||||||
|
// Position to the right of cursor
|
||||||
|
left = clickPoint.x + 10;
|
||||||
|
transform = 'translateX(0%)';
|
||||||
|
arrowPosition = 'left';
|
||||||
|
} else {
|
||||||
|
// Center horizontally
|
||||||
|
left = clickPoint.x;
|
||||||
|
transform = 'translateX(-50%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine vertical position
|
||||||
|
if (clickPoint.y - popupHeight - padding < 0) {
|
||||||
|
// Position below cursor
|
||||||
|
top = clickPoint.y + 10;
|
||||||
|
transform += ' translateY(0%)';
|
||||||
|
arrowPosition = arrowPosition === 'bottom' ? 'top' : arrowPosition;
|
||||||
|
} else {
|
||||||
|
// Position above cursor (default)
|
||||||
|
top = clickPoint.y - 10;
|
||||||
|
transform += ' translateY(-100%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { left, top, transform, arrowPosition };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update popup position when popup data changes or map moves
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popup || !mapRef.current) return;
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
const updatePosition = () => {
|
||||||
|
const clickPoint = map.project(popup.lngLat as any);
|
||||||
|
const position = calculatePopupPosition(clickPoint);
|
||||||
|
setPopupPosition(position);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update position initially
|
||||||
|
updatePosition();
|
||||||
|
|
||||||
|
// Update position when map moves or zooms
|
||||||
|
map.on('move', updatePosition);
|
||||||
|
map.on('zoom', updatePosition);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('move', updatePosition);
|
||||||
|
map.off('zoom', updatePosition);
|
||||||
|
};
|
||||||
|
}, [popup, mapRef]);
|
||||||
|
|
||||||
|
// Auto-dismiss timer with progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popup || !popupVisible || isHovered) {
|
||||||
|
setTimeLeft(autoDismissMs); // Reset timer when hovered
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = 50; // Update every 50ms for smooth progress
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => {
|
||||||
|
const newValue = prev - interval;
|
||||||
|
if (newValue <= 0) {
|
||||||
|
// Schedule onClose to run after the state update completes
|
||||||
|
setTimeout(() => onClose(), 0);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
});
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [popup, popupVisible, isHovered, onClose, autoDismissMs]);
|
||||||
|
|
||||||
if (!popup) return null;
|
if (!popup) return null;
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return null;
|
if (!map) return null;
|
||||||
|
|
||||||
const p = map.project(popup.lngLat as any);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Feature details"
|
aria-label="Feature details"
|
||||||
className={`custom-popup ${popupVisible ? 'visible' : ''}`}
|
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' }}
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: Math.round(popupPosition.left),
|
||||||
|
top: Math.round(popupPosition.top),
|
||||||
|
transform: popupPosition.transform,
|
||||||
|
pointerEvents: popupVisible ? 'auto' : 'none',
|
||||||
|
zIndex: 1000
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
>
|
>
|
||||||
<div className="mapbox-popup-inner" style={{ background: 'var(--surface-1)', color: 'var(--text-primary)', padding: 8, borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', border: '1px solid var(--border-1)', minWidth: 200, maxWidth: 350 }}>
|
<div className="mapbox-popup-inner" style={{ background: 'var(--surface-1)', color: 'var(--text-primary)', padding: 8, borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.15)', border: '1px solid var(--border-1)', minWidth: 200, maxWidth: 350, position: 'relative', overflow: 'hidden' }}>
|
||||||
|
{/* Auto-dismiss progress bar */}
|
||||||
|
{!isHovered && popupVisible && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
height: 2,
|
||||||
|
backgroundColor: '#0066cc',
|
||||||
|
width: `${(timeLeft / autoDismissMs) * 100}%`,
|
||||||
|
transition: 'width 50ms linear',
|
||||||
|
zIndex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 14 }}>{popup.text ?? 'Details'}</div>
|
<div style={{ fontWeight: 700, fontSize: 14 }}>{popup.text ?? 'Details'}</div>
|
||||||
<button aria-label="Close popup" onClick={() => { onClose(); }} style={{ background: 'var(--surface-2)', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer', borderRadius: 4, color: 'var(--text-secondary)' }}>
|
<button aria-label="Close popup" onClick={() => { onClose(); }} style={{ background: 'var(--surface-2)', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer', borderRadius: 4, color: 'var(--text-secondary)' }}>
|
||||||
@@ -38,11 +165,6 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose }: P
|
|||||||
<div style={{ fontWeight: 600, color: '#0066cc', marginBottom: 4 }}>
|
<div style={{ fontWeight: 600, color: '#0066cc', marginBottom: 4 }}>
|
||||||
📍 {popup.stats.count} crashes within {popup.stats.radiusMeters}m radius
|
📍 {popup.stats.count} crashes within {popup.stats.radiusMeters}m radius
|
||||||
</div>
|
</div>
|
||||||
{popup.stats.avg !== undefined && (
|
|
||||||
<div style={{ marginBottom: 4, color: 'var(--text-secondary)' }}>
|
|
||||||
<strong style={{ color: 'var(--text-primary)' }}>Severity Score:</strong> Avg {popup.stats.avg} (Min: {popup.stats.min}, Max: {popup.stats.max})
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{popup.stats.severityCounts && (
|
{popup.stats.severityCounts && (
|
||||||
<div style={{ marginTop: 6 }}>
|
<div style={{ marginTop: 6 }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 2, color: 'var(--text-primary)' }}>Severity Breakdown:</div>
|
<div style={{ fontWeight: 600, marginBottom: 2, color: 'var(--text-primary)' }}>Severity Breakdown:</div>
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataRes
|
|||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
limit: limit.toString(),
|
// When year filter is active, request all data by setting a high limit
|
||||||
|
limit: yearFilter ? '100000' : limit.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (yearFilter) {
|
if (yearFilter) {
|
||||||
@@ -51,10 +52,11 @@ export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataRes
|
|||||||
|
|
||||||
const result: CrashResponse = await response.json();
|
const result: CrashResponse = await response.json();
|
||||||
|
|
||||||
if (append) {
|
// When year filter is active, always replace data (don't append)
|
||||||
setData(prevData => [...prevData, ...result.data]);
|
if (yearFilter || !append) {
|
||||||
} else {
|
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
|
} else {
|
||||||
|
setData(prevData => [...prevData, ...result.data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPagination(result.pagination);
|
setPagination(result.pagination);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react';
|
|||||||
import MapView, { PopupData } from './components/MapView';
|
import MapView, { PopupData } from './components/MapView';
|
||||||
import ControlsPanel from './components/ControlsPanel';
|
import ControlsPanel from './components/ControlsPanel';
|
||||||
import PopupOverlay from './components/PopupOverlay';
|
import PopupOverlay from './components/PopupOverlay';
|
||||||
import Legend from './components/Legend';
|
|
||||||
import MapNavigationControl from './components/MapNavigationControl';
|
import MapNavigationControl from './components/MapNavigationControl';
|
||||||
import DirectionsSidebar from './components/DirectionsSidebar';
|
import DirectionsSidebar from './components/DirectionsSidebar';
|
||||||
import CrashDataControls from './components/CrashDataControls';
|
import CrashDataControls from './components/CrashDataControls';
|
||||||
@@ -22,16 +21,21 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
const [popup, setPopup] = useState<PopupData>(null);
|
const [popup, setPopup] = useState<PopupData>(null);
|
||||||
const [popupVisible, setPopupVisible] = useState(false);
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
const [isMapPickingMode, setIsMapPickingMode] = useState(false);
|
||||||
|
|
||||||
// Shared crash data state
|
// Shared crash data state - load all data for filtered year
|
||||||
const crashDataHook = useCrashData({ autoLoad: true, limit: 10000 });
|
const crashDataHook = useCrashData({ autoLoad: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'row' }}>
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'row' }}>
|
||||||
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
|
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
|
||||||
{/* Render sidebar as an overlay inside the map container so collapsing doesn't shift layout */}
|
{/* 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' }}>
|
<div style={{ position: 'absolute', left: 0, top: 0, height: '100%', zIndex: 40, pointerEvents: 'auto' }}>
|
||||||
<DirectionsSidebar mapRef={mapRef} profile="mapbox/driving" />
|
<DirectionsSidebar
|
||||||
|
mapRef={mapRef}
|
||||||
|
profile="mapbox/driving"
|
||||||
|
onMapPickingModeChange={setIsMapPickingMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ControlsPanel
|
<ControlsPanel
|
||||||
panelOpen={panelOpen}
|
panelOpen={panelOpen}
|
||||||
@@ -56,6 +60,8 @@ export default function Home() {
|
|||||||
pointsVisible={pointsVisible}
|
pointsVisible={pointsVisible}
|
||||||
useRealCrashData={true}
|
useRealCrashData={true}
|
||||||
crashData={crashDataHook.data}
|
crashData={crashDataHook.data}
|
||||||
|
crashDataHook={crashDataHook}
|
||||||
|
isMapPickingMode={isMapPickingMode}
|
||||||
onMapReady={(m) => { mapRef.current = m; }}
|
onMapReady={(m) => { mapRef.current = m; }}
|
||||||
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
|
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
|
||||||
/>
|
/>
|
||||||
@@ -63,10 +69,8 @@ export default function Home() {
|
|||||||
{/* Native Mapbox navigation control (zoom + compass) */}
|
{/* Native Mapbox navigation control (zoom + compass) */}
|
||||||
<MapNavigationControl mapRef={mapRef} position="top-right" />
|
<MapNavigationControl mapRef={mapRef} position="top-right" />
|
||||||
|
|
||||||
{/* Crash data loading controls */}
|
{/* Crash data loading controls with integrated crash density legend */}
|
||||||
<CrashDataControls crashDataHook={crashDataHook} />
|
<CrashDataControls crashDataHook={crashDataHook} />
|
||||||
|
|
||||||
<Legend />
|
|
||||||
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
|
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user