Compare commits
4 Commits
73d29bd5cd
...
dbf5a0239b
| Author | SHA1 | Date | |
|---|---|---|---|
| dbf5a0239b | |||
| f08493061e | |||
| 962c35ba0f | |||
| 1201a0bba3 |
@@ -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"}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]
|
|||||||
pandas
|
pandas
|
||||||
openpyxl
|
openpyxl
|
||||||
python-multipart
|
python-multipart
|
||||||
|
requests
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
/>
|
/>
|
||||||
{viewMode === 'points' ? renderPoints() : renderHeatmap()}
|
{viewMode === 'points' && renderPoints()}
|
||||||
|
{viewMode === 'heatmap' && renderHeatmap()}
|
||||||
|
{renderStaff()}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user