mega-plant/components/PlantMap.tsx
Dave 1429c8087b feat(plant): vehicle site history from Fuel Logs
/plant and /plant/map import Fuel Logs CSVs by exact QR and show each
vehicle's current and previous distinct site with the dates they were
observed.

- plant_site_observations is durable, QR-keyed history. It is never wiped
  and a previous site is never manufactured.
- An older or repeated snapshot must not move a vehicle backwards; untimed
  conflicting sites on one day are held for review.
- A manual site change shares the same history and the same lock.
- Current/previous metadata and the sticker QR are captured BEFORE the asset
  rows are deleted in the seed, so a re-seed cannot wipe them. Explicit
  empty values are preserved too: clearing a site is a deliberate manual
  observation, not permission to restore an old sheet value.
- Imports change location metadata only. They never touch income, cost or
  fuel measurements.
- A new named site is created only for a matched Plant vehicle. Missing
  coordinates mean "awaiting a pin", never a headquarters fallback.

Owner-confirmed 2026-11-04: TLB06's QR is 1089, not the old meter reading
4704. lib/plant-site-policy.json holds the confirmed aliases (Equestria
Bastion Job -> Equestria 215, Cosmo City / Lion Park -> Cosmo City); no
other identity is guessed.
2026-11-05 00:18:24 +02:00

