add new components
This commit is contained in:
parent
4a2cf69a2a
commit
8a3c18896e
|
|
@ -6,6 +6,7 @@ import {
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
TabsContent,
|
TabsContent,
|
||||||
} from "./ui/tabs";
|
} from "./ui/tabs";
|
||||||
|
import MultiSelect from "./common/MultiSelect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📄 MetadataForm.jsx
|
* 📄 MetadataForm.jsx
|
||||||
|
|
@ -13,15 +14,74 @@ import {
|
||||||
* Menggunakan Tailwind CSS murni untuk tampilan modern dan profesional.
|
* Menggunakan Tailwind CSS murni untuk tampilan modern dan profesional.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function MetadataForm({ onChange }) {
|
export default function MetadataForm({ onChange, initialValues = {} }) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// const [formData, setFormData] = useState({
|
||||||
|
// // 🧩 Identifikasi Dataset
|
||||||
|
// title: "",
|
||||||
|
// abstract: "",
|
||||||
|
// keywords: "",
|
||||||
|
// topicCategory: "",
|
||||||
|
// dateCreated: "",
|
||||||
|
// status: "",
|
||||||
|
// language: "eng",
|
||||||
|
|
||||||
|
// // 🧭 Referensi Spasial
|
||||||
|
// crs: "EPSG:4326",
|
||||||
|
// geometryType: "",
|
||||||
|
// xmin: "",
|
||||||
|
// xmax: "",
|
||||||
|
// ymin: "",
|
||||||
|
// ymax: "",
|
||||||
|
|
||||||
|
// // 🌐 Distribusi / Akses Data
|
||||||
|
// downloadLink: "",
|
||||||
|
// serviceLink: "",
|
||||||
|
// format: "",
|
||||||
|
// license: "Copyright",
|
||||||
|
|
||||||
|
// // 👤 Informasi Penanggung Jawab
|
||||||
|
// organization: "",
|
||||||
|
// contactName: "",
|
||||||
|
// contactEmail: "",
|
||||||
|
// contactPhone: "",
|
||||||
|
// role: "",
|
||||||
|
|
||||||
|
// // 🧾 Metadata Umum
|
||||||
|
// metadataStandard: "ISO 19115:2003/19139",
|
||||||
|
// metadataVersion: "1.0",
|
||||||
|
// metadataUUID: "",
|
||||||
|
// metadataDate: "",
|
||||||
|
// charset: "utf8",
|
||||||
|
// rsIdentifier: "WGS 1984"
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Generate UUID & tanggal metadata saat pertama kali load
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
// 🧩 Identifikasi Dataset
|
// 🧩 Identifikasi Dataset
|
||||||
title: "",
|
// title: "test_upload_geos",
|
||||||
abstract: "",
|
// title: "",
|
||||||
keywords: "",
|
title: initialValues.judul?initialValues.judul:"",
|
||||||
topicCategory: "",
|
// abstract: "hanya test",
|
||||||
dateCreated: "",
|
abstract: initialValues.abstrak?initialValues.abstrak:"",
|
||||||
status: "",
|
// keywords: "tets, demo, gatau",
|
||||||
|
keywords: initialValues.keyword?initialValues.keyword.join(', '):"",
|
||||||
|
topicCategory: initialValues.kategori?initialValues.kategori:"",
|
||||||
|
mapsetCategory: "019a0997-5b42-7c34-9ab8-35b4765ecb39",
|
||||||
|
dateCreated: today,
|
||||||
|
status: "completed",
|
||||||
language: "eng",
|
language: "eng",
|
||||||
|
|
||||||
// 🧭 Referensi Spasial
|
// 🧭 Referensi Spasial
|
||||||
|
|
@ -39,10 +99,10 @@ export default function MetadataForm({ onChange }) {
|
||||||
license: "Copyright",
|
license: "Copyright",
|
||||||
|
|
||||||
// 👤 Informasi Penanggung Jawab
|
// 👤 Informasi Penanggung Jawab
|
||||||
organization: "",
|
organization: "PUPR",
|
||||||
contactName: "",
|
contactName: "Dimas",
|
||||||
contactEmail: "",
|
contactEmail: "pu@gmail.com",
|
||||||
contactPhone: "",
|
contactPhone: "08222222222",
|
||||||
role: "",
|
role: "",
|
||||||
|
|
||||||
// 🧾 Metadata Umum
|
// 🧾 Metadata Umum
|
||||||
|
|
@ -54,7 +114,7 @@ export default function MetadataForm({ onChange }) {
|
||||||
rsIdentifier: "WGS 1984"
|
rsIdentifier: "WGS 1984"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate UUID & tanggal metadata saat pertama kali load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -78,14 +138,14 @@ export default function MetadataForm({ onChange }) {
|
||||||
<Tabs defaultValue="identifikasi" className="w-full">
|
<Tabs defaultValue="identifikasi" className="w-full">
|
||||||
|
|
||||||
{/* TAB LIST */}
|
{/* TAB LIST */}
|
||||||
<TabsList className="grid grid-cols-2 w-full mb-6">
|
{/* <TabsList className="grid grid-cols-2 w-full mb-6">
|
||||||
<TabsTrigger value="identifikasi">
|
<TabsTrigger value="identifikasi">
|
||||||
🧩 Identifikasi Dataset
|
🧩 Identifikasi Dataset
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="penanggung">
|
<TabsTrigger value="penanggung">
|
||||||
👤 Penanggung Jawab
|
👤 Penanggung Jawab
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList> */}
|
||||||
|
|
||||||
{/* TAB 1: IDENTIFIKASI */}
|
{/* TAB 1: IDENTIFIKASI */}
|
||||||
<TabsContent value="identifikasi">
|
<TabsContent value="identifikasi">
|
||||||
|
|
@ -111,35 +171,84 @@ export default function MetadataForm({ onChange }) {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Select
|
<SelectMultiple
|
||||||
label="Kategori / Topik"
|
label="Kategori Metadata"
|
||||||
name="topicCategory"
|
name="topicCategory"
|
||||||
value={formData.topicCategory}
|
value={formData.topicCategory}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
options={[
|
options={[
|
||||||
"Environment",
|
{label: "Biota", value: "Biota"},
|
||||||
"Boundaries",
|
{label: "Farming", value: "Farming"},
|
||||||
"Transportation",
|
{label: "Boundaries", value: "Boundaries"},
|
||||||
"Elevation",
|
{label: "Climatology, meteorology, atmospherea", value: "Climatology, meteorology, atmospherea"},
|
||||||
"Imagery"
|
{label: "Economy", value: "Economy"},
|
||||||
|
{label: "Elevation", value: "Elevation"},
|
||||||
|
{label: "Environment", value: "Environment"},
|
||||||
|
{label: "Geoscientific information", value: "Geoscientific information"},
|
||||||
|
{label: "Health", value: "Health"},
|
||||||
|
{label: "Imagery base maps earth cover", value: "Imagery base maps earth cover"},
|
||||||
|
{label: "Intelligence military", value: "Intelligence military"},
|
||||||
|
{label: "Inland waters", value: "Inland waters"},
|
||||||
|
{label: "Location", value: "Location"},
|
||||||
|
{label: "Oceans", value: "Oceans"},
|
||||||
|
{label: "Planning cadastre", value: "Planning cadastre"},
|
||||||
|
{label: "Society", value: "Society"},
|
||||||
|
{label: "Structure", value: "Structure"},
|
||||||
|
{label: "Transportation", value: "Transportation"},
|
||||||
|
{label: "Utilities communication", value: "Utilities communication"}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Select
|
||||||
|
label="Kategori Mapset"
|
||||||
|
name="mapsetCategory"
|
||||||
|
value={formData.mapsetCategory}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
"Batas Wilayah",
|
||||||
|
"Kependudukan",
|
||||||
|
"Lingkungan Hidup",
|
||||||
|
"Pemerintah Desa",
|
||||||
|
"Pendidikan",
|
||||||
|
"Sosial",
|
||||||
|
"Pendidikan SD",
|
||||||
|
"Pariwisata Kebudayaan",
|
||||||
|
"Kesehatan",
|
||||||
|
"Ekonomi",
|
||||||
|
"Kemiskinan",
|
||||||
|
"Infrastruktur"
|
||||||
|
]}
|
||||||
|
optValue={[
|
||||||
|
"019a0997-5b42-7c34-9ab8-35b4765ecb39",
|
||||||
|
"0196c80b-e680-7dca-9b90-b5ebe65de70d",
|
||||||
|
"0196c80c-855f-77f9-abd0-0c8a30b8c2f5",
|
||||||
|
"0196c80c-f805-76a8-82c7-af50b794871b",
|
||||||
|
"0196c80d-228d-7e1e-9116-78ba912b812c",
|
||||||
|
"0196c80d-3f05-7750-ab2a-f58655fef6ea",
|
||||||
|
"019936a6-4a5b-719f-8d88-d2df0af5aa20",
|
||||||
|
"0196c80c-c4fc-7ea6-afc0-3672a1b44b5b",
|
||||||
|
"0196c80c-61d8-7616-9abc-550a89283a57",
|
||||||
|
"0196c809-a0b0-79fb-b597-422d716fdce8",
|
||||||
|
"0196c80b-bb09-7424-9cd5-e3ec4946c7af",
|
||||||
|
"0196c80b-8710-7577-bc28-3ce66a02f56f"
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <Input
|
||||||
type="date"
|
type="date"
|
||||||
label="Tanggal Pembuatan Data"
|
label="Tanggal Pembuatan Data"
|
||||||
name="dateCreated"
|
name="dateCreated"
|
||||||
value={formData.dateCreated}
|
value={formData.dateCreated}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/> */}
|
||||||
|
|
||||||
<Select
|
{/* <Select
|
||||||
label="Status Dataset"
|
label="Status Dataset"
|
||||||
name="status"
|
name="status"
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
options={["onGoing", "completed", "planned"]}
|
options={["onGoing", "completed", "planned"]}
|
||||||
/>
|
/> */}
|
||||||
</Section>
|
</Section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -248,7 +357,7 @@ function Textarea({ label, name, value, onChange }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Select({ label, name, value, onChange, options = [] }) {
|
function Select({ label, name, value, onChange, options = [], optValue = [] }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
|
<label htmlFor={name} className="block text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
|
@ -262,8 +371,8 @@ function Select({ label, name, value, onChange, options = [] }) {
|
||||||
className="w-full border border-gray-300 rounded-md p-2 bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
className="w-full border border-gray-300 rounded-md p-2 bg-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||||
>
|
>
|
||||||
<option value="">-- Pilih --</option>
|
<option value="">-- Pilih --</option>
|
||||||
{options.map((opt) => (
|
{options.map((opt, i) => (
|
||||||
<option key={opt} value={opt}>
|
<option key={opt} value={optValue[i]}>
|
||||||
{opt}
|
{opt}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|
@ -271,3 +380,20 @@ function Select({ label, name, value, onChange, options = [] }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectMultiple({ label, name, value, onChange, options = []}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
{label}{" "}<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<MultiSelect
|
||||||
|
name={name}
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
48
src/components/common/ConfirmDialog.jsx
Normal file
48
src/components/common/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
trigger,
|
||||||
|
title = "Apakah kamu yakin?",
|
||||||
|
description = "Tindakan ini tidak dapat dibatalkan.",
|
||||||
|
confirmText = "Ya",
|
||||||
|
cancelText = "Batal",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
{trigger}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{description}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={onCancel}>
|
||||||
|
{cancelText}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
|
||||||
|
<AlertDialogAction onClick={onConfirm}>
|
||||||
|
{confirmText}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/common/MultiSelect.jsx
Normal file
105
src/components/common/MultiSelect.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
|
||||||
|
export default function MultiSelect({
|
||||||
|
name,
|
||||||
|
options = [],
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
placeholder = "Pilih data",
|
||||||
|
}) {
|
||||||
|
const toggle = (val) => {
|
||||||
|
let newValue
|
||||||
|
|
||||||
|
if (value.includes(val)) {
|
||||||
|
newValue = value.filter((v) => v !== val)
|
||||||
|
} else {
|
||||||
|
newValue = [...value, val]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange({
|
||||||
|
target: {
|
||||||
|
name,
|
||||||
|
value: newValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLabels = options
|
||||||
|
.filter((o) => value.includes(o.value))
|
||||||
|
.map((o) => o.label)
|
||||||
|
.join(", ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
// <Popover>
|
||||||
|
// <PopoverTrigger asChild>
|
||||||
|
// <Button
|
||||||
|
// variant="outline"
|
||||||
|
// className="w-full justify-start text-left"
|
||||||
|
// >
|
||||||
|
// <div className="flex-1 overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||||
|
// {value.length > 0 ? selectedLabels : placeholder}
|
||||||
|
// </div>
|
||||||
|
// </Button>
|
||||||
|
// </PopoverTrigger>
|
||||||
|
|
||||||
|
// <PopoverContent className="w-full">
|
||||||
|
// <div className="space-y-2">
|
||||||
|
// {options.map((opt) => (
|
||||||
|
// <label
|
||||||
|
// key={opt.value}
|
||||||
|
// className="flex items-center space-x-2"
|
||||||
|
// >
|
||||||
|
// <Checkbox
|
||||||
|
// checked={value.includes(opt.value)}
|
||||||
|
// onCheckedChange={() => toggle(opt.value)}
|
||||||
|
// />
|
||||||
|
// <span>{opt.label}</span>
|
||||||
|
// </label>
|
||||||
|
// ))}
|
||||||
|
// </div>
|
||||||
|
// </PopoverContent>
|
||||||
|
// </Popover>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left font-normal border-gray-300 rounded-md px-2 shadow-none"
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||||
|
{value.length > 0 ? selectedLabels : placeholder}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] max-h-60 overflow-y-auto z-50"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.includes(opt.value)}
|
||||||
|
onCheckedChange={() => toggle(opt.value)}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/components/layers_preview/GeoPreview.jsx
Normal file
117
src/components/layers_preview/GeoPreview.jsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import Map from "ol/Map";
|
||||||
|
import View from "ol/View";
|
||||||
|
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
||||||
|
import { OSM } from "ol/source";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import WKT from "ol/format/WKT";
|
||||||
|
import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style";
|
||||||
|
import { fromLonLat } from "ol/proj";
|
||||||
|
|
||||||
|
|
||||||
|
const GeoPreview = ({ features }) => {
|
||||||
|
const mapRef = useRef();
|
||||||
|
const mapObj = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
if (!features || features.length === 0) return;
|
||||||
|
|
||||||
|
const wktFormat = new WKT();
|
||||||
|
const vectorSource = new VectorSource();
|
||||||
|
|
||||||
|
features.forEach((item) => {
|
||||||
|
try {
|
||||||
|
const feature = wktFormat.readFeature(item.geometry, {
|
||||||
|
dataProjection: `EPSG:4326`,
|
||||||
|
featureProjection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
|
||||||
|
vectorSource.addFeature(feature);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("WKT parse error:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const vectorLayer = new VectorLayer({
|
||||||
|
source: vectorSource,
|
||||||
|
style: (feature) => {
|
||||||
|
const type = feature.getGeometry().getType();
|
||||||
|
|
||||||
|
// Style untuk polygon
|
||||||
|
if (type === "Polygon" || type === "MultiPolygon") {
|
||||||
|
return new Style({
|
||||||
|
fill: new Fill({
|
||||||
|
color: "rgba(0, 153, 255, 0.4)",
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: "#0099ff",
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style untuk line
|
||||||
|
if (type === "LineString" || type === "MultiLineString") {
|
||||||
|
return new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: "#0099ff",
|
||||||
|
width: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style untuk point
|
||||||
|
if (type === "Point" || type === "MultiPoint") {
|
||||||
|
return new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 6,
|
||||||
|
fill: new Fill({
|
||||||
|
color: "#0099ff",
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: "#ffffff",
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Buat map
|
||||||
|
mapObj.current = new Map({
|
||||||
|
target: mapRef.current,
|
||||||
|
layers: [
|
||||||
|
new TileLayer({ source: new OSM() }),
|
||||||
|
vectorLayer,
|
||||||
|
],
|
||||||
|
view: new View({
|
||||||
|
center: fromLonLat([110, -6]),
|
||||||
|
zoom: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom ke seluruh geometry
|
||||||
|
const extent = vectorSource.getExtent();
|
||||||
|
if (extent && extent[0] !== Infinity) {
|
||||||
|
mapObj.current.getView().fit(extent, {
|
||||||
|
padding: [20, 20, 20, 20],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mapObj.current) mapObj.current.setTarget(null);
|
||||||
|
};
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{ width: "100%", height: "500px", border: "1px solid #ccc" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeoPreview;
|
||||||
415
src/components/layers_style/CustomLayerStyle.jsx
Normal file
415
src/components/layers_style/CustomLayerStyle.jsx
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
|
||||||
|
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: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>`;
|
||||||
|
const singleColorSLD = (color, geometryType) => `
|
||||||
|
${sldHeader}
|
||||||
|
<NamedLayer>
|
||||||
|
<Name>layer</Name>
|
||||||
|
<UserStyle>
|
||||||
|
<FeatureTypeStyle>
|
||||||
|
<Rule>
|
||||||
|
${symbolizer(geometryType, color)}
|
||||||
|
</Rule>
|
||||||
|
</FeatureTypeStyle>
|
||||||
|
</UserStyle>
|
||||||
|
</NamedLayer>
|
||||||
|
${sldFooter}
|
||||||
|
`;
|
||||||
|
const uniqueValueSLD = (column, rules, geometryType) => `
|
||||||
|
${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) => `
|
||||||
|
${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}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const symbolizer = (geometryType, color) => {
|
||||||
|
if (geometryType.toUpperCase() === "POINT" || geometryType.toUpperCase() === "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">2</CssParameter>
|
||||||
|
</Stroke>
|
||||||
|
</Mark>
|
||||||
|
<Size>10</Size>
|
||||||
|
</Graphic>
|
||||||
|
</PointSymbolizer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometryType === "line") {
|
||||||
|
return `
|
||||||
|
<LineSymbolizer>
|
||||||
|
<Stroke>
|
||||||
|
<CssParameter name="stroke">${color}</CssParameter>
|
||||||
|
<CssParameter name="stroke-width">2</CssParameter>
|
||||||
|
</Stroke>
|
||||||
|
</LineSymbolizer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<PolygonSymbolizer>
|
||||||
|
<Fill>
|
||||||
|
<CssParameter name="fill">${color}</CssParameter>
|
||||||
|
<CssParameter name="fill-opacity">0.5</CssParameter>
|
||||||
|
</Fill>
|
||||||
|
<Stroke>
|
||||||
|
<CssParameter name="stroke">#232323</CssParameter>
|
||||||
|
</Stroke>
|
||||||
|
</PolygonSymbolizer>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// const randomColor = () =>
|
||||||
|
// "#" + Math.floor(Math.random() * 16777215).toString(16);
|
||||||
|
const randomColor = () => {
|
||||||
|
let color = "#000000"
|
||||||
|
|
||||||
|
while (color === "#000000") {
|
||||||
|
color =
|
||||||
|
"#" + Math.floor(Math.random() * 16777215)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(6, "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const CustomLayerStyle = ({ data = [], geometryType, onSubmit, onChange }) => {
|
||||||
|
const [columns, setColumns] = useState([]);
|
||||||
|
const [selectedStyle, setSelectedStyle] = useState("single");
|
||||||
|
|
||||||
|
// STYLE STATE
|
||||||
|
const [singleColor, setSingleColor] = useState("#3388ff");
|
||||||
|
|
||||||
|
const [uniqueColumn, setUniqueColumn] = useState("");
|
||||||
|
const [uniqueRules, setUniqueRules] = useState([]);
|
||||||
|
|
||||||
|
const [randomRules, setRandomRules] = useState([]);
|
||||||
|
|
||||||
|
const [propColumn, setPropColumn] = useState("");
|
||||||
|
const [propMin, setPropMin] = useState(3);
|
||||||
|
const [propMax, setPropMax] = useState(12);
|
||||||
|
|
||||||
|
const [iconMode, setIconMode] = useState("global"); // global | per-feature
|
||||||
|
const [iconGlobal, setIconGlobal] = useState("");
|
||||||
|
const [iconRules, setIconRules] = useState([]);
|
||||||
|
|
||||||
|
// Extract columns
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
const keys = Object.keys(data[0]).filter((k) => k !== "geometry");
|
||||||
|
setColumns(keys);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange({
|
||||||
|
type: selectedStyle,
|
||||||
|
color: singleColor,
|
||||||
|
unique: uniqueRules,
|
||||||
|
random: randomRules,
|
||||||
|
proportional: { propColumn, propMin, propMax }
|
||||||
|
});
|
||||||
|
}, [selectedStyle, singleColor, uniqueRules, randomRules, propColumn, propMin, propMax]);
|
||||||
|
|
||||||
|
|
||||||
|
// Handle unique value column selection
|
||||||
|
const generateUniqueRules = (column) => {
|
||||||
|
const values = [...new Set(data.map((d) => d[column]))];
|
||||||
|
|
||||||
|
const rules = values.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
color: randomColor(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setUniqueRules(rules);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle random per row
|
||||||
|
const generateRandomRules = () => {
|
||||||
|
const rules = data.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
color: randomColor(),
|
||||||
|
}));
|
||||||
|
setRandomRules(rules);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle icon rules
|
||||||
|
const generateIconRules = () => {
|
||||||
|
const rules = data.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
iconUrl: "",
|
||||||
|
}));
|
||||||
|
setIconRules(rules);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generateBase64(iconUrl) {
|
||||||
|
const response = await fetch(iconUrl, {
|
||||||
|
mode: "cors",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Final Submit
|
||||||
|
const submit = async () => {
|
||||||
|
let xml = "";
|
||||||
|
|
||||||
|
if (selectedStyle === "single") {
|
||||||
|
xml = singleColorSLD(singleColor, geometryType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStyle === "unique_value") {
|
||||||
|
xml = uniqueValueSLD(uniqueColumn, uniqueRules, geometryType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedStyle === "icon") {
|
||||||
|
// const iconCode = await generateBase64('https://cdn-icons-png.flaticon.com/512/0/614.png')
|
||||||
|
const iconCode = 'https://cdn-icons-png.flaticon.com/512/0/614.png'
|
||||||
|
xml = globalIconSLD(iconCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// xml = ``
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
styleType: "sld",
|
||||||
|
sldContent: xml
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white relative p-4 pb-0 rounded shadow h-full overflow-auto">
|
||||||
|
<h5 className="font-bold mb-3">🎨 Pengaturan Styling Data Spasial</h5>
|
||||||
|
|
||||||
|
{/* PILIH STYLE */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="font-semibold block mb-1">Jenis Styling</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
value={selectedStyle}
|
||||||
|
onChange={(e) => setSelectedStyle(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="single">Single Color</option>
|
||||||
|
<option value="unique_value">Unique Value (Kategori)</option>
|
||||||
|
{/* <option value="random">Random per Feature</option> */}
|
||||||
|
{/* <option value="proportional">Proportional (Ukuran)</option> */}
|
||||||
|
{geometryType === "Point" && <option value="icon">Icon per Feature</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------------- SINGLE COLOR ---------------- */}
|
||||||
|
{selectedStyle === "single" && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="font-semibold block mb-1">Pilih Warna</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="w-16 h-10 p-1 border rounded"
|
||||||
|
value={singleColor}
|
||||||
|
onChange={(e) => setSingleColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- UNIQUE VALUE ---------------- */}
|
||||||
|
{selectedStyle === "unique_value" && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="font-semibold block mb-1">Pilih Kolom Kategori</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
onChange={(e) => {
|
||||||
|
setUniqueColumn(e.target.value);
|
||||||
|
generateUniqueRules(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- pilih kolom --</option>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<option key={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* RULE LIST */}
|
||||||
|
<div className="mt-3">
|
||||||
|
{uniqueRules.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center mb-2">
|
||||||
|
<div className="w-36 mr-2">{r.value}</div>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="w-12 h-8 border rounded"
|
||||||
|
value={r.color}
|
||||||
|
onChange={(e) => {
|
||||||
|
const copy = [...uniqueRules];
|
||||||
|
copy[i].color = e.target.value;
|
||||||
|
setUniqueRules(copy);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- RANDOM COLOR ---------------- */}
|
||||||
|
{selectedStyle === "random" && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
className="bg-gray-600 text-white px-3 py-2 rounded mb-2"
|
||||||
|
onClick={generateRandomRules}
|
||||||
|
>
|
||||||
|
Generate Random Colors
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{randomRules.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center mb-2">
|
||||||
|
<span className="mr-2">ID {r.id}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="w-12 h-8 border rounded"
|
||||||
|
value={r.color}
|
||||||
|
onChange={(e) => {
|
||||||
|
const copy = [...randomRules];
|
||||||
|
copy[i].color = e.target.value;
|
||||||
|
setRandomRules(copy);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- PROPORTIONAL ---------------- */}
|
||||||
|
{selectedStyle === "proportional" && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="font-semibold block mb-1">Pilih Kolom Angka</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded p-2 mb-2"
|
||||||
|
onChange={(e) => setPropColumn(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- pilih kolom --</option>
|
||||||
|
{columns.map((c) => {
|
||||||
|
const isNumber = typeof data[0][c] === "number";
|
||||||
|
return isNumber && <option key={c}>{c}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="block mb-1">Ukuran Minimum</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
value={propMin}
|
||||||
|
onChange={(e) => setPropMin(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1">Ukuran Maksimum</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
value={propMax}
|
||||||
|
onChange={(e) => setPropMax(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</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="sticky bottom-0 bg-white pt-2">
|
||||||
|
<button
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white w-full py-1 rounded"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Terapkan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomLayerStyle;
|
||||||
187
src/components/layers_style/StylePreview.jsx
Normal file
187
src/components/layers_style/StylePreview.jsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import Map from "ol/Map";
|
||||||
|
import View from "ol/View";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import TileLayer from "ol/layer/Tile";
|
||||||
|
import OSM from "ol/source/OSM";
|
||||||
|
import WKT from "ol/format/WKT";
|
||||||
|
import Feature from "ol/Feature";
|
||||||
|
|
||||||
|
import Style from "ol/style/Style";
|
||||||
|
import Fill from "ol/style/Fill";
|
||||||
|
import Stroke from "ol/style/Stroke";
|
||||||
|
import CircleStyle from "ol/style/Circle";
|
||||||
|
|
||||||
|
import SldStyleParser from "geostyler-sld-parser";
|
||||||
|
import OlStyleParser from "geostyler-openlayers-parser";
|
||||||
|
import { defaults as defaultControls } from 'ol/control';
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// GEOMETRY
|
||||||
|
// =============================
|
||||||
|
const wkt = new WKT();
|
||||||
|
|
||||||
|
function parseWKT(str) {
|
||||||
|
try {
|
||||||
|
return wkt.readGeometry(str, {
|
||||||
|
dataProjection: "EPSG:4326",
|
||||||
|
featureProjection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WKT Error:", str);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(key) {
|
||||||
|
return key.replace(/[^a-zA-Z0-9_]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeatures(data) {
|
||||||
|
return data.map((row) => {
|
||||||
|
const geometry = parseWKT(row.geometry);
|
||||||
|
const feat = new Feature();
|
||||||
|
|
||||||
|
Object.entries(row).forEach(([key, value]) => {
|
||||||
|
if (key === "geometry") return;
|
||||||
|
|
||||||
|
// ORIGINAL KEY (untuk SLD / GeoStyler)
|
||||||
|
feat.set(key, value);
|
||||||
|
|
||||||
|
// NORMALIZED KEY (untuk sistem kamu)
|
||||||
|
const normalized = normalizeKey(key).toUpperCase();
|
||||||
|
// console.log(normalized, key);
|
||||||
|
if (normalized !== key) {
|
||||||
|
feat.set(normalized, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
feat.setGeometry(geometry);
|
||||||
|
return feat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// FALLBACK STYLE
|
||||||
|
// =============================
|
||||||
|
const defaultStyle = new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 6,
|
||||||
|
fill: new Fill({ color: "#3388ff" }),
|
||||||
|
stroke: new Stroke({ color: "#333", width: 1 }),
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({ color: "#3388ff", width: 2 }),
|
||||||
|
fill: new Fill({ color: "rgba(51,136,255,0.5)" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// PREVIEW MAP (GEOSTYLER)
|
||||||
|
// =============================
|
||||||
|
const SpatialStylePreviewGeoStyler = ({ data, styleConfig }) => {
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const mapObj = useRef(null);
|
||||||
|
const vectorLayer = useRef(null);
|
||||||
|
|
||||||
|
// init map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
const features = createFeatures(data);
|
||||||
|
|
||||||
|
const vectorSource = new VectorSource({ features });
|
||||||
|
vectorLayer.current = new VectorLayer({
|
||||||
|
source: vectorSource,
|
||||||
|
style: defaultStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapObj.current = new Map({
|
||||||
|
target: mapRef.current,
|
||||||
|
controls: defaultControls({
|
||||||
|
attribution: false, // ⬅️ ini kuncinya
|
||||||
|
zoom: true,
|
||||||
|
}),
|
||||||
|
layers: [
|
||||||
|
new TileLayer({ source: new OSM() }),
|
||||||
|
vectorLayer.current,
|
||||||
|
],
|
||||||
|
view: new View({
|
||||||
|
center: [12600000, -830000],
|
||||||
|
zoom: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => mapObj.current?.setTarget(null);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// APPLY SLD VIA GEOSTYLER
|
||||||
|
// =============================
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vectorLayer.current) return;
|
||||||
|
if (!styleConfig || styleConfig.styleType !== "sld") {
|
||||||
|
vectorLayer.current.setStyle(defaultStyle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySLD = async () => {
|
||||||
|
try {
|
||||||
|
const sldParser = new SldStyleParser();
|
||||||
|
const olParser = new OlStyleParser();
|
||||||
|
|
||||||
|
// 1️⃣ SLD XML → GeoStyler Style
|
||||||
|
const sldResult = await sldParser.readStyle(
|
||||||
|
styleConfig.sldContent
|
||||||
|
);
|
||||||
|
|
||||||
|
const geoStyle = sldResult.output;
|
||||||
|
|
||||||
|
if (!geoStyle) {
|
||||||
|
throw new Error("GeoStyler returned empty style");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ GeoStyler Style → OL Style / StyleFunction
|
||||||
|
const olResult = await olParser.writeStyle(geoStyle);
|
||||||
|
const olStyle = olResult.output;
|
||||||
|
|
||||||
|
// 3️⃣ APPLY STYLE (TYPE SAFE)
|
||||||
|
if (typeof olStyle === "function") {
|
||||||
|
vectorLayer.current.setStyle((feature, resolution) =>
|
||||||
|
olStyle(feature, resolution)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
vectorLayer.current.setStyle(olStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ FORCE REDRAW
|
||||||
|
vectorLayer.current.getSource().changed();
|
||||||
|
vectorLayer.current.changed();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("SLD parsing failed, fallback used", err);
|
||||||
|
vectorLayer.current.setStyle(defaultStyle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
applySLD();
|
||||||
|
}, [styleConfig]);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
className="w-full h-full rounded-lg border shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpatialStylePreviewGeoStyler;
|
||||||
157
src/components/layers_style/StylingLayers.jsx
Normal file
157
src/components/layers_style/StylingLayers.jsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import React, {useState} from "react";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||||
|
import CustomLayerStyle from "./CustomLayerStyle"; // komponen yang sudah kamu buat
|
||||||
|
|
||||||
|
function normalizeBase64(xmlString) {
|
||||||
|
return xmlString.replace(
|
||||||
|
/xlink:href="base64:([^"?]+)(\?[^"]*)?"/g,
|
||||||
|
(_, base64Content) => {
|
||||||
|
return `xlink:href="data:image/svg+xml;base64,${base64Content}"`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const StylingLayers = ({ data, geometryType, onSubmit, geosStyle, changeGeos=null }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState("custom");
|
||||||
|
const [customStyle, setCustomStyle] = useState(null);
|
||||||
|
const [uploadStyle, setUploadStyle] = useState(null);
|
||||||
|
const [geosStyleValue, setGeosStyleValue] = useState(null);
|
||||||
|
|
||||||
|
const [parsedSld, setParsedSld] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (activeTab === "custom") onSubmit(customStyle);
|
||||||
|
if (activeTab === "upload") onSubmit(uploadStyle);
|
||||||
|
if (activeTab === "geoserver") onSubmit(geosStyleValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-hidden bg-white">
|
||||||
|
<Tabs defaultValue="custom" value={activeTab} onValueChange={setActiveTab} className="w-full h-full overflow-hidden">
|
||||||
|
|
||||||
|
{/* TAB LIST */}
|
||||||
|
<TabsList className="grid grid-cols-2 w-full mb-4">
|
||||||
|
<TabsTrigger value="custom">Custom Styling</TabsTrigger>
|
||||||
|
<TabsTrigger value="upload">Upload SLD</TabsTrigger>
|
||||||
|
{/* <TabsTrigger value="geoserver">Ambil dari GeoServer</TabsTrigger> */}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* TAB 1 : CUSTOM STYLING */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<TabsContent forceMount value="custom" className="tabs-styling h-full overflow-hidden">
|
||||||
|
<CustomLayerStyle
|
||||||
|
data={data}
|
||||||
|
geometryType={geometryType}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onChange={setCustomStyle}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* TAB 2 : UPLOAD SLD */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<TabsContent forceMount value="upload" className="tabs-styling h-full overflow-auto">
|
||||||
|
<div className="p-4 pb-0 h-full flex flex-col">
|
||||||
|
<div className="p-4 border rounded-lg text-center">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Upload File SLD</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Unggah file .sld untuk mengganti style layer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".sld"
|
||||||
|
className="block w-full text-sm file:mr-4 file:px-4 file:py-2
|
||||||
|
file:rounded-md file:border file:bg-gray-100
|
||||||
|
file:hover:bg-gray-200 cursor-pointer"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const sld = normalizeBase64(reader.result)
|
||||||
|
setParsedSld(sld)
|
||||||
|
onSubmit({
|
||||||
|
styleType: "sld",
|
||||||
|
sldContent: sld,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white w-full py-1 rounded disabled:bg-gray-400"
|
||||||
|
onClick={() =>
|
||||||
|
onSubmit({
|
||||||
|
styleType: "sld",
|
||||||
|
sldContent: parsedSld,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!parsedSld}
|
||||||
|
>
|
||||||
|
Terapkan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
{/* TAB 3 : STYLE DARI GEOSERVER */}
|
||||||
|
{/* ---------------------------------------------------- */}
|
||||||
|
<TabsContent forceMount value="geoserver" className="tabs-styling h-full overflow-auto">
|
||||||
|
<div className="p-3 pt-0">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Ambil Style dari GeoServer</h3>
|
||||||
|
|
||||||
|
{/* <p className="text-sm text-gray-500 mb-3">
|
||||||
|
Masukkan nama workspace dan style di GeoServer.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="font-medium text-sm">Workspace</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="contoh: myworkspace"
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
onChange={(e) =>
|
||||||
|
onSubmit({
|
||||||
|
styleType: "from_geoserver",
|
||||||
|
workspace: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="font-medium text-sm">Nama Style</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="contoh: jalan_style"
|
||||||
|
className="w-full border rounded px-3 py-2 text-sm"
|
||||||
|
onChange={(e) =>
|
||||||
|
onSubmit({
|
||||||
|
styleType: "from_geoserver",
|
||||||
|
styleName: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
{geosStyle.map((item, i) => (
|
||||||
|
<div key={i} className="mb-1 p-[2px] border rounded hover:bg-gray-300 hover:cursor-pointer">
|
||||||
|
<small>{item.name}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StylingLayers;
|
||||||
136
src/components/ui/alert-dialog.jsx
Normal file
136
src/components/ui/alert-dialog.jsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (<AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
58
src/components/ui/button.jsx
Normal file
58
src/components/ui/button.jsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
28
src/components/ui/checkbox.jsx
Normal file
28
src/components/ui/checkbox.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none">
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
45
src/components/ui/popover.jsx
Normal file
45
src/components/ui/popover.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
138
src/components/ui/sheet.jsx
Normal file
138
src/components/ui/sheet.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ function TabsTrigger({ className, ...props }) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-sm font-medium text-gray-500 relative",
|
"px-4 py-2 text-sm font-medium text-gray-500 relative cursor-pointer",
|
||||||
"data-[state=active]:text-blue-600",
|
"data-[state=active]:text-blue-600",
|
||||||
"data-[state=active]:after:absolute data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:-bottom-[1px] data-[state=active]:after:h-[2px] data-[state=active]:after:bg-blue-600",
|
"data-[state=active]:after:absolute data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:-bottom-[1px] data-[state=active]:after:h-[2px] data-[state=active]:after:bg-blue-600",
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ export default function FilePreview({ result }) {
|
||||||
preview = [],
|
preview = [],
|
||||||
geometry_valid = 0,
|
geometry_valid = 0,
|
||||||
geometry_empty = 0,
|
geometry_empty = 0,
|
||||||
warning_examples = [],
|
warning_rows = [],
|
||||||
} = result;
|
} = result;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 w-full">
|
<div className="mt-4 w-full">
|
||||||
{/* Section: Warning Table */}
|
{/* Section: Warning Table */}
|
||||||
{warning_examples?.length > 0 ?? (
|
{warning_rows?.length > 0 ?? (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="font-semibold text-gray-700 mb-2">
|
<h3 className="font-semibold text-gray-700 mb-2">
|
||||||
⚠️ Beberapa nama wilayah perlu diperiksa kembali.
|
⚠️ Beberapa nama wilayah perlu diperiksa kembali.
|
||||||
|
|
@ -29,7 +29,7 @@ export default function FilePreview({ result }) {
|
||||||
<Table
|
<Table
|
||||||
title="Data Perlu Diperiksa"
|
title="Data Perlu Diperiksa"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={warning_examples}
|
rows={warning_rows}
|
||||||
total={geometry_empty}
|
total={geometry_empty}
|
||||||
limit={100}
|
limit={100}
|
||||||
variant="warning"
|
variant="warning"
|
||||||
|
|
@ -45,7 +45,7 @@ export default function FilePreview({ result }) {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={preview}
|
rows={preview}
|
||||||
total={geometry_valid}
|
total={geometry_valid}
|
||||||
limit={geometry_empty > 0 ? 5 : 15}
|
limit={geometry_empty > 0 ? 5 : 10}
|
||||||
variant="preview"
|
variant="preview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user