CARTO now answers every keyless basemap request with HTTP 200 and a valid
256x256 PNG that is just a dark square watermarked "API KEY REQUIRED".
Leaflet accepts it as a tile and paints it, so the map silently filled with
the warning — no console error, nothing in the network tab to catch it.
Esri's World Dark Gray Canvas is a genuine dark basemap and needs no key.
Its ArcGIS REST service orders the tile path {z}/{y}/{x}, the reverse of
what Leaflet knows how to substitute, so a getTileUrl override is the whole
fix. react-leaflet always instantiates Leaflet's own TileLayer, so the
custom class is added to the map directly and torn down on unmount rather
than passed as a prop.
551 lines
22 KiB
TypeScript
551 lines
22 KiB
TypeScript
"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 { cn } from "@/lib/utils";
|
||
|
||
type MapAsset = {
|
||
id: string;
|
||
code: string;
|
||
name: string;
|
||
category: string;
|
||
type: string;
|
||
is_active: boolean;
|
||
latitude: number | null;
|
||
longitude: number | null;
|
||
};
|
||
|
||
/** A machine counts as located only with a real, non-null, non-(0,0) pair. */
|
||
export function hasCoords(a: MapAsset): boolean {
|
||
if (a.latitude == null || a.longitude == null) return false;
|
||
if (a.latitude === 0 && a.longitude === 0) return false;
|
||
return true;
|
||
}
|
||
|
||
// Johannesburg / Ekurhuleni — the client operates around Halfway Gardens,
|
||
// Cosmo City and West Park. Swap this when the fleet spreads nationally.
|
||
const DEFAULT_CENTER: [number, number] = [-26.05, 28.05];
|
||
const DEFAULT_ZOOM = 10;
|
||
|
||
// Tile providers. Light mode stays on OpenStreetMap's standard tiles. Dark mode
|
||
// uses Esri's World Dark Gray Canvas, which is a genuine dark basemap and needs
|
||
// no API key.
|
||
//
|
||
// Do not switch dark mode back to CARTO (basemaps.cartocdn.com). CARTO retired
|
||
// keyless basemap access: it now answers every tile request with HTTP 200 and a
|
||
// 256x256 PNG that is just a dark square watermarked "API KEY REQUIRED". Leaflet
|
||
// treats that as a valid tile and paints it, so the map fills with the warning
|
||
// and nothing appears in the console or the network tab as an error.
|
||
//
|
||
// OSM's usage policy still forbids heavy/commercial traffic. That is fine for a
|
||
// handful of staff; move both providers to a paid tier before the site count grows.
|
||
const TILES = {
|
||
light: {
|
||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
},
|
||
dark: {
|
||
attribution:
|
||
'Tiles © <a href="https://www.esri.com/">Esri</a> — Esri, DeLorme, NAVTEQ',
|
||
},
|
||
} as const;
|
||
|
||
const ESRI_DARK_SERVICE =
|
||
"https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile";
|
||
|
||
/**
|
||
* Esri's ArcGIS REST service is keyless but orders the tile path {z}/{y}/{x} —
|
||
* the reverse of the {z}/{x}/{y} that every other provider uses and the only
|
||
* order Leaflet knows how to substitute. Overriding getTileUrl is the whole fix.
|
||
*/
|
||
const EsriDarkLayer = L.TileLayer.extend({
|
||
getTileUrl(coords: L.Coords) {
|
||
return `${ESRI_DARK_SERVICE}/${coords.z}/${coords.y}/${coords.x}`;
|
||
},
|
||
}) as unknown as typeof L.TileLayer;
|
||
|
||
/**
|
||
* react-leaflet always instantiates Leaflet's own TileLayer for <TileLayer>, so
|
||
* the Esri class cannot be slotted in as a prop. Adding it to the map directly
|
||
* and tearing it down on unmount is the supported way to swap the class.
|
||
*/
|
||
function DarkTiles({ attribution }: { attribution: string }) {
|
||
const map = useMap();
|
||
useEffect(() => {
|
||
// The url argument is never read: getTileUrl above fully overrides it.
|
||
const layer = new EsriDarkLayer(ESRI_DARK_SERVICE, { attribution });
|
||
layer.addTo(map);
|
||
return () => {
|
||
map.removeLayer(layer);
|
||
};
|
||
}, [map, attribution]);
|
||
return null;
|
||
}
|
||
|
||
const escapeHtml = (s: string) =>
|
||
s.replace(/[&<>"']/g, (c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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;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>`,
|
||
iconSize: [34, 34],
|
||
iconAnchor: [17, 17],
|
||
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],
|
||
});
|
||
}
|
||
|
||
/** Re-centres the map when the caller asks (fit-all, or a list selection). */
|
||
function MapController({ focus }: { focus: { lat: number; lng: number; zoom?: number } | null }) {
|
||
const map = useMap();
|
||
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 }: { assets: MapAsset[] }) {
|
||
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 [dark, setDark] = useState(false);
|
||
const [placingId, setPlacingId] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
|
||
// Read the theme class the ThemeProvider puts on <html>. Leaflet needs the
|
||
// right tile set, and this is cheaper than threading context through here.
|
||
useEffect(() => {
|
||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||
const read = () => setDark(document.documentElement.classList.contains("dark"));
|
||
read();
|
||
const obs = new MutationObserver(read);
|
||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||
mq.addEventListener("change", read);
|
||
return () => {
|
||
obs.disconnect();
|
||
mq.removeEventListener("change", read);
|
||
};
|
||
}, []);
|
||
|
||
const located = useMemo(() => assetsState.filter(hasCoords), [assetsState]);
|
||
const awaiting = useMemo(() => assetsState.filter((a) => !hasCoords(a)), [assetsState]);
|
||
|
||
const visible = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
return assetsState
|
||
.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),
|
||
);
|
||
}, [assetsState, 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 } : a)),
|
||
);
|
||
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 } : 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 lg:grid-cols-[minmax(0,1fr)_340px] gap-4 items-start">
|
||
{/* ── Map ─────────────────────────────────────────────────────── */}
|
||
<div className="relative rounded-2xl overflow-hidden border border-gray-200 dark:border-white/5 shadow-sm">
|
||
<MapContainer
|
||
center={DEFAULT_CENTER}
|
||
zoom={DEFAULT_ZOOM}
|
||
scrollWheelZoom
|
||
className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")}
|
||
>
|
||
{dark ? (
|
||
<DarkTiles attribution={TILES.dark.attribution} />
|
||
) : (
|
||
<TileLayer url={TILES.light.url} attribution={TILES.light.attribution} />
|
||
)}
|
||
<MapController focus={focus} />
|
||
|
||
{/* 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
|
||
>
|
||
{located.map((a) => (
|
||
<Marker
|
||
key={a.id}
|
||
position={[a.latitude as number, a.longitude as number]}
|
||
icon={badgeIcon(a.code || "?", a.is_active)}
|
||
>
|
||
<Popup>
|
||
<div style={{ minWidth: "180px" }}>
|
||
<div style={{ fontWeight: 700, marginBottom: 2 }}>{escapeHtml(a.name)}</div>
|
||
<div style={{ color: "#6b7280", fontSize: 12, marginBottom: 6 }}>
|
||
{escapeHtml(a.code || "no code")}
|
||
{a.type ? ` · ${escapeHtml(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.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
</MarkerClusterGroup>
|
||
</MapContainer>
|
||
|
||
{/* Placing mode — capture the map click outside React's synthetic tree */}
|
||
{placingId && (
|
||
<MapClickCapture
|
||
onPick={(lat, lng) => saveCoords(placingId, lat, lng)}
|
||
disabled={saving}
|
||
/>
|
||
)}
|
||
|
||
{/* Legend */}
|
||
<div className="absolute left-3 bottom-3 z-[500] flex flex-col gap-1.5 rounded-xl bg-white/95 dark:bg-surface/95 backdrop-blur border border-gray-200 dark:border-white/10 px-2.5 py-2 text-xs">
|
||
<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="h-px bg-gray-200 dark:bg-white/10 my-0.5" />
|
||
<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>
|
||
|
||
{/* Nothing located yet */}
|
||
{located.length === 0 && !placingId && (
|
||
<div className="absolute inset-0 z-[600] grid place-items-center p-4 bg-white/80 dark:bg-[#0f151b]/80 backdrop-blur-sm">
|
||
<div className="text-center max-w-sm">
|
||
<div className="w-12 h-12 mx-auto rounded-2xl bg-[#30638E]/10 grid place-items-center mb-3">
|
||
<MapPinned className="w-6 h-6 text-[#30638E]" />
|
||
</div>
|
||
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white mb-1">
|
||
No coordinates yet
|
||
</h3>
|
||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||
All {awaiting.length} machines are waiting on the on-site scan sheet. Until it
|
||
lands, drop a pin manually below to start building the map.
|
||
</p>
|
||
</div>
|
||
</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="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">
|
||
<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…"
|
||
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 : (awaiting[0]?.id ?? located[0]?.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(
|
||
"flex items-center gap-2 p-2 transition",
|
||
isPlacing ? "bg-[#EDAE49]/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.02]",
|
||
!a.is_active && !isPlacing && "opacity-60",
|
||
)}
|
||
>
|
||
<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.latitude?.toFixed(4)}, {a.longitude?.toFixed(4)}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400 dark:text-gray-500">
|
||
No coordinates yet
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlacingId(isPlacing ? null : a.id)}
|
||
aria-label={`Place ${a.name} on the map`}
|
||
className={cn(
|
||
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0",
|
||
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 && (
|
||
<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>
|
||
)}
|
||
</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;
|
||
}
|