/* ============================================================ 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 lgPenduduk = L.layerGroup(); const lgPoints = 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 L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).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())); }); // Sembunyikan loading setelah peta siap map.whenReady(() => { setTimeout(() => el('mapLoading').classList.add('hidden'), 600); }); /* ── Tambahkan layer groups ke peta ─────────────────── */ [lgFasilitasPublik, lgPenduduk].forEach(lg => lg.addTo(map)); if (document.getElementById("layerTitik") && document.getElementById("layerTitik").checked) lgPoints.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: '✓', error: '✕', info: 'ⓘ', warning: 'âš ' }; toast.className = `toast ${type}`; toast.innerHTML = `${icons[type] || '•'}${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: '' }; fasilitasPublikList.forEach(ri => { const d = getDistanceMeter(lat, lng, parseFloat(ri.latitude), parseFloat(ri.longitude)); if (d <= parseFloat(ri.radius)) { 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() { return L.divIcon({ className: '', html: `
`, iconSize: [28, 28], iconAnchor: [14, 28], popupAnchor: [0, -30] }); } function createPointIcon(is24) { const color = is24 ? '#4f8ef7' : '#78909c'; return L.divIcon({ className: '', html: `
`, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -14] }); } /* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• 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 }); mode = 'point'; // kembali ke point setelah locate el('btnToolPoint').classList.add('active'); el('btnToolLokasi').classList.remove('active'); } } /* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• RENDER FUNCTIONS ••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ /* ── Render Point (SPBU) ─────────────────────────────── */ 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) }).addTo(lgPoints); const popup = ` `; marker.bindPopup(popup, { maxWidth: 280 }); return marker; } /* ── 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' }).addTo(lgFasilitasPublik); const popup = ` `; marker.bindPopup(popup, { maxWidth: 300 }); circle.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); const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; const fillW = cek.status === 'Dalam Jangkauan' ? '#ef5350' : '#43a047'; const marker = L.circleMarker([lat, lng], { radius: 8, color: warna, fillColor: fillW, fillOpacity: 0.85, weight: 2 }).addTo(lgPenduduk); const badgeClass = cek.status === 'Dalam Jangkauan' ? 'badge-dalam' : 'badge-luar'; const popup = ` `; marker.bindPopup(popup, { maxWidth: 300 }); pendudukList.push({ data: item, marker }); return marker; } /* ── Refresh Status Penduduk ──────────────────────────── */ function refreshStatusPenduduk() { lgPenduduk.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 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 + '%'; } /* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• 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: el('layerSungai') && el('layerSungai').checked }), loadGeoJSON('data/admin_kecamatan.geojson', { title: 'Admin Kecamatan', dynamicColor: true, color: '#ef6c00', weight: 1.5, fitBounds: false, addToMap: el('layerKecamatan') && el('layerKecamatan').checked }), loadGeoJSON('data/Jaringan_Jalan.geojson', { title: 'Jaringan Jalan', color: '#d32f2f', weight: 1.5, fitBounds: false, adjustWeight: true, addToMap: el('layerGeoJalan') && el('layerGeoJalan').checked }) ]).then(([sungai, kecamatan, jalan]) => { geoSungaiLayer = sungai; geoKecamatanLayer = kecamatan; geoJalanLayer = jalan; if (el('layerSungai') && !el('layerSungai').checked) map.removeLayer(geoSungaiLayer); if (el('layerKecamatan') && !el('layerKecamatan').checked) map.removeLayer(geoKecamatanLayer); if (el('layerGeoJalan') && !el('layerGeoJalan').checked) map.removeLayer(geoJalanLayer); }); /* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• REVERSE GEOCODING ••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ function reverseGeocode(lat, lng) { el('fp_alamat').value = ''; el('geocodeStatus').textContent = '⏳ 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 = '✓'; setTimeout(() => { el('geocodeStatus').textContent = ''; }, 2000); }) .catch(() => { el('fp_alamat').value = 'Gagal mengambil alamat'; el('geocodeStatus').textContent = '✕'; }); } /* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• 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); const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; previewMarker = L.circleMarker(e.latlng, { radius: 8, color: warna, fillColor: warna, fillOpacity: 0.85, weight: 2 }).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 ••••••••••••••••••••••••••••••••••••••••••••••••••••••• */ 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.toggle('mobile-open'); } else { sidebar.classList.toggle('collapsed'); mapContainer.classList.toggle('full-width'); sidebarOpenBtn.classList.toggle('hidden'); map.invalidateSize(); } }); sidebarOpenBtn.addEventListener('click', () => { 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', lgPenduduk); bindLayerToggle('layerPendudukLuar', lgPenduduk); bindLayerToggle("layerTitik", lgPoints); bindLayerToggle("layerJalan", lgJalan); bindLayerToggle("layerPolygon", lgPolygon); el('layerGeoJalan').addEventListener('change', function () { if (geoJalanLayer) { this.checked ? geoJalanLayer.addTo(map) : map.removeLayer(geoJalanLayer); } }); el('layerKecamatan').addEventListener('change', function () { if (geoKecamatanLayer) { this.checked ? geoKecamatanLayer.addTo(map) : map.removeLayer(geoKecamatanLayer); } }); 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) { showToast('Semua field wajib diisi dan lokasi harus dipilih.', 'error'); 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; lgPoints.clearLayers(); APP_DATA.points.forEach(renderPoint); } else { APP_DATA.points.push(res.data); renderPoint(res.data); } 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) { showToast('Nama dan status jalan wajib diisi.', 'error'); return; } if (jalanLatLngs.length < 2) { showToast('Minimal 2 titik untuk jalan.', 'error'); 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); // Sinkronisasi array lokal renderJalan(res.data); hideModal('jalanModal'); el('nama_jalan').value = ''; el('status_jalan').value = ''; 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) { showToast('Nama dan status kepemilikan wajib diisi.', 'error'); return; } if (polygonLatLngs.length < 3) { showToast('Minimal 3 titik untuk polygon.', 'error'); 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); // Sinkronisasi array lokal renderPolygon(res.data); hideModal('polygonModal'); el('nama_parsil').value = ''; el('status_kepemilikan').value = ''; 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) { showToast('Nama Fasilitas Publik wajib diisi.', 'error'); return; } if (!lat || !lng) { showToast('Klik pada peta untuk menentukan lokasi.', 'error'); 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; // Update data lokal dan bersihkan radius lama agar tidak double if (id) { const idx = fasilitasPublikList.findIndex(r => String(r.id) === String(id)); if (idx >= 0) fasilitasPublikList[idx] = res.data; lgFasilitasPublik.clearLayers(); const backup = [...fasilitasPublikList]; fasilitasPublikList = []; // Reset state backup.forEach(item => renderFasilitasPublik(item)); } else { renderFasilitasPublik(res.data); } refreshStatusPenduduk(); 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) { showToast('Nama penduduk wajib diisi.', 'error'); return; } if (!lat || !lng) { showToast('Klik pada peta untuk menentukan lokasi.', 'error'); 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('fasilitas_publik_id', cek.id || ''); postData(url, fd).then(res => { if (res.status === 'success') { if (previewMarker) { map.removeLayer(previewMarker); previewMarker = null; } // Update data lokal TANPA mereload halaman if (id) { const idx = pendudukList.findIndex(p => String(p.data.id) === String(id)); if (idx >= 0) pendudukList[idx].data = res.data; refreshStatusPenduduk(); } else { renderPenduduk(res.data); updateDashboardStats(); // <--- Tambahkan Baris Ini } 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'); // 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); lgPoints.clearLayers(); APP_DATA.points.forEach(renderPoint); } 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') { fasilitasPublikList = fasilitasPublikList.filter(x => String(x.id) !== String(id)); lgFasilitasPublik.clearLayers(); const backup = [...fasilitasPublikList]; fasilitasPublikList = []; backup.forEach(item => renderFasilitasPublik(item)); refreshStatusPenduduk(); } else if (tipe === 'penduduk_miskin') { pendudukList = pendudukList.filter(x => String(x.data.id) !== String(id)); refreshStatusPenduduk(); } } else { showToast(res.message || 'Gagal menghapus.', 'error'); } }); }); }; /* ••••••••••••••••••••••••••••••••••••••••••••••••••••••• 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' }).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_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)); const warna = cek.status === 'Dalam Jangkauan' ? '#e53935' : '#2e7d32'; previewMarker = L.circleMarker(ll, { radius: 8, color: warna, fillColor: warna, fillOpacity: 0.85, weight: 2 }).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 ─────────────────────────────────────────────── */ setMode('point'); })();