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.');
}
});