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:
@@ -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 = `⏱ ${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='© <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;
|
||||
Reference in New Issue
Block a user