315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
'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, '>').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 = `
|
|
<div class="search-results-head"><div class="search-results-title">Rekomendasi</div></div>
|
|
<div class="search-results-list">
|
|
${matches.map((item, index) => `
|
|
<button type="button" class="search-result-item ${index === searchActiveIndex ? 'active' : ''}" data-index="${index}">
|
|
<div class="search-result-icon">${item.icon}</div>
|
|
<div class="search-result-info">
|
|
<div class="search-result-name">${esc(item.label)}</div>
|
|
<div class="search-result-sub">${esc(item.subtitle)}</div>
|
|
</div>
|
|
<div class="search-result-type">${esc(item.typeLabel)}</div>
|
|
</button>`).join('')}
|
|
</div>`;
|
|
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 = '<div class="loading"><span class="spinner"></span>Memuat data…</div>';
|
|
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()}`;
|
|
}
|