/* ============================================================
WebGIS - app.js
Sistem Informasi Geografis Pengentasan Kemiskinan
============================================================ */
(function () {
'use strict';
/* -- Konfigurasi ----------------------------------------- */
const DEFAULT_CENTER = [-0.05754868797441759, 109.32739998281652];
const DEFAULT_ZOOM = 14;
/* -- State Aplikasi -------------------------------------- */
let clickTimer = null; // Timer untuk membedakan single click & double click
let mode = 'point'; // mode aktif saat ini
let deleteCallback = null; // callback untuk konfirmasi hapus
// Preview sementara di peta
let previewMarker = null;
let previewCircle = null;
let previewLine = null;
let previewPolygon = null;
let previewVertices = [];
// Data untuk drawing
let jalanLatLngs = [];
let polygonLatLngs = [];
// Data runtime
let fasilitasPublikList = []; // [{id, nama, lat, lng, radius}, ...]
let pendudukList = []; // [{data, marker}, ...]
/* -- Layer Groups ---------------------------------------- */
let geoJalanLayer = null;
let geoKecamatanLayer= null;
let geoSungaiLayer = null;
const lgFasilitasPublik = L.layerGroup();
const lgPendudukDalam = L.layerGroup();
const lgPendudukLuar = L.layerGroup();
const lgSpbu24 = L.layerGroup();
const lgSpbuTidak = L.layerGroup();
const lgJalan = L.layerGroup();
const lgPolygon = L.layerGroup();
/* -- Inisialisasi Peta ----------------------------------- */
const savedCenter = (() => {
try { return JSON.parse(localStorage.getItem('webgis_center')); } catch { return null; }
})();
const savedZoom = parseInt(localStorage.getItem('webgis_zoom') || '0', 10) || DEFAULT_ZOOM;
const map = L.map('map', {
doubleClickZoom: false,
zoomControl: true
}).setView(savedCenter || DEFAULT_CENTER, savedZoom);
// Base tile (Premium CARTO Positron for SaaS look)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors © CARTO'
}).addTo(map);
// Scale control
L.control.scale({ imperial: false, metric: true, position: 'bottomleft' }).addTo(map);
// Simpan posisi peta
map.on('moveend zoomend', () => {
const c = map.getCenter();
localStorage.setItem('webgis_center', JSON.stringify([c.lat, c.lng]));
localStorage.setItem('webgis_zoom', String(map.getZoom()));
});
// Inisialisasi Lottie Splash Screen
const splashEl = document.getElementById('splashScreen');
if (splashEl && window.lottie) {
lottie.loadAnimation({
container: document.getElementById('lottieContainer'),
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animasi-lokasi.json'
});
}
// Sembunyikan loading setelah peta siap
map.whenReady(() => {
setTimeout(() => {
const ml = el('mapLoading');
if(ml) ml.classList.add('hidden');
}, 600);
// Sembunyikan Splash Screen dengan efek fade-out yang mulus
if (splashEl) {
setTimeout(() => {
splashEl.classList.add('splash-hidden');
setTimeout(() => splashEl.remove(), 500); // Hapus elemen setelah transisi opacity 0.5s selesai
}, 1500); // Delay 1.5 detik agar preloader premium sempat dinikmati user
}
});
/* -- Tambahkan layer groups ke peta ---------------------- */
[lgFasilitasPublik, lgPendudukDalam, lgPendudukLuar].forEach(lg => lg.addTo(map));
if (document.getElementById("layerSpbu24") && document.getElementById("layerSpbu24").checked) lgSpbu24.addTo(map);
if (document.getElementById("layerSpbuTidak") && document.getElementById("layerSpbuTidak").checked) lgSpbuTidak.addTo(map);
if (document.getElementById("layerJalan") && document.getElementById("layerJalan").checked) lgJalan.addTo(map);
if (document.getElementById("layerPolygon") && document.getElementById("layerPolygon").checked) lgPolygon.addTo(map);
/* -------------------------------------------------------
HELPER FUNCTIONS
------------------------------------------------------- */
function el(id) { return document.getElementById(id); }
function showToast(message, type = 'success', duration = 3500) {
const container = el('toastContainer');
const toast = document.createElement('div');
const icons = { success: '\u2713', error: '\u2715', info: '\u2139', warning: '\u26A0' };
toast.className = `toast ${type}`;
toast.innerHTML = `${icons[type] || '\u2022'}${message}`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => toast.remove(), 350);
}, duration);
}
function showDeleteConfirm(message, callback) {
el('deleteMessage').textContent = message;
deleteCallback = callback;
showModal('deleteModal');
}
function showModal(id) {
el('modalBackdrop').classList.remove('hidden');
el(id).classList.remove('hidden');
}
function hideModal(id) {
el(id).classList.add('hidden');
// sembunyikan backdrop hanya jika tidak ada modal lain terbuka
const openModals = document.querySelectorAll('.modal:not(.hidden)');
if (openModals.length === 0) {
el('modalBackdrop').classList.add('hidden');
}
}
function hideAllModals() {
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
el('modalBackdrop').classList.add('hidden');
}
function setHelpText(text) {
el('helpText').textContent = text;
}
/* -- Kalkulasi Geospasial -------------------------------- */
function hitungPanjang(latlngs) {
let total = 0;
for (let i = 1; i < latlngs.length; i++) {
total += latlngs[i - 1].distanceTo(latlngs[i]);
}
return total;
}
function hitungLuas(latlngs) {
if (latlngs.length < 3) return 0;
const pts = latlngs.map(ll => map.options.crs.project(ll));
let area = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
area += (pts[j].x * pts[i].y) - (pts[i].x * pts[j].y);
}
return Math.abs(area / 2);
}
function getDistanceMeter(lat1, lng1, lat2, lng2) {
return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2));
}
function cekJangkauan(lat, lng) {
let hasil = { status: 'Luar Jangkauan', id: '', nama: '' };
let minDistance = Infinity;
fasilitasPublikList.forEach(ri => {
const d = getDistanceMeter(lat, lng, parseFloat(ri.latitude), parseFloat(ri.longitude));
if (d <= parseFloat(ri.radius) && d < minDistance) {
minDistance = d;
hasil = { status: 'Dalam Jangkauan', id: ri.id, nama: ri.nama };
}
});
return hasil;
}
/* -- GeoJSON helpers ------------------------------------- */
function toLineFeature(latlngs, props = {}) {
return {
type: 'Feature',
geometry: { type: 'LineString', coordinates: latlngs.map(ll => [ll.lng, ll.lat]) },
properties: props
};
}
function toPolygonFeature(latlngs, props = {}) {
const ring = latlngs.map(ll => [ll.lng, ll.lat]);
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) {
ring.push([ring[0][0], ring[0][1]]);
}
return {
type: 'Feature',
geometry: { type: 'Polygon', coordinates: [ring] },
properties: props
};
}
function lineFeatureToLatLngs(feat) {
if (!feat?.geometry?.coordinates) return [];
return feat.geometry.coordinates.map(c => [c[1], c[0]]);
}
function polygonFeatureToLatLngs(feat) {
if (!feat?.geometry?.coordinates?.[0]) return [];
const ring = [...feat.geometry.coordinates[0]];
if (ring.length > 1) {
const f = ring[0], l = ring[ring.length - 1];
if (f[0] === l[0] && f[1] === l[1]) ring.pop();
}
return ring.map(c => [c[1], c[0]]);
}
/* -- Warna Helper ---------------------------------------- */
function warnaJalan(status) {
return { 'Jalan Nasional': '#d32f2f', 'Jalan Provinsi': '#f57c00', 'Jalan Kabupaten': '#388e3c' }[status] || '#1565c0';
}
function warnaPolygon(status) {
return { 'SHM': '#1b5e20', 'HGB': '#1565c0', 'HGU': '#ef6c00', 'HP': '#6a1b9a' }[status] || '#455a64';
}
function warnaKecamatan(nama) {
const colors = {
'Pontianak Barat': '#e41a1c', 'Pontianak Timur': '#377eb8',
'Pontianak Utara': '#4daf4a', 'Pontianak Selatan': '#984ea3',
'Pontianak Kota': '#ff7f00', 'Pontianak Tenggara': '#a65628'
};
return colors[nama] || '#888888';
}
/* -- Custom Marker Icons --------------------------------- */
function createFPIcon() {
const svg = ``;
return L.divIcon({
className: '',
html: `
`,
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -34]
});
}
function createPointIcon(is24) {
const markerClass = is24 ? 'point-marker-24' : 'point-marker-normal';
const svg = ``;
return L.divIcon({
className: '',
html: ``,
iconSize: [26, 26],
iconAnchor: [13, 26],
popupAnchor: [0, -28]
});
}
function createPendudukIcon(isDalam) {
const colorClass = isDalam ? 'pm-marker-dalam' : 'pm-marker-luar';
const svg = ``;
return L.divIcon({
className: '',
html: ``,
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -30]
});
}
/* -------------------------------------------------------
PREVIEW MANAGEMENT
------------------------------------------------------- */
function clearAllPreview() {
if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; }
if (previewCircle) { map.removeLayer(previewCircle); previewCircle = null; }
if (previewLine) { map.removeLayer(previewLine); previewLine = null; }
if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; }
previewVertices.forEach(v => map.removeLayer(v));
previewVertices = [];
jalanLatLngs = [];
polygonLatLngs = [];
}
function addVertexMarker(latlng, color = '#1565c0') {
const v = L.circleMarker(latlng, {
radius: 5, color, fillColor: color,
fillOpacity: 1, weight: 2
}).addTo(map);
previewVertices.push(v);
return v;
}
function updateJalanPreview() {
if (previewLine) { map.removeLayer(previewLine); previewLine = null; }
previewVertices.forEach(v => map.removeLayer(v));
previewVertices = [];
jalanLatLngs.forEach((ll, i) => {
const v = addVertexMarker(ll, '#d32f2f');
v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false });
});
if (jalanLatLngs.length >= 2) {
previewLine = L.polyline(jalanLatLngs, { color: '#d32f2f', weight: 4, opacity: 0.9 }).addTo(map);
el('panjang_meter').value = hitungPanjang(jalanLatLngs).toFixed(2);
}
el('jumlah_titik_jalan').value = jalanLatLngs.length;
el('koordinat_jalan').value = JSON.stringify(toLineFeature(jalanLatLngs), null, 2);
}
function updatePolygonPreview() {
if (previewPolygon) { map.removeLayer(previewPolygon); previewPolygon = null; }
previewVertices.forEach(v => map.removeLayer(v));
previewVertices = [];
polygonLatLngs.forEach((ll, i) => {
const v = addVertexMarker(ll, '#6a1b9a');
v.bindTooltip(`Titik ${i + 1}`, { direction: 'top', permanent: false });
});
if (polygonLatLngs.length >= 3) {
previewPolygon = L.polygon(polygonLatLngs, {
color: '#6a1b9a', fillColor: '#ba68c8', fillOpacity: 0.3, weight: 3
}).addTo(map);
el('luas_tanah').value = hitungLuas(polygonLatLngs).toFixed(4);
}
el('jumlah_titik_polygon').value = polygonLatLngs.length;
el('koordinat_polygon').value = JSON.stringify(toPolygonFeature(polygonLatLngs), null, 2);
}
/* -------------------------------------------------------
MODE MANAGEMENT
------------------------------------------------------- */
const MODES = {
point: { btn: 'btnToolPoint', help: 'Klik pada peta untuk menambah titik.' },
jalan: { btn: 'btnToolJalan', help: 'Klik titik-titik jalan. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' },
polygon: { btn: 'btnToolPolygon', help: 'Klik titik-titik polygon. Double-klik untuk selesai. Klik-kanan untuk hapus titik terakhir.' },
fasilitas_publik: { btn: 'btnToolFasilitasPublik',help: 'Klik pada peta untuk menentukan lokasi Fasilitas Publik.' },
penduduk_miskin:{ btn: 'btnToolPenduduk', help: 'Klik pada peta untuk menentukan lokasi penduduk miskin.' },
lokasi: { btn: 'btnToolLokasi', help: 'Mencari lokasi Anda...' },
};
function setMode(newMode) {
clearAllPreview();
hideAllModals();
mode = newMode;
// Update active button
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
const cfg = MODES[newMode];
if (cfg) {
const btn = el(cfg.btn);
if (btn) btn.classList.add('active');
setHelpText(cfg.help);
}
// Khusus lokasi: langsung trigger
if (newMode === 'lokasi') {
map.locate({ setView: true, maxZoom: 18 });
if (el('btnToolPoint')) {
mode = 'point'; // kembali ke point setelah locate
el('btnToolPoint').classList.add('active');
} else {
mode = 'view';
}
if (el('btnToolLokasi')) el('btnToolLokasi').classList.remove('active');
}
}
/* -------------------------------------------------------
RENDER FUNCTIONS
------------------------------------------------------- */
/* -- Render Point ---------------------------------------- */
function renderPoint(item) {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
const is24 = item.buka_24_jam === 'Ya';
const marker = L.marker([lat, lng], {
icon: createPointIcon(is24),
draggable: (typeof USER_ROLE !== 'undefined' && USER_ROLE !== 'guest')
}).addTo(is24 ? lgSpbu24 : lgSpbuTidak);
marker.on('dragend', function(e) {
const pos = e.target.getLatLng();
updateLocation('point', item.id, pos.lat, pos.lng, item.nama);
});
const popup = `
`;
marker.bindPopup(popup, { maxWidth: 280 });
return marker;
}
/* -- GLOBAL: EDIT Point / SPBU --------------------------- */
window.editPoint = function (id) {
fetch(`api/get_point.php?id=${id}`)
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
const d = res.data;
el('point_id').value = d.id;
el('point_nama').value = d.nama;
el('point_no_wa').value = d.no_wa;
// Handle radio buttons
if (d.buka_24_jam === 'Ya') {
document.querySelector('input[name="point_buka_24_jam"][value="Ya"]').checked = true;
} else {
document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true;
}
// Populate coordinates
el('point_latitude').value = d.latitude;
el('point_longitude').value = d.longitude;
el('pointModalTitle').textContent = 'Edit Titik';
// Preview di peta
const tempGeom = { lat: parseFloat(d.latitude), lng: parseFloat(d.longitude) };
if (previewMarker) map.removeLayer(previewMarker);
previewMarker = L.marker([tempGeom.lat, tempGeom.lng], {
icon: createPointIcon(d.buka_24_jam === 'Ya')
}).addTo(map);
map.setView([tempGeom.lat, tempGeom.lng], 16);
el('pointModal').classList.remove('hidden');
el('modalBackdrop').classList.remove('hidden');
} else {
showAlert('Gagal', res.message || 'Gagal mengambil data');
}
})
.catch(e => {
console.error(e);
showAlert('Terjadi Kesalahan', 'Error: ' + e.toString() + '. Mohon segarkan (refresh) halaman.');
});
};
/* -- Render Jalan ---------------------------------------- */
function renderJalan(item) {
try {
const feat = JSON.parse(item.koordinat);
const coords = lineFeatureToLatLngs(feat);
const poly = L.polyline(coords, {
color: warnaJalan(item.status_jalan), weight: 5, opacity: 0.85
}).addTo(lgJalan);
const popup = `
`;
poly.bindPopup(popup, { maxWidth: 280 });
return poly;
} catch (e) { console.error('renderJalan error:', e); }
}
/* -- Render Polygon -------------------------------------- */
function renderPolygon(item) {
try {
const feat = JSON.parse(item.koordinat);
const coords = polygonFeatureToLatLngs(feat);
const warna = warnaPolygon(item.status_kepemilikan);
const poly = L.polygon(coords, {
color: warna, fillColor: warna, fillOpacity: 0.3, weight: 3
}).addTo(lgPolygon);
const popup = `
`;
poly.bindPopup(popup, { maxWidth: 280 });
return poly;
} catch (e) { console.error('renderPolygon error:', e); }
}
/* -- Render Fasilitas Publik ----------------------------- */
function renderFasilitasPublik(item) {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
const radius = parseFloat(item.radius);
fasilitasPublikList.push(item);
const marker = L.marker([lat, lng], { icon: createFPIcon() }).addTo(lgFasilitasPublik);
const circle = L.circle([lat, lng], {
radius, color: '#e53935', fillColor: '#ef9a9a',
fillOpacity: 0.12, weight: 2, dashArray: '6 4',
interactive: false
}).addTo(lgFasilitasPublik);
const popup = `
`;
marker.bindPopup(popup, { maxWidth: 300 });
return { marker, circle };
}
/* -- Render Penduduk Miskin ------------------------------ */
function renderPenduduk(item) {
const lat = parseFloat(item.latitude);
const lng = parseFloat(item.longitude);
const cek = cekJangkauan(lat, lng);
// FIX SPASIAL: Pastikan status jangkauan tersimpan di objek item untuk dirender di sidebar
item.status_jangkauan = cek.status;
const marker = L.marker([lat, lng], {
icon: createPendudukIcon(cek.status === 'Dalam Jangkauan'),
draggable: (typeof USER_ROLE !== 'undefined' && USER_ROLE !== 'guest')
}).addTo(cek.status === 'Dalam Jangkauan' ? lgPendudukDalam : lgPendudukLuar);
marker.on('dragend', function(e) {
const pos = e.target.getLatLng();
updateLocation('penduduk_miskin', item.id, pos.lat, pos.lng, item.nama);
});
const badgeClass = cek.status === 'Dalam Jangkauan' ? 'badge-dalam' : 'badge-luar';
let fotoHtml = '';
if (item.foto_rumah) {
fotoHtml += `
`;
}
const popup = `
`;
marker.bindPopup(popup, { maxWidth: 300 });
pendudukList.push({ data: item, marker });
return marker;
}
/* -- Refresh Status Penduduk ----------------------------- */
function refreshStatusPenduduk() {
lgPendudukDalam.clearLayers();
lgPendudukLuar.clearLayers();
const backup = pendudukList.map(p => p.data);
pendudukList = [];
backup.forEach(item => renderPenduduk(item));
updateDashboardStats(); // Panggil fungsi update stat
}
/* -- Update Statistik Dashboard Secara Real-Time --------- */
function updateDashboardStats() {
const totalFP = fasilitasPublikList.length;
const totalPM = pendudukList.length;
let ditangani = 0;
pendudukList.forEach(p => {
const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude));
if (cek.status === 'Dalam Jangkauan') ditangani++;
});
const belum = totalPM - ditangani;
const persen = totalPM > 0 ? ((ditangani / totalPM) * 100).toFixed(1) : 0;
// Update SPBU Stats
if (el('statTotalSPBU')) {
const totalSPBU = APP_DATA.points.length;
let spbu24 = 0;
APP_DATA.points.forEach(p => { if (p.buka_24_jam === 'Ya') spbu24++; });
const spbuTidak = totalSPBU - spbu24;
el('statTotalSPBU').textContent = totalSPBU;
if (el('statSPBU24')) el('statSPBU24').textContent = spbu24;
if (el('statSPBUTidak')) el('statSPBUTidak').textContent = spbuTidak;
}
// Update angka di HTML
if (el('statTotalRI')) el('statTotalRI').textContent = totalFP;
if (el('statTotalPM')) el('statTotalPM').textContent = totalPM;
if (el('statDitangani')) el('statDitangani').textContent = ditangani;
if (el('statBelum')) el('statBelum').textContent = belum;
if (el('statPersen')) el('statPersen').textContent = persen + '%';
if (el('statProgressBar')) el('statProgressBar').style.width = persen + '%';
}
/* -- Real-Time Refresh Data (Modul Bantuan) -------------- */
async function refreshDataModul() {
try {
const res = await fetch('api/get_all_bantuan.php');
const json = await res.json();
if (json.status === 'success') {
// 1. Update data global
APP_DATA.fasilitasPublik = json.fasilitasPublik;
APP_DATA.pendudukMiskin = json.pendudukMiskin;
// 2. Clear layer peta
lgFasilitasPublik.clearLayers();
lgPendudukDalam.clearLayers();
lgPendudukLuar.clearLayers();
// 3. Reset array data lokal
fasilitasPublikList = [];
pendudukList = [];
// 4. Render ulang marker (renderPenduduk akan otomatis hitung jarak spasial terbaru)
APP_DATA.fasilitasPublik.forEach(renderFasilitasPublik);
APP_DATA.pendudukMiskin.forEach(renderPenduduk);
// 5. Update stat & panel data
updateDashboardStats();
if(window.__rebuildCards) window.__rebuildCards();
}
} catch (err) {
console.error('Gagal me-refresh data bantuan:', err);
}
}
/* -------------------------------------------------------
LOAD DATA AWAL
------------------------------------------------------- */
(APP_DATA.points || []).forEach(renderPoint);
(APP_DATA.jalan || []).forEach(renderJalan);
(APP_DATA.polygons || []).forEach(renderPolygon);
(APP_DATA.fasilitasPublik || []).forEach(renderFasilitasPublik);
(APP_DATA.pendudukMiskin || []).forEach(renderPenduduk);
/* -------------------------------------------------------
LOAD GEOJSON EKSTERNAL
------------------------------------------------------- */
function loadGeoJSON(url, options = {}) {
return fetch(url)
.then(r => { if (!r.ok) throw new Error('Gagal load: ' + url); return r.json(); })
.then(geojson => {
const layer = L.geoJSON(geojson, {
style: feat => {
const t = feat.geometry?.type || '';
if (t === 'Polygon' || t === 'MultiPolygon') {
let fill = options.fillColor || '#42a5f5';
if (options.dynamicColor && feat.properties) {
const nm = feat.properties.Ket || feat.properties.WADMKC ||
feat.properties.KECAMATAN || feat.properties.nama || '';
fill = warnaKecamatan(nm);
}
return { color: '#333', weight: 1.5, fillColor: fill, fillOpacity: 0.35 };
}
if (t === 'LineString' || t === 'MultiLineString') {
return { color: options.color || '#1565c0', weight: options.weight || 2, opacity: 0.9 };
}
return { color: options.color || '#1565c0' };
},
pointToLayer: (feat, latlng) => L.circleMarker(latlng, {
radius: 4, color: options.color || '#1565c0',
fillColor: options.fillColor || '#42a5f5', fillOpacity: 0.9, weight: 1
}),
onEachFeature: (feat, layer) => {
if (!feat.properties) return;
let html = `';
layer.bindPopup(html, { maxWidth: 280 });
}
});
if (options.adjustWeight) {
map.on('zoomend', () => {
const z = map.getZoom();
const w = z <= 12 ? 0.8 : z <= 14 ? 1.5 : z <= 16 ? 2 : 3;
layer.setStyle({ weight: w });
});
}
if (options.addToMap !== false) { layer.addTo(map); if (options.fitBounds) map.fitBounds(layer.getBounds()); } return layer;
})
.catch(err => { console.warn(err.message); return null; });
}
Promise.all([
loadGeoJSON('data/Sungai_Besar.geojson', {
title: 'Sungai Besar', color: '#0d47a1', fillColor: '#90caf9',
weight: 2, fitBounds: false, addToMap: false
}),
loadGeoJSON('data/Admin_Kecamatan.geojson', {
title: 'Admin Kecamatan', dynamicColor: true,
color: '#ef6c00', weight: 1.5, fitBounds: false, addToMap: false
}),
loadGeoJSON('data/Jaringan_Jalan.geojson', {
title: 'Jaringan Jalan', color: '#d32f2f',
weight: 1.5, fitBounds: false, adjustWeight: true, addToMap: false
})
]).then(([sungai, kecamatan, jalan]) => {
geoSungaiLayer = sungai;
geoKecamatanLayer = kecamatan;
geoJalanLayer = jalan;
if (el('layerSungai') && el('layerSungai').checked && geoSungaiLayer) geoSungaiLayer.addTo(map);
if (el('layerKecamatan') && el('layerKecamatan').checked && geoKecamatanLayer) geoKecamatanLayer.addTo(map);
if (el('layerGeoJalan') && el('layerGeoJalan').checked && geoJalanLayer) geoJalanLayer.addTo(map);
});
/* -------------------------------------------------------
REVERSE GEOCODING
------------------------------------------------------- */
function reverseGeocode(lat, lng) {
el('fp_alamat').value = '';
el('geocodeStatus').textContent = '\u23F3 Mencari alamat...';
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&accept-language=id`)
.then(r => r.json())
.then(data => {
el('fp_alamat').value = data.display_name || 'Alamat tidak ditemukan';
el('geocodeStatus').textContent = '\u2713';
setTimeout(() => { el('geocodeStatus').textContent = ''; }, 2000);
})
.catch(() => {
el('fp_alamat').value = 'Gagal mengambil alamat';
el('geocodeStatus').textContent = '\u2715';
});
}
/* -------------------------------------------------------
MAP EVENT HANDLERS
------------------------------------------------------- */
map.on('click', e => {
const { lat, lng } = e.latlng;
// Mode yang tidak perlu timer (langsung eksekusi)
if (mode === 'point') {
clearAllPreview();
el('point_id').value = '';
el('point_nama').value = '';
el('point_no_wa').value = '';
el('point_latitude').value = lat.toFixed(10);
el('point_longitude').value = lng.toFixed(10);
document.querySelector('input[name="point_buka_24_jam"][value="Tidak"]').checked = true;
el('pointModalTitle').textContent = 'Tambah Titik';
previewMarker = L.marker(e.latlng, { icon: createPointIcon(false) }).addTo(map);
showModal('pointModal');
return;
}
if (mode === 'fasilitas_publik') {
el('fp_id').value = '';
el('fp_nama').value = '';
el('fp_jenis').value = 'Masjid';
el('fp_kontak').value = '';
el('fp_radius').value = 300;
el('fp_radius_text').textContent = '300';
el('fp_latitude').value = lat.toFixed(10);
el('fp_longitude').value = lng.toFixed(10);
el('fpModalTitle').textContent = 'Tambah Fasilitas Publik';
if (previewMarker) map.removeLayer(previewMarker);
if (previewCircle) map.removeLayer(previewCircle);
previewMarker = L.marker(e.latlng, { icon: createFPIcon() }).addTo(map);
previewCircle = L.circle(e.latlng, {
radius: 300, color: '#e53935', fillColor: '#ef9a9a',
fillOpacity: 0.12, weight: 2, dashArray: '6 4'
}).addTo(map);
reverseGeocode(lat, lng);
showModal('fasilitasPublikModal');
return;
}
if (mode === 'penduduk_miskin') {
el('pm_id').value = '';
el('pm_nama').value = '';
el('pm_nik').value = '';
el('pm_keterangan').value = '';
el('pm_latitude').value = lat.toFixed(10);
el('pm_longitude').value = lng.toFixed(10);
el('pmModalTitle').textContent = 'Tambah Penduduk Miskin';
const cek = cekJangkauan(lat, lng);
updateStatusJangkauan(cek); if (previewMarker) map.removeLayer(previewMarker);
previewMarker = L.marker(e.latlng, {
icon: createPendudukIcon(cek.status === 'Dalam Jangkauan')
}).addTo(map);
showModal('pendudukModal');
return;
}
// Khusus mode Menggambar (Jalan & Polygon), gunakan Timer
if (mode === 'jalan' || mode === 'polygon') {
if (clickTimer) clearTimeout(clickTimer); // Hapus timer lama jika ada klik berdekatan
clickTimer = setTimeout(() => {
// Eksekusi jika benar-benar single-click
if (mode === 'jalan') {
jalanLatLngs.push(e.latlng);
updateJalanPreview();
} else if (mode === 'polygon') {
polygonLatLngs.push(e.latlng);
updatePolygonPreview();
}
}, 250); // Delay 250 milidetik
}
});
map.on('dblclick', () => {
if (clickTimer) {
clearTimeout(clickTimer); // Batalkan single-click jika terdeteksi double-click
clickTimer = null;
}
if (mode === 'jalan') {
if (jalanLatLngs.length < 2) { showToast('Minimal 2 titik untuk jalan.', 'warning'); return; }
updateJalanPreview();
showModal('jalanModal');
}
if (mode === 'polygon') {
if (polygonLatLngs.length < 3) { showToast('Minimal 3 titik untuk polygon.', 'warning'); return; }
updatePolygonPreview();
showModal('polygonModal');
}
});
map.on('contextmenu', () => {
if (mode === 'jalan' && jalanLatLngs.length > 0) {
jalanLatLngs.pop(); updateJalanPreview(); return;
}
if (mode === 'polygon' && polygonLatLngs.length > 0) {
polygonLatLngs.pop(); updatePolygonPreview(); return;
}
if (mode === 'point') {
clearAllPreview();
hideModal('pointModal');
}
});
map.on('locationfound', e => {
showToast('Lokasi ditemukan!', 'success');
L.marker(e.latlng)
.addTo(map)
.bindPopup('')
.openPopup();
});
map.on('locationerror', () => {
showToast('Tidak dapat mengakses lokasi. Pastikan izin lokasi browser aktif.', 'error');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { clearAllPreview(); hideAllModals(); }
});
/* -------------------------------------------------------
STATUS JANGKAUAN UI
------------------------------------------------------- */
function updateStatusJangkauan(cek) {
const elStatus = el('pm_status');
const elDitangani = el('pm_ditangani');
elStatus.textContent = cek.status;
elStatus.className = 'sj-value ' + (cek.status === 'Dalam Jangkauan' ? 'dalam' : 'luar');
elDitangani.textContent = cek.nama ? `Ditangani oleh: ${cek.nama}` : 'Tidak dalam jangkauan Fasilitas Publik manapun.';
}
/* -------------------------------------------------------
TOOL BUTTONS
------------------------------------------------------- */
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
const m = btn.dataset.mode;
if (m) setMode(m);
});
});
/* -------------------------------------------------------
SIDEBAR TOGGLE
------------------------------------------------------- */
/* -- Toggle Sidebar -------------------------------------- */
const sidebar = el('sidebar');
const mapContainer = el('mapContainer');
const sidebarOpenBtn= el('sidebarOpenBtn');
const isMobile = () => window.innerWidth <= 680;
el('sidebarToggle').addEventListener('click', () => {
if (isMobile()) {
sidebar.classList.remove('mobile-open');
} else {
sidebar.classList.add('collapsed');
mapContainer.classList.add('full-width');
sidebarOpenBtn.classList.remove('hidden');
map.invalidateSize();
}
});
sidebarOpenBtn.addEventListener('click', () => {
if (isMobile()) {
sidebar.classList.add('mobile-open');
} else {
sidebar.classList.remove('collapsed');
mapContainer.classList.remove('full-width');
sidebarOpenBtn.classList.add('hidden');
map.invalidateSize();
}
});
/* -------------------------------------------------------
LAYER CHECKBOXES
------------------------------------------------------- */
function bindLayerToggle(checkboxId, layerGroup) {
const cb = el(checkboxId);
if (!cb) return;
cb.addEventListener('change', () => {
if (cb.checked) {
if (!map.hasLayer(layerGroup)) layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
});
}
bindLayerToggle('layerFasilitasPublik', lgFasilitasPublik);
bindLayerToggle('layerPendudukDalam', lgPendudukDalam);
bindLayerToggle('layerPendudukLuar', lgPendudukLuar);
bindLayerToggle("layerSpbu24", lgSpbu24);
bindLayerToggle("layerSpbuTidak", lgSpbuTidak);
bindLayerToggle("layerJalan", lgJalan);
bindLayerToggle("layerPolygon", lgPolygon);
if (el('layerGeoJalan')) {
el('layerGeoJalan').addEventListener('change', function () {
if (geoJalanLayer) { this.checked ? geoJalanLayer.addTo(map) : map.removeLayer(geoJalanLayer); }
});
}
if (el('layerKecamatan')) {
el('layerKecamatan').addEventListener('change', function () {
if (geoKecamatanLayer) { this.checked ? geoKecamatanLayer.addTo(map) : map.removeLayer(geoKecamatanLayer); }
});
}
if (el('layerSungai')) {
el('layerSungai').addEventListener('change', function () {
if (geoSungaiLayer) { this.checked ? geoSungaiLayer.addTo(map) : map.removeLayer(geoSungaiLayer); }
});
}
/* -------------------------------------------------------
RADIUS SLIDER -> PREVIEW
------------------------------------------------------- */
el('fp_radius').addEventListener('input', function () {
const r = parseInt(this.value, 10);
el('fp_radius_text').textContent = r;
if (previewCircle) previewCircle.setRadius(r);
});
/* -------------------------------------------------------
MODAL CLOSE BUTTONS
------------------------------------------------------- */
[
['closePointModal', 'pointModal'],
['cancelPointModal', 'pointModal'],
['closeJalanModal', 'jalanModal'],
['cancelJalanModal', 'jalanModal'],
['closePolygonModal', 'polygonModal'],
['cancelPolygonModal', 'polygonModal'],
['closefasilitasPublikModal', 'fasilitasPublikModal'],
['cancelfasilitasPublikModal','fasilitasPublikModal'],
['closePendudukModal', 'pendudukModal'],
['cancelPendudukModal', 'pendudukModal'],
].forEach(([btnId, modalId]) => {
const btn = el(btnId);
if (btn) btn.addEventListener('click', () => {
hideModal(modalId);
clearAllPreview();
});
});
el('modalBackdrop').addEventListener('click', () => {
hideAllModals();
clearAllPreview();
});
/* Konfirmasi hapus */
el('confirmDelete').addEventListener('click', () => {
if (typeof deleteCallback === 'function') deleteCallback();
hideModal('deleteModal');
deleteCallback = null;
});
el('cancelDelete').addEventListener('click', () => {
hideModal('deleteModal');
deleteCallback = null;
});
/* -------------------------------------------------------
SAVE: POINT
------------------------------------------------------- */
el('btnSavePoint').addEventListener('click', () => {
const nama = el('point_nama').value.trim();
const no_wa = el('point_no_wa').value.trim();
const radio = document.querySelector('input[name="point_buka_24_jam"]:checked');
const lat = el('point_latitude').value.trim();
const lng = el('point_longitude').value.trim();
if (!nama || !no_wa || !radio || !lat || !lng) {
showAlert('Data Tidak Lengkap', 'Peringatan: Semua kolom wajib diisi dan Anda harus mengklik peta untuk menentukan lokasi SPBU terlebih dahulu.');
return;
}
const id = el('point_id').value;
const url = id ? 'point/update.php' : 'point/simpan.php';
const fd = new FormData();
if (id) fd.append('id', id);
fd.append('nama', nama);
fd.append('no_wa', no_wa);
fd.append('buka_24_jam', radio.value);
fd.append('latitude', lat);
fd.append('longitude', lng);
postData(url, fd).then(res => {
if (res.status === 'success') {
if (previewMarker) map.removeLayer(previewMarker);
if (id) {
const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.points[idx] = res.data;
lgSpbu24.clearLayers();
lgSpbuTidak.clearLayers();
APP_DATA.points.forEach(renderPoint);
} else {
APP_DATA.points.push(res.data);
renderPoint(res.data);
}
updateDashboardStats();
if(window.__rebuildCards) window.__rebuildCards();
hideModal('pointModal');
showToast(id ? 'Titik berhasil diperbarui.' : 'Titik berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan titik.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: JALAN
------------------------------------------------------- */
el('btnSaveJalan').addEventListener('click', () => {
const nama = el('nama_jalan').value.trim();
const status = el('status_jalan').value;
const panjang = el('panjang_meter').value;
const koordinat = el('koordinat_jalan').value.trim();
if (!nama || !status || jalanLatLngs.length < 2) {
showAlert('Data Tidak Lengkap', 'Peringatan: Nama, status jalan wajib diisi dan Anda harus menggambar garis (minimal 2 titik) di peta.');
return;
}
const fd = new FormData();
fd.append('nama_jalan', nama);
fd.append('status_jalan', status);
fd.append('panjang_meter', panjang || '0');
fd.append('koordinat', koordinat);
postData('jalan/simpan.php', fd).then(res => {
if (res.status === 'success') {
clearAllPreview();
APP_DATA.jalan.push(res.data);
renderJalan(res.data);
hideModal('jalanModal');
el('nama_jalan').value = '';
el('status_jalan').value = '';
if(window.__rebuildCards) window.__rebuildCards();
showToast('Jalan berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan jalan.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: POLYGON
------------------------------------------------------- */
el('btnSavePolygon').addEventListener('click', () => {
const nama = el('nama_parsil').value.trim();
const status = el('status_kepemilikan').value;
const luas = el('luas_tanah').value;
const koordinat = el('koordinat_polygon').value.trim();
if (!nama || !status || polygonLatLngs.length < 3) {
showAlert('Data Tidak Lengkap', 'Peringatan: Nama, status kepemilikan wajib diisi dan Anda harus menggambar area (minimal 3 titik) di peta.');
return;
}
const fd = new FormData();
fd.append('nama_parsil', nama);
fd.append('status_kepemilikan', status);
fd.append('luas_tanah', luas || '0');
fd.append('koordinat', koordinat);
postData('polygon/simpan.php', fd).then(res => {
if (res.status === 'success') {
clearAllPreview();
APP_DATA.polygons.push(res.data);
renderPolygon(res.data);
hideModal('polygonModal');
el('nama_parsil').value = '';
el('status_kepemilikan').value = '';
if(window.__rebuildCards) window.__rebuildCards();
showToast('Polygon berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan polygon.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: Fasilitas Publik
------------------------------------------------------- */
el('btnSaveFasilitasPublik').addEventListener('click', () => {
const nama = el('fp_nama').value.trim();
const lat = el('fp_latitude').value.trim();
const lng = el('fp_longitude').value.trim();
if (!nama || !lat || !lng) {
showAlert('Data Tidak Lengkap', 'Peringatan: Semua kolom wajib diisi dan Anda harus mengklik peta untuk menentukan lokasi Fasilitas Publik terlebih dahulu.');
return;
}
const id = el('fp_id').value;
const url = id ? 'fasilitas_publik/update.php' : 'fasilitas_publik/simpan.php';
const fd = new FormData();
if (id) fd.append('id', id);
fd.append('nama', nama);
fd.append('jenis', el('fp_jenis').value);
fd.append('alamat', el('fp_alamat').value.trim());
fd.append('latitude', lat);
fd.append('longitude', lng);
fd.append('radius', el('fp_radius').value);
fd.append('kontak', el('fp_kontak').value.trim());
postData(url, fd).then(res => {
if (res.status === 'success') {
if (previewMarker) map.removeLayer(previewMarker);
if (previewCircle) map.removeLayer(previewCircle);
previewMarker = null; previewCircle = null;
// Panggil refreshDataModul secara real-time tanpa reload
refreshDataModul();
hideModal('fasilitasPublikModal');
showToast(id ? 'Fasilitas Publik diperbarui.' : 'Fasilitas Publik berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan.', 'error');
}
});
});
/* -------------------------------------------------------
SAVE: PENDUDUK MISKIN
------------------------------------------------------- */
el('btnSavePenduduk').addEventListener('click', () => {
const nama = el('pm_nama').value.trim();
const lat = el('pm_latitude').value.trim();
const lng = el('pm_longitude').value.trim();
if (!nama || !lat || !lng) {
showAlert('Data Tidak Lengkap', 'Peringatan: Semua kolom wajib diisi dan Anda harus mengklik peta untuk menentukan lokasi Penduduk terlebih dahulu.');
return;
}
const cek = cekJangkauan(parseFloat(lat), parseFloat(lng));
const id = el('pm_id').value;
const url = id ? 'penduduk_miskin/update.php' : 'penduduk_miskin/simpan.php';
const fd = new FormData();
if (id) fd.append('id', id);
fd.append('nama', nama);
fd.append('nik', el('pm_nik').value.trim());
fd.append('keterangan', el('pm_keterangan').value.trim());
fd.append('latitude', lat);
fd.append('longitude', lng);
fd.append('status_jangkauan', cek.status);
fd.append('status_bantuan', el('pm_status_bantuan').value);
fd.append('fasilitas_publik_id', cek.id || '');
const fileRumah = el('pm_foto_rumah').files[0];
if (fileRumah) fd.append('foto_rumah', fileRumah);
const fileKk = el('pm_foto_kk').files[0];
if (fileKk) fd.append('foto_kk', fileKk);
postData(url, fd).then(res => {
if (res.status === 'success') {
if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; }
// Panggil refreshDataModul secara real-time tanpa reload
refreshDataModul();
hideModal('pendudukModal');
showToast(id ? 'Data penduduk diperbarui.' : 'Penduduk miskin berhasil disimpan.');
} else {
showToast(res.message || 'Gagal menyimpan.', 'error');
}
});
});
/* -------------------------------------------------------
GLOBAL: HAPUS DATA
------------------------------------------------------- */
window.hapusData = function (tipe, id, pesan) {
showDeleteConfirm(pesan || 'Hapus data ini?', () => {
const urlMap = {
point: 'point/hapus.php',
jalan: 'jalan/hapus.php',
polygon: 'polygon/hapus.php',
fasilitas_publik: 'fasilitas_publik/hapus.php',
penduduk_miskin: 'penduduk_miskin/hapus.php',
};
const fd = new FormData();
fd.append('id', id);
postData(urlMap[tipe], fd).then(res => {
if (res.status === 'success') {
map.closePopup(); // Tutup popup di map
showToast('Data berhasil dihapus.', 'success');
if(window.__rebuildCards) setTimeout(() => window.__rebuildCards(), 150);
// Hapus elemen dari Peta TANPA refresh web
if (tipe === 'point') {
const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.points.splice(idx, 1);
lgSpbu24.clearLayers();
lgSpbuTidak.clearLayers();
APP_DATA.points.forEach(renderPoint);
updateDashboardStats();
} else if (tipe === 'jalan') {
const idx = APP_DATA.jalan.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.jalan.splice(idx, 1);
lgJalan.clearLayers();
APP_DATA.jalan.forEach(renderJalan);
} else if (tipe === 'polygon') {
const idx = APP_DATA.polygons.findIndex(x => String(x.id) === String(id));
if (idx > -1) APP_DATA.polygons.splice(idx, 1);
lgPolygon.clearLayers();
APP_DATA.polygons.forEach(renderPolygon);
} else if (tipe === 'fasilitas_publik' || tipe === 'penduduk_miskin') {
// Khusus modul bantuan, panggil refresh penuh
refreshDataModul();
}
} else {
showToast(res.message || 'Gagal menghapus.', 'error');
}
});
});
};
/* -------------------------------------------------------
GLOBAL: UPDATE LOKASI DARI DRAG MARKER
------------------------------------------------------- */
window.updateLocation = function(tipe, id, lat, lng, nama) {
const urlMap = {
point: 'point/update_lokasi.php',
penduduk_miskin: 'penduduk_miskin/update_lokasi.php'
};
const fd = new FormData();
fd.append('id', id);
fd.append('latitude', lat.toFixed(10));
fd.append('longitude', lng.toFixed(10));
if (tipe === 'penduduk_miskin') {
const cek = cekJangkauan(lat, lng);
fd.append('status_jangkauan', cek.status);
fd.append('fasilitas_publik_id', cek.id || '');
}
postData(urlMap[tipe], fd).then(res => {
if (res.status === 'success') {
showToast('Lokasi ' + nama + ' berhasil diperbarui.', 'success');
if (tipe === 'penduduk_miskin') {
refreshDataModul();
} else if (tipe === 'point') {
const idx = APP_DATA.points.findIndex(x => String(x.id) === String(id));
if (idx > -1) {
APP_DATA.points[idx].latitude = lat;
APP_DATA.points[idx].longitude = lng;
}
}
} else {
showToast('Gagal mengubah lokasi ' + nama, 'error');
if (tipe === 'penduduk_miskin') refreshDataModul();
}
});
};
/* -------------------------------------------------------
GLOBAL: EDIT Fasilitas Publik
------------------------------------------------------- */
window.editFasilitasPublik = function (id) {
fetch(`api/get_fasilitas_publik.php?id=${id}`)
.then(r => r.json())
.then(res => {
if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; }
const d = res.data;
el('fp_id').value = d.id;
el('fp_nama').value = d.nama;
el('fp_jenis').value = d.jenis || 'Masjid';
el('fp_alamat').value = d.alamat || '';
el('fp_kontak').value = d.kontak || '';
el('fp_latitude').value = d.latitude;
el('fp_longitude').value= d.longitude;
el('fp_radius').value = d.radius;
el('fp_radius_text').textContent = d.radius;
el('fpModalTitle').textContent = 'Edit Fasilitas Publik';
// Preview di peta
if (previewMarker) map.removeLayer(previewMarker);
if (previewCircle) map.removeLayer(previewCircle);
const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude));
previewMarker = L.marker(ll, { icon: createFPIcon() }).addTo(map);
previewCircle = L.circle(ll, {
radius: parseInt(d.radius),
color: '#e53935', fillColor: '#ef9a9a', fillOpacity: 0.12,
weight: 2, dashArray: '6 4', interactive: false
}).addTo(map);
map.setView(ll, 16);
map.closePopup();
showModal('fasilitasPublikModal');
})
.catch(() => showToast('Gagal memuat data.', 'error'));
};
/* -------------------------------------------------------
GLOBAL: EDIT PENDUDUK
------------------------------------------------------- */
window.editPenduduk = function (id) {
fetch(`api/get_penduduk.php?id=${id}`)
.then(r => r.json())
.then(res => {
if (res.status !== 'success') { showToast('Data tidak ditemukan.', 'error'); return; }
const d = res.data;
el('pm_id').value = d.id;
el('pm_nama').value = d.nama;
el('pm_nik').value = d.nik || '';
el('pm_keterangan').value = d.keterangan || '';
el('pm_status_bantuan').value = d.status_bantuan || 'Belum Disalurkan';
el('pm_latitude').value = d.latitude;
el('pm_longitude').value = d.longitude;
el('pmModalTitle').textContent = 'Edit Penduduk Miskin';
const cek = cekJangkauan(parseFloat(d.latitude), parseFloat(d.longitude));
updateStatusJangkauan(cek);
if (previewMarker) map.removeLayer(previewMarker);
const ll = L.latLng(parseFloat(d.latitude), parseFloat(d.longitude));
previewMarker = L.marker(ll, {
icon: createPendudukIcon(cek.status === 'Dalam Jangkauan')
}).addTo(map);
map.setView(ll, 16);
map.closePopup();
showModal('pendudukModal');
})
.catch(() => showToast('Gagal memuat data.', 'error'));
};
/* -------------------------------------------------------
UTILITY: POST DATA
------------------------------------------------------- */
function postData(url, formData) {
return fetch(url, { method: 'POST', body: formData })
.then(r => r.json())
.catch(err => {
console.error(err);
showToast('Terjadi kesalahan koneksi.', 'error');
return { status: 'error', message: 'Koneksi gagal.' };
});
}
/* -------------------------------------------------------
FITUR PENCARIAN (SEARCHING)
------------------------------------------------------- */
const searchInput = el('searchInput');
const searchResults = el('searchResults');
if (searchInput && searchResults) {
searchInput.addEventListener('input', function() {
const query = this.value.toLowerCase().trim();
searchResults.innerHTML = '';
// Minimal ketik 2 huruf baru mencari
if (query.length < 2) {
searchResults.classList.add('hidden');
return;
}
let results = [];
// 1. Cari Titik Biasa
APP_DATA.points.forEach(item => {
if (item.nama.toLowerCase().includes(query)) {
results.push({ title: item.nama, type: '📍 Titik Lokasi', lat: item.latitude, lng: item.longitude });
}
});
// 2. Cari Fasilitas Publik
fasilitasPublikList.forEach(item => {
if (item.nama.toLowerCase().includes(query)) {
results.push({ title: item.nama, type: `🏢 Fasilitas Publik (${item.jenis})`, lat: item.latitude, lng: item.longitude });
}
});
// 3. Cari Penduduk Miskin (Berdasarkan Nama atau NIK)
pendudukList.forEach(p => {
if (p.data.nama.toLowerCase().includes(query) || (p.data.nik && p.data.nik.includes(query))) {
results.push({ title: p.data.nama, type: '👤 Penduduk Miskin', lat: p.data.latitude, lng: p.data.longitude });
}
});
// 4. Cari Jalan
APP_DATA.jalan.forEach(item => {
if (item.nama_jalan.toLowerCase().includes(query)) {
try {
const feat = JSON.parse(item.koordinat);
// Ambil titik koordinat pertama dari garis
const coords = feat.geometry.coordinates[0];
results.push({ title: item.nama_jalan, type: `🛣️ Jalan (${item.status_jalan})`, lat: coords[1], lng: coords[0] });
} catch(e) {}
}
});
// 5. Cari Polygon
APP_DATA.polygons.forEach(item => {
if (item.nama_parsil.toLowerCase().includes(query)) {
try {
const feat = JSON.parse(item.koordinat);
// Ambil titik koordinat pertama dari polygon
const coords = feat.geometry.coordinates[0][0];
results.push({ title: item.nama_parsil, type: `📐 Polygon (${item.status_kepemilikan})`, lat: coords[1], lng: coords[0] });
} catch(e) {}
}
});
// Tampilkan Hasil
if (results.length > 0) {
results.forEach(res => {
const div = document.createElement('div');
div.className = 'search-item';
div.innerHTML = `
${res.title}
${res.type}
`;
// Aksi saat item hasil dicentang
div.addEventListener('click', () => {
// Animasi terbang ke lokasi
map.flyTo([parseFloat(res.lat), parseFloat(res.lng)], 18, {
animate: true,
duration: 1.5
});
searchResults.classList.add('hidden');
searchInput.value = res.title; // Isi kotak input dengan nama yang dipilih
// Khusus tampilan HP: Sembunyikan sidebar agar peta terlihat langsung
if (window.innerWidth <= 680) {
el('sidebar').classList.remove('mobile-open');
}
});
searchResults.appendChild(div);
});
} else {
searchResults.innerHTML = 'Tidak ada data ditemukan
';
}
searchResults.classList.remove('hidden');
});
// Menyembunyikan dropdown jika klik di tempat lain
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.classList.add('hidden');
}
});
}
/* -- Init ------------------------------------------------ */
/* -- Modul Filter & Init -------------------------------- */
function setupModul(modul) {
const tools = ['btnToolPoint', 'btnToolJalan', 'btnToolPolygon', 'btnToolFasilitasPublik', 'btnToolPenduduk'];
const layers = ['layerFasilitasPublik', 'layerPendudukDalam', 'layerPendudukLuar', 'layerSpbu24', 'layerSpbuTidak', 'layerJalan', 'layerPolygon', 'layerGeoJalan', 'layerKecamatan', 'layerSungai'];
// Sembunyikan semua kecuali utuh
if (modul !== 'utuh') {
tools.forEach(t => { if(el(t)) el(t).style.display = 'none'; });
layers.forEach(l => {
let lgName = '';
if (l === 'layerSpbu24') lgName = 'lgSpbu24';
else if (l === 'layerSpbuTidak') lgName = 'lgSpbuTidak';
else if (l === 'layerPendudukDalam') lgName = 'lgPendudukDalam';
else if (l === 'layerPendudukLuar') lgName = 'lgPendudukLuar';
else lgName = l.replace('layer','lg').replace('Geo','geo').replace('Kecamatan','geoKecamatan').replace('Sungai','geoSungai');
try { if(eval(lgName)) map.removeLayer(eval(lgName)); } catch(e){}
if(el(l)) {
el(l).parentElement.style.display = 'none';
el(l).checked = false;
}
});
}
if (modul === 'spbu') {
if (USER_ROLE !== 'guest') {
if(el('btnToolPoint')) el('btnToolPoint').style.display = 'inline-flex';
}
if(el('layerSpbu24')) { el('layerSpbu24').parentElement.style.display = 'flex'; el('layerSpbu24').checked = true; lgSpbu24.addTo(map); }
if(el('layerSpbuTidak')) { el('layerSpbuTidak').parentElement.style.display = 'flex'; el('layerSpbuTidak').checked = true; lgSpbuTidak.addTo(map); }
setMode(USER_ROLE === 'guest' ? 'lokasi' : 'point');
} else if (modul === 'bantuan') {
if (USER_ROLE !== 'guest') {
if(el('btnToolPenduduk')) el('btnToolPenduduk').style.display = 'inline-flex';
if(el('btnToolFasilitasPublik')) el('btnToolFasilitasPublik').style.display = 'inline-flex';
}
if(el('layerFasilitasPublik')) { el('layerFasilitasPublik').parentElement.style.display = 'flex'; el('layerFasilitasPublik').checked = true; lgFasilitasPublik.addTo(map); }
if(el('layerPendudukDalam')) { el('layerPendudukDalam').parentElement.style.display = 'flex'; el('layerPendudukDalam').checked = true; lgPendudukDalam.addTo(map); }
if(el('layerPendudukLuar')) { el('layerPendudukLuar').parentElement.style.display = 'flex'; el('layerPendudukLuar').checked = true; lgPendudukLuar.addTo(map); }
setMode(USER_ROLE === 'guest' ? 'lokasi' : 'penduduk_miskin');
} else if (modul === 'tematik') {
if(el('layerGeoJalan')) { el('layerGeoJalan').parentElement.style.display = 'flex'; el('layerGeoJalan').checked = true; if(geoJalanLayer) geoJalanLayer.addTo(map); }
if(el('layerKecamatan')) { el('layerKecamatan').parentElement.style.display = 'flex'; el('layerKecamatan').checked = true; if(geoKecamatanLayer) geoKecamatanLayer.addTo(map); }
if(el('layerSungai')) { el('layerSungai').parentElement.style.display = 'flex'; el('layerSungai').checked = true; if(geoSungaiLayer) geoSungaiLayer.addTo(map); }
setMode('lokasi');
} else if (modul === 'digitasi') {
if (USER_ROLE !== 'guest') {
if(el('btnToolJalan')) el('btnToolJalan').style.display = 'inline-flex';
if(el('btnToolPolygon')) el('btnToolPolygon').style.display = 'inline-flex';
if(el('btnToolPoint')) el('btnToolPoint').style.display = 'inline-flex';
}
if(el('layerSpbu24')) { el('layerSpbu24').parentElement.style.display = 'flex'; el('layerSpbu24').checked = true; lgSpbu24.addTo(map); }
if(el('layerSpbuTidak')) { el('layerSpbuTidak').parentElement.style.display = 'flex'; el('layerSpbuTidak').checked = true; lgSpbuTidak.addTo(map); }
if(el('layerJalan')) { el('layerJalan').parentElement.style.display = 'flex'; el('layerJalan').checked = true; lgJalan.addTo(map); }
if(el('layerPolygon')) { el('layerPolygon').parentElement.style.display = 'flex'; el('layerPolygon').checked = true; lgPolygon.addTo(map); }
setMode(USER_ROLE === 'guest' ? 'lokasi' : 'polygon');
} else { // utuh
if (typeof USER_ROLE !== 'undefined') {
if (USER_ROLE === 'guest') setMode('lokasi');
else if (USER_ROLE === 'petugas') setMode('penduduk_miskin');
else setMode('point');
} else {
setMode('point');
}
}
// Force hide section tools if guest
if (typeof USER_ROLE !== 'undefined' && USER_ROLE === 'guest' && el('sectionTools')) {
el('sectionTools').classList.add('hidden');
}
}
/* -- Alert / Modal Helper -------------------------------- */
window.showAlert = function(title, message) {
el('alertTitle').textContent = title;
el('alertMessage').innerHTML = message;
el('alertModal').classList.remove('hidden');
el('modalBackdrop').classList.remove('hidden');
};
if(el('btnAlertOk')) {
el('btnAlertOk').addEventListener('click', () => {
el('alertModal').classList.add('hidden');
// Hanya sembunyikan backdrop jika tidak ada modal lain yang terbuka
if(document.querySelectorAll('.modal:not(.hidden)').length === 0) {
el('modalBackdrop').classList.add('hidden');
}
});
}
// Set mode awal
try {
setupModul(APP_DATA.modul || 'utuh');
} catch(e) {
console.error("Error in setupModul:", e);
}
/* ===============================================================
RIGHT SIDEBAR – PANEL KANAN
Data List Cards per Modul, flyTo, Search Filter
=============================================================== */
const rightSidebar = document.getElementById('rightSidebar');
const rightSidebarClose = document.getElementById('rightSidebarClose');
const rightSidebarToggle = document.getElementById('rightSidebarToggleBtn');
const rightCardList = document.getElementById('rightCardList');
const rightCardCount = document.getElementById('rightCardCount');
const rightSearchInput = document.getElementById('rightSearchInput');
/* ── Marker registry: map from data-id + type → Leaflet marker/layer ── */
const markerRegistry = {};
/* ── Register markers after render ─────────────────────────────── */
// Override renderPoint to register
const _origRenderPoint = renderPoint;
function renderPointAndRegister(item) {
const mk = _origRenderPoint(item);
if (mk) markerRegistry['point_' + item.id] = { marker: mk, lat: parseFloat(item.latitude), lng: parseFloat(item.longitude) };
return mk;
}
/* We need to patch the existing render calls */
/* Re-register all existing data loaded at init */
function registerAllMarkers() {
/* SPBU / Points */
lgSpbu24.eachLayer(mk => {
// match by iterating APP_DATA.points
});
/* We'll use a direct lookup approach by storing references during renderPoint */
}
/* ── Right Sidebar Toggle ─────────────────────────────────────── */
let rightOpen = true;
function openRightSidebar() {
rightSidebar.classList.remove('right-collapsed');
el('mapContainer').classList.add('with-right-sidebar');
rightSidebarToggle.classList.add('hidden');
map.invalidateSize();
rightOpen = true;
}
function closeRightSidebar() {
rightSidebar.classList.add('right-collapsed');
el('mapContainer').classList.remove('with-right-sidebar');
rightSidebarToggle.classList.remove('hidden');
map.invalidateSize();
rightOpen = false;
}
if (rightSidebarClose) rightSidebarClose.addEventListener('click', closeRightSidebar);
if (rightSidebarToggle) rightSidebarToggle.addEventListener('click', openRightSidebar);
// On mobile or tematik: right sidebar starts closed
if (window.innerWidth <= 680 || APP_DATA.modul === 'tematik') {
closeRightSidebar();
} else {
openRightSidebar();
}
/* ── Data Card Factory ─────────────────────────────────────────── */
function makeDataCard({ icon, iconClass, name, sub, badge, badgeClass, footerHtml, coords, onClick }) {
const card = document.createElement('div');
card.className = 'data-card';
const hdr = document.createElement('div');
hdr.className = 'data-card-header';
const iconWrap = document.createElement('div');
iconWrap.className = `data-card-icon-wrap ${iconClass}`;
iconWrap.textContent = icon;
const body = document.createElement('div');
body.className = 'data-card-body';
const nameEl = document.createElement('div');
nameEl.className = 'data-card-name';
nameEl.title = name;
nameEl.textContent = name;
const subEl = document.createElement('div');
subEl.className = 'data-card-sub';
subEl.textContent = sub;
body.appendChild(nameEl);
body.appendChild(subEl);
hdr.appendChild(iconWrap);
hdr.appendChild(body);
if (badge) {
const badgeEl = document.createElement('span');
badgeEl.className = `data-card-badge ${badgeClass}`;
badgeEl.textContent = badge;
hdr.appendChild(badgeEl);
}
card.appendChild(hdr);
if (coords) {
const coordEl = document.createElement('div');
coordEl.className = 'data-card-coords';
coordEl.innerHTML = ` ${coords}`;
card.appendChild(coordEl);
}
if (footerHtml) {
const footEl = document.createElement('div');
footEl.className = 'data-card-footer';
footEl.innerHTML = footerHtml;
card.appendChild(footEl);
}
card.addEventListener('click', onClick);
return card;
}
function emptyState(icon, title, sub) {
const div = document.createElement('div');
div.className = 'empty-state';
div.innerHTML = `
${icon}
${title}
${sub}
`;
return div;
}
function updateCount(count) {
if (rightCardCount) rightCardCount.textContent = `${count} entri`;
}
/* ── Fly & Open Popup ─────────────────────────────────────────── */
function flyToAndOpenPopup(lat, lng, markerOrLayer) {
map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 });
if (markerOrLayer) {
setTimeout(() => {
try { markerOrLayer.openPopup(); } catch(e) {}
}, 1400);
}
// On mobile: close right sidebar to see map
if (window.innerWidth <= 680) closeRightSidebar();
}
/* ── Build Cards by Modul ─────────────────────────────────────── */
/** Find Leaflet marker from a layer group by lat/lng */
function findMarkerInGroup(lg, lat, lng) {
let found = null;
lg.eachLayer(mk => {
if (found) return;
const ll = mk.getLatLng ? mk.getLatLng() : null;
if (ll && Math.abs(ll.lat - lat) < 0.0001 && Math.abs(ll.lng - lng) < 0.0001) found = mk;
});
return found;
}
function buildCardsSpbu(data, container) {
if (!data.length) {
container.appendChild(emptyState('⛽', 'Belum Ada SPBU', 'Tambahkan data SPBU menggunakan alat peta.'));
return 0;
}
data.forEach(item => {
const lat = parseFloat(item.latitude), lng = parseFloat(item.longitude);
const is24 = item.buka_24_jam === 'Ya';
const card = makeDataCard({
icon: '⛽',
iconClass: 'dc-icon-spbu',
name: item.nama,
sub: `WA: ${item.no_wa || '-'}`,
badge: is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam',
badgeClass: is24 ? 'badge-24jam' : 'badge-tidak24',
coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(is24 ? lgSpbu24 : lgSpbuTidak, lat, lng);
flyToAndOpenPopup(lat, lng, mk);
}
});
container.appendChild(card);
});
return data.length;
}
function buildCardsBantuan(pmData, container) {
if (!pmData.length) {
container.appendChild(emptyState('👥', 'Belum Ada Data Penduduk', 'Tambahkan data penduduk miskin menggunakan alat peta.'));
return 0;
}
pmData.forEach(item => {
const lat = parseFloat(item.data.latitude), lng = parseFloat(item.data.longitude);
const isDalam = item.data.status_jangkauan === 'Dalam Jangkauan';
const card = makeDataCard({
icon: '👤',
iconClass: 'dc-icon-pm',
name: item.data.nama,
sub: `NIK: ${item.data.nik || '-'}`,
badge: isDalam ? 'Dalam Jangkauan' : 'Luar Jangkauan',
badgeClass: isDalam ? 'badge-dalam-jk' : 'badge-luar-jk',
footerHtml: `
Status Penyaluran
${item.data.status_bantuan || 'Belum Disalurkan'}
`,
coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(isDalam ? lgPendudukDalam : lgPendudukLuar, lat, lng);
flyToAndOpenPopup(lat, lng, mk);
}
});
container.appendChild(card);
});
return pmData.length;
}
function buildCardsTematik(container) {
const items = [];
// Fasilitas Publik
fasilitasPublikList.forEach(fp => {
const lat = parseFloat(fp.latitude), lng = parseFloat(fp.longitude);
items.push({ icon: '🏢', iconClass: 'dc-icon-fp', name: fp.nama, sub: fp.jenis || 'Fasilitas Publik', badge: null, lat, lng, group: lgFasilitasPublik });
});
if (!items.length) {
container.appendChild(emptyState('🗺️', 'Belum Ada Layer', 'Layer tematik (Kecamatan, Sungai, Jalan) belum tersedia.'));
return 0;
}
items.forEach(it => {
const card = makeDataCard({
icon: it.icon,
iconClass: it.iconClass,
name: it.name,
sub: it.sub,
badge: null,
badgeClass: '',
coords: `${it.lat.toFixed(5)}, ${it.lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(it.group, it.lat, it.lng);
flyToAndOpenPopup(it.lat, it.lng, mk);
}
});
container.appendChild(card);
});
return items.length;
}
function buildCardsDigitasi(jalanData, polygonData, container) {
let count = 0;
// Polygons first
if (polygonData.length) {
polygonData.forEach(item => {
const badgeMap = { SHM: 'badge-shm', HGB: 'badge-hgb', HGU: 'badge-hgu', HP: 'badge-hp' };
let lat = 0, lng = 0;
try {
const feat = JSON.parse(item.koordinat);
const coords = feat.geometry.coordinates[0][0];
lat = coords[1]; lng = coords[0];
} catch(e) {}
const card = makeDataCard({
icon: '⬜',
iconClass: 'dc-icon-poly',
name: item.nama_parsil,
sub: `Luas: ${parseFloat(item.luas_tanah).toFixed(2)} m²`,
badge: item.status_kepemilikan,
badgeClass: badgeMap[item.status_kepemilikan] || 'badge-geo',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => {
if (!lat) return;
const warna = warnaPolygon(item.status_kepemilikan);
// find polygon in lgPolygon
let found = null;
lgPolygon.eachLayer(lyr => {
if (found) return;
const c = lyr.getCenter ? lyr.getCenter() : null;
if (c && Math.abs(c.lat - lat) < 0.002 && Math.abs(c.lng - lng) < 0.002) found = lyr;
});
if (lat) map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 });
if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400);
}
});
container.appendChild(card);
count++;
});
}
// Jalan
if (jalanData.length) {
jalanData.forEach(item => {
let lat = 0, lng = 0;
try {
const feat = JSON.parse(item.koordinat);
const coords = feat.geometry.coordinates[0];
lat = coords[1]; lng = coords[0];
} catch(e) {}
const statusColors = { 'Jalan Nasional': 'badge-luar-jk', 'Jalan Provinsi': 'badge-hgu', 'Jalan Kabupaten': 'badge-shm' };
const card = makeDataCard({
icon: '🛣️',
iconClass: 'dc-icon-jalan',
name: item.nama_jalan,
sub: `${parseFloat(item.panjang_meter).toFixed(0)} m`,
badge: item.status_jalan,
badgeClass: statusColors[item.status_jalan] || 'badge-geo',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => {
if (!lat) return;
let found = null;
lgJalan.eachLayer(lyr => {
if (found) return;
const lls = lyr.getLatLngs ? lyr.getLatLngs() : [];
const first = Array.isArray(lls[0]) ? lls[0][0] : lls[0];
if (first && Math.abs(first.lat - lat) < 0.001 && Math.abs(first.lng - lng) < 0.001) found = lyr;
});
map.flyTo([lat, lng], 16, { animate: true, duration: 1.2 });
if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400);
if (window.innerWidth <= 680) closeRightSidebar();
}
});
container.appendChild(card);
count++;
});
}
if (!count) {
container.appendChild(emptyState('📐', 'Belum Ada Data Digitasi', 'Tambahkan polygon/jalan menggunakan alat peta.'));
}
return count;
}
/* ── Main builder ─────────────────────────────────────────────── */
let currentSearchQuery = '';
let currentUtuhTab = 'spbu';
function buildRightSidebarCards(modul, searchQ = '') {
if (!rightCardList) return;
rightCardList.innerHTML = '';
let count = 0;
const filteredPoints = (APP_DATA.points || []).filter(it =>
!searchQ || (it.nama && it.nama.toLowerCase().includes(searchQ))
);
const filteredPM = (pendudukList || []).filter(it =>
!searchQ || (it.data.nama && it.data.nama.toLowerCase().includes(searchQ)) || (it.data.nik && it.data.nik.includes(searchQ))
);
const filteredJalan = (APP_DATA.jalan || []).filter(it =>
!searchQ || (it.nama_jalan && it.nama_jalan.toLowerCase().includes(searchQ))
);
const filteredPolygon = (APP_DATA.polygons || []).filter(it =>
!searchQ || (it.nama_parsil && it.nama_parsil.toLowerCase().includes(searchQ))
);
const filteredFP = (fasilitasPublikList || []).filter(it =>
!searchQ || (it.nama && it.nama.toLowerCase().includes(searchQ))
);
if (modul === 'spbu') {
count = buildCardsSpbu(filteredPoints, rightCardList);
} else if (modul === 'bantuan') {
count = buildCardsBantuan(filteredPM, rightCardList);
// Also add FP
if (filteredFP.length) {
filteredFP.forEach(fp => {
const lat = parseFloat(fp.latitude), lng = parseFloat(fp.longitude);
const card = makeDataCard({
icon: '🏢', iconClass: 'dc-icon-fp',
name: fp.nama, sub: fp.jenis || 'Fasilitas Publik',
badge: null, badgeClass: '',
coords: `${lat.toFixed(5)}, ${lng.toFixed(5)}`,
onClick: () => {
const mk = findMarkerInGroup(lgFasilitasPublik, lat, lng);
flyToAndOpenPopup(lat, lng, mk);
}
});
rightCardList.appendChild(card);
count++;
});
}
} else if (modul === 'tematik') {
// Kosongkan daftar data untuk peta tematik
rightCardList.appendChild(emptyState('🗺️', 'Peta Tematik', 'Mode Peta Tematik hanya menampilkan layer poligon dan garis pada peta.'));
count = 0;
} else if (modul === 'digitasi') {
count = buildCardsDigitasi(filteredJalan, filteredPolygon, rightCardList);
} else { // utuh – use tab system
if (currentUtuhTab === 'spbu') count = buildCardsSpbu(filteredPoints, rightCardList);
else if (currentUtuhTab === 'bantuan') count = buildCardsBantuan(filteredPM, rightCardList);
else if (currentUtuhTab === 'jalan') {
if (!filteredJalan.length) {
rightCardList.appendChild(emptyState('🛣️', 'Belum Ada Jalan', 'Tambahkan data jalan menggunakan alat peta.'));
} else {
filteredJalan.forEach(item => {
let lat = 0, lng = 0;
try { const f = JSON.parse(item.koordinat); lat = f.geometry.coordinates[0][1]; lng = f.geometry.coordinates[0][0]; } catch(e) {}
const card = makeDataCard({
icon: '🛣️', iconClass: 'dc-icon-jalan',
name: item.nama_jalan, sub: `${parseFloat(item.panjang_meter).toFixed(0)} m · ${item.status_jalan}`,
badge: null, badgeClass: '',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => { if (lat) map.flyTo([lat, lng], 16, { animate: true, duration: 1.2 }); if (window.innerWidth <= 680) closeRightSidebar(); }
});
rightCardList.appendChild(card);
count++;
});
}
} else if (currentUtuhTab === 'polygon') {
if (!filteredPolygon.length) {
rightCardList.appendChild(emptyState('⬜', 'Belum Ada Parsil', 'Tambahkan polygon menggunakan alat peta.'));
} else {
const badgeMap = { SHM: 'badge-shm', HGB: 'badge-hgb', HGU: 'badge-hgu', HP: 'badge-hp' };
filteredPolygon.forEach(item => {
let lat = 0, lng = 0;
try { const f = JSON.parse(item.koordinat); lat = f.geometry.coordinates[0][0][1]; lng = f.geometry.coordinates[0][0][0]; } catch(e) {}
const card = makeDataCard({
icon: '⬜', iconClass: 'dc-icon-poly',
name: item.nama_parsil, sub: `Luas: ${parseFloat(item.luas_tanah).toFixed(2)} m²`,
badge: item.status_kepemilikan, badgeClass: badgeMap[item.status_kepemilikan] || 'badge-geo',
coords: lat ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : null,
onClick: () => {
if (lat) {
map.flyTo([lat, lng], 17, { animate: true, duration: 1.2 });
let found = null;
lgPolygon.eachLayer(lyr => {
if (found) return;
const c = lyr.getCenter ? lyr.getCenter() : null;
if (c && Math.abs(c.lat - lat) < 0.002 && Math.abs(c.lng - lng) < 0.002) found = lyr;
});
if (found) setTimeout(() => { try { found.openPopup(); } catch(e) {} }, 1400);
}
if (window.innerWidth <= 680) closeRightSidebar();
}
});
rightCardList.appendChild(card);
count++;
});
}
}
}
updateCount(count);
}
/* ── Tab Switching (Modul Utuh) ───────────────────────────────── */
const tabBtns = document.querySelectorAll('.right-tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentUtuhTab = btn.dataset.tab;
buildRightSidebarCards(APP_DATA.modul, currentSearchQuery);
});
});
/* ── Right Search Input ───────────────────────────────────────── */
if (rightSearchInput) {
let rsTimer = null;
rightSearchInput.addEventListener('input', function() {
clearTimeout(rsTimer);
const q = this.value.toLowerCase().trim();
rsTimer = setTimeout(() => {
currentSearchQuery = q;
buildRightSidebarCards(APP_DATA.modul, q);
}, 200);
});
}
/* ── Trigger initial card build after markers loaded ────────────── */
// We wait a tick to let renderPoint/renderPenduduk etc. complete
setTimeout(() => buildRightSidebarCards(APP_DATA.modul), 100);
/* ── Re-build after CRUD ops ─────────────────────────────────── */
// Patch postData success to rebuild cards
const _origPostData = window.postData;
// We override the save success moments via a custom event
document.addEventListener('webgis:dataChanged', () => {
buildRightSidebarCards(APP_DATA.modul, currentSearchQuery);
});
// Hook into showToast to trigger rebuild on success saves
const _origShowToast = showToast;
// We'll re-export showToast patch at module level
window.__rebuildCards = () => buildRightSidebarCards(APP_DATA.modul, currentSearchQuery);
/* ===============================================================
CHART.JS MODAL
=============================================================== */
let activeChart = null;
const chartModal = document.getElementById('chartModal');
const chartModalClose = document.getElementById('chartModalClose');
const chartModalBody = document.getElementById('chartModalBody');
const chartModalTitle = document.getElementById('chartModalTitle');
const chartModalSub = document.getElementById('chartModalSubtitle');
const chartModalIcon = document.getElementById('chartModalIcon');
const btnStatistik = document.getElementById('btnLihatStatistik');
// Chart color palette (Merah Putih premium)
const CHART_COLORS = {
red: 'rgba(225, 29, 72, 0.85)',
redLight: 'rgba(225, 29, 72, 0.18)',
redMid: 'rgba(251, 113, 133, 0.85)',
dark: 'rgba(15, 23, 42, 0.85)',
darkMid: 'rgba(71, 85, 105, 0.7)',
green: 'rgba(22, 163, 74, 0.85)',
greenLight:'rgba(22,163,74,0.18)',
blue: 'rgba(59, 130, 246, 0.85)',
amber: 'rgba(217, 119, 6, 0.85)',
purple: 'rgba(107, 33, 168, 0.85)',
teal: 'rgba(20, 184, 166, 0.85)',
};
const CHART_DEFAULTS = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
position: 'bottom',
labels: {
font: { family: "'Plus Jakarta Sans', sans-serif", size: 12, weight: '600' },
padding: 16,
usePointStyle: true,
pointStyleWidth: 10,
color: '#334155'
}
},
tooltip: {
backgroundColor: 'rgba(15,23,42,0.9)',
titleColor: '#f8fafc',
bodyColor: '#cbd5e1',
borderColor: 'rgba(225,29,72,0.3)',
borderWidth: 1,
cornerRadius: 10,
padding: 12,
titleFont: { family: "'Plus Jakarta Sans', sans-serif", weight: '800', size: 13 },
bodyFont: { family: "'Plus Jakarta Sans', sans-serif", weight: '500', size: 12 },
}
}
};
function destroyChart() {
if (activeChart) { activeChart.destroy(); activeChart = null; }
}
function openChartModal() {
if (!chartModal) return;
destroyChart();
chartModalBody.innerHTML = '';
const modul = APP_DATA.modul;
if (modul === 'spbu') {
chartModalIcon.textContent = '⛽';
chartModalTitle.textContent = 'Statistik SPBU';
chartModalSub.textContent = 'Distribusi SPBU berdasarkan jam operasional';
buildChartSpbu();
} else if (modul === 'bantuan') {
chartModalIcon.textContent = '👥';
chartModalTitle.textContent = 'Statistik Bantuan';
chartModalSub.textContent = 'Cakupan jangkauan penduduk miskin';
buildChartBantuan();
} else if (modul === 'tematik') {
chartModalIcon.textContent = '🗺️';
chartModalTitle.textContent = 'Statistik Tematik';
chartModalSub.textContent = 'Distribusi jaringan jalan berdasarkan status';
buildChartTematik();
} else if (modul === 'digitasi') {
chartModalIcon.textContent = '📐';
chartModalTitle.textContent = 'Statistik Digitasi';
chartModalSub.textContent = 'Proporsi kepemilikan tanah & panjang jalan';
buildChartDigitasi();
} else {
chartModalIcon.textContent = '📊';
chartModalTitle.textContent = 'Dashboard Statistik';
chartModalSub.textContent = 'Ringkasan semua modul data spasial';
buildChartUtuh();
}
chartModal.classList.remove('hidden');
}
function closeChartModal() {
chartModal.classList.add('hidden');
destroyChart();
}
if (btnStatistik) btnStatistik.addEventListener('click', openChartModal);
if (chartModalClose) chartModalClose.addEventListener('click', closeChartModal);
if (chartModal) chartModal.addEventListener('click', e => { if (e.target === chartModal) closeChartModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeChartModal();
});
/* ── Chart Builders per Modul ─────────────────────────────────── */
function statMiniHTML(val, label, color) {
return ``;
}
function buildChartSpbu() {
const total = APP_DATA.points.length;
const ya = APP_DATA.points.filter(p => p.buka_24_jam === 'Ya').length;
const tidak = total - ya;
chartModalBody.innerHTML = `
${statMiniHTML(total, 'Total SPBU', '#0f172a')}
${statMiniHTML(ya, 'Buka 24 Jam', CHART_COLORS.green.replace('0.85','1'))}
${statMiniHTML(tidak, 'Tidak 24 Jam', CHART_COLORS.red.replace('0.85','1'))}
`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Buka 24 Jam', 'Tidak 24 Jam'],
datasets: [{
data: [ya, tidak],
backgroundColor: [CHART_COLORS.green, CHART_COLORS.red],
borderColor: ['#fff', '#fff'],
borderWidth: 3,
hoverBorderWidth: 4,
hoverOffset: 8
}]
},
options: {
...CHART_DEFAULTS,
cutout: '65%',
plugins: {
...CHART_DEFAULTS.plugins,
legend: { ...CHART_DEFAULTS.plugins.legend, position: 'bottom' }
}
}
});
}
function buildChartBantuan() {
const totalPM = pendudukList.length;
const dalam = pendudukList.filter(p => {
const cek = cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude));
return cek.status === 'Dalam Jangkauan';
}).length;
const luar = totalPM - dalam;
const totalFP = fasilitasPublikList.length;
const persen = totalPM > 0 ? ((dalam / totalPM) * 100).toFixed(1) : 0;
chartModalBody.innerHTML = `
${statMiniHTML(totalFP, 'Fasilitas Publik', '#3b82f6')}
${statMiniHTML(totalPM, 'Penduduk Miskin', '#0f172a')}
${statMiniHTML(dalam, 'Terjangkau', '#16a34a')}
${statMiniHTML(luar, 'Luar Jangkauan', '#e11d48')}
${statMiniHTML(persen + '%', 'Cakupan', '#d97706')}
`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Dalam Jangkauan', 'Luar Jangkauan'],
datasets: [{
label: 'Jumlah Penduduk',
data: [dalam, luar],
backgroundColor: [CHART_COLORS.green, CHART_COLORS.red],
borderRadius: 8,
borderSkipped: false,
maxBarThickness: 80
}]
},
options: {
...CHART_DEFAULTS,
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '600', size: 11 }, color: '#64748b' }
},
x: {
grid: { display: false },
ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '700', size: 12 }, color: '#0f172a' }
}
},
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } }
}
});
}
function buildChartTematik() {
// Count jalan by status from APP_DATA.jalan
const counts = { 'Jalan Nasional': 0, 'Jalan Provinsi': 0, 'Jalan Kabupaten': 0 };
const panjang = { 'Jalan Nasional': 0, 'Jalan Provinsi': 0, 'Jalan Kabupaten': 0 };
(APP_DATA.jalan || []).forEach(j => {
if (counts[j.status_jalan] !== undefined) {
counts[j.status_jalan]++;
panjang[j.status_jalan] += parseFloat(j.panjang_meter) || 0;
}
});
const totalPanjang = Object.values(panjang).reduce((a,b) => a+b, 0);
chartModalBody.innerHTML = `
${statMiniHTML(counts['Jalan Nasional'], 'Nasional', '#d32f2f')}
${statMiniHTML(counts['Jalan Provinsi'], 'Provinsi', '#f57c00')}
${statMiniHTML(counts['Jalan Kabupaten'], 'Kabupaten', '#388e3c')}
${statMiniHTML((totalPanjang/1000).toFixed(2)+' km', 'Total Panjang', '#0f172a')}
`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'bar',
data: {
labels: ['Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten'],
datasets: [{
label: 'Panjang (m)',
data: [panjang['Jalan Nasional'], panjang['Jalan Provinsi'], panjang['Jalan Kabupaten']],
backgroundColor: ['rgba(211,47,47,0.85)', 'rgba(245,124,0,0.85)', 'rgba(56,142,60,0.85)'],
borderRadius: 8,
borderSkipped: false,
maxBarThickness: 80
}]
},
options: {
...CHART_DEFAULTS,
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0,0,0,0.06)' },
ticks: {
font: { family: "'Plus Jakarta Sans', sans-serif", weight: '600', size: 11 },
color: '#64748b',
callback: val => val >= 1000 ? (val/1000).toFixed(1)+'km' : val+'m'
}
},
x: {
grid: { display: false },
ticks: { font: { family: "'Plus Jakarta Sans', sans-serif", weight: '700', size: 11 }, color: '#0f172a' }
}
},
plugins: { ...CHART_DEFAULTS.plugins, legend: { display: false } }
}
});
}
function buildChartDigitasi() {
const kepMap = { SHM: 0, HGB: 0, HGU: 0, HP: 0 };
(APP_DATA.polygons || []).forEach(p => { if (kepMap[p.status_kepemilikan] !== undefined) kepMap[p.status_kepemilikan]++; });
const totalPoly = APP_DATA.polygons.length;
const totalJalan = APP_DATA.jalan.length;
chartModalBody.innerHTML = `
${statMiniHTML(totalPoly, 'Total Parsil', '#0f172a')}
${statMiniHTML(kepMap.SHM, 'SHM', '#166534')}
${statMiniHTML(kepMap.HGB, 'HGB', '#1e40af')}
${statMiniHTML(kepMap.HGU, 'HGU', '#854d0e')}
${statMiniHTML(kepMap.HP, 'HP', '#6b21a8')}
${statMiniHTML(totalJalan, 'Ruas Jalan', '#d32f2f')}
`;
const ctx = document.getElementById('chartCanvas').getContext('2d');
activeChart = new Chart(ctx, {
type: 'polarArea',
data: {
labels: ['SHM', 'HGB', 'HGU', 'HP'],
datasets: [{
data: [kepMap.SHM, kepMap.HGB, kepMap.HGU, kepMap.HP],
backgroundColor: [
'rgba(22,163,74,0.75)',
'rgba(59,130,246,0.75)',
'rgba(217,119,6,0.75)',
'rgba(107,33,168,0.75)'
],
borderColor: '#fff',
borderWidth: 2
}]
},
options: {
...CHART_DEFAULTS,
scales: {
r: {
grid: { color: 'rgba(0,0,0,0.08)' },
ticks: {
backdropColor: 'transparent',
font: { family: "'Plus Jakarta Sans', sans-serif", size: 10 },
color: '#64748b'
}
}
}
}
});
}
function buildChartUtuh() {
const totalSPBU = APP_DATA.points.length;
const spbu24 = APP_DATA.points.filter(p => p.buka_24_jam === 'Ya').length;
const totalPM = pendudukList.length;
const dalam = pendudukList.filter(p => cekJangkauan(parseFloat(p.data.latitude), parseFloat(p.data.longitude)).status === 'Dalam Jangkauan').length;
const totalJalan = APP_DATA.jalan.length;
const totalPolygon = APP_DATA.polygons.length;
const totalFP = fasilitasPublikList.length;
chartModalBody.innerHTML = `
${statMiniHTML(totalSPBU, 'Total SPBU', '#0f172a')}
${statMiniHTML(totalPM, 'Penduduk Miskin', '#e11d48')}
${statMiniHTML(totalFP, 'Fasilitas Publik', '#3b82f6')}
${statMiniHTML(totalJalan, 'Ruas Jalan', '#d32f2f')}
${statMiniHTML(totalPolygon,'Parsil Tanah', '#6b21a8')}
`;
const ctxS = document.getElementById('chartSpbu').getContext('2d');
new Chart(ctxS, {
type: 'doughnut',
data: {
labels: ['Buka 24 Jam', 'Tidak 24 Jam'],
datasets: [{ data: [spbu24, totalSPBU - spbu24], backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], borderColor: '#fff', borderWidth: 2, hoverOffset: 6 }]
},
options: { ...CHART_DEFAULTS, cutout: '60%', plugins: { ...CHART_DEFAULTS.plugins, legend: { ...CHART_DEFAULTS.plugins.legend, labels: { ...CHART_DEFAULTS.plugins.legend.labels, font: { family: "'Plus Jakarta Sans', sans-serif", size: 10, weight: '600' }, padding: 10 } } } }
});
const ctxB = document.getElementById('chartBantuan').getContext('2d');
new Chart(ctxB, {
type: 'doughnut',
data: {
labels: ['Terjangkau', 'Luar Jangkauan'],
datasets: [{ data: [dalam, totalPM - dalam], backgroundColor: [CHART_COLORS.green, CHART_COLORS.red], borderColor: '#fff', borderWidth: 2, hoverOffset: 6 }]
},
options: { ...CHART_DEFAULTS, cutout: '60%', plugins: { ...CHART_DEFAULTS.plugins, legend: { ...CHART_DEFAULTS.plugins.legend, labels: { ...CHART_DEFAULTS.plugins.legend.labels, font: { family: "'Plus Jakarta Sans', sans-serif", size: 10, weight: '600' }, padding: 10 } } } }
});
}
/* ============================================================
MANAJEMEN DATA PENDUDUK (MODUL BANTUAN)
============================================================ */
window.openManajemenModal = function() {
document.getElementById('manajemenModal').classList.remove('hidden');
document.getElementById('modalBackdrop').classList.remove('hidden');
renderTabelManajemen('Semua');
// Set tab aktif kembali ke 'Semua'
document.querySelectorAll('.mj-tab').forEach(t => t.classList.remove('active'));
const allTab = document.querySelector('.mj-tab[data-filter="Semua"]');
if(allTab) allTab.classList.add('active');
};
if (document.getElementById('closeManajemenModal')) {
document.getElementById('closeManajemenModal').addEventListener('click', () => {
document.getElementById('manajemenModal').classList.add('hidden');
// Cek apakah modal lain tidak terbuka sebelum menutup backdrop
if(document.querySelectorAll('.modal:not(.hidden)').length <= 1) {
document.getElementById('modalBackdrop').classList.add('hidden');
}
});
}
document.querySelectorAll('.mj-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
document.querySelectorAll('.mj-tab').forEach(t => t.classList.remove('active'));
e.target.classList.add('active');
renderTabelManajemen(e.target.getAttribute('data-filter'));
});
});
window.renderTabelManajemen = function(filter) {
const tbody = document.getElementById('manajemenTableBody');
if (!tbody) return;
tbody.innerHTML = '';
let no = 1;
pendudukList.forEach(item => {
const d = item.data;
const statusBantuan = d.status_bantuan || 'Belum Disalurkan';
if (filter !== 'Semua' && statusBantuan !== filter) return;
let badgeClass = 'belum';
if (statusBantuan === 'Proses') badgeClass = 'proses';
if (statusBantuan === 'Sudah Disalurkan') badgeClass = 'sudah';
let jangkauanColor = d.status_jangkauan === 'Dalam Jangkauan' ? '#16a34a' : '#e11d48';
const tr = document.createElement('tr');
tr.innerHTML = `
${no++} |
${d.nama}
NIK: ${d.nik || '-'}
|
${d.status_jangkauan}
|
${d.foto_rumah ? `Lihat Foto` : '-'}
|
|
`;
tbody.appendChild(tr);
});
if (no === 1) {
tbody.innerHTML = `| Tidak ada data. |
`;
}
};
window.lihatFotoModal = function(url) {
if (typeof Swal !== 'undefined') {
Swal.fire({
imageUrl: url,
imageAlt: 'Foto',
showConfirmButton: false,
showCloseButton: true,
customClass: { popup: 'swal-foto-popup' }
});
} else {
window.open(url, '_blank');
}
};
window.updateStatusLangsung = function(id, newValue, oldValue) {
// Tutup dropdown global
if (typeof closeGlobalDropdown === 'function') closeGlobalDropdown();
if (newValue === oldValue) return;
const fd = new FormData();
fd.append('id', id);
fd.append('status_bantuan', newValue);
fetch('api/update_status_bantuan.php', { method: 'POST', body: fd })
.then(r => r.json())
.then(res => {
if (res.status === 'success') {
showToast('Status penyaluran diperbarui.', 'success');
refreshDataModul().then(() => {
const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter');
renderTabelManajemen(activeFilter);
});
} else {
showToast('Gagal mengubah status', 'error');
const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter');
renderTabelManajemen(activeFilter);
}
}).catch(err => {
showToast('Koneksi terputus', 'error');
const activeFilter = document.querySelector('.mj-tab.active').getAttribute('data-filter');
renderTabelManajemen(activeFilter);
});
};
let globalDropdown = null;
window.closeGlobalDropdown = function() {
if (globalDropdown) {
globalDropdown.remove();
globalDropdown = null;
}
document.querySelectorAll('.mj-dropdown.open').forEach(d => d.classList.remove('open'));
};
window.toggleDropdown = function(id) {
const el = document.getElementById(`dropdown-${id}`);
if (!el) return;
// If clicking the same one that's open, close it
if (el.classList.contains('open')) {
closeGlobalDropdown();
return;
}
// Close any existing first
closeGlobalDropdown();
// Open it
el.classList.add('open');
// Create a new one on body
const menuTemplate = el.querySelector('.mj-dropdown-menu');
globalDropdown = document.createElement('div');
globalDropdown.className = 'mj-dropdown-menu';
globalDropdown.innerHTML = menuTemplate.innerHTML;
const rect = el.querySelector('.mj-dropdown-toggle').getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
globalDropdown.style.position = 'fixed';
globalDropdown.style.left = rect.left + 'px';
globalDropdown.style.zIndex = '9999999';
// Force visibility since it's not under .mj-dropdown.open anymore
globalDropdown.style.opacity = '1';
globalDropdown.style.visibility = 'visible';
globalDropdown.style.transform = 'scaleY(1)';
if (spaceBelow < 140) {
globalDropdown.style.top = 'auto';
globalDropdown.style.bottom = (window.innerHeight - rect.top + 6) + 'px';
globalDropdown.style.transformOrigin = 'bottom left';
} else {
globalDropdown.style.top = (rect.bottom + 6) + 'px';
globalDropdown.style.bottom = 'auto';
globalDropdown.style.transformOrigin = 'top left';
}
document.body.appendChild(globalDropdown);
};
// Global click to close
document.addEventListener('click', function(e) {
if (!e.target.closest('.mj-dropdown') && !e.target.closest('.mj-dropdown-menu')) {
closeGlobalDropdown();
}
});
// Close when scrolling table to prevent floating menu out of sync
document.querySelector('#manajemenModal .table-responsive')?.addEventListener('scroll', closeGlobalDropdown);
window.addEventListener('scroll', closeGlobalDropdown, true);
})();