336 lines
13 KiB
TypeScript
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;
|