Enhance crash data visualization with density legend and improve map interaction handling
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
|
||||
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' }}>
|
||||
Crash Data Status
|
||||
</div>
|
||||
@@ -96,7 +116,7 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
||||
{yearFilter && ` (${yearFilter})`}
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
{pagination && !yearFilter && (
|
||||
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
<br />
|
||||
@@ -104,6 +124,12 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pagination && yearFilter && (
|
||||
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
||||
All crashes for {yearFilter} loaded
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ marginBottom: '8px', color: '#ffff99' }}>
|
||||
Loading...
|
||||
@@ -117,7 +143,7 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{pagination?.hasNext && (
|
||||
{pagination?.hasNext && !yearFilter && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
|
||||
@@ -9,11 +9,12 @@ import { calculateRouteCrashDensity, createRouteGradientStops } from '../lib/map
|
||||
interface Props {
|
||||
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
|
||||
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)
|
||||
|
||||
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
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -46,6 +47,13 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
|
||||
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
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -55,19 +63,31 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving" }
|
||||
const { lng, lat } = e.lngLat;
|
||||
|
||||
if (isOriginMapPicking) {
|
||||
// Prevent other map click handlers from running
|
||||
if (e.originalEvent) {
|
||||
e.originalEvent.stopPropagation();
|
||||
e.originalEvent.stopImmediatePropagation();
|
||||
}
|
||||
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 });
|
||||
return;
|
||||
} else if (isDestMapPicking) {
|
||||
// Prevent other map click handlers from running
|
||||
if (e.originalEvent) {
|
||||
e.originalEvent.stopPropagation();
|
||||
e.originalEvent.stopImmediatePropagation();
|
||||
}
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,22 @@ import React from 'react';
|
||||
|
||||
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 }}>Crash Density</div>
|
||||
<div style={{ position: 'absolute', bottom: '580px', right: '12px', zIndex: 30 }}>
|
||||
<div style={{
|
||||
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', 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,165,0,0.8)' }} />
|
||||
<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>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ fontSize: 11 }}>Low</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700 }}>High</span>
|
||||
<span style={{ fontSize: 11, color: '#ccc' }}>Low</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#ccc' }}>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 style={{ marginTop: 8, paddingTop: 6, borderTop: '1px solid rgba(64, 64, 64, 0.5)' }}>
|
||||
<div style={{ fontSize: 11, color: '#9ca3af' }}>
|
||||
Real DC crash data (2020+)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ interface MapViewProps {
|
||||
onGeocoderResult?: (lngLat: [number, number]) => void;
|
||||
useRealCrashData?: boolean; // whether to use real crash data or synthetic data
|
||||
crashData?: CrashData[]; // external crash data to use
|
||||
isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups)
|
||||
}
|
||||
|
||||
export default function MapView({
|
||||
@@ -53,7 +54,8 @@ export default function MapView({
|
||||
onPopupCreate,
|
||||
onGeocoderResult,
|
||||
useRealCrashData = true,
|
||||
crashData = []
|
||||
crashData = [],
|
||||
isMapPickingMode = false
|
||||
}: MapViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -88,7 +90,7 @@ export default function MapView({
|
||||
// Add layers if they don't exist
|
||||
if (!map.getLayer('dc-heat')) {
|
||||
map.addLayer({
|
||||
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', maxzoom: 15,
|
||||
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes',
|
||||
paint: {
|
||||
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
|
||||
'heatmap-intensity': heatIntensity,
|
||||
@@ -317,7 +319,7 @@ export default function MapView({
|
||||
|
||||
if (!map.getLayer('dc-heat')) {
|
||||
map.addLayer({
|
||||
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', maxzoom: 15,
|
||||
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes',
|
||||
paint: {
|
||||
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1],
|
||||
'heatmap-intensity': heatIntensity,
|
||||
@@ -403,7 +405,7 @@ Fatalities: ${(crashData.fatalDriver || 0) + (crashData.fatalPedestrian || 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) => {
|
||||
@@ -421,7 +423,7 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
||||
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 });
|
||||
if (onPopupCreate && !isMapPickingMode) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual crash reports and details', stats });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -439,10 +441,10 @@ Fatalities: ${(crashData.fatalDriver || 0) + (crashData.fatalPedestrian || 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 {
|
||||
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 +480,7 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
||||
detailedText = `Detailed Analysis - ${crashData.address}`;
|
||||
}
|
||||
|
||||
if (onPopupCreate) onPopupCreate({
|
||||
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
|
||||
lngLat: coords,
|
||||
crashData,
|
||||
text: detailedText,
|
||||
@@ -495,7 +497,7 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
||||
// Get comprehensive stats for the clicked location
|
||||
const stats = await computeNearbyStats(coords, 500); // 500m radius
|
||||
|
||||
if (onPopupCreate) onPopupCreate({
|
||||
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
|
||||
lngLat: coords,
|
||||
text: 'Area Crash Analysis',
|
||||
stats
|
||||
@@ -516,13 +518,13 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
|
||||
const stats = await computeNearbyStats(coords, 400); // 400m radius for general clicks
|
||||
|
||||
if (stats.count > 0) {
|
||||
if (onPopupCreate) onPopupCreate({
|
||||
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
|
||||
lngLat: coords,
|
||||
text: 'Location Analysis',
|
||||
stats
|
||||
});
|
||||
} else {
|
||||
if (onPopupCreate) onPopupCreate({
|
||||
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
|
||||
lngLat: coords,
|
||||
text: 'No crashes found in this area',
|
||||
stats: { count: 0, radiusMeters: 800 }
|
||||
|
||||
@@ -36,7 +36,8 @@ export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataRes
|
||||
|
||||
const params = new URLSearchParams({
|
||||
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) {
|
||||
@@ -51,10 +52,11 @@ export function useCrashData(options: UseCrashDataOptions = {}): UseCrashDataRes
|
||||
|
||||
const result: CrashResponse = await response.json();
|
||||
|
||||
if (append) {
|
||||
setData(prevData => [...prevData, ...result.data]);
|
||||
} else {
|
||||
// When year filter is active, always replace data (don't append)
|
||||
if (yearFilter || !append) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setData(prevData => [...prevData, ...result.data]);
|
||||
}
|
||||
|
||||
setPagination(result.pagination);
|
||||
|
||||
@@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react';
|
||||
import MapView, { PopupData } from './components/MapView';
|
||||
import ControlsPanel from './components/ControlsPanel';
|
||||
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';
|
||||
@@ -22,16 +21,21 @@ export default function Home() {
|
||||
});
|
||||
const [popup, setPopup] = useState<PopupData>(null);
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [isMapPickingMode, setIsMapPickingMode] = useState(false);
|
||||
|
||||
// Shared crash data state
|
||||
const crashDataHook = useCrashData({ autoLoad: true, limit: 10000 });
|
||||
// Shared crash data state - load all data for filtered year
|
||||
const crashDataHook = useCrashData({ autoLoad: true });
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'row' }}>
|
||||
<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" />
|
||||
<DirectionsSidebar
|
||||
mapRef={mapRef}
|
||||
profile="mapbox/driving"
|
||||
onMapPickingModeChange={setIsMapPickingMode}
|
||||
/>
|
||||
</div>
|
||||
<ControlsPanel
|
||||
panelOpen={panelOpen}
|
||||
@@ -56,6 +60,7 @@ export default function Home() {
|
||||
pointsVisible={pointsVisible}
|
||||
useRealCrashData={true}
|
||||
crashData={crashDataHook.data}
|
||||
isMapPickingMode={isMapPickingMode}
|
||||
onMapReady={(m) => { mapRef.current = m; }}
|
||||
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
|
||||
/>
|
||||
@@ -63,10 +68,8 @@ export default function Home() {
|
||||
{/* Native Mapbox navigation control (zoom + compass) */}
|
||||
<MapNavigationControl mapRef={mapRef} position="top-right" />
|
||||
|
||||
{/* Crash data loading controls */}
|
||||
{/* Crash data loading controls with integrated crash density legend */}
|
||||
<CrashDataControls crashDataHook={crashDataHook} />
|
||||
|
||||
<Legend />
|
||||
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user