feat: Enhance DirectionsSidebar with gradient routes and theming options
- Added `gradientRoutes` and `mapStyleChoice` props to DirectionsSidebar for customizable route rendering and theming. - Implemented logic to handle gradient routes based on crash data availability. - Updated route rendering colors and styles for better visual distinction. - Refactored sidebar and toggle button styles for improved UI consistency across themes. refactor: Introduce UnifiedControlPanel for better control management - Replaced ControlsPanel with UnifiedControlPanel to consolidate map and crash data controls. - Added state management for panel visibility and local storage persistence. - Enhanced UI for map controls, including options for heatmap visibility and gradient routes. style: Update GeocodeInput and Legend components for consistent theming - Refactored styles in GeocodeInput to use CSS variables for background and border colors. - Updated Legend component styles to align with new theming variables for better integration. chore: Introduce new CSS variables for panel theming - Added a new color palette for panels and sidebars in globals.css to support light and dark themes.
This commit is contained in:
@@ -15,21 +15,53 @@ interface ControlsPanelProps {
|
|||||||
onChangeRadius: (v: number) => void;
|
onChangeRadius: (v: number) => void;
|
||||||
heatIntensity: number;
|
heatIntensity: number;
|
||||||
onChangeIntensity: (v: number) => void;
|
onChangeIntensity: (v: number) => void;
|
||||||
|
gradientRoutes: boolean;
|
||||||
|
onToggleGradientRoutes: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ControlsPanel({ panelOpen, onTogglePanel, mapStyleChoice, onChangeStyle, heatVisible, onToggleHeat, pointsVisible, onTogglePoints, heatRadius, onChangeRadius, heatIntensity, onChangeIntensity }: ControlsPanelProps) {
|
export default function ControlsPanel({ panelOpen, onTogglePanel, mapStyleChoice, onChangeStyle, heatVisible, onToggleHeat, pointsVisible, onTogglePoints, heatRadius, onChangeRadius, heatIntensity, onChangeIntensity, gradientRoutes, onToggleGradientRoutes }: ControlsPanelProps) {
|
||||||
|
const panelStyle = {
|
||||||
|
backgroundColor: 'var(--panel-darker)',
|
||||||
|
color: '#f9fafb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.2)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
zIndex: 20, // Ensure proper layering
|
||||||
|
fontWeight: '500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle = {
|
||||||
|
backgroundColor: 'var(--panel-dark)',
|
||||||
|
color: '#f9fafb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
outline: 'none'
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="map-control">
|
<div className="map-control" style={panelStyle}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<div style={{ fontWeight: 700 }}>Map Controls</div>
|
<div style={{ fontWeight: 700, fontSize: '16px', color: '#f9fafb' }}>Map Controls</div>
|
||||||
<button aria-expanded={panelOpen} aria-label={panelOpen ? 'Collapse panel' : 'Expand panel'} onClick={() => onTogglePanel(!panelOpen)} style={{ borderRadius: 6, padding: '4px 8px' }}>{panelOpen ? '−' : '+'}</button>
|
<button aria-expanded={panelOpen} aria-label={panelOpen ? 'Collapse panel' : 'Expand panel'} onClick={() => onTogglePanel(!panelOpen)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'var(--panel-dark)',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>{panelOpen ? '−' : '+'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{panelOpen && (
|
{panelOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="mc-row">
|
<div className="mc-row">
|
||||||
<label className="mc-label">Style</label>
|
<label className="mc-label">Style</label>
|
||||||
<select className="map-select" value={mapStyleChoice} onChange={(e) => onChangeStyle(e.target.value as 'dark' | 'streets')}>
|
<select className="map-select" style={selectStyle} value={mapStyleChoice} onChange={(e) => onChangeStyle(e.target.value as 'dark' | 'streets')}>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
<option value="streets">Streets</option>
|
<option value="streets">Streets</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -44,6 +76,11 @@ export default function ControlsPanel({ panelOpen, onTogglePanel, mapStyleChoice
|
|||||||
<input type="checkbox" checked={pointsVisible} onChange={(e) => onTogglePoints(e.target.checked)} />
|
<input type="checkbox" checked={pointsVisible} onChange={(e) => onTogglePoints(e.target.checked)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mc-row">
|
||||||
|
<label className="mc-label">Gradient Routes</label>
|
||||||
|
<input type="checkbox" checked={gradientRoutes} onChange={(e) => onToggleGradientRoutes(e.target.checked)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 6 }}>
|
<div style={{ marginBottom: 6 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12 }}>Radius: {heatRadius}</label>
|
<label style={{ display: 'block', fontSize: 12 }}>Radius: {heatRadius}</label>
|
||||||
<input className="mc-range" type="range" min={5} max={100} value={heatRadius} onChange={(e) => onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} />
|
<input className="mc-range" type="range" min={5} max={100} value={heatRadius} onChange={(e) => onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} />
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { UseCrashDataResult } from '../hooks/useCrashData';
|
|||||||
interface CrashDataControlsProps {
|
interface CrashDataControlsProps {
|
||||||
crashDataHook: UseCrashDataResult;
|
crashDataHook: UseCrashDataResult;
|
||||||
onDataLoaded?: (dataCount: number) => void;
|
onDataLoaded?: (dataCount: number) => void;
|
||||||
|
mapStyleChoice?: 'dark' | 'streets';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrashDataControls({ crashDataHook, onDataLoaded }: CrashDataControlsProps) {
|
export default function CrashDataControls({ crashDataHook, onDataLoaded, mapStyleChoice = 'dark' }: CrashDataControlsProps) {
|
||||||
const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
|
const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
|
||||||
const currentYear = new Date().getFullYear().toString();
|
const currentYear = new Date().getFullYear().toString();
|
||||||
const [selectedYear, setSelectedYear] = useState<string>(yearFilter || currentYear);
|
const [selectedYear, setSelectedYear] = useState<string>(yearFilter || currentYear);
|
||||||
@@ -47,39 +48,40 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
}, [data.length, onDataLoaded]);
|
}, [data.length, onDataLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '320px', // Position above the map controls panel with some margin
|
top: '12px', // Position at top right instead of bottom
|
||||||
right: '12px', // Align with map controls panel
|
right: '12px', // Right side positioning
|
||||||
backgroundColor: 'rgba(26, 26, 26, 0.95)', // Match the map controls styling more closely
|
backgroundColor: 'var(--panel-darker)', // Use new color palette
|
||||||
color: 'white',
|
color: '#f9fafb', // White text for both themes
|
||||||
padding: '12px',
|
padding: '16px',
|
||||||
borderRadius: '10px', // Match map controls border radius
|
borderRadius: '12px',
|
||||||
zIndex: 30,
|
zIndex: 1000, // Much higher z-index to appear above everything
|
||||||
fontSize: '13px', // Match map controls font size
|
fontSize: '14px',
|
||||||
width: '240px', // Match map controls width
|
fontWeight: '500',
|
||||||
backdropFilter: 'blur(8px)', // Match map controls backdrop filter
|
width: '280px',
|
||||||
border: '1px solid rgba(64, 64, 64, 0.5)', // Add subtle border
|
backdropFilter: 'blur(20px)',
|
||||||
boxShadow: '0 6px 18px rgba(0,0,0,0.15)' // Match map controls shadow
|
border: '2px solid var(--panel-medium)',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.2)'
|
||||||
}}>
|
}}>
|
||||||
{/* Crash Density Legend */}
|
{/* Crash Density Legend */}
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<div style={{ fontSize: '14px', fontWeight: 700, marginBottom: '8px' }}>Crash Density</div>
|
<div style={{ fontSize: '16px', fontWeight: 700, marginBottom: '10px', color: '#f9fafb' }}>Crash Density</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 2, 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: 20, height: 14, background: 'rgba(0,0,0,0)', border: '1px solid rgba(249, 250, 251, 0.4)', borderRadius: '2px' }} />
|
||||||
<div style={{ width: 18, height: 12, background: 'rgba(255,255,0,0.7)' }} />
|
<div style={{ width: 20, height: 14, background: 'rgba(255,255,0,0.8)', borderRadius: '2px' }} />
|
||||||
<div style={{ width: 18, height: 12, background: 'rgba(255,165,0,0.8)' }} />
|
<div style={{ width: 20, height: 14, background: 'rgba(255,165,0,0.85)', borderRadius: '2px' }} />
|
||||||
<div style={{ width: 18, height: 12, background: 'rgba(255,69,0,0.9)' }} />
|
<div style={{ width: 20, height: 14, background: 'rgba(255,69,0,0.9)', borderRadius: '2px' }} />
|
||||||
<div style={{ width: 18, height: 12, background: 'rgba(255,0,0,0.95)' }} />
|
<div style={{ width: 20, height: 14, background: 'rgba(255,0,0,0.95)', borderRadius: '2px' }} />
|
||||||
<div style={{ width: 18, height: 12, background: 'rgba(139,0,0,1)' }} />
|
<div style={{ width: 20, height: 14, background: 'rgba(139,0,0,1)', borderRadius: '2px' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<span style={{ fontSize: 11, color: '#ccc' }}>Low</span>
|
<span style={{ fontSize: 12, color: '#ffffff', fontWeight: '600' }}>Low</span>
|
||||||
<span style={{ fontSize: 11, color: '#ccc' }}>High</span>
|
<span style={{ fontSize: 12, color: '#ffffff', fontWeight: '600' }}>High</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ borderTop: '1px solid rgba(64, 64, 64, 0.5)', marginTop: '8px', paddingTop: '8px' }}></div>
|
<div style={{ borderTop: mapStyleChoice === 'streets' ? '1px solid rgba(156, 163, 175, 0.5)' : '1px solid rgba(64, 64, 64, 0.5)', marginTop: '8px', paddingTop: '8px' }}></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '8px', fontWeight: 700, fontSize: '14px' }}>
|
<div style={{ marginBottom: '8px', fontWeight: 700, fontSize: '14px' }}>
|
||||||
@@ -87,37 +89,42 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Year Filter */}
|
{/* Year Filter */}
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: '#ccc' }}>
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#e5e7eb', fontWeight: '600' }}>
|
||||||
Filter by Year:
|
Filter by Year:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedYear}
|
value={yearFilter || ''}
|
||||||
onChange={(e) => handleYearChange(e.target.value)}
|
onChange={(e) => handleYearChange(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'rgba(64, 64, 64, 0.8)',
|
|
||||||
color: 'white',
|
|
||||||
border: '1px solid rgba(128, 128, 128, 0.5)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--panel-dark)',
|
||||||
|
color: '#f9fafb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
outline: 'none',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getAvailableYears().map(year => (
|
<option value="">All Years</option>
|
||||||
<option key={year} value={year}>{year}</option>
|
{Array.from({ length: 2025 - 2015 + 1 }, (_, i) => 2015 + i).map(year => (
|
||||||
|
<option key={year} value={year} style={{ backgroundColor: 'var(--panel-dark)', color: '#f9fafb' }}>
|
||||||
|
{year}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '6px' }}>
|
<div style={{ marginBottom: '12px', color: '#f9fafb', fontWeight: '600', fontSize: '15px' }}>
|
||||||
Loaded: {data.length.toLocaleString()} crashes
|
Loaded: {data.length.toLocaleString()} crashes
|
||||||
{yearFilter && ` (${yearFilter})`}
|
{yearFilter && ` (${yearFilter})`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pagination && !yearFilter && (
|
{pagination && !yearFilter && (
|
||||||
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
<div style={{ marginBottom: '8px', fontSize: '13px', color: '#9ca3af', fontWeight: '500' }}>
|
||||||
Page {pagination.page} of {pagination.totalPages}
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
<br />
|
<br />
|
||||||
Total: {pagination.total.toLocaleString()} crashes
|
Total: {pagination.total.toLocaleString()} crashes
|
||||||
@@ -125,19 +132,29 @@ export default function CrashDataControls({ crashDataHook, onDataLoaded }: Crash
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{pagination && yearFilter && (
|
{pagination && yearFilter && (
|
||||||
<div style={{ marginBottom: '6px', fontSize: '12px', color: '#ccc' }}>
|
<div style={{ marginBottom: '8px', fontSize: '13px', color: '#9ca3af', fontWeight: '500' }}>
|
||||||
All crashes for {yearFilter} loaded
|
All crashes for {yearFilter} loaded
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ marginBottom: '8px', color: '#ffff99' }}>
|
<div style={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#fbbf24',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ marginBottom: '8px', color: '#ff6666', fontSize: '12px' }}>
|
<div style={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
Error: {error}
|
Error: {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ 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
|
onMapPickingModeChange?: (isActive: boolean) => void; // callback when map picking mode changes
|
||||||
|
gradientRoutes?: boolean; // whether to use gradient routes or solid routes
|
||||||
|
mapStyleChoice?: 'dark' | 'streets'; // map style for panel theming
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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", onMapPickingModeChange }: Props) {
|
export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving", onMapPickingModeChange, gradientRoutes = true, mapStyleChoice = 'dark' }: 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);
|
||||||
@@ -54,6 +56,15 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
}
|
}
|
||||||
}, [isOriginMapPicking, isDestMapPicking, onMapPickingModeChange]);
|
}, [isOriginMapPicking, isDestMapPicking, onMapPickingModeChange]);
|
||||||
|
|
||||||
|
// Handle gradient routes setting changes
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || routes.length === 0) return;
|
||||||
|
|
||||||
|
// Re-render existing routes with updated gradient setting
|
||||||
|
renderMultipleRoutes(map, routes, selectedRouteIndex);
|
||||||
|
}, [gradientRoutes]);
|
||||||
|
|
||||||
// Handle map clicks for point selection
|
// Handle map clicks for point selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -223,7 +234,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
|
|
||||||
// Function to render multiple routes with different styles
|
// Function to render multiple routes with different styles
|
||||||
function renderMultipleRoutes(map: mapboxgl.Map, routes: any[], selectedIndex: number) {
|
function renderMultipleRoutes(map: mapboxgl.Map, routes: any[], selectedIndex: number) {
|
||||||
const routeColors = ['#2563eb', '#dc2626']; // blue, red
|
const routeColors = ['#2563eb', '#9333ea']; // blue, purple
|
||||||
const routeWidths = [6, 4]; // selected route is thicker
|
const routeWidths = [6, 4]; // selected route is thicker
|
||||||
const routeOpacities = [0.95, 0.7]; // selected route is more opaque
|
const routeOpacities = [0.95, 0.7]; // selected route is more opaque
|
||||||
|
|
||||||
@@ -274,8 +285,8 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply crash density gradient to all routes if crash data is available
|
// Apply crash density gradient to all routes if crash data is available and gradient routes enabled
|
||||||
if (crashDataHook.data.length > 0) {
|
if (gradientRoutes && crashDataHook.data.length > 0) {
|
||||||
const routeCoordinates = (route.geometry as any).coordinates as [number, number][];
|
const routeCoordinates = (route.geometry as any).coordinates as [number, number][];
|
||||||
const crashDensities = calculateRouteCrashDensity(routeCoordinates, crashDataHook.data, 150);
|
const crashDensities = calculateRouteCrashDensity(routeCoordinates, crashDataHook.data, 150);
|
||||||
const gradientStops = createRouteGradientStops(crashDensities);
|
const gradientStops = createRouteGradientStops(crashDensities);
|
||||||
@@ -285,7 +296,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
map.setPaintProperty(layerId, 'line-width', isSelected ? routeWidths[0] : routeWidths[1]);
|
map.setPaintProperty(layerId, 'line-width', isSelected ? routeWidths[0] : routeWidths[1]);
|
||||||
map.setPaintProperty(layerId, 'line-opacity', isSelected ? routeOpacities[0] : routeOpacities[1]);
|
map.setPaintProperty(layerId, 'line-opacity', isSelected ? routeOpacities[0] : routeOpacities[1]);
|
||||||
} else {
|
} else {
|
||||||
// Apply solid color styling when no crash data
|
// Apply solid color styling when gradient routes disabled or no crash data
|
||||||
map.setPaintProperty(layerId, 'line-gradient', undefined); // Remove gradient
|
map.setPaintProperty(layerId, 'line-gradient', undefined); // Remove gradient
|
||||||
map.setPaintProperty(layerId, 'line-color', routeColors[index] || routeColors[0]);
|
map.setPaintProperty(layerId, 'line-color', routeColors[index] || routeColors[0]);
|
||||||
map.setPaintProperty(layerId, 'line-width', isSelected ? routeWidths[0] : routeWidths[1]);
|
map.setPaintProperty(layerId, 'line-width', isSelected ? routeWidths[0] : routeWidths[1]);
|
||||||
@@ -303,7 +314,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
source: sourceId,
|
source: sourceId,
|
||||||
layout: { "line-join": "round", "line-cap": "round" },
|
layout: { "line-join": "round", "line-cap": "round" },
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#2563eb", // Blue outline
|
"line-color": "#60a5fa", // Light blue outline
|
||||||
"line-width": routeWidths[0] + 4, // Thicker than the main line
|
"line-width": routeWidths[0] + 4, // Thicker than the main line
|
||||||
"line-opacity": 0.8
|
"line-opacity": 0.8
|
||||||
},
|
},
|
||||||
@@ -553,22 +564,44 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
};
|
};
|
||||||
}, [collapsed, mapRef]);
|
}, [collapsed, mapRef]);
|
||||||
|
|
||||||
|
// Dynamic styling based on map style
|
||||||
|
const getSidebarClasses = () => {
|
||||||
|
const baseClasses = "relative flex flex-col z-40";
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return `${baseClasses} w-11 h-11 self-start m-3 rounded-full overflow-hidden bg-transparent`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use dark backgrounds for both themes for better contrast
|
||||||
|
return `${baseClasses} w-[340px] h-full rounded-tr-lg rounded-br-lg border-r` +
|
||||||
|
` bg-[var(--panel-darker)] border-[var(--panel-medium)]`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToggleButtonClasses = () => {
|
||||||
|
if (collapsed) {
|
||||||
|
// Dark button for collapsed state on both themes
|
||||||
|
return 'w-full h-full rounded-full text-[#f9fafb] flex items-center justify-center shadow-md border focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#9ca3af]' +
|
||||||
|
' bg-[var(--panel-dark)] border-[var(--panel-medium)]';
|
||||||
|
} else {
|
||||||
|
// Dark button for expanded state on both themes
|
||||||
|
return 'absolute top-3 right-3 -m-1 p-1 w-9 h-9 rounded-md text-[#f9fafb] border flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#9ca3af] z-50 pointer-events-auto' +
|
||||||
|
' bg-[var(--panel-dark)] border-[var(--panel-medium)] hover:bg-[var(--panel-medium)]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
role="region"
|
role="region"
|
||||||
aria-label="Directions sidebar"
|
aria-label="Directions sidebar"
|
||||||
className={`relative flex flex-col z-40 ${collapsed ? 'w-11 h-11 self-start m-3 rounded-full overflow-hidden bg-transparent' : 'w-[340px] h-full bg-[#1a1a1a] rounded-tr-lg rounded-br-lg border-r border-[#2a2a2a]'}`}
|
className={getSidebarClasses()}
|
||||||
>
|
>
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<button
|
<button
|
||||||
aria-label={collapsed ? 'Expand directions' : 'Collapse directions'}
|
aria-label={collapsed ? 'Expand directions' : 'Collapse directions'}
|
||||||
onClick={() => setCollapsed((s) => !s)}
|
onClick={() => setCollapsed((s) => !s)}
|
||||||
title={collapsed ? 'Expand directions' : 'Minimize directions'}
|
title={collapsed ? 'Expand directions' : 'Minimize directions'}
|
||||||
className={collapsed
|
className={getToggleButtonClasses()}
|
||||||
? 'w-full h-full rounded-full bg-[#f5f5f5] text-[#1f2937] flex items-center justify-center shadow-md border border-[#e0e0e0] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#9ca3af]'
|
|
||||||
: 'absolute top-3 right-3 -m-1 p-1 w-9 h-9 rounded-md bg-[#2a2a2a] text-[#d1d5db] border border-[#404040] flex items-center justify-center hover:bg-[#363636] focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[#9ca3af] z-50 pointer-events-auto'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* increase hit area with an inner svg and ensure cursor is pointer */}
|
{/* increase hit area with an inner svg and ensure cursor is pointer */}
|
||||||
<svg aria-hidden="true" className="w-5 h-5 pointer-events-none" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg aria-hidden="true" className="w-5 h-5 pointer-events-none" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -650,13 +683,13 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button onClick={handleGetRoute} disabled={loading} className="flex-1 px-4 py-2 rounded-lg bg-[#2563eb] hover:bg-[#1d4ed8] text-white shadow-md disabled:opacity-60 transition-colors">{loading ? 'Routing…' : 'Get Route'}</button>
|
<button onClick={handleGetRoute} disabled={loading} className="flex-1 px-4 py-2 rounded-lg text-white shadow-md disabled:opacity-60 transition-colors" style={{ backgroundColor: 'var(--panel-dark)' }}>{loading ? 'Routing…' : 'Get Route'}</button>
|
||||||
<button onClick={handleClear} className="px-4 py-2 rounded-lg border border-[#404040] bg-[#2a2a2a] text-sm text-[#d1d5db] hover:bg-[#363636]">Clear</button>
|
<button onClick={handleClear} className="px-4 py-2 rounded-lg border text-sm text-[#d1d5db] transition-colors" style={{ backgroundColor: 'var(--panel-medium)', borderColor: 'var(--panel-light)' }} onMouseEnter={(e) => (e.target as HTMLButtonElement).style.backgroundColor = 'var(--panel-light)'} onMouseLeave={(e) => (e.target as HTMLButtonElement).style.backgroundColor = 'var(--panel-medium)'}>Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route Options */}
|
{/* Route Options */}
|
||||||
{routes.length > 1 && (
|
{routes.length > 1 && (
|
||||||
<div className="mt-4 p-3 rounded-lg bg-[#2a2a2a] border border-[#404040]">
|
<div className="mt-4 p-3 rounded-lg border" style={{ backgroundColor: 'var(--panel-medium)', borderColor: 'var(--panel-light)' }}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<svg className="w-4 h-4 text-[#d1d5db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#d1d5db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m-6 3l6-3" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m-6 3l6-3" />
|
||||||
@@ -666,7 +699,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{routes.map((route, index) => {
|
{routes.map((route, index) => {
|
||||||
const isSelected = index === selectedRouteIndex;
|
const isSelected = index === selectedRouteIndex;
|
||||||
const colors = ['#2563eb', '#dc2626']; // blue, red
|
const colors = ['#2563eb', '#9333ea']; // blue, purple
|
||||||
const labels = ['Route 1 (Fastest)', 'Route 2 (Alternative)'];
|
const labels = ['Route 1 (Fastest)', 'Route 2 (Alternative)'];
|
||||||
const duration = Math.round(route.duration / 60);
|
const duration = Math.round(route.duration / 60);
|
||||||
const distance = Math.round(route.distance / 1000 * 10) / 10;
|
const distance = Math.round(route.distance / 1000 * 10) / 10;
|
||||||
@@ -682,9 +715,18 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
}}
|
}}
|
||||||
className={`w-full text-left p-2 rounded-md border transition-colors ${
|
className={`w-full text-left p-2 rounded-md border transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-[#2563eb] bg-[#1e40af]/20'
|
? 'border-[#2563eb]'
|
||||||
: 'border-[#404040] bg-[#1a1a1a] hover:bg-[#363636]'
|
: 'border-[var(--panel-light)]'
|
||||||
}`}
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? 'var(--panel-light)' : 'var(--panel-medium)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected) (e.target as HTMLButtonElement).style.backgroundColor = 'var(--panel-light)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected) (e.target as HTMLButtonElement).style.backgroundColor = 'var(--panel-medium)';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -717,7 +759,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
|
|
||||||
{/* Show reroute information if available */}
|
{/* Show reroute information if available */}
|
||||||
{rerouteInfo && (
|
{rerouteInfo && (
|
||||||
<div className="mt-4 p-3 rounded-lg bg-[#065f46] border border-[#10b981]">
|
<div className="mt-4 p-3 rounded-lg border border-[#10b981]" style={{ backgroundColor: 'var(--panel-dark)' }}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<svg className="w-4 h-4 text-[#10b981]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#10b981]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
@@ -747,7 +789,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
|
|
||||||
{/* Route Safety Legend */}
|
{/* Route Safety Legend */}
|
||||||
{(originCoord && destCoord) && (
|
{(originCoord && destCoord) && (
|
||||||
<div className="mt-4 p-3 rounded-lg bg-[#2a2a2a] border border-[#404040]">
|
<div className="mt-4 p-3 rounded-lg border" style={{ backgroundColor: 'var(--panel-medium)', borderColor: 'var(--panel-light)' }}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<svg className="w-4 h-4 text-[#d1d5db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-[#d1d5db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
@@ -768,7 +810,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
<span className="text-[#d1d5db]">High risk</span>
|
<span className="text-[#d1d5db]">High risk</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-1 bg-[#dc2626] rounded"></div>
|
<div className="w-4 h-1 bg-[#9333ea] rounded"></div>
|
||||||
<span className="text-[#d1d5db]">Very high risk</span>
|
<span className="text-[#d1d5db]">Very high risk</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -784,7 +826,7 @@ export default function DirectionsSidebar({ mapRef, profile = "mapbox/driving",
|
|||||||
|
|
||||||
{/* Map picking mode indicator */}
|
{/* Map picking mode indicator */}
|
||||||
{(isOriginMapPicking || isDestMapPicking) && (
|
{(isOriginMapPicking || isDestMapPicking) && (
|
||||||
<div className="mt-4 p-3 rounded-lg bg-[#1e40af] border border-[#3b82f6]">
|
<div className="mt-4 p-3 rounded-lg border" style={{ backgroundColor: 'var(--panel-dark)', borderColor: '#3b82f6' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5 text-[#93c5fd]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-[#93c5fd]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function GeocodeInput({
|
|||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
{/* Search bar container matching the design */}
|
{/* Search bar container matching the design */}
|
||||||
<div className="flex items-center bg-[#2a2a2a] border border-[#404040] rounded-lg overflow-hidden">
|
<div className="flex items-center border rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--panel-medium)', borderColor: 'var(--panel-light)' }}>
|
||||||
{/* Input field */}
|
{/* Input field */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -181,7 +181,10 @@ export default function GeocodeInput({
|
|||||||
onMapPick();
|
onMapPick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-4 py-3 bg-[#f5f5f5] text-[#1f2937] hover:bg-[#e5e7eb] focus:outline-none focus:ring-2 focus:ring-[#9ca3af] focus:ring-offset-2 focus:ring-offset-[#2a2a2a] transition-colors"
|
className="px-4 py-3 text-[#1f2937] focus:outline-none focus:ring-2 focus:ring-[#9ca3af] focus:ring-offset-2 transition-colors hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--panel-lightest)'
|
||||||
|
}}
|
||||||
title={isMapPickingMode ? "Cancel map picking" : "Pick point on map"}
|
title={isMapPickingMode ? "Cancel map picking" : "Pick point on map"}
|
||||||
>
|
>
|
||||||
{isMapPickingMode ? (
|
{isMapPickingMode ? (
|
||||||
@@ -201,11 +204,14 @@ export default function GeocodeInput({
|
|||||||
|
|
||||||
{/* Suggestions dropdown */}
|
{/* Suggestions dropdown */}
|
||||||
{!isMapPickingMode && showDropdown && suggestions.length > 0 && (
|
{!isMapPickingMode && showDropdown && suggestions.length > 0 && (
|
||||||
<div className="absolute left-0 right-0 mt-1 bg-[#2a2a2a] border border-[#404040] rounded-lg shadow-lg overflow-hidden z-50 max-h-64 overflow-y-auto">
|
<div className="absolute left-0 right-0 mt-1 border rounded-lg shadow-lg overflow-hidden z-50 max-h-64 overflow-y-auto" style={{ backgroundColor: 'var(--panel-medium)', borderColor: 'var(--panel-light)' }}>
|
||||||
{suggestions.map((f: any, i: number) => (
|
{suggestions.map((f: any, i: number) => (
|
||||||
<button
|
<button
|
||||||
key={f.id || i}
|
key={f.id || i}
|
||||||
className="w-full text-left px-4 py-3 hover:bg-[#3a3a3a] border-b border-[#404040] last:border-b-0"
|
className="w-full text-left px-4 py-3 border-b last:border-b-0 transition-colors"
|
||||||
|
style={{ borderColor: 'var(--panel-light)' }}
|
||||||
|
onMouseEnter={(e) => (e.target as HTMLButtonElement).style.backgroundColor = 'var(--panel-light)'}
|
||||||
|
onMouseLeave={(e) => (e.target as HTMLButtonElement).style.backgroundColor = 'transparent'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(f);
|
onSelect(f);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ export default function Legend() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ position: 'absolute', bottom: '580px', right: '12px', zIndex: 30 }}>
|
<div style={{ position: 'absolute', bottom: '580px', right: '12px', zIndex: 30 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: 'rgba(26, 26, 26, 0.95)',
|
backgroundColor: 'var(--panel-darker)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
boxShadow: '0 6px 18px rgba(0,0,0,0.15)',
|
boxShadow: '0 6px 18px rgba(0,0,0,0.15)',
|
||||||
border: '1px solid rgba(64, 64, 64, 0.5)',
|
border: '1px solid var(--panel-medium)',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
width: '240px',
|
width: '240px',
|
||||||
backdropFilter: 'blur(8px)'
|
backdropFilter: 'blur(8px)'
|
||||||
|
|||||||
377
web/src/app/components/UnifiedControlPanel.tsx
Normal file
377
web/src/app/components/UnifiedControlPanel.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { UseCrashDataResult } from '../hooks/useCrashData';
|
||||||
|
|
||||||
|
interface UnifiedControlPanelProps {
|
||||||
|
// Map controls props
|
||||||
|
mapStyleChoice: 'dark' | 'streets';
|
||||||
|
onChangeStyle: (v: 'dark' | 'streets') => void;
|
||||||
|
heatVisible: boolean;
|
||||||
|
onToggleHeat: (v: boolean) => void;
|
||||||
|
pointsVisible: boolean;
|
||||||
|
onTogglePoints: (v: boolean) => void;
|
||||||
|
heatRadius: number;
|
||||||
|
onChangeRadius: (v: number) => void;
|
||||||
|
heatIntensity: number;
|
||||||
|
onChangeIntensity: (v: number) => void;
|
||||||
|
gradientRoutes: boolean;
|
||||||
|
onToggleGradientRoutes: (v: boolean) => void;
|
||||||
|
|
||||||
|
// Crash data controls props
|
||||||
|
crashDataHook: UseCrashDataResult;
|
||||||
|
onDataLoaded?: (dataCount: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnifiedControlPanel({
|
||||||
|
mapStyleChoice,
|
||||||
|
onChangeStyle,
|
||||||
|
heatVisible,
|
||||||
|
onToggleHeat,
|
||||||
|
pointsVisible,
|
||||||
|
onTogglePoints,
|
||||||
|
heatRadius,
|
||||||
|
onChangeRadius,
|
||||||
|
heatIntensity,
|
||||||
|
onChangeIntensity,
|
||||||
|
gradientRoutes,
|
||||||
|
onToggleGradientRoutes,
|
||||||
|
crashDataHook,
|
||||||
|
onDataLoaded
|
||||||
|
}: UnifiedControlPanelProps) {
|
||||||
|
// Panel state management
|
||||||
|
const [mainPanelOpen, setMainPanelOpen] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
const v = typeof window !== 'undefined' ? window.localStorage.getItem('unified_panel_open') : null;
|
||||||
|
return v === null ? true : v === '1';
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [mapControlsOpen, setMapControlsOpen] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_controls_section_open') : null;
|
||||||
|
return v === null ? true : v === '1';
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [crashDataOpen, setCrashDataOpen] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
const v = typeof window !== 'undefined' ? window.localStorage.getItem('crash_data_section_open') : null;
|
||||||
|
return v === null ? true : v === '1';
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crash data state
|
||||||
|
const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
|
||||||
|
const currentYear = new Date().getFullYear().toString();
|
||||||
|
const [selectedYear, setSelectedYear] = useState<string>(yearFilter || currentYear);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (onDataLoaded) {
|
||||||
|
onDataLoaded(data.length);
|
||||||
|
}
|
||||||
|
}, [data.length, onDataLoaded]);
|
||||||
|
|
||||||
|
const handleYearChange = (year: string) => {
|
||||||
|
setSelectedYear(year);
|
||||||
|
const filterYear = year === 'all' ? null : year;
|
||||||
|
if (setYearFilter) {
|
||||||
|
setYearFilter(filterYear);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMainPanel = (next: boolean) => {
|
||||||
|
setMainPanelOpen(next);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('unified_panel_open', next ? '1' : '0');
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMapControls = (next: boolean) => {
|
||||||
|
setMapControlsOpen(next);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('map_controls_section_open', next ? '1' : '0');
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCrashData = (next: boolean) => {
|
||||||
|
setCrashDataOpen(next);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('crash_data_section_open', next ? '1' : '0');
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelStyle = {
|
||||||
|
backgroundColor: 'var(--panel-darker)',
|
||||||
|
color: '#f9fafb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.4), 0 4px 12px rgba(0,0,0,0.2)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
zIndex: 20,
|
||||||
|
fontWeight: '500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectStyle = {
|
||||||
|
backgroundColor: 'var(--panel-dark)',
|
||||||
|
color: '#f9fafb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
outline: 'none'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionHeaderStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '12px',
|
||||||
|
paddingBottom: '8px',
|
||||||
|
borderBottom: '1px solid var(--panel-medium)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleButtonStyle = {
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: 'var(--panel-dark)',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
border: '1px solid var(--panel-medium)',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="map-control" style={{
|
||||||
|
...panelStyle,
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '50px',
|
||||||
|
right: '12px',
|
||||||
|
width: '280px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
{/* Main panel header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '16px', color: '#f9fafb' }}>Control Panel</div>
|
||||||
|
<button
|
||||||
|
aria-expanded={mainPanelOpen}
|
||||||
|
aria-label={mainPanelOpen ? 'Collapse panel' : 'Expand panel'}
|
||||||
|
onClick={() => toggleMainPanel(!mainPanelOpen)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: 'var(--panel-dark)',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
border: '2px solid var(--panel-medium)',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mainPanelOpen ? '−' : '+'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mainPanelOpen && (
|
||||||
|
<>
|
||||||
|
{/* Map Controls Section */}
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Map Controls</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMapControls(!mapControlsOpen)}
|
||||||
|
style={toggleButtonStyle}
|
||||||
|
aria-expanded={mapControlsOpen}
|
||||||
|
>
|
||||||
|
{mapControlsOpen ? '−' : '+'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapControlsOpen && (
|
||||||
|
<div style={{ paddingLeft: '8px' }}>
|
||||||
|
<div className="mc-row">
|
||||||
|
<label className="mc-label">Style</label>
|
||||||
|
<select className="map-select" style={selectStyle} value={mapStyleChoice} onChange={(e) => onChangeStyle(e.target.value as 'dark' | 'streets')}>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="streets">Streets</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mc-row">
|
||||||
|
<label className="mc-label">Heatmap</label>
|
||||||
|
<input type="checkbox" checked={heatVisible} onChange={(e) => onToggleHeat(e.target.checked)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mc-row">
|
||||||
|
<label className="mc-label">Points</label>
|
||||||
|
<input type="checkbox" checked={pointsVisible} onChange={(e) => onTogglePoints(e.target.checked)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mc-row">
|
||||||
|
<label className="mc-label">Gradient Routes</label>
|
||||||
|
<input type="checkbox" checked={gradientRoutes} onChange={(e) => onToggleGradientRoutes(e.target.checked)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 6 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 12 }}>Radius: {heatRadius}</label>
|
||||||
|
<input className="mc-range" type="range" min={5} max={100} value={heatRadius} onChange={(e) => onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 6 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 12 }}>Intensity: {heatIntensity}</label>
|
||||||
|
<input className="mc-range" type="range" min={0.1} max={5} step={0.1} value={heatIntensity} onChange={(e) => onChangeIntensity(Number(e.target.value))} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.9, marginTop: 8 }}>Tip: switching style will reapply layers.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Crash Data Controls Section */}
|
||||||
|
<div>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '14px', color: '#f9fafb' }}>Crash Data</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCrashData(!crashDataOpen)}
|
||||||
|
style={toggleButtonStyle}
|
||||||
|
aria-expanded={crashDataOpen}
|
||||||
|
>
|
||||||
|
{crashDataOpen ? '−' : '+'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{crashDataOpen && (
|
||||||
|
<div style={{ paddingLeft: '8px' }}>
|
||||||
|
{/* Crash Density Legend */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, marginBottom: '10px', color: '#f9fafb' }}>Density Legend</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<div style={{ width: 18, height: 12, background: 'rgba(0,0,0,0)', border: '1px solid rgba(249, 250, 251, 0.4)', borderRadius: '2px' }} />
|
||||||
|
<div style={{ width: 18, height: 12, background: 'rgba(255,255,0,0.8)', borderRadius: '2px' }} />
|
||||||
|
<div style={{ width: 18, height: 12, background: 'rgba(255,165,0,0.85)', borderRadius: '2px' }} />
|
||||||
|
<div style={{ width: 18, height: 12, background: 'rgba(255,69,0,0.9)', borderRadius: '2px' }} />
|
||||||
|
<div style={{ width: 18, height: 12, background: 'rgba(255,0,0,0.95)', borderRadius: '2px' }} />
|
||||||
|
<div style={{ width: 18, height: 12, background: 'rgba(139,0,0,1)', borderRadius: '2px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#ffffff', fontWeight: '600' }}>Low</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#ffffff', fontWeight: '600' }}>High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Year Filter */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#e5e7eb', fontWeight: '600' }}>
|
||||||
|
Filter by Year:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={yearFilter || ''}
|
||||||
|
onChange={(e) => handleYearChange(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
|
<option value="">All Years</option>
|
||||||
|
{Array.from({ length: 2025 - 2015 + 1 }, (_, i) => 2015 + i).map(year => (
|
||||||
|
<option key={year} value={year} style={{ backgroundColor: 'var(--panel-dark)', color: '#f9fafb' }}>
|
||||||
|
{year}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Status */}
|
||||||
|
<div style={{ marginBottom: '12px', color: '#f9fafb', fontWeight: '600', fontSize: '14px' }}>
|
||||||
|
Loaded: {data.length.toLocaleString()} crashes
|
||||||
|
{yearFilter && ` (${yearFilter})`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pagination && !yearFilter && (
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#9ca3af', fontWeight: '500' }}>
|
||||||
|
Page {pagination.page} of {pagination.totalPages}
|
||||||
|
<br />
|
||||||
|
Total: {pagination.total.toLocaleString()} crashes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pagination && yearFilter && (
|
||||||
|
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#9ca3af', fontWeight: '500' }}>
|
||||||
|
All crashes for {yearFilter} loaded
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#fbbf24',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
{pagination?.hasNext && !yearFilter && (
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
backgroundColor: loading ? 'rgba(102, 102, 102, 0.8)' : 'var(--panel-dark)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid var(--panel-medium)',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background-color 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
backgroundColor: loading ? 'rgba(102, 102, 102, 0.8)' : 'var(--panel-dark)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid var(--panel-medium)',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background-color 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@
|
|||||||
--text-secondary: #4b5563;
|
--text-secondary: #4b5563;
|
||||||
--text-tertiary: #6b7280;
|
--text-tertiary: #6b7280;
|
||||||
--text-muted: #9ca3af;
|
--text-muted: #9ca3af;
|
||||||
|
|
||||||
|
/* New color palette for panels and sidebars */
|
||||||
|
--panel-lightest: #E4E4E5;
|
||||||
|
--panel-light: #BEBEBE;
|
||||||
|
--panel-medium: #949494;
|
||||||
|
--panel-dark: #7A7A7A;
|
||||||
|
--panel-darker: #545454;
|
||||||
|
--panel-darkest: #3C3C3C;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -44,6 +52,14 @@
|
|||||||
--text-secondary: #d1d5db;
|
--text-secondary: #d1d5db;
|
||||||
--text-tertiary: #a1a1aa;
|
--text-tertiary: #a1a1aa;
|
||||||
--text-muted: #71717a;
|
--text-muted: #71717a;
|
||||||
|
|
||||||
|
/* New color palette for panels and sidebars - darker versions for dark mode */
|
||||||
|
--panel-lightest: #7A7A7A;
|
||||||
|
--panel-light: #545454;
|
||||||
|
--panel-medium: #3C3C3C;
|
||||||
|
--panel-dark: #2A2A2A;
|
||||||
|
--panel-darker: #1F1F1F;
|
||||||
|
--panel-darkest: #141414;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import MapView, { PopupData } from './components/MapView';
|
import MapView, { PopupData } from './components/MapView';
|
||||||
import ControlsPanel from './components/ControlsPanel';
|
import UnifiedControlPanel from './components/UnifiedControlPanel';
|
||||||
import PopupOverlay from './components/PopupOverlay';
|
import PopupOverlay from './components/PopupOverlay';
|
||||||
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 { useCrashData } from './hooks/useCrashData';
|
import { useCrashData } from './hooks/useCrashData';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -16,9 +15,8 @@ export default function Home() {
|
|||||||
const [mapStyleChoice, setMapStyleChoice] = useState<'dark' | 'streets'>('dark');
|
const [mapStyleChoice, setMapStyleChoice] = useState<'dark' | 'streets'>('dark');
|
||||||
const [heatRadius, setHeatRadius] = useState(16);
|
const [heatRadius, setHeatRadius] = useState(16);
|
||||||
const [heatIntensity, setHeatIntensity] = useState(1);
|
const [heatIntensity, setHeatIntensity] = useState(1);
|
||||||
const [panelOpen, setPanelOpen] = useState<boolean>(() => {
|
const [gradientRoutes, setGradientRoutes] = useState(true);
|
||||||
try { const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_panel_open') : null; return v === null ? true : v === '1'; } catch (e) { return true; }
|
|
||||||
});
|
|
||||||
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);
|
const [isMapPickingMode, setIsMapPickingMode] = useState(false);
|
||||||
@@ -35,11 +33,11 @@ export default function Home() {
|
|||||||
mapRef={mapRef}
|
mapRef={mapRef}
|
||||||
profile="mapbox/driving"
|
profile="mapbox/driving"
|
||||||
onMapPickingModeChange={setIsMapPickingMode}
|
onMapPickingModeChange={setIsMapPickingMode}
|
||||||
|
gradientRoutes={gradientRoutes}
|
||||||
|
mapStyleChoice={mapStyleChoice}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ControlsPanel
|
<UnifiedControlPanel
|
||||||
panelOpen={panelOpen}
|
|
||||||
onTogglePanel={(next) => { setPanelOpen(next); try { window.localStorage.setItem('map_panel_open', next ? '1' : '0'); } catch (e) {} }}
|
|
||||||
mapStyleChoice={mapStyleChoice}
|
mapStyleChoice={mapStyleChoice}
|
||||||
onChangeStyle={(v) => setMapStyleChoice(v)}
|
onChangeStyle={(v) => setMapStyleChoice(v)}
|
||||||
heatVisible={heatVisible}
|
heatVisible={heatVisible}
|
||||||
@@ -50,6 +48,9 @@ export default function Home() {
|
|||||||
onChangeRadius={(v) => setHeatRadius(v)}
|
onChangeRadius={(v) => setHeatRadius(v)}
|
||||||
heatIntensity={heatIntensity}
|
heatIntensity={heatIntensity}
|
||||||
onChangeIntensity={(v) => setHeatIntensity(v)}
|
onChangeIntensity={(v) => setHeatIntensity(v)}
|
||||||
|
gradientRoutes={gradientRoutes}
|
||||||
|
onToggleGradientRoutes={(v) => setGradientRoutes(v)}
|
||||||
|
crashDataHook={crashDataHook}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MapView
|
<MapView
|
||||||
@@ -69,8 +70,6 @@ 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 with integrated crash density legend */}
|
|
||||||
<CrashDataControls crashDataHook={crashDataHook} />
|
|
||||||
<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