"use client"; /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @next/next/no-img-element */ import React, { useCallback, useEffect, useMemo, useState } from "react"; import Image from "next/image"; import { getLegendUrl } from "@/shared/utils/wms"; import { ActiveLayer } from "@/app/(modules)/maps/state/active-layers"; import { useAtomValue } from "jotai"; import { leafletLayerInstancesAtom } from "@/app/(modules)/maps/state/leaflet-layer-instances"; import L from "leaflet"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; interface LegendDisplayProps { layer: ActiveLayer; } export const LegendDisplay = ({ layer }: LegendDisplayProps) => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const instances = useAtomValue(leafletLayerInstancesAtom); const leafletInstance = useMemo(() => instances.get(layer.id), [instances, layer.id]); const filterKey = useMemo(() => `filter__${String(layer.id).replace(/[^a-zA-Z0-9_-]/g, "_")}`, [layer.id]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [rules, setRules] = useState([]); const [attributeName, setAttributeName] = useState(null); // Active values from URL (?filter=["FAIR","GOOD"]) or state const initialFilterFromUrl = useMemo(() => { try { // Prefer per-layer key; fallback to legacy "filter" const raw = searchParams?.get(filterKey) ?? searchParams?.get("filter"); if (!raw) return [] as string[]; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? (parsed.filter((v) => typeof v === "string") as string[]) : []; } catch { return [] as string[]; } }, [searchParams, filterKey]); const [activeValues, setActiveValues] = useState(initialFilterFromUrl.slice(0, 1)); // Build JSON legend URL and fetch useEffect(() => { const baseUrl = layer.layer.url; const layerName = layer.layer.layers; if (!baseUrl || !layerName) return; const legendJsonUrl = getLegendUrl({ baseUrl, layerName, format: "application/json" }); let cancelled = false; (async () => { try { setLoading(true); setError(null); const res = await fetch(legendJsonUrl); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const json = (await res.json()) as GeoServerLegendJSON; if (cancelled) return; const parsedRules = extractRules(json); setRules(parsedRules); // Cache legend JSON for cross-component usage (e.g., highlight icon) try { const cacheKey = `legend-json:${baseUrl}:${layerName}`; sessionStorage.setItem(cacheKey, JSON.stringify(json)); } catch {} const attr = deriveAttributeName(parsedRules); if (attr) setAttributeName(attr); } catch (e) { console.error("Failed to fetch legend JSON:", e); if (!cancelled) setError("Gagal memuat legenda"); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [layer.layer.url, layer.layer.layers]); useEffect(() => { setActiveValues(initialFilterFromUrl.slice(0, 1)); }, [initialFilterFromUrl]); // Build and apply CQL_FILTER whenever activeValues change or instance/attribute available useEffect(() => { if (!leafletInstance || !(leafletInstance instanceof L.TileLayer.WMS)) return; let cql = ""; if (activeValues.length === 1) { const pickedTitle = activeValues[0]; const matched = findRuleByTitle(rules, pickedTitle); if (matched?.filter) { cql = normalizeFilter(matched.filter); } else { cql = buildCqlFilter(attributeName, activeValues); } } if (cql) { (leafletInstance as any).setParams({ CQL_FILTER: cql }, false); } else { if ((leafletInstance as any).wmsParams?.CQL_FILTER) { delete (leafletInstance as any).wmsParams.CQL_FILTER; } (leafletInstance as any).setParams({}, false); } (leafletInstance as any).redraw?.(); }, [leafletInstance, attributeName, activeValues, rules]); const onToggle = useCallback((value: string) => { setActiveValues((prev) => { const isActive = prev.length === 1 && prev[0] === value; const next = isActive ? [] : [value]; return next; }); }, []); useEffect(() => { try { const params = new URLSearchParams(searchParams?.toString()); const desired = activeValues.length === 1 ? JSON.stringify(activeValues) : null; const current = params.get(filterKey); if (desired && current !== desired) { params.set(filterKey, desired); } else if (!desired && current) { params.delete(filterKey); } else { return; } router.replace(`${pathname}?${params.toString()}`, { scroll: false }); } catch { // ignore } // Only run when selection changes or location context changes }, [activeValues, pathname, router, searchParams, filterKey]); // If no WMS URL, fallback to previous image legend rendering if (!layer.layer.url || !layer.layer.layers) { return (

No legend available

); } // Show image legend if JSON failed but URL exists if (error) { return (
{error}
{`${layer.name}
); } return (
{loading &&
Memuat legenda…
} {!loading && rules.length === 0 && (
{`${layer.name}
)} {!loading && rules.length > 0 && (
{rules.map((r, idx) => { const label = (r.title || r.name || "").trim() || `Rule ${idx + 1}`; const active = label && activeValues.length === 1 && activeValues[0] === label; const iconUrl = getRuleIcon(r); const color = iconUrl ? undefined : getRuleColor(r) || "#999999"; return ( ); })}
)}
); }; type LegendRule = { name?: string; title?: string; filter?: string; symbolizers?: unknown[]; [k: string]: unknown; }; type GeoServerLegendJSON = | { rules?: LegendRule[]; [k: string]: unknown; } | { legend?: { rules?: LegendRule[] }; [k: string]: unknown; }; function extractRules(json: GeoServerLegendJSON): LegendRule[] { // 1) { rules: [...] } if (Array.isArray((json as any)?.rules)) return ((json as any).rules ?? []) as LegendRule[]; // 2) { legend: { rules: [...] } } if (Array.isArray((json as any)?.legend?.rules)) return ((json as any).legend.rules ?? []) as LegendRule[]; // 3) GeoServer sometimes returns { Legend: { rules: [...] } } if (Array.isArray((json as any)?.Legend?.rules)) return ((json as any).Legend.rules ?? []) as LegendRule[]; // 3b) Or { Legend: [ { rules: [...] }, ... ] } if (Array.isArray((json as any)?.Legend)) { const arr = (json as any).Legend as any[]; const all = arr.flatMap((entry) => (Array.isArray(entry?.rules) ? entry.rules : [])); if (all.length) return all as LegendRule[]; } // 2b) Or { legend: [ { rules: [...] }, ... ] } if (Array.isArray((json as any)?.legend)) { const arr = (json as any).legend as any[]; const all = arr.flatMap((entry) => (Array.isArray(entry?.rules) ? entry.rules : [])); if (all.length) return all as LegendRule[]; } // 4) Some variants: { classes: [{ name, title, symbolizers }] } if (Array.isArray((json as any)?.classes)) { return ((json as any).classes as any[]).map((c) => ({ name: c?.name, title: c?.title, symbolizers: c?.symbolizers, filter: c?.filter })); } return []; } function deriveAttributeName(rules: LegendRule[]): string | null { for (const r of rules) { const f = r.filter; if (f) { const trimmed = f.trim().replace(/^\[|\]$/g, ""); let m = /^(\w+)\s*=\s*['"][^'"]+['"]/.exec(trimmed); if (m) return m[1]; m = /^(\w+)\s*(?:[<>]=?|=)\s*['"][^'"]+['"]/.exec(trimmed); if (m) return m[1]; } const t = (r.title || r.name || "").trim(); if (t) { const m2 = /^(\w+)\s*=\s*['"][^'"]+['"]/.exec(t); if (m2) return m2[1]; } } return null; } function buildCqlFilter(attribute: string | null, values: string[]): string | "" { if (!attribute || !values?.length) return ""; const safeVals = values.map(quoteCql); return `${attribute} IN (${safeVals.join(",")})`; } function quoteCql(v: string) { const escaped = v.replace(/'/g, "''"); return `'${escaped}'`; } function getRuleColor(rule: LegendRule): string | null { const sym = (rule as any)?.symbolizers; if (Array.isArray(sym) && sym.length) { for (const s of sym) { const poly = (s as any)?.polygonSymbolizer || (s as any)?.PolygonSymbolizer; const polyColor = poly?.fill?.color || poly?.Fill?.CssParameter || poly?.fill?.CssParameter; const foundPoly = pickColor(polyColor) || pickColor(poly?.fill) || pickColor(poly); if (foundPoly) return foundPoly; const point = (s as any)?.pointSymbolizer || (s as any)?.PointSymbolizer; const g = point?.graphic || point?.Graphic || point?.graphics; const foundPoint = pickColor(g?.fill) || pickColor(g?.stroke) || pickColor(g); if (foundPoint) return foundPoint; const line = (s as any)?.lineSymbolizer || (s as any)?.LineSymbolizer; const foundLine = pickColor(line?.stroke) || pickColor(line); if (foundLine) return foundLine; } } const any = deepFindColor(rule as any); return any; } function pickColor(node: any): string | null { if (!node) return null; if (typeof node === "string" && looksLikeColor(node)) return node; if (typeof node?.color === "string" && looksLikeColor(node.color)) return node.color; if (typeof node?.fill === "string" && looksLikeColor(node.fill)) return node.fill; if (typeof node?.stroke === "string" && looksLikeColor(node.stroke)) return node.stroke; if (typeof node?.css === "string" && looksLikeColor(node.css)) return node.css; if (typeof node?.["#text"] === "string" && looksLikeColor(node["#text"])) return node["#text"]; return null; } function deepFindColor(obj: any, depth = 0): string | null { if (!obj || depth > 4) return null; const direct = pickColor(obj); if (direct) return direct; if (typeof obj === "object") { for (const k of Object.keys(obj)) { const c = deepFindColor(obj[k], depth + 1); if (c) return c; } } return null; } function looksLikeColor(s: string) { const t = s.trim(); return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(t) || /^rgba?\(/.test(t) || /^hsla?\(/.test(t); } // getRuleValue removed as we now use labels directly function findRuleByTitle(rules: LegendRule[], title: string | null | undefined): LegendRule | undefined { if (!title) return undefined; const key = title.trim(); return rules.find((r) => (r.title || r.name || "").trim() === key); } function normalizeFilter(expr: string): string { let s = expr.trim(); if (s.startsWith("[") && s.endsWith("]")) { s = s.slice(1, -1).trim(); } if (!/^\(.*\)$/.test(s)) { s = `(${s})`; } return s; } // icon URL helpers function getRuleIcon(rule: LegendRule): string | null { const sym = (rule as any)?.symbolizers; if (Array.isArray(sym) && sym.length) { for (const s of sym) { const point = (s as any)?.pointSymbolizer || (s as any)?.PointSymbolizer || (s as any)?.Point; const g = point?.graphic || point?.Graphic || point?.graphics || point; const url = deepFindUrl(g) || deepFindUrl(point) || deepFindUrl(s); if (url) return url; } } return deepFindUrl(rule as any); } function deepFindUrl(obj: any, depth = 0): string | null { if (!obj || depth > 4) return null; const direct = pickUrl(obj); if (direct) return direct; if (typeof obj === "object") { for (const k of Object.keys(obj)) { const v = (obj as any)[k]; const u = deepFindUrl(v, depth + 1); if (u) return u; } } return null; } function pickUrl(node: any): string | null { if (!node) return null; // typical fields const candidates: unknown[] = [node.url, node.href, node.externalGraphic, node["xlink:href"], node["@xlink:href"]]; for (const c of candidates) { if (typeof c === "string" && looksLikeUrl(c)) return c; } if (typeof node === "string" && looksLikeUrl(node)) return node; return null; } function 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; }