feat(dispatch): Phase 4 — Leaflet map panel with rep markers, lead pins, route polylines, and storm polygon

- Real Plano TX map using Leaflet with dark/light tile layer filter
- Custom DivIcon rep markers (initials circles, pulsing ring on top AI pick)
- Urgency-colored lead pins with radar rings on selected lead
- Route polyline with stroke-dashoffset draw animation keyed to selected lead
- Travel time chip at route midpoint — solid color background, white text, triple ring shadow
- Storm polygon overlay (NE Plano) with Storm Zone Active badge
- Route Active chip top-right, legend overlay bottom-left (rep status + lead urgency counts)
- Hover tooltips on rep markers (name, status, slots, rating/close%) and lead pins (name, urgency, address, notes snippet)
- invalidateSize() with 50ms defer + window resize listener to fix blank tile area after flex layout settles
This commit is contained in:
Satyam
2026-03-26 14:13:51 +05:30
parent c0706077d3
commit a636e23568
@@ -0,0 +1,427 @@
import React, { useEffect, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Polyline, Polygon, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useTheme } from '../../context/ThemeContext';
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PLANO_CENTER = [33.055, -96.752];
const DEFAULT_ZOOM = 12;
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' };
const URGENCY_COLORS = { emergency: '#EF4444', high: '#F59E0B', standard: '#6B7280' };
// Storm polygon — northeast Plano, sweeping toward central
const STORM_POLYGON = [
[33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
[33.051, -96.698], [33.042, -96.725], [33.058, -96.750],
[33.080, -96.758],
];
// ---------------------------------------------------------------------------
// Custom marker factories
// ---------------------------------------------------------------------------
const createRepIcon = (rep, isTopPick) => {
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
const size = isTopPick ? 42 : 36;
const ring = isTopPick
? `<div style="position:absolute;inset:-5px;border-radius:50%;border:2px solid ${color};opacity:0.5;animation:repRing 1.6s ease-out infinite;"></div>`
: '';
return L.divIcon({
html: `
<div style="position:relative;width:${size}px;height:${size}px;">
${ring}
<div style="
width:${size}px;height:${size}px;border-radius:50%;
background:${color}DD;border:2.5px solid white;
display:flex;align-items:center;justify-content:center;
font-size:${isTopPick ? 12 : 11}px;font-weight:900;color:white;
font-family:system-ui,sans-serif;
box-shadow:0 3px 10px ${color}60,0 1px 3px rgba(0,0,0,0.25);
transition:transform 0.2s;
">${rep.initials}</div>
<div style="
position:absolute;bottom:-3px;right:-3px;
width:10px;height:10px;border-radius:50%;
background:${color};border:2px solid white;
"></div>
</div>`,
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
};
const createLeadIcon = (lead, isSelected) => {
const color = URGENCY_COLORS[lead.urgency] || '#6B7280';
const size = isSelected ? 22 : 14;
const pulse = lead.urgency !== 'standard';
const radar = isSelected
? `<div style="position:absolute;inset:-7px;border-radius:50%;border:2px solid ${color};animation:radarPing 1.4s ease-out infinite;"></div>
<div style="position:absolute;inset:-14px;border-radius:50%;border:1.5px solid ${color};animation:radarPing 1.4s ease-out 0.4s infinite;"></div>`
: '';
return L.divIcon({
html: `
<div style="position:relative;width:${size}px;height:${size}px;${pulse && !isSelected ? 'animation:dotPulse 1.6s ease-in-out infinite;' : ''}">
${radar}
<div style="
width:${size}px;height:${size}px;border-radius:50%;
background:${color};border:2px solid white;
box-shadow:0 2px 6px ${color}80;
"></div>
</div>`,
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
};
const createTravelChipIcon = (text, color) => {
const label = `&#x23F1;&nbsp;${text}`;
return L.divIcon({
html: `
<div style="
background:${color};
border-radius:999px;
padding:7px 16px;
font-size:15px;
font-weight:900;
color:#ffffff;
box-shadow:0 4px 16px rgba(0,0,0,0.45), 0 0 0 3px white, 0 0 0 5px ${color};
white-space:nowrap;
font-family:system-ui,sans-serif;
letter-spacing:0.2px;
text-shadow:0 1px 3px rgba(0,0,0,0.35);
pointer-events:none;
">${label}</div>`,
className: '',
iconAnchor: [56, 20],
});
};
// ---------------------------------------------------------------------------
// MapController — fits bounds when lead selection changes + fixes tile gaps
// ---------------------------------------------------------------------------
const MapController = ({ selectedLeadId, leadPos, repPos, routePath }) => {
const map = useMap();
const prevId = useRef(null);
// Invalidate size on mount and on every window resize so Leaflet fills
// the full flex container — prevents the blank tile area on the right.
useEffect(() => {
const invalidate = () => map.invalidateSize({ animate: false });
// Defer one frame so the flex layout has fully settled
const t = setTimeout(invalidate, 50);
window.addEventListener('resize', invalidate);
return () => {
clearTimeout(t);
window.removeEventListener('resize', invalidate);
};
}, [map]);
useEffect(() => {
if (selectedLeadId === prevId.current) return;
prevId.current = selectedLeadId;
if (!leadPos || !repPos) return;
const positions = [leadPos, repPos, ...(routePath || [])].filter(Boolean);
if (positions.length < 2) return;
map.fitBounds(L.latLngBounds(positions), {
padding: [72, 72],
maxZoom: 14,
animate: true,
duration: 0.7,
});
}, [selectedLeadId]); // eslint-disable-line react-hooks/exhaustive-deps
return null;
};
// ---------------------------------------------------------------------------
// CSS animations — injected as a style block
// ---------------------------------------------------------------------------
const MAP_STYLES = `
.dispatch-route {
stroke-dasharray: 5000;
stroke-dashoffset: 5000;
animation: drawRoute 1.1s cubic-bezier(0.4,0,0.2,1) forwards;
}
@keyframes drawRoute {
to { stroke-dashoffset: 0; }
}
@keyframes radarPing {
0% { transform: scale(1); opacity: 0.75; }
100% { transform: scale(1); opacity: 0; }
0% { transform: scale(1); }
100% { transform: scale(2.4); opacity: 0; }
}
@keyframes dotPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.7; }
}
@keyframes repRing {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(1.8); opacity: 0; }
}
.leaflet-container { font-family: system-ui, sans-serif; }
.dispatch-tooltip {
background: white;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
padding: 8px 10px;
pointer-events: none;
}
.dispatch-tooltip::before { display: none; }
.leaflet-tooltip.dispatch-tooltip { white-space: nowrap; }
`;
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, stormMode, accent, isDesktop }) => {
const { theme } = useTheme();
// Top recommendation for the selected lead
const topRec = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id]?.[0] ?? null) : null;
const topRep = topRec ? reps.find(r => r.id === topRec.repId) : null;
// Positions for bounds fitting
const leadPos = selectedLead
? [selectedLead.property.lat, selectedLead.property.lng]
: null;
const repPos = topRep
? [topRep.currentLat, topRep.currentLng]
: null;
// Travel time chip — midpoint of route
const routeMid = topRec?.routePath?.length
? topRec.routePath[Math.floor(topRec.routePath.length / 2)]
: null;
// Derive estimated drive minutes from top rec factors (driveTime score → minutes)
const driveMinutes = topRec
? Math.round(8 + ((100 - topRec.factors.driveTime) / 100) * 22)
: null;
const routeColor = stormMode ? '#F59E0B' : accent;
const containerStyle = isDesktop ? { height: 'calc(72rem + 2.375rem)' } : undefined;
const containerClass = !isDesktop
? 'min-h-[280px] sm:min-h-[360px] md:min-h-[420px]'
: '';
return (
<div className={`relative overflow-hidden ${containerClass}`} style={containerStyle}>
{/* Inject map animations */}
<style>{MAP_STYLES}</style>
<MapContainer
center={PLANO_CENTER}
zoom={DEFAULT_ZOOM}
scrollWheelZoom={true}
zoomControl={false}
className="w-full h-full"
style={{ height: '100%', width: '100%' }}
>
{/* Tile layer — dark/light aware */}
<TileLayer
key={theme}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className={
theme === 'dark'
? 'map-tiles filter invert grayscale contrast-100'
: 'map-tiles'
}
/>
{/* Fit bounds on lead selection */}
<MapController
selectedLeadId={selectedLead?.id}
leadPos={leadPos}
repPos={repPos}
routePath={topRec?.routePath}
/>
{/* Storm polygon */}
{stormMode && (
<Polygon
positions={STORM_POLYGON}
pathOptions={{
color: '#F59E0B',
fillColor: '#F59E0B',
fillOpacity: 0.13,
weight: 2,
dashArray: '6 5',
opacity: 0.7,
}}
/>
)}
{/* Route polyline — animates in with stroke-dashoffset draw */}
{topRec && (
<Polyline
key={selectedLead.id}
positions={topRec.routePath}
pathOptions={{
color: routeColor,
weight: 3.5,
opacity: 0.85,
className: 'dispatch-route',
lineCap: 'round',
lineJoin: 'round',
}}
/>
)}
{/* Lead pins */}
{dispatchLeads.map(lead => {
const urgColor = URGENCY_COLORS[lead.urgency] || '#6B7280';
const notesSnip = lead.notes
? (lead.notes.length > 55 ? lead.notes.slice(0, 55) + '…' : lead.notes)
: null;
return (
<Marker
key={lead.id}
position={[lead.property.lat, lead.property.lng]}
icon={createLeadIcon(lead, selectedLead?.id === lead.id)}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="dispatch-tooltip">
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 150, maxWidth: 220 }}>
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>
{lead.customer.name}
</p>
<p style={{ fontSize: 10, fontWeight: 700, color: urgColor, marginBottom: 2 }}>
{lead.urgency.toUpperCase()}
</p>
<p style={{ fontSize: 10, color: '#71717a', marginBottom: notesSnip ? 4 : 0 }}>
{lead.property.address}
</p>
{notesSnip && (
<p style={{ fontSize: 9, color: '#a1a1aa', fontStyle: 'italic', lineHeight: 1.4, borderTop: '1px solid #f0f0f0', paddingTop: 4 }}>
{notesSnip}
</p>
)}
</div>
</Tooltip>
</Marker>
);
})}
{/* Rep markers */}
{reps.map(rep => {
const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
return (
<Marker
key={rep.id}
position={[rep.currentLat, rep.currentLng]}
icon={createRepIcon(rep, topRep?.id === rep.id)}
>
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="dispatch-tooltip">
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140 }}>
<p style={{ fontSize: 11, fontWeight: 800, color: repColor, marginBottom: 3 }}>
{rep.name}
</p>
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 1 }}>
{REP_STATUS_LABELS[rep.status]} · {rep.todayAppointments}/{rep.maxDaily} slots
</p>
<p style={{ fontSize: 10, color: '#71717a' }}>
{rep.rating} · {rep.closeRate}% close
</p>
</div>
</Tooltip>
</Marker>
);
})}
{/* Travel time chip at route midpoint */}
{topRec && routeMid && driveMinutes && (
<Marker
key={`chip-${selectedLead.id}`}
position={routeMid}
icon={createTravelChipIcon(`~${driveMinutes} min`, routeColor)}
/>
)}
</MapContainer>
{/* ── Legend overlay — bottom-left ── */}
<div className="absolute bottom-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
{/* Rep status legend */}
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
<div className="space-y-1">
{Object.entries(REP_STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300 capitalize">
{REP_STATUS_LABELS[status]}
</span>
<span className="text-[10px] text-zinc-400 ml-1">
{DISPATCH_REPS.filter(r => r.status === status).length}
</span>
</div>
))}
</div>
</div>
{/* Lead urgency legend */}
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Leads</p>
<div className="space-y-1">
{Object.entries(URGENCY_COLORS).map(([urgency, color]) => (
<div key={urgency} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300 capitalize">
{urgency}
</span>
<span className="text-[10px] text-zinc-400 ml-1">
{dispatchLeads.filter(l => l.urgency === urgency).length}
</span>
</div>
))}
</div>
</div>
</div>
{/* Storm mode overlay label */}
{stormMode && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1000] pointer-events-none">
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/90 backdrop-blur-sm shadow-lg">
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-white">
Storm Zone Active
</span>
</div>
</div>
)}
{/* Selected lead info chip — top right */}
{selectedLead && topRep && (
<div className="absolute top-3 right-3 z-[1000] pointer-events-none">
<div
className="rounded-xl border px-3 py-2 bg-white/92 dark:bg-zinc-900/85 backdrop-blur-md shadow-lg border-zinc-200/80 dark:border-white/[0.08]"
style={{ borderLeftWidth: 3, borderLeftColor: routeColor }}
>
<p className="text-[9px] font-black uppercase tracking-widest mb-0.5" style={{ color: routeColor }}>
Route Active
</p>
<p className="text-[11px] font-bold text-zinc-900 dark:text-white">{topRep.name}</p>
<p className="text-[10px] text-zinc-400 mt-0.5 truncate max-w-[140px]">
{selectedLead.property.address}
</p>
</div>
</div>
)}
</div>
);
};
export default DispatchMapPanel;