"use client"; import { useEffect, useMemo, useState } from "react"; import L from "leaflet"; import { MapContainer, Marker, Popup, TileLayer, Tooltip, Circle, useMap, useMapEvents } from "react-leaflet"; import { useTheme } from "next-themes"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Maximize2, Mail, MapPin, Minimize2, Move, Phone, RefreshCw, Search, Tag } from "lucide-react"; import { toast } from "sonner"; import { getMappableSuppliers } from "@/lib/pb-helpers"; import getPocketBase from "@/lib/pocketbase"; import type { Supplier } from "@/lib/types"; import { buildClusters, CLUSTER_PALETTE, clusterDistanceForZoom, parseCoordinates, type Cluster, } from "@/lib/geo"; import { clusterBubble, pinMarker } from "@/components/leaflet-markers"; type SupplierPoint = Supplier & { lat: number; lng: number; }; type SupplierCluster = Cluster; const DEFAULT_CENTER: [number, number] = [-26.2041, 28.0473]; const CLUSTER_ZOOM_BREAKPOINT = 12; const LABEL_ZOOM_BREAKPOINT = 12; /** * Max number of permanent supplier-name labels visible on the map at * once. Picked by distance to viewport centre — keeps the DOM light * when zoomed in over a dense cluster of pins. */ const LABEL_LIMIT = 24; /** Stable empty Set so consumers can `has()` without re-creating it. */ const EMPTY_LABELED_IDS = new Set(); const LIGHT_TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; const DARK_TILE_URL = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"; const LIGHT_TILE_ATTRIBUTION = '© OpenStreetMap'; const DARK_TILE_ATTRIBUTION = '© OpenStreetMap © CARTO'; function MapWatcher({ isFullscreen, onZoomChange, onViewportChange, }: { isFullscreen: boolean; onZoomChange: (zoom: number) => void; onViewportChange: (viewport: { center: [number, number]; zoom: number }) => void; }) { const map = useMap(); useEffect(() => { window.setTimeout(() => map.invalidateSize(), 180); }, [isFullscreen, map]); useMapEvents({ zoomend: (event) => { const target = event.target; const zoom = target.getZoom(); const center = target.getCenter(); onZoomChange(zoom); onViewportChange({ center: [center.lat, center.lng], zoom }); }, moveend: (event) => { const target = event.target; const center = target.getCenter(); onViewportChange({ center: [center.lat, center.lng], zoom: target.getZoom() }); }, }); return null; } function ClusterMarker({ cluster }: { cluster: SupplierCluster }) { const map = useMap(); const count = cluster.items.length; if (count === 1) { const supplier = cluster.items[0]; return ( ); } return ( { map.closePopup(); map.flyTo([cluster.center[0], cluster.center[1]], Math.min(map.getZoom() + 2, CLUSTER_ZOOM_BREAKPOINT), { duration: 0.4 }); }, }} /> ); } function SupplierPopup({ supplier, editMode }: { supplier: SupplierPoint; editMode: boolean }) { return (

{supplier.suppliername || "Unnamed supplier"}

