From f08493061e2f742b942ceb9b49d62400e46574f8 Mon Sep 17 00:00:00 2001 From: Floke Date: Mon, 9 Mar 2026 14:44:10 +0000 Subject: [PATCH] [31b88f42] Finalize staff locations and travel time logic --- heatmap-tool/backend/main.py | 59 +++++++++- heatmap-tool/backend/requirements.txt | 1 + heatmap-tool/frontend/src/App.tsx | 109 +++++++++++++++--- .../frontend/src/components/MapDisplay.tsx | 44 +++++-- 4 files changed, 185 insertions(+), 28 deletions(-) diff --git a/heatmap-tool/backend/main.py b/heatmap-tool/backend/main.py index 5c887e33..e0fcaf31 100644 --- a/heatmap-tool/backend/main.py +++ b/heatmap-tool/backend/main.py @@ -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, diff --git a/heatmap-tool/backend/requirements.txt b/heatmap-tool/backend/requirements.txt index e5f82de1..0c9579f6 100644 --- a/heatmap-tool/backend/requirements.txt +++ b/heatmap-tool/backend/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard] pandas openpyxl python-multipart +requests diff --git a/heatmap-tool/frontend/src/App.tsx b/heatmap-tool/frontend/src/App.tsx index 35ec0945..527592f0 100644 --- a/heatmap-tool/frontend/src/App.tsx +++ b/heatmap-tool/frontend/src/App.tsx @@ -57,7 +57,13 @@ function App() { const [staffData, setStaffData] = useState({ 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>({}); + const [orsError, setOrsError] = useState(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 = { ...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() { {(showSales || showTechnicians) && ( -
- - setTravelRadius(parseInt(e.target.value))} - style={{ width: '100%' }} - /> +
+
+ + + {orsError ? ( + <> +
+ + setAverageSpeed(parseInt(e.target.value))} + style={{ width: '100%' }} + /> +
+
+ {orsError} +
+
+ Resulting Radius: {travelRadius.toFixed(1)} km +
+ + ) : ( +
+ Using real road data (OpenRouteService) +
+ )}
)} @@ -240,6 +322,7 @@ function App() { showSales={showSales} showTechnicians={showTechnicians} travelRadius={travelRadius} + isochrones={isochrones} /> )}
diff --git a/heatmap-tool/frontend/src/components/MapDisplay.tsx b/heatmap-tool/frontend/src/components/MapDisplay.tsx index 68ffa45f..1e77fec2 100644 --- a/heatmap-tool/frontend/src/components/MapDisplay.tsx +++ b/heatmap-tool/frontend/src/components/MapDisplay.tsx @@ -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; } const MapDisplay: React.FC = ({ @@ -35,7 +36,8 @@ const MapDisplay: React.FC = ({ 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 = ({ if (showSales) { staffData.sales.forEach((person, idx) => { + const iso = isochrones[person.name]; elements.push( Sales: {person.name} ({person.plz}) - + {iso ? ( + + ) : ( + + )} ); }); @@ -137,16 +148,25 @@ const MapDisplay: React.FC = ({ if (showTechnicians) { staffData.technicians.forEach((person, idx) => { + const iso = isochrones[person.name]; elements.push( Tech: {person.name} ({person.plz}) - + {iso ? ( + + ) : ( + + )} ); });