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';
// Shared color taxonomy — same source of truth the Territory Map reads from,
// so pin colors stay consistent across the app.
import { REP_STATUS_COLORS, REP_STATUS_LABELS, URGENCY_COLORS } from '../../data/statusTaxonomy';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PLANO_CENTER = [33.055, -96.752];
const DEFAULT_ZOOM = 12;
// 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
? `
`
: '';
return L.divIcon({
html: `
`,
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
? `
`
: '';
return L.divIcon({
html: `
`,
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
};
const createTravelChipIcon = (text, color) => {
const label = `⏱ ${text}`;
return L.divIcon({
html: `
${label}
`,
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.
// On mobile the map panel is inside a CollapsePanel that animates
// height 0→auto over 220ms, so we need to wait past that before calling
// invalidateSize, otherwise Leaflet initialises at near-zero height.
useEffect(() => {
const invalidate = () => map.invalidateSize({ animate: false });
// 50ms covers desktop (no animation). 320ms covers the 220ms collapse
// animation with margin so tiles fill correctly on mobile/tablet.
const t1 = setTimeout(invalidate, 50);
const t2 = setTimeout(invalidate, 320);
window.addEventListener('resize', invalidate);
return () => {
clearTimeout(t1);
clearTimeout(t2);
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, mapHeight }) => {
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;
// Explicit pixel height on both the wrapper div and MapContainer.
// Do NOT use h-full / height:100% — the CollapsePanel ancestor has
// height:auto so the percentage chain resolves to 0. Inline px values
// bypass the chain entirely and give Leaflet a reliable offsetHeight.
// mapHeight prop is computed responsively in LynkDispatchPage (300–520px).
const mapH = isDesktop ? 'calc(72rem + 2.375rem)' : (mapHeight || '300px');
return (
{/* Inject map animations */}
{/* Tile layer — dark/light aware */}
{/* Fit bounds on lead selection */}
{/* Storm polygon */}
{stormMode && (
)}
{/* Route polyline — animates in with stroke-dashoffset draw */}
{topRec && (
)}
{/* 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 (
{lead.customer.name}
{lead.urgency.toUpperCase()}
{lead.property.address}
{notesSnip && (
{notesSnip}
)}
);
})}
{/* Rep markers */}
{reps.map(rep => {
const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
return (
{rep.name}
{REP_STATUS_LABELS[rep.status]} · {rep.todayAppointments}/{rep.maxDaily} slots
★ {rep.rating} · {rep.closeRate}% close
);
})}
{/* Travel time chip at route midpoint */}
{topRec && routeMid && driveMinutes && (
)}
{/* ── Legend overlay — top-left (kept in view; the desktop map is
~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
{/* Rep status legend */}
Reps
{Object.entries(REP_STATUS_COLORS).map(([status, color]) => (
{REP_STATUS_LABELS[status]}
{DISPATCH_REPS.filter(r => r.status === status).length}
))}
{/* Lead urgency legend */}
Leads
{Object.entries(URGENCY_COLORS).map(([urgency, color]) => (
{urgency}
{dispatchLeads.filter(l => l.urgency === urgency).length}
))}
{/* Storm mode overlay label */}
{stormMode && (
)}
{/* Selected lead info chip — top right */}
{selectedLead && topRep && (
Route Active
{topRep.name}
→ {selectedLead.property.address}
)}
);
};
export default DispatchMapPanel;