4061 lines
200 KiB
HTML
4061 lines
200 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(.5rem + env(safe-area-inset-top)) .5rem .5rem .5rem;
|
|
box-sizing: border-box;
|
|
}
|
|
#map {
|
|
height: calc(100vh - 1rem);
|
|
width: 100%;
|
|
border-radius: .75rem;
|
|
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; }
|
|
.leaflet-control-layers {
|
|
max-height: calc(100vh - 8rem);
|
|
overflow-y: auto;
|
|
width: 190px;
|
|
font-size: 12px;
|
|
}
|
|
.leaflet-control-layers-toggle {
|
|
width: 30px !important;
|
|
height: 30px !important;
|
|
background-size: 18px 18px !important;
|
|
}
|
|
.leaflet-control-layers-overlays label,
|
|
.leaflet-control-layers-list label {
|
|
margin-bottom: 0.15rem;
|
|
line-height: 1.2;
|
|
}
|
|
.leaflet-control-layers input {
|
|
margin-top: 0.15rem;
|
|
}
|
|
.leaflet-draw-toolbar a {
|
|
width: 32px !important;
|
|
height: 32px !important;
|
|
line-height: 32px !important;
|
|
background-size: 18px 18px !important;
|
|
background-repeat: no-repeat !important;
|
|
background-position: center center !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-marker {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='9' r='2.5'/%3E%3Cpath 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'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-polyline {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 18l6-6 4 4 6-8'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-polygon {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2l8 5v10l-8 5-8-5V7z'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-rectangle {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='5' y='5' width='14' height='14' rx='1'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-circle {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='7'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-edit-edit {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20h9'/%3E%3Cpath d='M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-edit-remove {
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230f172a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M8 6V4h8v2'/%3E%3Cpath d='M6 6l1 14h10l1-14'/%3E%3Cpath d='M10 11v6'/%3E%3Cpath d='M14 11v6'/%3E%3C/svg>") !important;
|
|
}
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-polygon,
|
|
.leaflet-draw-toolbar .leaflet-draw-draw-polyline,
|
|
.leaflet-draw-toolbar .leaflet-draw-edit-edit,
|
|
.leaflet-draw-toolbar .leaflet-draw-edit-remove {
|
|
margin-bottom: 0;
|
|
}
|
|
.leaflet-draw-section {
|
|
margin-bottom: 2px;
|
|
}
|
|
.leaflet-top.leaflet-left .leaflet-control-layers,
|
|
.leaflet-top.leaflet-left .leaflet-bar,
|
|
.leaflet-top.leaflet-left .leaflet-draw {
|
|
margin-top: 0.35rem;
|
|
}
|
|
|
|
/* 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.45rem; height: 2.45rem; display: inline-flex; align-items: center; justify-content: center; border-radius: 0.8rem; 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; }
|
|
#registryDrawer { transition: transform .24s ease, opacity .24s ease; }
|
|
#registryDrawer.hidden-drawer { transform: translateX(18px); opacity: 0; pointer-events: none; }
|
|
#bantuanDrawer { transition: transform .24s ease, opacity .24s ease; }
|
|
#bantuanDrawer.hidden-drawer { transform: translateX(18px); opacity: 0; pointer-events: none; }
|
|
#layerDrawer { transition: transform .24s ease, opacity .24s ease; }
|
|
#layerDrawer.hidden-drawer { transform: translateX(-18px); opacity: 0; pointer-events: none; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-slate-50 text-slate-800 font-sans">
|
|
|
|
<div class="app-shell">
|
|
<div id="map"></div>
|
|
|
|
<div id="mainPanel" class="hidden absolute left-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2000] w-[260px] max-w-[calc(100vw-3rem)] rounded-2xl border border-slate-200 bg-white/90 p-2.5 shadow-2xl backdrop-blur-md md:w-[290px]">
|
|
<div>
|
|
<h1 class="text-base font-black tracking-tight text-indigo-700">GIS</h1>
|
|
<p id="modeHint" class="hidden"></p>
|
|
</div>
|
|
<div id="mainPanelBody">
|
|
<div id="connectionStatus" class="mt-1 text-[9px] font-bold uppercase tracking-wide text-amber-500">Mengecek koneksi...</div>
|
|
<div class="mt-2 flex items-center justify-between rounded-xl bg-slate-50 px-2.5 py-1.5 text-xs text-slate-600">
|
|
<span>SPBU</span>
|
|
<span id="totalCount" class="text-sm font-black text-indigo-600">0</span>
|
|
</div>
|
|
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
|
<button class="icon-btn" id="toggleEditModeBtn" title="Aktifkan mode edit">
|
|
<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 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
|
</button>
|
|
<div id="editModeButtons" class="hidden flex flex-wrap gap-1.5">
|
|
<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="modeRumahIbadahBtn" title="Input rumah ibadah">
|
|
<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 3 4 8v13h16V8z"/><path d="M9 21v-7h6v7"/><path d="M12 7v6"/><path d="M9 10h6"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="modePendudukMiskinBtn" title="Input penduduk miskin">
|
|
<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 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4z"/><path d="M4 21a8 8 0 0 1 16 0"/><path d="M18 8h4"/><path d="M20 6v4"/></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>
|
|
<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 id="inputRegistryPanel" class="mt-3 hidden rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-sm 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>
|
|
<option value="rumahIbadah">Rumah Ibadah</option>
|
|
<option value="pendudukMiskin">Penduduk Miskin</option>
|
|
<option value="roadDamage">Jalan Rusak</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>
|
|
|
|
</div>
|
|
|
|
<div id="minimalToolbar" class="hidden absolute left-6 top-[calc(1rem+env(safe-area-inset-top))] z-[2100] flex items-center gap-2 rounded-2xl border border-slate-200 bg-white/90 p-2 shadow-xl backdrop-blur-md">
|
|
<button class="icon-btn" id="miniModePointBtn" 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="miniModePolylineBtn" 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="miniModePolygonBtn" 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="miniModeRumahIbadahBtn" title="Input rumah ibadah">
|
|
<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 3 4 8v13h16V8z"/><path d="M9 21v-7h6v7"/><path d="M12 7v6"/><path d="M9 10h6"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="miniModePendudukMiskinBtn" title="Input penduduk miskin">
|
|
<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 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4z"/><path d="M4 21a8 8 0 0 1 16 0"/><path d="M18 8h4"/><path d="M20 6v4"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="toggleRegistryDrawerBtn" title="Buka registry & filter">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="10" y1="18" x2="14" y2="18"/></svg>
|
|
</button>
|
|
<button class="icon-btn" id="toggleBantuanDrawerBtn" title="Manajemen bantuan penduduk miskin">
|
|
<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 21c4.97 0 9-2.24 9-5V8l-9-5-9 5v8c0 2.76 4.03 5 9 5Z"/><path d="M12 12v9"/><path d="M7 10.5 12 13l5-2.5"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="registryDrawer" class="hidden-drawer fixed right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[2100] h-[calc(100vh-2rem-env(safe-area-inset-top))] w-[460px] max-w-[calc(100vw-1rem)] rounded-l-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md overflow-y-auto">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 class="text-sm font-black tracking-tight text-slate-800">Registry</h2>
|
|
<p class="mt-1 text-[11px] text-slate-500">Filter dan pencarian data input.</p>
|
|
</div>
|
|
<button id="registryDrawerCloseBtn" 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="registryCount" class="text-sm font-black text-indigo-600">0</span>
|
|
</div>
|
|
|
|
<div class="mt-3 grid gap-2">
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Nama / Atribut</label>
|
|
<input id="registrySearchQuery" 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>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jenis</label>
|
|
<select id="registryTypeFilter" 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>
|
|
<option value="rumahIbadah">Rumah Ibadah</option>
|
|
<option value="pendudukMiskin">Penduduk Miskin</option>
|
|
<option value="roadDamage">Jalan Rusak</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="registryDateFrom" 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="registryDateTo" 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="registryApplyFilterBtn" 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="registryResetFilterBtn" 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="registryList" 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="bantuanDrawer" class="hidden-drawer fixed right-4 top-[calc(1rem+env(safe-area-inset-top))] z-[2101] h-[calc(100vh-2rem-env(safe-area-inset-top))] w-[460px] max-w-[calc(100vw-1rem)] rounded-l-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl backdrop-blur-md overflow-y-auto">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 class="text-sm font-black tracking-tight text-slate-800">Manajemen Bantuan</h2>
|
|
<p class="mt-1 text-[11px] text-slate-500">Kelola bantuan untuk penduduk miskin per wilayah rumah ibadah.</p>
|
|
</div>
|
|
<button id="bantuanDrawerCloseBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup manajemen bantuan">
|
|
<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 grid gap-2 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">Rumah Ibadah Wilayah</label>
|
|
<select id="bantuanRumahFilter" 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="">Pilih rumah ibadah...</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Status Bantuan</label>
|
|
<select id="bantuanStatusFilter" 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="belum">Belum Dibantu</option>
|
|
<option value="proses">Sedang Diproses</option>
|
|
<option value="sudah">Sudah Dibantu</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Cari Ketua KK</label>
|
|
<input id="bantuanSearchQuery" type="text" placeholder="Nama ketua KK..." 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 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">Prioritas</span>
|
|
<span id="bantuanCount" class="text-sm font-black text-indigo-600">0</span>
|
|
</div>
|
|
|
|
<div id="bantuanList" 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">
|
|
Pilih rumah ibadah untuk melihat daftar penerima bantuan.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="layerDrawer" class="hidden-drawer absolute left-6 top-[calc(1rem+env(safe-area-inset-top)+4.4rem)] z-[2099] w-[220px] 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>
|
|
<h2 class="text-sm font-black tracking-tight text-slate-800">Layer</h2>
|
|
<p class="mt-1 text-[11px] text-slate-500">Dropdown semua layer.</p>
|
|
</div>
|
|
<button id="layerDrawerCloseBtn" class="icon-btn h-9 w-9 text-slate-600" title="Tutup layer">
|
|
<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 space-y-2 text-sm text-slate-700">
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-spbuBuka24JamLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>SPBU Buka 24 Jam</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-spbuTidak24JamLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>SPBU Tidak Buka 24 Jam</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-jalanLayerGroup" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>Jalan</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-polygonLayerGroup" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>Polygon</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-roadDamageLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>Jalan Rusak</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-rumahIbadahLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>Rumah Ibadah</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-pendudukMiskinLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>Penduduk Miskin</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
|
<input id="layerToggle-roadNetworkLayer" type="checkbox" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
|
|
<span>Jalan Eksternal</span>
|
|
</label>
|
|
</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="Tambahkan nama atribut" 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>
|
|
|
|
<div id="rumahIbadahModal" class="fixed inset-0 z-[10003] hidden items-center justify-center bg-slate-900/50 px-4">
|
|
<div class="w-full max-w-xl 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 Rumah Ibadah</h3>
|
|
<p class="text-sm text-slate-500">Alamat diambil otomatis dari reverse geocode koordinat peta.</p>
|
|
</div>
|
|
<button id="closeRumahIbadahModalBtn" 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="rumahIbadahId" type="hidden">
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Rumah Ibadah</label>
|
|
<input id="rumahIbadahNama" type="text" placeholder="Masukkan nama rumah ibadah" class="w-full rounded-lg border border-slate-300 p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">
|
|
</div>
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Alamat Otomatis</label>
|
|
<textarea id="rumahIbadahAlamat" rows="3" readonly class="w-full rounded-lg border border-slate-200 bg-slate-50 p-2 text-sm text-slate-600"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">PIC</label>
|
|
<input id="rumahIbadahPic" type="text" placeholder="Nama PIC" 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">Radius Pencarian</label>
|
|
<input id="rumahIbadahRadius" type="range" min="50" max="5000" step="50" value="1000" class="w-full">
|
|
<div class="mt-1 text-xs text-slate-600">Radius: <span id="rumahIbadahRadiusValue">1000</span> meter</div>
|
|
</div>
|
|
<div class="sm:col-span-2 text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
|
|
📍 Koordinat: <span id="rumahIbadahCoords">-</span>
|
|
</div>
|
|
</div>
|
|
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
|
|
<button id="saveRumahIbadahBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan</button>
|
|
<button id="cancelRumahIbadahBtn" 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>
|
|
|
|
<div id="pendudukMiskinModal" class="fixed inset-0 z-[10004] hidden items-center justify-center bg-slate-900/50 px-4">
|
|
<div class="w-full max-w-xl 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 Penduduk Miskin</h3>
|
|
<p class="text-sm text-slate-500">Data akan ditautkan otomatis ke rumah ibadah terdekat.</p>
|
|
</div>
|
|
<button id="closePendudukMiskinModalBtn" 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="pendudukMiskinId" type="hidden">
|
|
<div class="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Nama Ketua KK</label>
|
|
<input id="pendudukMiskinNama" type="text" placeholder="Masukkan nama ketua KK" 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">Jumlah Anggota Keluarga</label>
|
|
<input id="pendudukMiskinAnggota" type="number" min="1" step="1" value="1" 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">Rumah Ibadah Terdekat</label>
|
|
<input id="pendudukMiskinRumahIbadah" 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="sm:col-span-2">
|
|
<label class="mb-1 block text-[10px] font-bold uppercase tracking-widest text-slate-400">Jarak ke Rumah Ibadah</label>
|
|
<input id="pendudukMiskinJarak" 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="sm:col-span-2 text-[10px] text-slate-500 bg-slate-50 p-2 rounded">
|
|
📍 Koordinat: <span id="pendudukMiskinCoords">-</span>
|
|
</div>
|
|
</div>
|
|
<div class="mt-5 flex flex-wrap gap-2 border-t pt-4">
|
|
<button id="savePendudukMiskinBtn" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-bold text-white hover:bg-indigo-700">Simpan</button>
|
|
<button id="cancelPendudukMiskinBtn" 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 src="https://cdn.jsdelivr.net/npm/proj4@2.12.1/dist/proj4.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();
|
|
let drawControl = null;
|
|
let activeDrawHandler = null;
|
|
let currentDrawMode = 'polyline';
|
|
let appMode = 'view';
|
|
let isEditModeEnabled = true;
|
|
let pendingShape = null;
|
|
let pendingNewFeatureLayer = null;
|
|
let featureLayerMap = {};
|
|
let locationLayerMap = {};
|
|
let locationRecords = [];
|
|
let featureRecords = [];
|
|
let editingFeatureId = null;
|
|
let choroplethLayer = null;
|
|
let spbuBuka24JamLayer = L.layerGroup().addTo(map);
|
|
let spbuTidak24JamLayer = L.layerGroup().addTo(map);
|
|
let spbuLayerControl = null;
|
|
let spatialLayerControl = null;
|
|
let utilityControl = null;
|
|
let rumahIbadahLayer = L.featureGroup().addTo(map);
|
|
let pendudukMiskinLayer = L.featureGroup().addTo(map);
|
|
let rumahIbadahLayerMap = {};
|
|
let pendudukMiskinLayerMap = {};
|
|
let rumahIbadahRecords = [];
|
|
let pendudukMiskinRecords = [];
|
|
let pendingRumahIbadahCoords = null;
|
|
let pendingPendudukMiskinCoords = null;
|
|
let editingRumahIbadahId = null;
|
|
let selectedRumahIbadahId = null;
|
|
let selectedBantuanRumahIbadahId = null;
|
|
let jalanLayerGroup = L.featureGroup().addTo(map);
|
|
let polygonLayerGroup = L.featureGroup().addTo(map);
|
|
let roadDamageLayer = L.featureGroup().addTo(map);
|
|
let roadNetworkLayer = L.featureGroup().addTo(map);
|
|
let roadDamageRecords = [];
|
|
let pendingRoadDamageCoords = null;
|
|
let isMainPanelCollapsed = false;
|
|
let hiddenLayersDuringChoropleth = [];
|
|
let layerDrawerControls = [];
|
|
|
|
const ROAD_NETWORK_GEOJSON_PATH = 'data/Export_Output_2.json';
|
|
let roadNetworkLoaded = false;
|
|
|
|
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 === 'Polygon' && Array.isArray(geometry.coordinates)) {
|
|
const outerRing = geometry.coordinates[0];
|
|
if (!Array.isArray(outerRing)) {
|
|
return feature;
|
|
}
|
|
|
|
return {
|
|
...feature,
|
|
geometry: {
|
|
...geometry,
|
|
coordinates: [outerRing]
|
|
}
|
|
};
|
|
}
|
|
|
|
if (geometry.type !== 'MultiPolygon' || !Array.isArray(geometry.coordinates)) {
|
|
return feature;
|
|
}
|
|
|
|
const normalizedParts = geometry.coordinates
|
|
.map(part => (Array.isArray(part) && Array.isArray(part[0]) ? [part[0]] : part));
|
|
|
|
const partsWithArea = normalizedParts
|
|
.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: 'transparent',
|
|
weight: 0,
|
|
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';
|
|
|
|
// Diagnostic: Disabled tooltip
|
|
// layer.bindTooltip(districtName, { sticky: true });
|
|
layer.on('mouseover', () => {
|
|
layer.setStyle({ weight: 0, fillOpacity: 0.86, color: 'transparent' });
|
|
});
|
|
layer.on('mouseout', () => {
|
|
choroplethLayer.resetStyle(layer);
|
|
});
|
|
layer.on('click', () => {
|
|
const mainValue = getChoroplethValue(props);
|
|
|
|
updateChoroplethPanel(props);
|
|
// Diagnostic: Disabled popup to check if black rectangle comes from popup element
|
|
// 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) {
|
|
setSpatialOverlayVisibility(false);
|
|
try {
|
|
await buildChoroplethLayer();
|
|
} catch (error) {
|
|
showMessage(error.message || 'Gagal memuat peta choropleth', 'bg-red-600 text-white');
|
|
panel.classList.add('hidden');
|
|
setSpatialOverlayVisibility(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
clearChoroplethLayer();
|
|
setSpatialOverlayVisibility(true);
|
|
}
|
|
|
|
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 getRegistryUi() {
|
|
const drawerPanel = document.getElementById('registryDrawer');
|
|
const drawerSearch = document.getElementById('registrySearchQuery');
|
|
|
|
if (drawerPanel && drawerSearch) {
|
|
return {
|
|
panel: drawerPanel,
|
|
count: document.getElementById('registryCount'),
|
|
list: document.getElementById('registryList'),
|
|
search: drawerSearch,
|
|
type: document.getElementById('registryTypeFilter'),
|
|
from: document.getElementById('registryDateFrom'),
|
|
to: document.getElementById('registryDateTo')
|
|
};
|
|
}
|
|
|
|
return {
|
|
panel: document.getElementById('inputRegistryPanel'),
|
|
count: document.getElementById('inputRegistryCount'),
|
|
list: document.getElementById('inputRegistryList'),
|
|
search: document.getElementById('inputSearchQuery'),
|
|
type: document.getElementById('inputTypeFilter'),
|
|
from: document.getElementById('inputDateFrom'),
|
|
to: document.getElementById('inputDateTo')
|
|
};
|
|
}
|
|
|
|
function enforceRegistryDateRange(showFeedback = false) {
|
|
const ui = getRegistryUi();
|
|
const fromEl = ui.from;
|
|
const toEl = ui.to;
|
|
if (!fromEl || !toEl) return;
|
|
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() {
|
|
const ui = getRegistryUi();
|
|
return {
|
|
search: String(ui.search && ui.search.value ? ui.search.value : '').toLowerCase().trim(),
|
|
type: ui.type && ui.type.value ? ui.type.value : 'all',
|
|
from: ui.from && ui.from.value ? ui.from.value : '',
|
|
to: ui.to && ui.to.value ? ui.to.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
|
|
}));
|
|
|
|
const roadDamageRegistryRecords = roadDamageRecords.map(record => ({
|
|
id: Number(record.id),
|
|
type: 'roadDamage',
|
|
title: `Jalan Rusak - ${record.jenis_kerusakan || 'Tanpa jenis'}`,
|
|
subtitle: record.deskripsi || '-',
|
|
status: record.severity || '-',
|
|
created_at: record.created_at || null,
|
|
latitude: Number(record.latitude),
|
|
longitude: Number(record.longitude)
|
|
}));
|
|
|
|
const rumahIbadahRegistryRecords = rumahIbadahRecords.map(record => ({
|
|
id: Number(record.id),
|
|
type: 'rumahIbadah',
|
|
title: record.nama || 'Tanpa nama',
|
|
subtitle: record.pic || '-',
|
|
status: `${Number(record.radius_m || 0).toLocaleString('id-ID')} m`,
|
|
created_at: record.created_at || null,
|
|
latitude: Number(record.latitude),
|
|
longitude: Number(record.longitude)
|
|
}));
|
|
|
|
const pendudukMiskinRegistryRecords = pendudukMiskinRecords.map(record => ({
|
|
id: Number(record.id),
|
|
type: 'pendudukMiskin',
|
|
title: record.nama_ketua_kk || 'Tanpa nama',
|
|
subtitle: `${Number(record.jumlah_anggota || 0)} anggota`,
|
|
status: record.rumah_ibadah_nama || '-',
|
|
created_at: record.created_at || null,
|
|
latitude: Number(record.latitude),
|
|
longitude: Number(record.longitude)
|
|
}));
|
|
|
|
return [...pointRecords, ...spatialRecords, ...roadDamageRegistryRecords, ...rumahIbadahRegistryRecords, ...pendudukMiskinRegistryRecords].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';
|
|
if (type === 'roadDamage') return 'Jalan Rusak';
|
|
if (type === 'rumahIbadah') return 'Rumah Ibadah';
|
|
if (type === 'pendudukMiskin') return 'Penduduk Miskin';
|
|
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';
|
|
if (type === 'roadDamage') return 'bg-red-100 text-red-700';
|
|
if (type === 'rumahIbadah') return 'bg-teal-100 text-teal-700';
|
|
if (type === 'pendudukMiskin') return 'bg-rose-100 text-rose-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;
|
|
}
|
|
|
|
if (type === 'rumahIbadah') {
|
|
const item = rumahIbadahLayerMap[id];
|
|
if (!item) return;
|
|
map.setView(item.marker.getLatLng(), Math.max(map.getZoom(), 16));
|
|
item.marker.openPopup();
|
|
return;
|
|
}
|
|
|
|
if (type === 'pendudukMiskin') {
|
|
const marker = pendudukMiskinLayerMap[id];
|
|
if (!marker) return;
|
|
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
|
|
marker.openPopup();
|
|
return;
|
|
}
|
|
|
|
if (type === 'roadDamage') {
|
|
let selectedMarker = null;
|
|
roadDamageLayer.eachLayer(layer => {
|
|
if (layer && layer.roadDamageId === id) {
|
|
selectedMarker = layer;
|
|
}
|
|
});
|
|
|
|
if (selectedMarker) {
|
|
map.setView(selectedMarker.getLatLng(), Math.max(map.getZoom(), 16));
|
|
selectedMarker.openPopup();
|
|
return;
|
|
}
|
|
|
|
const record = roadDamageRecords.find(item => Number(item.id) === Number(id));
|
|
if (record && Number.isFinite(Number(record.latitude)) && Number.isFinite(Number(record.longitude))) {
|
|
map.setView([Number(record.latitude), Number(record.longitude)], Math.max(map.getZoom(), 16));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const layer = featureLayerMap[id];
|
|
if (!layer) return;
|
|
if (layer.getBounds) {
|
|
map.fitBounds(layer.getBounds(), { padding: [40, 40] });
|
|
}
|
|
layer.openPopup();
|
|
}
|
|
|
|
function renderInputRegistry() {
|
|
const ui = getRegistryUi();
|
|
const listEl = ui.list;
|
|
const countEl = ui.count;
|
|
if (!listEl || !countEl) return;
|
|
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}`
|
|
: record.type === 'roadDamage'
|
|
? `Deskripsi: ${record.subtitle}`
|
|
: record.type === 'rumahIbadah'
|
|
? `PIC: ${record.subtitle}`
|
|
: record.type === 'pendudukMiskin'
|
|
? `Anggota: ${record.subtitle}`
|
|
: `Kategori: ${record.subtitle}`;
|
|
const statusLabel = record.type === 'point'
|
|
? `24 Jam: ${record.status}`
|
|
: record.type === 'roadDamage'
|
|
? `Tingkat: ${record.status}`
|
|
: record.type === 'rumahIbadah'
|
|
? `Radius: ${record.status}`
|
|
: record.type === 'pendudukMiskin'
|
|
? `Rumah Ibadah: ${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 ui = getRegistryUi();
|
|
const panel = ui.panel;
|
|
if (!panel) return;
|
|
|
|
const useDrawer = panel.id === 'registryDrawer';
|
|
const isHidden = useDrawer ? panel.classList.contains('hidden-drawer') : panel.classList.contains('hidden');
|
|
const shouldOpen = forceState === null ? isHidden : !!forceState;
|
|
|
|
if (useDrawer) {
|
|
panel.classList.toggle('hidden-drawer', !shouldOpen);
|
|
const triggerBtn = document.getElementById('toggleRegistryDrawerBtn');
|
|
if (triggerBtn) {
|
|
triggerBtn.classList.toggle('active', shouldOpen);
|
|
}
|
|
const leafletTriggerBtn = document.getElementById('leafletRegistryBtn');
|
|
if (leafletTriggerBtn) {
|
|
leafletTriggerBtn.classList.toggle('active', shouldOpen);
|
|
}
|
|
} else {
|
|
panel.classList.toggle('hidden', !shouldOpen);
|
|
}
|
|
|
|
if (shouldOpen) {
|
|
toggleBantuanDrawer(false);
|
|
toggleLayerDrawer(false);
|
|
renderInputRegistry();
|
|
}
|
|
}
|
|
|
|
function getLayerDrawerUi() {
|
|
return {
|
|
panel: document.getElementById('layerDrawer'),
|
|
closeBtn: document.getElementById('layerDrawerCloseBtn'),
|
|
toggles: {
|
|
spbuBuka24JamLayer: document.getElementById('layerToggle-spbuBuka24JamLayer'),
|
|
spbuTidak24JamLayer: document.getElementById('layerToggle-spbuTidak24JamLayer'),
|
|
jalanLayerGroup: document.getElementById('layerToggle-jalanLayerGroup'),
|
|
polygonLayerGroup: document.getElementById('layerToggle-polygonLayerGroup'),
|
|
roadDamageLayer: document.getElementById('layerToggle-roadDamageLayer'),
|
|
rumahIbadahLayer: document.getElementById('layerToggle-rumahIbadahLayer'),
|
|
pendudukMiskinLayer: document.getElementById('layerToggle-pendudukMiskinLayer'),
|
|
roadNetworkLayer: document.getElementById('layerToggle-roadNetworkLayer')
|
|
}
|
|
};
|
|
}
|
|
|
|
function getLayerEntries() {
|
|
return [
|
|
{ key: 'spbuBuka24JamLayer', layer: spbuBuka24JamLayer, label: 'SPBU Buka 24 Jam' },
|
|
{ key: 'spbuTidak24JamLayer', layer: spbuTidak24JamLayer, label: 'SPBU Tidak Buka 24 Jam' },
|
|
{ key: 'jalanLayerGroup', layer: jalanLayerGroup, label: 'Jalan' },
|
|
{ key: 'polygonLayerGroup', layer: polygonLayerGroup, label: 'Polygon' },
|
|
{ key: 'roadDamageLayer', layer: roadDamageLayer, label: 'Jalan Rusak' },
|
|
{ key: 'rumahIbadahLayer', layer: rumahIbadahLayer, label: 'Rumah Ibadah' },
|
|
{ key: 'pendudukMiskinLayer', layer: pendudukMiskinLayer, label: 'Penduduk Miskin' },
|
|
{ key: 'roadNetworkLayer', layer: roadNetworkLayer, label: 'Jalan Eksternal' }
|
|
];
|
|
}
|
|
|
|
function setLayerVisible(layer, visible) {
|
|
if (!layer) return;
|
|
if (visible) {
|
|
if (!map.hasLayer(layer)) {
|
|
map.addLayer(layer);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (map.hasLayer(layer)) {
|
|
map.removeLayer(layer);
|
|
}
|
|
}
|
|
|
|
function syncLayerDrawerState() {
|
|
const ui = getLayerDrawerUi();
|
|
const entries = getLayerEntries();
|
|
|
|
entries.forEach(entry => {
|
|
const toggle = ui.toggles[entry.key];
|
|
if (toggle) {
|
|
toggle.checked = map.hasLayer(entry.layer);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderLayerDrawer() {
|
|
syncLayerDrawerState();
|
|
}
|
|
|
|
function toggleLayerDrawer(forceState = null) {
|
|
const ui = getLayerDrawerUi();
|
|
const panel = ui.panel;
|
|
if (!panel) return;
|
|
|
|
const isHidden = panel.classList.contains('hidden-drawer');
|
|
const shouldOpen = forceState === null ? isHidden : !!forceState;
|
|
|
|
panel.classList.toggle('hidden-drawer', !shouldOpen);
|
|
|
|
const triggerBtn = document.getElementById('toggleLayerDrawerBtn');
|
|
if (triggerBtn) {
|
|
triggerBtn.classList.toggle('active', shouldOpen);
|
|
}
|
|
|
|
if (shouldOpen) {
|
|
toggleInputRegistry(false);
|
|
toggleBantuanDrawer(false);
|
|
renderLayerDrawer();
|
|
}
|
|
}
|
|
|
|
function bindLayerDrawerEvents() {
|
|
const ui = getLayerDrawerUi();
|
|
const entries = getLayerEntries();
|
|
|
|
entries.forEach(entry => {
|
|
const toggle = ui.toggles[entry.key];
|
|
if (!toggle || layerDrawerControls.includes(entry.key)) return;
|
|
|
|
toggle.addEventListener('change', () => setLayerVisible(entry.layer, toggle.checked));
|
|
layerDrawerControls.push(entry.key);
|
|
});
|
|
|
|
if (ui.closeBtn && !ui.closeBtn.dataset.bound) {
|
|
ui.closeBtn.addEventListener('click', () => toggleLayerDrawer(false));
|
|
ui.closeBtn.dataset.bound = '1';
|
|
}
|
|
}
|
|
|
|
function normalizeAidStatus(status) {
|
|
const value = String(status || '').toLowerCase().trim();
|
|
if (value === 'proses' || value === 'sudah') return value;
|
|
return 'belum';
|
|
}
|
|
|
|
function getAidStatusBadge(status) {
|
|
const normalized = normalizeAidStatus(status);
|
|
if (normalized === 'sudah') {
|
|
return { label: 'Sudah Dibantu', className: 'bg-emerald-100 text-emerald-700' };
|
|
}
|
|
if (normalized === 'proses') {
|
|
return { label: 'Sedang Diproses', className: 'bg-amber-100 text-amber-700' };
|
|
}
|
|
return { label: 'Belum Dibantu', className: 'bg-rose-100 text-rose-700' };
|
|
}
|
|
|
|
function getBantuanUi() {
|
|
return {
|
|
panel: document.getElementById('bantuanDrawer'),
|
|
count: document.getElementById('bantuanCount'),
|
|
list: document.getElementById('bantuanList'),
|
|
rumahFilter: document.getElementById('bantuanRumahFilter'),
|
|
statusFilter: document.getElementById('bantuanStatusFilter'),
|
|
search: document.getElementById('bantuanSearchQuery')
|
|
};
|
|
}
|
|
|
|
function getBantuanFilters() {
|
|
const ui = getBantuanUi();
|
|
return {
|
|
rumahId: ui.rumahFilter && ui.rumahFilter.value ? Number(ui.rumahFilter.value) : 0,
|
|
status: ui.statusFilter && ui.statusFilter.value ? ui.statusFilter.value : 'all',
|
|
search: String(ui.search && ui.search.value ? ui.search.value : '').toLowerCase().trim()
|
|
};
|
|
}
|
|
|
|
function openBantuanForRumah(rumahIbadahId) {
|
|
selectedBantuanRumahIbadahId = Number(rumahIbadahId) || null;
|
|
toggleBantuanDrawer(true);
|
|
}
|
|
|
|
function toggleBantuanDrawer(forceState = null) {
|
|
const ui = getBantuanUi();
|
|
const panel = ui.panel;
|
|
if (!panel) return;
|
|
|
|
const isHidden = panel.classList.contains('hidden-drawer');
|
|
const shouldOpen = forceState === null ? isHidden : !!forceState;
|
|
|
|
panel.classList.toggle('hidden-drawer', !shouldOpen);
|
|
|
|
const triggerBtn = document.getElementById('toggleBantuanDrawerBtn');
|
|
if (triggerBtn) {
|
|
triggerBtn.classList.toggle('active', shouldOpen);
|
|
}
|
|
|
|
const leafletTriggerBtn = document.getElementById('leafletBantuanBtn');
|
|
if (leafletTriggerBtn) {
|
|
leafletTriggerBtn.classList.toggle('active', shouldOpen);
|
|
}
|
|
|
|
if (shouldOpen) {
|
|
toggleInputRegistry(false);
|
|
toggleLayerDrawer(false);
|
|
renderBantuanDrawer();
|
|
}
|
|
}
|
|
|
|
function renderBantuanDrawer() {
|
|
const ui = getBantuanUi();
|
|
if (!ui.panel || !ui.list || !ui.count || !ui.rumahFilter || !ui.statusFilter || !ui.search) return;
|
|
|
|
const previousValue = ui.rumahFilter.value;
|
|
const options = ['<option value="">Pilih rumah ibadah...</option>'].concat(
|
|
rumahIbadahRecords.map(record => `<option value="${Number(record.id)}">${record.nama} (radius ${Number(record.radius_m || 0).toLocaleString('id-ID')} m)</option>`)
|
|
);
|
|
ui.rumahFilter.innerHTML = options.join('');
|
|
|
|
const defaultValue = selectedBantuanRumahIbadahId != null ? String(selectedBantuanRumahIbadahId) : previousValue;
|
|
if (defaultValue && rumahIbadahRecords.some(item => String(Number(item.id)) === String(defaultValue))) {
|
|
ui.rumahFilter.value = String(defaultValue);
|
|
}
|
|
|
|
const filters = getBantuanFilters();
|
|
selectedBantuanRumahIbadahId = filters.rumahId || null;
|
|
|
|
if (!filters.rumahId) {
|
|
ui.count.innerText = '0';
|
|
ui.list.innerHTML = `
|
|
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
|
Pilih rumah ibadah untuk menampilkan daftar penerima bantuan pada wilayahnya.
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const rumahRecord = rumahIbadahRecords.find(item => Number(item.id) === Number(filters.rumahId));
|
|
if (!rumahRecord) {
|
|
ui.count.innerText = '0';
|
|
ui.list.innerHTML = `
|
|
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
|
Data rumah ibadah tidak ditemukan.
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const residents = getPoorResidentsInRumahRadius(rumahRecord).filter(item => {
|
|
const status = normalizeAidStatus(item.bantuan_status);
|
|
if (filters.status !== 'all' && status !== filters.status) return false;
|
|
if (filters.search && !(item.nama_ketua_kk || '').toLowerCase().includes(filters.search)) return false;
|
|
return true;
|
|
});
|
|
|
|
ui.count.innerText = String(residents.length);
|
|
|
|
if (!residents.length) {
|
|
ui.list.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 data penduduk miskin yang sesuai filter untuk wilayah ${rumahRecord.nama}.
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
ui.list.innerHTML = residents.map(item => {
|
|
const badge = getAidStatusBadge(item.bantuan_status);
|
|
const jarak = formatDistanceMeters(Number(item._distanceToRumah));
|
|
const created = formatInputDate(item.created_at);
|
|
const updated = item.bantuan_updated_at ? formatInputDate(item.bantuan_updated_at) : '-';
|
|
const noteValue = String(item.bantuan_catatan || '').replace(/"/g, '"');
|
|
|
|
return `
|
|
<div class="rounded-xl border border-slate-200 bg-white p-3 shadow-sm">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div>
|
|
<h3 class="text-sm font-bold text-slate-800">${item.nama_ketua_kk}</h3>
|
|
<p class="mt-0.5 text-[11px] text-slate-500">${item.jumlah_anggota} anggota • ${jarak} dari ${rumahRecord.nama}</p>
|
|
</div>
|
|
<span class="registry-chip ${badge.className}">${badge.label}</span>
|
|
</div>
|
|
|
|
<div class="mt-2 grid grid-cols-2 gap-x-2 gap-y-1 text-[11px] text-slate-600">
|
|
<div>ID: ${item.id}</div>
|
|
<div>Koordinat: ${Number(item.latitude).toFixed(5)}, ${Number(item.longitude).toFixed(5)}</div>
|
|
<div>Dibuat: ${created}</div>
|
|
<div>Update Bantuan: ${updated}</div>
|
|
</div>
|
|
|
|
<div class="mt-3 grid gap-2">
|
|
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-400">Status Bantuan</label>
|
|
<select id="aidStatus-${item.id}" 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="belum" ${normalizeAidStatus(item.bantuan_status) === 'belum' ? 'selected' : ''}>Belum Dibantu</option>
|
|
<option value="proses" ${normalizeAidStatus(item.bantuan_status) === 'proses' ? 'selected' : ''}>Sedang Diproses</option>
|
|
<option value="sudah" ${normalizeAidStatus(item.bantuan_status) === 'sudah' ? 'selected' : ''}>Sudah Dibantu</option>
|
|
</select>
|
|
<label class="text-[10px] font-bold uppercase tracking-widest text-slate-400">Catatan Bantuan</label>
|
|
<textarea id="aidNote-${item.id}" rows="2" placeholder="Contoh: paket sembako tahap 1" class="w-full rounded-lg border border-slate-300 bg-white p-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500">${noteValue}</textarea>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-2 gap-2 border-t pt-3">
|
|
<button onclick="saveAidStatus(${item.id})" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-bold text-white hover:bg-indigo-700">Simpan Bantuan</button>
|
|
<button onclick="focusRegistryItem('pendudukMiskin', ${item.id})" class="rounded-lg bg-slate-100 px-3 py-2 text-xs font-bold text-slate-700 hover:bg-slate-200">Lihat di Peta</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function saveAidStatus(id) {
|
|
const statusEl = document.getElementById(`aidStatus-${id}`);
|
|
const noteEl = document.getElementById(`aidNote-${id}`);
|
|
if (!statusEl || !noteEl) {
|
|
showMessage('Komponen update bantuan tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('id', String(id));
|
|
formData.append('bantuan_status', normalizeAidStatus(statusEl.value));
|
|
formData.append('bantuan_catatan', String(noteEl.value || '').trim());
|
|
|
|
fetch('update_bantuan_penduduk.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'update_bantuan_penduduk.php'))
|
|
.then(data => {
|
|
if (data.status !== 'success') {
|
|
throw new Error(data.message || 'Gagal memperbarui bantuan');
|
|
}
|
|
|
|
const record = pendudukMiskinRecords.find(item => Number(item.id) === Number(id));
|
|
if (record) {
|
|
record.bantuan_status = normalizeAidStatus(statusEl.value);
|
|
record.bantuan_catatan = String(noteEl.value || '').trim();
|
|
record.bantuan_updated_at = data.bantuan_updated_at || new Date().toISOString();
|
|
}
|
|
|
|
refreshRumahIbadahPopups();
|
|
renderBantuanDrawer();
|
|
loadPendudukMiskin(true);
|
|
showMessage('✅ Status bantuan berhasil diperbarui', 'bg-green-600 text-white');
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal memperbarui bantuan', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
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 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'),
|
|
rumahIbadah: document.getElementById('modeRumahIbadahBtn'),
|
|
pendudukMiskin: document.getElementById('modePendudukMiskinBtn'),
|
|
choropleth: document.getElementById('modeChoroplethBtn'),
|
|
roadDamage: document.getElementById('leafletRoadDamageBtn') || document.getElementById('modeRoadDamageBtn'),
|
|
miniPoint: document.getElementById('miniModePointBtn'),
|
|
miniPolyline: document.getElementById('miniModePolylineBtn'),
|
|
miniPolygon: document.getElementById('miniModePolygonBtn'),
|
|
miniRumahIbadah: document.getElementById('miniModeRumahIbadahBtn'),
|
|
miniPendudukMiskin: document.getElementById('miniModePendudukMiskinBtn')
|
|
};
|
|
|
|
Object.values(modeButtons).forEach(button => {
|
|
if (button) button.classList.remove('active');
|
|
});
|
|
if (mode === 'point') {
|
|
if (modeButtons.point) modeButtons.point.classList.add('active');
|
|
if (modeButtons.miniPoint) modeButtons.miniPoint.classList.add('active');
|
|
}
|
|
if (mode === 'polyline') {
|
|
if (modeButtons.polyline) modeButtons.polyline.classList.add('active');
|
|
if (modeButtons.miniPolyline) modeButtons.miniPolyline.classList.add('active');
|
|
}
|
|
if (mode === 'polygon') {
|
|
if (modeButtons.polygon) modeButtons.polygon.classList.add('active');
|
|
if (modeButtons.miniPolygon) modeButtons.miniPolygon.classList.add('active');
|
|
}
|
|
if (mode === 'rumahIbadah') {
|
|
if (modeButtons.rumahIbadah) modeButtons.rumahIbadah.classList.add('active');
|
|
if (modeButtons.miniRumahIbadah) modeButtons.miniRumahIbadah.classList.add('active');
|
|
}
|
|
if (mode === 'pendudukMiskin') {
|
|
if (modeButtons.pendudukMiskin) modeButtons.pendudukMiskin.classList.add('active');
|
|
if (modeButtons.miniPendudukMiskin) modeButtons.miniPendudukMiskin.classList.add('active');
|
|
}
|
|
if (modeButtons[mode]) {
|
|
modeButtons[mode].classList.add('active');
|
|
}
|
|
|
|
toggleChoroplethMode(mode === 'choropleth');
|
|
|
|
if (mode === 'view') {
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Mode Lihat';
|
|
runActionBtn.disabled = true;
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
if (mode === 'choropleth') {
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Choropleth Aktif';
|
|
runActionBtn.disabled = true;
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
if (mode === 'roadDamage') {
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Klik Peta';
|
|
runActionBtn.disabled = true;
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
loadRoadDamage(true);
|
|
return;
|
|
}
|
|
|
|
if (mode === 'rumahIbadah' || mode === 'pendudukMiskin') {
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Klik Peta';
|
|
runActionBtn.disabled = true;
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
runActionBtn.disabled = false;
|
|
clearDraftBtn.disabled = false;
|
|
|
|
if (mode === 'point') {
|
|
spatialControls.classList.add('hidden');
|
|
runActionBtn.innerText = 'Klik Peta';
|
|
clearDraftBtn.disabled = true;
|
|
updateDrawControl(null);
|
|
document.getElementById('modeMeasure').value = '';
|
|
return;
|
|
}
|
|
|
|
spatialControls.classList.remove('hidden');
|
|
runActionBtn.innerText = mode === 'polyline' ? 'Gambar Jalan' : 'Gambar Polygon';
|
|
setSpatialType(mode);
|
|
}
|
|
|
|
function setEditModeEnabled(enabled) {
|
|
isEditModeEnabled = !!enabled;
|
|
const toggleBtn = document.getElementById('toggleEditModeBtn');
|
|
const editButtonsWrap = document.getElementById('editModeButtons');
|
|
|
|
if (!toggleBtn || !editButtonsWrap) return;
|
|
|
|
editButtonsWrap.classList.toggle('hidden', !isEditModeEnabled);
|
|
toggleBtn.classList.toggle('active', isEditModeEnabled);
|
|
toggleBtn.title = isEditModeEnabled ? 'Nonaktifkan mode edit' : 'Aktifkan mode edit';
|
|
|
|
if (!isEditModeEnabled) {
|
|
setDataMode('view');
|
|
return;
|
|
}
|
|
}
|
|
|
|
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.roadDamageId = Number(record.id);
|
|
|
|
marker.bindPopup(getRoadDamagePopup(record));
|
|
});
|
|
|
|
renderInputRegistry();
|
|
})
|
|
.catch(err => {
|
|
console.error('Gagal memuat data jalan rusak:', err);
|
|
});
|
|
}
|
|
|
|
function getRoadNetworkColor(status) {
|
|
const key = String(status || '').toLowerCase().trim();
|
|
if (key.includes('nasional')) return '#0f766e';
|
|
if (key.includes('propinsi') || key.includes('provinsi')) return '#1d4ed8';
|
|
if (key.includes('kota')) return '#dc2626';
|
|
if (key.includes('kabupaten')) return '#b45309';
|
|
return '#475569';
|
|
}
|
|
|
|
function sanitizeRoadCoordinates(coords) {
|
|
if (!Array.isArray(coords)) {
|
|
return [];
|
|
}
|
|
|
|
const result = [];
|
|
for (const coord of coords) {
|
|
const x = Number(coord && coord[0]);
|
|
const y = Number(coord && coord[1]);
|
|
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
continue;
|
|
}
|
|
|
|
// Data jalan berada pada UTM 49S (easting normal sekitar 100k-900k).
|
|
if (x < 100000 || x > 900000 || Math.abs(y) > 11000000) {
|
|
continue;
|
|
}
|
|
|
|
result.push([x, y]);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function sanitizeRoadFeature(feature) {
|
|
if (!feature || !feature.geometry) {
|
|
return null;
|
|
}
|
|
|
|
const geometry = feature.geometry;
|
|
const type = geometry.type;
|
|
|
|
if (type === 'LineString') {
|
|
const cleaned = sanitizeRoadCoordinates(geometry.coordinates);
|
|
if (cleaned.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...feature,
|
|
geometry: {
|
|
...geometry,
|
|
coordinates: cleaned
|
|
}
|
|
};
|
|
}
|
|
|
|
if (type === 'MultiLineString') {
|
|
const cleanedLines = (Array.isArray(geometry.coordinates) ? geometry.coordinates : [])
|
|
.map(line => sanitizeRoadCoordinates(line))
|
|
.filter(line => line.length >= 2);
|
|
|
|
if (!cleanedLines.length) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...feature,
|
|
geometry: {
|
|
...geometry,
|
|
coordinates: cleanedLines
|
|
}
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function sanitizeRoadGeoJson(data) {
|
|
return {
|
|
...data,
|
|
features: (Array.isArray(data.features) ? data.features : [])
|
|
.map(sanitizeRoadFeature)
|
|
.filter(Boolean)
|
|
};
|
|
}
|
|
|
|
function convertExportRoadCoord(coords) {
|
|
const x = Number(coords && coords[0]);
|
|
const y = Number(coords && coords[1]);
|
|
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
return L.latLng(0, 0);
|
|
}
|
|
|
|
// If coordinates are already lon/lat, use them directly.
|
|
if (Math.abs(x) <= 180 && Math.abs(y) <= 90) {
|
|
return L.latLng(y, x);
|
|
}
|
|
|
|
if (typeof proj4 !== 'function') {
|
|
return L.latLng(0, 0);
|
|
}
|
|
|
|
// Export data uses UTM 49S with false northing removed (small +/- values).
|
|
const adjustedNorthing = Math.abs(y) < 1000000 ? y + 10000000 : y;
|
|
const projected = proj4('EPSG:32749', 'EPSG:4326', [x, adjustedNorthing]);
|
|
return L.latLng(projected[1], projected[0]);
|
|
}
|
|
|
|
async function loadExternalRoadNetwork(force = false) {
|
|
if (roadNetworkLoaded && !force) {
|
|
return;
|
|
}
|
|
|
|
if (typeof proj4 === 'function' && !proj4.defs('EPSG:32749')) {
|
|
proj4.defs('EPSG:32749', '+proj=utm +zone=49 +south +datum=WGS84 +units=m +no_defs +type=crs');
|
|
}
|
|
|
|
if (force) {
|
|
roadNetworkLayer.clearLayers();
|
|
roadNetworkLoaded = false;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(ROAD_NETWORK_GEOJSON_PATH, { cache: 'no-store' });
|
|
if (!response.ok) {
|
|
throw new Error(`Gagal memuat ${ROAD_NETWORK_GEOJSON_PATH} (HTTP ${response.status})`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (!data || data.type !== 'FeatureCollection' || !Array.isArray(data.features)) {
|
|
throw new Error('Format data jalan tidak valid.');
|
|
}
|
|
|
|
const sanitizedRoadData = sanitizeRoadGeoJson(data);
|
|
|
|
const roadGeoJsonLayer = L.geoJSON(sanitizedRoadData, {
|
|
coordsToLatLng: convertExportRoadCoord,
|
|
filter: feature => {
|
|
const type = feature && feature.geometry && feature.geometry.type;
|
|
return type === 'LineString' || type === 'MultiLineString';
|
|
},
|
|
style: feature => {
|
|
const props = feature.properties || {};
|
|
return {
|
|
color: getRoadNetworkColor(props.status_jal),
|
|
weight: 2.8,
|
|
opacity: 0.92
|
|
};
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties || {};
|
|
const namaJalan = String(props.nama_jalan || '').trim() || 'Tanpa nama';
|
|
const status = String(props.status_jal || '').trim() || '-';
|
|
const fungsi = String(props.Fungsi_Jal || '').trim() || '-';
|
|
const panjang = Number(props.Plan_Len);
|
|
const panjangText = Number.isFinite(panjang) ? `${panjang.toFixed(1)} m` : '-';
|
|
|
|
layer.bindPopup(`
|
|
<div class="p-2">
|
|
<strong>${namaJalan}</strong><br>
|
|
Status: ${status}<br>
|
|
Fungsi: ${fungsi}<br>
|
|
Panjang: ${panjangText}
|
|
</div>
|
|
`);
|
|
}
|
|
});
|
|
|
|
roadNetworkLayer.addLayer(roadGeoJsonLayer);
|
|
roadNetworkLoaded = true;
|
|
} catch (error) {
|
|
console.error('Gagal memuat layer jalan eksternal:', error);
|
|
showMessage(error.message || 'Gagal memuat data jalan eksternal', 'bg-red-600 text-white');
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
drawControl = new L.Control.Draw({
|
|
position: 'topleft',
|
|
edit: {
|
|
featureGroup: featureLayer,
|
|
edit: true,
|
|
remove: true
|
|
},
|
|
draw: {
|
|
polygon: {
|
|
allowIntersection: false,
|
|
showArea: true,
|
|
shapeOptions: {
|
|
color: '#3b82f6',
|
|
weight: 3
|
|
}
|
|
},
|
|
polyline: {
|
|
shapeOptions: {
|
|
color: '#16a34a',
|
|
weight: 5
|
|
}
|
|
},
|
|
rectangle: true,
|
|
circle: true,
|
|
circlemarker: false,
|
|
marker: true
|
|
}
|
|
});
|
|
|
|
map.addControl(drawControl);
|
|
}
|
|
|
|
function openNewLocationPopup(lat, 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([lat, lng]).setContent(content).openOn(map);
|
|
}
|
|
|
|
async function reverseGeocodeLocation(lat, lng) {
|
|
const url = new URL('https://nominatim.openstreetmap.org/reverse');
|
|
url.searchParams.set('format', 'jsonv2');
|
|
url.searchParams.set('lat', String(lat));
|
|
url.searchParams.set('lon', String(lng));
|
|
url.searchParams.set('accept-language', 'id');
|
|
|
|
const response = await fetch(url.toString(), {
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Reverse geocode gagal (HTTP ${response.status})`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.display_name || 'Alamat tidak ditemukan';
|
|
}
|
|
|
|
function distanceMeters(lat1, lng1, lat2, lng2) {
|
|
const earthRadius = 6371000;
|
|
const toRadians = value => (value * Math.PI) / 180;
|
|
const deltaLat = toRadians(lat2 - lat1);
|
|
const deltaLng = toRadians(lng2 - lng1);
|
|
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(deltaLng / 2) ** 2;
|
|
return 2 * earthRadius * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
function formatDistanceMeters(value) {
|
|
if (!Number.isFinite(value)) return '-';
|
|
if (value >= 1000) {
|
|
return `${(value / 1000).toFixed(2)} km`;
|
|
}
|
|
return `${Math.round(value)} m`;
|
|
}
|
|
|
|
function findNearestRumahIbadah(lat, lng) {
|
|
if (!Array.isArray(rumahIbadahRecords) || !rumahIbadahRecords.length) {
|
|
return null;
|
|
}
|
|
|
|
return rumahIbadahRecords.reduce((nearest, record) => {
|
|
const distance = distanceMeters(lat, lng, Number(record.latitude), Number(record.longitude));
|
|
if (!nearest || distance < nearest.distance) {
|
|
return { record, distance };
|
|
}
|
|
return nearest;
|
|
}, null);
|
|
}
|
|
|
|
function syncRumahIbadahRadius(value) {
|
|
const slider = document.getElementById('rumahIbadahRadius');
|
|
const field = document.getElementById('rumahIbadahRadiusValue');
|
|
const numericValue = Math.max(50, Number(value) || 1000);
|
|
|
|
if (slider) slider.value = numericValue;
|
|
if (field) field.innerText = String(numericValue);
|
|
}
|
|
|
|
function openRumahIbadahModal(lat, lng, record = null) {
|
|
pendingRumahIbadahCoords = { lat, lng };
|
|
editingRumahIbadahId = record ? Number(record.id) : null;
|
|
|
|
document.getElementById('rumahIbadahId').value = record ? String(record.id) : '';
|
|
document.getElementById('rumahIbadahNama').value = record ? record.nama || '' : '';
|
|
document.getElementById('rumahIbadahPic').value = record ? record.pic || '' : '';
|
|
document.getElementById('rumahIbadahAlamat').value = record ? record.alamat || '' : 'Mencari alamat...';
|
|
syncRumahIbadahRadius(record ? record.radius_m : 1000);
|
|
document.getElementById('rumahIbadahCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
|
|
|
const modal = document.getElementById('rumahIbadahModal');
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
suppressAutoReload = true;
|
|
|
|
if (!record) {
|
|
reverseGeocodeLocation(lat, lng)
|
|
.then(address => {
|
|
if (pendingRumahIbadahCoords && pendingRumahIbadahCoords.lat === lat && pendingRumahIbadahCoords.lng === lng) {
|
|
document.getElementById('rumahIbadahAlamat').value = address;
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (pendingRumahIbadahCoords && pendingRumahIbadahCoords.lat === lat && pendingRumahIbadahCoords.lng === lng) {
|
|
document.getElementById('rumahIbadahAlamat').value = 'Alamat tidak ditemukan';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function closeRumahIbadahModal() {
|
|
const modal = document.getElementById('rumahIbadahModal');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
pendingRumahIbadahCoords = null;
|
|
editingRumahIbadahId = null;
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
|
|
}
|
|
|
|
function openPendudukMiskinModal(lat, lng, record = null) {
|
|
pendingPendudukMiskinCoords = { lat, lng };
|
|
document.getElementById('pendudukMiskinId').value = record ? String(record.id) : '';
|
|
document.getElementById('pendudukMiskinNama').value = record ? (record.nama_ketua_kk || '') : '';
|
|
document.getElementById('pendudukMiskinAnggota').value = record ? String(Number(record.jumlah_anggota) || 1) : '1';
|
|
document.getElementById('pendudukMiskinCoords').innerText = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;
|
|
const nearest = findNearestRumahIbadah(lat, lng);
|
|
|
|
if (nearest) {
|
|
document.getElementById('pendudukMiskinRumahIbadah').value = nearest.record.nama;
|
|
document.getElementById('pendudukMiskinJarak').value = `${formatDistanceMeters(nearest.distance)} dari rumah ibadah terdekat`;
|
|
} else {
|
|
document.getElementById('pendudukMiskinRumahIbadah').value = 'Belum ada data rumah ibadah';
|
|
document.getElementById('pendudukMiskinJarak').value = '-';
|
|
}
|
|
|
|
const modal = document.getElementById('pendudukMiskinModal');
|
|
modal.classList.remove('hidden');
|
|
modal.classList.add('flex');
|
|
suppressAutoReload = true;
|
|
}
|
|
|
|
function closePendudukMiskinModal() {
|
|
const modal = document.getElementById('pendudukMiskinModal');
|
|
modal.classList.add('hidden');
|
|
modal.classList.remove('flex');
|
|
pendingPendudukMiskinCoords = null;
|
|
suppressAutoReload = !!document.querySelector('[id^="edit-mode-"]:not(.hidden)') || !!editingFeatureId;
|
|
}
|
|
|
|
function buildRumahIbadahPopup(record) {
|
|
const residentsInArea = getPoorResidentsInRumahRadius(record);
|
|
|
|
return `
|
|
<div class="w-full p-4">
|
|
<h3 class="text-lg font-bold text-slate-800">${record.nama}</h3>
|
|
<p class="mt-1 text-sm text-slate-600">PIC: ${record.pic}</p>
|
|
<p class="mt-1 text-sm text-slate-600">Radius: ${Number(record.radius_m).toLocaleString('id-ID')} m</p>
|
|
<p class="mt-1 text-sm text-slate-600">Alamat: ${record.alamat}</p>
|
|
<p class="mt-1 text-sm text-slate-600">Penduduk miskin dalam wilayah: <span class="font-semibold">${residentsInArea.length}</span></p>
|
|
<div class="mt-4 grid grid-cols-2 gap-2 border-t pt-3">
|
|
<button onclick="openRumahIbadahEditor(${record.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit</button>
|
|
<button onclick="showNearbyPoorLocations(${record.id})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Cari Miskin</button>
|
|
<button onclick="openBantuanForRumah(${record.id})" class="rounded-lg bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700 hover:bg-violet-100">Kelola Bantuan</button>
|
|
<button onclick="deleteRumahIbadah(${record.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 buildPendudukMiskinPopup(record) {
|
|
const nearestName = record.rumah_ibadah_nama || '-';
|
|
const radiusInfo = record.jarak_ke_rumah_ibadah_m != null ? formatDistanceMeters(Number(record.jarak_ke_rumah_ibadah_m)) : '-';
|
|
const aidStatus = getAidStatusBadge(record.bantuan_status);
|
|
const aidNote = record.bantuan_catatan ? record.bantuan_catatan : '-';
|
|
|
|
return `
|
|
<div class="w-full p-4">
|
|
<h3 class="text-lg font-bold text-slate-800">${record.nama_ketua_kk}</h3>
|
|
<p class="mt-1 text-sm text-slate-600">Anggota keluarga: ${record.jumlah_anggota}</p>
|
|
<p class="mt-1 text-sm text-slate-600">Rumah ibadah terdekat: ${nearestName}</p>
|
|
<p class="mt-1 text-sm text-slate-600">Jarak: ${radiusInfo}</p>
|
|
<p class="mt-1 text-sm text-slate-600">Status bantuan: <span class="font-semibold">${aidStatus.label}</span></p>
|
|
<p class="mt-1 text-xs text-slate-500">Catatan: ${aidNote}</p>
|
|
<div class="mt-4 grid grid-cols-3 gap-2 border-t pt-3">
|
|
<button onclick="openPendudukMiskinEditor(${record.id})" class="rounded-lg bg-indigo-50 px-3 py-2 text-xs font-bold text-indigo-700 hover:bg-indigo-100">Edit</button>
|
|
<button onclick="deletePendudukMiskin(${record.id})" class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-700 hover:bg-red-100">Hapus</button>
|
|
<button onclick="focusRegistryItem('rumahIbadah', ${record.rumah_ibadah_id || 0})" class="rounded-lg bg-emerald-50 px-3 py-2 text-xs font-bold text-emerald-700 hover:bg-emerald-100">Lihat Rumah Ibadah</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function getPoorResidentsInRumahRadius(rumahRecord) {
|
|
if (!rumahRecord) return [];
|
|
|
|
const radius = Number(rumahRecord.radius_m || 0);
|
|
const rumahLat = Number(rumahRecord.latitude);
|
|
const rumahLng = Number(rumahRecord.longitude);
|
|
|
|
if (!Number.isFinite(radius) || !Number.isFinite(rumahLat) || !Number.isFinite(rumahLng)) {
|
|
return [];
|
|
}
|
|
|
|
return pendudukMiskinRecords
|
|
.map(item => {
|
|
const distance = distanceMeters(rumahLat, rumahLng, Number(item.latitude), Number(item.longitude));
|
|
return { ...item, _distanceToRumah: distance };
|
|
})
|
|
.filter(item => {
|
|
if (!Number.isFinite(item._distanceToRumah) || item._distanceToRumah > radius) {
|
|
return false;
|
|
}
|
|
|
|
if (item.rumah_ibadah_id != null) {
|
|
return Number(item.rumah_ibadah_id) === Number(rumahRecord.id);
|
|
}
|
|
|
|
const nearest = findNearestRumahIbadah(Number(item.latitude), Number(item.longitude));
|
|
return nearest && Number(nearest.record.id) === Number(rumahRecord.id);
|
|
})
|
|
.sort((a, b) => a._distanceToRumah - b._distanceToRumah);
|
|
}
|
|
|
|
function openRumahIbadahEditor(id) {
|
|
const record = rumahIbadahRecords.find(item => Number(item.id) === Number(id));
|
|
if (!record) {
|
|
showMessage('Rumah ibadah tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
openRumahIbadahModal(Number(record.latitude), Number(record.longitude), record);
|
|
}
|
|
|
|
function openPendudukMiskinEditor(id) {
|
|
const record = pendudukMiskinRecords.find(item => Number(item.id) === Number(id));
|
|
if (!record) {
|
|
showMessage('Data penduduk miskin tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
openPendudukMiskinModal(Number(record.latitude), Number(record.longitude), record);
|
|
}
|
|
|
|
function showNearbyPoorLocations(rumahIbadahId) {
|
|
const record = rumahIbadahRecords.find(item => Number(item.id) === Number(rumahIbadahId));
|
|
if (!record) {
|
|
showMessage('Rumah ibadah tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
selectedRumahIbadahId = Number(rumahIbadahId);
|
|
const withinRadius = getPoorResidentsInRumahRadius(record);
|
|
|
|
if (!withinRadius.length) {
|
|
showMessage('Belum ada penduduk miskin di dalam radius ini', 'bg-amber-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const bounds = L.latLngBounds(withinRadius.map(item => [Number(item.latitude), Number(item.longitude)]));
|
|
map.fitBounds(bounds.pad(0.2));
|
|
|
|
withinRadius.forEach(item => {
|
|
const layer = pendudukMiskinLayerMap[item.id];
|
|
if (layer) {
|
|
layer.openPopup();
|
|
}
|
|
});
|
|
|
|
showMessage(`Menampilkan ${withinRadius.length} lokasi miskin terdekat`, 'bg-emerald-600 text-white');
|
|
}
|
|
|
|
function saveRumahIbadah() {
|
|
if (!pendingRumahIbadahCoords) {
|
|
showMessage('Koordinat rumah ibadah tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const id = document.getElementById('rumahIbadahId').value;
|
|
const nama = document.getElementById('rumahIbadahNama').value.trim();
|
|
const alamat = document.getElementById('rumahIbadahAlamat').value.trim();
|
|
const pic = document.getElementById('rumahIbadahPic').value.trim();
|
|
const radius = Number(document.getElementById('rumahIbadahRadius').value || 1000);
|
|
|
|
if (!nama || !alamat || !pic) {
|
|
showMessage('Nama, alamat, dan PIC wajib diisi', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (alamat.toLowerCase().includes('mencari alamat')) {
|
|
showMessage('Alamat masih diproses, tunggu reverse geocode selesai', 'bg-amber-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('nama', nama);
|
|
formData.append('alamat', alamat);
|
|
formData.append('pic', pic);
|
|
formData.append('latitude', pendingRumahIbadahCoords.lat);
|
|
formData.append('longitude', pendingRumahIbadahCoords.lng);
|
|
formData.append('radius_m', radius);
|
|
|
|
const endpoint = id ? 'update_rumah_ibadah.php' : 'save_rumah_ibadah.php';
|
|
if (id) {
|
|
formData.append('id', id);
|
|
}
|
|
|
|
fetch(endpoint, { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, endpoint))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showMessage('✅ Rumah ibadah berhasil disimpan', 'bg-green-600 text-white');
|
|
closeRumahIbadahModal();
|
|
loadRumahIbadah(true);
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menyimpan rumah ibadah');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menyimpan rumah ibadah', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function deleteRumahIbadah(id) {
|
|
if (!confirm('Hapus rumah ibadah ini?')) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
|
|
fetch('delete_rumah_ibadah.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'delete_rumah_ibadah.php'))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showMessage('🗑️ Rumah ibadah berhasil dihapus', 'bg-orange-600 text-white');
|
|
loadRumahIbadah(true);
|
|
loadPendudukMiskin(true);
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menghapus rumah ibadah');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menghapus rumah ibadah', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function savePendudukMiskin() {
|
|
if (!pendingPendudukMiskinCoords) {
|
|
showMessage('Koordinat penduduk miskin tidak ditemukan', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const id = document.getElementById('pendudukMiskinId').value;
|
|
const namaKetuaKk = document.getElementById('pendudukMiskinNama').value.trim();
|
|
const jumlahAnggota = Number(document.getElementById('pendudukMiskinAnggota').value || 0);
|
|
const nearest = findNearestRumahIbadah(pendingPendudukMiskinCoords.lat, pendingPendudukMiskinCoords.lng);
|
|
|
|
if (!namaKetuaKk || jumlahAnggota <= 0) {
|
|
showMessage('Nama ketua KK dan jumlah anggota wajib diisi', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (!nearest) {
|
|
showMessage('Tambahkan rumah ibadah terlebih dahulu', 'bg-red-600 text-white');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('nama_ketua_kk', namaKetuaKk);
|
|
formData.append('jumlah_anggota', jumlahAnggota);
|
|
formData.append('latitude', pendingPendudukMiskinCoords.lat);
|
|
formData.append('longitude', pendingPendudukMiskinCoords.lng);
|
|
formData.append('rumah_ibadah_id', nearest.record.id);
|
|
formData.append('rumah_ibadah_nama', nearest.record.nama);
|
|
formData.append('jarak_ke_rumah_ibadah_m', nearest.distance.toFixed(2));
|
|
|
|
const endpoint = id ? 'update_penduduk_miskin.php' : 'save_penduduk_miskin.php';
|
|
if (id) {
|
|
formData.append('id', id);
|
|
}
|
|
|
|
fetch(endpoint, { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, endpoint))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showMessage('✅ Penduduk miskin berhasil disimpan', 'bg-green-600 text-white');
|
|
closePendudukMiskinModal();
|
|
loadPendudukMiskin(true);
|
|
loadRumahIbadah(true);
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menyimpan penduduk miskin');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menyimpan penduduk miskin', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function deletePendudukMiskin(id) {
|
|
if (!confirm('Hapus data penduduk miskin ini?')) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
|
|
fetch('delete_penduduk_miskin.php', { method: 'POST', body: formData })
|
|
.then(res => parseJsonResponse(res, 'delete_penduduk_miskin.php'))
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showMessage('🗑️ Data penduduk miskin berhasil dihapus', 'bg-orange-600 text-white');
|
|
loadPendudukMiskin(true);
|
|
loadRumahIbadah(true);
|
|
} else {
|
|
throw new Error(data.message || 'Gagal menghapus data');
|
|
}
|
|
})
|
|
.catch(err => showMessage(err.message || 'Gagal menghapus data', 'bg-red-600 text-white'));
|
|
}
|
|
|
|
function createRumahIbadahIcon() {
|
|
return L.divIcon({
|
|
className: 'custom-marker',
|
|
html: `<svg width="32" height="40" viewBox="0 0 24 24" fill="#0f766e" xmlns="http://www.w3.org/2000/svg"><path d="M12 2 4 7v15h16V7z"/><path d="M9 22v-8h6v8" fill="#fff" opacity="0.85"/></svg>`,
|
|
iconSize: [32, 40],
|
|
iconAnchor: [16, 40],
|
|
popupAnchor: [0, -40]
|
|
});
|
|
}
|
|
|
|
function createPendudukMiskinIcon() {
|
|
return L.divIcon({
|
|
className: 'custom-marker',
|
|
html: `<svg width="32" height="40" viewBox="0 0 24 24" fill="#b91c1c" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="8" r="4"/><path d="M4 22a8 8 0 0 1 16 0"/></svg>`,
|
|
iconSize: [32, 40],
|
|
iconAnchor: [16, 40],
|
|
popupAnchor: [0, -40]
|
|
});
|
|
}
|
|
|
|
function clearRumahIbadahLayers() {
|
|
rumahIbadahLayer.clearLayers();
|
|
rumahIbadahLayerMap = {};
|
|
}
|
|
|
|
function clearPendudukMiskinLayers() {
|
|
pendudukMiskinLayer.clearLayers();
|
|
pendudukMiskinLayerMap = {};
|
|
}
|
|
|
|
function refreshRumahIbadahPopups() {
|
|
Object.values(rumahIbadahLayerMap).forEach(entry => {
|
|
if (!entry || !entry.record) return;
|
|
const popupContent = buildRumahIbadahPopup(entry.record);
|
|
if (entry.marker) entry.marker.bindPopup(popupContent);
|
|
if (entry.circle) entry.circle.bindPopup(popupContent);
|
|
});
|
|
}
|
|
|
|
function loadRumahIbadah(force = false) {
|
|
if (!force && (document.hidden || suppressAutoReload)) return;
|
|
|
|
fetch('get_rumah_ibadah.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 rumah ibadah tidak valid');
|
|
}
|
|
|
|
rumahIbadahRecords = data;
|
|
clearRumahIbadahLayers();
|
|
|
|
data.forEach(record => {
|
|
const marker = L.marker([Number(record.latitude), Number(record.longitude)], {
|
|
icon: createRumahIbadahIcon()
|
|
}).addTo(rumahIbadahLayer);
|
|
const circle = L.circle([Number(record.latitude), Number(record.longitude)], {
|
|
radius: Number(record.radius_m || 1000),
|
|
color: '#0f766e',
|
|
weight: 2,
|
|
fillColor: '#14b8a6',
|
|
fillOpacity: 0.12
|
|
}).addTo(rumahIbadahLayer);
|
|
|
|
marker.bindPopup(buildRumahIbadahPopup(record));
|
|
circle.bindPopup(buildRumahIbadahPopup(record));
|
|
|
|
rumahIbadahLayerMap[record.id] = { marker, circle, record };
|
|
});
|
|
|
|
renderInputRegistry();
|
|
const bantuanPanel = document.getElementById('bantuanDrawer');
|
|
if (bantuanPanel && !bantuanPanel.classList.contains('hidden-drawer')) {
|
|
renderBantuanDrawer();
|
|
}
|
|
})
|
|
.catch(err => console.error('Gagal memuat rumah ibadah:', err));
|
|
}
|
|
|
|
function loadPendudukMiskin(force = false) {
|
|
if (!force && (document.hidden || suppressAutoReload)) return;
|
|
|
|
fetch('get_penduduk_miskin.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 penduduk miskin tidak valid');
|
|
}
|
|
|
|
pendudukMiskinRecords = data;
|
|
clearPendudukMiskinLayers();
|
|
|
|
data.forEach(record => {
|
|
const marker = L.marker([Number(record.latitude), Number(record.longitude)], {
|
|
icon: createPendudukMiskinIcon()
|
|
}).addTo(pendudukMiskinLayer);
|
|
|
|
marker.bindPopup(buildPendudukMiskinPopup(record));
|
|
pendudukMiskinLayerMap[record.id] = marker;
|
|
});
|
|
|
|
refreshRumahIbadahPopups();
|
|
const bantuanPanel = document.getElementById('bantuanDrawer');
|
|
if (bantuanPanel && !bantuanPanel.classList.contains('hidden-drawer')) {
|
|
renderBantuanDrawer();
|
|
}
|
|
|
|
renderInputRegistry();
|
|
})
|
|
.catch(err => console.error('Gagal memuat penduduk miskin:', err));
|
|
}
|
|
|
|
function convertCircleToPolygon(circleLayer, vertices = 48) {
|
|
const center = circleLayer.getLatLng();
|
|
const radius = Number(circleLayer.getRadius() || 0);
|
|
const points = [];
|
|
const latFactor = 111320;
|
|
const lngFactor = latFactor * Math.cos((center.lat * Math.PI) / 180);
|
|
|
|
for (let index = 0; index < vertices; index += 1) {
|
|
const angle = (index / vertices) * Math.PI * 2;
|
|
const dx = radius * Math.cos(angle);
|
|
const dy = radius * Math.sin(angle);
|
|
const lat = center.lat + (dy / latFactor);
|
|
const lng = center.lng + (dx / (lngFactor || 1));
|
|
points.push([lat, lng]);
|
|
}
|
|
|
|
return L.polygon(points);
|
|
}
|
|
|
|
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 addSpatialFeatureLayer(layer, type) {
|
|
featureLayer.addLayer(layer);
|
|
|
|
if (type === 'polyline') {
|
|
jalanLayerGroup.addLayer(layer);
|
|
return;
|
|
}
|
|
|
|
if (type === 'polygon') {
|
|
polygonLayerGroup.addLayer(layer);
|
|
}
|
|
}
|
|
|
|
function removeSpatialFeatureLayer(layer, type) {
|
|
featureLayer.removeLayer(layer);
|
|
|
|
if (type === 'polyline') {
|
|
jalanLayerGroup.removeLayer(layer);
|
|
return;
|
|
}
|
|
|
|
if (type === 'polygon') {
|
|
polygonLayerGroup.removeLayer(layer);
|
|
}
|
|
}
|
|
|
|
function setSpatialOverlayVisibility(visible) {
|
|
const overlayLayers = [jalanLayerGroup, polygonLayerGroup, roadDamageLayer, roadNetworkLayer, rumahIbadahLayer, pendudukMiskinLayer];
|
|
|
|
if (visible) {
|
|
hiddenLayersDuringChoropleth.forEach(layer => {
|
|
if (!map.hasLayer(layer)) {
|
|
map.addLayer(layer);
|
|
}
|
|
});
|
|
hiddenLayersDuringChoropleth = [];
|
|
return;
|
|
}
|
|
|
|
hiddenLayersDuringChoropleth = overlayLayers.filter(layer => map.hasLayer(layer));
|
|
hiddenLayersDuringChoropleth.forEach(layer => map.removeLayer(layer));
|
|
}
|
|
|
|
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) {
|
|
const pendingType = pendingNewFeatureLayer.featureType || document.getElementById('featureType').value;
|
|
removeSpatialFeatureLayer(pendingNewFeatureLayer, pendingType);
|
|
}
|
|
|
|
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();
|
|
jalanLayerGroup.clearLayers();
|
|
polygonLayerGroup.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;
|
|
addSpatialFeatureLayer(layer, 'polyline');
|
|
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;
|
|
addSpatialFeatureLayer(layer, 'polygon');
|
|
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 {
|
|
removeSpatialFeatureLayer(layer, type);
|
|
throw new Error(data.message || 'Gagal menyimpan fitur');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
removeSpatialFeatureLayer(layer, type);
|
|
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) removeSpatialFeatureLayer(layer, layer.featureType || 'polyline');
|
|
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) {
|
|
let { layerType, layer } = event;
|
|
if (layerType === 'marker') {
|
|
const latlng = layer.getLatLng();
|
|
openNewLocationPopup(latlng.lat, latlng.lng);
|
|
return;
|
|
}
|
|
|
|
if (layerType === 'circlemarker') {
|
|
showMessage('Circle marker dinonaktifkan', 'bg-amber-600 text-white');
|
|
return;
|
|
}
|
|
|
|
if (layerType === 'circle') {
|
|
layer = convertCircleToPolygon(layer);
|
|
layerType = 'polygon';
|
|
}
|
|
|
|
const activeType = (layerType === 'polyline') ? 'polyline' : 'polygon';
|
|
document.getElementById('featureType').value = activeType;
|
|
|
|
const statusSelect = document.getElementById('featureStatus');
|
|
statusSelect.innerHTML = buildFeatureStatusOptions(activeType);
|
|
if (!statusSelect.value && statusSelect.options.length > 0) {
|
|
statusSelect.selectedIndex = 0;
|
|
}
|
|
|
|
const color = getFeatureStatusColor(activeType, document.getElementById('featureStatus').value);
|
|
if (activeType === 'polyline') {
|
|
layer.setStyle({ color, weight: 5 });
|
|
} else {
|
|
layer.setStyle({ color, fillColor: color, fillOpacity: 0.25, weight: 3 });
|
|
}
|
|
|
|
pendingShape = layer;
|
|
addSpatialFeatureLayer(layer, activeType);
|
|
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-1 text-[9px] font-bold uppercase tracking-wide ${colorClass}`;
|
|
}
|
|
|
|
function toggleMainPanel(forceState = null) {
|
|
const panelBody = document.getElementById('mainPanelBody');
|
|
const toggleIcon = document.getElementById('toggleMainPanelIcon');
|
|
const nextState = typeof forceState === 'boolean' ? forceState : !isMainPanelCollapsed;
|
|
|
|
isMainPanelCollapsed = nextState;
|
|
panelBody.classList.toggle('hidden', isMainPanelCollapsed);
|
|
|
|
if (isMainPanelCollapsed) {
|
|
toggleIcon.innerHTML = '<path d="m9 18 6-6-6-6"/>';
|
|
} else {
|
|
toggleIcon.innerHTML = '<path d="m15 18-6-6 6-6"/>';
|
|
}
|
|
}
|
|
|
|
function isSpbuBuka24Jam(status) {
|
|
return String(status || '').trim().toLowerCase() === 'ya';
|
|
}
|
|
|
|
function ensureSpbuLayerControl() {
|
|
if (spbuLayerControl) return;
|
|
|
|
spbuLayerControl = L.control.layers(null, {
|
|
'SPBU Buka 24 Jam': spbuBuka24JamLayer,
|
|
'SPBU Tidak Buka 24 Jam': spbuTidak24JamLayer
|
|
}, {
|
|
collapsed: true,
|
|
position: 'topleft'
|
|
}).addTo(map);
|
|
}
|
|
|
|
function ensureSpatialLayerControl() {
|
|
if (spatialLayerControl) return;
|
|
|
|
spatialLayerControl = L.control.layers(null, {
|
|
'Jalan': jalanLayerGroup,
|
|
'Polygon': polygonLayerGroup,
|
|
'Jalan Rusak': roadDamageLayer,
|
|
'Rumah Ibadah': rumahIbadahLayer,
|
|
'Penduduk Miskin': pendudukMiskinLayer
|
|
}, {
|
|
collapsed: true,
|
|
position: 'topleft'
|
|
}).addTo(map);
|
|
}
|
|
|
|
function ensureLeafletUtilityControl() {
|
|
if (utilityControl) return;
|
|
|
|
utilityControl = L.control({ position: 'topleft' });
|
|
utilityControl.onAdd = function() {
|
|
const container = L.DomUtil.create('div', 'leaflet-bar');
|
|
|
|
function setUtilityModeActive(activeMode) {
|
|
rumahIbadahBtn.classList.toggle('active', activeMode === 'rumahIbadah');
|
|
pendudukMiskinBtn.classList.toggle('active', activeMode === 'pendudukMiskin');
|
|
choroplethBtn.classList.toggle('active', activeMode === 'choropleth');
|
|
roadDamageBtn.classList.toggle('active', activeMode === 'roadDamage');
|
|
}
|
|
|
|
const registryBtn = L.DomUtil.create('a', '', container);
|
|
registryBtn.href = '#';
|
|
registryBtn.id = 'leafletRegistryBtn';
|
|
registryBtn.title = 'Buka Registry';
|
|
registryBtn.innerHTML = 'Rg';
|
|
|
|
const layerBtn = L.DomUtil.create('a', '', container);
|
|
layerBtn.href = '#';
|
|
layerBtn.id = 'leafletLayerBtn';
|
|
layerBtn.title = 'Layer';
|
|
layerBtn.innerHTML = 'Ly';
|
|
|
|
const bantuanBtn = L.DomUtil.create('a', '', container);
|
|
bantuanBtn.href = '#';
|
|
bantuanBtn.id = 'leafletBantuanBtn';
|
|
bantuanBtn.title = 'Manajemen Bantuan';
|
|
bantuanBtn.innerHTML = 'Bn';
|
|
|
|
const rumahIbadahBtn = L.DomUtil.create('a', '', container);
|
|
rumahIbadahBtn.href = '#';
|
|
rumahIbadahBtn.id = 'leafletRumahIbadahBtn';
|
|
rumahIbadahBtn.title = 'Input Rumah Ibadah';
|
|
rumahIbadahBtn.innerHTML = 'Ri';
|
|
|
|
const pendudukMiskinBtn = L.DomUtil.create('a', '', container);
|
|
pendudukMiskinBtn.href = '#';
|
|
pendudukMiskinBtn.id = 'leafletPendudukMiskinBtn';
|
|
pendudukMiskinBtn.title = 'Input Penduduk Miskin';
|
|
pendudukMiskinBtn.innerHTML = 'Pm';
|
|
|
|
const choroplethBtn = L.DomUtil.create('a', '', container);
|
|
choroplethBtn.href = '#';
|
|
choroplethBtn.id = 'leafletChoroplethBtn';
|
|
choroplethBtn.title = 'Tampilkan Choropleth Kecamatan';
|
|
choroplethBtn.innerHTML = 'Ch';
|
|
|
|
const roadDamageBtn = L.DomUtil.create('a', '', container);
|
|
roadDamageBtn.href = '#';
|
|
roadDamageBtn.id = 'leafletRoadDamageBtn';
|
|
roadDamageBtn.title = 'Lapor Jalan Rusak';
|
|
roadDamageBtn.innerHTML = 'Jr';
|
|
|
|
L.DomEvent.disableClickPropagation(container);
|
|
|
|
L.DomEvent.on(registryBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
toggleInputRegistry();
|
|
});
|
|
|
|
L.DomEvent.on(layerBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
toggleLayerDrawer();
|
|
});
|
|
|
|
L.DomEvent.on(bantuanBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
toggleBantuanDrawer();
|
|
});
|
|
|
|
L.DomEvent.on(rumahIbadahBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
setDataMode('rumahIbadah');
|
|
toggleInputRegistry(false);
|
|
setUtilityModeActive('rumahIbadah');
|
|
showMessage('Mode rumah ibadah aktif. Klik peta untuk menambah lokasi.', 'bg-indigo-600 text-white');
|
|
});
|
|
|
|
L.DomEvent.on(pendudukMiskinBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
setDataMode('pendudukMiskin');
|
|
toggleInputRegistry(false);
|
|
setUtilityModeActive('pendudukMiskin');
|
|
showMessage('Mode penduduk miskin aktif. Klik peta untuk menambah lokasi.', 'bg-rose-600 text-white');
|
|
});
|
|
|
|
L.DomEvent.on(choroplethBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
setDataMode('choropleth');
|
|
toggleInputRegistry(false);
|
|
setUtilityModeActive('choropleth');
|
|
showMessage('Mode choropleth aktif. Klik kecamatan untuk melihat populasi.', 'bg-slate-700 text-white');
|
|
});
|
|
|
|
L.DomEvent.on(roadDamageBtn, 'click', function(event) {
|
|
L.DomEvent.stop(event);
|
|
setDataMode('roadDamage');
|
|
toggleInputRegistry(false);
|
|
setUtilityModeActive('roadDamage');
|
|
showMessage('Mode laporan jalan rusak aktif. Klik peta untuk menandai lokasi.', 'bg-red-600 text-white');
|
|
});
|
|
|
|
return container;
|
|
};
|
|
|
|
utilityControl.addTo(map);
|
|
}
|
|
|
|
function clearAllMarkers() {
|
|
spbuBuka24JamLayer.clearLayers();
|
|
spbuTidak24JamLayer.clearLayers();
|
|
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 isOpen24 = isSpbuBuka24Jam(loc.buka_24jam);
|
|
const color = isOpen24 ? '#16a34a' : '#dc2626';
|
|
const targetLayer = isOpen24 ? spbuBuka24JamLayer : spbuTidak24JamLayer;
|
|
|
|
const marker = L.marker([lat, lng], {
|
|
icon: createColoredIcon(color),
|
|
draggable: true
|
|
}).addTo(targetLayer);
|
|
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 = isSpbuBuka24Jam(loc.buka_24jam) ? '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 === 'rumahIbadah') {
|
|
openRumahIbadahModal(e.latlng.lat, e.latlng.lng);
|
|
return;
|
|
}
|
|
|
|
if (appMode === 'pendudukMiskin') {
|
|
openPendudukMiskinModal(e.latlng.lat, e.latlng.lng);
|
|
return;
|
|
}
|
|
|
|
if (appMode !== 'point') return;
|
|
|
|
openNewLocationPopup(e.latlng.lat, e.latlng.lng);
|
|
});
|
|
|
|
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();
|
|
loadRumahIbadah();
|
|
loadPendudukMiskin();
|
|
}, REFRESH_INTERVAL_MS);
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
loadMarkers({ silent: true, force: true });
|
|
loadFeatures(true);
|
|
loadRumahIbadah(true);
|
|
loadPendudukMiskin(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 () {
|
|
ensureLeafletUtilityControl();
|
|
bindLayerDrawerEvents();
|
|
updateDrawControl();
|
|
setDataMode('view');
|
|
enforceRegistryDateRange(false);
|
|
loadMarkers({ force: true });
|
|
loadFeatures(true);
|
|
loadRumahIbadah(true);
|
|
loadPendudukMiskin(true);
|
|
loadRoadDamage(true);
|
|
loadExternalRoadNetwork(true);
|
|
startAutoRefresh();
|
|
};
|
|
|
|
const toggleEditModeBtn = document.getElementById('toggleEditModeBtn');
|
|
if (toggleEditModeBtn) {
|
|
toggleEditModeBtn.addEventListener('click', () => {
|
|
setEditModeEnabled(!isEditModeEnabled);
|
|
});
|
|
}
|
|
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('modeRumahIbadahBtn').addEventListener('click', () => setDataMode('rumahIbadah'));
|
|
document.getElementById('modePendudukMiskinBtn').addEventListener('click', () => setDataMode('pendudukMiskin'));
|
|
document.getElementById('modeChoroplethBtn').addEventListener('click', () => setDataMode('choropleth'));
|
|
document.getElementById('modeRoadDamageBtn').addEventListener('click', () => setDataMode('roadDamage'));
|
|
const miniModePointBtn = document.getElementById('miniModePointBtn');
|
|
if (miniModePointBtn) {
|
|
miniModePointBtn.addEventListener('click', () => {
|
|
setDataMode('point');
|
|
showMessage('Klik peta untuk menambah point SPBU', 'bg-indigo-600 text-white');
|
|
});
|
|
}
|
|
const miniModePolylineBtn = document.getElementById('miniModePolylineBtn');
|
|
if (miniModePolylineBtn) {
|
|
miniModePolylineBtn.addEventListener('click', () => {
|
|
setDataMode('polyline');
|
|
startDraw();
|
|
});
|
|
}
|
|
const miniModePolygonBtn = document.getElementById('miniModePolygonBtn');
|
|
if (miniModePolygonBtn) {
|
|
miniModePolygonBtn.addEventListener('click', () => {
|
|
setDataMode('polygon');
|
|
startDraw();
|
|
});
|
|
}
|
|
const miniModeRumahIbadahBtn = document.getElementById('miniModeRumahIbadahBtn');
|
|
if (miniModeRumahIbadahBtn) {
|
|
miniModeRumahIbadahBtn.addEventListener('click', () => {
|
|
setDataMode('rumahIbadah');
|
|
showMessage('Klik peta untuk menambah rumah ibadah', 'bg-indigo-600 text-white');
|
|
});
|
|
}
|
|
const miniModePendudukMiskinBtn = document.getElementById('miniModePendudukMiskinBtn');
|
|
if (miniModePendudukMiskinBtn) {
|
|
miniModePendudukMiskinBtn.addEventListener('click', () => {
|
|
setDataMode('pendudukMiskin');
|
|
showMessage('Klik peta untuk menambah penduduk miskin', 'bg-indigo-600 text-white');
|
|
});
|
|
}
|
|
const toggleRegistryDrawerBtn = document.getElementById('toggleRegistryDrawerBtn');
|
|
if (toggleRegistryDrawerBtn) {
|
|
toggleRegistryDrawerBtn.addEventListener('click', () => toggleInputRegistry());
|
|
}
|
|
const toggleBantuanDrawerBtn = document.getElementById('toggleBantuanDrawerBtn');
|
|
if (toggleBantuanDrawerBtn) {
|
|
toggleBantuanDrawerBtn.addEventListener('click', () => toggleBantuanDrawer());
|
|
}
|
|
const registryDrawerCloseBtn = document.getElementById('registryDrawerCloseBtn');
|
|
if (registryDrawerCloseBtn) {
|
|
registryDrawerCloseBtn.addEventListener('click', () => toggleInputRegistry(false));
|
|
}
|
|
const bantuanDrawerCloseBtn = document.getElementById('bantuanDrawerCloseBtn');
|
|
if (bantuanDrawerCloseBtn) {
|
|
bantuanDrawerCloseBtn.addEventListener('click', () => toggleBantuanDrawer(false));
|
|
}
|
|
const bantuanRumahFilter = document.getElementById('bantuanRumahFilter');
|
|
if (bantuanRumahFilter) {
|
|
bantuanRumahFilter.addEventListener('change', renderBantuanDrawer);
|
|
}
|
|
const bantuanStatusFilter = document.getElementById('bantuanStatusFilter');
|
|
if (bantuanStatusFilter) {
|
|
bantuanStatusFilter.addEventListener('change', renderBantuanDrawer);
|
|
}
|
|
const bantuanSearchQuery = document.getElementById('bantuanSearchQuery');
|
|
if (bantuanSearchQuery) {
|
|
bantuanSearchQuery.addEventListener('input', renderBantuanDrawer);
|
|
}
|
|
const toggleMainPanelBtn = document.getElementById('toggleMainPanelBtn');
|
|
if (toggleMainPanelBtn) {
|
|
toggleMainPanelBtn.addEventListener('click', () => toggleMainPanel());
|
|
}
|
|
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);
|
|
});
|
|
const registryUi = getRegistryUi();
|
|
if (registryUi.type) {
|
|
registryUi.type.addEventListener('change', renderInputRegistry);
|
|
}
|
|
if (registryUi.from) {
|
|
registryUi.from.addEventListener('change', () => {
|
|
enforceRegistryDateRange(true);
|
|
renderInputRegistry();
|
|
});
|
|
}
|
|
if (registryUi.to) {
|
|
registryUi.to.addEventListener('change', () => {
|
|
enforceRegistryDateRange(true);
|
|
renderInputRegistry();
|
|
});
|
|
}
|
|
const registryApplyFilterBtn = document.getElementById('registryApplyFilterBtn');
|
|
if (registryApplyFilterBtn) {
|
|
registryApplyFilterBtn.addEventListener('click', () => {
|
|
enforceRegistryDateRange(true);
|
|
renderInputRegistry();
|
|
});
|
|
}
|
|
const registryResetFilterBtn = document.getElementById('registryResetFilterBtn');
|
|
if (registryResetFilterBtn) {
|
|
registryResetFilterBtn.addEventListener('click', () => {
|
|
const ui = getRegistryUi();
|
|
if (ui.search) ui.search.value = '';
|
|
if (ui.type) ui.type.value = 'all';
|
|
if (ui.from) ui.from.value = '';
|
|
if (ui.to) ui.to.value = '';
|
|
enforceRegistryDateRange(false);
|
|
renderInputRegistry();
|
|
});
|
|
}
|
|
if (registryUi.search) {
|
|
registryUi.search.addEventListener('input', () => {
|
|
renderInputRegistry();
|
|
});
|
|
registryUi.search.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
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);
|
|
});
|
|
|
|
document.getElementById('saveRumahIbadahBtn').addEventListener('click', saveRumahIbadah);
|
|
document.getElementById('cancelRumahIbadahBtn').addEventListener('click', closeRumahIbadahModal);
|
|
document.getElementById('closeRumahIbadahModalBtn').addEventListener('click', closeRumahIbadahModal);
|
|
document.getElementById('rumahIbadahRadius').addEventListener('input', (event) => syncRumahIbadahRadius(event.target.value));
|
|
document.getElementById('rumahIbadahModal').addEventListener('click', (event) => {
|
|
if (event.target.id === 'rumahIbadahModal') closeRumahIbadahModal();
|
|
});
|
|
|
|
document.getElementById('savePendudukMiskinBtn').addEventListener('click', savePendudukMiskin);
|
|
document.getElementById('cancelPendudukMiskinBtn').addEventListener('click', closePendudukMiskinModal);
|
|
document.getElementById('closePendudukMiskinModalBtn').addEventListener('click', closePendudukMiskinModal);
|
|
document.getElementById('pendudukMiskinModal').addEventListener('click', (event) => {
|
|
if (event.target.id === 'pendudukMiskinModal') closePendudukMiskinModal();
|
|
});
|
|
|
|
// 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> |