4 Commits

7 changed files with 378 additions and 21 deletions

View File

@@ -1 +1 @@
{"task_id": "31e88f42-8544-8024-ad7c-da1733e94f9a", "token": "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", "readme_path": "connector-superoffice/README.md", "session_start_time": "2026-03-09T13:25:40.399888"} {"task_id": "31b88f42-8544-8010-8793-e7688563a69e", "token": "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", "readme_path": "heatmap-tool", "session_start_time": "2026-03-09T14:44:44.115786"}

View File

@@ -124,6 +124,9 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8002:8000" - "8002:8000"
environment:
ORS_API_KEY: "${ORS_API_KEY}"
PYTHONUNBUFFERED: "1"
heatmap-frontend: heatmap-frontend:
build: build:

View File

@@ -5,7 +5,12 @@ Eine Webanwendung zur Visualisierung von Excel-Daten (XLSX) auf einer Deutschlan
## Features ## Features
* **Excel Upload:** Lädt beliebige `.xlsx` Dateien. Erkennt automatisch die PLZ-Spalte (oder fragt nach, wenn unklar). * **Excel Upload:** Lädt beliebige `.xlsx` Dateien. Erkennt automatisch die PLZ-Spalte (oder fragt nach, wenn unklar).
* **Datenschutz:** Daten werden nur temporär im RAM des Containers verarbeitet. Keine Datenbank. * **Personal-Standorte:** Fest hinterlegte Standorte von Vertrieblern (Blau) und Technikern (Orange) können unabhängig von Excel-Daten eingeblendet werden.
* **Einzugsgebiete (Isochronen):**
* Visualisierung der Erreichbarkeit basierend auf echter Fahrtzeit über das Straßennetz (via OpenRouteService).
* Berücksichtigt Autobahnen und Straßentypen (verzerrte Polygone statt perfekter Kreise).
* **Fallback:** Automatisches Umschalten auf ein mathematisches Kreis-Modell (Radius = Zeit * Geschwindigkeit), falls kein API-Key vorhanden ist.
* **Interaktive Planung:** Schieberegler für Fahrtzeit (bis 60 Min.) und Durchschnittsgeschwindigkeit zur Simulation von Reichweiten.
* **Visualisierung:** * **Visualisierung:**
* **Punkte-Karte:** Kreise pro PLZ, Radius = Anzahl der Einträge. Mit Marker-Clustering beim Herauszoomen. * **Punkte-Karte:** Kreise pro PLZ, Radius = Anzahl der Einträge. Mit Marker-Clustering beim Herauszoomen.
* **Heatmap:** Klassische Dichte-Darstellung. * **Heatmap:** Klassische Dichte-Darstellung.
@@ -16,27 +21,33 @@ Eine Webanwendung zur Visualisierung von Excel-Daten (XLSX) auf einer Deutschlan
Das Projekt ist vollständig in den **GTM-Engine Stack** integriert. Das Projekt ist vollständig in den **GTM-Engine Stack** integriert.
1. Container starten (im Root-Verzeichnis): 1. **API Key konfigurieren:** Für echte Fahrtzeiten (Isochronen) einen kostenlosen Key auf [openrouteservice.org](https://openrouteservice.org/) erstellen und in der `.env` hinterlegen:
```env
ORS_API_KEY=dein_key_hier
```
2. Container starten (im Root-Verzeichnis):
```bash ```bash
docker-compose up -d --build heatmap-frontend heatmap-backend docker-compose up -d --build heatmap-frontend heatmap-backend
``` ```
2. Anwendung öffnen:
* **URL:** `https://<DEINE-IP>:8090/heatmap/` (via Nginx Gateway)
* **Login:** Basic Auth (`admin` / `gemini`)
## Architektur ## Architektur
* **Frontend:** React 19, Vite, Leaflet (`react-leaflet`, `react-leaflet-cluster`, `react-leaflet-heatmap-layer-v3`). * **Frontend:** React 19, Vite, Leaflet (`react-leaflet`, `react-leaflet-cluster`, `react-leaflet-heatmap-layer-v3`).
* **Interner Port:** 80 (Nginx) * **Interner Port:** 80 (Nginx)
* **Routing:** `/heatmap/` (via Gateway) * **Routing:** `/heatmap/` (via Gateway)
* **Backend:** Python FastAPI, Pandas (für Excel-Processing). * **Backend:** Python FastAPI, Pandas, Requests (für ORS API).
* **Interner Port:** 8000 * **Interner Port:** 8000
* **Kommunikation:** Nginx (Frontend) leitet `/api/` Anfragen an das Backend weiter. * **Kommunikation:** Nginx (Frontend) leitet `/api/` Anfragen an das Backend weiter.
## Lessons Learned & Known Issues (WICHTIG!) ## Lessons Learned & Known Issues (WICHTIG!)
### 1. Docker Networking & Nginx Proxy ### 1. OpenRouteService (ORS) API Limits
* **Limit:** Der kostenlose "Free Tier" erlaubt Isochronen bis maximal **60 Minuten**.
* **Fehler:** Anfragen über 60 Min. resultieren in einem `400 Bad Request`. Das Frontend ist daher auf diesen Wert begrenzt.
* **Debouncing:** Da API-Calls kontingentiert sind (500/Tag), werden Isochronen erst nach einer kurzen Pause (800ms) beim Schieben des Reglers angefordert.
### 2. Docker Networking & Nginx Proxy
* **Integration:** Das Tool läuft nun hinter einem zentralen Nginx-Gateway. * **Integration:** Das Tool läuft nun hinter einem zentralen Nginx-Gateway.
* **Pfad-Anpassung:** `vite.config.ts` nutzt `base: '/heatmap/'`, damit Assets korrekt geladen werden. * **Pfad-Anpassung:** `vite.config.ts` nutzt `base: '/heatmap/'`, damit Assets korrekt geladen werden.
* **API-Routing:** Das Frontend-Nginx (`nginx.conf`) proxied `/api/` an `http://heatmap-backend:8000`. Dies verhindert `405 Method Not Allowed` Fehler bei POST-Requests, die sonst vom statischen Server abgefangen würden. * **API-Routing:** Das Frontend-Nginx (`nginx.conf`) proxied `/api/` an `http://heatmap-backend:8000`. Dies verhindert `405 Method Not Allowed` Fehler bei POST-Requests, die sonst vom statischen Server abgefangen würden.

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 import FastAPI, File, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import pandas as pd
import io
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, List from typing import Dict, List
@@ -21,6 +23,65 @@ df_storage = None
plz_column_name = None plz_column_name = None
plz_geocoord_df = 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"},
{"name": "Sebastian Hosbach", "plz": "44135"},
{"name": "Pierre Holein", "plz": "65428"},
{"name": "Daniel Sengstock", "plz": "24939"},
],
"technicians": [
{"name": "Merlin Kraus", "plz": "84489"},
{"name": "Bledar Haxijaj", "plz": "85570"},
{"name": "Aaron Schmelting", "plz": "44649"},
{"name": "Sergej", "plz": "97070"},
{"name": "Christoph Kadlubek", "plz": "30159"},
]
}
# --- 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") @app.on_event("startup")
def load_plz_data(): def load_plz_data():
global plz_geocoord_df global plz_geocoord_df
@@ -59,6 +120,38 @@ class PlzColumnRequest(BaseModel):
def read_root(): def read_root():
return {"message": "Heatmap Tool Backend"} 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 is None or plz_geocoord_df.empty:
raise HTTPException(status_code=500, detail="Geocoding data is not available on the server.")
result = {"sales": [], "technicians": []}
for category in ["sales", "technicians"]:
for person in STAFF_DATA[category]:
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,
"lat": float(coords["y"]),
"lon": float(coords["x"])
})
else:
print(f"WARNING: PLZ {plz} not found for {person['name']}")
return result
@app.post("/api/upload") @app.post("/api/upload")
async def upload_file(file: UploadFile = File(...)): async def upload_file(file: UploadFile = File(...)):
global df_storage, plz_column_name global df_storage, plz_column_name

