463 lines
16 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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();
|
|
|