398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
"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<string | null>(null);
|
|
const [rules, setRules] = useState<LegendRule[]>([]);
|
|
const [attributeName, setAttributeName] = useState<string | null>(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<string[]>(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 (
|
|
<div className="pt-2 mt-2">
|
|
<div className="text-xs text-gray-500">
|
|
<p>No legend available</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show image legend if JSON failed but URL exists
|
|
if (error) {
|
|
return (
|
|
<div className="pt-2 mt-2">
|
|
<div className="text-xs text-gray-500 space-y-2">
|
|
<div className="text-zinc-500">{error}</div>
|
|
<Image src={getLegendUrl({ baseUrl: layer.layer.url, layerName: layer.layer.layers ?? "", width: 200 })} alt={`${layer.name} legend`} width={200} height={40} className="w-auto h-auto max-w-full" unoptimized />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="pt-2 mt-2">
|
|
<div className="text-xs text-gray-700">
|
|
{loading && <div className="text-zinc-500">Memuat legenda…</div>}
|
|
{!loading && rules.length === 0 && (
|
|
<div className="text-zinc-600 space-y-2">
|
|
<Image src={getLegendUrl({ baseUrl: layer.layer.url, layerName: layer.layer.layers ?? "", width: 200 })} alt={`${layer.name} legend`} width={200} height={40} className="w-auto h-auto max-w-full" unoptimized />
|
|
</div>
|
|
)}
|
|
{!loading && rules.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{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 (
|
|
<button
|
|
key={(r.title || r.name || label || idx.toString()) as string}
|
|
type="button"
|
|
onClick={() => onToggle(label)}
|
|
className={`cursor-pointer inline-flex items-center gap-2 rounded-md border px-2 py-1 transition-colors ${
|
|
active ? "bg-primary text-primary-foreground border-primary" : "bg-white text-zinc-800 border-zinc-300 hover:border-zinc-400"
|
|
}`}
|
|
>
|
|
{iconUrl ? (
|
|
<img src={iconUrl} alt="" width={12} height={12} className="h-3 w-3 rounded-sm border border-black/10 object-contain bg-white" />
|
|
) : (
|
|
<span className="inline-block h-3 w-3 rounded-sm border border-black/10" style={{ backgroundColor: color }} aria-hidden />
|
|
)}
|
|
<span className="whitespace-nowrap">{label || "(untitled)"}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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;
|
|
}
|