Maps Update
This commit is contained in:
98
web/src/app/components/CrashDataControls.tsx
Normal file
98
web/src/app/components/CrashDataControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
74
web/src/app/components/GeocodeInput.tsx
Normal file
74
web/src/app/components/GeocodeInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user