/* ============================================================ 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: `
${svg}
`, 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: `
${svg}
`, 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: `
${svg}
`, 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 `
${val}
${label}
`; } 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')}
⛽ SPBU
👥 Jangkauan Bantuan
`; 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); })();