'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(), ibadahRadius: L.layerGroup(), pendudukMiskin: L.layerGroup(), spbu24: L.layerGroup(), spbuNon24: L.layerGroup(), jalanNasional: L.layerGroup(), jalanProvinsi: L.layerGroup(), jalanKabupaten: L.layerGroup(), persilSHM: L.layerGroup(), persilHGB: L.layerGroup(), persilHGU: L.layerGroup(), persilHP: L.layerGroup(), // Layer referensi GeoJSON statis basemap_kecamatan: L.layerGroup(), basemap_jalan: L.layerGroup(), basemap_sungai: L.layerGroup() }; const layerVisible = { rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true, spbu24: true, spbuNon24: true, jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true, persilSHM: true, persilHGB: true, persilHGU: true, persilHP: true, // Basemap GeoJSON (default tampil semua) basemap_kecamatan: true, basemap_jalan: true, basemap_sungai: true }; const searchEntries = []; let searchActiveIndex = -1; const searchPointZoom = 15; const searchIntentPrefix = { jalan: 'jalan', spbu: 'spbu', rumahibadah: 'ri', pendudukmiskin: 'pm', persil: 'persil' }; 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, '"'); } 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: 'persil', terms: ['persil', '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 === 'persil' && /(persil|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 = `
Rekomendasi
${matches.map((item, index) => ` `).join('')}
`; 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) { window.location.href = tab + '.html'; } function initPage(tab) { currentTab = tab; try { map.removeControl(drawControl); } catch (e) { } const tabLayers = { kemiskinan: ['rumahIbadah', 'ibadahRadius', 'pendudukMiskin'], spbu: ['spbu24', 'spbuNon24'], jalan: ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten'], persil: ['persilSHM', 'persilHGB', 'persilHGU', 'persilHP'], basemap: ['basemap_kecamatan', 'basemap_jalan', 'basemap_sungai'] }; if (tabLayers[tab]) { tabLayers[tab].forEach(key => { if (layerVisible[key]) layers[key].addTo(map); }); } const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', persil: 'Persil Tanah', basemap: 'Peta Referensi Pontianak' }; document.getElementById('panel-title').textContent = titles[tab] || tab; renderPanel(tab); } window.startDrawJalan = function() { showInstruction("Klik titik pada peta untuk menggambar rute jalan. Double-klik untuk selesai."); new L.Draw.Polyline(map, drawControl.options.draw.polyline).enable(); }; window.startDrawPersil = function() { showInstruction("Klik titik pada peta untuk batas persil. Klik titik awal untuk menutup area."); new L.Draw.Polygon(map, drawControl.options.draw.polygon).enable(); }; async function renderPanel(tab) { const pb = document.getElementById('panel-body'); pb.innerHTML = '
Memuat data…
'; if (tab === 'kemiskinan') await renderKemiskinanPanel(pb); if (tab === 'spbu') await renderSpbuPanel(pb); if (tab === 'jalan') await renderJalanPanel(pb); if (tab === 'persil') await renderPersilPanel(pb); if (tab === 'basemap') await renderBasemapPanel(pb); } function findGeomLayer(type, id) { const keys = type === 'jalan' ? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten'] : ['persilSHM', 'persilHGB', 'persilHGU', 'persilHP']; 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}` : `persil${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 = persilCfg[data.jenis_hak] || persilCfg.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'] : ['persilSHM', 'persilHGB', 'persilHGU', 'persilHP']; for (const k of keys) { layers[k].eachLayer(l => { if (l._dataId == id) { map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 }); map.once('moveend', () => 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); openPersilForm(); } }); initSearchBar();