// =============================== // KONFIGURASI API // =============================== const API = { spbu: 'api/spbu.php', jalan: 'api/jalan.php', parsil: 'api/parsil.php' }; // =============================== // WARNA TEMA // =============================== const COLORS = { spbu24: { fill: '#22c55e', stroke: '#15803d', glow: 'rgba(34,197,94,.35)' }, spbuLtd: { fill: '#f87171', stroke: '#dc2626', glow: 'rgba(248,113,113,.35)' }, jalanNasional: '#f97316', jalanProvinsi: '#3b82f6', jalanKabupaten: '#10b981', SHM: { stroke: '#d97706', fill: '#fef3c7' }, HGB: { stroke: '#8b5cf6', fill: '#ede9fe' }, HGU: { stroke: '#0891b2', fill: '#cffafe' }, HP: { stroke: '#ec4899', fill: '#fce7f3' }, }; // =============================== // STATE GLOBAL // =============================== let map; let layers = { jalan: null, parsil: null }; let spbu24Group, spbuLtdGroup; let currentGeom = null; let drawPoints = []; let tempLayer = null; let savedDrawPoints = []; let allSpbu = [], allJalan = [], allParsil = []; let isDraggingMarker = false; let vertexMarkers = []; let undoStack = []; let redoStack = []; let editingFeature = null; // =============================== // CONFIRM DIALOG // =============================== function showConfirm(message, title = 'Konfirmasi') { return new Promise(resolve => { const overlay = document.getElementById('confirm-overlay'); const titleEl = document.getElementById('confirm-title'); const msgEl = document.getElementById('confirm-message'); if (titleEl) titleEl.textContent = title; if (msgEl) msgEl.textContent = message; overlay.classList.add('open'); const btnOk = document.getElementById('confirm-ok'); const btnCancel = document.getElementById('confirm-cancel'); function cleanup() { overlay.classList.remove('open'); btnOk.removeEventListener('click', onOk); btnCancel.removeEventListener('click', onCancel); } function onOk() { cleanup(); resolve(true); } function onCancel() { cleanup(); resolve(false); } btnOk.addEventListener('click', onOk); btnCancel.addEventListener('click', onCancel); }); } function showToast(msg, type = 'ok') { const container = document.getElementById('toasts'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = (type === 'ok' ? '✅ ' : '❌ ') + msg; container.appendChild(toast); setTimeout(() => toast.remove(), 3200); } // =============================== // ICON SPBU // =============================== function makeSpbuIcon(is24) { const c = is24 ? COLORS.spbu24 : COLORS.spbuLtd; const id = is24 ? 'g' : 'r'; const svg = ` `; return L.divIcon({ html: svg, className: '', iconSize: [32, 40], iconAnchor: [16, 38], popupAnchor: [0, -38] }); } // =============================== // INIT MAP // =============================== function initMap() { map = L.map('map').setView([-0.0263, 109.3425], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }).addTo(map); map.on('click', onMapClick); map.on('mousemove', e => { document.getElementById('cur-lat').innerText = e.latlng.lat.toFixed(5); document.getElementById('cur-lng').innerText = e.latlng.lng.toFixed(5); }); spbu24Group = L.layerGroup().addTo(map); spbuLtdGroup = L.layerGroup().addTo(map); } // =============================== // BUILD POPUP — SPBU // =============================== function buildSpbuPopup(s) { const nama = s.namaspbu || '—'; const is24 = s.statusbuka === 'full'; const coord = (s.lat && s.lng) ? `${Number(s.lat).toFixed(6)}, ${Number(s.lng).toFixed(6)}` : '—'; const wa = s.nomorwa || ''; const badgeCls = is24 ? 'bg' : 'br'; const statusLabel = is24 ? 'Buka 24 Jam' : 'Tidak 24 Jam'; return `
${nama}
SPBU
📍 Koordinat ${coord}
⏰ Status ${statusLabel}
${wa ? `
📱 WhatsApp ${wa}
` : ''}
`; } // =============================== // BUILD POPUP — JALAN // =============================== function buildJalanPopup(p) { const nama = p.nama || '—'; const status = p.status || 'kabupaten'; const panjang = p.panjang_meter ? Number(p.panjang_meter).toFixed(2) + ' m' : '—'; let statusLabel = 'Kabupaten', badgeClass = 'bb-green'; if (status === 'nasional') { statusLabel = 'Nasional'; badgeClass = 'bb-orange'; } if (status === 'provinsi') { statusLabel = 'Provinsi'; badgeClass = 'bb-blue'; } return `
🛣️
${nama}
Jalan
🛣️ Status ${statusLabel}
📏 Panjang ${panjang}
`; } // =============================== // BUILD POPUP — PARSIL // =============================== function buildParsilPopup(props) { const nama = props.nama || '—'; const status = props.status_kepemilikan || 'SHM'; const luas = props.luas_m2 ? Number(props.luas_m2).toFixed(2) + ' m²' : '—'; const badgeMap = { SHM: 'bp-shm', HGB: 'bp-hgb', HGU: 'bp-hgu', HP: 'bp-hp' }; const badgeClass = badgeMap[status] || 'bp-shm'; return `
🗺️
${nama}
Parsil Tanah
📋 Kepemilikan ${status}
📐 Luas ${luas}
`; } // =============================== // RENDER SPBU // =============================== function renderSpbuLayer() { spbu24Group.clearLayers(); spbuLtdGroup.clearLayers(); allSpbu.forEach(s => { const is24 = s.statusbuka === 'full'; const icon = makeSpbuIcon(is24); const marker = L.marker([s.lat, s.lng], { icon, draggable: true, title: s.namaspbu }); marker.bindPopup(buildSpbuPopup(s)); marker.on('click', () => { if (!currentGeom) marker.openPopup(); }); marker.on('dragstart', () => { isDraggingMarker = true; marker.setOpacity(0.65); }); marker.on('dragend', async () => { isDraggingMarker = false; marker.setOpacity(1); const newPos = marker.getLatLng(); await updateSpbuCoordinates(s.id, newPos.lat, newPos.lng); s.lat = newPos.lat; s.lng = newPos.lng; marker.setPopupContent(buildSpbuPopup(s)); showToast('Koordinat SPBU diperbarui'); }); if (is24) spbu24Group.addLayer(marker); else spbuLtdGroup.addLayer(marker); }); } // =============================== // LOAD DATA // =============================== async function loadAllData() { try { const [spbuRes, jalanRes, parsilRes] = await Promise.all([ fetch(API.spbu + '?action=list').then(r => r.json()), fetch(API.jalan + '?action=list').then(r => r.json()), fetch(API.parsil + '?action=list').then(r => r.json()) ]); allSpbu = spbuRes.data || []; allJalan = jalanRes.data || []; allParsil = parsilRes.data || []; renderSpbuLayer(); renderJalanLayer(); renderParsilLayer(); renderSpbuList(); } catch (e) { console.error(e); } finally { document.getElementById('loading').style.display = 'none'; } } // =============================== // RENDER JALAN // =============================== function renderJalanLayer() { if (layers.jalan) map.removeLayer(layers.jalan); const geojson = allJalan.map(j => ({ type: 'Feature', geometry: JSON.parse(j.geom), properties: j })); layers.jalan = L.geoJSON(geojson, { style: f => { const s = f.properties.status; let color = COLORS.jalanKabupaten; if (s === 'nasional') color = COLORS.jalanNasional; else if (s === 'provinsi') color = COLORS.jalanProvinsi; return { color, weight: 4, opacity: 0.9 }; }, onEachFeature: (f, layer) => { layer.bindPopup(buildJalanPopup(f.properties)); layer.on('click', e => { if (!currentGeom) { L.DomEvent.stopPropagation(e); layer.openPopup(); } }); } }).addTo(map); } // =============================== // RENDER PARSIL // =============================== function renderParsilLayer() { if (layers.parsil) map.removeLayer(layers.parsil); const geojson = allParsil.map(p => ({ type: 'Feature', geometry: JSON.parse(p.geom), properties: p })); layers.parsil = L.geoJSON(geojson, { style: f => { const c = COLORS[f.properties.status_kepemilikan] || COLORS.SHM; return { color: c.stroke, fillColor: c.fill, weight: 2.5, fillOpacity: 0.45 }; }, onEachFeature: (f, layer) => { layer.bindPopup(buildParsilPopup(f.properties)); layer.on('click', e => { if (!currentGeom) { L.DomEvent.stopPropagation(e); layer.openPopup(); } }); } }).addTo(map); } // =============================== // LIST SPBU & STATS // =============================== function renderSpbuList() { const el = document.getElementById('spbu-list'); if (!el) return; document.getElementById('cnt-total').innerText = allSpbu.length; document.getElementById('cnt-24').innerText = allSpbu.filter(s => s.statusbuka === 'full').length; document.getElementById('cnt-jalan').innerText = allJalan.length; document.getElementById('cnt-parsil').innerText = allParsil.length; if (!allSpbu.length) { el.innerHTML = '
Belum ada SPBU tercatat.
'; return; } el.innerHTML = allSpbu.map(s => { const lat = parseFloat(s.lat) || 0; const lng = parseFloat(s.lng) || 0; const dot = s.statusbuka === 'full' ? 'dot-green' : 'dot-red'; return `
${s.namaspbu || '—'}
`; }).join(''); } function flyTo(lat, lng) { if (map) map.setView([lat, lng], 16); } // =============================== // EDIT / HAPUS SPBU // =============================== function editSpbu(id) { const s = allSpbu.find(x => x.id == id); if (!s) return showToast('Data SPBU tidak ditemukan', 'er'); document.getElementById('e-spbu-id').value = s.id; document.getElementById('e-spbu-nama').value = s.namaspbu || ''; document.getElementById('e-spbu-wa').value = s.nomorwa || ''; document.getElementById('e-spbu-status').value = s.statusbuka || 'limited'; openModal('m-edit-spbu'); } async function hapusSpbu(id) { const ok = await showConfirm('Hapus SPBU ini? Tindakan tidak dapat dibatalkan.', 'Hapus SPBU'); if (!ok) return; try { const res = await fetch(API.spbu, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id }) }); const j = await res.json(); if (j.status === 'success') { loadAllData(); showToast('SPBU berhasil dihapus'); } else showToast(j.message || 'Gagal hapus', 'er'); } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } async function updateSpbu() { const id = parseInt(document.getElementById('e-spbu-id').value || 0); const namaspbu = document.getElementById('e-spbu-nama').value; const nomorwa = document.getElementById('e-spbu-wa').value; const statusbuka = document.getElementById('e-spbu-status').value; try { const res = await fetch(API.spbu, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id, namaspbu, nomorwa, statusbuka }) }); const j = await res.json(); if (j.status === 'success') { closeModal('m-edit-spbu'); loadAllData(); showToast('SPBU diperbarui'); } else showToast(j.message || 'Gagal update', 'er'); } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } async function updateSpbuCoordinates(id, lat, lng) { const spbu = allSpbu.find(x => x.id == id); if (!spbu) return; await fetch(API.spbu, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id, namaspbu: spbu.namaspbu, nomorwa: spbu.nomorwa, statusbuka: spbu.statusbuka, koordinat: { type: 'Point', coordinates: [lng, lat] } }) }); } // =============================== // EDIT / HAPUS JALAN // =============================== function editJalan(id) { const j = allJalan.find(x => x.id == id); if (!j) return showToast('Data jalan tidak ditemukan', 'er'); document.getElementById('e-jalan-id').value = j.id; document.getElementById('e-j-nama').value = j.nama || ''; document.getElementById('e-j-status').value = j.status || 'kabupaten'; document.getElementById('e-j-panjang').value = j.panjang_meter || ''; openModal('m-edit-jalan'); } async function hapusJalan(id) { const ok = await showConfirm('Hapus jalan ini? Tindakan tidak dapat dibatalkan.', 'Hapus Jalan'); if (!ok) return; try { const res = await fetch(API.jalan, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id }) }); const j = await res.json(); if (j.status === 'success') { loadAllData(); showToast('Jalan berhasil dihapus'); } else showToast(j.message || 'Gagal hapus', 'er'); } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } async function updateJalan() { const id = parseInt(document.getElementById('e-jalan-id').value || 0); const nama = document.getElementById('e-j-nama').value; const status = document.getElementById('e-j-status').value; const panjang_meter = parseFloat(document.getElementById('e-j-panjang').value); try { const res = await fetch(API.jalan, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id, nama, status, panjang_meter }) }); const j = await res.json(); if (j.status === 'success') { closeModal('m-edit-jalan'); loadAllData(); showToast('Jalan diperbarui'); } else showToast(j.message || 'Gagal update', 'er'); } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } // =============================== // EDIT GEOMETRI JALAN // =============================== function startEditGeomJalan(id) { map.closePopup(); stopEditGeom(); const j = allJalan.find(x => x.id == id); if (!j) return; const geom = JSON.parse(j.geom); let coords = geom.coordinates.map(c => [c[1], c[0]]); const geomLayer = L.polyline(coords, { color: '#f97316', weight: 4, dashArray: '8,5', opacity: 1 }).addTo(map); const vms = []; const updatePolyline = () => { const pts = vms.map(vm => vm.getLatLng()); geomLayer.setLatLngs(pts); const turfCoords = pts.map(p => [p.lng, p.lat]); if (turfCoords.length >= 2) { const panjang = turf.length(turf.lineString(turfCoords), { units: 'meters' }); document.getElementById('geom-edit-info').textContent = `Panjang: ${panjang.toFixed(2)} m`; } }; coords.forEach(c => { const vm = L.marker(c, { draggable: true, icon: vertexIcon() }).addTo(map); vm.on('drag', updatePolyline); vms.push(vm); }); editingFeature = { type: 'jalan', id, geomLayer, vms }; showEditGeomToolbar('Drag titik untuk edit geometri jalan'); } // =============================== // EDIT / HAPUS PARSIL // =============================== function editParsil(id) { const p = allParsil.find(x => x.id == id); if (!p) return showToast('Data parsil tidak ditemukan', 'er'); document.getElementById('e-parsil-id').value = p.id; document.getElementById('e-p-nama').value = p.nama || ''; document.getElementById('e-p-status').value = p.status_kepemilikan || 'SHM'; document.getElementById('e-p-luas').value = p.luas_m2 || ''; openModal('m-edit-parsil'); } async function hapusParsil(id) { const ok = await showConfirm('Hapus parsil ini? Tindakan tidak dapat dibatalkan.', 'Hapus Parsil'); if (!ok) return; try { const res = await fetch(API.parsil, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id }) }); const j = await res.json(); if (j.status === 'success') { loadAllData(); showToast('Parsil berhasil dihapus'); } else showToast(j.message || 'Gagal hapus', 'er'); } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } async function updateParsil() { const id = parseInt(document.getElementById('e-parsil-id').value || 0); const nama = document.getElementById('e-p-nama').value; const status_kepemilikan = document.getElementById('e-p-status').value; const luas_m2 = parseFloat(document.getElementById('e-p-luas').value); try { const res = await fetch(API.parsil, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id, nama, status_kepemilikan, luas_m2 }) }); const j = await res.json(); if (j.status === 'success') { closeModal('m-edit-parsil'); loadAllData(); showToast('Parsil diperbarui'); } else showToast(j.message || 'Gagal update', 'er'); } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } // =============================== // EDIT GEOMETRI PARSIL // =============================== function startEditGeomParsil(id) { map.closePopup(); stopEditGeom(); const p = allParsil.find(x => x.id == id); if (!p) return; const geom = JSON.parse(p.geom); let coords = geom.coordinates[0].slice(0, -1).map(c => [c[1], c[0]]); const geomLayer = L.polygon(coords, { color: '#8b5cf6', fillColor: '#ede9fe', weight: 2.5, dashArray: '8,5', fillOpacity: 0.3 }).addTo(map); const vms = []; const updatePolygon = () => { const pts = vms.map(vm => vm.getLatLng()); geomLayer.setLatLngs(pts); const turfCoords = [...pts.map(p => [p.lng, p.lat])]; turfCoords.push(turfCoords[0]); if (turfCoords.length >= 4) { const luas = turf.area(turf.polygon([turfCoords])); document.getElementById('geom-edit-info').textContent = `Luas: ${luas.toFixed(2)} m²`; } }; coords.forEach(c => { const vm = L.marker(c, { draggable: true, icon: vertexIcon() }).addTo(map); vm.on('drag', updatePolygon); vms.push(vm); }); editingFeature = { type: 'parsil', id, geomLayer, vms }; showEditGeomToolbar('Drag titik untuk edit geometri parsil'); } function vertexIcon() { return L.divIcon({ html: `
`, className: '', iconSize: [14, 14], iconAnchor: [7, 7] }); } function showEditGeomToolbar(hint) { const tb = document.getElementById('geom-edit-toolbar'); document.getElementById('geom-edit-hint').textContent = hint; document.getElementById('geom-edit-info').textContent = ''; tb.classList.add('visible'); } function stopEditGeom() { if (!editingFeature) return; if (editingFeature.geomLayer) map.removeLayer(editingFeature.geomLayer); editingFeature.vms.forEach(vm => { try { map.removeLayer(vm); } catch (e) {} }); editingFeature = null; document.getElementById('geom-edit-toolbar').classList.remove('visible'); } async function saveEditGeom() { if (!editingFeature) return; const { type, id, vms } = editingFeature; const pts = vms.map(vm => vm.getLatLng()); try { if (type === 'jalan') { const coords = pts.map(p => [p.lng, p.lat]); const panjang = turf.length(turf.lineString(coords), { units: 'meters' }); const j = allJalan.find(x => x.id == id); const res = await fetch(API.jalan, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id, nama: j.nama, status: j.status, panjang_meter: panjang, geom: { type: 'LineString', coordinates: coords } }) }); const data = await res.json(); if (data.status === 'success') { showToast('Geometri jalan diperbarui'); stopEditGeom(); loadAllData(); } else showToast(data.message || 'Gagal simpan', 'er'); } else if (type === 'parsil') { const coords = pts.map(p => [p.lng, p.lat]); coords.push(coords[0]); const luas = turf.area(turf.polygon([coords])); const p = allParsil.find(x => x.id == id); const res = await fetch(API.parsil, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', id, nama: p.nama, status_kepemilikan: p.status_kepemilikan, luas_m2: luas, geom: { type: 'Polygon', coordinates: [coords] } }) }); const data = await res.json(); if (data.status === 'success') { showToast('Geometri parsil diperbarui'); stopEditGeom(); loadAllData(); } else showToast(data.message || 'Gagal simpan', 'er'); } } catch (e) { showToast('Gagal: ' + e.message, 'er'); } } // =============================== // DRAWING // =============================== function onMapClick(e) { if (isDraggingMarker) return; if (!currentGeom) return; if (currentGeom === 'point') { document.getElementById('spbu-lat').value = e.latlng.lat; document.getElementById('spbu-lng').value = e.latlng.lng; openModal('m-add-spbu'); return; } drawPoints.push({ lat: e.latlng.lat, lng: e.latlng.lng }); undoStack.push({ lat: e.latlng.lat, lng: e.latlng.lng }); redoStack = []; drawTemp(); } function drawTemp() { if (tempLayer) { map.removeLayer(tempLayer); tempLayer = null; } vertexMarkers.forEach(m => { try { map.removeLayer(m); } catch (e) {} }); vertexMarkers = []; if (!drawPoints.length) { updateCounter(); return; } const latlngs = drawPoints.map(p => [p.lat, p.lng]); if (currentGeom === 'polyline' && drawPoints.length >= 2) tempLayer = L.polyline(latlngs, { color: COLORS.jalanProvinsi, weight: 3, dashArray: '7,5', opacity: .85 }).addTo(map); if (currentGeom === 'polygon' && drawPoints.length >= 3) tempLayer = L.polygon(latlngs, { color: COLORS.SHM.stroke, weight: 2.5, dashArray: '7,5', fillColor: COLORS.SHM.fill, fillOpacity: .25 }).addTo(map); drawPoints.forEach(p => { const vm = L.circleMarker([p.lat, p.lng], { radius: 6, color: '#fff', weight: 2.5, fillColor: currentGeom === 'polygon' ? COLORS.SHM.stroke : COLORS.jalanProvinsi, fillOpacity: 1 }).addTo(map); vertexMarkers.push(vm); }); updateCounter(); } function undoDraw() { if (!drawPoints.length) return; redoStack.push(drawPoints.pop()); drawTemp(); } function redoDraw() { if (!redoStack.length) return; drawPoints.push(redoStack.pop()); drawTemp(); } function finishDrawing() { if (currentGeom === 'polyline') { if (drawPoints.length < 2) { showToast('Minimal 2 titik untuk membuat jalan!', 'er'); return; } finishJalanDrawing(); } else if (currentGeom === 'polygon') { if (drawPoints.length < 3) { showToast('Minimal 3 titik untuk membuat parsil!', 'er'); return; } finishParsilDrawing(); } } function finishJalanDrawing() { savedDrawPoints = drawPoints.map(p => ({ lat: p.lat, lng: p.lng })); const coords = savedDrawPoints.map(p => [p.lng, p.lat]); const panjang = turf.length(turf.lineString(coords), { units: 'meters' }); document.getElementById('j-nama').value = ''; document.getElementById('j-status').value = 'kabupaten'; document.getElementById('j-panjang').value = panjang.toFixed(2); openModal('m-add-jalan'); } function finishParsilDrawing() { savedDrawPoints = drawPoints.map(p => ({ lat: p.lat, lng: p.lng })); const coords = savedDrawPoints.map(p => [p.lng, p.lat]); coords.push(coords[0]); const luas = turf.area(turf.polygon([coords])); document.getElementById('p-nama').value = ''; document.getElementById('p-status').value = 'SHM'; document.getElementById('p-luas').value = luas.toFixed(2); openModal('m-add-parsil'); } async function saveSpbu() { const lat = parseFloat(document.getElementById('spbu-lat').value); const lng = parseFloat(document.getElementById('spbu-lng').value); const namaspbu = document.getElementById('spbu-nama').value.trim(); if (!namaspbu) { showToast('Nama SPBU wajib diisi!', 'er'); return; } if (isNaN(lat) || isNaN(lng)) { showToast('Koordinat tidak valid!', 'er'); return; } try { const res = await fetch(API.spbu, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', namaspbu, nomorwa: document.getElementById('spbu-wa').value, statusbuka: document.getElementById('spbu-status').value, koordinat: { type: 'Point', coordinates: [lng, lat] } }) }); const data = await res.json(); if (data.status !== 'success') { showToast(data.message || 'Gagal simpan', 'er'); return; } closeModal('m-add-spbu'); deactivateGeom(); loadAllData(); showToast('SPBU berhasil ditambahkan'); } catch (e) { showToast('Error: ' + e.message, 'er'); } } async function saveJalan() { const nama = document.getElementById('j-nama').value.trim(); if (!nama) { showToast('Nama jalan wajib diisi!', 'er'); return; } if (savedDrawPoints.length < 2) { showToast('Data koordinat hilang, silakan gambar ulang.', 'er'); return; } try { const res = await fetch(API.jalan, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', nama, status: document.getElementById('j-status').value, panjang_meter: parseFloat(document.getElementById('j-panjang').value), geom: { type: 'LineString', coordinates: savedDrawPoints.map(p => [p.lng, p.lat]) } }) }); const data = await res.json(); if (data.status !== 'success') { showToast(data.message || 'Gagal simpan', 'er'); return; } closeModal('m-add-jalan'); deactivateGeom(); cancelDraw(); loadAllData(); showToast('Jalan berhasil ditambahkan'); } catch (e) { showToast('Error: ' + e.message, 'er'); } } async function saveParsil() { const nama = document.getElementById('p-nama').value.trim(); if (!nama) { showToast('Nama parsil wajib diisi!', 'er'); return; } if (savedDrawPoints.length < 3) { showToast('Data koordinat hilang, silakan gambar ulang.', 'er'); return; } const coords = savedDrawPoints.map(p => [p.lng, p.lat]); coords.push(coords[0]); try { const res = await fetch(API.parsil, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', nama, status_kepemilikan: document.getElementById('p-status').value, luas_m2: parseFloat(document.getElementById('p-luas').value), geom: { type: 'Polygon', coordinates: [coords] } }) }); const data = await res.json(); if (data.status !== 'success') { showToast(data.message || 'Gagal simpan', 'er'); return; } closeModal('m-add-parsil'); deactivateGeom(); cancelDraw(); loadAllData(); showToast('Parsil berhasil ditambahkan'); } catch (e) { showToast('Error: ' + e.message, 'er'); } } function cancelDraw() { drawPoints = []; undoStack = []; redoStack = []; savedDrawPoints = []; if (tempLayer) { try { map.removeLayer(tempLayer); } catch (e) {} tempLayer = null; } vertexMarkers.forEach(m => { try { map.removeLayer(m); } catch (e) {} }); vertexMarkers = []; updateCounter(); document.getElementById('draw-toolbar').classList.remove('visible'); } function deactivateGeom() { currentGeom = null; document.querySelectorAll('.geom-btn').forEach(b => b.classList.remove('active')); document.getElementById('geom-hint').classList.remove('on'); document.getElementById('map').classList.remove('crosshair-cursor'); document.getElementById('draw-toolbar').classList.remove('visible'); } function openModal(id) { document.getElementById(id).classList.add('open'); } function closeModal(id) { document.getElementById(id).classList.remove('open'); } function updateCounter() { const n = drawPoints.length; document.getElementById('dt-counter').innerText = n + ' titik'; document.getElementById('btn-undo').disabled = n === 0; document.getElementById('btn-redo').disabled = redoStack.length === 0; } // =============================== // SEARCH // =============================== function setupSearch() { const input = document.getElementById('search-input'); const results = document.getElementById('search-results'); if (!input || !results) return; input.addEventListener('input', () => { const q = input.value.trim().toLowerCase(); results.innerHTML = ''; if (!q) { results.classList.remove('open'); return; } const hits = []; allSpbu.forEach(s => { if ((s.namaspbu || '').toLowerCase().includes(q)) hits.push({ label: s.namaspbu, sub: 'SPBU · Point', icon: '⛽', action: () => { flyTo(s.lat, s.lng); input.value = ''; results.classList.remove('open'); } }); }); allJalan.forEach(j => { if ((j.nama || '').toLowerCase().includes(q)) hits.push({ label: j.nama, sub: `Jalan ${j.status || ''}`, icon: '🛣️', action: () => { try { const geom = JSON.parse(j.geom); const mid = geom.coordinates[Math.floor(geom.coordinates.length/2)]; map.setView([mid[1], mid[0]], 16); } catch (e) {} input.value = ''; results.classList.remove('open'); } }); }); allParsil.forEach(p => { if ((p.nama || '').toLowerCase().includes(q)) hits.push({ label: p.nama, sub: `Parsil · ${p.status_kepemilikan || 'SHM'}`, icon: '🗺️', action: () => { try { const geom = JSON.parse(p.geom); const coords = geom.coordinates[0]; const cx = coords.reduce((a,c)=>a+c[0],0)/coords.length; const cy = coords.reduce((a,c)=>a+c[1],0)/coords.length; map.setView([cy,cx],17); } catch (e) {} input.value = ''; results.classList.remove('open'); } }); }); if (!hits.length) { results.innerHTML = '
Tidak ada hasil
'; } else { results.innerHTML = hits.slice(0, 8).map((h, i) => `
${h.icon}
${h.label}
${h.sub}
` ).join(''); results.querySelectorAll('.sr-item').forEach((el, i) => { el.addEventListener('click', () => hits[i].action()); }); } results.classList.add('open'); }); document.addEventListener('click', e => { if (!e.target.closest('#search-wrap')) results.classList.remove('open'); }); } // =============================== // INIT // =============================== document.addEventListener('DOMContentLoaded', () => { initMap(); loadAllData(); setupSearch(); document.getElementById('btn-finish').onclick = finishDrawing; document.getElementById('btn-undo').onclick = undoDraw; document.getElementById('btn-redo').onclick = redoDraw; document.getElementById('btn-cancel-draw').onclick = () => { cancelDraw(); deactivateGeom(); }; document.getElementById('btn-geom-save').onclick = saveEditGeom; document.getElementById('btn-geom-cancel').onclick = stopEditGeom; // Geom buttons document.querySelectorAll('.geom-btn').forEach(btn => { btn.addEventListener('click', () => { const geom = btn.dataset.geom; if (currentGeom === geom) { cancelDraw(); deactivateGeom(); return; } cancelDraw(); currentGeom = geom; document.querySelectorAll('.geom-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.getElementById('map').classList.add('crosshair-cursor'); document.getElementById('draw-toolbar').classList.toggle('visible', geom === 'polyline' || geom === 'polygon'); const hint = document.getElementById('geom-hint'); const hintText = document.getElementById('geom-hint-text'); hint.classList.add('on'); if (geom === 'point') hintText.innerText = 'Klik peta untuk tambah SPBU'; if (geom === 'polyline') hintText.innerText = 'Klik peta untuk menambah titik jalan, klik Selesai jika sudah'; if (geom === 'polygon') hintText.innerText = 'Klik peta untuk menambah titik parsil, klik Selesai jika sudah'; }); }); // Layer toggle custom const ldbtn = document.getElementById('ldbtn'); const layerMenu = document.getElementById('layer-menu'); if (ldbtn && layerMenu) { ldbtn.addEventListener('click', e => { e.stopPropagation(); layerMenu.classList.toggle('open'); const chev = ldbtn.querySelector('.chev'); if (chev) chev.textContent = layerMenu.classList.contains('open') ? '▲' : '▼'; }); document.addEventListener('click', () => { layerMenu.classList.remove('open'); const chev = ldbtn.querySelector('.chev'); if (chev) chev.textContent = '▼'; }); const cbPoint = document.getElementById('lm-point'); const cbSpbu24 = document.getElementById('lm-spbu24'); const cbSpbuLtd = document.getElementById('lm-spbuLtd'); const subSpbu = document.getElementById('lm-sub-spbu'); function syncSpbuLayers() { const spbuOn = cbPoint.checked; subSpbu.classList.toggle('hidden', !spbuOn); if (spbuOn && cbSpbu24.checked) spbu24Group.addTo(map); else map.removeLayer(spbu24Group); if (spbuOn && cbSpbuLtd.checked) spbuLtdGroup.addTo(map); else map.removeLayer(spbuLtdGroup); } cbPoint.addEventListener('change', syncSpbuLayers); cbSpbu24.addEventListener('change', syncSpbuLayers); cbSpbuLtd.addEventListener('change', syncSpbuLayers); document.getElementById('lm-jalan').addEventListener('change', function () { if (this.checked) { if (layers.jalan) layers.jalan.addTo(map); } else { if (layers.jalan) map.removeLayer(layers.jalan); } }); document.getElementById('lm-parsil').addEventListener('change', function () { if (this.checked) { if (layers.parsil) layers.parsil.addTo(map); } else { if (layers.parsil) map.removeLayer(layers.parsil); } }); } });