import { useCallback, useEffect, useRef } from "react"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import L from "leaflet"; import type { Feature, FeatureCollection, Geometry, MultiPoint, Point, GeoJsonObject, GeoJsonProperties } from "geojson"; import { activeLayersAtom } from "../state/active-layers"; import { leafletLayerInstancesAtom } from "../state/leaflet-layer-instances"; import { featureInformationAtom, FeatureInformationType } from "../state/feature-information"; import { constructWfsUrl } from "@/shared/utils/wms"; import colorScaleApi from "@/shared/services/color-scale"; import { appConfig } from "@/shared/config/app-config"; import { mergeDataToGeoJSON } from "@/shared/utils/mege-data-geojson"; import jatimGeojson from "@/public/jatim.json"; // -------- Helpers (module scope) -------- const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null; const looksLikeUrl = (s: string): boolean => { const t = s.trim(); if (/^(data:)/i.test(t)) return true; if (/^https?:\/\//i.test(t)) return true; if (/\.(svg|png|jpg|jpeg|gif)(\?|#|$)/i.test(t)) return true; return false; }; const pickUrl = (node: unknown): string | null => { if (typeof node === "string" && looksLikeUrl(node)) return node; if (!isRecord(node)) return null; const candidates: unknown[] = [node["url"], node["href"], node["externalGraphic"], node["xlink:href"], node["@xlink:href"], node["@href"]]; for (const c of candidates) { if (typeof c === "string" && looksLikeUrl(c)) return c; } return null; }; const deepFindUrl = (obj: unknown, depth = 0): string | null => { if (!obj || depth > 4) return null; const u = pickUrl(obj); if (u) return u; if (isRecord(obj)) { for (const k of Object.keys(obj)) { const r = deepFindUrl(obj[k], depth + 1); if (r) return r; } } else if (Array.isArray(obj)) { for (const el of obj) { const r = deepFindUrl(el, depth + 1); if (r) return r; } } return null; }; const extractLegendRulesFlexible = (json: unknown): unknown[] => { if (!json) return []; if (isRecord(json)) { const rules = json["rules"]; if (Array.isArray(rules)) return rules as unknown[]; const legendObj = json["legend"]; if (isRecord(legendObj) && Array.isArray(legendObj["rules"])) return (legendObj["rules"] as unknown[]) ?? []; const legendArr = json["legend"]; if (Array.isArray(legendArr)) { const all = legendArr.flatMap((e) => (isRecord(e) && Array.isArray(e["rules"]) ? (e["rules"] as unknown[]) : [])); if (all.length) return all; } const LegendObj = json["Legend"]; if (isRecord(LegendObj) && Array.isArray(LegendObj["rules"])) return (LegendObj["rules"] as unknown[]) ?? []; const LegendArr = json["Legend"]; if (Array.isArray(LegendArr)) { const all = LegendArr.flatMap((e) => (isRecord(e) && Array.isArray(e["rules"]) ? (e["rules"] as unknown[]) : [])); if (all.length) return all; } const classesArr = json["classes"]; if (Array.isArray(classesArr)) return classesArr as unknown[]; } return []; }; const findIconUrlInRule = (rule: unknown): string | null => { if (isRecord(rule)) { const sym = rule["symbolizers"]; if (Array.isArray(sym) && sym.length) { for (const s of sym) { if (!isRecord(s)) continue; const point = (s["pointSymbolizer"] ?? s["PointSymbolizer"] ?? s["Point"]) as unknown; const g = isRecord(point) ? point["graphic"] ?? point["Graphic"] ?? point["graphics"] ?? point : point; const url = deepFindUrl(g) || deepFindUrl(point) || deepFindUrl(s); if (url) return url; } } return deepFindUrl(rule); } return null; }; export const LayerManager = ({ map }: { map: L.Map | null }) => { const activeLayers = useAtomValue(activeLayersAtom); const [layerInstances, setLayerInstances] = useAtom(leafletLayerInstancesAtom); const setFeatureInformation = useSetAtom(featureInformationAtom); const featureInformation = useAtomValue(featureInformationAtom); const previousLayersRef = useRef>(new Map()); const highlightRef = useRef(null); const backdropRef = useRef(null); const clearHighlight = useCallback(() => { if (map && highlightRef.current) { map.removeLayer(highlightRef.current); highlightRef.current = null; } if (map && backdropRef.current) { map.removeLayer(backdropRef.current); backdropRef.current = null; } }, [map]); // Ensure custom panes for proper stacking and non-blocking UI useEffect(() => { if (!map) return; if (!map.getPane("backdropPane")) { const p = map.createPane("backdropPane"); p.style.zIndex = "450"; p.style.pointerEvents = "none"; } if (!map.getPane("highlightPane")) { const p = map.createPane("highlightPane"); p.style.zIndex = "650"; p.style.pointerEvents = "none"; } }, [map]); // Handle map click for feature info useEffect(() => { if (!map) return; const handleMapClick = async (e: L.LeafletMouseEvent) => { setFeatureInformation([]); clearHighlight(); // Find all visible WMS layers const wmsLayers = activeLayers.filter((layer) => layer.layer.type === "wms" && layer.settings.visible); if (wmsLayers.length === 0) return; try { const allFeatureInfo: FeatureInformationType[] = []; const highlightCandidates: Array<{ layer: (typeof wmsLayers)[number]; features: Feature[] }> = []; for (const layer of wmsLayers) { if (layer.layer.type !== "wms") continue; const url = layer.layer.url; const layers = layer.layer.layers; if (!url || !layers) continue; const bounds = map.getBounds(); const size = map.getSize(); // Construct GetFeatureInfo URL const params = new URLSearchParams({ service: "WMS", version: "1.1.1", request: "GetFeatureInfo", layers: layers, query_layers: layers, info_format: "application/json", feature_count: "20", x: Math.floor(e.containerPoint.x).toString(), y: Math.floor(e.containerPoint.y).toString(), width: Math.floor(size.x).toString(), height: Math.floor(size.y).toString(), srs: "EPSG:4326", bbox: `${bounds.getSouthWest().lng},${bounds.getSouthWest().lat},${bounds.getNorthEast().lng},${bounds.getNorthEast().lat}`, }); // Respect active CQL_FILTER (if set on the Leaflet WMS instance) try { const instance = (layerInstances as Map).get(layer.id) as L.TileLayer.WMS | undefined; const cql = (instance?.wmsParams as unknown as Record)?.["CQL_FILTER"] as string | undefined; if (cql) params.set("CQL_FILTER", cql); } catch { // ignore } const featureInfoUrl = `${url}?${params.toString()}`; // Fetch the feature info const response = await fetch(featureInfoUrl); if (!response.ok) continue; const featureData = await response.json(); if (featureData && featureData.features && featureData.features.length > 0) { const layerFeatureInfo = { id: layer.id, name: layer.name || layer.id, info: featureData.features, }; allFeatureInfo.push(layerFeatureInfo); highlightCandidates.push({ layer, features: featureData.features as Feature[] }); } } // Update state with all found features setFeatureInformation(allFeatureInfo.length > 0 ? allFeatureInfo : null); // Add geometry-based highlight for the returned features if (highlightCandidates.length > 0) { try { const group = L.layerGroup(); // selected rule label (if any) from URL (?filter=["label"]) to resolve icon for this layer const getSelectedFilterLabelFromUrl = (layerId: string): string | null => { try { const sp = new URLSearchParams(window.location.search); const filterKey = `filter__${String(layerId).replace(/[^a-zA-Z0-9_-]/g, "_")}`; const raw = sp.get(filterKey) ?? sp.get("filter"); if (!raw) return null; const arr = JSON.parse(raw); return Array.isArray(arr) && arr.length === 1 && typeof arr[0] === "string" ? arr[0] : null; } catch { return null; } }; // selected label for each layer will be resolved within loop // Helper within effect: try get legend icon URL for a given layer and selected rule label const tryGetIconForLayerRule = (baseUrl: string, layerName: string, ruleLabel: string | null): string | null => { if (!baseUrl || !layerName || !ruleLabel) return null; try { const cacheKey = `legend-json:${baseUrl}:${layerName}`; const cached = sessionStorage.getItem(cacheKey); const json: unknown = cached ? JSON.parse(cached) : null; if (!json) return null; const rules = extractLegendRulesFlexible(json); const rule = rules.find((r) => { if (!isRecord(r)) return false; const labelVal = r["title"] ?? r["name"]; return typeof labelVal === "string" && labelVal.trim() === ruleLabel; }); if (!rule) return null; return findIconUrlInRule(rule); } catch { return null; } }; for (const hc of highlightCandidates) { const selectedLabel = getSelectedFilterLabelFromUrl(hc.layer.id); const fc: FeatureCollection = { type: "FeatureCollection", features: hc.features as Feature[], }; const lg = L.geoJSON(fc, { pane: "highlightPane", pointToLayer: (feature: Feature, latlng: L.LatLng) => { // Try use icon for selected rule; else circle marker const iconUrl = tryGetIconForLayerRule(hc.layer.layer.url ?? "", hc.layer.layer.layers ?? "", selectedLabel); if (iconUrl) { const icon = L.icon({ iconUrl, iconSize: [24, 24], iconAnchor: [12, 12] }); const m = L.marker(latlng, { icon, pane: "highlightPane" }); const halo = L.circleMarker(latlng, { radius: 14, color: "#ffcc00", weight: 3, opacity: 1, fillColor: "#ffcc00", fillOpacity: 0.25, pane: "highlightPane", }); return L.layerGroup([halo, m]); } return L.circleMarker(latlng, { radius: 10, color: "#ffcc00", weight: 3, opacity: 1, fillColor: "#ffcc00", fillOpacity: 0.5, pane: "highlightPane", }); }, style: (feature?: Feature): L.PathOptions => { const geomType = feature?.geometry?.type; if (geomType && /LineString/i.test(geomType)) { return { color: "#ffcc00", weight: 5, opacity: 1, } as L.PathOptions; } if (geomType && /Polygon/i.test(geomType)) { return { color: "#ffcc00", weight: 3, opacity: 1, fillColor: "#fff2a8", fillOpacity: 0.35, } as L.PathOptions; } // default return { color: "#ffcc00", weight: 3, opacity: 1, } as L.PathOptions; }, interactive: false, }); lg.addTo(group); } group.addTo(map); // bring to front if possible (group as unknown as { bringToFront?: () => void }).bringToFront?.(); highlightRef.current = group; // Add backdrop rectangle with current viewport bounds, below highlight try { const b = map.getBounds(); const rect = L.rectangle(b, { pane: "backdropPane", interactive: false, stroke: false, fillColor: "#000000", fillOpacity: 0.25, }); rect.addTo(map); (rect as unknown as { bringToBack?: () => void }).bringToBack?.(); backdropRef.current = rect; } catch {} } catch (err) { console.warn("Failed to add geometry highlight", err); // fallback: mark click location try { const marker = L.circleMarker(e.latlng, { radius: 10, color: "#ffcc00", weight: 3, opacity: 1, fillColor: "#ffcc00", fillOpacity: 0.6, }); marker.addTo(map); (marker as unknown as { bringToFront?: () => void }).bringToFront?.(); highlightRef.current = marker; } catch {} } } } catch (error) { console.error("Error fetching feature info:", error); setFeatureInformation(null); clearHighlight(); } }; map.on("click", handleMapClick); return () => { map.off("click", handleMapClick); }; }, [map, activeLayers, setFeatureInformation, layerInstances, clearHighlight]); // Remove highlight when feature information panel is closed (null or empty array) useEffect(() => { const empty = !featureInformation || featureInformation.length === 0; if (empty) { clearHighlight(); } }, [featureInformation, clearHighlight]); // Keep backdrop following the viewport while the panel is open useEffect(() => { if (!map) return; const hasInfo = !!featureInformation && featureInformation.length > 0; if (!hasInfo) return; const updateBackdrop = () => { try { if (backdropRef.current instanceof L.Rectangle) { const b = map.getBounds(); backdropRef.current.setBounds(b); } } catch { // ignore } }; // Initial sync updateBackdrop(); map.on("move", updateBackdrop); map.on("zoom", updateBackdrop); map.on("moveend", updateBackdrop); map.on("zoomend", updateBackdrop); return () => { map.off("move", updateBackdrop); map.off("zoom", updateBackdrop); map.off("moveend", updateBackdrop); map.off("zoomend", updateBackdrop); }; }, [map, featureInformation]); // (helpers moved to module scope) // Handle layer creation and removal useEffect(() => { if (!map) return; const instances = layerInstances; const currentLayerIds = new Set(activeLayers.map((layer) => layer.id)); // Remove layers that are no longer in activeLayers instances.forEach((layer, id) => { if (!currentLayerIds.has(id)) { layer.remove(); instances.delete(id); } }); // Create new layers const layerPromises: Promise<{ id: string; zIndex: number; leafletLayer: L.Layer; bounds?: L.LatLngBoundsExpression; visible: boolean; } | null>[] = []; activeLayers.forEach((layer) => { if (instances.has(layer.id)) return; // Skip if layer already exists const mode = layer.mode || "basic"; if (mode === "basic" && layer.layer.type === "wms") { const leafletLayer = L.tileLayer.wms(layer.layer.url ?? "", { layers: layer.layer.layers, format: "image/png", transparent: true, opacity: layer.settings.opacity, zIndex: layer.settings.zIndex, }); layerPromises.push( Promise.resolve({ id: layer.id, zIndex: layer.settings.zIndex ?? 0, leafletLayer, bounds: layer.layer.bounds ?? undefined, visible: layer.settings.visible, }) ); } else if (mode === "choropleth") { const promise = (async () => { try { const sourceUrl = constructWfsUrl(layer.layer) as string; const colorScale = await colorScaleApi.getColorScale({ source_url: sourceUrl, boundary_file_id: appConfig.boundaryFileId, }); if (!colorScale?.data) return null; const enrichedGeoJSON = mergeDataToGeoJSON(jatimGeojson as unknown as FeatureCollection, colorScale.data); const leafletLayer = L.geoJSON(enrichedGeoJSON, { style: (feature?: Feature) => ({ fillColor: String((feature?.properties as Record | null)?.["color"] ?? "#cccccc"), weight: 2, color: "#333333", // Initialize with current layer opacity fillOpacity: layer.settings.opacity, opacity: layer.settings.opacity, }), onEachFeature: (feature: Feature, layerInstance) => { const props = (feature.properties ?? {}) as Record; const wadmkk = String(props["WADMKK"] ?? ""); const wadmpr = String(props["WADMPR"] ?? ""); const value = String(props["value"] ?? ""); layerInstance.bindPopup(`${wadmkk}, ${wadmpr}
Value: ${value}`); }, }); return { id: layer.id, zIndex: layer.settings.zIndex ?? 0, leafletLayer, visible: layer.settings.visible, }; } catch (err) { console.error("Failed to add choropleth layer", err); return null; } })(); layerPromises.push(promise); } }); // Add new layers to map and instances Promise.all(layerPromises).then((results) => { results .filter((r): r is NonNullable => !!r) .sort((a, b) => a.zIndex - b.zIndex) .forEach(({ id, leafletLayer, visible, bounds }) => { instances.set(id, leafletLayer); // Only add to map if visible if (visible) { leafletLayer.addTo(map); // Only fit bounds for newly added layers if (bounds && !previousLayersRef.current.has(id)) { map.fitBounds(bounds); } } }); setLayerInstances(instances); }); }, [activeLayers, map, setLayerInstances, layerInstances]); // Handle visibility, opacity, and zIndex changes separately useEffect(() => { if (!map) return; const instances = layerInstances; activeLayers.forEach((layer) => { const leafletLayer = instances.get(layer.id); if (!leafletLayer) return; const previousLayer = previousLayersRef.current.get(layer.id); const currentSettings = layer.settings; // Handle visibility changes if (!previousLayer || previousLayer.visible !== currentSettings.visible) { if (currentSettings.visible) { if (!map.hasLayer(leafletLayer)) { leafletLayer.addTo(map); } } else { if (map.hasLayer(leafletLayer)) { map.removeLayer(leafletLayer); } } } // Handle opacity changes if (!previousLayer || previousLayer.opacity !== currentSettings.opacity) { // Try WMS/Tile-like layers const asTile = leafletLayer as unknown as { setOpacity?: (n: number) => void }; if (asTile.setOpacity) { asTile.setOpacity(currentSettings.opacity); } else if ((leafletLayer as L.GeoJSON).setStyle) { // Fallback for GeoJSON/vector layers try { (leafletLayer as L.GeoJSON).setStyle({ fillOpacity: currentSettings.opacity, opacity: currentSettings.opacity, }); } catch { // ignore } } } // Handle zIndex changes if (!previousLayer || previousLayer.zIndex !== currentSettings.zIndex) { (leafletLayer as unknown as { setZIndex?: (n: number) => void }).setZIndex?.(currentSettings.zIndex); } // Update previous layer state previousLayersRef.current.set(layer.id, { visible: currentSettings.visible, opacity: currentSettings.opacity, zIndex: currentSettings.zIndex, }); }); // Clean up previous layer references for removed layers const currentLayerIds = new Set(activeLayers.map((l) => l.id)); previousLayersRef.current.forEach((_, id) => { if (!currentLayerIds.has(id)) { previousLayersRef.current.delete(id); } }); }, [activeLayers, map, layerInstances]); return null; };