[31b88f42] Add staff locations and reach visualization to Heatmap Tool

This commit is contained in:
2026-03-09 13:44:15 +00:00
parent 73d29bd5cd
commit 1201a0bba3
3 changed files with 196 additions and 10 deletions

View File

@@ -21,6 +21,22 @@ df_storage = None
plz_column_name = None plz_column_name = None
plz_geocoord_df = None plz_geocoord_df = None
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"},
]
}
@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 +75,30 @@ class PlzColumnRequest(BaseModel):
def read_root(): def read_root():
return {"message": "Heatmap Tool Backend"} return {"message": "Heatmap Tool Backend"}
@app.get("/api/staff-locations")
async def get_staff_locations():
global plz_geocoord_df
if 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]
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

@@ -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,22 @@ 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 [travelRadius, setTravelRadius] = useState(50); // In km
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();
}, []);
const handleUploadSuccess = (response: any) => { const handleUploadSuccess = (response: any) => {
setError(null); setError(null);
@@ -153,6 +181,34 @@ 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' }}>
<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>
)}
<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 +236,10 @@ function App() {
radiusMultiplier={radiusMultiplier} radiusMultiplier={radiusMultiplier}
viewMode={viewMode} viewMode={viewMode}
tooltipColumns={tooltipColumns} tooltipColumns={tooltipColumns}
staffData={staffData}
showSales={showSales}
showTechnicians={showTechnicians}
travelRadius={travelRadius}
/> />
)} )}
</div> </div>

View File

@@ -1,22 +1,44 @@
// 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 } 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;
} }
const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier, viewMode, tooltipColumns }) => { const MapDisplay: React.FC<MapDisplayProps> = ({
heatmapData,
radiusMultiplier,
viewMode,
tooltipColumns,
staffData,
showSales,
showTechnicians,
travelRadius
}) => {
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 +52,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 +115,68 @@ const MapDisplay: React.FC<MapDisplayProps> = ({ heatmapData, radiusMultiplier,
/> />
); );
if (heatmapData.length === 0) { const renderStaff = () => {
const elements: React.ReactNode[] = [];
if (showSales) {
staffData.sales.forEach((person, idx) => {
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 }}
/>
</React.Fragment>
);
});
}
if (showTechnicians) {
staffData.technicians.forEach((person, idx) => {
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 }}
/>
</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' }}>
<p>No data to display on the map.</p> <div style={{ textAlign: 'center' }}>
<p>Upload a file and apply filters to see the heatmap.</p> <p>No data to display on the map.</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>
); );
}; };