[31b88f42] Finalize staff locations and travel time logic

This commit is contained in:
2026-03-09 14:44:10 +00:00
parent 9afe4148ba
commit 2c90da3ba5
4 changed files with 185 additions and 28 deletions

View File

@@ -1,7 +1,9 @@
import requests
import os
import io
import pandas as pd
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import pandas as pd
import io
from pydantic import BaseModel
from typing import Dict, List
@@ -21,6 +23,12 @@ df_storage = None
plz_column_name = None
plz_geocoord_df = None
# OpenRouteService API Key (should be in .env)
ORS_API_KEY = os.getenv("ORS_API_KEY")
if ORS_API_KEY:
# Strip quotes if they were passed literally from .env
ORS_API_KEY = ORS_API_KEY.strip("'\"")
STAFF_DATA = {
"sales": [
{"name": "Alexander Kiss", "plz": "86911"},
@@ -37,6 +45,43 @@ STAFF_DATA = {
]
}
# --- Pydantic Models ---
class IsochroneRequest(BaseModel):
locations: List[Dict[str, float]] # List of {"lat": ..., "lon": ...}
range_minutes: int
@app.get("/api/isochrones")
async def get_isochrones(lat: float, lon: float, minutes: int):
if not ORS_API_KEY:
# Return a warning or a simple "simulated" isochrone if no key is provided
return {"error": "ORS_API_KEY missing", "simulated": True}
url = "https://api.openrouteservice.org/v2/isochrones/driving-car"
headers = {
'Accept': 'application/json, application/geo+json, application/gpx+xml, img/png; charset=utf-8',
'Authorization': ORS_API_KEY,
'Content-Type': 'application/json; charset=utf-8'
}
# ORS expects [lon, lat]
body = {
"locations": [[lon, lat]],
"range": [minutes * 60], # range is in seconds
"range_type": "time"
}
try:
response = requests.post(url, json=body, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"ORS Error: {response.status_code} - {response.text}")
raise HTTPException(status_code=response.status_code, detail=f"ORS API error: {response.text}")
except Exception as e:
print(f"Request failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.on_event("startup")
def load_plz_data():
global plz_geocoord_df
@@ -75,10 +120,14 @@ class PlzColumnRequest(BaseModel):
def read_root():
return {"message": "Heatmap Tool Backend"}
@app.get("/health")
def health_check():
return {"status": "ok", "geocoding_loaded": not plz_geocoord_df.empty if plz_geocoord_df is not None else False}
@app.get("/api/staff-locations")
async def get_staff_locations():
global plz_geocoord_df
if plz_geocoord_df.empty:
if plz_geocoord_df is None or plz_geocoord_df.empty:
raise HTTPException(status_code=500, detail="Geocoding data is not available on the server.")
result = {"sales": [], "technicians": []}
@@ -88,6 +137,10 @@ async def get_staff_locations():
plz = person["plz"]
if plz in plz_geocoord_df.index:
coords = plz_geocoord_df.loc[plz]
# If there are duplicates, loc returns a DataFrame. Take the first row.
if isinstance(coords, pd.DataFrame):
coords = coords.iloc[0]
result[category].append({
"name": person["name"],
"plz": plz,

View File

@@ -3,3 +3,4 @@ uvicorn[standard]
pandas
openpyxl
python-multipart
requests

View File

@@ -57,7 +57,13 @@ function App() {
const [staffData, setStaffData] = useState<StaffData>({ sales: [], technicians: [] });
const [showSales, setShowSales] = useState(false);
const [showTechnicians, setShowTechnicians] = useState(false);
const [travelRadius, setTravelRadius] = useState(50); // In km
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 () => {
@@ -71,6 +77,56 @@ function App() {
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) {
@@ -194,18 +250,44 @@ function App() {
</div>
{(showSales || showTechnicians) && (
<div className="radius-control" style={{ marginBottom: '20px' }}>
<label htmlFor="travel-radius">Travel Radius: {travelRadius} km</label>
<input
type="range"
id="travel-radius"
min="10"
max="200"
step="5"
value={travelRadius}
onChange={(e) => setTravelRadius(parseInt(e.target.value))}
style={{ width: '100%' }}
/>
<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>
)}
@@ -240,6 +322,7 @@ function App() {
showSales={showSales}
showTechnicians={showTechnicians}
travelRadius={travelRadius}
isochrones={isochrones}
/>
)}
</div>

View File

@@ -1,6 +1,6 @@
// src/components/MapDisplay.tsx
import React from 'react';
import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle } from 'react-leaflet';
import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle, GeoJSON } from 'react-leaflet';
import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3';
import 'leaflet/dist/leaflet.css';
import 'leaflet.heat';
@@ -25,6 +25,7 @@ interface MapDisplayProps {
showSales: boolean;
showTechnicians: boolean;
travelRadius: number;
isochrones?: Record<string, any>;
}
const MapDisplay: React.FC<MapDisplayProps> = ({
@@ -35,7 +36,8 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
staffData,
showSales,
showTechnicians,
travelRadius
travelRadius,
isochrones = {}
}) => {
const germanyCenter: [number, number] = [51.1657, 10.4515];
const maxCount = heatmapData.length > 0 ? Math.max(...heatmapData.map(p => p.count), 1) : 1;
@@ -120,16 +122,25 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
if (showSales) {
staffData.sales.forEach((person, idx) => {
const iso = isochrones[person.name];
elements.push(
<React.Fragment key={`sales-${idx}`}>
<Marker position={[person.lat, person.lon]} icon={salesIcon}>
<Tooltip permanent={false}>Sales: {person.name} ({person.plz})</Tooltip>
</Marker>
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.1 }}
/>
{iso ? (
<GeoJSON
key={`iso-sales-${person.name}-${iso.minutes}`}
data={iso.data}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.2, weight: 1 }}
/>
) : (
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.1 }}
/>
)}
</React.Fragment>
);
});
@@ -137,16 +148,25 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
if (showTechnicians) {
staffData.technicians.forEach((person, idx) => {
const iso = isochrones[person.name];
elements.push(
<React.Fragment key={`tech-${idx}`}>
<Marker position={[person.lat, person.lon]} icon={technicianIcon}>
<Tooltip permanent={false}>Tech: {person.name} ({person.plz})</Tooltip>
</Marker>
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.1 }}
/>
{iso ? (
<GeoJSON
key={`iso-tech-${person.name}-${iso.minutes}`}
data={iso.data}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.2, weight: 1 }}
/>
) : (
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.1 }}
/>
)}
</React.Fragment>
);
});