Files
WEB-GIS/index.html
T
2026-04-14 10:52:05 +07:00

2131 lines
104 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="modeRoadDamageBtn" title="Lapor jalan rusak">
<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="M6 12h12M12 6v12M2 12h20M12 2v20"/><circle cx="12" cy="12" r="10"/></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">&lt; 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">&gt; 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>
<!-- Modal Lapor Jalan Rusak -->
<div id="roadDamageModal" class="fixed inset-0 z-[10002] 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">Lapor Jalan Rusak</h3>
<p class="text-sm text-slate-500">Dokumentasikan kondisi jalan dengan foto untuk membantu perbaikan.</p>
</div>
<button id="closeRoadDamageModalBtn" 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">
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis Kerusakan</label>
<select id="roadDamageType" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-red-500">
<option value="">Pilih jenis kerusakan</option>
<option value="lubang">Lubang</option>
<option value="retak">Retak</option>
<option value="ambles">Ambles</option>
<option value="bergelombang">Bergelombang</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Tingkat Kerusakan</label>
<select id="roadDamageSeverity" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-red-500">
<option value="ringan">Ringan</option>
<option value="sedang" selected>Sedang</option>
<option value="berat">Berat</option>
</select>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Deskripsi</label>
<textarea id="roadDamageDesc" placeholder="Jelaskan kondisi jalan, lokasi spesifik, dll..." class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-red-500" rows="3"></textarea>
</div>
<div>
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Foto Kondisi (Opsional)</label>
<input id="roadDamageImage" type="file" accept="image/*" class="w-full rounded-lg border border-slate-300 p-2 text-sm">
<div id="imagePreview" class="mt-2 hidden">
<img id="previewImg" src="" alt="Preview" class="max-h-40 rounded-lg">
</div>
</div>
<div class="text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
📍 Koordinat: <span id="roadDamageCoords">-</span>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
<button id="saveRoadDamageBtn" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700">Simpan Laporan</button>
<button id="cancelRoadDamageBtn" 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;
let roadDamageLayer = L.featureGroup().addTo(map);
let roadDamageRecords = [];
let pendingRoadDamageCoords = 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;
const DISTRICT_POPULATION_MAP = {
'pontianak barat': 148800,
'pontianak kota': 98500,
'pontianak selatan': 133600,
'pontianak tenggara': 52900,
'pontianak timur': 121500,
'pontianak utara': 129200
};
function normalizeDistrictName(name) {
return String(name || '')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim();
}
function enrichFeaturePopulation(feature) {
const properties = feature.properties || {};
const districtName = properties.name || properties.NAME || properties.Ket || '';
const districtKey = normalizeDistrictName(districtName);
const mappedPopulation = DISTRICT_POPULATION_MAP[districtKey];
if (mappedPopulation == null) {
return feature;
}
return {
...feature,
properties: {
...properties,
population: Number(mappedPopulation)
}
};
}
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)
.map(enrichFeaturePopulation)
};
}
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'),
roadDamage: document.getElementById('modeRoadDamageBtn')
};
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;
}
if (mode === 'roadDamage') {
hint.innerText = 'Mode laporan jalan rusak aktif: klik peta untuk menandai lokasi kerusakan.';
spatialControls.classList.add('hidden');
runActionBtn.innerText = 'Klik Peta';
runActionBtn.disabled = true;
clearDraftBtn.disabled = true;
updateDrawControl(null);
document.getElementById('modeMeasure').value = '';
loadRoadDamage(true);
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 openRoadDamageModal(lat, lng) {
pendingRoadDamageCoords = { lat, lng };
document.getElementById('roadDamageCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
document.getElementById('roadDamageType').value = '';
document.getElementById('roadDamageSeverity').value = 'sedang';
document.getElementById('roadDamageDesc').value = '';
document.getElementById('roadDamageImage').value = '';
document.getElementById('imagePreview').classList.add('hidden');
const modal = document.getElementById('roadDamageModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
suppressAutoReload = true;
}
function closeRoadDamageModal() {
const modal = document.getElementById('roadDamageModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingRoadDamageCoords = null;
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
}
function saveRoadDamage() {
if (!pendingRoadDamageCoords) {
showMessage('Koordinat tidak ditemukan', 'bg-red-600 text-white');
return;
}
const jenis = document.getElementById('roadDamageType').value;
if (!jenis) {
showMessage('Pilih jenis kerusakan terlebih dahulu', 'bg-red-600 text-white');
return;
}
const severity = document.getElementById('roadDamageSeverity').value;
const deskripsi = document.getElementById('roadDamageDesc').value;
const fileInput = document.getElementById('roadDamageImage');
const formData = new FormData();
formData.append('latitude', pendingRoadDamageCoords.lat);
formData.append('longitude', pendingRoadDamageCoords.lng);
formData.append('jenis_kerusakan', jenis);
formData.append('severity', severity);
formData.append('deskripsi', deskripsi);
if (fileInput.files && fileInput.files[0]) {
formData.append('gambar', fileInput.files[0]);
}
fetch('save_jalan_rusak.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'save_jalan_rusak.php'))
.then(data => {
if (data.status === 'success') {
showMessage('✅ Laporan jalan rusak berhasil disimpan', 'bg-green-600 text-white');
closeRoadDamageModal();
loadRoadDamage(true);
} else {
throw new Error(data.message || 'Gagal menyimpan laporan');
}
})
.catch(err => showMessage(err.message || 'Gagal menyimpan laporan', 'bg-red-600 text-white'));
}
function createRoadDamageIcon(severity) {
const colorMap = {
'ringan': '#fbbf24',
'sedang': '#f97316',
'berat': '#dc2626'
};
const color = colorMap[severity] || '#f97316';
return L.divIcon({
className: "custom-marker",
html: `<svg width="32" height="40" viewBox="0 0 32 40" fill="${color}" xmlns="http://www.w3.org/2000/svg">
<path d="M16 0C8.27 0 2 6.27 2 14c0 8 14 26 14 26s14-18 14-26c0-7.73-6.27-14-14-14z"/>
<circle cx="16" cy="14" r="5" fill="white"/>
<text x="16" y="16" text-anchor="middle" dy=".3em" font-size="10" font-weight="bold" fill="${color}">!</text>
</svg>`,
iconSize: [32, 40],
iconAnchor: [16, 40],
popupAnchor: [0, -40]
});
}
function getRoadDamagePopup(record) {
const imageHtml = record.gambar ? `<img src="${record.gambar}" alt="Foto jalan rusak" class="mt-2 w-full rounded-lg max-h-48 object-cover">` : '';
return `
<div class="w-full p-3">
<h4 class="font-bold text-slate-800">Laporan Jalan Rusak</h4>
<p class="mt-1 text-sm text-slate-600"><strong>Jenis:</strong> ${record.jenis_kerusakan}</p>
<p class="text-sm text-slate-600"><strong>Tingkat:</strong>
<span class="px-2 py-1 rounded text-xs font-bold ${
record.severity === 'ringan' ? 'bg-yellow-100 text-yellow-700' :
record.severity === 'sedang' ? 'bg-orange-100 text-orange-700' :
'bg-red-100 text-red-700'
}">${record.severity}</span>
</p>
${record.deskripsi ? `<p class="mt-1 text-sm text-slate-600"><strong>Deskripsi:</strong> ${record.deskripsi}</p>` : ''}
<p class="mt-1 text-xs text-slate-500">📍 ${record.latitude.toFixed(5)}, ${record.longitude.toFixed(5)}</p>
${imageHtml}
<div class="mt-3 flex gap-2 border-t pt-2">
<button onclick="deleteRoadDamage(${record.id})" class="flex-1 rounded bg-red-50 px-2 py-1 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
</div>
</div>
`;
}
function loadRoadDamage(force = false) {
if (!force && (document.hidden || suppressAutoReload)) return;
fetch('get_jalan_rusak.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)) {
throw new Error('Format data tidak valid');
}
roadDamageRecords = data;
roadDamageLayer.clearLayers();
data.forEach(record => {
const marker = L.marker([record.latitude, record.longitude], {
icon: createRoadDamageIcon(record.severity)
}).addTo(roadDamageLayer);
marker.bindPopup(getRoadDamagePopup(record));
});
})
.catch(err => {
console.error('Gagal memuat data jalan rusak:', err);
});
}
function deleteRoadDamage(id) {
if (!confirm('Hapus laporan jalan rusak ini?')) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_jalan_rusak.php', { method: 'POST', body: formData })
.then(res => parseJsonResponse(res, 'delete_jalan_rusak.php'))
.then(data => {
if (data.status === 'success') {
showMessage('🗑️ Laporan berhasil dihapus', 'bg-orange-600 text-white');
loadRoadDamage(true);
} else {
throw new Error(data.message || 'Gagal menghapus laporan');
}
})
.catch(err => showMessage(err.message || 'Gagal menghapus laporan', 'bg-red-600 text-white'));
}
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: '&copy; 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 === 'roadDamage') {
openRoadDamageModal(e.latlng.lat, e.latlng.lng);
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('modeRoadDamageBtn').addEventListener('click', () => setDataMode('roadDamage'));
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);
});
// Road Damage Modal Event Listeners
document.getElementById('saveRoadDamageBtn').addEventListener('click', saveRoadDamage);
document.getElementById('cancelRoadDamageBtn').addEventListener('click', closeRoadDamageModal);
document.getElementById('closeRoadDamageModalBtn').addEventListener('click', closeRoadDamageModal);
document.getElementById('roadDamageImage').addEventListener('change', (event) => {
const file = event.target.files[0];
const preview = document.getElementById('imagePreview');
const previewImg = document.getElementById('previewImg');
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
previewImg.src = e.target.result;
preview.classList.remove('hidden');
};
reader.readAsDataURL(file);
} else {
preview.classList.add('hidden');
}
});
document.getElementById('roadDamageModal').addEventListener('click', (event) => {
if (event.target.id === 'roadDamageModal') closeRoadDamageModal();
});
</script>
</body>
</html>