// ═══════════════════════════════════════════════════════════════ // MAP SETUP // ═══════════════════════════════════════════════════════════════ const map = L.map('map', { preferCanvas: false }).setView([-0.0263, 109.3425], 13); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); setTimeout(() => map.invalidateSize(), 150); // ═══════════════════════════════════════════════════════════════ // STATE // ═══════════════════════════════════════════════════════════════ let mode = null; // 'ibadah' | 'miskin' | null let editMode = null; // { type:'ibadah'|'miskin', id } — saat edit let ibadahList = []; let miskinList = []; let myMarker = null; let pendingLatLng = null; let pendingAddr = null; let tempMarker = null; // ═══════════════════════════════════════════════════════════════ // TOAST // ═══════════════════════════════════════════════════════════════ function showToast(msg, color = '#5b7fff') { const t = document.getElementById('toast'); t.innerHTML = msg; t.style.display = 'block'; t.style.borderLeft = `3px solid ${color}`; clearTimeout(t._timer); t._timer = setTimeout(() => t.style.display = 'none', 3500); } // ═══════════════════════════════════════════════════════════════ // TOGGLE MODE + HIGHLIGHT // ═══════════════════════════════════════════════════════════════ function setMode(m) { if (mode === m) { mode = null; clearHighlights(); updateToolbar(); resetMapCursor(); return; } mode = m; updateToolbar(); resetMapCursor(); if (m === 'ibadah') { document.getElementById('map').classList.add('adding-ibadah'); highlightAll('ibadah'); showToast('🏛️ Klik peta untuk menentukan lokasi Rumah Ibadah', '#5b7fff'); } else { document.getElementById('map').classList.add('adding-miskin'); highlightAll('miskin'); showToast('⛽ Klik peta untuk menentukan lokasi SPBU / Pangkalan BBM', '#f59e0b'); } } function resetMapCursor() { document.getElementById('map').classList.remove('adding-ibadah', 'adding-miskin'); } function updateToolbar() { document.getElementById('toggle-ibadah').className = 'toggle-btn' + (mode === 'ibadah' ? ' active-ibadah' : ''); document.getElementById('toggle-miskin').className = 'toggle-btn' + (mode === 'miskin' ? ' active-miskin' : ''); } function highlightAll(type) { clearHighlights(); if (type === 'ibadah') { ibadahList.forEach(ib => { if (ib.circle) ib.circle.setStyle({ fillOpacity: 0.18, weight: 3, opacity: 1 }); pulseMarker(ib.marker); const c = document.getElementById('card-ibadah-' + ib.id); if (c) c.classList.add('highlighted'); }); } else { miskinList.forEach(m => { pulseMarker(m.marker); const c = document.getElementById('card-miskin-' + m.id); if (c) c.classList.add('highlighted'); }); } } function clearHighlights() { ibadahList.forEach(ib => { if (ib.circle) ib.circle.setStyle({ fillOpacity: 0.08, weight: 2, opacity: 0.8 }); const c = document.getElementById('card-ibadah-' + ib.id); if (c) c.classList.remove('highlighted'); }); miskinList.forEach(m => { const c = document.getElementById('card-miskin-' + m.id); if (c) c.classList.remove('highlighted'); }); } function pulseMarker(marker) { if (!marker) return; const el = marker.getElement(); if (!el) return; el.classList.remove('leaflet-marker-pulsing'); void el.offsetWidth; el.classList.add('leaflet-marker-pulsing'); setTimeout(() => el.classList.remove('leaflet-marker-pulsing'), 3000); } // ═══════════════════════════════════════════════════════════════ // REVERSE GEOCODING // ═══════════════════════════════════════════════════════════════ async function reverseGeocode(lat, lng) { try { const r = await fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&addressdetails=1`, { headers: { 'Accept-Language': 'id' } } ); const d = await r.json(); return d.display_name || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; } catch { return `${lat.toFixed(5)}, ${lng.toFixed(5)}`; } } // ═══════════════════════════════════════════════════════════════ // RADIUS: cari rumah ibadah TERDEKAT yang masih dalam radius // ═══════════════════════════════════════════════════════════════ function getNearestIbadah(lat, lng) { let nearest = null, minDist = Infinity; for (const ib of ibadahList) { const dist = map.distance([lat, lng], [ib.lat, ib.lng]); if (dist <= ib.radius && dist < minDist) { minDist = dist; nearest = ib; } } return nearest; // null = di luar semua radius } function updateAllMiskinColors() { miskinList.forEach(m => { const nearest = getNearestIbadah(m.lat, m.lng); m.inRadius = nearest !== null; m.nearestIbadah = nearest ? nearest.id : null; if (m.marker) m.marker.setIcon(createMiskinIcon(m.inRadius)); if (m.marker) m.marker.setPopupContent(buildMiskinPopup(m)); }); updateStats(); renderMiskinList(); } // ═══════════════════════════════════════════════════════════════ // ICONS // ═══════════════════════════════════════════════════════════════ function createIbadahIcon(emoji) { return L.divIcon({ html: `
${emoji}
`, iconSize: [36,36], iconAnchor: [18,36], popupAnchor: [0,-40], className: '' }); } function createMiskinIcon(inRadius) { // Icon SPBU — fuel pump style const c = inRadius ? '#f59e0b' : '#ef4444'; const s = inRadius ? 'rgba(245,158,11,0.5)' : 'rgba(239,68,68,0.5)'; return L.divIcon({ html: `
`, iconSize: [22,22], iconAnchor: [11,11], popupAnchor: [0,-14], className: '' }); } function createTempIcon(type) { if (type === 'ibadah') return L.divIcon({ html: `
`, iconSize: [36,36], iconAnchor: [18,36], className: '' }); return L.divIcon({ html: `
`, iconSize: [20,20], iconAnchor: [10,10], className: '' }); } function createMyIcon() { return L.divIcon({ html: `
`, iconSize: [24,24], iconAnchor: [12,12], popupAnchor: [0,-14], className: '' }); } // ═══════════════════════════════════════════════════════════════ // MAP CLICK → FORM POPUP // ═══════════════════════════════════════════════════════════════ map.on('click', async (e) => { if (!mode) return; const { lat, lng } = e.latlng; pendingLatLng = { lat, lng }; pendingAddr = null; editMode = null; // ini bukan edit if (tempMarker) map.removeLayer(tempMarker); tempMarker = L.marker([lat, lng], { icon: createTempIcon(mode) }).addTo(map); openFormOverlay(mode, lat, lng, null); // null = mode tambah baru reverseGeocode(lat, lng).then(addr => { pendingAddr = addr; const el = document.getElementById(mode === 'ibadah' ? 'f-ibadah-addr' : 'f-miskin-addr'); if (el) el.innerHTML = `📍 ${addr}`; }); }); // ═══════════════════════════════════════════════════════════════ // FORM OVERLAY — bisa untuk tambah BARU atau EDIT // ═══════════════════════════════════════════════════════════════ function openFormOverlay(type, lat, lng, existingData) { const isEdit = existingData !== null; editMode = isEdit ? { type, id: existingData.id } : null; const header = document.getElementById('form-header'); const title = document.getElementById('form-title'); const fI = document.getElementById('form-ibadah-fields'); const fM = document.getElementById('form-miskin-fields'); const saveBtn = document.getElementById('btn-form-save'); const coordText = `Lat: ${lat.toFixed(6)} Lng: ${lng.toFixed(6)}`; if (type === 'ibadah') { title.textContent = isEdit ? '✏️ Edit Rumah Ibadah' : '🏛️ Tambah Rumah Ibadah'; header.className = 'form-header ibadah-header'; fI.style.display = 'block'; fM.style.display = 'none'; document.getElementById('f-ibadah-coord').textContent = coordText; document.getElementById('f-ibadah-name').value = isEdit ? existingData.name : ''; document.getElementById('f-ibadah-type').value = isEdit ? existingData.type : '🕌'; const rad = isEdit ? existingData.radius : 500; document.getElementById('f-radius-slider').value = rad; document.getElementById('f-radius-val').textContent = fmtRadius(rad); if (isEdit) { document.getElementById('f-ibadah-addr').innerHTML = `📍 ${existingData.addr}`; pendingAddr = existingData.addr; } else { document.getElementById('f-ibadah-addr').innerHTML = '🔄 Memuat alamat...'; } saveBtn.onclick = isEdit ? applyEditIbadah : saveIbadah; } else { title.textContent = isEdit ? '✏️ Edit Penduduk Miskin' : '👤 Tambah Penduduk Miskin'; header.className = 'form-header miskin-header'; fI.style.display = 'none'; fM.style.display = 'block'; document.getElementById('f-miskin-coord').textContent = coordText; document.getElementById('f-miskin-name').value = isEdit ? existingData.name : ''; if (isEdit) { document.getElementById('f-miskin-addr').innerHTML = `📍 ${existingData.addr}`; pendingAddr = existingData.addr; } else { document.getElementById('f-miskin-addr').innerHTML = '🔄 Memuat alamat...'; } saveBtn.onclick = isEdit ? applyEditMiskin : saveMiskin; } document.getElementById('form-overlay').classList.remove('hidden'); setTimeout(() => { const inp = document.getElementById(type === 'ibadah' ? 'f-ibadah-name' : 'f-miskin-name'); if (inp) inp.focus(); }, 100); } window.closeFormOverlay = function () { document.getElementById('form-overlay').classList.add('hidden'); if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } pendingLatLng = null; pendingAddr = null; editMode = null; }; // radius slider in form document.getElementById('f-radius-slider').addEventListener('input', function () { document.getElementById('f-radius-val').textContent = fmtRadius(parseInt(this.value)); }); // SPBU radius slider document.getElementById('f-radius-slider-spbu').addEventListener('input', function () { document.getElementById('f-radius-val-spbu').textContent = fmtRadius(parseInt(this.value)); }); function fmtRadius(v) { return v >= 1000 ? (v/1000).toFixed(1)+' km' : v+' m'; } // ═══════════════════════════════════════════════════════════════ // SAVE — TAMBAH IBADAH BARU // ═══════════════════════════════════════════════════════════════ function saveIbadah() { if (!pendingLatLng) return; const { lat, lng } = pendingLatLng; const name = document.getElementById('f-ibadah-name').value.trim() || 'Rumah Ibadah'; const type = document.getElementById('f-ibadah-type').value; const radius = parseInt(document.getElementById('f-radius-slider').value); const addr = pendingAddr || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } // also get spbu radius slider for form that was open const id = Date.now(); const marker = L.marker([lat, lng], { icon: createIbadahIcon(type) }).addTo(map); const circle = L.circle([lat, lng], { radius, color: '#5b7fff', fillColor: '#5b7fff', fillOpacity: 0.08, weight: 2, dashArray: '6,4', opacity: 0.8 }).addTo(map); const obj = { id, name, type, lat, lng, radius, addr, marker, circle }; marker.bindPopup(() => buildIbadahPopup(obj)); ibadahList.push(obj); closeFormOverlay(); updateAllMiskinColors(); renderIbadahList(); updateStats(); setTimeout(() => { pulseMarker(marker); const c = document.getElementById('card-ibadah-' + id); if (c) { c.classList.add('highlighted'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); } }, 100); showToast(`✅ ${type} ${name} ditambahkan`, '#5b7fff'); } // ═══════════════════════════════════════════════════════════════ // EDIT — TERAPKAN EDIT IBADAH // ═══════════════════════════════════════════════════════════════ function applyEditIbadah() { if (!editMode) return; const ib = ibadahList.find(x => x.id === editMode.id); if (!ib) return; ib.name = document.getElementById('f-ibadah-name').value.trim() || ib.name; ib.type = document.getElementById('f-ibadah-type').value; ib.radius = parseInt(document.getElementById('f-radius-slider').value); // koordinat & addr tidak berubah saat edit (hanya metadata) // Update marker icon & circle ib.marker.setIcon(createIbadahIcon(ib.type)); ib.circle.setRadius(ib.radius); ib.marker.setPopupContent(buildIbadahPopup(ib)); closeFormOverlay(); updateAllMiskinColors(); renderIbadahList(); updateStats(); showToast(`✏️ ${ib.type} ${ib.name} diperbarui`, '#5b7fff'); } // ═══════════════════════════════════════════════════════════════ // SAVE — TAMBAH MISKIN BARU // ═══════════════════════════════════════════════════════════════ function saveMiskin() { if (!pendingLatLng) return; const { lat, lng } = pendingLatLng; const name = document.getElementById('f-miskin-name').value.trim() || 'SPBU'; const spbuType = document.getElementById('f-spbu-type') ? document.getElementById('f-spbu-type').value : '⛽'; const radius = parseInt(document.getElementById('f-radius-slider-spbu').value) || 1000; const addr = pendingAddr || `${lat.toFixed(5)}, ${lng.toFixed(5)}`; const nearest = getNearestIbadah(lat, lng); if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } const id = Date.now(); const inRadius = nearest !== null; const marker = L.marker([lat, lng], { icon: createMiskinIcon(true) }).addTo(map); const circle = L.circle([lat, lng], { radius, color: '#f59e0b', fillColor: '#f59e0b', fillOpacity: 0.06, weight: 1.5, dashArray: '4,4', opacity: 0.7 }).addTo(map); const obj = { id, name, type: spbuType, lat, lng, inRadius, nearestIbadah: nearest ? nearest.id : null, addr, marker, circle, radius }; marker.bindPopup(() => buildMiskinPopup(obj)); miskinList.push(obj); closeFormOverlay(); updateStats(); renderMiskinList(); setTimeout(() => { pulseMarker(marker); const c = document.getElementById('card-miskin-' + id); if (c) { c.classList.add('highlighted'); c.scrollIntoView({ behavior:'smooth', block:'nearest' }); } }, 100); showToast(`⛽ ${spbuType} ${name} ditambahkan`, '#f59e0b'); } // ═══════════════════════════════════════════════════════════════ // EDIT — TERAPKAN EDIT MISKIN // ═══════════════════════════════════════════════════════════════ function applyEditMiskin() { if (!editMode) return; const m = miskinList.find(x => x.id === editMode.id); if (!m) return; m.name = document.getElementById('f-miskin-name').value.trim() || m.name; m.marker.setPopupContent(buildMiskinPopup(m)); closeFormOverlay(); updateStats(); renderMiskinList(); showToast(`✏️ Data ${m.name} diperbarui`, '#22c55e'); } // ═══════════════════════════════════════════════════════════════ // POPUP BUILDERS // ═══════════════════════════════════════════════════════════════ function buildIbadahPopup(ib) { return `${ib.type} ${ib.name}
Radius: ${fmtRadius(ib.radius)}  ·  ${ib.lat.toFixed(5)}, ${ib.lng.toFixed(5)}
✏️ Edit
`; } function buildMiskinPopup(m) { const nearest = ibadahList.find(x => x.id === m.nearestIbadah); const nearestLabel = nearest ? `
📌 Terdekat: ${nearest.type} ${nearest.name} (${Math.round(map.distance([m.lat,m.lng],[nearest.lat,nearest.lng]))}m)
` : ''; return `⛽ ${m.type || ''} ${m.name}
⛽ SPBU / Pangkalan BBM
${nearestLabel}
${m.lat.toFixed(5)}, ${m.lng.toFixed(5)}
✏️ Edit
`; } // ═══════════════════════════════════════════════════════════════ // OPEN EDIT (dipanggil dari popup peta ATAU tombol edit di sidebar) // ═══════════════════════════════════════════════════════════════ window.openEditIbadah = (id) => { const ib = ibadahList.find(x => x.id === id); if (!ib) return; map.closePopup(); pendingLatLng = { lat: ib.lat, lng: ib.lng }; openFormOverlay('ibadah', ib.lat, ib.lng, ib); }; window.openEditMiskin = (id) => { const m = miskinList.find(x => x.id === id); if (!m) return; map.closePopup(); pendingLatLng = { lat: m.lat, lng: m.lng }; openFormOverlay('miskin', m.lat, m.lng, m); }; // ═══════════════════════════════════════════════════════════════ // RENDER SIDEBAR // ═══════════════════════════════════════════════════════════════ function renderIbadahList() { const el = document.getElementById('ibadah-list'); document.getElementById('count-ibadah').textContent = ibadahList.length; if (!ibadahList.length) { el.innerHTML = '
Belum ada rumah ibadah
'; return; } el.innerHTML = ibadahList.map(ib => `
${ib.type}
${ib.name}
${truncate(ib.addr, 52)}
${ib.lat.toFixed(4)}, ${ib.lng.toFixed(4)}
${fmtRadius(ib.radius)}
` ).join(''); } function renderMiskinList() { const el = document.getElementById('miskin-list'); document.getElementById('count-miskin').textContent = miskinList.length; if (!miskinList.length) { el.innerHTML = '
Belum ada data SPBU
'; return; } el.innerHTML = miskinList.map(m => { const nearest = ibadahList.find(x => x.id === m.nearestIbadah); const nearestHtml = nearest ? `
📌 ${nearest.type} ${truncate(nearest.name, 20)}
` : ''; return `
${m.type || '⛽'}
${m.name}
${truncate(m.addr, 52)}
${m.lat.toFixed(4)}, ${m.lng.toFixed(4)}
${nearestHtml}
`; }).join(''); } // ─── Live radius update dari slider sidebar ─────────────────── window.liveRadius = (id, val) => { const ib = ibadahList.find(x => x.id === id); if (!ib) return; ib.radius = parseInt(val); if (ib.circle) ib.circle.setRadius(ib.radius); const disp = document.getElementById('rdisplay-' + id); if (disp) disp.textContent = fmtRadius(ib.radius); ib.marker.setPopupContent(buildIbadahPopup(ib)); updateAllMiskinColors(); // recalculate nearest }; // ═══════════════════════════════════════════════════════════════ // FOCUS (klik card → pan + popup) // ═══════════════════════════════════════════════════════════════ window.focusIbadah = (id) => { const ib = ibadahList.find(x => x.id === id); if (!ib) return; map.setView([ib.lat, ib.lng], Math.max(map.getZoom(), 15)); ib.marker.openPopup(); pulseMarker(ib.marker); }; window.focusMiskin = (id) => { const m = miskinList.find(x => x.id === id); if (!m) return; map.setView([m.lat, m.lng], Math.max(map.getZoom(), 16)); m.marker.openPopup(); pulseMarker(m.marker); }; // ═══════════════════════════════════════════════════════════════ // STATS // ═══════════════════════════════════════════════════════════════ function updateStats() { document.getElementById('stat-ibadah').textContent = ibadahList.length; document.getElementById('stat-in').textContent = miskinList.length; document.getElementById('stat-out').textContent = 0; // not used for SPBU } function truncate(s, n) { return s.length > n ? s.slice(0,n)+'…' : s; } // ═══════════════════════════════════════════════════════════════ // REMOVE // ═══════════════════════════════════════════════════════════════ window.removeIbadah = (id) => { const i = ibadahList.findIndex(x => x.id === id); if (i < 0) return; map.removeLayer(ibadahList[i].marker); map.removeLayer(ibadahList[i].circle); ibadahList.splice(i, 1); updateAllMiskinColors(); renderIbadahList(); updateStats(); }; window.removeMiskin = (id) => { const i = miskinList.findIndex(x => x.id === id); if (i < 0) return; map.removeLayer(miskinList[i].marker); if (miskinList[i].circle) map.removeLayer(miskinList[i].circle); miskinList.splice(i, 1); updateStats(); renderMiskinList(); }; // ═══════════════════════════════════════════════════════════════ // RESET // ═══════════════════════════════════════════════════════════════ document.getElementById('btn-reset').addEventListener('click', () => { ibadahList.forEach(ib => { map.removeLayer(ib.marker); map.removeLayer(ib.circle); }); miskinList.forEach(m => { map.removeLayer(m.marker); if (m.circle) map.removeLayer(m.circle); }); if (myMarker) { map.removeLayer(myMarker); myMarker = null; } if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; } ibadahList = []; miskinList = []; mode = null; editMode = null; updateToolbar(); resetMapCursor(); document.getElementById('form-overlay').classList.add('hidden'); document.getElementById('my-location-info').style.display = 'none'; document.getElementById('loc-bar').style.display = 'none'; renderIbadahList(); renderMiskinList(); updateStats(); showToast('🗑️ Semua data dihapus', '#ef4444'); }); // ═══════════════════════════════════════════════════════════════ // GEOLOCATION // ═══════════════════════════════════════════════════════════════ function doLocate() { if (!navigator.geolocation) { showToast('❌ Browser tidak mendukung Geolocation', '#ef4444'); return; } showToast('📡 Mendeteksi lokasi...', '#f59e0b'); navigator.geolocation.getCurrentPosition(async (pos) => { const lat = pos.coords.latitude, lng = pos.coords.longitude; if (myMarker) map.removeLayer(myMarker); myMarker = L.marker([lat, lng], { icon: createMyIcon() }).addTo(map); const addr = await reverseGeocode(lat, lng); myMarker.bindPopup(`💻 Lokasi Saya
±${Math.round(pos.coords.accuracy)}m · ${lat.toFixed(6)}, ${lng.toFixed(6)}
`).openPopup(); map.setView([lat, lng], 15); document.getElementById('my-location-info').style.display = 'block'; document.getElementById('my-addr').textContent = truncate(addr, 80); document.getElementById('my-coord').textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)} · ±${Math.round(pos.coords.accuracy)}m`; const lb = document.getElementById('loc-bar'); lb.style.display = 'flex'; document.getElementById('loc-bar-addr').textContent = truncate(addr, 60); document.getElementById('loc-bar-coord').textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; showToast('✅ Lokasi berhasil ditemukan!', '#f59e0b'); }, (err) => { const msgs = { 1:'Izin lokasi ditolak', 2:'Posisi tidak tersedia', 3:'Timeout' }; showToast('❌ ' + (msgs[err.code] || 'Gagal mendapatkan lokasi'), '#ef4444'); }, { enableHighAccuracy: false, timeout: 10000 }); } document.getElementById('btn-locate').addEventListener('click', doLocate); document.getElementById('btn-locate-map').addEventListener('click', doLocate); // ═══════════════════════════════════════════════════════════════ // KEYBOARD // ═══════════════════════════════════════════════════════════════ document.addEventListener('keydown', e => { if (e.key === 'Escape') { if (!document.getElementById('form-overlay').classList.contains('hidden')) { closeFormOverlay(); } else { mode = null; updateToolbar(); resetMapCursor(); clearHighlights(); } } });