592 lines
21 KiB
TypeScript
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;
|
||
|
|
};
|