function setStatus(element, message, type) { element.textContent = message; element.classList.remove('error', 'success'); if (type) { element.classList.add(type); } } function roadColorByStatus(status) { if (status === 'nasional') { return '#dc2626'; } if (status === 'provinsi') { return '#2563eb'; } return '#16a34a'; } function parcelColorByStatus(status) { if (status === 'SHM') { return '#ea580c'; } if (status === 'HGB') { return '#0ea5e9'; } if (status === 'HGU') { return '#22c55e'; } return '#a855f7'; } function calculatePolylineLengthMeters(latlngs) { let total = 0; for (let index = 1; index < latlngs.length; index += 1) { total += latlngs[index - 1].distanceTo(latlngs[index]); } return total; } function calculatePolygonAreaMeters(latlngs) { if (window.L && L.GeometryUtil && typeof L.GeometryUtil.geodesicArea === 'function') { return L.GeometryUtil.geodesicArea(latlngs); } const earthRadius = 6378137; const toRad = (degree) => degree * Math.PI / 180; let area = 0; for (let i = 0; i < latlngs.length; i += 1) { const p1 = latlngs[i]; const p2 = latlngs[(i + 1) % latlngs.length]; area += toRad(p2.lng - p1.lng) * (2 + Math.sin(toRad(p1.lat)) + Math.sin(toRad(p2.lat))); } return Math.abs(area * earthRadius * earthRadius / 2); } function polylineToWkt(latlngs) { const coordinates = latlngs.map((point) => `${point.lng} ${point.lat}`).join(', '); return `LINESTRING(${coordinates})`; } function polygonToWkt(latlngs) { const ring = latlngs.map((point) => `${point.lng} ${point.lat}`); if (ring[0] !== ring[ring.length - 1]) { ring.push(ring[0]); } return `POLYGON((${ring.join(', ')}))`; } async function parseJsonResponse(response) { const text = await response.text(); try { return JSON.parse(text); } catch (error) { throw new Error('Response server bukan JSON valid.'); } } document.addEventListener('DOMContentLoaded', async () => { const map = L.map('map').setView([-0.0605, 109.3449], 13); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(map); if (typeof window.addKecamatanLayer === 'function') { window.addKecamatanLayer(map, { fitBounds: true }).catch((error) => { console.warn('Data kecamatan belum termuat:', error); }); } const editableLayer = new L.FeatureGroup(); map.addLayer(editableLayer); const modalBackdrop = document.getElementById('modal-backdrop'); const modalTitle = document.getElementById('modal-title'); const featureForm = document.getElementById('feature-form'); const roadFields = document.getElementById('road-fields'); const parcelFields = document.getElementById('parcel-fields'); const formStatus = document.getElementById('form-status'); const cancelButton = document.getElementById('btn-cancel'); let pendingLayer = null; let pendingType = null; let pendingMode = 'create'; function closeModal(shouldRemovePendingLayer) { modalBackdrop.classList.remove('open'); modalBackdrop.setAttribute('aria-hidden', 'true'); setStatus(formStatus, '', ''); if (shouldRemovePendingLayer && pendingMode === 'create' && pendingLayer) { editableLayer.removeLayer(pendingLayer); } pendingLayer = null; pendingType = null; pendingMode = 'create'; featureForm.reset(); } function openRoadModal(layer, mode, initialData) { pendingLayer = layer; pendingType = 'road'; pendingMode = mode; modalTitle.textContent = mode === 'create' ? 'Tambah Data Jalan' : 'Update Data Jalan'; roadFields.hidden = false; parcelFields.hidden = true; const latlngs = layer.getLatLngs(); const lengthMeters = calculatePolylineLengthMeters(latlngs); document.getElementById('nama_jalan').value = initialData && initialData.nama_jalan ? initialData.nama_jalan : ''; document.getElementById('status_jalan').value = initialData && initialData.status ? initialData.status : 'nasional'; document.getElementById('panjang_meter').value = lengthMeters.toFixed(2); modalBackdrop.classList.add('open'); modalBackdrop.setAttribute('aria-hidden', 'false'); } function openParcelModal(layer, mode, initialData) { pendingLayer = layer; pendingType = 'parcel'; pendingMode = mode; modalTitle.textContent = mode === 'create' ? 'Tambah Data Parsil Tanah' : 'Update Data Parsil Tanah'; roadFields.hidden = true; parcelFields.hidden = false; const latlngs = layer.getLatLngs()[0]; const areaM2 = calculatePolygonAreaMeters(latlngs); document.getElementById('nama_pemilik').value = initialData && initialData.nama_pemilik ? initialData.nama_pemilik : ''; document.getElementById('status_kepemilikan').value = initialData && initialData.status_kepemilikan ? initialData.status_kepemilikan : 'SHM'; document.getElementById('luas_m2').value = areaM2.toFixed(2); modalBackdrop.classList.add('open'); modalBackdrop.setAttribute('aria-hidden', 'false'); } function attachRoadPopup(layer) { const props = layer.featureProps; layer.setStyle({ color: roadColorByStatus(props.status), weight: 5 }); layer.bindPopup(` Jalan: ${props.nama_jalan}
Status: ${props.status}
Panjang: ${Number(props.panjang_meter).toFixed(2)} m
`); layer.on('popupopen', (event) => { const popupElement = event.popup.getElement(); const editButton = popupElement.querySelector('[data-action="edit"]'); const deleteButton = popupElement.querySelector('[data-action="delete"]'); const popupStatus = popupElement.querySelector('[data-role="popup-status"]'); editButton.addEventListener('click', () => { map.closePopup(); openRoadModal(layer, 'update', layer.featureProps); }); deleteButton.addEventListener('click', async () => { if (!window.confirm('Hapus data jalan ini?')) { return; } setStatus(popupStatus, 'Menghapus data...', ''); deleteButton.disabled = true; try { const response = await fetch('api/road_delete.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ id: layer.featureId }) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal menghapus data jalan.'); } editableLayer.removeLayer(layer); map.closePopup(); } catch (error) { setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error'); deleteButton.disabled = false; } }); }); } function attachParcelPopup(layer) { const props = layer.featureProps; layer.setStyle({ color: parcelColorByStatus(props.status_kepemilikan), fillColor: parcelColorByStatus(props.status_kepemilikan), fillOpacity: 0.35, weight: 3 }); layer.bindPopup(` Pemilik: ${props.nama_pemilik}
Status: ${props.status_kepemilikan}
Luas: ${Number(props.luas_m2).toFixed(2)} m²
`); layer.on('popupopen', (event) => { const popupElement = event.popup.getElement(); const editButton = popupElement.querySelector('[data-action="edit"]'); const deleteButton = popupElement.querySelector('[data-action="delete"]'); const popupStatus = popupElement.querySelector('[data-role="popup-status"]'); editButton.addEventListener('click', () => { map.closePopup(); openParcelModal(layer, 'update', layer.featureProps); }); deleteButton.addEventListener('click', async () => { if (!window.confirm('Hapus data parsil ini?')) { return; } setStatus(popupStatus, 'Menghapus data...', ''); deleteButton.disabled = true; try { const response = await fetch('api/parcel_delete.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ id: layer.featureId }) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal menghapus data parsil.'); } editableLayer.removeLayer(layer); map.closePopup(); } catch (error) { setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error'); deleteButton.disabled = false; } }); }); } async function loadRoads() { const response = await fetch('api/road_read.php', { headers: { 'Accept': 'application/json' } }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal memuat data jalan.'); } result.data.forEach((item) => { if (!item.geometry_geojson) { return; } const layer = L.geoJSON(item.geometry_geojson).getLayers()[0]; if (!layer) { return; } layer.featureType = 'road'; layer.featureId = item.id; layer.featureProps = { nama_jalan: item.nama_jalan, status: item.status, panjang_meter: item.panjang_meter }; attachRoadPopup(layer); editableLayer.addLayer(layer); }); } async function loadParcels() { const response = await fetch('api/parcel_read.php', { headers: { 'Accept': 'application/json' } }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal memuat data parsil.'); } result.data.forEach((item) => { if (!item.geometry_geojson) { return; } const layer = L.geoJSON(item.geometry_geojson).getLayers()[0]; if (!layer) { return; } layer.featureType = 'parcel'; layer.featureId = item.id; layer.featureProps = { nama_pemilik: item.nama_pemilik, status_kepemilikan: item.status_kepemilikan, luas_m2: item.luas_m2 }; attachParcelPopup(layer); editableLayer.addLayer(layer); }); } const drawControl = new L.Control.Draw({ position: 'topleft', draw: { polyline: { shapeOptions: { color: '#dc2626', weight: 5 } }, polygon: { allowIntersection: false, showArea: true, shapeOptions: { color: '#ea580c', fillOpacity: 0.35, weight: 3 } }, rectangle: false, circle: false, marker: false, circlemarker: false }, edit: { featureGroup: editableLayer, remove: true } }); map.addControl(drawControl); map.on(L.Draw.Event.CREATED, (event) => { const layer = event.layer; const geometryType = event.layerType; editableLayer.addLayer(layer); if (geometryType === 'polyline') { openRoadModal(layer, 'create', null); return; } if (geometryType === 'polygon') { openParcelModal(layer, 'create', null); return; } editableLayer.removeLayer(layer); }); map.on(L.Draw.Event.EDITED, async (event) => { const layers = event.layers.getLayers(); for (const layer of layers) { try { if (layer.featureType === 'road') { const lengthMeters = calculatePolylineLengthMeters(layer.getLatLngs()); const payload = { id: layer.featureId, nama_jalan: layer.featureProps.nama_jalan, status: layer.featureProps.status, panjang_meter: Number(lengthMeters.toFixed(2)), wkt: polylineToWkt(layer.getLatLngs()) }; const response = await fetch('api/road_update.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal update geometri jalan.'); } layer.featureProps.panjang_meter = payload.panjang_meter; attachRoadPopup(layer); } if (layer.featureType === 'parcel') { const areaM2 = calculatePolygonAreaMeters(layer.getLatLngs()[0]); const payload = { id: layer.featureId, nama_pemilik: layer.featureProps.nama_pemilik, status_kepemilikan: layer.featureProps.status_kepemilikan, luas_m2: Number(areaM2.toFixed(2)), wkt: polygonToWkt(layer.getLatLngs()[0]) }; const response = await fetch('api/parcel_update.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal update geometri parsil.'); } layer.featureProps.luas_m2 = payload.luas_m2; attachParcelPopup(layer); } } catch (error) { window.alert(error.message || 'Gagal menyimpan update geometri.'); } } }); map.on(L.Draw.Event.DELETED, async (event) => { const layers = event.layers.getLayers(); for (const layer of layers) { if (!layer.featureType || !layer.featureId) { continue; } try { const endpoint = layer.featureType === 'road' ? 'api/road_delete.php' : 'api/parcel_delete.php'; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ id: layer.featureId }) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal menghapus data.'); } } catch (error) { window.alert(error.message || 'Gagal sinkron delete ke database.'); } } }); featureForm.addEventListener('submit', async (event) => { event.preventDefault(); if (!pendingLayer || !pendingType) { return; } setStatus(formStatus, 'Menyimpan data...', ''); try { if (pendingType === 'road') { const namaJalan = document.getElementById('nama_jalan').value.trim(); const statusJalan = document.getElementById('status_jalan').value; const panjangMeter = Number(document.getElementById('panjang_meter').value); const wkt = polylineToWkt(pendingLayer.getLatLngs()); const endpoint = pendingMode === 'create' ? 'api/road_create.php' : 'api/road_update.php'; const payload = { nama_jalan: namaJalan, status: statusJalan, panjang_meter: panjangMeter, wkt: wkt }; if (pendingMode === 'update') { payload.id = pendingLayer.featureId; } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal menyimpan data jalan.'); } pendingLayer.featureType = 'road'; pendingLayer.featureId = pendingMode === 'create' ? result.id : pendingLayer.featureId; pendingLayer.featureProps = { nama_jalan: namaJalan, status: statusJalan, panjang_meter: panjangMeter }; attachRoadPopup(pendingLayer); } if (pendingType === 'parcel') { const namaPemilik = document.getElementById('nama_pemilik').value.trim(); const statusKepemilikan = document.getElementById('status_kepemilikan').value; const luasM2 = Number(document.getElementById('luas_m2').value); const wkt = polygonToWkt(pendingLayer.getLatLngs()[0]); const endpoint = pendingMode === 'create' ? 'api/parcel_create.php' : 'api/parcel_update.php'; const payload = { nama_pemilik: namaPemilik, status_kepemilikan: statusKepemilikan, luas_m2: luasM2, wkt: wkt }; if (pendingMode === 'update') { payload.id = pendingLayer.featureId; } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); const result = await parseJsonResponse(response); if (!response.ok || !result.success) { throw new Error(result.message || 'Gagal menyimpan data parsil.'); } pendingLayer.featureType = 'parcel'; pendingLayer.featureId = pendingMode === 'create' ? result.id : pendingLayer.featureId; pendingLayer.featureProps = { nama_pemilik: namaPemilik, status_kepemilikan: statusKepemilikan, luas_m2: luasM2 }; attachParcelPopup(pendingLayer); } setStatus(formStatus, 'Data berhasil disimpan.', 'success'); setTimeout(() => closeModal(false), 350); } catch (error) { setStatus(formStatus, error.message || 'Gagal menyimpan data.', 'error'); } }); cancelButton.addEventListener('click', () => closeModal(true)); modalBackdrop.addEventListener('click', (event) => { if (event.target === modalBackdrop) { closeModal(true); } }); try { await loadRoads(); await loadParcels(); if (editableLayer.getLayers().length > 0) { map.fitBounds(editableLayer.getBounds().pad(0.2)); } } catch (error) { window.alert(error.message || 'Gagal memuat data spasial.'); } });