// public/js/app.js — SINERGI Core JavaScript v1.1 // Fix: Auto-zoom ke Kota Pontianak + auto-fitBounds setelah data dimuat /* ============================================================ KONSTANTA KOTA PONTIANAK ============================================================ */ const PONTIANAK = { lat: -0.0263, lng: 109.3425, zoom: 13, // Bounding box seluruh Kota Pontianak (untuk fitBounds fallback) bounds: [[-0.115, 109.280], [0.055, 109.420]] }; /* ============================================================ UTILITY FUNCTIONS ============================================================ */ const App = { url: document.querySelector('meta[name="app-url"]')?.content || '', formatNumber(n) { return Number(n || 0).toLocaleString('id-ID'); }, formatRupiah(n) { return 'Rp ' + Number(n || 0).toLocaleString('id-ID'); }, formatTanggal(str) { if (!str) return '-'; const bulan = ['','Januari','Februari','Maret','April','Mei','Juni', 'Juli','Agustus','September','Oktober','November','Desember']; const d = new Date(str); return `${d.getDate()} ${bulan[d.getMonth() + 1]} ${d.getFullYear()}`; }, async fetchJSON(url) { try { const r = await fetch(url); return await r.json(); } catch (e) { console.error('Fetch error:', url, e); return null; } }, showModal(id) { const el = document.getElementById(id); if (el) { el.classList.add('active'); document.body.style.overflow = 'hidden'; } }, hideModal(id) { const el = document.getElementById(id); if (el) { el.classList.remove('active'); document.body.style.overflow = ''; } }, confirmDelete(url, msg = 'Apakah Anda yakin ingin menghapus data ini?') { if (!confirm(msg)) return false; const form = document.createElement('form'); form.method = 'POST'; form.action = url; const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; form.innerHTML = ``; document.body.appendChild(form); form.submit(); }, startClock() { const el = document.getElementById('live-clock'); if (!el) return; const update = () => { const now = new Date(); el.textContent = now.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); }; update(); setInterval(update, 1000); }, initFlash() { document.querySelectorAll('.flash-message').forEach(el => { setTimeout(() => { el.style.transition = 'opacity 0.5s, max-height 0.5s'; el.style.opacity = '0'; el.style.maxHeight = '0'; el.style.overflow = 'hidden'; setTimeout(() => el.remove(), 500); }, 4500); }); }, initTableSearch(inputId, tableId) { const input = document.getElementById(inputId); const table = document.getElementById(tableId); if (!input || !table) return; input.addEventListener('input', () => { const q = input.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(row => { row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }); }, init() { this.startClock(); this.initFlash(); // Close modal on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', e => { if (e.target === overlay) overlay.classList.remove('active'); }); }); // Close modal on ESC document.addEventListener('keydown', e => { if (e.key === 'Escape') { document.querySelectorAll('.modal-overlay.active') .forEach(m => m.classList.remove('active')); document.body.style.overflow = ''; } }); // Sidebar active link highlight const path = window.location.pathname; document.querySelectorAll('.sidebar-nav a').forEach(a => { const href = a.getAttribute('href'); if (href && href !== '/' && path.startsWith(href)) { a.classList.add('active'); } }); } }; /* ============================================================ GIS MAP MODULE — dengan auto-zoom ke Pontianak ============================================================ */ const GISMap = { map: null, layers: {}, overlayLayers: {}, heatLayer: null, _allMarkerBounds: [], // Kumpulkan semua koordinat untuk fitBounds akhir warnaMiskin: { 'Miskin Ekstrem': '#dc2626', 'Miskin': '#ea580c', 'Rentan Miskin': '#ca8a04', }, createMarker(lat, lng, color, size = 10) { return L.circleMarker([lat, lng], { radius: size, fillColor: color, color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.88 }); }, createRIMarker(ri) { const icon = L.divIcon({ className: '', html: `
`, iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -34] }); return L.marker([parseFloat(ri.lat), parseFloat(ri.lng)], { icon }); }, // ─── INISIALISASI PETA ───────────────────────────────────── // Selalu mulai dari Kota Pontianak, zoom sesuai konteks init(containerId, lat, lng, zoom) { // Reset state untuk peta baru this._allMarkerBounds = []; // Gunakan koordinat Pontianak sebagai default yang kuat const centerLat = (lat && !isNaN(lat)) ? lat : PONTIANAK.lat; const centerLng = (lng && !isNaN(lng)) ? lng : PONTIANAK.lng; const initZoom = (zoom && !isNaN(zoom)) ? zoom : PONTIANAK.zoom; this.map = L.map(containerId, { zoomControl: true, attributionControl: true, // Batasi pan agar tidak keluar wilayah Kalimantan Barat maxBounds: [[-2.0, 107.5], [2.0, 112.0]], maxBoundsViscosity: 0.8 }); // ── Langsung set view ke Pontianak dulu sebelum tile load ── this.map.setView([centerLat, centerLng], initZoom); // Tile layers const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, // Tambahkan subdomains untuk load lebih cepat subdomains: ['a','b','c'] }); const satellite = L.tileLayer( 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles © Esri', maxZoom: 19 } ); const topo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { attribution: '© OpenTopoMap', maxZoom: 17 }); osm.addTo(this.map); this.layers = { '🗺 Peta Jalan': osm, '🛰 Satelit': satellite, '🏔 Topografi': topo }; // Layer control dasar this._layerControl = L.control.layers(this.layers, {}, { position: 'topright', collapsed: true }).addTo(this.map); // Tombol reset zoom ke Pontianak this._addResetButton(); return this.map; }, // Tombol reset zoom ke seluruh Kota Pontianak _addResetButton() { const ResetControl = L.Control.extend({ options: { position: 'topleft' }, onAdd: () => { const btn = L.DomUtil.create('button', ''); btn.innerHTML = '🏙'; btn.title = 'Kembali ke Kota Pontianak'; btn.style.cssText = ` width:30px;height:30px;border:2px solid rgba(0,0,0,0.2); border-radius:4px;background:#fff;cursor:pointer; font-size:14px;display:flex;align-items:center; justify-content:center;box-shadow:0 1px 5px rgba(0,0,0,0.2); `; L.DomEvent.on(btn, 'click', L.DomEvent.stopPropagation) .on(btn, 'click', L.DomEvent.preventDefault) .on(btn, 'click', () => { this.map.flyToBounds(PONTIANAK.bounds, { duration: 0.8, padding: [20, 20] }); }); return btn; } }); new ResetControl().addTo(this.map); }, // ─── LAYER WARGA MISKIN ──────────────────────────────────── addWargaLayer(wargaData, onClickCb) { const group = L.featureGroup(); // featureGroup mendukung getBounds() wargaData.forEach(w => { if (!w.lat || !w.lng) return; const lat = parseFloat(w.lat); const lng = parseFloat(w.lng); if (isNaN(lat) || isNaN(lng)) return; const color = w.is_blind_spot ? '#7c3aed' : (this.warnaMiskin[w.status_kemiskinan] || '#6b7280'); const size = w.status_kemiskinan === 'Miskin Ekstrem' ? 11 : 9; const m = this.createMarker(lat, lng, color, size); const popup = `
${w.nama || 'Data laporan'}
${w.kelurahan || ''}
${w.status_kemiskinan || ''} ${w.level_prioritas ? `
Prioritas: ${w.level_prioritas} ${w.skor_prioritas ? `(${w.skor_prioritas}/100)` : ''}` : ''} ${w.penghasilan_bulanan !== undefined ? ` Penghasilan: Rp ${Number(w.penghasilan_bulanan||0).toLocaleString('id-ID')}/bln` : ''} ${w.is_blind_spot ? '
⚠ Blind Spot — Belum terjangkau RI' : ''}
`; m.bindPopup(popup, { maxWidth: 240 }); if (onClickCb) m.on('click', () => onClickCb(w)); group.addLayer(m); this._allMarkerBounds.push([lat, lng]); }); group.addTo(this.map); return group; }, // ─── LAYER RUMAH IBADAH ──────────────────────────────────── addRILayer(riData, onClickCb) { const group = L.featureGroup(); riData.forEach(ri => { if (!ri.lat || !ri.lng) return; const lat = parseFloat(ri.lat); const lng = parseFloat(ri.lng); if (isNaN(lat) || isNaN(lng)) return; const m = this.createRIMarker(ri); // Radius lingkaran pelayanan const radius = parseInt(ri.radius_meter) || 500; const circle = L.circle([lat, lng], { radius, fillColor: '#16a34a', fillOpacity: 0.07, color: '#16a34a', weight: 1.5, dashArray: '6,5', interactive: false }); const popup = `
⛪ ${ri.nama}
${ri.jenis || ''} • ${ri.kelurahan || ''} ${ri.alamat ? `
${ri.alamat}` : ''}
📏 Radius pelayanan: ${radius.toLocaleString()}m ${ri.nama_ketua ? `Ketua: ${ri.nama_ketua}` : ''}
`; m.bindPopup(popup, { maxWidth: 240 }); if (onClickCb) m.on('click', () => onClickCb(ri)); group.addLayer(circle); group.addLayer(m); this._allMarkerBounds.push([lat, lng]); }); group.addTo(this.map); return group; }, // ─── LAYER LAPORAN PUBLIK ────────────────────────────────── addLaporanLayer(laporanData) { const group = L.featureGroup(); laporanData.forEach(lap => { if (!lap.lat || !lap.lng) return; const lat = parseFloat(lap.lat); const lng = parseFloat(lap.lng); if (isNaN(lat) || isNaN(lng)) return; const m = this.createMarker(lat, lng, '#0284c7', 8); m.bindPopup(`
📋 Laporan Masuk
${lap.nama_warga || ''}
${lap.kode_laporan || ''}
`, { maxWidth: 200 }); group.addLayer(m); this._allMarkerBounds.push([lat, lng]); }); group.addTo(this.map); return group; }, // ─── HEATMAP LAYER ───────────────────────────────────────── addHeatmap(points) { if (typeof L.heatLayer === 'undefined') { console.warn('Leaflet.heat tidak tersedia'); return; } const data = points .map(p => [parseFloat(p.lat), parseFloat(p.lng), parseFloat(p.intensity || 0.5)]) .filter(p => !isNaN(p[0]) && !isNaN(p[1]) && p[0] !== 0); if (this.heatLayer) this.map.removeLayer(this.heatLayer); this.heatLayer = L.heatLayer(data, { radius: 28, blur: 22, maxZoom: 16, max: 1.0, gradient: { 0.0: 'transparent', 0.3: '#fef9c3', 0.55: '#fb923c', 0.75: '#ef4444', 1.0: '#7f1d1d' } }).addTo(this.map); }, // ─── AUTO FIT BOUNDS ─────────────────────────────────────── // Panggil ini SETELAH semua layer dimuat untuk zoom ke data autoFitBounds(forceKotaPontianak = false) { if (forceKotaPontianak || this._allMarkerBounds.length === 0) { // Tidak ada data → tampilkan seluruh Kota Pontianak this.map.flyToBounds(PONTIANAK.bounds, { duration: 0.6, padding: [20, 20], maxZoom: 14 }); return; } try { const bounds = L.latLngBounds(this._allMarkerBounds); if (bounds.isValid()) { // Jika data sangat menyebar → fit ke batas data // Jika data sangat mepet (1-2 titik) → zoom level wajar const boundsSize = bounds.getNorthEast().distanceTo(bounds.getSouthWest()); if (boundsSize < 500) { // Data terlalu dekat → zoom ke tengah dengan zoom 15 this.map.flyTo(bounds.getCenter(), 15, { duration: 0.6 }); } else { this.map.flyToBounds(bounds, { duration: 0.6, padding: [40, 40], maxZoom: 15 // Jangan zoom terlalu dekat }); } } else { this.map.flyToBounds(PONTIANAK.bounds, { duration: 0.5 }); } } catch (e) { this.map.flyToBounds(PONTIANAK.bounds, { duration: 0.5 }); } }, // fitBounds dari layerGroup (kompatibel dengan kode lama) fitBounds(layerGroup) { try { const bounds = layerGroup.getBounds(); if (bounds.isValid()) { this.map.flyToBounds(bounds, { duration: 0.5, padding: [30, 30] }); } } catch (e) { this.autoFitBounds(true); } } }; /* ============================================================ CHART MODULE (Chart.js wrapper) ============================================================ */ const Charts = { defaults: { fontFamily: "'Inter', system-ui, sans-serif", colors: ['#1a3a5c', '#e8a020', '#16a34a', '#dc2626', '#0284c7', '#7c3aed', '#ea580c', '#0d9488'] }, bar(canvasId, labels, datasets, opts = {}) { const ctx = document.getElementById(canvasId); if (!ctx) return null; return new Chart(ctx, { type: 'bar', data: { labels, datasets: datasets.map((d, i) => ({ backgroundColor: this.defaults.colors[i % this.defaults.colors.length] + 'cc', borderColor: this.defaults.colors[i % this.defaults.colors.length], borderRadius: 4, borderWidth: 1, ...d })) }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: datasets.length > 1 }, tooltip: { callbacks: { label: ctx => ' ' + Number(ctx.raw).toLocaleString('id-ID') } } }, scales: { y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { font: { size: 11 } } }, x: { grid: { display: false }, ticks: { font: { size: 11 } } } }, ...opts } }); }, line(canvasId, labels, datasets, opts = {}) { const ctx = document.getElementById(canvasId); if (!ctx) return null; return new Chart(ctx, { type: 'line', data: { labels, datasets: datasets.map((d, i) => ({ borderColor: this.defaults.colors[i % this.defaults.colors.length], backgroundColor: this.defaults.colors[i % this.defaults.colors.length] + '18', tension: 0.4, fill: true, borderWidth: 2.5, pointRadius: 4, pointHoverRadius: 6, ...d })) }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: datasets.length > 1 } }, scales: { y: { beginAtZero: true, grid: { color: '#f1f5f9' }, ticks: { font: { size: 11 } } }, x: { grid: { display: false }, ticks: { font: { size: 11 } } } }, ...opts } }); }, donut(canvasId, labels, data, colors) { const ctx = document.getElementById(canvasId); if (!ctx) return null; return new Chart(ctx, { type: 'doughnut', data: { labels, datasets: [{ data, backgroundColor: colors || this.defaults.colors, borderWidth: 2, borderColor: '#fff' }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { size: 11 }, padding: 14, usePointStyle: true } } }, cutout: '65%' } }); } }; /* ============================================================ LOCATION PICKER — untuk form laporan & input warga ============================================================ */ const LocationPicker = { map: null, marker: null, init(mapId, latInputId, lngInputId, initLat, initLng) { // Selalu mulai dari Pontianak const startLat = (initLat && !isNaN(initLat)) ? initLat : PONTIANAK.lat; const startLng = (initLng && !isNaN(initLng)) ? initLng : PONTIANAK.lng; this.map = L.map(mapId, { zoomControl: true }).setView([startLat, startLng], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19, subdomains: ['a','b','c'] }).addTo(this.map); // Jika ada koordinat awal, langsung pasang marker if (initLat && initLng && !isNaN(initLat)) { this.placeMarker(initLat, initLng, latInputId, lngInputId); } // Klik peta = pasang / pindah marker this.map.on('click', e => { this.placeMarker(e.latlng.lat, e.latlng.lng, latInputId, lngInputId); }); // Kontrol tombol lokasi saya this._addGeolocateButton(latInputId, lngInputId); // Instruksi klik const hint = L.control({ position: 'bottomleft' }); hint.onAdd = () => { const d = L.DomUtil.create('div'); d.style.cssText = 'background:rgba(255,255,255,0.9);padding:6px 10px;border-radius:6px;font-size:11px;color:#374151;box-shadow:0 1px 4px rgba(0,0,0,0.15)'; d.innerHTML = '📍 Klik peta atau drag marker untuk menentukan lokasi'; return d; }; hint.addTo(this.map); }, placeMarker(lat, lng, latInputId, lngInputId) { if (this.marker) this.map.removeLayer(this.marker); this.marker = L.marker([lat, lng], { draggable: true, autoPan: true }).addTo(this.map); this.marker.bindPopup('📍 Lokasi dipilih').openPopup(); document.getElementById(latInputId).value = lat.toFixed(7); document.getElementById(lngInputId).value = lng.toFixed(7); this.marker.on('dragend', e => { const pos = e.target.getLatLng(); document.getElementById(latInputId).value = pos.lat.toFixed(7); document.getElementById(lngInputId).value = pos.lng.toFixed(7); }); }, _addGeolocateButton(latInputId, lngInputId) { const GeoBtn = L.Control.extend({ options: { position: 'topright' }, onAdd: () => { const btn = L.DomUtil.create('button', ''); btn.innerHTML = '📍 Lokasi Saya'; btn.title = 'Gunakan lokasi GPS saya'; btn.style.cssText = ` padding:6px 10px;border:2px solid rgba(0,0,0,0.2);border-radius:4px; background:#1a3a5c;color:#fff;cursor:pointer;font-size:11px;font-weight:600; box-shadow:0 1px 5px rgba(0,0,0,0.2);white-space:nowrap; `; L.DomEvent.disableClickPropagation(btn); btn.onclick = () => { if (!navigator.geolocation) { alert('GPS tidak tersedia di browser Anda'); return; } btn.innerHTML = '⌛ Mencari...'; btn.disabled = true; navigator.geolocation.getCurrentPosition( pos => { const { latitude, longitude } = pos.coords; this.placeMarker(latitude, longitude, latInputId, lngInputId); this.map.flyTo([latitude, longitude], 17, { duration: 0.8 }); btn.innerHTML = '📍 Lokasi Saya'; btn.disabled = false; }, err => { alert('Gagal mendapatkan lokasi: ' + err.message); btn.innerHTML = '📍 Lokasi Saya'; btn.disabled = false; }, { enableHighAccuracy: true, timeout: 10000 } ); }; return btn; } }); new GeoBtn().addTo(this.map); } }; // ── Bootstrap ── document.addEventListener('DOMContentLoaded', () => App.init());