satupeta-main/app/(modules)/maps/components/sidebar/layer-controls/layer-control-item/legend-display.tsx
2026-02-23 12:21:05 +07:00

398 lines
14 KiB
TypeScript
Executable File

"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;
}