satupeta-main/shared/components/preview-map.tsx

281 lines
10 KiB
TypeScript
Raw Permalink Normal View History

2026-01-27 02:31:12 +00:00
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='&copy; <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;
}