// ═══════════════════════════════════════════════════════════════
// 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();
}
}
});