Refactor MapView and PopupOverlay components for improved readability and maintainability

This commit is contained in:
2025-09-28 01:16:16 -04:00
parent e895f01216
commit c63f7754e5
3 changed files with 264 additions and 42 deletions

View File

@@ -6,7 +6,7 @@ import 'mapbox-gl/dist/mapbox-gl.css';
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 { useCrashData, UseCrashDataResult } from '../hooks/useCrashData';
import { CrashData } from '../api/crashes/route';
export type PopupData = {
@@ -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
crashDataHook?: UseCrashDataResult; // the crash data hook from main page
isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups)
}
@@ -55,20 +56,28 @@ export default function MapView({
onGeocoderResult,
useRealCrashData = true,
crashData = [],
crashDataHook,
isMapPickingMode = false
}: 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 isMapPickingModeRef = useRef<boolean>(isMapPickingMode);
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
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
useEffect(() => {
const activeData = crashData.length > 0 ? crashData : crashDataHook.data;
console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', crashDataHook.data.length);
const currentCrashDataHook = crashDataHook || internalCrashDataHook;
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) {
console.log('Converting crash data to GeoJSON...');
dcDataRef.current = convertCrashDataToGeoJSON(activeData);
@@ -142,7 +151,7 @@ export default function MapView({
console.log('Map style not loaded yet');
}
}
}, [useRealCrashData, crashDataHook.data, crashData, heatRadius, heatIntensity, heatVisible, pointsVisible]);
}, [useRealCrashData, crashDataHook?.data, crashData, heatRadius, heatIntensity, heatVisible, pointsVisible]);
useEffect(() => {
const el = containerRef.current;
@@ -218,7 +227,8 @@ export default function MapView({
// 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;
const currentCrashDataHook = crashDataHook || internalCrashDataHook;
const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data;
console.log('Initializing map data, activeData length:', activeData.length);
if (useRealCrashData && activeData.length > 0) {
console.log('Using real crash data');
@@ -234,11 +244,61 @@ export default function MapView({
const computeNearbyStats = async (center: [number, number], radiusMeters = 300) => {
try {
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`);
if (!response.ok) {
console.warn('Failed to fetch nearby crash data:', response.status);
return { count: 0 };
return { count: 0, radiusMeters };
}
const data = await response.json();
@@ -260,24 +320,6 @@ export default function MapView({
return { count: 0, radiusMeters };
}
// Calculate severity statistics from MongoDB data
const severityValues = validCrashes.map((crash: any) => {
// Convert severity to numeric value for stats
switch (crash.severity) {
case 'Fatal': return 6;
case 'Major Injury': return 4;
case 'Minor Injury': return 2;
case 'Property Damage Only': return 1;
default: return 1;
}
});
// Calculate statistics
const sum = severityValues.reduce((s: number, x: number) => s + x, 0);
const avg = +(sum / severityValues.length).toFixed(2);
const min = Math.min(...severityValues);
const max = Math.max(...severityValues);
// Count by severity type
const severityCounts = {
fatal: validCrashes.filter((c: any) => c.severity === 'Fatal').length,
@@ -288,12 +330,9 @@ export default function MapView({
return {
count: validCrashes.length,
avg,
min,
max,
radiusMeters,
severityCounts,
crashes: validCrashes.slice(0, 5) // Include first 5 crashes for detailed info
crashes: validCrashes.slice(0, 5)
};
} catch (error) {
console.error('Error computing nearby stats:', error);
@@ -301,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 = () => {
if (!map || !dcDataRef.current) {
console.log('addDataAndLayers: map or data not ready', !!map, !!dcDataRef.current);
@@ -377,6 +432,10 @@ export default function MapView({
try { map.fitBounds(dcBounds, { padding: 20 }); } catch (e) { /* ignore if fitBounds fails */ }
map.on('click', 'dc-point', async (e) => {
if (isMapPickingModeRef.current) {
return;
}
const feature = e.features && e.features[0];
if (!feature) return;
@@ -409,6 +468,10 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
});
map.on('click', 'dc-heat', async (e) => {
if (isMapPickingModeRef.current) {
return;
}
const p = e.point;
const bbox = [[p.x - 6, p.y - 6], [p.x + 6, p.y + 6]] as [mapboxgl.PointLike, mapboxgl.PointLike];
const nearby = map.queryRenderedFeatures(bbox, { layers: ['dc-point'] });
@@ -504,8 +567,42 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
});
});
// 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
map.on('dblclick', 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
@@ -518,13 +615,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 && !isMapPickingMode) onPopupCreate({
if (onPopupCreate) onPopupCreate({
lngLat: coords,
text: 'Location Analysis',
stats
});
} else {
if (onPopupCreate && !isMapPickingMode) onPopupCreate({
if (onPopupCreate) onPopupCreate({
lngLat: coords,
text: 'No crashes found in this area',
stats: { count: 0, radiusMeters: 800 }
@@ -549,6 +646,8 @@ Major Injuries: ${(crashData.majorInjuriesDriver || 0) + (crashData.majorInjurie
};
}, []);
// update visibility & paint when props change
useEffect(() => {
const map = mapRef.current;

View File

@@ -1,6 +1,6 @@
"use client";
import React from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import mapboxgl from 'mapbox-gl';
import type { PopupData } from './MapView';
@@ -9,23 +9,150 @@ interface Props {
popupVisible: boolean;
mapRef: React.MutableRefObject<mapboxgl.Map | null>;
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;
const map = mapRef.current;
if (!map) return null;
const p = map.project(popup.lngLat as any);
return (
<div
role="dialog"
aria-label="Feature details"
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={{ 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)' }}>
@@ -38,11 +165,6 @@ export default function PopupOverlay({ popup, popupVisible, mapRef, onClose }: P
<div style={{ fontWeight: 600, color: '#0066cc', marginBottom: 4 }}>
📍 {popup.stats.count} crashes within {popup.stats.radiusMeters}m radius
</div>
{popup.stats.avg !== undefined && (
<div style={{ marginBottom: 4, color: 'var(--text-secondary)' }}>
<strong style={{ color: 'var(--text-primary)' }}>Severity Score:</strong> Avg {popup.stats.avg} (Min: {popup.stats.min}, Max: {popup.stats.max})
</div>
)}
{popup.stats.severityCounts && (
<div style={{ marginTop: 6 }}>
<div style={{ fontWeight: 600, marginBottom: 2, color: 'var(--text-primary)' }}>Severity Breakdown:</div>

View File

@@ -60,6 +60,7 @@ export default function Home() {
pointsVisible={pointsVisible}
useRealCrashData={true}
crashData={crashDataHook.data}
crashDataHook={crashDataHook}
isMapPickingMode={isMapPickingMode}
onMapReady={(m) => { mapRef.current = m; }}
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}