"use client"; /* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { BRAND_COLORS } from "@/lib/map-data"; export interface UserMarker { id: string; lat: number; lng: number; type: string; name: string; meta?: { poverty_level?: string; family_count?: number; penghasilan?: number; fuzzy_label?: string; fuzzy_score?: number; fuzzy_detail?: Record; notes?: string; poi_type?: string; radius?: number; coordinates?: [number, number][]; gas_types?: string[]; created_by_username?: string; created_at?: string; updated_at?: string; }; } interface LeafletMapProps { mode: "poverty" | "land" | "gas-station"; selectedYear?: number; selectedId?: string; onSelect?: (id: string) => void; visibilityFilters?: Record; fuelPriceDisplay?: "ron92" | "ron95" | "diesel"; fullBleed?: boolean; mapStyle?: "street" | "satellite"; onMapClick?: (lat: number, lng: number) => void; userMarkers?: UserMarker[]; onMarkerClick?: (marker: UserMarker) => void; onMapReady?: (mapInstance: any) => void; onShapeCreated?: (shape: UserMarker) => void; placingMarkerType?: string | null; dragDropEnabled?: boolean; onMarkerDragEnd?: (marker: UserMarker, newLat: number, newLng: number) => void; } const POVERTY_LEVEL_COLOR: Record = { Extreme: "#ef4444", Miskin: "#f59e0b", Rentan: "#3b82f6", }; const FUZZY_LABEL_COLOR: Record = { "SANGAT TINGGI": "#ef4444", "TINGGI": "#f97316", "SEDANG": "#f59e0b", "RENDAH": "#10b981", "TIDAK PRIORITAS":"#6b7280", }; function fmt(n: number) { return "Rp " + n.toLocaleString("id-ID"); } const POI_TYPE_LABELS: Record = { marker: "Data Warga", mosque: "Masjid", church: "Gereja", cathedral: "Gereja Katolik", temple: "Pura", vihara: "Vihara", klenteng: "Klenteng", synagogue: "Sinagog", clinic: "Klinik", "food-bank": "Lumbung Pangan", school: "Sekolah", flag: "Tengara", protected: "Kawasan Lindung", registry: "Kantor Pendaftaran", "charging-station": "Pengisi Daya EV", "gas-pump": "SPBU", wrench: "Bengkel", line: "Garis", polygon: "Poligon", circle: "Lingkaran", }; function buildMarkerTooltip(m: UserMarker): string { const meta = m.meta; const isHousehold = m.type === "marker"; const typeLabel = POI_TYPE_LABELS[m.type] ?? m.type.replace(/-/g, " "); const base = `
${m.name}
`; if (isHousehold && meta) { const lvlColor = POVERTY_LEVEL_COLOR[meta.poverty_level ?? ""] ?? "#6b7280"; const fuzzyColor = FUZZY_LABEL_COLOR[meta.fuzzy_label ?? ""] ?? "#6b7280"; const displayPovertyLevel = meta.poverty_level === "Extreme" ? "Ekstrem" : (meta.poverty_level ?? "—"); return base + `
Tingkat ${displayPovertyLevel} Tanggungan ${meta.family_count ?? "—"} jiwa Penghasilan ${meta.penghasilan != null ? fmt(meta.penghasilan) : "—"}/bln ${meta.fuzzy_label ? ` Prioritas ${meta.fuzzy_label} ` : ""} ${meta.fuzzy_score != null ? ` Skor ${meta.fuzzy_score.toFixed(2)} ` : ""} ${meta.notes ? ` Catatan ${meta.notes} ` : ""}
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
`; } const gasTypesHtml = m.type === "gas-pump" && meta?.gas_types && meta.gas_types.length > 0 ? `
${meta.gas_types.map((gt: string) => `${gt}`).join("")}
` : ""; return base + `
${typeLabel}
${meta?.poi_type ? `
${meta.poi_type}
` : ""} ${meta?.notes ? `
${meta.notes}
` : ""} ${gasTypesHtml}
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
`; } export default function LeafletMap({ mode, selectedYear = 2026, selectedId, onSelect, visibilityFilters, fuelPriceDisplay = "ron95", fullBleed = true, mapStyle = "street", onMapClick, userMarkers, onMarkerClick, onMapReady, onShapeCreated, placingMarkerType, dragDropEnabled = false, onMarkerDragEnd, }: LeafletMapProps) { const mapContainerRef = useRef(null); const mapInstanceRef = useRef(null); const [leafletLoaded, setLeafletLoaded] = useState(false); const layersRef = useRef([]); const streetLayerRef = useRef(null); const satelliteLayerRef = useRef(null); // 1. Inject Leaflet JS/CSS and optionally Geoman for land drawing useEffect(() => { let isMounted = true; // Promise-based script loader that handles already-loaded scripts const loadScript = (id: string, src: string): Promise => { return new Promise((resolve, reject) => { const existing = document.getElementById(id) as HTMLScriptElement | null; if (existing) { // Script tag exists — check if it already finished loading // by testing for the global it should have created if (id === "leaflet-js" && (window as any).L) { resolve(); return; } if (id === "geoman-js" && (window as any).L?.PM) { resolve(); return; } // Still loading — listen for completion const onLoad = () => { existing.removeEventListener("load", onLoad); resolve(); }; const onError = () => { existing.removeEventListener("error", onError); reject(new Error(`Script ${id} failed`)); }; existing.addEventListener("load", onLoad); existing.addEventListener("error", onError); return; } const script = document.createElement("script"); script.id = id; script.src = src; script.async = true; script.onload = () => resolve(); script.onerror = () => reject(new Error(`Failed to load ${src}`)); document.body.appendChild(script); }); }; const loadAll = async () => { if (typeof window === "undefined") return; // Inject Leaflet CSS if (!document.getElementById("leaflet-css")) { const link = document.createElement("link"); link.id = "leaflet-css"; link.rel = "stylesheet"; link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; document.head.appendChild(link); } // Inject Geoman CSS for land mode if (mode === "land" && !document.getElementById("geoman-css")) { const link = document.createElement("link"); link.id = "geoman-css"; link.rel = "stylesheet"; link.href = "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.css"; document.head.appendChild(link); } try { // Step 1: Load Leaflet core (skip if already loaded) if (!(window as any).L) { await loadScript("leaflet-js", "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"); } // Step 2: For land mode, load Geoman AFTER Leaflet (skip if already loaded) if (mode === "land" && !(window as any).L?.PM) { await loadScript("geoman-js", "https://unpkg.com/@geoman-io/leaflet-geoman-free@2.17.0/dist/leaflet-geoman.js"); } // Both scripts loaded — safe to create the map if (isMounted) setLeafletLoaded(true); } catch (err) { console.error("[LeafletMap] Script loading failed:", err); // Still allow non-land modes to proceed without Geoman if (mode !== "land" && (window as any).L && isMounted) { setLeafletLoaded(true); } } }; loadAll(); return () => { isMounted = false; }; }, [mode]); // 2. Initialize Leaflet Map useEffect(() => { if (!leafletLoaded || !mapContainerRef.current) return; const L = (window as any).L; if (!L) return; // For land mode, verify Geoman is truly available before creating the map if (mode === "land" && !L.PM) { console.warn("[LeafletMap] Geoman not available for land mode — aborting map init"); return; } // Create Map Instance centered in Pontianak const map = L.map(mapContainerRef.current, { center: [-0.055453066586343774, 109.34839801239819], zoom: 12, zoomControl: true }); // Safety net: if Geoman loaded but map.pm is still undefined, // manually re-initialize Geoman on this map instance if (mode === "land" && L.PM && !map.pm) { try { L.PM.reInitLayer(map); } catch (e) { console.warn("[LeafletMap] L.PM.reInitLayer failed, trying manual init:", e); // Fallback: Geoman v2 attaches via addInitHook, try calling it directly if (typeof map._initPathRoot === "function") map._initPathRoot(); } } // Hide Geoman's default toolbar — we drive drawing programmatically if (mode === "land" && map.pm) { try { map.pm.addControls({ position: "topleft" }); map.pm.removeControls(); } catch (_) { /* ignore */ } } const street = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap contributors' }); const satellite = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' }); streetLayerRef.current = street; satelliteLayerRef.current = satellite; // Only the active layer lives on the map — a hidden (opacity 0) tile // layer still downloads tiles on every pan/zoom. const active = mapStyle === "satellite" ? satellite : street; active.setOpacity(1); active.addTo(map); mapInstanceRef.current = map; onMapReady?.(map); return () => { if (mapInstanceRef.current) { mapInstanceRef.current.remove(); mapInstanceRef.current = null; } }; }, [leafletLoaded]); // 2.5 Handle Map Style Cross-Fade Animation useEffect(() => { const map = mapInstanceRef.current; const street = streetLayerRef.current; const satellite = satelliteLayerRef.current; if (!map || !street || !satellite) return; const incoming = mapStyle === "satellite" ? satellite : street; const outgoing = mapStyle === "satellite" ? street : satellite; if (!map.hasLayer(incoming)) { incoming.setOpacity(0); incoming.addTo(map); } if (!map.hasLayer(outgoing)) { // Initial mount — active layer already on the map, nothing to fade. incoming.setOpacity(1); return; } let rafId = 0; let start: number | null = null; const duration = 400; // 400ms transition const animate = (timestamp: number) => { if (!start) start = timestamp; const progress = Math.min((timestamp - start) / duration, 1); incoming.setOpacity(progress); outgoing.setOpacity(1 - progress); if (progress < 1) { rafId = requestAnimationFrame(animate); } else { // Detach the hidden layer so it stops requesting tiles. map.removeLayer(outgoing); } }; rafId = requestAnimationFrame(animate); return () => cancelAnimationFrame(rafId); }, [mapStyle, leafletLoaded]); // 3. Render Layers based on Props useEffect(() => { const map = mapInstanceRef.current; const L = (window as any).L; if (!map || !L) return; // Clear existing layers layersRef.current.forEach((layer) => map.removeLayer(layer)); layersRef.current = []; if (mode === "poverty") { // Poverty mode renders only real data: household + POI markers from // the database (drawn below from userMarkers). No mock choropleth. } else if (mode === "land") { // Land mode renders only real data: land_markers + land_shapes from // the database (drawn below from userMarkers). No mock zone polygons. } else if (mode === "gas-station") { // Gas station mode renders only real data from the database (drawn below from userMarkers) } // Status-based colors for shapes const ROAD_STATUS_COLORS: Record = { "National Road": "#ef4444", "Provincial Road": "#eab308", "Regency Road": "#22c55e", }; const LAND_STATUS_COLORS: Record = { "SHM": "#a855f7", "HGB": "#f97316", "HGU": "#14b8a6", "HP": "#ec4899", }; // Draw User-Placed Markers and Shapes const RELIGION_TYPES = new Set(["mosque","church","cathedral","temple","vihara","klenteng","synagogue"]); if (userMarkers && userMarkers.length > 0) { userMarkers.forEach((m) => { // Visibility filter (land mode): skip rendering if this type is hidden. // visibilityFilters is undefined for poverty/gas-station, so this is a no-op there. if (visibilityFilters && visibilityFilters[m.type] === false) return; // Render user-placed polylines (lines) if (m.type === "line" && m.meta?.coordinates) { const validCoords = m.meta.coordinates.filter( (c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1]) ); if (validCoords.length >= 2) { const lineColor = ROAD_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f"; const polyline = L.polyline(validCoords, { color: lineColor, weight: 4, opacity: 0.85 }).addTo(map); const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : ""; polyline.bindTooltip(`${m.name}
GARIS${statusLabel}`, { sticky: true }); polyline.on("click", (e: any) => { e.originalEvent?.stopPropagation(); onMarkerClick?.(m); }); layersRef.current.push(polyline); } return; } // Render user-placed polygons if (m.type === "polygon" && m.meta?.coordinates) { const validCoords = m.meta.coordinates.filter( (c: any) => Array.isArray(c) && typeof c[0] === "number" && !isNaN(c[0]) && typeof c[1] === "number" && !isNaN(c[1]) ); if (validCoords.length >= 3) { const polyColor = LAND_STATUS_COLORS[m.meta?.poi_type ?? ""] ?? "#ff3b1f"; const polygon = L.polygon(validCoords, { fillColor: polyColor, fillOpacity: 0.25, color: polyColor, weight: 2, opacity: 0.85 }).addTo(map); const statusLabel = m.meta?.poi_type ? ` · ${m.meta.poi_type}` : ""; polygon.bindTooltip(`${m.name}
POLIGON${statusLabel}`, { sticky: true }); polygon.on("click", (e: any) => { e.originalEvent?.stopPropagation(); onMarkerClick?.(m); }); layersRef.current.push(polygon); } return; } // Render user-placed circles if (m.type === "circle" && typeof m.lat === "number" && !isNaN(m.lat) && typeof m.lng === "number" && !isNaN(m.lng) && (m.meta?.radius ?? 0) > 0) { const circle = L.circle([m.lat, m.lng], { radius: m.meta!.radius!, fillColor: "#ff3b1f", fillOpacity: 0.2, color: "#ff3b1f", weight: 2, opacity: 0.85 }).addTo(map); circle.bindTooltip(`${m.name}
Jenis: LINGKARAN
Radius: ${m.meta!.radius!.toFixed(1)}m`, { sticky: true }); circle.on("click", (e: any) => { e.originalEvent?.stopPropagation(); onMarkerClick?.(m); }); layersRef.current.push(circle); return; } let markerColor = "rgb(59, 130, 246)"; let iconName = "map-pin"; switch (m.type) { case "marker": markerColor = FUZZY_LABEL_COLOR[m.meta?.fuzzy_label ?? ""] ?? markerColor; iconName = "map-pin"; break; case "mosque": markerColor = "rgb(16, 185, 129)"; iconName = "mosque"; break; case "church": markerColor = "rgb(139, 92, 246)"; iconName = "church"; break; case "cathedral": markerColor = "rgb(124, 58, 237)"; iconName = "crown-cross"; break; case "temple": markerColor = "rgb(234, 88, 12)"; iconName = "hands-praying"; break; case "vihara": markerColor = "rgb(202, 138, 4)"; iconName = "hands-praying"; break; case "klenteng": markerColor = "rgb(185, 28, 28)"; iconName = "hands-praying"; break; case "synagogue": markerColor = "rgb(245, 158, 11)"; iconName = "synagogue"; break; case "clinic": markerColor = "rgb(239, 68, 68)"; iconName = "first-aid"; break; case "food-bank": markerColor = "rgb(217, 70, 239)"; iconName = "bowl-food"; break; case "school": markerColor = "rgb(6, 182, 212)"; iconName = "graduation-cap"; break; case "flag": markerColor = "rgb(244, 63, 94)"; iconName = "flag"; break; case "protected": markerColor = "rgb(4, 120, 87)"; iconName = "tree"; break; case "registry": markerColor = "rgb(29, 78, 216)"; iconName = "bank"; break; case "charging-station": { iconName = "charging-station"; const evType = m.meta?.poi_type ?? ""; if (evType === "DC Fast") { markerColor = "#10b981"; // Emerald } else if (evType === "Supercharger") { markerColor = "#14b8a6"; // Teal } else if (evType === "AC Level 2") { markerColor = "#3b82f6"; // Blue } else { markerColor = "rgb(16, 185, 129)"; // Default green } break; } case "gas-pump": { iconName = "gas-pump"; const brand = m.meta?.poi_type ?? ""; if (brand === "Pertamina") { markerColor = "#ef4444"; // Red } else if (brand === "Shell") { markerColor = "#eab308"; // Yellow } else if (brand === "BP") { markerColor = "#10b981"; // Green } else if (brand === "Vivo") { markerColor = "#3b82f6"; // Blue } else { markerColor = "rgb(220, 38, 38)"; // Default red } break; } case "wrench": { iconName = "wrench"; const spec = m.meta?.poi_type ?? ""; if (spec === "Umum") { markerColor = "#6b7280"; // Gray } else if (spec === "AC & Kelistrikan") { markerColor = "#0ea5e9"; // Sky } else if (spec === "Mesin") { markerColor = "#f97316"; // Orange } else if (spec === "Ban & Velg") { markerColor = "#f59e0b"; // Amber } else { markerColor = "rgb(107, 114, 128)"; // Default gray } break; } } let borderStyle = "2px solid white"; if (m.type === "gas-pump" || m.type === "charging-station" || m.type === "wrench") { const notesLower = (m.meta?.notes ?? "").toLowerCase(); if (notesLower.includes("24 jam") || notesLower.includes("24 hours")) { borderStyle = "2.5px solid #22c55e"; // Emerald green border for 24h } else if (notesLower.includes("tutup") || notesLower.includes("offline") || notesLower.includes("maintenance")) { borderStyle = "2.5px solid #ef4444"; // Red border for closed/offline } else if (notesLower.includes("buka") || notesLower.includes("active") || notesLower.includes("open") || notesLower.includes("jam:")) { borderStyle = "2.5px solid #3b82f6"; // Blue border for active/open/hours } } const customIcon = L.divIcon({ className: "custom-user-marker", html: `
`, iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -32] }); const isDraggableMarker = !!dragDropEnabled && (m.type === "marker" || RELIGION_TYPES.has(m.type) || ["clinic", "food-bank", "school", "flag", "protected", "registry", "charging-station", "gas-pump", "wrench"].includes(m.type)); const marker = L.marker([m.lat, m.lng], { icon: customIcon, draggable: isDraggableMarker }).addTo(map); if (isDraggableMarker) { marker.on("dragend", (e: any) => { const newLatLng = e.target.getLatLng(); onMarkerDragEnd?.(m, newLatLng.lat, newLatLng.lng); }); } const tooltipHtml = buildMarkerTooltip(m); marker.bindTooltip(tooltipHtml, { direction: "top", className: "user-marker-tooltip", }); marker.on("click", (e: any) => { e.originalEvent?.stopPropagation(); onMarkerClick?.(m); }); layersRef.current.push(marker); if (RELIGION_TYPES.has(m.type) && (m.meta?.radius ?? 0) > 0) { const circle = L.circle([m.lat, m.lng], { radius: m.meta!.radius!, color: markerColor, fillColor: markerColor, fillOpacity: 0.08, weight: 1.5, dashArray: "5 4", interactive: false, }).addTo(map); layersRef.current.push(circle); } }); } }, [mode, visibilityFilters, leafletLoaded, userMarkers, dragDropEnabled]); // 4. Handle Map Click Callback useEffect(() => { const map = mapInstanceRef.current; if (!map || !onMapClick) return; const handleMapClick = (e: any) => { onMapClick(e.latlng.lat, e.latlng.lng); }; map.on("click", handleMapClick); return () => { map.off("click", handleMapClick); }; }, [onMapClick, leafletLoaded]); // 5. Handle Geoman Shape Creation useEffect(() => { const map = mapInstanceRef.current; if (!map || mode !== "land" || !map.pm) return; const handlePmCreate = (e: any) => { try { const layer = e.layer; const type = String(e.shape || e.layerType || "").toLowerCase(); // Remove Geoman temp layer asynchronously in the next tick to prevent event loop crashes setTimeout(() => { try { layer.remove(); } catch (_) { /* ignore */ } }, 0); const id = `shape-${Date.now()}`; let newShape: UserMarker | null = null; const geojson = layer.toGeoJSON(); if (type === "line" || type === "polyline") { const geomCoords = geojson.geometry.coordinates; if (Array.isArray(geomCoords) && geomCoords.length > 0) { const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]); newShape = { id, lat: coordinates[0][0], lng: coordinates[0][1], type: "line", name: "Survey Line " + new Date().toLocaleDateString("id-ID"), meta: { coordinates } }; } } else if (type === "polygon" || type === "rectangle") { const geomCoords = geojson.geometry.coordinates[0]; if (Array.isArray(geomCoords) && geomCoords.length > 0) { const coordinates: [number, number][] = geomCoords.map(([lng, lat]: [number, number]) => [lat, lng]); newShape = { id, lat: coordinates[0][0], lng: coordinates[0][1], type: "polygon", name: "Survey Area " + new Date().toLocaleDateString("id-ID"), meta: { coordinates } }; } } else if (type === "circle") { const latlng = layer.getLatLng(); const radius = layer.getRadius(); newShape = { id, lat: latlng.lat, lng: latlng.lng, type: "circle", name: "Buffer Zone " + new Date().toLocaleDateString("id-ID"), meta: { radius } }; } if (newShape && onShapeCreated) { onShapeCreated(newShape); } } catch (err) { console.error("[LeafletMap] pm:create handler error:", err); } }; map.on("pm:create", handlePmCreate); return () => { map.off("pm:create", handlePmCreate); }; }, [leafletLoaded, mode, onShapeCreated]); // 6. Programmatically enable Geoman drawing tools based on placingMarkerType useEffect(() => { const map = mapInstanceRef.current; if (!map || mode !== "land" || !map.pm) return; try { map.pm.disableDraw(); if (!placingMarkerType) return; const options = { templineStyle: { color: "#ff3b1f" }, hintlineStyle: { color: "#ff3b1f", dashArray: "5 5" }, pathOptions: { color: "#ff3b1f", fillColor: "#ff3b1f", fillOpacity: 0.2 } }; if (placingMarkerType === "line") { map.pm.enableDraw("Line", options); } else if (placingMarkerType === "polygon") { map.pm.enableDraw("Polygon", options); } else if (placingMarkerType === "circle") { map.pm.enableDraw("Circle", options); } } catch (err) { console.error("[LeafletMap] Geoman enableDraw error:", err); } }, [placingMarkerType, leafletLoaded, mode]); return (
{!leafletLoaded && (

Menginisialisasi peta Leaflet...

)}
); }