[31b88f42] Finalize staff locations and travel time logic
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -3,3 +3,4 @@ uvicorn[standard]
|
||||
pandas
|
||||
openpyxl
|
||||
python-multipart
|
||||
requests
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user