Files
Brancheneinstufung2/heatmap-tool/frontend/src/App.tsx

336 lines
13 KiB
TypeScript

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<string, string[]>;
}
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<FilterOptions>({});
const [heatmapData, setHeatmapData] = useState<HeatmapPoint[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [radiusMultiplier, setRadiusMultiplier] = useState(1);
const [viewMode, setViewMode] = useState<MapMode>('points');
const [plzColumnNeeded, setPlzColumnNeeded] = useState(false);
const [availableColumns, setAvailableColumns] = useState<string[]>([]);
const [tooltipColumns, setTooltipColumns] = useState<TooltipColumn[]>([]);
const [staffData, setStaffData] = useState<StaffData>({ 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<Record<string, any>>({});
const [orsError, setOrsError] = useState<string | null>(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<string, any> = { ...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 (
<ErrorBoundary>
<div className="App">
<header className="App-header">
<h1>German PLZ Heatmap Tool</h1>
</header>
<main className="App-main">
<div className="control-panel">
<FileUpload
onUploadSuccess={handleUploadSuccess}
setIsLoading={setIsLoading}
setError={setError}
/>
{plzColumnNeeded ? (
<PlzSelector
columns={availableColumns}
onSubmit={handlePlzColumnSubmit}
isLoading={isLoading}
/>
) : (
<>
<FilterPanel
filters={filters}
tooltipColumns={tooltipColumns}
setTooltipColumns={setTooltipColumns}
onFilterChange={(selectedFilters) => handleFilterChange(selectedFilters, tooltipColumns)}
isLoading={isLoading}
/>
<div className="map-controls" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #555' }}>
<h3>Map Settings</h3>
<div className="toggle-switch" style={{ marginBottom: '15px' }}>
<label>
<input type="radio" value="points" checked={viewMode === 'points'} onChange={() => setViewMode('points')} />
Points
</label>
<label>
<input type="radio" value="heatmap" checked={viewMode === 'heatmap'} onChange={() => setViewMode('heatmap')} />
Heatmap
</label>
</div>
<div className="staff-toggles" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>
<input type="checkbox" checked={showSales} onChange={(e) => setShowSales(e.target.checked)} />
Show Sales Reps
</label>
<label style={{ display: 'block', marginBottom: '15px' }}>
<input type="checkbox" checked={showTechnicians} onChange={(e) => setShowTechnicians(e.target.checked)} />
Show Technicians
</label>
</div>
{(showSales || showTechnicians) && (
<div className="radius-control" style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#444', borderRadius: '4px' }}>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="travel-time">Travel Time: {travelTime} min (max 60)</label>
<input
type="range"
id="travel-time"
min="15"
max="60"
step="5"
value={travelTime}
</div>
{orsError ? (
<>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="avg-speed">Avg. Speed: {averageSpeed} km/h</label>
<input
type="range"
id="avg-speed"
min="30"
max="120"
step="5"
value={averageSpeed}
onChange={(e) => setAverageSpeed(parseInt(e.target.value))}
style={{ width: '100%' }}
/>
</div>
<div style={{ fontSize: '0.8em', color: '#ffcc00', marginBottom: '5px' }}>
{orsError}
</div>
<div style={{ fontSize: '0.9em', color: '#ccc', textAlign: 'center', borderTop: '1px solid #666', paddingTop: '5px' }}>
Resulting Radius: <strong>{travelRadius.toFixed(1)} km</strong>
</div>
</>
) : (
<div style={{ fontSize: '0.8em', color: '#4CAF50' }}>
Using real road data (OpenRouteService)
</div>
)}
</div>
)}
<label htmlFor="radius-slider">Marker Size: {radiusMultiplier.toFixed(1)}x</label>
<input
type="range"
id="radius-slider"
min="0.1"
max="5"
step="0.1"
value={radiusMultiplier}
onChange={(e) => setRadiusMultiplier(parseFloat(e.target.value))}
style={{ width: '100%' }}
disabled={viewMode === 'heatmap'}
/>
</div>
</>
)}
</div>
<div className="map-container">
{isLoading ? (
<p>Loading map data...</p>
) : error ? (
<p className="error">{error}</p>
) : (
<MapDisplay
heatmapData={heatmapData}
radiusMultiplier={radiusMultiplier}
viewMode={viewMode}
tooltipColumns={tooltipColumns}
staffData={staffData}
showSales={showSales}
showTechnicians={showTechnicians}
travelRadius={travelRadius}
isochrones={isochrones}
/>
)}
</div>
</main>
</div>
</ErrorBoundary>
);
}
export default App;