/* ============================================================ markers.js — Draggable markers + inside/outside radius colors ============================================================ */ 'use strict'; // ==================================================================== // UTILITY FUNCTIONS // ==================================================================== function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function truncate(str, n = 28) { if (!str) return ''; return str.length > n ? str.slice(0, n) + '…' : str; } function formatRp(n) { if (!n) return 'Rp 0'; return 'Rp ' + Number(n).toLocaleString('id-ID'); } function formatDate(str) { if (!str) return '—'; return new Date(str).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }); } function educationLabel(edu) { const labels = { 'tidak_sekolah': 'Tidak Sekolah', 'sd': 'SD', 'smp': 'SMP', 'sma': 'SMA', 'diploma': 'Diploma', 'sarjana': 'Sarjana', 'pascasarjana': 'Pascasarjana' }; return labels[edu] || edu; } const radiusCircles = {}; let dragInProgress = false; // ==================================================================== // CENTERS // ==================================================================== function renderCenters() { layerCenters.clearLayers(); Object.values(radiusCircles).forEach(c => { if (MAP.hasLayer(c)) MAP.removeLayer(c); }); const filtered = State.centers.filter(c => { if (State.activeFilter === 'houses') return false; if (State.searchQuery) { const q = State.searchQuery.toLowerCase(); return c.name.toLowerCase().includes(q) || (c.address || '').toLowerCase().includes(q); } return true; }); filtered.forEach(center => { if (!center._marker) { addCenterMarker(center); } else { layerCenters.addLayer(center._marker); if (radiusCircles[center.id]) { radiusCircles[center.id].addTo(MAP); } } }); updateLayerCounts(); renderCenterList(); } function addCenterMarker(center) { const color = CENTER_COLORS[center.worship_type] || '#3a56d4'; const icon = CENTER_ICONS[center.worship_type] || 'fa-place-of-worship'; const circle = L.circle([center.latitude, center.longitude], { radius: center.radius, color: color, fillColor: color, fillOpacity: 0.07, weight: 1.5, dashArray: '4 3', }); circle.addTo(MAP); radiusCircles[center.id] = circle; const marker = L.marker([center.latitude, center.longitude], { icon: L.divIcon({ html: `
`, iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -34], className: '', }), title: center.name, draggable: true, }); marker.on('dragstart', function() { marker.closePopup(); marker.setZIndexOffset(1000); }); marker.on('drag', function(e) { if (radiusCircles[center.id]) radiusCircles[center.id].setLatLng(marker.getLatLng()); }); marker.on('dragend', async function(e) { marker.setZIndexOffset(0); const pos = marker.getLatLng(); center.latitude = pos.lat; center.longitude = pos.lng; if (radiusCircles[center.id]) radiusCircles[center.id].setLatLng(pos); try { const r = await ApiCenters.patch(center.id, { latitude: pos.lat, longitude: pos.lng }); if (r.ok && r.data?.success) { showToast('Posisi tempat ibadah diperbarui.', 'success'); updateAllHouseColors(); recountCenterHouseholds(); loadStats(); renderCenterList(); renderHouseList(); updateLayerCounts(); } else showToast('Gagal menyimpan posisi.', 'error'); } catch (err) { showToast('Gagal menyimpan posisi.', 'error'); } showCenterPopup(marker, center); }); marker.on('click', () => showCenterPopup(marker, center)); layerCenters.addLayer(marker); center._marker = marker; } function showCenterPopup(marker, center) { const color = CENTER_COLORS[center.worship_type] || '#3a56d4'; const icon = CENTER_ICONS[center.worship_type] || 'fa-place-of-worship'; const label = CENTER_LABELS[center.worship_type] || center.worship_type; // Hitung ulang jumlah rumah saat popup dibuka (memastikan data fresh) let count = 0; let nearbyHouses = []; State.houses.forEach(house => { if (house.is_active === false) return; const distance = haversineMeters( house.latitude, house.longitude, center.latitude, center.longitude ); if (distance <= center.radius) { count++; nearbyHouses.push({ id: house.id, name: house.head_name, distance: Math.round(distance) }); } }); // Update center household_count jika berbeda if (center.household_count !== count) { center.household_count = count; renderCenterList(); // Refresh sidebar jika ada perubahan } // Buat HTML daftar rumah dalam radius let housesListHtml = ''; if (nearbyHouses.length > 0) { const displayHouses = nearbyHouses.slice(0, 5); housesListHtml = ` `; } const popup = L.popup({ maxWidth: 320, closeButton: true }) .setLatLng(marker.getLatLng()) .setContent(` `); marker.unbindPopup(); marker.bindPopup(popup).openPopup(); } // Fungsi helper untuk terbang ke rumah dan membuka popupnya function flyToAndOpenHouse(houseId, centerLat, centerLng) { const house = State.houses.find(h => h.id == houseId); if (house) { flyTo(house.latitude, house.longitude, 17); setTimeout(() => { if (house._marker) { house._marker.openPopup(); } }, 800); } } window.flyToAndOpenHouse = flyToAndOpenHouse; function liveUpdateRadius(centerId, value) { const valSpan = document.getElementById('rcVal_' + centerId); if (valSpan) valSpan.textContent = value + 'm'; if (radiusCircles[centerId]) { radiusCircles[centerId].setRadius(parseInt(value)); } // Live update: Hitung ulang jumlah rumah saat slider digeser const center = State.centers.find(c => c.id == centerId); if (center) { let tempCount = 0; State.houses.forEach(house => { if (house.is_active === false) return; const distance = haversineMeters( house.latitude, house.longitude, center.latitude, center.longitude ); if (distance <= parseInt(value)) { tempCount++; } }); // Update tampilan jumlah rumah di popup secara live const popupContent = document.querySelector('.leaflet-popup-content'); if (popupContent && center._marker && center._marker.isPopupOpen()) { const countElement = popupContent.querySelector('.popup-section:first-child .popup-row strong'); if (countElement) { countElement.textContent = tempCount; countElement.style.color = '#d63230'; } } } } async function saveRadius(centerId, value) { try { const r = await ApiCenters.patch(centerId, { radius: parseInt(value) }); if (r.ok && r.data?.success) { const center = State.centers.find(c => c.id == centerId); if (center) { center.radius = parseInt(value); // Perbarui lingkaran di peta if (radiusCircles[centerId]) { radiusCircles[centerId].setRadius(parseInt(value)); } } showToast('Radius diperbarui.', 'success'); // Update semua data yang terpengaruh updateAllHouseColors(); recountCenterHouseholds(); // Hitung ulang jumlah rumah per center renderHouseList(); // Refresh daftar rumah renderCenterList(); // Refresh daftar center dengan count baru updateLayerCounts(); // Update badge layer loadStats(); // Update statistik dashboard // Refresh popup center jika terbuka if (center._marker && center._marker.isPopupOpen()) { showCenterPopup(center._marker, center); } } else showToast('Gagal menyimpan radius.', 'error'); } catch (err) { showToast('Gagal menyimpan radius.', 'error'); } } async function showCoverageHouseholds(centerId) { showLoading(true); const r = await ApiCenters.coverage(centerId); showLoading(false); if (!r.ok) { showToast('Gagal memuat data.', 'error'); return; } const { center, households, count } = r.data.data; if (!households.length) { showToast(`Tidak ada rumah dalam radius ${center.radius}m.`, 'warning'); return; } const tempLayer = L.layerGroup(); households.forEach(h => { L.circleMarker([h.latitude, h.longitude], { radius: 10, color: '#3a56d4', fillColor: '#3a56d4', fillOpacity: 0.25, weight: 2, }).addTo(tempLayer).bindTooltip(h.head_name, { permanent: false }); }); tempLayer.addTo(MAP); setTimeout(() => MAP.removeLayer(tempLayer), 10000); flyTo(center.latitude, center.longitude, 15); showToast(`${count} rumah dalam jangkauan ${center.name}.`, 'success'); } // ==================================================================== // HOUSES // ==================================================================== function renderHouses() { layerHouses.clearLayers(); const filtered = State.houses.filter(h => { if (State.activeFilter === 'centers') return false; if (State.povertyFilter && h.poverty_status !== State.povertyFilter) return false; if (State.aidFilter && h.aid_status !== State.aidFilter) return false; if (State.conditionFilter && h.house_condition !== State.conditionFilter) return false; if (State.searchQuery) { const q = State.searchQuery.toLowerCase(); if (!h.head_name.toLowerCase().includes(q) && !(h.full_address || h.address || '').toLowerCase().includes(q) && !(h.head_nik || '').includes(q)) return false; } return true; }); filtered.forEach(h => { if (!h._marker) { addHouseMarker(h); } else { // Update color if radius status changed const newColor = getHouseMarkerColor(h.latitude, h.longitude); if (h._marker._houseColor !== newColor) { h._marker._houseColor = newColor; h._marker.setIcon(L.divIcon({ html: `
`, iconSize: [26, 26], iconAnchor: [13, 26], popupAnchor: [0, -28], className: '', })); } layerHouses.addLayer(h._marker); } }); updateLayerCounts(); renderHouseList(filtered); } function isHouseInsideAnyRadius(lat, lng) { for (const center of State.centers) { if (!center.is_active) continue; const distance = haversineMeters(lat, lng, center.latitude, center.longitude); if (distance <= center.radius) return true; } return false; } function haversineMeters(lat1, lng1, lat2, lng2) { const R = 6371000; const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } function recountCenterHouseholds() { console.log('Recounting center households...'); State.centers.forEach(center => { let count = 0; let householdsInRadius = []; State.houses.forEach(house => { if (house.is_active === false) return; const distance = haversineMeters( house.latitude, house.longitude, center.latitude, center.longitude ); if (distance <= center.radius) { count++; householdsInRadius.push({ id: house.id, name: house.head_name, distance: Math.round(distance) }); } }); center.household_count = count; center.households_in_radius = householdsInRadius.slice(0, 10); // Simpan 10 terdekat }); } function getHouseMarkerColor(lat, lng) { return isHouseInsideAnyRadius(lat, lng) ? '#d63230' : '#0b9e73'; } function updateAllHouseColors() { State.houses.forEach(h => { if (h._marker) { const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude); const newColor = insideRadius ? '#d63230' : '#0b9e73'; if (h._marker._houseColor !== newColor) { h._marker._houseColor = newColor; h._marker.setIcon(L.divIcon({ html: `
`, iconSize: [26, 26], iconAnchor: [13, 26], popupAnchor: [0, -28], className: '', })); } } }); } // Fungsi untuk mendapatkan foto rumah (akan digunakan di popup) function getHousePhotosHtml(houseData) { if (!houseData.house_photos) return ''; let photosArr = []; try { photosArr = JSON.parse(houseData.house_photos); } catch(e) { return ''; } if (!photosArr.length) return ''; const thumbnails = photosArr.slice(0, 4).map(name => `` ).join(''); return `
Foto Rumah ${photosArr.length}
`; } // showHousePopup (redesigned popup) async function showHousePopup(marker, h, forceRefresh = false) { let houseData = h; // Fetch fresh data if aid_history is missing or forceRefresh=true if (forceRefresh || !houseData.aid_history || houseData.aid_history.length === undefined) { try { showLoading(true); const r = await ApiHouses.show(houseData.id); showLoading(false); if (r.ok && r.data?.success) { houseData = r.data.data; const index = State.houses.findIndex(hh => hh.id === houseData.id); if (index !== -1) { State.houses[index] = houseData; if (State.houses[index]._marker) { State.houses[index]._marker._houseData = houseData; } } } } catch (err) { showLoading(false); console.error('Failed to load fresh household data:', err); } } // ── Derived values ─────────────────────────────────────────────── const hasAid = (houseData.aid_history && houseData.aid_history.length > 0); const aidStatusText = hasAid ? 'Penerima Bantuan' : 'Belum Ada Bantuan'; const aidStatusColor = hasAid ? '#0b9e73' : '#d97706'; let age = '—'; if (houseData.head_date_of_birth) { const bd = new Date(houseData.head_date_of_birth), now = new Date(); let a = now.getFullYear() - bd.getFullYear(); if (now.getMonth() < bd.getMonth() || (now.getMonth() === bd.getMonth() && now.getDate() < bd.getDate())) a--; age = a; } const povColor = POVERTY_COLORS[houseData.poverty_status] || '#9ba4b5'; const povLabel = POVERTY_LABELS[houseData.poverty_status] || houseData.poverty_status; // Employment one-liner let jobLine = '—'; if (houseData.head_employment_status === 'unemployed') { jobLine = 'Tidak Bekerja'; } else if (houseData.head_employment_status === 'studying') { jobLine = escapeHtml(houseData.head_institution_name || 'Pelajar/Mahasiswa'); } else if (houseData.head_employment_status === 'working') { jobLine = escapeHtml(houseData.head_job_name || 'Bekerja'); if (houseData.head_monthly_income) jobLine += ` · ${formatRp(houseData.head_monthly_income)}/bln`; } const fullAddress = houseData.full_address || houseData.address || '—'; const conditionIcon = houseData.house_condition === 'layak' ? ` Layak` : houseData.house_condition === 'tidak_layak' ? ` Tidak Layak` : '—'; // ── Location pills (RT/RW · Kelurahan · Kecamatan) ─────────────── const locationPills = [ houseData.rt ? `RT ${escapeHtml(houseData.rt)}/${escapeHtml(houseData.rw || '?')}` : null, houseData.kelurahan ? escapeHtml(houseData.kelurahan) : null, houseData.kecamatan ? escapeHtml(houseData.kecamatan) : null, ].filter(Boolean); const locationPillsHtml = locationPills.length ? `
${locationPills.map(p => `${p}`).join('')}
` : ''; // ── Family members ──────────────────────────────────────────────── let membersHtml = ''; if (houseData.household_members && houseData.household_members.length) { const members = houseData.household_members; const shown = members.slice(0, 5); const extra = members.length - shown.length; membersHtml = `
Anggota Keluarga ${members.length}
${shown.map(m => { let statusLine = ''; if (m.employment_status === 'working') statusLine = escapeHtml(m.job_name || 'Bekerja'); else if (m.employment_status === 'studying') statusLine = escapeHtml(m.institution_name || 'Sekolah'); else if (m.employment_status === 'unemployed') statusLine = 'Tidak bekerja'; return `
${escapeHtml(m.name)}
${escapeHtml(m.relationship || '—')}${statusLine ? ' · ' + statusLine : ''}
`; }).join('')} ${extra > 0 ? `
+${extra} anggota lainnya
` : ''}
`; } // ── Aid history ─────────────────────────────────────────────────── let aidHistoryHtml = ''; if (hasAid) { const latestAids = houseData.aid_history.slice(0, 5); const extraAids = houseData.aid_history.length - latestAids.length; aidHistoryHtml = `
Riwayat Bantuan ${houseData.aid_history.length}
${latestAids.map(aid => `
${escapeHtml(aid.aid_type_label || (typeof AID_LABELS !== 'undefined' && AID_LABELS[aid.aid_type]) || aid.aid_type || 'Bantuan')} ${aid.amount ? `${formatRp(aid.amount)}` : ''}
${formatDate(aid.aid_date)}
${aid.description || aid.notes ? `
${escapeHtml((aid.description || aid.notes || '').substring(0, 55))}${(aid.description || aid.notes || '').length > 55 ? '…' : ''}
` : ''}
`).join('')} ${extraAids > 0 ? `
+${extraAids} bantuan lainnya
` : ''}
`; } else { aidHistoryHtml = `
Riwayat Bantuan
Belum ada riwayat bantuan
`; } // ── Assigned center ─────────────────────────────────────────────── const centerHtml = houseData.center_name ? `
${escapeHtml(houseData.center_name)}
` : ''; // ── Coordinates ────────────────────────────────────────────────── const lat = (houseData.latitude || marker.getLatLng().lat).toFixed(6); const lng = (houseData.longitude || marker.getLatLng().lng).toFixed(6); // ── Photos Html ────────────────────────────────────────────────── const photosHtml = getHousePhotosHtml(houseData); // ── Build popup ────────────────────────────────────────────────── const popup = L.popup({ maxWidth: 360, minWidth: 300, closeButton: true, className: 'hp-leaflet-popup' }) .setLatLng(marker.getLatLng()) .setContent(`
${escapeHtml(houseData.head_name)}
NIK: ${escapeHtml(houseData.head_nik || houseData.nik || '—')}
${povLabel}
${aidStatusText}
Lokasi
${escapeHtml(fullAddress)}
${locationPillsHtml} ${centerHtml}
${lat}, ${lng}
${photosHtml}
Kepala Keluarga
Usia ${age} tahun
Pendidikan ${educationLabel(houseData.head_education) || '—'}
Pekerjaan ${jobLine}
Kondisi Rumah ${conditionIcon}
${membersHtml} ${aidHistoryHtml}
${window.canDelete ? `` : ''}
`); marker.unbindPopup(); marker.bindPopup(popup).openPopup(); } // Update click handler untuk memuat data fresh function addHouseMarker(h) { const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude); const color = insideRadius ? '#d63230' : '#0b9e73'; const marker = L.marker([h.latitude, h.longitude], { icon: L.divIcon({ html: `
`, iconSize: [26, 26], iconAnchor: [13, 26], popupAnchor: [0, -28], className: '', }), title: h.head_name, draggable: true, }); marker._houseColor = color; marker._houseData = h; marker._originalId = h.id; // Dragstart marker.on('dragstart', function() { dragInProgress = true; marker.closePopup(); marker.setZIndexOffset(1000); }); // Drag - update color live marker.on('drag', function(e) { const pos = marker.getLatLng(); const newColor = getHouseMarkerColor(pos.lat, pos.lng); if (marker._houseColor !== newColor) { marker._houseColor = newColor; marker.setIcon(L.divIcon({ html: `
`, iconSize: [26, 26], iconAnchor: [13, 26], popupAnchor: [0, -28], className: '', })); } }); marker.on('dragend', async function(e) { dragInProgress = false; marker.setZIndexOffset(0); const pos = marker.getLatLng(); const lat = pos.lat, lng = pos.lng; // Update local data h.latitude = lat; h.longitude = lng; // Update warna marker const newColor = getHouseMarkerColor(lat, lng); marker._houseColor = newColor; marker.setIcon(L.divIcon({ html: `
`, iconSize: [26, 26], iconAnchor: [13, 26], popupAnchor: [0, -28], className: '', })); // Reverse geocode untuk dapat alamat baru let newAddress = h.full_address || h.address; try { newAddress = await reverseGeocode(lat, lng); h.full_address = newAddress; } catch (err) {} // Save ke database via API showLoading(true); try { const r = await ApiHouses.patch(h.id, { latitude: lat, longitude: lng, full_address: newAddress }); if (r.ok && r.data?.success) { if (r.data.data?.managing_center_id) { h.managing_center_id = r.data.data.managing_center_id; const center = State.centers.find(c => c.id == r.data.data.managing_center_id); if (center) h.center_name = center.name; } showToast('Posisi rumah berhasil diperbarui.', 'success'); // Urutan yang benar - update state dulu // 1. Update data di State (Harus pertama) const index = State.houses.findIndex(hi => hi.id === h.id); if (index !== -1) { State.houses[index] = h; } // 2. Hitung ulang jumlah rumah per center (membaca dari State.houses yang sudah diupdate) recountCenterHouseholds(); // 3. Update warna semua marker rumah (yang mungkin berubah status radius) updateAllHouseColors(); // 4. Update tampilan sidebar renderCenterList(); renderHouseList(); updateLayerCounts(); // 5. Update statistik dashboard (ambil data fresh dari server) await loadStats(); // 6. Refresh popup jika terbuka if (marker.isPopupOpen()) { await showHousePopup(marker, h, true); } } else { showToast(r.data?.message || 'Gagal menyimpan posisi.', 'error'); // Kembalikan ke posisi lama jika gagal marker.setLatLng([h.latitude, h.longitude]); } } catch (err) { showToast('Gagal menyimpan posisi: ' + err.message, 'error'); marker.setLatLng([h.latitude, h.longitude]); } finally { showLoading(false); } // Tampilkan popup dengan data terbaru (jika belum terbuka) if (!marker.isPopupOpen()) { await showHousePopup(marker, h, true); } }); // Click handler dengan loading state marker.on('click', async () => { const loadingPopup = L.popup() .setLatLng(marker.getLatLng()) .setContent('
Memuat data...
') .openPopup(); try { const r = await ApiHouses.show(h.id); if (r.ok && r.data?.success) { const freshData = r.data.data; marker._houseData = freshData; const index = State.houses.findIndex(hh => hh.id === freshData.id); if (index !== -1) { State.houses[index] = freshData; State.houses[index]._marker = marker; } await showHousePopup(marker, freshData); } else { loadingPopup.setContent('
Gagal memuat data
'); setTimeout(() => marker.closePopup(), 1500); } } catch (err) { loadingPopup.setContent('
Error memuat data
'); setTimeout(() => marker.closePopup(), 1500); } }); layerHouses.addLayer(marker); h._marker = marker; } // ==================================================================== // SIDEBAR LISTS // ==================================================================== function renderCenterList() { const el = document.getElementById('centersList'); const cnt = document.getElementById('centerCount'); const show = State.activeFilter !== 'houses'; document.getElementById('centersListSection').style.display = show ? '' : 'none'; if (!show) return; const filtered = State.centers.filter(c => { if (!State.searchQuery) return true; const q = State.searchQuery.toLowerCase(); return c.name.toLowerCase().includes(q) || (c.address || '').toLowerCase().includes(q); }); cnt.textContent = filtered.length; if (!filtered.length) { el.innerHTML = '

Tidak ada data

'; return; } el.innerHTML = filtered.slice(0, 50).map(c => { const color = CENTER_COLORS[c.worship_type] || '#3a56d4'; const icon = CENTER_ICONS[c.worship_type] || 'fa-place-of-worship'; return `
${truncate(c.name, 24)}
${CENTER_LABELS[c.worship_type] || ''} · ${c.household_count ?? 0} rumah
${window.canDelete ? `` : ''}
`; }).join(''); } function renderHouseList(filtered) { const el = document.getElementById('housesList'); const cnt = document.getElementById('houseCount'); const show = State.activeFilter !== 'centers'; document.getElementById('housesListSection').style.display = show ? '' : 'none'; if (!show) return; const list = filtered || State.houses; cnt.textContent = list.length; if (!list.length) { el.innerHTML = '

Tidak ada data

'; return; } el.innerHTML = list.slice(0, 80).map(h => { const insideRadius = isHouseInsideAnyRadius(h.latitude, h.longitude); const color = insideRadius ? '#d63230' : '#0b9e73'; const status = insideRadius ? 'Dalam Radius' : 'Luar Radius'; return `
${truncate(h.head_name, 22)}
${status}
${window.canDelete ? `` : ''}
`; }).join(''); if (list.length > 80) el.innerHTML += `

+${list.length - 80} lainnya — gunakan filter

`; } function updateLayerCounts() { const cc = document.getElementById('layerCenterCount'); const hc = document.getElementById('layerHouseCount'); if (cc) cc.textContent = State.centers.length; if (hc) hc.textContent = State.houses.length; }