535 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// Leaflet's and marker-cluster's stylesheets are imported globally in
// app/globals.css — see the comment there for why they must not live here.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-cluster";
import L from "leaflet";
import { MapPinned, Loader2, Search, X, MapPin, Trash2 } from "lucide-react";
import { VehicleSiteHistory } from "./VehicleSiteHistory";
import { SiteCoordinateEditor } from "./SiteCoordinateEditor";
import { cn } from "@/lib/utils";
import { HEAD_OFFICE, hasCoords, resolveAssetLocation, type MapAsset, type MapSite } from "@/lib/plant-map";
// Detailed street tiles in both themes. Esri's gray canvas returns placeholder
// images above zoom 16 here, even though its metadata lists higher levels.
const STREET_TILES = {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
};
const escapeHtml = (s: string) =>
s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
/**
* Leaflet's stock markers point at image files that Webpack rewrites, which
* breaks under Next's bundler. A divIcon with inline markup sidesteps asset
* resolution entirely and lets the pin carry the machine code and brand colour.
*/
function badgeIcon(label: string, active: boolean) {
const fill = active ? "#00798C" : "#9CA3AF";
const ring = active ? "rgba(0,121,140,0.28)" : "rgba(156,163,175,0.25)";
return L.divIcon({
className: "plant-map-marker",
html: `<span style="display:flex;width:44px;height:44px;align-items:center;justify-content:center"><span style="
display:flex;align-items:center;justify-content:center;
width:34px;height:34px;border-radius:9999px;
background:${fill};color:#fff;font-weight:700;font-size:12px;
letter-spacing:.02em;border:2.5px solid #fff;
box-shadow:0 0 0 6px ${ring},0 2px 6px rgba(0,0,0,.3);
">${escapeHtml(label)}</span></span>`,
iconSize: [44, 44],
iconAnchor: [22, 22],
popupAnchor: [0, -20],
});
}
/**
* Cluster bubble showing how many machines share a point. Sizing steps up with
* the count so a glance reads magnitude, and the colour walks the brand's own
* ramp — teal for a handful, indigo navy for a full yard.
*
* `fontSize` and `iconSize` are interpolated from the plugin's 0–10
* `childCount`, which is how markercluster reports cluster density.
*/
function clusterIcon(cluster: L.MarkerCluster) {
const count = cluster.getChildCount();
const density = Math.min(cluster.getChildCount() / 10, 1);
const size = Math.round(44 + density * 16); // 44px floor keeps it tappable on a phone
const fill = count < 10 ? "#00798C" : count < 50 ? "#30638E" : "#003D5B";
const fontSize = count > 999 ? 13 : count > 99 ? 14 : 15;
return L.divIcon({
className: "plant-map-cluster",
html: `<span style="
display:flex;align-items:center;justify-content:center;
width:${size}px;height:${size}px;border-radius:9999px;
background:${fill};color:#fff;font-weight:700;font-size:${fontSize}px;
font-variant-numeric:tabular-nums;
border:3px solid rgba(255,255,255,.92);
box-shadow:0 0 0 7px ${fill}33,0 3px 10px rgba(0,0,0,.35);
">${count}</span>`,
iconSize: [size, size],
// Anchor on the centre: a cluster represents a point, not a pin tip.
iconAnchor: [size / 2, size / 2],
});
}
function waypointIcon(office = false) {
return L.divIcon({
className: "plant-map-waypoint",
html: `<span style="display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:12px;background:${office ? "#30638E" : "#EDAE49"};color:${office ? "#fff" : "#003D5B"};border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.35)"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>`,
iconSize: [44, 44],
// Keep the waypoint above machines clustered at the same coordinates.
iconAnchor: [22, 68],
popupAnchor: [0, -68],
});
}
function MapController({ focus, points, fitKey, onPopupChange }: {
focus: { lat: number; lng: number; zoom?: number } | null;
points: [number, number][];
fitKey: number;
onPopupChange: (open: boolean) => void;
}) {
const map = useMap();
useEffect(() => {
const opened = () => onPopupChange(true);
const closed = () => onPopupChange(false);
map.on("popupopen", opened); map.on("popupclose", closed);
return () => { map.off("popupopen", opened); map.off("popupclose", closed); };
}, [map, onPopupChange]);
useEffect(() => {
map.fitBounds(L.latLngBounds(points), { padding: [50, 70], maxZoom: 14 });
}, [map, points, fitKey]);
useEffect(() => {
if (!focus) return;
map.flyTo([focus.lat, focus.lng], focus.zoom ?? Math.max(map.getZoom(), 15), { duration: 0.8 });
}, [focus, map]);
return null;
}
export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite[] }) {
const [assetsState, setAssets] = useState<MapAsset[]>(assets);
const [query, setQuery] = useState("");
const [showInactive, setShowInactive] = useState(true);
const [focus, setFocus] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
const [fitKey, setFitKey] = useState(0);
const [popupOpen, setPopupOpen] = useState(false);
const [placingId, setPlacingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => setAssets(assets), [assets]);
const mappedAssets = useMemo(() => assetsState.map(a => ({ ...a, ...resolveAssetLocation(a, sites) })), [assetsState, sites]);
const located = useMemo(() => mappedAssets.filter(hasCoords), [mappedAssets]);
const awaiting = useMemo(() => mappedAssets.filter(a => !hasCoords(a)), [mappedAssets]);
const pinnedSites = useMemo(() => sites.filter(hasCoords), [sites]);
const unpinnedSites = useMemo(() => sites.filter(site => !hasCoords(site)), [sites]);
const points = useMemo<[number, number][]>(() => [
[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude],
...pinnedSites.map(s => [s.latitude, s.longitude] as [number, number]),
...located.map(a => [a.latitude!, a.longitude!] as [number, number]),
], [pinnedSites, located]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return mappedAssets
.filter((a) => (showInactive ? true : a.is_active))
.filter(
(a) =>
!q ||
a.code.toLowerCase().includes(q) ||
a.name.toLowerCase().includes(q) ||
a.type.toLowerCase().includes(q) ||
a.siteName?.toLowerCase().includes(q) ||
a.qr_code?.toLowerCase().includes(q),
);
}, [mappedAssets, query, showInactive]);
const saveCoords = useCallback(async (id: string, lat: number, lng: number) => {
setSaving(true);
try {
const res = await fetch(`/api/assets/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ latitude: lat, longitude: lng }),
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || "Could not save location");
setAssets((curr) =>
curr.map((a) => (a.id === id ? { ...a, latitude: lat, longitude: lng, ...(data.asset || {}) } : a)),
);
setPlacingId(null);
setNotice("Location saved");
} catch (e: any) {
setNotice(e?.message || "Could not save location");
} finally {
setSaving(false);
}
}, []);
const clearCoords = useCallback(async (id: string) => {
setSaving(true);
try {
const res = await fetch(`/api/assets/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ latitude: null, longitude: null }),
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || "Could not clear location");
setAssets((curr) =>
curr.map((a) => (a.id === id ? { ...a, latitude: null, longitude: null, ...(data.asset || {}) } : a)),
);
setNotice("Location cleared");
} catch (e: any) {
setNotice(e?.message || "Could not clear location");
} finally {
setSaving(false);
}
}, []);
useEffect(() => {
if (!notice) return;
const t = window.setTimeout(() => setNotice(null), 2500);
return () => window.clearTimeout(t);
}, [notice]);
const placing = placingId ? assetsState.find((a) => a.id === placingId) : null;
return (
<div className="grid min-w-0 lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
{/* ── Map ─────────────────────────────────────────────────────── */}
<div className="relative min-w-0 rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm">
<MapContainer
center={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]}
zoom={10}
maxZoom={19}
scrollWheelZoom
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
>
<TileLayer url={STREET_TILES.url} attribution={STREET_TILES.attribution} maxNativeZoom={19} maxZoom={19} />
<MapController focus={focus} points={points} fitKey={fitKey} onPopupChange={setPopupOpen} />
{placingId && <MapClickCapture onPick={(lat, lng) => saveCoords(placingId, lat, lng)} disabled={saving} />}
<Marker position={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]} icon={waypointIcon(true)} title="Head office" zIndexOffset={1000}>
<Popup><strong>Head office</strong><p>Default location for unassigned machines.</p></Popup>
</Marker>
{pinnedSites.map(site => (
<Marker key={site.id} position={[site.latitude, site.longitude]} icon={waypointIcon()} title={site.name} zIndexOffset={1000}>
<Popup>
<strong>{site.name}</strong>
<p>{assetsState.filter(a => a.site === site.id).length} machines assigned · Site location</p>
<a href={`/sites/${site.id}`}>View site details</a>
</Popup>
</Marker>
))}
{/* Clustering: machines sharing a point collapse into one numbered
bubble. Clicking a bubble zooms to the machines inside it, and
markers that genuinely overlap at max zoom spiderfy apart so every
machine stays individually reachable. */}
<MarkerClusterGroup
iconCreateFunction={clusterIcon}
chunkedLoading
showCoverageOnHover={false}
zoomToBoundsOnClick
spiderfyOnMaxZoom
maxClusterRadius={70}
removeOutsideVisibleBounds
>
{visible.filter(hasCoords).map((a) => (
<Marker
key={a.id}
position={[a.latitude as number, a.longitude as number]}
icon={badgeIcon(a.code || "?", a.is_active)}
title={`${a.code || a.name} · ${a.siteName || (a.source === "head-office" ? "Head office" : "Manual pin")}`}
>
<Popup>
<div style={{ minWidth: "180px" }}>
<div style={{ fontWeight: 700, marginBottom: 2 }}>{a.name}</div>
<div style={{ color: "#6b7280", fontSize: 12, marginBottom: 6 }}>
{a.code || "no code"}
{a.type ? ` · ${a.type}` : ""}
</div>
<div
style={{
display: "inline-block",
fontSize: 12,
padding: "2px 8px",
borderRadius: 999,
background: a.is_active ? "rgba(0,121,140,.12)" : "rgba(156,163,175,.18)",
color: a.is_active ? "#00798C" : "#6b7280",
fontWeight: 600,
}}
>
{a.is_active ? "Active" : "Inactive"}
</div>
<div style={{ color: "#6b7280", fontSize: 12, marginTop: 6 }}>
{a.siteName ? `${a.siteName} · Site location` : a.source === "head-office" ? "Head office · Default location" : "Manual location"}
<br />{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
</div>
<div className="mt-3 border-t border-gray-200 pt-3 dark:[&_dt]:text-[#003D5B] dark:[&_dd]:text-gray-600 dark:[&_span]:text-gray-500"><VehicleSiteHistory asset={a} currentSite={a.siteName} previousSite={sites.find(site => site.id === a.previous_site)?.name} /></div>
{a.qr_code && <p className="text-xs text-gray-500">QR: {a.qr_code}</p>}
</div>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
</MapContainer>
{!popupOpen && <button type="button" onClick={() => { setFocus(null); setFitKey(k => k + 1); }}
className="absolute top-3 right-3 z-[500] min-h-[44px] px-3 rounded-lg bg-white dark:bg-surface text-sm font-medium shadow text-[#003D5B] dark:text-white">
Fit all
</button>}
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 bg-white dark:bg-surface border-t border-gray-200 dark:border-white/10 px-3 py-2 text-xs">
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#EDAE49]" /><span className="text-gray-600 dark:text-gray-300">Site</span></span>
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#30638E]" /><span className="text-gray-600 dark:text-gray-300">Head office</span></span>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#00798C] border-2 border-white shadow" />
<span className="text-gray-600 dark:text-gray-300">Active</span>
</span>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-gray-400 border-2 border-white shadow" />
<span className="text-gray-600 dark:text-gray-300">Inactive</span>
</span>
{located.length > 1 && (
<>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#30638E] border-2 border-white shadow" />
<span className="text-gray-600 dark:text-gray-300">
Cluster — tap to zoom
</span>
</span>
</>
)}
</div>
{notice && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[700] px-3 py-2 rounded-xl bg-[#003D5B] text-white text-xs font-medium shadow-lg">
{notice}
</div>
)}
</div>
{/* ── Side panel ──────────────────────────────────────────────── */}
<aside className="min-w-0 rounded-2xl border border-gray-200 dark:border-white/5 bg-white dark:bg-surface shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 dark:border-white/5">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-2">Sites & head office</h2>
<button type="button" onClick={() => setFocus({ lat: HEAD_OFFICE.latitude, lng: HEAD_OFFICE.longitude })}
className="flex items-center gap-2 w-full min-h-[44px] text-left text-sm text-[#003D5B] dark:text-white">
<MapPinned className="w-4 h-4 shrink-0 text-[#30638E]" />Head office
</button>
{pinnedSites.map(site => (
<button key={site.id} type="button"
onClick={() => setFocus({ lat: site.latitude, lng: site.longitude })}
className="flex items-center gap-2 w-full min-h-[44px] text-left text-sm text-[#003D5B] dark:text-white">
<MapPin className="w-4 h-4 shrink-0 text-[#EDAE49]" />
<span className="min-w-0 flex-1 truncate">{site.name}</span>
<span className="text-xs shrink-0 text-gray-500 dark:text-gray-400">{assetsState.filter(a => a.site === site.id).length} assigned</span>
</button>
))}
{unpinnedSites.length > 0 && (
<details className="mt-2 min-w-0 border-t border-gray-100 dark:border-white/10">
<summary className="flex min-h-[44px] cursor-pointer items-center text-xs font-medium text-[#30638E] dark:text-gray-300">{unpinnedSites.length} sites need coordinates</summary>
<div className="min-w-0 space-y-4 pt-2 [&_input]:min-w-0 [&_.flex-1]:min-w-0">
{unpinnedSites.map(site => <div key={site.id} className="min-w-0">
<h3 className="break-words text-sm font-medium text-[#003D5B] dark:text-white">{site.name}</h3>
<SiteCoordinateEditor siteId={site.id} name={site.name} latitude={site.latitude} longitude={site.longitude} />
</div>)}
</div>
</details>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Assigned machines follow their site's pin. These locations are based on assignments, not live GPS.</p>
</div>
<div className="p-4 border-b border-gray-100 dark:border-white/5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Machines</h2>
<span className="text-xs text-gray-500 dark:text-gray-400">
{located.length} located · {awaiting.length} awaiting
</span>
</div>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter code, name, type, site…"
className="w-full min-h-[44px] pl-9 pr-3 rounded-lg text-sm bg-gray-50 dark:bg-white/5 border border-transparent focus:bg-white dark:focus:bg-white/10 focus:border-[#30638E] focus:outline-none"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowInactive((v) => !v)}
aria-pressed={showInactive}
className={cn(
"flex-1 inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 rounded-lg text-xs font-medium transition",
showInactive
? "bg-[#003D5B] text-white"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5",
)}
>
{showInactive ? "All machines" : "Active only"}
</button>
<button
type="button"
onClick={() => {
setPlacingId((cur) => (cur ? null : (mappedAssets.find(a => !a.site)?.id ?? null)));
setNotice(null);
}}
className={cn(
"inline-flex items-center justify-center gap-1.5 min-h-[44px] px-3 rounded-lg text-xs font-semibold transition",
placingId
? "bg-[#EDAE49] text-[#003D5B] hover:bg-[#E5A340]"
: "bg-[#30638E] text-white hover:bg-[#003D5B]",
)}
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <MapPin className="w-4 h-4" />}
{placingId ? "Placing…" : "Set location"}
</button>
</div>
{placingId && (
<p className="mt-2.5 text-xs text-[#EDAE49] font-medium leading-relaxed">
Tap the map to place{" "}
<span className="font-bold">{placing?.code || placing?.name}</span>, or pick another
machine below.
</p>
)}
</div>
<div className="max-h-[52vh] lg:max-h-[calc(100vh-25rem)] overflow-y-auto">
{visible.length === 0 ? (
<p className="p-4 text-sm text-gray-500 dark:text-gray-400">No machines match.</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-white/5">
{visible.map((a) => {
const on = hasCoords(a);
const isPlacing = placingId === a.id;
return (
<li
key={a.id}
className={cn(
"p-2 transition",
isPlacing ? "bg-[#EDAE49]/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.02]",
!a.is_active && !isPlacing && "opacity-60",
)}
>
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
disabled={!on}
onClick={() => setFocus({ lat: a.latitude as number, lng: a.longitude as number })}
className="flex-1 min-w-0 min-h-[44px] text-left disabled:cursor-default"
>
<span className="flex items-center gap-2">
<span
className={cn(
"font-mono text-xs px-1.5 py-0.5 rounded font-semibold shrink-0",
a.is_active
? "bg-[#30638E]/10 text-[#30638E]"
: "bg-gray-100 dark:bg-white/5 text-gray-500",
)}
>
{a.code || "—"}
</span>
<span className="truncate text-sm text-[#003D5B] dark:text-white">
{a.name}
</span>
</span>
<span className="block mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{on ? (
<span className="text-[#00798C] font-medium">
{a.siteName || (a.source === "head-office" ? "Head office · Default" : "Manual pin")}
<span className="block font-normal text-gray-500 dark:text-gray-400">{a.latitude?.toFixed(4)}, {a.longitude?.toFixed(4)}</span>
</span>
) : (
<span className="text-gray-400 dark:text-gray-500">
{a.source === "missing-site" ? `${a.siteName} · Site coordinates needed` : "No coordinates yet"}
</span>
)}
</span>
</button>
<button
type="button"
onClick={() => setPlacingId(isPlacing ? null : a.id)}
aria-label={`Place ${a.name} on the map`}
disabled={Boolean(a.site) || saving}
title={a.site ? "Add or edit coordinates in the site panel above" : undefined}
className={cn(
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0 disabled:opacity-40",
isPlacing
? "bg-[#EDAE49] text-[#003D5B]"
: "text-[#30638E] hover:bg-[#30638E]/10",
)}
>
{isPlacing ? <X className="w-4 h-4" /> : <MapPin className="w-4 h-4" />}
</button>
{on && !a.site && (
<button
type="button"
onClick={() => clearCoords(a.id)}
disabled={saving}
aria-label={`Clear location for ${a.name}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg text-[#D1495B] hover:bg-[#D1495B]/10 transition shrink-0 disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<details className="mt-1 px-1">
<summary className="flex min-h-[44px] cursor-pointer items-center text-xs font-medium text-[#30638E] dark:text-gray-300">Vehicle site details</summary>
<div className="pb-2 pl-1"><VehicleSiteHistory asset={a} currentSite={a.siteName} previousSite={sites.find(site => site.id === a.previous_site)?.name} />
<p className="mt-2 break-words text-xs text-gray-500 dark:text-gray-400">QR: {a.qr_code || a.code || "Not recorded"}</p>
</div>
</details>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
);
}
/**
* Leaflet's own click handler is managed by react-leaflet's internals, so a
* tap while "placing" needs its own listener on the container. Registered
* imperatively and removed on unmount.
*/
function MapClickCapture({
onPick,
disabled,
}: {
onPick: (lat: number, lng: number) => void;
disabled: boolean;
}) {
const mapRef = useMap();
const handlerRef = useRef(onPick);
handlerRef.current = onPick;
useEffect(() => {
if (disabled) return;
const map = mapRef;
const onClick = (e: L.LeafletMouseEvent) => handlerRef.current(e.latlng.lat, e.latlng.lng);
map.on("click", onClick);
return () => {
map.off("click", onClick);
};
}, [mapRef, disabled]);
return null;
}