import { useState, useEffect } from 'react'; import axios from 'axios'; import './App.css'; import 'react-leaflet-cluster/dist/assets/MarkerCluster.css'; import 'react-leaflet-cluster/dist/assets/MarkerCluster.Default.css'; import FileUpload from './components/FileUpload'; import FilterPanel from './components/FilterPanel'; import MapDisplay from './components/MapDisplay'; import ErrorBoundary from './components/ErrorBoundary'; import PlzSelector from './components/PlzSelector'; // Define types for our state export interface FilterOptions { [key: string]: string[]; } export interface TooltipColumn { id: string; name: string; visible: boolean; } export interface HeatmapPoint { plz: string; lat: number; lon: number; count: number; attributes_summary?: Record; } export interface StaffLocation { name: string; plz: string; lat: number; lon: number; } export interface StaffData { sales: StaffLocation[]; technicians: StaffLocation[]; } export type MapMode = 'points' | 'heatmap'; function App() { const [filters, setFilters] = useState({}); const [heatmapData, setHeatmapData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [radiusMultiplier, setRadiusMultiplier] = useState(1); const [viewMode, setViewMode] = useState('points'); const [plzColumnNeeded, setPlzColumnNeeded] = useState(false); const [availableColumns, setAvailableColumns] = useState([]); const [tooltipColumns, setTooltipColumns] = useState([]); const [staffData, setStaffData] = useState({ sales: [], technicians: [] }); const [showSales, setShowSales] = useState(false); const [showTechnicians, setShowTechnicians] = useState(false); const [travelTime, setTravelTime] = useState(60); // In minutes const [averageSpeed, setAverageSpeed] = useState(80); // In km/h const [isochrones, setIsochrones] = useState>({}); const [orsError, setOrsError] = useState(null); // Derived travel radius in km (fallback) const travelRadius = (travelTime / 60) * averageSpeed; useEffect(() => { const fetchStaffLocations = async () => { try { const response = await axios.get('/heatmap/api/staff-locations'); setStaffData(response.data); } catch (err) { console.error("Failed to fetch staff locations:", err); } }; fetchStaffLocations(); }, []); // Fetch isochrones when settings change useEffect(() => { if (!showSales && !showTechnicians) return; const fetchAllIsochrones = async () => { const activeStaff = [ ...(showSales ? staffData.sales : []), ...(showTechnicians ? staffData.technicians : []) ]; if (activeStaff.length === 0) return; const newIsochrones: Record = { ...isochrones }; let anyOrsError = false; for (const person of activeStaff) { // Skip if already fetched for this person and time if (newIsochrones[person.name] && newIsochrones[person.name].minutes === travelTime) { continue; } try { const response = await axios.get('/heatmap/api/isochrones', { params: { lat: person.lat, lon: person.lon, minutes: travelTime } }); if (response.data.simulated || response.data.error) { setOrsError("OpenRouteService API Key missing or invalid. Showing circular fallback."); anyOrsError = true; break; } newIsochrones[person.name] = { data: response.data, minutes: travelTime }; } catch (err) { console.error(`Failed to fetch isochrone for ${person.name}:`, err); // Don't set anyOrsError here, just let this one person use the fallback } } setIsochrones(newIsochrones); if (!anyOrsError) setOrsError(null); }; const timer = setTimeout(fetchAllIsochrones, 800); // Debounce return () => clearTimeout(timer); }, [travelTime, showSales, showTechnicians, staffData]); const handleUploadSuccess = (response: any) => { setError(null); if (response.plz_column_needed) { setAvailableColumns(response.columns); setPlzColumnNeeded(true); setFilters({}); setHeatmapData([]); setTooltipColumns([]); } else { setPlzColumnNeeded(false); const newFilters = response.filters; setFilters(newFilters); // Initialize tooltip columns based on filters setTooltipColumns(Object.keys(newFilters).map(name => ({ id: name, name, visible: true }))); setHeatmapData([]); // Clear previous heatmap data // Automatically fetch data with no filters on successful upload // Pass initial tooltip config handleFilterChange({}, Object.keys(newFilters).map(name => ({ id: name, name, visible: true }))); } }; const handlePlzColumnSubmit = async (selectedColumn: string) => { setIsLoading(true); setError(null); try { const response = await axios.post('/heatmap/api/set-plz-column', { plz_column: selectedColumn, }); handleUploadSuccess(response.data); // Re-use the success handler } catch (error: any) { if (axios.isAxiosError(error) && error.response) { setError(`Failed to set PLZ column: ${error.response.data.detail || error.message}`); } else { setError(`Failed to set PLZ column: ${error.message}`); } } finally { setIsLoading(false); } }; const handleFilterChange = async (selectedFilters: FilterOptions, currentTooltipConfig: TooltipColumn[]) => { setIsLoading(true); setError(null); try { const response = await axios.post('/heatmap/api/heatmap', { filters: selectedFilters, tooltip_config: currentTooltipConfig, // Pass tooltip config to backend }); setHeatmapData(response.data); } catch (error: any) { if (axios.isAxiosError(error) && error.response) { setError(`Failed to fetch heatmap data: ${error.response.data.detail || error.message}`); } else { setError(`Failed to fetch heatmap data: ${error.message}`); } setHeatmapData([]); // Clear data on error } finally { setIsLoading(false); } }; // Need to re-fetch data when tooltip config changes. // Optimization: In a real app, we might debounce this or add an "Apply" button for sorting. // For now, let's keep it manual via the "Apply Filters" button in the FilterPanel to avoid excessive API calls while dragging. // The FilterPanel calls onFilterChange when "Apply" is clicked, passing both filters and the current tooltipColumns. return (

German PLZ Heatmap Tool

{plzColumnNeeded ? ( ) : ( <> handleFilterChange(selectedFilters, tooltipColumns)} isLoading={isLoading} />

Map Settings

{(showSales || showTechnicians) && (
{orsError ? ( <>
setAverageSpeed(parseInt(e.target.value))} style={{ width: '100%' }} />
{orsError}
Resulting Radius: {travelRadius.toFixed(1)} km
) : (
Using real road data (OpenRouteService)
)}
)} setRadiusMultiplier(parseFloat(e.target.value))} style={{ width: '100%' }} disabled={viewMode === 'heatmap'} />
)}
{isLoading ? (

Loading map data...

) : error ? (

{error}

) : ( )}
); } export default App;