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