1836 lines
89 KiB
HTML
1836 lines
89 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="id">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Sistem GIS</title>
|
|
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
|
|
<!-- Tailwind CSS -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
|
<style>
|
|
html, body { height: 100%; }
|
|
body { overflow: hidden; }
|
|
.app-shell {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100vh;
|
|
padding: calc(1rem + env(safe-area-inset-top)) 1rem 1rem 1rem;
|
|
box-sizing: border-box;
|
|
}
|
|
#map {
|
|
height: calc(100vh - 2rem);
|
|
width: 100%;
|
|
border-radius: 1rem;
|
|
z-index: 1;
|
|
}
|
|
.custom-msg { display: none; position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 250px; }
|
|
.leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; overflow: hidden; }
|
|
.leaflet-popup-content { margin: 0; width: 280px !important; }
|
|
.leaflet-draw-toolbar a { background-color: white; }
|
|
|
|
/* CSS Tambahan agar ikon SVG tidak terpotong atau memiliki background */
|
|
.custom-marker { background: transparent !important; border: none !important; }
|
|
.feature-card { box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); }
|
|
.icon-btn { width: 2.75rem; height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; border-radius: 0.9rem; border: 1px solid rgba(203,213,225,.9); background: rgba(255,255,255,.92); backdrop-filter: blur(14px); transition: .18s ease; }
|
|
.icon-btn:hover { transform: translateY(-1px); box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); }
|
|
.icon-btn.active { background: #4f46e5; color: white; border-color: #4f46e5; }
|
|
.registry-card { box-shadow: 0 18px 45px rgba(15, 23, 42, 0.14); }
|
|
.registry-list { max-height: min(56vh, 520px); overflow-y: auto; }
|
|
.registry-item { transition: .18s ease; }
|
|
.registry-item:hover { transform: translateY(-1px); box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1); }
|
|
.registry-chip { display: inline-flex; align-items: center; gap: .35rem; border-radius: 9999px; padding: .25rem .6rem; font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-50 text-slate-800 font-sans">
|
|
|
|
<div class="app-shell">
|
|
<div id="map"></div>
|
|
|
|
<div class="absolute left-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2000] w-[300px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-2xl backdrop-blur-md md:w-[340px]">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-lg font-black tracking-tight text-indigo-700">GIS</h1>
|
|
<p id="modeHint" class="mt-1 text-xs text-slate-500">Mode default hanya menampilkan peta dan atribut data yang sudah dibuat.</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick="toggleInputRegistry()" class="icon-btn text-slate-700" title="Buka registry input">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 4v16"/><path d="M8 8h11"/><path d="M8 12h11"/><path d="M8 16h7"/></svg>
|
|
</button>
|
|
<button onclick="loadMarkers({ force: true }); loadFeatures(true);" class="icon-btn text-indigo-600" title="Refresh data">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 21h5v-5"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="connectionStatus" class="mt-2 text-[10px] font-bold uppercase tracking-wider text-amber-500">Mengecek koneksi ke database...</div>
|
|
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2 text-xs text-slate-600">
|
|
<span>Total SPBU</span>
|
|
<span id="totalCount" class="text-base font-black text-indigo-600">0</span>
|
|
</div>
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
<button class="icon-btn" id="modeViewBtn" title="Lihat data">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="modePointBtn" title="Tambah point SPBU">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2C8.13 2 5 5.13 5 9c0 5 7 13 7 13s7-8 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="modePolylineBtn" title="Tambah polyline jalan">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 18l6-6 4 4 6-8"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="modePolygonBtn" title="Tambah polygon parsil">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l8 5v10l-8 5-8-5V7z"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="modeChoroplethBtn" title="Lihat peta choropleth kecamatan">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6l6-2 6 2 6-2v14l-6 2-6-2-6 2z"/><path d="M9 4v14"/><path d="M15 6v14"/></svg>
|
|
</button>
|
|
</div>
|
|
<div id="spatialControls" class="mt-3 hidden rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
|
<div id="statusControlWrap">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
|
|
<select id="drawStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"></select>
|
|
</div>
|
|
<div id="measureControlWrap" class="mt-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Panjang / Luas</label>
|
|
<input id="modeMeasure" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
<div class="mt-3 flex gap-2">
|
|
<button id="runActionBtn" class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-indigo-700">Mulai</button>
|
|
<button id="clearDraftBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Bersih</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div id="inputRegistryPanel" class="absolute right-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2000] hidden w-[320px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md registry-card">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 class="text-sm font-black tracking-tight text-slate-800">Registry Input</h2>
|
|
<p class="mt-1 text-[11px] text-slate-500">Daftar input yang bisa difilter.</p>
|
|
</div>
|
|
<button id="closeInputRegistryBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup registry">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-3 flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2">
|
|
<span class="text-[11px] font-bold uppercase tracking-widest text-slate-500">Tercatat</span>
|
|
<span id="inputRegistryCount" class="text-sm font-black text-indigo-600">0</span>
|
|
</div>
|
|
|
|
<div class="mt-3 grid gap-2">
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Nama / Atribut</label>
|
|
<input id="inputSearchQuery" type="text" placeholder="Cari nama, kategori, nomor..." class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
</div>
|
|
<div class="sm:col-span-1">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis</label>
|
|
<select id="inputTypeFilter" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
<option value="all">Semua</option>
|
|
<option value="point">Poin</option>
|
|
<option value="polyline">Polyline</option>
|
|
<option value="polygon">Polygon</option>
|
|
</select>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Dari</label>
|
|
<input id="inputDateFrom" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Sampai</label>
|
|
<input id="inputDateTo" type="date" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 flex gap-2">
|
|
<button id="applyInputFilterBtn" class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white transition hover:bg-indigo-700">Terapkan</button>
|
|
<button id="clearInputFilterBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-200">Reset</button>
|
|
</div>
|
|
|
|
<div id="inputRegistryList" class="registry-list mt-3 space-y-2 pr-1">
|
|
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
|
Memuat data input...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="choroplethPanel" class="absolute right-6 bottom-6 z-[2000] hidden w-[320px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 class="text-sm font-black tracking-tight text-slate-800">Choropleth Kecamatan Pontianak</h3>
|
|
<p class="mt-1 text-[11px] text-slate-500">Klik kecamatan untuk melihat jumlah penduduk.</p>
|
|
</div>
|
|
<span class="rounded-full bg-indigo-100 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-indigo-700">Interaktif</span>
|
|
</div>
|
|
|
|
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
|
<div id="choroplethName" class="text-xs font-bold uppercase tracking-wider text-slate-500">Belum dipilih</div>
|
|
<div id="choroplethPopulation" class="mt-1 text-xl font-black text-slate-800">-</div>
|
|
<div id="choroplethDensity" class="mt-1 text-xs text-slate-500">Klik salah satu kecamatan di peta.</div>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] text-slate-600">
|
|
<div class="rounded-lg bg-[#fee2e2] px-2 py-1">< 80.000 jiwa</div>
|
|
<div class="rounded-lg bg-[#fecaca] px-2 py-1">80.000 - 100.000</div>
|
|
<div class="rounded-lg bg-[#fca5a5] px-2 py-1">100.000 - 120.000</div>
|
|
<div class="rounded-lg bg-[#f87171] px-2 py-1">> 120.000 jiwa</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notifikasi -->
|
|
<div id="statusMsg" class="custom-msg p-4 rounded-lg shadow-xl font-medium transition-all transform"></div>
|
|
|
|
<!-- Modal Edit Fitur -->
|
|
<div id="featureModal" class="fixed inset-0 z-[10000] hidden items-center justify-center bg-slate-900/50 px-4">
|
|
<div class="w-full max-w-lg rounded-2xl bg-white p-5 shadow-2xl">
|
|
<div class="flex items-start justify-between gap-4 border-b pb-3">
|
|
<div>
|
|
<h3 class="text-lg font-bold text-slate-800">Edit Fitur Spasial</h3>
|
|
<p class="text-sm text-slate-500">Ubah atribut atau simpan geometri yang sudah diedit di peta.</p>
|
|
</div>
|
|
<button id="closeFeatureModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
|
|
</div>
|
|
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
|
<input id="editFeatureId" type="hidden">
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Objek</label>
|
|
<input id="editFeatureName" type="text" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Kategori</label>
|
|
<input id="editFeatureKategori" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tipe</label>
|
|
<input id="editFeatureType" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
|
|
<select id="editFeatureStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"></select>
|
|
</div>
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Panjang / Luas</label>
|
|
<input id="editFeatureMeasure" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
</div>
|
|
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
|
|
<button id="saveFeatureAttrBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan Atribut</button>
|
|
<button id="toggleFeatureGeometryBtn" class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700">Edit Geometri</button>
|
|
<button id="saveFeatureGeometryBtn" class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-bold text-white hover:bg-amber-600">Simpan Geometri</button>
|
|
<button id="deleteFeatureBtn" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700">Hapus</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Fitur Baru -->
|
|
<div id="newFeatureModal" class="fixed inset-0 z-[10001] hidden items-center justify-center bg-slate-900/50 px-4">
|
|
<div class="w-full max-w-lg rounded-2xl bg-white p-5 shadow-2xl">
|
|
<div class="flex items-start justify-between gap-4 border-b pb-3">
|
|
<div>
|
|
<h3 class="text-lg font-bold text-slate-800">Input Fitur Baru</h3>
|
|
<p class="text-sm text-slate-500">Isi nama setelah garis atau polygon selesai digambar.</p>
|
|
</div>
|
|
<button id="closeNewFeatureModalBtn" class="rounded-lg bg-slate-100 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Tutup</button>
|
|
</div>
|
|
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Objek</label>
|
|
<input id="newFeatureName" type="text" placeholder="Contoh: Jl. Ahmad Yani" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Kategori</label>
|
|
<input id="newFeatureKategori" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tipe</label>
|
|
<input id="newFeatureTypeLabel" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status</label>
|
|
<select id="newFeatureStatus" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500"></select>
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Panjang / Luas</label>
|
|
<input id="newFeatureMeasure" type="text" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600">
|
|
</div>
|
|
</div>
|
|
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
|
|
<button id="saveNewFeatureBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan</button>
|
|
<button id="cancelNewFeatureBtn" class="rounded-lg bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-200">Batal</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<select id="featureType" class="hidden">
|
|
<option value="polyline">polyline</option>
|
|
<option value="polygon">polygon</option>
|
|
</select>
|
|
<select id="featureStatus" class="hidden"></select>
|
|
</div>
|
|
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
|
|
|
<script>
|
|
// Inisialisasi Peta (Default Pontianak)
|
|
const map = L.map('map').setView([-0.0263, 109.3425], 13);
|
|
const REFRESH_INTERVAL_MS = 5000;
|
|
let autoRefreshTimer = null;
|
|
let isSyncing = false;
|
|
let suppressAutoReload = false;
|
|
let isFeatureDrawing = false;
|
|
let isFeatureSaving = false;
|
|
let latestSnapshotHash = '';
|
|
let featureLayer = L.featureGroup().addTo(map);
|
|
let drawControl = null;
|
|
let activeDrawHandler = null;
|
|
let currentDrawMode = 'polyline';
|
|
let appMode = 'view';
|
|
let pendingShape = null;
|
|
let pendingNewFeatureLayer = null;
|
|
let featureLayerMap = {};
|
|
let locationLayerMap = {};
|
|
let locationRecords = [];
|
|
let featureRecords = [];
|
|
let editingFeatureId = null;
|
|
let choroplethLayer = null;
|
|
|
|
const FEATURE_STATUS_OPTIONS = {
|
|
polyline: [
|
|
{ value: 'Jalan Nasional', label: 'Jalan Nasional', color: '#16a34a' },
|
|
{ value: 'Jalan Provinsi', label: 'Jalan Provinsi', color: '#f59e0b' },
|
|
{ value: 'Jalan Kabupaten', label: 'Jalan Kabupaten', color: '#dc2626' }
|
|
],
|
|
polygon: [
|
|
{ value: 'SHM', label: 'SHM', color: '#10b981' },
|
|
{ value: 'HGB', label: 'HGB', color: '#3b82f6' },
|
|
{ value: 'HGU', label: 'HGU', color: '#8b5cf6' },
|
|
{ value: 'HP', label: 'HP', color: '#64748b' }
|
|
]
|
|
};
|
|
|
|
const CHOROPLETH_GEOJSON_PATH = 'data/pontianak-kecamatan.geojson';
|
|
let choroplethGeoJsonData = null;
|
|
|
|
function getPopulationFill(population) {
|
|
if (population > 120000) return '#ef4444';
|
|
if (population > 100000) return '#f87171';
|
|
if (population > 80000) return '#fca5a5';
|
|
return '#fecaca';
|
|
}
|
|
|
|
function getChoroplethValue(properties) {
|
|
if (properties.population != null) {
|
|
return { key: 'population', label: 'Populasi', value: Number(properties.population || 0), unit: 'jiwa' };
|
|
}
|
|
|
|
if (properties.density != null) {
|
|
return { key: 'density', label: 'Kepadatan', value: Number(properties.density || 0), unit: 'jiwa/km²' };
|
|
}
|
|
|
|
return { key: 'none', label: 'Nilai', value: 0, unit: '' };
|
|
}
|
|
|
|
function resetChoroplethPanel() {
|
|
document.getElementById('choroplethName').innerText = 'Belum dipilih';
|
|
document.getElementById('choroplethPopulation').innerText = '-';
|
|
document.getElementById('choroplethDensity').innerText = 'Klik salah satu kecamatan di peta.';
|
|
}
|
|
|
|
function updateChoroplethPanel(properties) {
|
|
const mainValue = getChoroplethValue(properties);
|
|
const population = Number(properties.population || 0);
|
|
const area = Number(properties.area_km2 || 0);
|
|
const density = area > 0 ? population / area : 0;
|
|
|
|
document.getElementById('choroplethName').innerText = properties.name || properties.NAME || 'Tanpa nama';
|
|
document.getElementById('choroplethPopulation').innerText = `${mainValue.value.toLocaleString('id-ID')} ${mainValue.unit}`;
|
|
|
|
if (area > 0 && population > 0) {
|
|
document.getElementById('choroplethDensity').innerText = `Kepadatan: ${density.toLocaleString('id-ID', { maximumFractionDigits: 0 })} jiwa/km²`;
|
|
} else {
|
|
document.getElementById('choroplethDensity').innerText = `${mainValue.label} ditampilkan dari properti GeoJSON.`;
|
|
}
|
|
}
|
|
|
|
function clearChoroplethLayer() {
|
|
if (choroplethLayer) {
|
|
map.removeLayer(choroplethLayer);
|
|
choroplethLayer = null;
|
|
}
|
|
}
|
|
|
|
function getRingArea(ring) {
|
|
if (!Array.isArray(ring) || ring.length < 3) {
|
|
return 0;
|
|
}
|
|
|
|
let area = 0;
|
|
for (let index = 0; index < ring.length; index += 1) {
|
|
const [x1, y1] = ring[index];
|
|
const [x2, y2] = ring[(index + 1) % ring.length];
|
|
area += (x1 * y2) - (x2 * y1);
|
|
}
|
|
|
|
return Math.abs(area) / 2;
|
|
}
|
|
|
|
function getPolygonPartArea(part) {
|
|
const outerRing = Array.isArray(part) ? part[0] : null;
|
|
return getRingArea(outerRing);
|
|
}
|
|
|
|
function sanitizeChoroplethFeature(feature) {
|
|
const geometry = feature.geometry || {};
|
|
|
|
if (geometry.type !== 'MultiPolygon' || !Array.isArray(geometry.coordinates)) {
|
|
return feature;
|
|
}
|
|
|
|
const partsWithArea = geometry.coordinates
|
|
.map(part => ({ part, area: getPolygonPartArea(part) }))
|
|
.filter(entry => entry.area > 0)
|
|
.sort((left, right) => right.area - left.area);
|
|
|
|
if (partsWithArea.length <= 1) {
|
|
return feature;
|
|
}
|
|
|
|
const largestArea = partsWithArea[0].area;
|
|
const filteredParts = partsWithArea
|
|
.filter(entry => entry.area >= largestArea * 0.1)
|
|
.map(entry => entry.part);
|
|
|
|
return {
|
|
...feature,
|
|
geometry: {
|
|
...geometry,
|
|
coordinates: filteredParts.length > 0 ? filteredParts : [partsWithArea[0].part]
|
|
}
|
|
};
|
|
}
|
|
|
|
function sanitizeChoroplethGeoJson(data) {
|
|
return {
|
|
...data,
|
|
features: data.features.map(sanitizeChoroplethFeature)
|
|
};
|
|
}
|
|
|
|
async function loadChoroplethGeoJson() {
|
|
if (choroplethGeoJsonData) {
|
|
return choroplethGeoJsonData;
|
|
}
|
|
|
|
const response = await fetch(CHOROPLETH_GEOJSON_PATH, { cache: 'no-store' });
|
|
if (!response.ok) {
|
|
throw new Error(`Gagal memuat ${CHOROPLETH_GEOJSON_PATH} (HTTP ${response.status})`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features)) {
|
|
throw new Error('Format GeoJSON tidak valid, harus FeatureCollection.');
|
|
}
|
|
|
|
choroplethGeoJsonData = sanitizeChoroplethGeoJson(data);
|
|
return choroplethGeoJsonData;
|
|
}
|
|
|
|
async function buildChoroplethLayer() {
|
|
clearChoroplethLayer();
|
|
|
|
const geoJsonData = await loadChoroplethGeoJson();
|
|
|
|
choroplethLayer = L.geoJSON(geoJsonData, {
|
|
style: feature => ({
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
fillColor: getPopulationFill(Number(getChoroplethValue(feature.properties || {}).value || 0)),
|
|
fillOpacity: 0.72
|
|
}),
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties || {};
|
|
const districtName = props.name || props.NAME || 'Tanpa nama';
|
|
|
|
layer.bindTooltip(districtName, { sticky: true });
|
|
layer.on('mouseover', () => {
|
|
layer.setStyle({ weight: 3, fillOpacity: 0.86, color: '#1f2937' });
|
|
});
|
|
layer.on('mouseout', () => {
|
|
choroplethLayer.resetStyle(layer);
|
|
});
|
|
layer.on('click', () => {
|
|
const mainValue = getChoroplethValue(props);
|
|
|
|
updateChoroplethPanel(props);
|
|
layer.bindPopup(`<div class="p-1"><strong>${districtName}</strong><br>${mainValue.label}: ${mainValue.value.toLocaleString('id-ID')} ${mainValue.unit}</div>`).openPopup();
|
|
});
|
|
}
|
|
}).addTo(map);
|
|
|
|
map.fitBounds(choroplethLayer.getBounds(), { padding: [20, 20] });
|
|
resetChoroplethPanel();
|
|
}
|
|
|
|
async function toggleChoroplethMode(enabled) {
|
|
const panel = document.getElementById('choroplethPanel');
|
|
|
|
panel.classList.toggle('hidden', !enabled);
|
|
if (enabled) {
|
|
try {
|
|
await buildChoroplethLayer();
|
|
} catch (error) {
|
|
showMessage(error.message || 'Gagal memuat peta choropleth', 'bg-red-600 text-white');
|
|
panel.classList.add('hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
clearChoroplethLayer();
|
|
}
|
|
|
|
function getFeatureStatusColor(type, status) {
|
|
const options = FEATURE_STATUS_OPTIONS[type] || [];
|
|
const found = options.find(item => item.value === status);
|
|
return found ? found.color : '#0f172a';
|
|
}
|
|
|
|
function formatMeters(value) {
|
|
return `${value.toFixed(2)} m`;
|
|
}
|
|
|
|
function formatSquareMeters(value) {
|
|
return `${value.toFixed(2)} m²`;
|
|
}
|
|
|
|
function parseDateOnly(value) {
|
|
if (!value) return null;
|
|
const normalized = String(value).replace(' ', 'T');
|
|
const date = new Date(normalized);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return date;
|
|
}
|
|
|
|
function formatInputDate(value) {
|
|
const date = parseDateOnly(value);
|
|
if (!date) return 'Tanggal tidak tersedia';
|
|
|
|
return new Intl.DateTimeFormat('id-ID', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
}).format(date);
|
|
}
|
|
|
|
function getRecordDateKey(record) {
|
|
const date = parseDateOnly(record && record.created_at);
|
|
if (!date) return null;
|
|
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function enforceRegistryDateRange(showFeedback = false) {
|
|
const fromEl = document.getElementById('inputDateFrom');
|
|
const toEl = document.getElementById('inputDateTo');
|
|
const from = fromEl.value;
|
|
const to = toEl.value;
|
|
|
|
// Batasi tanggal akhir agar tidak boleh sebelum tanggal awal.
|
|
toEl.min = from || '';
|
|
|
|
if (from && to && to < from) {
|
|
toEl.value = from;
|
|
if (showFeedback) {
|
|
showMessage('Tanggal akhir disesuaikan ke tanggal awal', 'bg-slate-700 text-white');
|
|
}
|
|
}
|
|
}
|
|
|
|
function getRegistryFilters() {
|
|
return {
|
|
search: document.getElementById('inputSearchQuery').value.toLowerCase().trim(),
|
|
type: document.getElementById('inputTypeFilter').value,
|
|
from: document.getElementById('inputDateFrom').value,
|
|
to: document.getElementById('inputDateTo').value
|
|
};
|
|
}
|
|
|
|
function recordMatchesFilters(record, filters) {
|
|
// Filter berdasarkan pencarian
|
|
if (filters.search) {
|
|
const searchQuery = filters.search;
|
|
const matchesSearch =
|
|
(record.title || '').toLowerCase().includes(searchQuery) ||
|
|
(record.subtitle || '').toLowerCase().includes(searchQuery) ||
|
|
(record.status || '').toLowerCase().includes(searchQuery) ||
|
|
String(record.id).includes(searchQuery);
|
|
|
|
if (!matchesSearch) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (filters.type !== 'all' && record.type !== filters.type) {
|
|
return false;
|
|
}
|
|
|
|
const dateKey = getRecordDateKey(record);
|
|
if (!filters.from && !filters.to) {
|
|
return true;
|
|
}
|
|
|
|
if (!dateKey) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.from && dateKey < filters.from) {
|
|
return false;
|
|
}
|
|
|
|
if (filters.to && dateKey > filters.to) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function buildRegistryRecords() {
|
|
const pointRecords = locationRecords.map(loc => ({
|
|
id: Number(loc.id),
|
|
type: 'point',
|
|
title: loc.nama || 'Tanpa nama',
|
|
subtitle: loc.nomor || '-',
|
|
status: loc.buka_24jam || '-',
|
|
created_at: loc.created_at || null
|
|
}));
|
|
|
|
const spatialRecords = featureRecords.map(feature => ({
|
|
id: Number(feature.id),
|
|
type: feature.type,
|
|
title: feature.nama_objek || 'Tanpa nama',
|
|
subtitle: feature.kategori || '-',
|
|
status: feature.status_objek || '-',
|
|
created_at: feature.created_at || null
|
|
}));
|
|
|
|
return [...pointRecords, ...spatialRecords].sort((left, right) => {
|
|
const leftDate = parseDateOnly(left.created_at);
|
|
const rightDate = parseDateOnly(right.created_at);
|
|
|
|
if (!leftDate && !rightDate) return right.id - left.id;
|
|
if (!leftDate) return 1;
|
|
if (!rightDate) return -1;
|
|
return rightDate - leftDate;
|
|
});
|
|
}
|
|
|
|
function getRegistryTypeLabel(type) {
|
|
if (type === 'point') return 'Poin';
|
|
if (type === 'polyline') return 'Polyline';
|
|
if (type === 'polygon') return 'Polygon';
|
|
return 'Data';
|
|
}
|
|
|
|
function getRegistryTypeStyle(type) {
|
|
if (type === 'point') return 'bg-emerald-100 text-emerald-700';
|
|
if (type === 'polyline') return 'bg-indigo-100 text-indigo-700';
|
|
if (type === 'polygon') return 'bg-amber-100 text-amber-700';
|
|
return 'bg-slate-100 text-slate-700';
|
|
}
|
|
|
|
function focusRegistryItem(type, id) {
|
|
if (type === 'point') {
|
|
const marker = locationLayerMap[id];
|
|
if (!marker) return;
|
|
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
|
|
marker.openPopup();
|
|
return;
|
|
}
|
|
|
|
const layer = featureLayerMap[id];
|
|
if (!layer) return;
|
|
if (layer.getBounds) {
|
|
map.fitBounds(layer.getBounds(), { padding: [40, 40] });
|
|
}
|
|
layer.openPopup();
|
|
}
|
|
|
|
function renderInputRegistry() {
|
|
const listEl = document.getElementById('inputRegistryList');
|
|
const countEl = document.getElementById('inputRegistryCount');
|
|
const filters = getRegistryFilters();
|
|
const records = buildRegistryRecords().filter(record => recordMatchesFilters(record, filters));
|
|
|
|
countEl.innerText = records.length;
|
|
|
|
if (!records.length) {
|
|
listEl.innerHTML = `
|
|
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
|
Tidak ada input yang sesuai dengan filter.
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = records.map(record => {
|
|
const typeLabel = getRegistryTypeLabel(record.type);
|
|
const typeClass = getRegistryTypeStyle(record.type);
|
|
const dateLabel = formatInputDate(record.created_at);
|
|
const measure = record.type === 'point'
|
|
? `Telp: ${record.subtitle}`
|
|
: `Kategori: ${record.subtitle}`;
|
|
const statusLabel = record.type === 'point'
|
|
? `24 Jam: ${record.status}`
|
|
: `Status: ${record.status}`;
|
|
|
|
return `
|
|
<button type="button" onclick="focusRegistryItem('${record.type}', ${record.id})" class="registry-item w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-left shadow-sm">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="registry-chip ${typeClass}">${typeLabel}</span>
|
|
<span class="text-[10px] font-bold uppercase tracking-widest text-slate-400">ID ${record.id}</span>
|
|
</div>
|
|
<h3 class="mt-1 truncate text-sm font-semibold text-slate-800">${record.title}</h3>
|
|
<p class="mt-1 text-[11px] text-slate-500">${measure}</p>
|
|
<p class="mt-0.5 text-[11px] text-slate-500">${statusLabel}</p>
|
|
</div>
|
|
<div class="shrink-0 text-right text-[10px] font-bold uppercase tracking-widest text-slate-400">
|
|
<div>Tanggal</div>
|
|
<div class="mt-1 max-w-[110px] normal-case tracking-normal text-slate-600">${dateLabel}</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function toggleInputRegistry(forceState = null) {
|
|
const panel = document.getElementById('inputRegistryPanel');
|
|
const shouldOpen = forceState === null ? panel.classList.contains('hidden') : !!forceState;
|
|
|
|
panel.classList.toggle('hidden', !shouldOpen);
|
|
if (shouldOpen) {
|
|
renderInputRegistry();
|
|
}
|
|
}
|
|
|
|
function getFeatureOptions(type) {
|
|
return FEATURE_STATUS_OPTIONS[type] || [];
|
|
}
|
|
|
|
function setSpatialType(type) {
|
|
document.getElementById('featureType').value = type;
|
|
const statusSelect = document.getElementById('featureStatus');
|
|
const drawStatus = document.getElementById('drawStatus');
|
|
|
|
drawStatus.innerHTML = '';
|
|
getFeatureOptions(type).forEach(option => {
|
|
const el = document.createElement('option');
|
|
el.value = option.value;
|
|
el.textContent = option.label;
|
|
drawStatus.appendChild(el);
|
|
});
|
|
|
|
if (!drawStatus.value) drawStatus.selectedIndex = 0;
|
|
|
|
statusSelect.innerHTML = '';
|
|
Array.from(drawStatus.options).forEach(option => {
|
|
statusSelect.appendChild(option.cloneNode(true));
|
|
});
|
|
|
|
statusSelect.value = drawStatus.value;
|
|
currentDrawMode = type;
|
|
updateDrawControl(type);
|
|
updateFeatureMeasurePreview();
|
|
}
|
|
|
|
function setDataMode(mode) {
|
|
appMode = mode;
|
|
const hint = document.getElementById('modeHint');
|
|
const statusWrap = document.getElementById('statusControlWrap');
|
|
const measureWrap = document.getElementById('measureControlWrap');
|
|
const runActionBtn = document.getElementById('runActionBtn');
|
|
const clearDraftBtn = document.getElementById('clearDraftBtn');
|
|
const spatialControls = document.getElementById('spatialControls');
|
|
const modeButtons = {
|
|
view: document.getElementById('modeViewBtn'),
|
|
point: document.getElementById('modePointBtn'),
|
|
polyline: document.getElementById('modePolylineBtn'),
|
|
polygon: document.getElementById('modePolygonBtn'),
|
|
choropleth: document.getElementById('modeChoroplethBtn')
|
|
};
|
|
|
|
Object.values(modeButtons).forEach(button => button.classList.remove('active'));
|
|
if (modeButtons[mode]) {
|
|
modeButtons[mode].classList.add('active');
|
|
}
|
|
|
|
toggleChoroplethMode(mode === 'choropleth');
|
|
|
|
if (mode === 'view') {
|
|
hint.innerText = 'Mode default: hanya melihat peta dan atribut data yang sudah dibuat.';
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Mode Lihat';
|
|
runActionBtn.disabled = true;
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
if (mode === 'choropleth') {
|
|
hint.innerText = 'Mode choropleth aktif: klik kecamatan untuk melihat populasi.';
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Choropleth Aktif';
|
|
runActionBtn.disabled = true;
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
runActionBtn.disabled = false;
|
|
clearDraftBtn.disabled = false;
|
|
|
|
if (mode === 'point') {
|
|
hint.innerText = 'Mode point aktif: klik peta untuk menambah SPBU baru.';
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Klik Peta';
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
spatialControls.classList.remove('hidden');
|
|
hint.innerText = mode === 'polyline'
|
|
? 'Mode polyline aktif: gambar jalan lalu isi nama pada modal.'
|
|
: 'Mode polygon aktif: gambar parsil lalu isi nama pada modal.';
|
|
runActionBtn.innerText = mode === 'polyline' ? 'Gambar Jalan' : 'Gambar Polygon';
|
|
setSpatialType(mode);
|
|
}
|
|
|
|
function updateFeatureMeasurePreview() {
|
|
const type = document.getElementById('featureType').value;
|
|
const layer = pendingShape;
|
|
const measureField = document.getElementById('modeMeasure');
|
|
|
|
if (!layer) {
|
|
measureField.value = type === 'polyline' ? 'Panjang otomatis setelah gambar' : 'Luas otomatis setelah gambar';
|
|
return;
|
|
}
|
|
|
|
if (type === 'polyline') {
|
|
measureField.value = formatMeters(calculatePolylineLength(layer));
|
|
} else {
|
|
measureField.value = formatSquareMeters(calculatePolygonArea(layer));
|
|
}
|
|
}
|
|
|
|
function updateDrawControl(type) {
|
|
if (drawControl) {
|
|
map.removeControl(drawControl);
|
|
drawControl = null;
|
|
}
|
|
|
|
if (type !== 'polyline' && type !== 'polygon') {
|
|
return;
|
|
}
|
|
|
|
drawControl = new L.Control.Draw({
|
|
edit: false,
|
|
draw: {
|
|
polygon: type === 'polygon' ? {
|
|
allowIntersection: false,
|
|
showArea: true,
|
|
shapeOptions: {
|
|
color: getFeatureStatusColor('polygon', document.getElementById('featureStatus').value),
|
|
weight: 3
|
|
}
|
|
} : false,
|
|
polyline: type === 'polyline' ? {
|
|
shapeOptions: {
|
|
color: getFeatureStatusColor('polyline', document.getElementById('featureStatus').value),
|
|
weight: 5
|
|
}
|
|
} : false,
|
|
rectangle: false,
|
|
circle: false,
|
|
circlemarker: false,
|
|
marker: false
|
|
}
|
|
});
|
|
|
|
map.addControl(drawControl);
|
|
}
|
|
|
|
function calculatePolylineLength(layer) {
|
|
const latlngs = layer.getLatLngs();
|
|
const points = Array.isArray(latlngs[0]) ? latlngs.flat() : latlngs;
|
|
|
|
let total = 0;
|
|
for (let i = 1; i < points.length; i += 1) {
|
|
total += map.distance(points[i - 1], points[i]);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function calculatePolygonArea(layer) {
|
|
const latlngs = layer.getLatLngs();
|
|
const rings = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
|
|
if (L.GeometryUtil && typeof L.GeometryUtil.geodesicArea === 'function') {
|
|
return L.GeometryUtil.geodesicArea(rings);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function geometryToCoordinates(layer) {
|
|
const latlngs = layer.getLatLngs();
|
|
const type = document.getElementById('featureType').value;
|
|
|
|
if (type === 'polyline') {
|
|
return latlngs.map(point => [point.lat, point.lng]);
|
|
}
|
|
|
|
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
|
|
return [ring.map(point => [point.lat, point.lng])];
|
|
}
|
|
|
|
function getFeaturePopup(feature) {
|
|
const color = getFeatureStatusColor(feature.type, feature.status_objek);
|
|
const measureText = feature.type === 'polyline'
|
|
? formatMeters(Number(feature.nilai_ukur))
|
|
: formatSquareMeters(Number(feature.nilai_ukur));
|
|
|
|
return `
|
|
<div class="w-full p-4">
|
|
<h3 class="text-lg font-bold text-slate-800">${feature.nama_objek}</h3>
|
|
<p class="mt-1 text-sm text-slate-600">Kategori: ${feature.kategori}</p>
|
|
<p class="mt-1 text-sm text-slate-600">Status: <span class="font-semibold" style="color: ${color}">${feature.status_objek}</span></p>
|
|
<p class="mt-1 text-sm text-slate-600">${feature.type === 'polyline' ? 'Panjang' : 'Luas'}: <span class="font-semibold">${measureText}</span></p>
|
|
<div class="mt-4 grid grid-cols-2 gap-2 border-t pt-3">
|
|
<button onclick="openFeatureEditor(${feature.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit Atribut</button>
|
|
<button onclick="toggleFeatureGeometryEditing(${feature.id})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Edit Geometri</button>
|
|
<button onclick="saveFeatureGeometry(${feature.id})" class="rounded-lg bg-amber-50 px-3 py-2 text-xs font-bold text-amber-700 hover:bg-amber-100">Simpan Geometri</button>
|
|
<button onclick="deleteFeature(${feature.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function buildFeatureStatusOptions(type) {
|
|
return getFeatureOptions(type).map(option => `<option value="${option.value}">${option.label}</option>`).join('');
|
|
}
|
|
|
|
function geometryToLayer(type, geometry) {
|
|
if (type === 'polyline') {
|
|
return L.polyline(geometry);
|
|
}
|
|
|
|
return L.polygon(geometry);
|
|
}
|
|
|
|
function getFeatureGeometry(layer) {
|
|
const type = layer instanceof L.Polygon ? 'polygon' : 'polyline';
|
|
const latlngs = layer.getLatLngs();
|
|
|
|
if (type === 'polyline') {
|
|
return latlngs.map(point => [point.lat, point.lng]);
|
|
}
|
|
|
|
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
|
|
return [ring.map(point => [point.lat, point.lng])];
|
|
}
|
|
|
|
function updateFeatureLayerStyle(layer, type, status) {
|
|
const color = getFeatureStatusColor(type, status);
|
|
layer.setStyle(type === 'polyline'
|
|
? { color, weight: 5 }
|
|
: { color, fillColor: color, fillOpacity: 0.25, weight: 3 }
|
|
);
|
|
}
|
|
|
|
function getFeatureLayerById(id) {
|
|
return featureLayerMap[id] || null;
|
|
}
|
|
|
|
function clearFeatureInput() {
|
|
document.getElementById('modeMeasure').value = '';
|
|
document.getElementById('newFeatureName').value = '';
|
|
document.getElementById('newFeatureMeasure').value = '';
|
|
document.getElementById('newFeatureKategori').value = '';
|
|
document.getElementById('newFeatureTypeLabel').value = '';
|
|
document.getElementById('newFeatureStatus').innerHTML = '';
|
|
pendingShape = null;
|
|
pendingNewFeatureLayer = null;
|
|
if (appMode === 'polyline' || appMode === 'polygon') {
|
|
setSpatialType(appMode);
|
|
}
|
|
}
|
|
|
|
function openNewFeatureModal(layer) {
|
|
const type = document.getElementById('featureType').value;
|
|
const status = document.getElementById('featureStatus').value;
|
|
const modal = document.getElementById('newFeatureModal');
|
|
|
|
pendingNewFeatureLayer = layer;
|
|
document.getElementById('newFeatureKategori').value = type === 'polyline' ? 'Jalan' : 'Tanah';
|
|
document.getElementById('newFeatureTypeLabel').value = type === 'polyline' ? 'Jalan (Polyline)' : 'Parsil Tanah (Polygon)';
|
|
document.getElementById('newFeatureStatus').innerHTML = buildFeatureStatusOptions(type);
|
|
document.getElementById('newFeatureStatus').value = status;
|
|
document.getElementById('newFeatureMeasure').value = type === 'polyline'
|
|
? formatMeters(calculatePolylineLength(layer))
|
|
: formatSquareMeters(calculatePolygonArea(layer));
|
|
document.getElementById('newFeatureName').value = '';
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
suppressAutoReload = true;
|
|
}
|
|
|
|
function closeNewFeatureModal(removeLayer = true) {
|
|
const modal = document.getElementById('newFeatureModal');
|
|
|
|
if (removeLayer && pendingNewFeatureLayer) {
|
|
featureLayer.removeLayer(pendingNewFeatureLayer);
|
|
}
|
|
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
clearFeatureInput();
|
|
}
|
|
|
|
function loadFeatures(force = false) {
|
|
if (!force && (document.hidden || suppressAutoReload || isFeatureDrawing || isFeatureSaving)) return;
|
|
|
|
fetch('get_features.php')
|
|
.then(res => {
|
|
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
|
|
return res.text();
|
|
})
|
|
.then(text => {
|
|
const data = JSON.parse(text);
|
|
if (!Array.isArray(data)) {
|
|
if (data && data.status === 'error') {
|
|
throw new Error(data.message || 'Terjadi error pada server');
|
|
}
|
|
throw new Error('Format data fitur tidak valid');
|
|
}
|
|
|
|
featureRecords = data;
|
|
featureLayer.clearLayers();
|
|
featureLayerMap = {};
|
|
|
|
data.forEach(feature => {
|
|
const color = getFeatureStatusColor(feature.type, feature.status_objek);
|
|
const geometry = feature.geometry_data;
|
|
|
|
if (feature.type === 'polyline') {
|
|
const layer = L.polyline(geometry, { color, weight: 5 });
|
|
layer.featureData = feature;
|
|
layer.featureType = 'polyline';
|
|
featureLayerMap[feature.id] = layer;
|
|
featureLayer.addLayer(layer);
|
|
layer.bindPopup(getFeaturePopup(feature));
|
|
} else if (feature.type === 'polygon') {
|
|
const layer = L.polygon(geometry, { color, fillColor: color, fillOpacity: 0.25, weight: 3 });
|
|
layer.featureData = feature;
|
|
layer.featureType = 'polygon';
|
|
featureLayerMap[feature.id] = layer;
|
|
featureLayer.addLayer(layer);
|
|
layer.bindPopup(getFeaturePopup(feature));
|
|
}
|
|
});
|
|
renderInputRegistry();
|
|
})
|
|
.catch(err => {
|
|
console.error('Gagal memuat fitur spasial:', err);
|
|
if (force) {
|
|
showMessage('Gagal memuat data jalan/parsil', 'bg-red-600 text-white');
|
|
}
|
|
});
|
|
}
|
|
|
|
function saveFeature(layer) {
|
|
const type = document.getElementById('featureType').value;
|
|
const nama = document.getElementById('newFeatureName').value.trim();
|
|
const status = document.getElementById('featureStatus').value;
|
|
const kategori = type === 'polyline' ? 'Jalan' : 'Tanah';
|
|
const nilai = type === 'polyline' ? calculatePolylineLength(layer) : calculatePolygonArea(layer);
|
|
const geometry = geometryToCoordinates(layer);
|
|
|
|
if (!nama) {
|
|
showMessage('Nama objek wajib diisi', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('nama', nama);
|
|
formData.append('kategori', kategori);
|
|
formData.append('status', status);
|
|
formData.append('type', type);
|
|
formData.append('geometry', JSON.stringify(geometry));
|
|
formData.append('nilai', nilai);
|
|
|
|
isFeatureSaving = true;
|
|
fetch('save_feature.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'save_feature.php'))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showMessage('✅ Fitur berhasil disimpan', 'bg-green-600 text-white');
|
|
loadFeatures(true);
|
|
clearFeatureInput();
|
|
closeNewFeatureModal(false);
|
|
} else {
|
|
featureLayer.removeLayer(layer);
|
|
throw new Error(data.message || 'Gagal menyimpan fitur');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
featureLayer.removeLayer(layer);
|
|
showMessage(err.message || 'Gagal menyimpan fitur', 'bg-red-600 text-white');
|
|
})
|
|
.finally(() => {
|
|
isFeatureSaving = false;
|
|
});
|
|
}
|
|
|
|
function saveNewFeatureFromModal() {
|
|
const layer = pendingNewFeatureLayer;
|
|
|
|
if (!layer) {
|
|
showMessage('Tidak ada fitur baru untuk disimpan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (!document.getElementById('newFeatureName').value.trim()) {
|
|
showMessage('Nama objek wajib diisi', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('featureStatus').value = document.getElementById('newFeatureStatus').value;
|
|
saveFeature(layer);
|
|
}
|
|
|
|
function resetSpatialDraft(type) {
|
|
const measureField = document.getElementById('modeMeasure');
|
|
measureField.value = '';
|
|
|
|
if (pendingNewFeatureLayer && document.getElementById('featureType').value === type) {
|
|
closeNewFeatureModal(true);
|
|
return;
|
|
}
|
|
|
|
pendingShape = null;
|
|
pendingNewFeatureLayer = null;
|
|
updateFeatureMeasurePreview();
|
|
showMessage(`Draft ${type === 'polyline' ? 'jalan' : 'polygon'} dibersihkan`, 'bg-slate-700 text-white');
|
|
}
|
|
|
|
function openFeatureEditor(id) {
|
|
const layer = getFeatureLayerById(id);
|
|
if (!layer || !layer.featureData) {
|
|
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (editingFeatureId && featureLayerMap[editingFeatureId] && featureLayerMap[editingFeatureId].editing) {
|
|
featureLayerMap[editingFeatureId].editing.disable();
|
|
editingFeatureId = null;
|
|
}
|
|
|
|
const feature = layer.featureData;
|
|
const modal = document.getElementById('featureModal');
|
|
document.getElementById('editFeatureId').value = feature.id;
|
|
document.getElementById('editFeatureName').value = feature.nama_objek;
|
|
document.getElementById('editFeatureKategori').value = feature.kategori;
|
|
document.getElementById('editFeatureType').value = feature.type;
|
|
document.getElementById('editFeatureStatus').innerHTML = buildFeatureStatusOptions(feature.type);
|
|
document.getElementById('editFeatureStatus').value = feature.status_objek;
|
|
document.getElementById('editFeatureMeasure').value = feature.type === 'polyline'
|
|
? formatMeters(Number(feature.nilai_ukur))
|
|
: formatSquareMeters(Number(feature.nilai_ukur));
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
suppressAutoReload = true;
|
|
}
|
|
|
|
function closeFeatureEditor() {
|
|
if (editingFeatureId && featureLayerMap[editingFeatureId] && featureLayerMap[editingFeatureId].editing) {
|
|
featureLayerMap[editingFeatureId].editing.disable();
|
|
editingFeatureId = null;
|
|
}
|
|
|
|
const modal = document.getElementById('featureModal');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
|
|
}
|
|
|
|
function collectFeatureFormData(id, layer) {
|
|
const feature = layer.featureData;
|
|
const type = feature.type;
|
|
const nama = document.getElementById('editFeatureName').value.trim();
|
|
const kategori = document.getElementById('editFeatureKategori').value.trim();
|
|
const status = document.getElementById('editFeatureStatus').value;
|
|
const geometry = JSON.stringify(getFeatureGeometry(layer));
|
|
const nilai = type === 'polyline' ? calculatePolylineLength(layer) : calculatePolygonArea(layer);
|
|
|
|
return { id, nama, kategori, status, type, geometry, nilai };
|
|
}
|
|
|
|
function saveFeatureAttributes() {
|
|
const id = Number(document.getElementById('editFeatureId').value);
|
|
const layer = getFeatureLayerById(id);
|
|
|
|
if (!layer) {
|
|
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const payload = collectFeatureFormData(id, layer);
|
|
if (!payload.nama) {
|
|
showMessage('Nama objek wajib diisi', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
Object.entries(payload).forEach(([key, value]) => formData.append(key, value));
|
|
|
|
fetch('update_feature.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'update_feature.php'))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
layer.featureData.nama_objek = payload.nama;
|
|
layer.featureData.kategori = payload.kategori;
|
|
layer.featureData.status_objek = payload.status;
|
|
layer.featureData.nilai_ukur = payload.nilai;
|
|
updateFeatureLayerStyle(layer, payload.type, payload.status);
|
|
layer.bindPopup(getFeaturePopup(layer.featureData));
|
|
document.getElementById('editFeatureMeasure').value = payload.type === 'polyline'
|
|
? formatMeters(payload.nilai)
|
|
: formatSquareMeters(payload.nilai);
|
|
showMessage('✅ Atribut fitur diperbarui', 'bg-green-600 text-white');
|
|
loadFeatures(true);
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menyimpan perubahan');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menyimpan perubahan', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function toggleFeatureGeometryEditing(id) {
|
|
const layer = getFeatureLayerById(id);
|
|
if (!layer) {
|
|
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (!layer.editing || typeof layer.editing.enable !== 'function') {
|
|
showMessage('Edit geometri tidak didukung untuk layer ini', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (editingFeatureId === id) {
|
|
layer.editing.disable();
|
|
editingFeatureId = null;
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
|
|
showMessage('Mode edit geometri dimatikan', 'bg-slate-700 text-white');
|
|
return;
|
|
}
|
|
|
|
if (editingFeatureId && featureLayerMap[editingFeatureId] && featureLayerMap[editingFeatureId].editing) {
|
|
featureLayerMap[editingFeatureId].editing.disable();
|
|
}
|
|
|
|
layer.editing.enable();
|
|
editingFeatureId = id;
|
|
suppressAutoReload = true;
|
|
showMessage('Geser titik vertex lalu klik Simpan Geometri', 'bg-emerald-600 text-white');
|
|
}
|
|
|
|
function saveFeatureGeometry(id) {
|
|
const layer = getFeatureLayerById(id);
|
|
if (!layer || !layer.featureData) {
|
|
showMessage('Fitur tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (layer.editing && typeof layer.editing.disable === 'function') {
|
|
layer.editing.disable();
|
|
}
|
|
editingFeatureId = null;
|
|
|
|
const payload = collectFeatureFormData(id, layer);
|
|
const formData = new FormData();
|
|
Object.entries(payload).forEach(([key, value]) => formData.append(key, value));
|
|
|
|
fetch('update_feature.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'update_feature.php'))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
layer.featureData.geometry_data = JSON.parse(payload.geometry);
|
|
layer.featureData.nilai_ukur = payload.nilai;
|
|
updateFeatureLayerStyle(layer, payload.type, payload.status);
|
|
layer.setLatLngs(layer.featureData.geometry_data);
|
|
layer.bindPopup(getFeaturePopup(layer.featureData));
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
|
|
document.getElementById('editFeatureMeasure').value = payload.type === 'polyline'
|
|
? formatMeters(payload.nilai)
|
|
: formatSquareMeters(payload.nilai);
|
|
showMessage('✅ Geometri fitur diperbarui', 'bg-green-600 text-white');
|
|
loadFeatures(true);
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menyimpan geometri');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menyimpan geometri', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function deleteFeature(id) {
|
|
if (!confirm('Hapus fitur ini dari database?')) return;
|
|
|
|
const layer = getFeatureLayerById(id);
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
|
|
fetch('delete_feature.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'delete_feature.php'))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
if (layer) featureLayer.removeLayer(layer);
|
|
delete featureLayerMap[id];
|
|
featureRecords = featureRecords.filter(feature => Number(feature.id) !== Number(id));
|
|
renderInputRegistry();
|
|
if (editingFeatureId === id) editingFeatureId = null;
|
|
closeFeatureEditor();
|
|
showMessage('🗑️ Fitur berhasil dihapus', 'bg-orange-600 text-white');
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menghapus fitur');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menghapus fitur', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function startDraw() {
|
|
const type = document.getElementById('featureType').value;
|
|
const status = document.getElementById('featureStatus').value;
|
|
|
|
if (appMode !== 'polyline' && appMode !== 'polygon') {
|
|
showMessage('Pilih mode Polyline atau Polygon dulu', 'bg-slate-700 text-white');
|
|
return;
|
|
}
|
|
|
|
if (!status) {
|
|
showMessage('Pilih status terlebih dahulu', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const shapeOptions = type === 'polyline'
|
|
? {
|
|
color: getFeatureStatusColor('polyline', status),
|
|
weight: 5
|
|
}
|
|
: {
|
|
color: getFeatureStatusColor('polygon', status),
|
|
fillColor: getFeatureStatusColor('polygon', status),
|
|
fillOpacity: 0.25,
|
|
weight: 3
|
|
};
|
|
|
|
const toolbar = document.querySelector(type === 'polyline' ? '.leaflet-draw-draw-polyline' : '.leaflet-draw-draw-polygon');
|
|
if (toolbar) {
|
|
isFeatureDrawing = true;
|
|
suppressAutoReload = true;
|
|
map.closePopup();
|
|
toolbar.click();
|
|
return;
|
|
}
|
|
|
|
// Fallback: aktifkan handler draw langsung jika tombol toolbar tidak ditemukan.
|
|
if (activeDrawHandler && typeof activeDrawHandler.disable === 'function') {
|
|
activeDrawHandler.disable();
|
|
}
|
|
|
|
activeDrawHandler = type === 'polyline'
|
|
? new L.Draw.Polyline(map, { shapeOptions })
|
|
: new L.Draw.Polygon(map, { allowIntersection: false, showArea: true, shapeOptions });
|
|
|
|
isFeatureDrawing = true;
|
|
suppressAutoReload = true;
|
|
map.closePopup();
|
|
activeDrawHandler.enable();
|
|
|
|
showMessage('Mode gambar aktif, klik pada peta untuk mulai menggambar', 'bg-indigo-600 text-white');
|
|
}
|
|
|
|
map.on(L.Draw.Event.DRAWSTOP, function() {
|
|
if (activeDrawHandler && typeof activeDrawHandler.disable === 'function') {
|
|
activeDrawHandler.disable();
|
|
activeDrawHandler = null;
|
|
}
|
|
isFeatureDrawing = false;
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
|
|
});
|
|
|
|
map.on(L.Draw.Event.CREATED, function(event) {
|
|
const { layerType, layer } = event;
|
|
const activeType = document.getElementById('featureType').value;
|
|
|
|
if ((activeType === 'polyline' && layerType !== 'polyline') || (activeType === 'polygon' && layerType !== 'polygon')) {
|
|
return;
|
|
}
|
|
|
|
const color = getFeatureStatusColor(activeType, document.getElementById('featureStatus').value);
|
|
if (layerType === 'polyline') {
|
|
layer.setStyle({ color, weight: 5 });
|
|
} else {
|
|
layer.setStyle({ color, fillColor: color, fillOpacity: 0.25, weight: 3 });
|
|
}
|
|
|
|
pendingShape = layer;
|
|
featureLayer.addLayer(layer);
|
|
updateFeatureMeasurePreview();
|
|
isFeatureDrawing = false;
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
|
|
openNewFeatureModal(layer);
|
|
});
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
// Fungsi membuat ikon SVG berwarna
|
|
function createColoredIcon(color) {
|
|
return L.divIcon({
|
|
className: "custom-marker",
|
|
html: `<svg width="30" height="42" viewBox="0 0 24 24" fill="${color}" stroke="white" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M12 0C7.58 0 4 3.58 4 8c0 5.25 7 13 8 13s8-7.75 8-13c0-4.42-3.58-8-8-8zm0 11c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>
|
|
</svg>`,
|
|
iconSize: [30, 42],
|
|
iconAnchor: [15, 42],
|
|
popupAnchor: [0, -40]
|
|
});
|
|
}
|
|
|
|
function setConnectionStatus(message, colorClass) {
|
|
const statusEl = document.getElementById('connectionStatus');
|
|
statusEl.innerText = message;
|
|
statusEl.className = `mt-2 text-[10px] font-bold uppercase tracking-wider ${colorClass}`;
|
|
}
|
|
|
|
function clearAllMarkers() {
|
|
map.eachLayer((layer) => {
|
|
if (layer instanceof L.Marker) map.removeLayer(layer);
|
|
});
|
|
locationLayerMap = {};
|
|
}
|
|
|
|
function createSnapshotHash(locations) {
|
|
const normalized = locations
|
|
.map(loc => ({
|
|
id: Number(loc.id),
|
|
nama: loc.nama,
|
|
nomor: loc.nomor,
|
|
buka_24jam: loc.buka_24jam,
|
|
latitude: Number(loc.latitude),
|
|
longitude: Number(loc.longitude)
|
|
}))
|
|
.sort((a, b) => a.id - b.id);
|
|
|
|
return JSON.stringify(normalized);
|
|
}
|
|
|
|
// Memuat semua data dari database
|
|
function loadMarkers(options = {}) {
|
|
const { silent = false, force = false } = options;
|
|
|
|
if (isSyncing) return;
|
|
if (!force && (document.hidden || suppressAutoReload)) return;
|
|
isSyncing = true;
|
|
|
|
if (!silent) {
|
|
setConnectionStatus("🔄 Memuat data...", "text-blue-500");
|
|
}
|
|
|
|
fetch('get_location.php')
|
|
.then(res => {
|
|
if (!res.ok) throw new Error('HTTP Error: ' + res.status);
|
|
return res.text(); // Ambil teks dulu untuk debug jika ada error PHP
|
|
})
|
|
.then(text => {
|
|
try {
|
|
const data = JSON.parse(text);
|
|
if (!Array.isArray(data)) {
|
|
if (data && data.status === 'error') {
|
|
throw new Error(data.message || 'Terjadi error pada server');
|
|
}
|
|
throw new Error('Format data bukan array');
|
|
}
|
|
|
|
locationRecords = data;
|
|
const currentHash = createSnapshotHash(data);
|
|
if (!force && currentHash === latestSnapshotHash) {
|
|
if (!silent) {
|
|
setConnectionStatus("✅ Terhubung ke database", "text-green-600");
|
|
}
|
|
return;
|
|
}
|
|
|
|
latestSnapshotHash = currentHash;
|
|
clearAllMarkers();
|
|
document.getElementById('totalCount').innerText = data.length;
|
|
|
|
data.forEach(loc => {
|
|
// Konversi lat/lng ke float (MySQL Decimal sering dikirim sebagai string)
|
|
const lat = parseFloat(loc.latitude);
|
|
const lng = parseFloat(loc.longitude);
|
|
|
|
if (isNaN(lat) || isNaN(lng)) return;
|
|
|
|
const color = loc.buka_24jam === 'Ya' ? '#16a34a' : '#dc2626';
|
|
|
|
const marker = L.marker([lat, lng], {
|
|
icon: createColoredIcon(color),
|
|
draggable: true
|
|
}).addTo(map);
|
|
locationLayerMap[loc.id] = marker;
|
|
marker.locationData = loc;
|
|
|
|
// Event Drag & Drop untuk Update Lokasi
|
|
marker.on('dragend', function(event) {
|
|
const newPos = event.target.getLatLng();
|
|
updateMarkerLocation(loc.id, newPos.lat, newPos.lng);
|
|
});
|
|
|
|
marker.bindPopup(getPopupContent(loc));
|
|
});
|
|
|
|
renderInputRegistry();
|
|
|
|
setConnectionStatus("✅ Terhubung ke database", "text-green-600");
|
|
} catch (e) {
|
|
console.error("JSON Parse Error:", e, "Raw text:", text);
|
|
throw new Error('Gagal membaca data dari server (JSON Error)');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Fetch Error:", err);
|
|
setConnectionStatus(`❌ ${err.message || 'Gagal terhubung ke database'}`, "text-red-600");
|
|
|
|
// Jika di Canvas Preview, berikan info spesifik
|
|
if (window.location.hostname === "") {
|
|
console.warn("Info: Jalankan proyek ini di Laragon (http://localhost/...) agar PHP dapat berjalan.");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
isSyncing = false;
|
|
});
|
|
}
|
|
|
|
// HTML untuk isi Popup (View & Edit Mode)
|
|
function getPopupContent(loc) {
|
|
const statusClass = loc.buka_24jam === 'Ya' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
|
|
|
|
return `
|
|
<div class="w-full">
|
|
<!-- Mode Tampilan -->
|
|
<div id="view-mode-${loc.id}" class="p-4">
|
|
<h3 class="font-bold text-lg text-slate-800 leading-tight">${loc.nama}</h3>
|
|
<p class="text-sm text-slate-600 mt-1 italic">Telp: ${loc.nomor}</p>
|
|
<div class="mt-2">
|
|
<span class="inline-block px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide ${statusClass}">
|
|
24 Jam: ${loc.buka_24jam}
|
|
</span>
|
|
</div>
|
|
<div class="mt-4 flex gap-2 border-t pt-3">
|
|
<button onclick="toggleEdit(${loc.id})" class="flex-1 bg-indigo-50 text-indigo-600 py-2 rounded text-xs font-bold hover:bg-indigo-100 transition">Edit</button>
|
|
<button onclick="deleteData(${loc.id})" class="flex-1 bg-red-50 text-red-600 py-2 rounded text-xs font-bold hover:bg-red-100 transition">Hapus</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mode Edit -->
|
|
<div id="edit-mode-${loc.id}" class="hidden p-4 bg-slate-50">
|
|
<h4 class="font-bold text-[10px] text-slate-400 mb-3 uppercase tracking-widest border-b pb-1">Update Informasi</h4>
|
|
<div class="space-y-3">
|
|
<input type="text" id="edit_nama_${loc.id}" value="${loc.nama}" class="w-full border p-2 text-sm rounded outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
|
|
<input type="text" id="edit_nomor_${loc.id}" value="${loc.nomor}" class="w-full border p-2 text-sm rounded outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
|
|
<select id="edit_status_${loc.id}" class="w-full border p-2 text-sm rounded outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
|
|
<option value="Ya" ${loc.buka_24jam === 'Ya' ? 'selected' : ''}>Buka 24 Jam</option>
|
|
<option value="Tidak" ${loc.buka_24jam === 'Tidak' ? 'selected' : ''}>Tidak</option>
|
|
</select>
|
|
<div class="flex gap-2 pt-1">
|
|
<button onclick="saveEdit(${loc.id})" class="flex-1 bg-green-600 text-white py-2 rounded text-xs font-bold shadow-md hover:bg-green-700 transition">Simpan</button>
|
|
<button onclick="toggleEdit(${loc.id})" class="flex-1 bg-slate-300 text-slate-700 py-2 rounded text-xs font-bold hover:bg-slate-400 transition">Batal</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Fungsi Update Koordinat (Drag)
|
|
function updateMarkerLocation(id, lat, lng) {
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
formData.append('lat', lat);
|
|
formData.append('lng', lng);
|
|
|
|
fetch('update_coords.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'update_coords.php'))
|
|
.then(data => {
|
|
if(data.status === 'success') showMessage("📍 Lokasi diperbarui!", "bg-green-600 text-white");
|
|
})
|
|
.catch(err => showMessage("Gagal simpan lokasi", "bg-red-600 text-white"));
|
|
}
|
|
|
|
// Fungsi Hapus Data
|
|
function deleteData(id) {
|
|
if(!confirm("Hapus data ini dari database?")) return;
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
|
|
fetch('delete_location.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'delete_location.php'))
|
|
.then(data => {
|
|
if(data.status === 'success') {
|
|
showMessage("🗑️ Data berhasil dihapus", "bg-orange-600 text-white");
|
|
loadMarkers({ force: true });
|
|
}
|
|
})
|
|
.catch(err => showMessage("Gagal hapus data", "bg-red-600 text-white"));
|
|
}
|
|
|
|
function toggleEdit(id) {
|
|
document.getElementById(`view-mode-${id}`).classList.toggle('hidden');
|
|
document.getElementById(`edit-mode-${id}`).classList.toggle('hidden');
|
|
|
|
// Tahan auto refresh saat ada popup edit terbuka agar input tidak tertimpa.
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)');
|
|
}
|
|
|
|
// Fungsi Update Atribut
|
|
function saveEdit(id) {
|
|
const nama = document.getElementById(`edit_nama_${id}`).value;
|
|
const nomor = document.getElementById(`edit_nomor_${id}`).value;
|
|
const status = document.getElementById(`edit_status_${id}`).value;
|
|
|
|
if(!nama || !nomor) return alert("Field tidak boleh kosong!");
|
|
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
formData.append('nama', nama);
|
|
formData.append('nomor', nomor);
|
|
formData.append('status', status);
|
|
|
|
fetch('update_details.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'update_details.php'))
|
|
.then(data => {
|
|
if(data.status === 'success') {
|
|
suppressAutoReload = false;
|
|
showMessage("✅ Data berhasil diperbarui", "bg-indigo-600 text-white");
|
|
loadMarkers({ force: true });
|
|
}
|
|
})
|
|
.catch(err => showMessage("Gagal update data", "bg-red-600 text-white"));
|
|
}
|
|
|
|
// Klik Peta untuk Input Baru
|
|
map.on('click', function(e) {
|
|
if (isFeatureDrawing) return;
|
|
if (appMode !== 'point') return;
|
|
|
|
const lat = e.latlng.lat;
|
|
const lng = e.latlng.lng;
|
|
const content = `
|
|
<div class="p-4 w-full">
|
|
<h3 class="font-bold text-indigo-700 border-b pb-2 mb-3 uppercase text-[10px] tracking-widest">Input SPBU Baru</h3>
|
|
<div class="space-y-3">
|
|
<input type="text" id="new_nama" placeholder="Nama SPBU" class="w-full border rounded p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
|
|
<input type="text" id="new_nomor" placeholder="Nomor SPBU" class="w-full border rounded p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
|
|
<select id="new_status" class="w-full border rounded p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm">
|
|
<option value="Ya">Buka 24 Jam</option>
|
|
<option value="Tidak">Tidak</option>
|
|
</select>
|
|
<div class="text-[10px] text-slate-400 bg-slate-50 p-2 rounded border border-dashed text-center">
|
|
Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}
|
|
</div>
|
|
<button onclick="saveNew(${lat}, ${lng})" class="w-full bg-indigo-600 text-white py-2 rounded font-bold text-sm shadow-lg hover:bg-indigo-700 transition">Simpan</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
L.popup().setLatLng(e.latlng).setContent(content).openOn(map);
|
|
});
|
|
|
|
function saveNew(lat, lng) {
|
|
const nama = document.getElementById('new_nama').value;
|
|
const nomor = document.getElementById('new_nomor').value;
|
|
const status = document.getElementById('new_status').value;
|
|
|
|
if(!nama || !nomor) return alert("Mohon lengkapi semua field.");
|
|
|
|
const formData = new FormData();
|
|
formData.append('nama', nama);
|
|
formData.append('nomor', nomor);
|
|
formData.append('status', status);
|
|
formData.append('lat', lat);
|
|
formData.append('lng', lng);
|
|
|
|
fetch('save_location.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'save_location.php'))
|
|
.then(data => {
|
|
if(data.status === 'success') {
|
|
showMessage("✨ Berhasil disimpan ke database!", "bg-green-600 text-white");
|
|
map.closePopup();
|
|
loadMarkers({ force: true });
|
|
}
|
|
})
|
|
.catch(err => showMessage("Gagal menyimpan ke server", "bg-red-600 text-white"));
|
|
}
|
|
|
|
function startAutoRefresh() {
|
|
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
|
autoRefreshTimer = setInterval(() => {
|
|
loadMarkers({ silent: true });
|
|
loadFeatures();
|
|
}, REFRESH_INTERVAL_MS);
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
loadMarkers({ silent: true, force: true });
|
|
loadFeatures(true);
|
|
}
|
|
});
|
|
|
|
// Fungsi Notifikasi Toast
|
|
function showMessage(text, classes) {
|
|
const el = document.getElementById('statusMsg');
|
|
el.innerText = text;
|
|
el.className = `custom-msg p-4 rounded-lg shadow-2xl font-medium ${classes}`;
|
|
el.style.display = 'block';
|
|
setTimeout(() => { el.style.display = 'none'; }, 3000);
|
|
}
|
|
|
|
async function parseJsonResponse(res, endpointName) {
|
|
const text = await res.text();
|
|
|
|
if (!text || !text.trim()) {
|
|
throw new Error(`Respons kosong dari ${endpointName}`);
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (error) {
|
|
throw new Error(`Respons ${endpointName} bukan JSON valid: ${text.slice(0, 160)}`);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.message || `HTTP ${res.status} dari ${endpointName}`);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
window.onload = function () {
|
|
setDataMode('view');
|
|
enforceRegistryDateRange(false);
|
|
loadMarkers({ force: true });
|
|
loadFeatures(true);
|
|
startAutoRefresh();
|
|
};
|
|
|
|
document.getElementById('modeViewBtn').addEventListener('click', () => setDataMode('view'));
|
|
document.getElementById('modePointBtn').addEventListener('click', () => setDataMode('point'));
|
|
document.getElementById('modePolylineBtn').addEventListener('click', () => setDataMode('polyline'));
|
|
document.getElementById('modePolygonBtn').addEventListener('click', () => setDataMode('polygon'));
|
|
document.getElementById('modeChoroplethBtn').addEventListener('click', () => setDataMode('choropleth'));
|
|
document.getElementById('drawStatus').addEventListener('change', () => {
|
|
const type = document.getElementById('featureType').value;
|
|
document.getElementById('featureStatus').value = document.getElementById('drawStatus').value;
|
|
if (type === 'polyline' || type === 'polygon') {
|
|
updateDrawControl(type);
|
|
}
|
|
});
|
|
document.getElementById('runActionBtn').addEventListener('click', () => {
|
|
if (appMode === 'polyline' || appMode === 'polygon') {
|
|
startDraw();
|
|
return;
|
|
}
|
|
|
|
if (appMode === 'point') {
|
|
showMessage('Klik peta untuk menambah point SPBU', 'bg-indigo-600 text-white');
|
|
}
|
|
});
|
|
document.getElementById('clearDraftBtn').addEventListener('click', () => {
|
|
if (appMode === 'polyline' || appMode === 'polygon') {
|
|
resetSpatialDraft(appMode);
|
|
}
|
|
});
|
|
document.getElementById('closeFeatureModalBtn').addEventListener('click', closeFeatureEditor);
|
|
document.getElementById('saveFeatureAttrBtn').addEventListener('click', saveFeatureAttributes);
|
|
document.getElementById('toggleFeatureGeometryBtn').addEventListener('click', () => {
|
|
const id = Number(document.getElementById('editFeatureId').value);
|
|
toggleFeatureGeometryEditing(id);
|
|
});
|
|
document.getElementById('saveFeatureGeometryBtn').addEventListener('click', () => {
|
|
const id = Number(document.getElementById('editFeatureId').value);
|
|
saveFeatureGeometry(id);
|
|
});
|
|
document.getElementById('deleteFeatureBtn').addEventListener('click', () => {
|
|
const id = Number(document.getElementById('editFeatureId').value);
|
|
deleteFeature(id);
|
|
});
|
|
document.getElementById('closeInputRegistryBtn').addEventListener('click', () => toggleInputRegistry(false));
|
|
document.getElementById('inputTypeFilter').addEventListener('change', renderInputRegistry);
|
|
document.getElementById('inputDateFrom').addEventListener('change', () => {
|
|
enforceRegistryDateRange(true);
|
|
renderInputRegistry();
|
|
});
|
|
document.getElementById('inputDateTo').addEventListener('change', () => {
|
|
enforceRegistryDateRange(true);
|
|
renderInputRegistry();
|
|
});
|
|
document.getElementById('applyInputFilterBtn').addEventListener('click', () => {
|
|
enforceRegistryDateRange(true);
|
|
renderInputRegistry();
|
|
});
|
|
document.getElementById('clearInputFilterBtn').addEventListener('click', () => {
|
|
document.getElementById('inputSearchQuery').value = '';
|
|
document.getElementById('inputTypeFilter').value = 'all';
|
|
document.getElementById('inputDateFrom').value = '';
|
|
document.getElementById('inputDateTo').value = '';
|
|
enforceRegistryDateRange(false);
|
|
renderInputRegistry();
|
|
});
|
|
|
|
// Event listener untuk pencarian real-time
|
|
document.getElementById('inputSearchQuery').addEventListener('input', () => {
|
|
renderInputRegistry();
|
|
});
|
|
|
|
// Event listener untuk filter tipe (instant filtering)
|
|
document.getElementById('inputTypeFilter').addEventListener('change', () => {
|
|
renderInputRegistry();
|
|
});
|
|
document.getElementById('featureModal').addEventListener('click', (event) => {
|
|
if (event.target.id === 'featureModal') closeFeatureEditor();
|
|
});
|
|
document.getElementById('closeNewFeatureModalBtn').addEventListener('click', () => closeNewFeatureModal(true));
|
|
document.getElementById('cancelNewFeatureBtn').addEventListener('click', () => closeNewFeatureModal(true));
|
|
document.getElementById('saveNewFeatureBtn').addEventListener('click', saveNewFeatureFromModal);
|
|
document.getElementById('newFeatureModal').addEventListener('click', (event) => {
|
|
if (event.target.id === 'newFeatureModal') closeNewFeatureModal(true);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |