satupeta-main/app/(modules)/maps/components/layer-manager.tsx
2026-01-27 09:31:12 +07:00

592 lines
21 KiB
TypeScript

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<string, unknown> => 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<Map<string, { visible: boolean; opacity: number; zIndex: number }>>(new Map());
const highlightRef = useRef<L.Layer | null>(null);
const backdropRef = useRef<L.Layer | null>(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<Geometry>[] }> = [];
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<string, L.Layer>).get(layer.id) as L.TileLayer.WMS | undefined;
const cql = (instance?.wmsParams as unknown as Record<string, unknown>)?.["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<Geometry>[] });
}
}
// 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<Geometry> = {
type: "FeatureCollection",
features: hc.features as Feature<Geometry>[],
};
const lg = L.geoJSON(fc, {
pane: "highlightPane",
pointToLayer: (feature: Feature<Point | MultiPoint>, 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<Geometry, GeoJsonProperties>): 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<Geometry>, colorScale.data);
const leafletLayer = L.geoJSON(enrichedGeoJSON, {
style: (feature?: Feature<Geometry, GeoJsonProperties>) => ({
fillColor: String((feature?.properties as Record<string, unknown> | null)?.["color"] ?? "#cccccc"),
weight: 2,
color: "#333333",
// Initialize with current layer opacity
fillOpacity: layer.settings.opacity,
opacity: layer.settings.opacity,
}),
onEachFeature: (feature: Feature<Geometry, GeoJsonProperties>, layerInstance) => {
const props = (feature.properties ?? {}) as Record<string, unknown>;
const wadmkk = String(props["WADMKK"] ?? "");
const wadmpr = String(props["WADMPR"] ?? "");
const value = String(props["value"] ?? "");
layerInstance.bindPopup(`<strong>${wadmkk}, ${wadmpr}</strong><br>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<typeof r> => !!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<GeoJsonObject>).setStyle) {
// Fallback for GeoJSON/vector layers
try {
(leafletLayer as L.GeoJSON<GeoJsonObject>).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;
};