'use strict';
const API_BASE = 'api';
// ─── Map globals (only set on map page) ─────────────────────────────────────
let map = null;
let layers = null;
let layerVisible = null;
// ─── Utilities ───────────────────────────────────────────────────────────────
function toast(msg, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
container.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
function openModal(title, bodyHTML, footerHTML) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = bodyHTML;
document.getElementById('modal-footer').innerHTML = footerHTML;
document.getElementById('modal-overlay').classList.add('open');
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
}
const _modalOverlay = document.getElementById('modal-overlay');
if (_modalOverlay) {
_modalOverlay.addEventListener('click', e => {
if (e.target.id === 'modal-overlay') closeModal();
});
}
async function callAPI(ep, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
try {
const res = await fetch(`${API_BASE}/${ep}`, opts);
if (res.status === 401) {
window.location.href = '?page=login';
return { status: 'error', message: 'Unauthorized' };
}
return await res.json();
} catch (err) {
return { status: 'error', message: 'Koneksi gagal' };
}
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function showInstruction(msg) {
const el = document.getElementById('instruction-banner');
if (el) { el.textContent = '🖱 ' + msg; el.classList.add('show'); }
}
function hideInstruction() {
const el = document.getElementById('instruction-banner');
if (el) el.classList.remove('show');
}
// ─── Search ───────────────────────────────────────────────────────────────────
const searchEntries = [];
let searchActiveIndex = -1;
const searchPointZoom = 15;
const searchIntentPrefix = { rumahibadah: 'ri', pendudukmiskin: 'pm' };
function normalizeSearchText(text) {
return String(text || '').toLowerCase().normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9\s]/g, ' ')
.replace(/\s+/g, ' ').trim();
}
function getSearchIntent(query) {
const q = normalizeSearchText(query);
if (!q) return null;
const aliases = [
{ key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] },
{ key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] }
];
for (const alias of aliases) {
if (alias.terms.some(term => q.includes(term))) return alias.key;
}
return null;
}
function clearSearchEntries(prefix) {
for (let i = searchEntries.length - 1; i >= 0; i--) {
if (searchEntries[i].key.startsWith(prefix + ':')) searchEntries.splice(i, 1);
}
}
function registerSearchEntries(prefix, items) {
clearSearchEntries(prefix);
items.forEach(item => searchEntries.push(item));
}
function getSearchMatches(query) {
const q = normalizeSearchText(query);
if (!q) return [];
const tokens = q.split(' ');
const intent = getSearchIntent(q);
const prefix = intent ? searchIntentPrefix[intent] : null;
const pool = prefix
? searchEntries.filter(entry => entry.key.startsWith(prefix + ':'))
: searchEntries;
return pool.map(entry => {
let score = 0;
const entryLabel = normalizeSearchText(entry.label);
const entryType = normalizeSearchText(entry.typeLabel);
const entryText = entry.searchText;
if (entry.searchText === q) score += 120;
if (entry.searchText.startsWith(q)) score += 80;
if (entryLabel.startsWith(q)) score += 90;
if (entryType.startsWith(q)) score += 60;
if (entry.searchText.includes(q)) score += 50;
const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
score += tokenHits * 12;
if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
if (intent) {
if (entry.key.startsWith(intent + ':')) score += 120;
if (entryType.includes(intent)) score += 110;
}
return { ...entry, score };
}).filter(e => e.score > 0).sort((a, b) => b.score - a.score || a.label.localeCompare(b.label)).slice(0, 10);
}
function renderSearchResults(query) {
const panel = document.getElementById('search-results');
if (!panel) return;
const matches = getSearchMatches(query);
if (!matches.length || !normalizeSearchText(query)) {
searchActiveIndex = -1;
panel.classList.remove('show');
panel.innerHTML = '';
return;
}
if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0;
panel.innerHTML = `
${matches.map((item, index) => `
`).join('')}
`;
panel.classList.add('show');
}
function closeSearchResults() {
const panel = document.getElementById('search-results');
if (panel) panel.classList.remove('show');
}
function selectSearchResult(item) {
const input = document.getElementById('search-input');
if (input) input.value = item.label;
closeSearchResults();
item.focus();
}
function initSearchBar() {
const input = document.getElementById('search-input');
const panel = document.getElementById('search-results');
const wrapper = document.getElementById('header-search');
if (!input || !panel || !wrapper) return;
input.addEventListener('input', () => { searchActiveIndex = 0; renderSearchResults(input.value); });
input.addEventListener('focus', () => renderSearchResults(input.value));
input.addEventListener('keydown', e => {
const matches = getSearchMatches(input.value);
if (e.key === 'Enter') { e.preventDefault(); const active = matches[searchActiveIndex] || matches[0]; if (active) selectSearchResult(active); return; }
if (!matches.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1); renderSearchResults(input.value); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); searchActiveIndex = Math.max(searchActiveIndex - 1, 0); renderSearchResults(input.value); }
});
panel.addEventListener('mousedown', e => {
const item = e.target.closest('.search-result-item');
if (!item) return;
e.preventDefault();
const matches = getSearchMatches(input.value);
const selected = matches[Number(item.dataset.index)];
if (selected) selectSearchResult(selected);
});
document.addEventListener('click', e => { if (!wrapper.contains(e.target)) closeSearchResults(); });
}
// ─── Map-specific functions (only called when map page is active) ─────────────
function initMap() {
const CENTER = [-0.0263, 109.3425];
map = window._leafletMap = L.map('map', { zoomControl: false }).setView(CENTER, 13);
L.control.zoom({ position: 'topleft' }).addTo(map);
const baseLayers = {
'OSM Standard': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }),
'CartoDB Light': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© CartoDB', maxZoom: 19 }),
'Satellite (Esri)': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri', maxZoom: 19 })
};
baseLayers['OSM Standard'].addTo(map);
L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
layers = {
rumahIbadah: L.layerGroup().addTo(map),
ibadahRadius: L.layerGroup().addTo(map),
pendudukMiskin: L.layerGroup().addTo(map)
};
layerVisible = { rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true };
let currentTab = 'rumah_ibadah';
window.currentTab = currentTab;
window._mapClickCb = null;
map.on('mousemove', e => {
const el = document.getElementById('coord-display');
if (el) el.textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
});
map.on('zoomend', () => {
const el = document.getElementById('status-zoom');
if (el) el.textContent = `Zoom: ${map.getZoom()}`;
});
initSearchBar();
return map;
}
let currentTab = 'rumah_ibadah';
let _mapClickCb = null;
function onceMapClick(cb) {
if (!map) return;
if (_mapClickCb) map.off('click', _mapClickCb);
_mapClickCb = function (e) { _mapClickCb = null; cb(e.latlng.lat, e.latlng.lng); };
map.once('click', _mapClickCb);
}
function cancelMapClick() {
if (map && _mapClickCb) { map.off('click', _mapClickCb); _mapClickCb = null; }
hideInstruction();
document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active'));
}
async function getAddressFromCoords(lat, lng) {
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}`);
if (!res.ok) return '';
const data = await res.json();
return data.display_name || '';
} catch (err) { return ''; }
}
function switchTab(tab) {
currentTab = tab;
window.currentTab = tab;
cancelMapClick();
document.querySelectorAll('.nav-tab').forEach(el => {
const onClickAttr = el.getAttribute('onclick') || '';
el.classList.toggle('active', onClickAttr.includes(`'${tab}'`));
});
const titleEl = document.getElementById('panel-title');
if (titleEl) titleEl.textContent = tab === 'rumah_ibadah' ? 'Daftar Rumah Ibadah' : 'Daftar Penduduk Miskin';
renderPanel(tab);
}
async function renderPanel(tab) {
const pb = document.getElementById('panel-body');
if (!pb) return;
pb.innerHTML = 'Memuat data…
';
if (tab === 'rumah_ibadah') await renderRumahIbadahPanel(pb);
else if (tab === 'penduduk_miskin') await renderPendudukMiskinPanel(pb);
}
function focusMarkerById(layerKey, id, zoom = searchPointZoom) {
if (!layers || !map) return;
const layer = layers[layerKey];
if (!layer) return;
let target = null;
layer.eachLayer(marker => { if (marker._dataId === id) target = marker; });
if (!target) return;
map.flyTo(target.getLatLng(), zoom, { duration: 1 });
map.once('moveend', () => target.openPopup());
}
function toggleLayer(key, el) {
if (!layers || !map) return;
layerVisible[key] = !layerVisible[key];
el.classList.toggle('checked', layerVisible[key]);
el.querySelector('.layer-check').textContent = layerVisible[key] ? '✓' : '';
layerVisible[key] ? map.addLayer(layers[key]) : map.removeLayer(layers[key]);
}
function updateStatusCount() {
if (!layers || !map) return;
let total = 0;
Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); });
const countEl = document.getElementById('status-count');
if (countEl) countEl.textContent = `Total fitur: ${total}`;
const zoomEl = document.getElementById('status-zoom');
if (zoomEl) zoomEl.textContent = `Zoom: ${map.getZoom()}`;
}