mega-plant/components/PlantMap.tsx
David 3cd3025116 Map: dark tiles from Esri, because CARTO retired keyless access
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.
2026-10-03 09:20:24 +02:00

551 lines
22 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 { 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
dark: {
attribution:
'Tiles &copy; <a href="https://www.esri.com/">Esri</a> &mdash; 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) =>
({ "&": "&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;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;
}