Files
2026-06-10 18:54:25 +07:00

923 lines
37 KiB
JavaScript

// ===============================
// 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
<defs>
<filter id="glow-${id}" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="2.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<ellipse cx="16" cy="38" rx="7" ry="2.5" fill="rgba(0,0,0,0.18)"/>
<path d="M16 2 C9.4 2 4 7.4 4 14 C4 22 16 36 16 36 C16 36 28 22 28 14 C28 7.4 22.6 2 16 2Z"
fill="${c.fill}" stroke="${c.stroke}" stroke-width="1.8" filter="url(#glow-${id})"/>
<circle cx="16" cy="14" r="7" fill="white" opacity="0.92"/>
<text x="16" y="18.5" text-anchor="middle" font-size="10" font-family="sans-serif">⛽</text>
</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 `
<div class="pcard-detail">
<div class="pcd-header">
<div class="pcd-header-icon">⛽</div>
<div class="pcd-header-text">
<div class="pcd-title">${nama}</div>
<div class="pcd-subtitle">SPBU</div>
</div>
</div>
<div class="pcd-rows">
<div class="pcd-row">
<span class="pcd-label">📍 Koordinat</span>
<span class="pcd-val">${coord}</span>
</div>
<div class="pcd-row">
<span class="pcd-label">⏰ Status</span>
<span class="badge ${badgeCls}">${statusLabel}</span>
</div>
${wa ? `<div class="pcd-row">
<span class="pcd-label">📱 WhatsApp</span>
<span class="pcd-val">${wa}</span>
</div>` : ''}
</div>
<div class="pcd-actions">
<button class="pbtn pbtn-edit" onclick="editSpbu(${s.id})">✏️ Edit</button>
<button class="pbtn pbtn-del" onclick="hapusSpbu(${s.id})">🗑️ Hapus</button>
</div>
</div>`;
}
// ===============================
// 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 `
<div class="pcard-detail">
<div class="pcd-header">
<div class="pcd-header-icon jalan">🛣️</div>
<div class="pcd-header-text">
<div class="pcd-title">${nama}</div>
<div class="pcd-subtitle">Jalan</div>
</div>
</div>
<div class="pcd-rows">
<div class="pcd-row">
<span class="pcd-label">🛣️ Status</span>
<span class="badge ${badgeClass}">${statusLabel}</span>
</div>
<div class="pcd-row">
<span class="pcd-label">📏 Panjang</span>
<span class="pcd-val">${panjang}</span>
</div>
</div>
<div class="pcd-actions">
<button class="pbtn pbtn-edit" onclick="editJalan(${p.id})">✏️ Edit Atribut</button>
<button class="pbtn pbtn-move" onclick="startEditGeomJalan(${p.id})">📐 Edit Geometri</button>
<button class="pbtn pbtn-del" onclick="hapusJalan(${p.id})">🗑️ Hapus</button>
</div>
</div>`;
}
// ===============================
// 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 `
<div class="pcard-detail">
<div class="pcd-header">
<div class="pcd-header-icon parsil">🗺️</div>
<div class="pcd-header-text">
<div class="pcd-title">${nama}</div>
<div class="pcd-subtitle">Parsil Tanah</div>
</div>
</div>
<div class="pcd-rows">
<div class="pcd-row">
<span class="pcd-label">📋 Kepemilikan</span>
<span class="badge ${badgeClass}">${status}</span>
</div>
<div class="pcd-row">
<span class="pcd-label">📐 Luas</span>
<span class="pcd-val">${luas}</span>
</div>
</div>
<div class="pcd-actions">
<button class="pbtn pbtn-edit" onclick="editParsil(${props.id})">✏️ Edit Atribut</button>
<button class="pbtn pbtn-move" onclick="startEditGeomParsil(${props.id})">📐 Edit Geometri</button>
<button class="pbtn pbtn-del" onclick="hapusParsil(${props.id})">🗑️ Hapus</button>
</div>
</div>`;
}
// ===============================
// 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 = '<div class="empty">Belum ada SPBU tercatat.</div>';
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 `
<div class="spbu-item" onclick="flyTo(${lat},${lng})">
<div class="si-top">
<div class="si-name">
<span class="si-dot ${dot}"></span>
${s.namaspbu || '—'}
</div>
<div class="si-actions" onclick="event.stopPropagation()">
<button class="ib" title="Edit" onclick="editSpbu(${s.id})">✏️</button>
<button class="ib del" title="Hapus" onclick="hapusSpbu(${s.id})">🗑️</button>
</div>
</div>
</div>`;
}).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: `<div style="width:14px;height:14px;background:#fff;border:2.5px solid #16a34a;border-radius:50%;box-shadow:0 2px 8px rgba(22,163,74,.4);cursor:grab;"></div>`,
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 = '<div class="sr-empty">Tidak ada hasil</div>';
} else {
results.innerHTML = hits.slice(0, 8).map((h, i) =>
`<div class="sr-item" data-idx="${i}">
<span class="sr-icon">${h.icon}</span>
<div class="sr-text">
<div class="sr-label">${h.label}</div>
<div class="sr-sub">${h.sub}</div>
</div>
</div>`
).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); }
});
}
});