Files
Webgis-Poverty/js/app-core.js
T
Abimanyu Ridho 35e1a93aeb Initial commit
2026-06-06 00:22:16 +07:00

463 lines
16 KiB
JavaScript

'use strict';
const API_BASE = 'api';
const CENTER = [-0.0263, 109.3425];
const map = 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);
const layers = {
rumahIbadah: L.layerGroup().addTo(map),
ibadahRadius: L.layerGroup().addTo(map),
pendudukMiskin: L.layerGroup().addTo(map),
spbu24: L.layerGroup().addTo(map),
spbuNon24: L.layerGroup().addTo(map),
jalanNasional: L.layerGroup().addTo(map),
jalanProvinsi: L.layerGroup().addTo(map),
jalanKabupaten: L.layerGroup().addTo(map),
parsilSHM: L.layerGroup().addTo(map),
parsilHGB: L.layerGroup().addTo(map),
parsilHGU: L.layerGroup().addTo(map),
parsilHP: L.layerGroup().addTo(map)
};
const layerVisible = {
rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true,
spbu24: true, spbuNon24: true,
jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true,
parsilSHM: true, parsilHGB: true, parsilHGU: true, parsilHP: true
};
const searchEntries = [];
let searchActiveIndex = -1;
const searchPointZoom = 15;
const searchIntentPrefix = {
jalan: 'jalan',
spbu: 'spbu',
rumahibadah: 'ri',
pendudukmiskin: 'pm',
parsil: 'parsil'
};
const drawnItems = new L.FeatureGroup().addTo(map);
const drawControl = new L.Control.Draw({
draw: {
polyline: { shapeOptions: { color: '#2563eb', weight: 3 } },
polygon: { shapeOptions: { color: '#7c3aed', weight: 2, fillOpacity: 0.15 } },
rectangle: false, circle: false, circlemarker: false, marker: false
},
edit: { featureGroup: drawnItems, remove: false }
});
let pendingGeoJSON = null;
let pendingLength = null;
let pendingArea = null;
let currentTab = 'kemiskinan';
let geometryEditState = null;
let _mapClickCb = null;
function toast(msg, type = 'info') {
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
document.getElementById('toast-container').appendChild(t);
setTimeout(() => t.remove(), 3000);
}
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');
}
document.getElementById('modal-overlay').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 {
return await (await fetch(`${API_BASE}/${ep}`, opts)).json();
} catch (err) {
return { status: 'error', message: 'Koneksi gagal' };
}
}
function showInstruction(msg) {
const el = document.getElementById('instruction-banner');
el.textContent = '🖱 ' + msg;
el.classList.add('show');
}
function hideInstruction() {
document.getElementById('instruction-banner').classList.remove('show');
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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: 'jalan', terms: ['jalan', 'road', 'street'] },
{ key: 'spbu', terms: ['spbu', 'pom', 'bensin', 'bbm'] },
{ key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] },
{ key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] },
{ key: 'parsil', terms: ['parsil', 'tanah', 'sertifikat', 'bidang', 'lahan'] }
];
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 (tokens[0] && (entryText.split(' ').some(word => word.startsWith(tokens[0])) || entryLabel.split(' ').some(word => word.startsWith(tokens[0])))) score += 15;
if (intent) {
if (entry.key.startsWith(intent + ':')) score += 120;
if (entryType.includes(intent)) score += 110;
if (intent === 'jalan' && entryLabel.startsWith('jalan')) score += 40;
if (intent === 'rumahibadah' && /(masjid|gereja|pura|vihara|klenteng)/.test(entryText)) score += 35;
if (intent === 'spbu' && /(spbu|pom|bensin|bbm)/.test(entryText)) score += 35;
if (intent === 'pendudukmiskin' && /(miskin|penduduk|warga)/.test(entryText)) score += 35;
if (intent === 'parsil' && /(parsil|tanah|sertifikat|bidang)/.test(entryText)) score += 35;
}
return { ...entry, score };
})
.filter(entry => entry.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) return;
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();
});
}
function onceMapClick(cb) {
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 (_mapClickCb) {
map.off('click', _mapClickCb);
_mapClickCb = null;
}
hideInstruction();
document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active'));
}
function switchTab(tab) {
currentTab = tab;
cancelMapClick();
document.querySelectorAll('.nav-tab').forEach((el, i) => {
el.classList.toggle('active', ['kemiskinan', 'spbu', 'jalan', 'parsil'][i] === tab);
});
try { map.removeControl(drawControl); } catch (e) { }
const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', parsil: 'Parsil Tanah' };
document.getElementById('panel-title').textContent = titles[tab];
renderPanel(tab);
if (tab === 'jalan' || tab === 'parsil') map.addControl(drawControl);
}
async function renderPanel(tab) {
const pb = document.getElementById('panel-body');
pb.innerHTML = '<div class="loading"><span class="spinner"></span>Memuat data…</div>';
if (tab === 'kemiskinan') await renderKemiskinanPanel(pb);
if (tab === 'spbu') await renderSpbuPanel(pb);
if (tab === 'jalan') await renderJalanPanel(pb);
if (tab === 'parsil') await renderParsilPanel(pb);
}
async function preloadFeatureLayers() {
const sandbox = document.createElement('div');
try {
await Promise.all([
renderSpbuPanel(sandbox),
renderJalanPanel(sandbox),
renderParsilPanel(sandbox)
]);
} catch (err) {
console.warn('Gagal memuat sebagian layer awal:', err);
}
}
function findGeomLayer(type, id) {
const keys = type === 'jalan'
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
: ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
for (const key of keys) {
let found = null;
layers[key].eachLayer(layer => {
if (layer._dataId === id) found = layer;
});
if (found) return found;
}
return null;
}
function getGeometryEditHandler() {
return drawControl?._toolbars?.edit?._modes?.edit?.handler || null;
}
function getGeometryGroupKey(type, data) {
return type === 'jalan' ? `jalan${data.status_jalan}` : `parsil${data.jenis_hak}`;
}
function cloneEditableLayer(type, sourceLayer) {
if (type === 'jalan') {
const coords = sourceLayer.getLatLngs().map(ll => [ll.lat, ll.lng]);
return L.polyline(coords, { ...sourceLayer.options });
}
const ring = sourceLayer.getLatLngs()[0].map(ll => [ll.lat, ll.lng]);
return L.polygon(ring, { ...sourceLayer.options });
}
function buildGeometryLayer(type, data) {
if (type === 'jalan') {
const cfg = jalanCfg[data.status_jalan] || jalanCfg.Kabupaten;
const coords = (data.geojson?.coordinates || []).map(c => [c[1], c[0]]);
return L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 });
}
const cfg = parsilCfg[data.jenis_hak] || parsilCfg.SHM;
const coords = ((data.geojson?.coordinates || [])[0] || []).map(c => [c[1], c[0]]);
return L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 });
}
function cancelGeometryEdit() {
const handler = getGeometryEditHandler();
if (handler) handler.disable();
drawnItems.clearLayers();
geometryEditState = null;
switchTab(currentTab);
}
function focusGeom(id, type) {
const keys = type === 'jalan'
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
: ['parsilSHM', 'parsilHGB', 'parsilHGU', 'parsilHP'];
for (const k of keys) {
layers[k].eachLayer(l => {
if (l._dataId === id) {
map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 });
l.openPopup();
}
});
}
}
function focusMarkerById(layerKey, id, zoom = searchPointZoom) {
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) {
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() {
let total = 0;
Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); });
document.getElementById('status-count').textContent = `Total fitur: ${total}`;
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
}
map.on('mousemove', e => {
document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
});
map.on('zoomend', () => {
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
});
map.on(L.Draw.Event.CREATED, function (e) {
const { layer, layerType: type } = e;
if (type === 'polyline') {
const lls = layer.getLatLngs();
pendingLength = turf.length(turf.lineString(lls.map(c => [c.lng, c.lat])), { units: 'meters' });
pendingGeoJSON = { type: 'LineString', coordinates: lls.map(c => [c.lng, c.lat]) };
drawnItems.addLayer(layer);
openJalanForm();
}
if (type === 'polygon') {
const lls = layer.getLatLngs()[0];
const ring = [...lls.map(c => [c.lng, c.lat]), [lls[0].lng, lls[0].lat]];
pendingArea = turf.area(turf.polygon([ring]));
pendingGeoJSON = { type: 'Polygon', coordinates: [ring] };
drawnItems.addLayer(layer);
openParsilForm();
}
});
initSearchBar();