281 lines
10 KiB
TypeScript
Executable File
281 lines
10 KiB
TypeScript
Executable File
import { MapContainer, Popup, TileLayer, useMapEvents, WMSTileLayer } from "react-leaflet";
|
|
import { mapConfig } from "../config/map-config";
|
|
import { Mapset } from "@/shared/types/mapset";
|
|
import { getWmsLayerBounds, parseWmsUrl } from "../utils/wms";
|
|
import { LatLngBoundsExpression, LatLngLiteral, LeafletMouseEvent } from "leaflet";
|
|
import React, { useEffect, useState } from "react";
|
|
import { MapLegend } from "./map-legend";
|
|
import { PopupAttribute } from "./popup-attribute";
|
|
|
|
export default function PreviewMap({
|
|
mapset,
|
|
isActiveControl,
|
|
centerCustom,
|
|
onMapClick,
|
|
isShowPopup = false,
|
|
isShowLoadingTileLayer = false,
|
|
isShowLegend = false,
|
|
}: Readonly<{
|
|
mapset: Mapset;
|
|
isActiveControl?: boolean;
|
|
centerCustom?: [number, number];
|
|
onMapClick?: (e: LeafletMouseEvent) => void;
|
|
isShowPopup?: boolean;
|
|
isShowLoadingTileLayer?: boolean;
|
|
isShowLegend?: boolean;
|
|
}>) {
|
|
const parsed = parseWmsUrl(mapset?.layer_url);
|
|
const [bounds, setBounds] = useState<LatLngBoundsExpression | undefined>(parsed?.params.bounds ?? undefined);
|
|
|
|
// Popup content state
|
|
const [popupLoading, setPopupLoading] = useState(false);
|
|
const [popupError, setPopupError] = useState<string | null>(null);
|
|
const [popupData, setPopupData] = useState<Record<string, unknown>[] | null>(null);
|
|
const [clickedPosition, setClickedPosition] = useState<LatLngLiteral | null>(null);
|
|
// tile layer loading state
|
|
const [wmsPending, setWmsPending] = useState(0); // jumlah tile yang belum selesai
|
|
const [wmsBusy, setWmsBusy] = useState(false); // grid loading
|
|
|
|
// Reset popup content when mapset changes
|
|
useEffect(() => {
|
|
setClickedPosition(null);
|
|
setPopupData(null);
|
|
setPopupError(null);
|
|
setPopupLoading(false);
|
|
}, [mapset?.id]);
|
|
|
|
useEffect(() => {
|
|
const fetchBounds = async () => {
|
|
if (parsed?.baseUrl && parsed?.params.layers && !parsed?.params.bounds) {
|
|
const newBounds = await getWmsLayerBounds(parsed.baseUrl, parsed.params.layers);
|
|
if (newBounds) {
|
|
setBounds(newBounds);
|
|
}
|
|
}
|
|
};
|
|
|
|
fetchBounds();
|
|
}, [parsed?.baseUrl, parsed?.params.layers, parsed?.params.bounds]);
|
|
|
|
// safety cap: kalau ada tile nyangkut >12s, anggap selesai
|
|
useEffect(() => {
|
|
if (wmsPending === 0) return;
|
|
const t = setTimeout(() => setWmsPending(0), 12000);
|
|
return () => clearTimeout(t);
|
|
}, [wmsPending]);
|
|
|
|
const isMapLoading = wmsBusy;
|
|
|
|
// Add a fallback center and zoom
|
|
const center: [number, number] = centerCustom ?? [mapConfig.center[0], mapConfig.center[1]];
|
|
const zoom = 8;
|
|
|
|
return (
|
|
<MapContainer
|
|
key={bounds ? "with-bounds" : "without-bounds"}
|
|
{...(bounds ? { bounds } : { center, zoom })}
|
|
className="h-full w-full"
|
|
style={{ height: "100%", width: "100%" }}
|
|
attributionControl={false}
|
|
zoomControl={isActiveControl ?? false}
|
|
scrollWheelZoom={isActiveControl ?? false}
|
|
dragging={isActiveControl ?? false}
|
|
doubleClickZoom={isActiveControl ?? false}
|
|
keyboard={isActiveControl ?? false}
|
|
touchZoom={isActiveControl ?? false}
|
|
boxZoom={isActiveControl ?? false}
|
|
>
|
|
{isShowPopup && (
|
|
<MapClick
|
|
parsed={parsed}
|
|
onClick={(e) => {
|
|
setClickedPosition(e.latlng);
|
|
onMapClick?.(e);
|
|
}}
|
|
onWmsStart={() => {
|
|
setPopupError(null);
|
|
setPopupData(null);
|
|
setPopupLoading(true);
|
|
}}
|
|
onWmsResult={({ data, error }) => {
|
|
setPopupLoading(false);
|
|
setPopupError(error ?? null);
|
|
setPopupData(data ?? null);
|
|
}}
|
|
/>
|
|
)}
|
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' />
|
|
{parsed && (
|
|
<WMSTileLayer
|
|
key={parsed.baseUrl + parsed.params.layers}
|
|
url={parsed.baseUrl}
|
|
layers={parsed.params.layers}
|
|
styles={parsed.params.styles}
|
|
format="image/png"
|
|
transparent={true}
|
|
version={parsed.params.version}
|
|
eventHandlers={{
|
|
loading: () => setWmsBusy(true),
|
|
load: () => {
|
|
setWmsBusy(false);
|
|
setWmsPending(0);
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isShowLegend && !isMapLoading && parsed && <MapLegend parsed={parsed} width={600} />}
|
|
|
|
{isShowLoadingTileLayer && isMapLoading && (
|
|
<div className="absolute inset-0 z-[1000] grid place-items-center bg-white/40 backdrop-blur-sm max-w-full">
|
|
<div className="flex flex-col items-center justify-center space-y-2">
|
|
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-300 border-t-zinc-900" />
|
|
<div className="text-center font-sans font-medium">Memuat peta</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{clickedPosition && (
|
|
<Popup position={clickedPosition} autoPan>
|
|
{/* loading data state */}
|
|
{popupLoading && <div className="text-xs">Loading…</div>}
|
|
{!popupLoading && popupError && <div className="text-xs text-red-600">{popupError}</div>}
|
|
|
|
{/* succesfully fetched data */}
|
|
{!popupLoading && !popupError && popupData && <PopupAttribute popupData={popupData} />}
|
|
{!popupLoading && !popupError && popupData !== undefined && !popupData?.length && <div className="text-xs">No data available.</div>}
|
|
</Popup>
|
|
)}
|
|
</MapContainer>
|
|
);
|
|
}
|
|
|
|
// Local helper: register Leaflet click events inside MapContainer
|
|
function MapClick({
|
|
onClick,
|
|
onWmsStart,
|
|
onWmsResult,
|
|
parsed,
|
|
}: {
|
|
onClick?: (e: LeafletMouseEvent) => void;
|
|
onWmsStart?: () => void;
|
|
// ⬇️ "data" untuk kirim objek JSON
|
|
onWmsResult?: (r: { data?: Record<string, unknown>[]; html?: string; error?: string; url?: string }) => void;
|
|
parsed: ReturnType<typeof parseWmsUrl> | null | undefined;
|
|
}) {
|
|
useMapEvents({
|
|
click: async (e) => {
|
|
onClick?.(e);
|
|
onWmsStart?.();
|
|
|
|
try {
|
|
if (!parsed?.baseUrl || !parsed?.params?.layers) {
|
|
onWmsResult?.({ error: "WMS baseUrl/layers tidak tersedia" });
|
|
return;
|
|
}
|
|
|
|
const map = e.target;
|
|
const version = parsed.params.version || "1.1.1";
|
|
const srs = parsed.params.srs || "EPSG:4326";
|
|
const size = map.getSize();
|
|
const bounds = map.getBounds();
|
|
const sw = bounds.getSouthWest();
|
|
const ne = bounds.getNorthEast();
|
|
const pt = map.latLngToContainerPoint(e.latlng);
|
|
const X = Math.round(pt.x);
|
|
const Y = Math.round(pt.y);
|
|
|
|
const urlObj = new URL(parsed.baseUrl);
|
|
urlObj.searchParams.set("SERVICE", "WMS");
|
|
urlObj.searchParams.set("VERSION", version);
|
|
urlObj.searchParams.set("REQUEST", "GetFeatureInfo");
|
|
urlObj.searchParams.set("FORMAT", "image/png");
|
|
urlObj.searchParams.set("TRANSPARENT", "true");
|
|
urlObj.searchParams.set("QUERY_LAYERS", parsed.params.layers);
|
|
urlObj.searchParams.set("LAYERS", parsed.params.layers);
|
|
urlObj.searchParams.set("STYLES", parsed.params.styles ?? "");
|
|
urlObj.searchParams.set("exceptions", "application/vnd.ogc.se_inimage");
|
|
|
|
// ⬇️ minta JSON dulu (kalau didukung GeoServer)
|
|
urlObj.searchParams.set("INFO_FORMAT", "application/json");
|
|
urlObj.searchParams.set("FEATURE_COUNT", "50");
|
|
|
|
if (version === "1.3.0") {
|
|
urlObj.searchParams.set("CRS", srs);
|
|
} else {
|
|
urlObj.searchParams.set("SRS", srs);
|
|
}
|
|
|
|
// BBOX lon,lat untuk 1.1.1 EPSG:4326
|
|
urlObj.searchParams.set("BBOX", [sw.lng, sw.lat, ne.lng, ne.lat].join(","));
|
|
urlObj.searchParams.set("WIDTH", String(size.x));
|
|
urlObj.searchParams.set("HEIGHT", String(size.y));
|
|
|
|
if (version === "1.3.0") {
|
|
urlObj.searchParams.set("I", String(X));
|
|
urlObj.searchParams.set("J", String(Y));
|
|
} else {
|
|
urlObj.searchParams.set("X", String(X));
|
|
urlObj.searchParams.set("Y", String(Y));
|
|
}
|
|
|
|
const url = urlObj.toString();
|
|
|
|
// --- fetch: prefer JSON, fallback HTML ---
|
|
const res = await fetch(url, {
|
|
headers: { Accept: "application/json, text/html;q=0.9" },
|
|
});
|
|
const raw = await res.text();
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
|
|
let features: Record<string, unknown>[] = [];
|
|
const ctype = res.headers.get("content-type") || "";
|
|
const looksJson = ctype.includes("application/json") || /^[\[\{]/.test(raw.trim());
|
|
|
|
if (looksJson) {
|
|
// JSON dari GeoServer (bentuk bisa variasi)
|
|
const j = JSON.parse(raw);
|
|
if (Array.isArray(j)) {
|
|
features = j as Record<string, unknown>[];
|
|
} else if (Array.isArray(j?.features)) {
|
|
// GeoJSON-like
|
|
features = j.features.map((f: unknown) => (typeof f === "object" && f !== null && "properties" in f ? (f as { properties: unknown }).properties : f));
|
|
} else if (j?.properties) {
|
|
features = [j.properties];
|
|
} else {
|
|
features = [j];
|
|
}
|
|
} else {
|
|
// Fallback: parse HTML tabel → array objek
|
|
const doc = new DOMParser().parseFromString(raw, "text/html");
|
|
const tables = Array.from(doc.querySelectorAll("table"));
|
|
for (const tbl of tables) {
|
|
const rows = Array.from((tbl as HTMLTableElement).rows);
|
|
if (!rows.length) continue;
|
|
const headerRow = rows.find((r) => Array.from(r.cells).every((c) => c.tagName === "TH")) || rows[0];
|
|
const headers = Array.from(headerRow.cells).map((c) => (c.textContent || "").trim());
|
|
const start = rows.indexOf(headerRow) + 1;
|
|
for (let i = start; i < rows.length; i++) {
|
|
const r = rows[i];
|
|
const obj: Record<string, unknown> = {};
|
|
headers.forEach((h, idx) => {
|
|
if (!h) return;
|
|
obj[h] = r.cells[idx]?.textContent?.trim() ?? "";
|
|
});
|
|
if (Object.values(obj).some((v) => v !== "")) features.push(obj);
|
|
}
|
|
}
|
|
}
|
|
|
|
// kirim objek JSON (array of row objects)
|
|
onWmsResult?.({ data: features, url });
|
|
} catch (err: unknown) {
|
|
onWmsResult?.({
|
|
error: err instanceof Error ? err.message : "Gagal memuat GetFeatureInfo",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
return null;
|
|
}
|