View File

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

View File

@@ -28,6 +28,18 @@ export interface HeatmapPoint {
attributes_summary?: Record<string, string[]>; 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'; export type MapMode = 'points' | 'heatmap';
function App() { function App() {
@@ -42,6 +54,78 @@ function App() {
const [availableColumns, setAvailableColumns] = useState<string[]>([]); const [availableColumns, setAvailableColumns] = useState<string[]>([]);
const [tooltipColumns, setTooltipColumns] = useState<TooltipColumn[]>([]); 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) => { const handleUploadSuccess = (response: any) => {
setError(null); setError(null);
@@ -153,6 +237,60 @@ function App() {
Heatmap Heatmap
</label> </label>
</div> </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> <label htmlFor="radius-slider">Marker Size: {radiusMultiplier.toFixed(1)}x</label>
<input <input
type="range" type="range"
@@ -180,6 +318,11 @@ function App() {
radiusMultiplier={radiusMultiplier} radiusMultiplier={radiusMultiplier}
viewMode={viewMode} viewMode={viewMode}
tooltipColumns={tooltipColumns} tooltipColumns={tooltipColumns}
staffData={staffData}
showSales={showSales}
showTechnicians={showTechnicians}
travelRadius={travelRadius}
isochrones={isochrones}
/> />
)} )}
</div> </div>

View File

@@ -1,22 +1,46 @@
// src/components/MapDisplay.tsx // src/components/MapDisplay.tsx
import React from 'react'; import React from 'react';
import { MapContainer, TileLayer, CircleMarker, Tooltip } from 'react-leaflet'; import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle, GeoJSON } from 'react-leaflet';
import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3'; import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import 'leaflet.heat'; import 'leaflet.heat';
import MarkerClusterGroup from 'react-leaflet-cluster'; import MarkerClusterGroup from 'react-leaflet-cluster';
import type { HeatmapPoint, MapMode, TooltipColumn } from '../App'; import L from 'leaflet';
import type { HeatmapPoint, MapMode, TooltipColumn, StaffData, StaffLocation } from '../App';
// Fix for default Leaflet icon missing in some environments
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
interface MapDisplayProps { interface MapDisplayProps {
heatmapData: HeatmapPoint[]; heatmapData: HeatmapPoint[];
radiusMultiplier: number; radiusMultiplier: number;
viewMode: MapMode; viewMode: MapMode;
tooltipColumns: TooltipColumn[]; tooltipColumns: TooltipColumn[];
staffData: StaffData;
showSales: boolean;
showTechnicians: boolean;
travelRadius: number;
isochrones?: Record<string, any>;
} }
const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier, viewMode, tooltipColumns }) => { const MapDisplay: React.FC<MapDisplayProps> = ({
heatmapData,
radiusMultiplier,
viewMode,
tooltipColumns,
staffData,
showSales,
showTechnicians,
travelRadius,
isochrones = {}
}) => {
const germanyCenter: [number, number] = [51.1657, 10.4515]; const germanyCenter: [number, number] = [51.1657, 10.4515];
const maxCount = Math.max(...heatmapData.map(p => p.count), 1); const maxCount = heatmapData.length > 0 ? Math.max(...heatmapData.map(p => p.count), 1) : 1;
const calculateRadius = (count: number) => { const calculateRadius = (count: number) => {
return 3 + Math.log(count + 1) * 5 * radiusMultiplier; return 3 + Math.log(count + 1) * 5 * radiusMultiplier;
@@ -30,6 +54,24 @@ const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier,
return '#66bd63'; // Green return '#66bd63'; // Green
}; };
const salesIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
const technicianIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
const renderPoints = () => ( const renderPoints = () => (
<MarkerClusterGroup> <MarkerClusterGroup>
{heatmapData.map((point, idx) => ( {heatmapData.map((point, idx) => (
@@ -75,22 +117,86 @@ const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier,
/> />
); );
if (heatmapData.length === 0) { const renderStaff = () => {
const elements: React.ReactNode[] = [];
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>
{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>
);
});
}
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>
{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>
);
});
}
return elements;
};
const hasData = heatmapData.length > 0 || showSales || showTechnicians;
if (!hasData) {
return ( return (
<div> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#888' }}>
<div style={{ textAlign: 'center' }}>
<p>No data to display on the map.</p> <p>No data to display on the map.</p>
<p>Upload a file and apply filters to see the heatmap.</p> <p>Upload a file or enable staff layers to see something.</p>
</div>
</div> </div>
); );
} }
return ( return (
<MapContainer key={viewMode} center={germanyCenter} zoom={6} style={{ height: '100%', width: '100%' }}> <MapContainer key={`${viewMode}-${showSales}-${showTechnicians}`} center={germanyCenter} zoom={6} style={{ height: '100%', width: '100%' }}>
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
/> />
{viewMode === 'points' ? renderPoints() : renderHeatmap()} {viewMode === 'points' && renderPoints()}
{viewMode === 'heatmap' && renderHeatmap()}
{renderStaff()}
</MapContainer> </MapContainer>
); );
}; };