1242 lines
36 KiB
JavaScript
1242 lines
36 KiB
JavaScript
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function setStatus(statusLabel, text, type) {
|
|
statusLabel.textContent = text;
|
|
statusLabel.classList.remove('error', 'success');
|
|
if (type) {
|
|
statusLabel.classList.add(type);
|
|
}
|
|
}
|
|
|
|
function buildPointForm(lat, lng) {
|
|
return `
|
|
<div class="form-popup">
|
|
<h3>Tambah Data SPBU</h3>
|
|
<div class="field">
|
|
<label>Nama SPBU</label>
|
|
<input type="text" data-field="nama" placeholder="Contoh: SPBU Pertamina 34.XXX" required>
|
|
</div>
|
|
<div class="field">
|
|
<label>No</label>
|
|
<input type="text" data-field="no" placeholder="Contoh: 001" required>
|
|
</div>
|
|
<div class="field">
|
|
<label>Status 24 Jam</label>
|
|
<select data-field="status_24jam">
|
|
<option value="24jam">24 Jam</option>
|
|
<option value="tidak">Tidak</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Latitude</label>
|
|
<input type="text" data-field="latitude" value="${lat.toFixed(6)}" readonly>
|
|
</div>
|
|
<div class="field">
|
|
<label>Longitude</label>
|
|
<input type="text" data-field="longitude" value="${lng.toFixed(6)}" readonly>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn-save" type="button" data-action="save">Save</button>
|
|
<button class="btn-cancel" type="button" data-action="cancel">Cancel</button>
|
|
</div>
|
|
<div class="status" data-role="status">Klik Save untuk menyimpan data ke database.</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 index = 0; index < latlngs.length; index += 1) {
|
|
const pointA = latlngs[index];
|
|
const pointB = latlngs[(index + 1) % latlngs.length];
|
|
area += toRad(pointB.lng - pointA.lng) * (2 + Math.sin(toRad(pointA.lat)) + Math.sin(toRad(pointB.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 rawText = await response.text();
|
|
|
|
try {
|
|
return JSON.parse(rawText);
|
|
} catch (error) {
|
|
const preview = String(rawText || '').slice(0, 180).trim();
|
|
if (preview) {
|
|
throw new Error(`Respons server bukan JSON yang valid: ${preview}`);
|
|
}
|
|
throw new Error('Respons server bukan JSON yang valid.');
|
|
}
|
|
}
|
|
|
|
async function initializeMap() {
|
|
if (typeof L === 'undefined') {
|
|
alert('Leaflet gagal dimuat. Periksa koneksi internet.');
|
|
return;
|
|
}
|
|
|
|
const mapContainer = document.getElementById('map');
|
|
if (!mapContainer) {
|
|
return;
|
|
}
|
|
|
|
const map = L.map('map', {
|
|
zoomControl: true
|
|
}).setView([-0.06054796552220232, 109.34490231930592], 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 markersLayer = L.featureGroup().addTo(map);
|
|
const spatialLayer = L.featureGroup().addTo(map);
|
|
|
|
let activePopupMarker = null;
|
|
let isDrawing = false;
|
|
let activeRefineState = null;
|
|
|
|
const blueIcon = L.icon({
|
|
iconUrl: 'assets/vendor/leaflet/images/marker-icon.png',
|
|
iconRetinaUrl: 'assets/vendor/leaflet/images/marker-icon-2x.png',
|
|
shadowUrl: 'assets/vendor/leaflet/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34],
|
|
shadowSize: [41, 41]
|
|
});
|
|
|
|
const yellowIcon = L.icon({
|
|
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-yellow.png',
|
|
iconRetinaUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-yellow.png',
|
|
shadowUrl: 'assets/vendor/leaflet/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34],
|
|
shadowSize: [41, 41]
|
|
});
|
|
|
|
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 setSectionEnabled(sectionElement, enabled) {
|
|
if (!sectionElement) {
|
|
return;
|
|
}
|
|
|
|
const fields = sectionElement.querySelectorAll('input, select, textarea, button');
|
|
fields.forEach((field) => {
|
|
field.disabled = !enabled;
|
|
});
|
|
}
|
|
|
|
function getPointIcon(status24Jam) {
|
|
const normalizedStatus = String(status24Jam || '').toLowerCase();
|
|
if (normalizedStatus === '24jam') {
|
|
return blueIcon;
|
|
}
|
|
return yellowIcon;
|
|
}
|
|
|
|
function fitAllData() {
|
|
const combined = L.featureGroup();
|
|
markersLayer.eachLayer((layer) => combined.addLayer(layer));
|
|
spatialLayer.eachLayer((layer) => combined.addLayer(layer));
|
|
|
|
if (combined.getLayers().length < 1) {
|
|
return;
|
|
}
|
|
|
|
const bounds = combined.getBounds();
|
|
if (!bounds.isValid()) {
|
|
return;
|
|
}
|
|
|
|
map.fitBounds(bounds.pad(0.2), { maxZoom: 16 });
|
|
}
|
|
|
|
function cloneLatLngs(latlngs) {
|
|
return JSON.parse(JSON.stringify(latlngs));
|
|
}
|
|
|
|
function setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, isActive) {
|
|
if (refineButton) {
|
|
refineButton.disabled = isActive;
|
|
}
|
|
if (saveRefineButton) {
|
|
saveRefineButton.disabled = !isActive;
|
|
}
|
|
if (cancelRefineButton) {
|
|
cancelRefineButton.disabled = !isActive;
|
|
}
|
|
}
|
|
|
|
async function persistSpatialGeometry(layer) {
|
|
if (!layer || !layer.featureType || !layer.featureProps) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function closeModal(removePendingLayer) {
|
|
if (!modalBackdrop || !featureForm || !formStatus) {
|
|
return;
|
|
}
|
|
|
|
modalBackdrop.classList.remove('open');
|
|
modalBackdrop.setAttribute('aria-hidden', 'true');
|
|
setStatus(formStatus, '', '');
|
|
|
|
if (removePendingLayer && pendingMode === 'create' && pendingLayer) {
|
|
spatialLayer.removeLayer(pendingLayer);
|
|
}
|
|
|
|
pendingLayer = null;
|
|
pendingType = null;
|
|
pendingMode = 'create';
|
|
featureForm.reset();
|
|
setSectionEnabled(roadFields, false);
|
|
setSectionEnabled(parcelFields, false);
|
|
}
|
|
|
|
function openRoadModal(layer, mode, initialData) {
|
|
if (!modalBackdrop || !modalTitle || !roadFields || !parcelFields) {
|
|
return;
|
|
}
|
|
|
|
pendingLayer = layer;
|
|
pendingType = 'road';
|
|
pendingMode = mode;
|
|
|
|
modalTitle.textContent = mode === 'create' ? 'Tambah Data Jalan' : 'Update Data Jalan';
|
|
roadFields.hidden = false;
|
|
parcelFields.hidden = true;
|
|
setSectionEnabled(roadFields, true);
|
|
setSectionEnabled(parcelFields, false);
|
|
|
|
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) {
|
|
if (!modalBackdrop || !modalTitle || !roadFields || !parcelFields) {
|
|
return;
|
|
}
|
|
|
|
pendingLayer = layer;
|
|
pendingType = 'parcel';
|
|
pendingMode = mode;
|
|
|
|
modalTitle.textContent = mode === 'create' ? 'Tambah Data Parsil Tanah' : 'Update Data Parsil Tanah';
|
|
roadFields.hidden = true;
|
|
parcelFields.hidden = false;
|
|
setSectionEnabled(roadFields, false);
|
|
setSectionEnabled(parcelFields, true);
|
|
|
|
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 renderPoint(point) {
|
|
const pointId = Number(point.id);
|
|
const latitude = Number(point.latitude);
|
|
const longitude = Number(point.longitude);
|
|
|
|
if (!Number.isFinite(pointId) || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
return;
|
|
}
|
|
|
|
const marker = L.marker([latitude, longitude], {
|
|
icon: getPointIcon(point.status_24jam),
|
|
draggable: true
|
|
});
|
|
|
|
marker.addTo(markersLayer);
|
|
marker.bindPopup(`
|
|
<div class="point-popup">
|
|
<b>${escapeHtml(point.nama || '')}</b><br>
|
|
No: ${escapeHtml(point.no || '')}<br>
|
|
Status: ${escapeHtml(point.status_24jam || '')}<br>
|
|
Lat: ${latitude.toFixed(6)}<br>
|
|
Lng: ${longitude.toFixed(6)}
|
|
<div class="meta">Geser marker untuk memperbarui posisi titik.</div>
|
|
<div class="meta">ID: ${pointId}</div>
|
|
<button class="btn-delete" type="button" data-action="delete" data-point-id="${pointId}">Hapus Titik</button>
|
|
<div class="status" data-role="delete-status"></div>
|
|
</div>
|
|
`);
|
|
|
|
let previousLatLng = null;
|
|
|
|
marker.on('dragstart', () => {
|
|
previousLatLng = marker.getLatLng();
|
|
});
|
|
|
|
marker.on('dragend', async () => {
|
|
const latestLatLng = marker.getLatLng();
|
|
try {
|
|
await updatePointPosition(pointId, latestLatLng.lat, latestLatLng.lng);
|
|
await loadPoints();
|
|
} catch (error) {
|
|
if (previousLatLng) {
|
|
marker.setLatLng(previousLatLng);
|
|
}
|
|
alert(error.message || 'Gagal memperbarui posisi titik.');
|
|
}
|
|
});
|
|
|
|
marker.on('popupopen', (popupEvent) => {
|
|
const popupElement = popupEvent.popup.getElement();
|
|
if (!popupElement) {
|
|
return;
|
|
}
|
|
|
|
const deleteButton = popupElement.querySelector('[data-action="delete"]');
|
|
const deleteStatus = popupElement.querySelector('[data-role="delete-status"]');
|
|
|
|
if (!deleteButton || !deleteStatus) {
|
|
return;
|
|
}
|
|
|
|
deleteButton.addEventListener('click', async () => {
|
|
const requestedId = Number(deleteButton.getAttribute('data-point-id'));
|
|
if (!Number.isFinite(requestedId) || requestedId <= 0) {
|
|
setStatus(deleteStatus, 'ID titik tidak valid.', 'error');
|
|
return;
|
|
}
|
|
|
|
const isConfirmed = window.confirm('Hapus titik ini dari peta dan database?');
|
|
if (!isConfirmed) {
|
|
return;
|
|
}
|
|
|
|
deleteButton.disabled = true;
|
|
setStatus(deleteStatus, 'Menghapus data...', '');
|
|
|
|
try {
|
|
await deletePoint(requestedId);
|
|
setStatus(deleteStatus, 'Data berhasil dihapus.', 'success');
|
|
map.closePopup();
|
|
await loadPoints();
|
|
} catch (error) {
|
|
setStatus(deleteStatus, error.message || 'Gagal menghapus data.', 'error');
|
|
deleteButton.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function attachRoadPopup(layer) {
|
|
const props = layer.featureProps;
|
|
layer.setStyle({ color: roadColorByStatus(props.status), weight: 5 });
|
|
|
|
layer.bindPopup(`
|
|
<b>Jalan:</b> ${escapeHtml(props.nama_jalan)}<br>
|
|
<b>Status:</b> ${escapeHtml(props.status)}<br>
|
|
<b>Panjang:</b> ${Number(props.panjang_meter).toFixed(2)} m
|
|
<div class="popup-actions">
|
|
<button type="button" class="btn-popup-edit" data-action="edit">Edit</button>
|
|
<button type="button" class="btn-popup-refine" data-action="refine">Rapikan</button>
|
|
</div>
|
|
<div class="popup-actions">
|
|
<button type="button" class="btn-popup-save" data-action="save-refine" disabled>Simpan Rapikan</button>
|
|
<button type="button" class="btn-popup-cancel" data-action="cancel-refine" disabled>Batal Rapikan</button>
|
|
<button type="button" class="btn-popup-delete" data-action="delete">Hapus</button>
|
|
</div>
|
|
<div class="status" data-role="popup-status"></div>
|
|
`);
|
|
|
|
layer.off('popupopen');
|
|
layer.on('popupopen', (event) => {
|
|
const popupElement = event.popup.getElement();
|
|
if (!popupElement) {
|
|
return;
|
|
}
|
|
|
|
const editButton = popupElement.querySelector('[data-action="edit"]');
|
|
const refineButton = popupElement.querySelector('[data-action="refine"]');
|
|
const saveRefineButton = popupElement.querySelector('[data-action="save-refine"]');
|
|
const cancelRefineButton = popupElement.querySelector('[data-action="cancel-refine"]');
|
|
const deleteButton = popupElement.querySelector('[data-action="delete"]');
|
|
const popupStatus = popupElement.querySelector('[data-role="popup-status"]');
|
|
|
|
if (!editButton || !refineButton || !saveRefineButton || !cancelRefineButton || !deleteButton || !popupStatus) {
|
|
return;
|
|
}
|
|
|
|
const canRefine = !!(layer.editing && typeof layer.editing.enable === 'function');
|
|
refineButton.disabled = !canRefine;
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, activeRefineState && activeRefineState.layer === layer);
|
|
|
|
refineButton.addEventListener('click', () => {
|
|
if (!canRefine) {
|
|
setStatus(popupStatus, 'Mode rapikan tidak didukung pada layer ini.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (activeRefineState && activeRefineState.layer && activeRefineState.layer !== layer && activeRefineState.layer.editing) {
|
|
activeRefineState.layer.editing.disable();
|
|
}
|
|
|
|
activeRefineState = {
|
|
layer: layer,
|
|
snapshot: cloneLatLngs(layer.getLatLngs())
|
|
};
|
|
|
|
layer.editing.enable();
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, true);
|
|
setStatus(popupStatus, 'Mode rapikan aktif. Geser titik vertex lalu klik Simpan Rapikan.', '');
|
|
});
|
|
|
|
saveRefineButton.addEventListener('click', async () => {
|
|
if (!activeRefineState || activeRefineState.layer !== layer) {
|
|
return;
|
|
}
|
|
|
|
saveRefineButton.disabled = true;
|
|
setStatus(popupStatus, 'Menyimpan geometri...', '');
|
|
|
|
try {
|
|
await persistSpatialGeometry(layer);
|
|
if (layer.editing) {
|
|
layer.editing.disable();
|
|
}
|
|
activeRefineState = null;
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, false);
|
|
setStatus(popupStatus, 'Geometri jalan berhasil dirapikan.', 'success');
|
|
} catch (error) {
|
|
setStatus(popupStatus, error.message || 'Gagal menyimpan geometri.', 'error');
|
|
saveRefineButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
cancelRefineButton.addEventListener('click', () => {
|
|
if (!activeRefineState || activeRefineState.layer !== layer) {
|
|
return;
|
|
}
|
|
|
|
layer.setLatLngs(activeRefineState.snapshot);
|
|
if (typeof layer.redraw === 'function') {
|
|
layer.redraw();
|
|
}
|
|
|
|
if (layer.editing) {
|
|
layer.editing.disable();
|
|
}
|
|
|
|
activeRefineState = null;
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, false);
|
|
setStatus(popupStatus, 'Perubahan geometri dibatalkan.', '');
|
|
});
|
|
|
|
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.');
|
|
}
|
|
|
|
spatialLayer.removeLayer(layer);
|
|
map.closePopup();
|
|
} catch (error) {
|
|
setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error');
|
|
deleteButton.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function attachParcelPopup(layer) {
|
|
const props = layer.featureProps;
|
|
const color = parcelColorByStatus(props.status_kepemilikan);
|
|
|
|
layer.setStyle({
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.35,
|
|
weight: 3
|
|
});
|
|
|
|
layer.bindPopup(`
|
|
<b>Pemilik:</b> ${escapeHtml(props.nama_pemilik)}<br>
|
|
<b>Status:</b> ${escapeHtml(props.status_kepemilikan)}<br>
|
|
<b>Luas:</b> ${Number(props.luas_m2).toFixed(2)} m2
|
|
<div class="popup-actions">
|
|
<button type="button" class="btn-popup-edit" data-action="edit">Edit</button>
|
|
<button type="button" class="btn-popup-refine" data-action="refine">Rapikan</button>
|
|
</div>
|
|
<div class="popup-actions">
|
|
<button type="button" class="btn-popup-save" data-action="save-refine" disabled>Simpan Rapikan</button>
|
|
<button type="button" class="btn-popup-cancel" data-action="cancel-refine" disabled>Batal Rapikan</button>
|
|
<button type="button" class="btn-popup-delete" data-action="delete">Hapus</button>
|
|
</div>
|
|
<div class="status" data-role="popup-status"></div>
|
|
`);
|
|
|
|
layer.off('popupopen');
|
|
layer.on('popupopen', (event) => {
|
|
const popupElement = event.popup.getElement();
|
|
if (!popupElement) {
|
|
return;
|
|
}
|
|
|
|
const editButton = popupElement.querySelector('[data-action="edit"]');
|
|
const refineButton = popupElement.querySelector('[data-action="refine"]');
|
|
const saveRefineButton = popupElement.querySelector('[data-action="save-refine"]');
|
|
const cancelRefineButton = popupElement.querySelector('[data-action="cancel-refine"]');
|
|
const deleteButton = popupElement.querySelector('[data-action="delete"]');
|
|
const popupStatus = popupElement.querySelector('[data-role="popup-status"]');
|
|
|
|
if (!editButton || !refineButton || !saveRefineButton || !cancelRefineButton || !deleteButton || !popupStatus) {
|
|
return;
|
|
}
|
|
|
|
const canRefine = !!(layer.editing && typeof layer.editing.enable === 'function');
|
|
refineButton.disabled = !canRefine;
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, activeRefineState && activeRefineState.layer === layer);
|
|
|
|
refineButton.addEventListener('click', () => {
|
|
if (!canRefine) {
|
|
setStatus(popupStatus, 'Mode rapikan tidak didukung pada layer ini.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (activeRefineState && activeRefineState.layer && activeRefineState.layer !== layer && activeRefineState.layer.editing) {
|
|
activeRefineState.layer.editing.disable();
|
|
}
|
|
|
|
activeRefineState = {
|
|
layer: layer,
|
|
snapshot: cloneLatLngs(layer.getLatLngs())
|
|
};
|
|
|
|
layer.editing.enable();
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, true);
|
|
setStatus(popupStatus, 'Mode rapikan aktif. Geser titik vertex lalu klik Simpan Rapikan.', '');
|
|
});
|
|
|
|
saveRefineButton.addEventListener('click', async () => {
|
|
if (!activeRefineState || activeRefineState.layer !== layer) {
|
|
return;
|
|
}
|
|
|
|
saveRefineButton.disabled = true;
|
|
setStatus(popupStatus, 'Menyimpan geometri...', '');
|
|
|
|
try {
|
|
await persistSpatialGeometry(layer);
|
|
if (layer.editing) {
|
|
layer.editing.disable();
|
|
}
|
|
activeRefineState = null;
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, false);
|
|
setStatus(popupStatus, 'Geometri parsil berhasil dirapikan.', 'success');
|
|
} catch (error) {
|
|
setStatus(popupStatus, error.message || 'Gagal menyimpan geometri.', 'error');
|
|
saveRefineButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
cancelRefineButton.addEventListener('click', () => {
|
|
if (!activeRefineState || activeRefineState.layer !== layer) {
|
|
return;
|
|
}
|
|
|
|
layer.setLatLngs(activeRefineState.snapshot);
|
|
if (typeof layer.redraw === 'function') {
|
|
layer.redraw();
|
|
}
|
|
|
|
if (layer.editing) {
|
|
layer.editing.disable();
|
|
}
|
|
|
|
activeRefineState = null;
|
|
setRefineButtonsState(refineButton, saveRefineButton, cancelRefineButton, false);
|
|
setStatus(popupStatus, 'Perubahan geometri dibatalkan.', '');
|
|
});
|
|
|
|
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.');
|
|
}
|
|
|
|
spatialLayer.removeLayer(layer);
|
|
map.closePopup();
|
|
} catch (error) {
|
|
setStatus(popupStatus, error.message || 'Gagal menghapus data.', 'error');
|
|
deleteButton.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadPoints() {
|
|
const response = await fetch('api/read_points.php', {
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
|
|
const result = await parseJsonResponse(response);
|
|
if (!response.ok || !result.success) {
|
|
throw new Error(result && result.message ? result.message : 'Gagal memuat data titik.');
|
|
}
|
|
|
|
markersLayer.clearLayers();
|
|
(result.data || []).forEach(renderPoint);
|
|
}
|
|
|
|
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 && result.message ? result.message : 'Gagal memuat data jalan.');
|
|
}
|
|
|
|
(result.data || []).forEach((item) => {
|
|
if (!item.geometry_geojson) {
|
|
return;
|
|
}
|
|
|
|
const parsed = L.geoJSON(item.geometry_geojson);
|
|
const layer = parsed.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);
|
|
spatialLayer.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 && result.message ? result.message : 'Gagal memuat data parsil.');
|
|
}
|
|
|
|
(result.data || []).forEach((item) => {
|
|
if (!item.geometry_geojson) {
|
|
return;
|
|
}
|
|
|
|
const parsed = L.geoJSON(item.geometry_geojson);
|
|
const layer = parsed.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);
|
|
spatialLayer.addLayer(layer);
|
|
});
|
|
}
|
|
|
|
async function savePoint(payload) {
|
|
const response = await fetch('api/create_point.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 && result.message ? result.message : 'Gagal menyimpan data titik.');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function deletePoint(id) {
|
|
const response = await fetch('api/delete_point.php', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({ id: id })
|
|
});
|
|
|
|
const result = await parseJsonResponse(response);
|
|
if (!response.ok || !result.success) {
|
|
throw new Error(result && result.message ? result.message : 'Gagal menghapus data titik.');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function updatePointPosition(id, latitude, longitude) {
|
|
const response = await fetch('api/update_point_position.php', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
id: id,
|
|
latitude: latitude,
|
|
longitude: longitude
|
|
})
|
|
});
|
|
|
|
const result = await parseJsonResponse(response);
|
|
if (!response.ok || !result.success) {
|
|
throw new Error(result && result.message ? result.message : 'Gagal memperbarui posisi titik.');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
if (L.Control && L.Control.Draw) {
|
|
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: spatialLayer,
|
|
remove: true
|
|
}
|
|
});
|
|
|
|
map.addControl(drawControl);
|
|
|
|
map.on(L.Draw.Event.DRAWSTART, () => {
|
|
isDrawing = true;
|
|
});
|
|
|
|
map.on(L.Draw.Event.DRAWSTOP, () => {
|
|
isDrawing = false;
|
|
});
|
|
|
|
map.on(L.Draw.Event.CREATED, (event) => {
|
|
const layer = event.layer;
|
|
spatialLayer.addLayer(layer);
|
|
|
|
if (event.layerType === 'polyline') {
|
|
openRoadModal(layer, 'create', null);
|
|
return;
|
|
}
|
|
|
|
if (event.layerType === 'polygon') {
|
|
openParcelModal(layer, 'create', null);
|
|
return;
|
|
}
|
|
|
|
spatialLayer.removeLayer(layer);
|
|
});
|
|
|
|
map.on(L.Draw.Event.EDITED, async (event) => {
|
|
const layers = event.layers.getLayers();
|
|
|
|
for (const layer of layers) {
|
|
try {
|
|
await persistSpatialGeometry(layer);
|
|
} catch (error) {
|
|
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 spasial.');
|
|
}
|
|
} catch (error) {
|
|
alert(error.message || 'Gagal sinkron delete ke database.');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (featureForm) {
|
|
setSectionEnabled(roadFields, false);
|
|
setSectionEnabled(parcelFields, false);
|
|
|
|
featureForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
if (!pendingLayer || !pendingType || !formStatus) {
|
|
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), 300);
|
|
} catch (error) {
|
|
setStatus(formStatus, error.message || 'Gagal menyimpan data.', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (cancelButton) {
|
|
cancelButton.addEventListener('click', () => {
|
|
closeModal(true);
|
|
});
|
|
}
|
|
|
|
if (modalBackdrop) {
|
|
modalBackdrop.addEventListener('click', (event) => {
|
|
if (event.target === modalBackdrop) {
|
|
closeModal(true);
|
|
}
|
|
});
|
|
}
|
|
|
|
map.on('click', (event) => {
|
|
if (activeRefineState && activeRefineState.layer && activeRefineState.layer.editing && activeRefineState.layer.editing.enabled()) {
|
|
return;
|
|
}
|
|
|
|
if (isDrawing || (modalBackdrop && modalBackdrop.classList.contains('open'))) {
|
|
return;
|
|
}
|
|
|
|
if (event.originalEvent && event.originalEvent.target && event.originalEvent.target.closest('.leaflet-interactive')) {
|
|
return;
|
|
}
|
|
|
|
if (activePopupMarker) {
|
|
map.removeLayer(activePopupMarker);
|
|
}
|
|
|
|
const lat = event.latlng.lat;
|
|
const lng = event.latlng.lng;
|
|
activePopupMarker = L.marker([lat, lng]).addTo(map);
|
|
activePopupMarker.bindPopup(buildPointForm(lat, lng));
|
|
|
|
activePopupMarker.once('popupopen', (popupEvent) => {
|
|
const popupElement = popupEvent.popup.getElement();
|
|
if (!popupElement) {
|
|
return;
|
|
}
|
|
|
|
const saveButton = popupElement.querySelector('[data-action="save"]');
|
|
const cancelPopupButton = popupElement.querySelector('[data-action="cancel"]');
|
|
const statusLabel = popupElement.querySelector('[data-role="status"]');
|
|
const namaInput = popupElement.querySelector('[data-field="nama"]');
|
|
const noInput = popupElement.querySelector('[data-field="no"]');
|
|
const statusInput = popupElement.querySelector('[data-field="status_24jam"]');
|
|
const latitudeInput = popupElement.querySelector('[data-field="latitude"]');
|
|
const longitudeInput = popupElement.querySelector('[data-field="longitude"]');
|
|
|
|
if (!saveButton || !cancelPopupButton || !statusLabel || !namaInput || !noInput || !statusInput || !latitudeInput || !longitudeInput) {
|
|
return;
|
|
}
|
|
|
|
cancelPopupButton.addEventListener('click', () => {
|
|
map.closePopup();
|
|
if (activePopupMarker) {
|
|
map.removeLayer(activePopupMarker);
|
|
activePopupMarker = null;
|
|
}
|
|
});
|
|
|
|
saveButton.addEventListener('click', async () => {
|
|
const nama = namaInput.value.trim();
|
|
const no = noInput.value.trim();
|
|
const status24jam = statusInput.value;
|
|
const latitude = latitudeInput.value;
|
|
const longitude = longitudeInput.value;
|
|
|
|
if (!nama || !no) {
|
|
setStatus(statusLabel, 'Nama dan No wajib diisi.', 'error');
|
|
return;
|
|
}
|
|
|
|
saveButton.disabled = true;
|
|
setStatus(statusLabel, 'Menyimpan data...', '');
|
|
|
|
try {
|
|
await savePoint({
|
|
nama: nama,
|
|
no: no,
|
|
status_24jam: status24jam,
|
|
latitude: latitude,
|
|
longitude: longitude
|
|
});
|
|
|
|
setStatus(statusLabel, 'Data berhasil disimpan.', 'success');
|
|
|
|
if (activePopupMarker) {
|
|
map.removeLayer(activePopupMarker);
|
|
activePopupMarker = null;
|
|
}
|
|
|
|
await loadPoints();
|
|
map.closePopup();
|
|
} catch (error) {
|
|
setStatus(statusLabel, error.message || 'Terjadi kesalahan saat menyimpan data.', 'error');
|
|
saveButton.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
activePopupMarker.openPopup();
|
|
});
|
|
|
|
spatialLayer.clearLayers();
|
|
|
|
try {
|
|
await loadPoints();
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert(error.message || 'Gagal memuat data titik.');
|
|
}
|
|
|
|
try {
|
|
await loadRoads();
|
|
} catch (error) {
|
|
console.warn('Data jalan belum termuat:', error);
|
|
}
|
|
|
|
try {
|
|
await loadParcels();
|
|
} catch (error) {
|
|
console.warn('Data parsil belum termuat:', error);
|
|
}
|
|
|
|
fitAllData();
|
|
|
|
const syncMapSize = () => {
|
|
map.invalidateSize();
|
|
};
|
|
|
|
window.addEventListener('resize', syncMapSize);
|
|
window.addEventListener('load', () => {
|
|
setTimeout(syncMapSize, 0);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initializeMap().catch((error) => {
|
|
console.error(error);
|
|
alert('Terjadi kesalahan saat inisialisasi peta.');
|
|
});
|
|
});
|