/* ============================================================
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 `
`;
}
// 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 = `
${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 = `
${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 = `
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(`
`);
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 = ''; 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 = ''; 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;
}