{supplier.supplieraddress &&

{supplier.supplieraddress}

} {supplier.suppliernumber &&

Phone: {supplier.suppliernumber}

} {supplier.email &&

Email: {supplier.email}

} {supplier.supply_tags &&

Tags: {supplier.supply_tags}

} {editMode && (

Drag this marker to correct its saved location.

)}
); } export function SupplierMap() { const { resolvedTheme } = useTheme(); const [suppliers, setSuppliers] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [zoom, setZoom] = useState(10); const [viewport, setViewport] = useState<{ center: [number, number]; zoom: number } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const [editMode, setEditMode] = useState(false); const isDarkMap = resolvedTheme === "dark"; const fetchSuppliers = async () => { setLoading(true); try { setSuppliers(await getMappableSuppliers()); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to load suppliers"); } finally { setLoading(false); } }; useEffect(() => { fetchSuppliers(); }, []); const saveSupplierLocation = async (supplier: SupplierPoint, lat: number, lng: number) => { const coordinates = `${lat.toFixed(6)}, ${lng.toFixed(6)}`; setSuppliers((current) => current.map((item) => (item.id === supplier.id ? { ...item, coordinates } : item)) ); try { const pb = getPocketBase(); await pb.collection("suppliers").update(supplier.id, { coordinates }); toast.success(`Updated ${supplier.suppliername || "supplier"} location`); } catch (err) { setSuppliers((current) => current.map((item) => (item.id === supplier.id ? { ...item, coordinates: supplier.coordinates } : item)) ); toast.error(err instanceof Error ? err.message : "Failed to update supplier location"); } }; const { mapped, missing } = useMemo(() => { const filtered = suppliers.filter((supplier) => { const query = search.trim().toLowerCase(); if (!query) return true; return [supplier.suppliername, supplier.supplieraddress, supplier.supply_tags] .some((value) => value?.toLowerCase().includes(query)); }); const supplierPoints: SupplierPoint[] = []; const missingCoordinates: Supplier[] = []; for (const supplier of filtered) { const coordinates = parseCoordinates(supplier.coordinates); if (!coordinates) { missingCoordinates.push(supplier); continue; } supplierPoints.push({ ...supplier, lat: coordinates[0], lng: coordinates[1], }); } return { mapped: supplierPoints, missing: missingCoordinates }; }, [search, suppliers]); const clusterDistanceKm = clusterDistanceForZoom(zoom); // `clusterDistanceKm` only changes when zoom crosses one of // {7, 9, 11} (see clusterDistanceForZoom). Dep'ing on the bucket // value — not on zoom — keeps `buildClusters` from re-running on // every wheel tick. If you change the cluster distance function, // keep this bucketed. const clusters = useMemo(() => buildClusters(mapped, clusterDistanceKm), [clusterDistanceKm, mapped]); const showIndividualSuppliers = zoom >= CLUSTER_ZOOM_BREAKPOINT; const showLabels = zoom >= LABEL_ZOOM_BREAKPOINT; const mapCenter: [number, number] = mapped.length ? [ mapped.reduce((sum, supplier) => sum + supplier.lat, 0) / mapped.length, mapped.reduce((sum, supplier) => sum + supplier.lng, 0) / mapped.length, ] : DEFAULT_CENTER; const clusterBySupplierId = useMemo(() => { const m = new Map(); for (const cluster of clusters) { for (const supplier of cluster.items) { m.set(supplier.id, cluster); } } return m; }, [clusters]); /** * IDs of the suppliers that should render a permanent name label * at the current zoom. Top LABEL_LIMIT by squared distance to the * viewport centre — cheap, no sqrt, and "centre of viewport" is a * good enough proxy for "what the user is actually looking at". */ const labeledIds = useMemo(() => { if (!showLabels || !viewport) return EMPTY_LABELED_IDS; const [cLat, cLng] = viewport.center; const ranked = mapped .map((supplier) => ({ id: supplier.id, d: (supplier.lat - cLat) ** 2 + (supplier.lng - cLng) ** 2, })) .sort((a, b) => a.d - b.d) .slice(0, LABEL_LIMIT); return new Set(ranked.map((entry) => entry.id)); }, [mapped, showLabels, viewport]); return (
Mapped Suppliers

{mapped.length}

With usable coordinates

Proximity Groups

{clusters.length}

Grouped within {clusterDistanceKm}km at this zoom

Needs Coordinates

{missing.length}

Supplier records to update

setSearch(event.target.value)} placeholder="Search suppliers, tags, address..." className="pl-9" />
{editMode && (
Drag pins to save locations
)}
{!showIndividualSuppliers && clusters.map((cluster) => ( ))} {showIndividualSuppliers && clusters.map((cluster) => ( ))} {showIndividualSuppliers && mapped.map((supplier) => { const cluster = clusterBySupplierId.get(supplier.id); const label = cluster ? String(cluster.items.findIndex((item) => item.id === supplier.id) + 1) : ""; return ( { const marker = event.target as L.Marker; const position = marker.getLatLng(); void saveSupplierLocation(supplier, position.lat, position.lng); }, }} > {showLabels && labeledIds.has(supplier.id) && ( {supplier.suppliername || "Unnamed supplier"} )} ); })}
Proximity Groups {clusters.length === 0 ? (

No mapped suppliers yet.

) : ( clusters.map((cluster, index) => (

Group {index + 1}

{cluster.items.length} supplier{cluster.items.length === 1 ? "" : "s"}
{cluster.items.map((supplier, supplierIndex) => (

{supplierIndex + 1}. {supplier.suppliername || "Unnamed supplier"}

{supplier.supplieraddress && (

{supplier.supplieraddress}

)}
))}
)) )}
Missing Coordinates {missing.length === 0 ? (

All visible suppliers have coordinates.

) : ( missing.map((supplier) => (

{supplier.suppliername || "Unnamed supplier"}

{supplier.supplieraddress &&

{supplier.supplieraddress}

}
{supplier.suppliernumber && {supplier.suppliernumber}} {supplier.email && {supplier.email}} {supplier.supply_tags && {supplier.supply_tags}}
)) )}
); }