satupeta-main/app/(modules)/admin/mapset-upload/_components/map/CustomLayerStyle.tsx
2026-02-23 12:21:05 +07:00

305 lines
10 KiB
TypeScript
Executable File

// app/admin/upload/_components/map/CustomLayerStyle.tsx
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { toast } from "sonner";
// --- HELPER FUNCTION SLD GENERATOR ---
const sldHeader = `<?xml version="1.0" encoding="UTF-8"?>
<StyledLayerDescriptor version="1.0.0"
xmlns="http://www.opengis.net/sld"
xmlns:ogc="http://www.opengis.net/ogc"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:se="http://www.opengis.net/se"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.opengis.net/sld
http://schemas.opengis.net/sld/1.0.0/StyledLayerDescriptor.xsd">
`;
const sldFooter = `</StyledLayerDescriptor>`;
// Helper symbolizer generator
const symbolizer = (geometryType: string, color: string) => {
const geomUpper = geometryType?.toUpperCase();
if (geomUpper === "POINT" || geomUpper === "MULTIPOINT") {
return `
<PointSymbolizer>
<Graphic>
<Mark>
<WellKnownName>circle</WellKnownName>
<Fill>
<CssParameter name="fill">${color}</CssParameter>
</Fill>
<Stroke>
<CssParameter name="stroke">#000000</CssParameter>
<CssParameter name="stroke-width">1</CssParameter>
</Stroke>
</Mark>
<Size>10</Size>
</Graphic>
</PointSymbolizer>
`;
}
if (geomUpper === "LINE" || geomUpper === "LINESTRING" || geomUpper === "MULTILINESTRING") {
return `
<LineSymbolizer>
<Stroke>
<CssParameter name="stroke">${color}</CssParameter>
<CssParameter name="stroke-width">2</CssParameter>
</Stroke>
</LineSymbolizer>
`;
}
// Polygon default
return `
<PolygonSymbolizer>
<Fill>
<CssParameter name="fill">${color}</CssParameter>
<CssParameter name="fill-opacity">0.5</CssParameter>
</Fill>
<Stroke>
<CssParameter name="stroke">#232323</CssParameter>
<CssParameter name="stroke-width">1</CssParameter>
</Stroke>
</PolygonSymbolizer>
`;
};
const singleColorSLD = (color: string, geometryType: string) => `${sldHeader}
<NamedLayer>
<Name>layer</Name>
<UserStyle>
<FeatureTypeStyle>
<Rule>
${symbolizer(geometryType, color)}
</Rule>
</FeatureTypeStyle>
</UserStyle>
</NamedLayer>
${sldFooter}
`;
const uniqueValueSLD = (column: string, rules: any[], geometryType: string) => `${sldHeader}
<NamedLayer>
<Name>layer</Name>
<UserStyle>
<FeatureTypeStyle>
${rules.map(r => `
<Rule>
<ogc:Filter>
<ogc:PropertyIsEqualTo>
<ogc:PropertyName>${column}</ogc:PropertyName>
<ogc:Literal>${r.value}</ogc:Literal>
</ogc:PropertyIsEqualTo>
</ogc:Filter>
${symbolizer(geometryType, r.color)}
</Rule>
`).join("")}
</FeatureTypeStyle>
</UserStyle>
</NamedLayer>
${sldFooter}
`;
const globalIconSLD = (iconCode:string) => `${sldHeader}
<NamedLayer>
<Name>layer</Name>
<UserStyle>
<FeatureTypeStyle>
<Rule>
<PointSymbolizer>
<Graphic>
<ExternalGraphic>
<OnlineResource
xlink:type="simple"
xlink:href="${iconCode}"/>
<Format>image/png</Format>
</ExternalGraphic>
<Size>10</Size>
</Graphic>
</PointSymbolizer>
</Rule>
</FeatureTypeStyle>
</UserStyle>
</NamedLayer>
${sldFooter}
`;
// Helper Random Color
const randomColor = () => {
let color = "#000000"
while (color === "#000000") {
color = "#" + Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
}
return color;
}
interface CustomLayerStyleProps {
data: any[];
geometryType: string;
onSubmit: (val: any) => void;
onChange?: (val: any) => void;
}
const CustomLayerStyle = ({ data = [], geometryType, onSubmit }: CustomLayerStyleProps) => {
const [columns, setColumns] = useState<string[]>([]);
const [selectedStyle, setSelectedStyle] = useState("single");
const [singleColor, setSingleColor] = useState("#3388ff");
const [uniqueColumn, setUniqueColumn] = useState("");
const [uniqueRules, setUniqueRules] = useState<{value: any, color: string}[]>([]);
const [iconGlobal, setIconGlobal] = useState("");
const [validIcon, setValidIcon] = useState(true);
useEffect(() => {
if (data && data.length > 0) {
// Ambil keys dari row pertama, filter geometry
const keys = Object.keys(data[0]).filter((k) => k !== "geometry");
setColumns(keys);
}
}, [data]);
useEffect(() => {
if (!iconGlobal) return;
const img = new Image();
img.onload = () => setValidIcon(true);
img.onerror = () => {
setValidIcon(false);
toast.error("URL icon tidak valid, Mohon ganti.");
}
img.src = iconGlobal;
}, [iconGlobal]);
const generateUniqueRules = (column: string) => {
const values = [...new Set(data.map((d) => d[column]))].slice(0, 20); // Limit 20 unique values agar tidak berat
const rules = values.map((v) => ({
value: v,
color: randomColor(),
}));
setUniqueRules(rules);
};
const submit = () => {
if (selectedStyle === "icon" && !validIcon) {
return
}
let xml = "";
if (selectedStyle === "single") {
xml = singleColorSLD(singleColor, geometryType);
} else if (selectedStyle === "unique_value") {
xml = uniqueValueSLD(uniqueColumn, uniqueRules, geometryType);
} else if (selectedStyle === "icon") {
const iconCode = 'https://cdn-icons-png.flaticon.com/512/0/614.png'
// xml = globalIconSLD(iconCode);
xml = globalIconSLD(iconGlobal);
}
// Kirim SLD string ke parent
onSubmit({
styleType: "sld",
sldContent: xml
});
};
return (
<div className="bg-white p-4 h-full flex flex-col">
{/* <h5 className="font-bold mb-3 text-sm">🎨 Pengaturan Style</h5> */}
<div className="mb-4">
<label className="text-xs font-semibold text-slate-500 mb-1 block">Jenis Styling</label>
<select
className="w-full border rounded p-2 text-sm"
value={selectedStyle}
onChange={(e) => setSelectedStyle(e.target.value)}
>
<option value="single">Single Color</option>
<option value="unique_value">Kategori (Unique Value)</option>
{selectedStyle === "icon" && geometryType === "Point" && (
<option value="icon">URL Icon</option>
)}
</select>
</div>
{/* --- SINGLE COLOR --- */}
{selectedStyle === "single" && (
<div className="mb-4">
<label className="text-xs font-semibold text-slate-500 mb-1 block">Warna Layer</label>
<div className="flex items-center gap-2">
<input
type="color"
className="w-10 h-10 p-0 border rounded cursor-pointer"
value={singleColor}
onChange={(e) => setSingleColor(e.target.value)}
/>
<span className="text-sm text-slate-600">{singleColor}</span>
</div>
</div>
)}
{/* --- UNIQUE VALUE --- */}
{selectedStyle === "unique_value" && (
<div className="mb-4 flex-1 overflow-auto">
<label className="text-xs font-semibold text-slate-500 mb-1 block">Pilih Kolom Kategori</label>
<select
className="w-full border rounded p-2 text-sm mb-3"
value={uniqueColumn}
onChange={(e) => {
setUniqueColumn(e.target.value);
generateUniqueRules(e.target.value);
}}
>
<option value="">-- Pilih Kolom --</option>
{columns.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
{uniqueRules.length > 0 && (
<div className="border rounded p-2 bg-slate-50 max-h-60 overflow-y-auto">
<p className="text-xs font-semibold mb-2 text-slate-500">Preview Kategori (Max 20)</p>
{uniqueRules.map((r, i) => (
<div key={i} className="flex items-center gap-2 mb-1">
<input
type="color"
className="w-6 h-6 p-0 border rounded-full overflow-hidden shrink-0"
value={r.color}
onChange={(e) => {
const copy = [...uniqueRules];
copy[i].color = e.target.value;
setUniqueRules(copy);
}}
/>
<span className="text-xs truncate">{String(r.value)}</span>
</div>
))}
</div>
)}
</div>
)}
{/* ---------------- ICON PER FEATURE ---------------- */}
{selectedStyle === "icon" && geometryType === "Point" && (
<div className="mb-3">
<label className="block mb-1">Masukkan URL Icon</label>
<input
type="text"
className="w-full border rounded p-2"
placeholder="https://example.com/icon.png"
value={iconGlobal}
onChange={(e) => setIconGlobal(e.target.value)}
/>
</div>
)}
<div className="mt-auto pt-4 border-t">
<Button onClick={submit} className="w-full">Terapkan Style</Button>
</div>
</div>
);
};
export default CustomLayerStyle;