Files
d1041231004-webgis-spbu/index.html
T
2026-06-11 14:02:22 +07:00

912 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGIS — SPBU · Jalan · Parsil Tanah</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0f1923; --surface:#1a2535; --surface2:#243044; --border:#2e3f57;
--accent:#3b9eff; --accent2:#56d4a0; --danger:#ff5c5c; --warn:#ffb84d;
--text:#e2eaf5; --muted:#7a90aa; --radius:8px; --shadow:0 4px 20px rgba(0,0,0,.45);
--font:'Segoe UI',system-ui,sans-serif;
}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);}
#app{display:flex;flex-direction:column;height:100vh;}
header{
background:var(--surface);border-bottom:1px solid var(--border);
padding:10px 18px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
z-index:1000;position:relative;
}
header h1{font-size:.95rem;font-weight:700;color:var(--accent);white-space:nowrap;}
header h1 span{color:var(--text);}
.tabs{display:flex;gap:5px;}
.tab-btn{
padding:6px 14px;border-radius:20px;border:1px solid var(--border);
background:transparent;color:var(--muted);cursor:pointer;font-size:.8rem;transition:all .2s;
}
.tab-btn.active{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
.tab-btn:hover:not(.active){border-color:var(--accent);color:var(--accent);}
#mode-badge{
margin-left:auto;padding:5px 12px;border-radius:20px;
background:var(--surface2);border:1px solid var(--border);font-size:.75rem;color:var(--muted);
}
#mode-badge.active{border-color:var(--accent2);color:var(--accent2);}
#map{flex:1;}
/* Sidebar */
#sidebar{
position:absolute;right:12px;top:70px;width:290px;
max-height:calc(100vh - 90px);background:var(--surface);
border:1px solid var(--border);border-radius:var(--radius);
box-shadow:var(--shadow);overflow-y:auto;z-index:900;
display:none;flex-direction:column;
}
#sidebar.open{display:flex;}
.sidebar-header{
padding:12px 14px;border-bottom:1px solid var(--border);
display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;
background:var(--surface);z-index:1;
}
.sidebar-header h3{font-size:.85rem;font-weight:600;}
.sidebar-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1rem;}
.record-list{padding:8px;}
.record-item{
padding:9px 10px;border-radius:6px;border:1px solid var(--border);
margin-bottom:6px;transition:background .15s;
display:flex;justify-content:space-between;align-items:flex-start;gap:6px;
}
.record-item:hover{background:var(--surface2);}
.record-item .info{flex:1;cursor:pointer;}
.record-item .name{font-size:.82rem;font-weight:600;}
.record-item .sub{font-size:.72rem;color:var(--muted);margin-top:2px;}
.record-actions{display:flex;gap:4px;flex-shrink:0;align-items:flex-start;}
.btn-icon{
background:var(--surface2);border:1px solid var(--border);
color:var(--muted);cursor:pointer;border-radius:5px;
padding:4px 7px;font-size:.72rem;transition:all .15s;
}
.btn-icon:hover{border-color:var(--accent);color:var(--accent);}
.btn-icon.danger:hover{border-color:var(--danger);color:var(--danger);}
.badge{font-size:.65rem;padding:2px 7px;border-radius:10px;font-weight:700;white-space:nowrap;}
.badge-green {background:#1a4a30;color:var(--accent2);}
.badge-red {background:#4a1a1a;color:var(--danger);}
.badge-blue {background:#1a2e4a;color:var(--accent);}
.badge-yellow{background:#4a3a10;color:var(--warn);}
.badge-nat {background:#1a3a4a;color:#7fd4ff;}
.badge-prov {background:#2e1a4a;color:#c07fff;}
.badge-kab {background:#1a4a2a;color:#7fffc0;}
/* Modal */
.modal-overlay{
position:fixed;inset:0;background:rgba(0,0,0,.6);
z-index:9000;display:none;align-items:center;justify-content:center;
}
.modal-overlay.open{display:flex;}
.modal{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);box-shadow:var(--shadow);width:360px;max-width:95vw;
}
.modal-header{
padding:14px 16px;border-bottom:1px solid var(--border);
display:flex;justify-content:space-between;align-items:center;
}
.modal-header h3{font-size:.9rem;font-weight:700;}
.modal-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;}
.modal-body{padding:16px;}
.field{margin-bottom:13px;}
.field label{
display:block;font-size:.72rem;color:var(--muted);margin-bottom:4px;
font-weight:600;text-transform:uppercase;letter-spacing:.05em;
}
.field input,.field select{
width:100%;padding:8px 10px;border-radius:6px;border:1px solid var(--border);
background:var(--surface2);color:var(--text);font-size:.85rem;outline:none;
}
.field input:focus,.field select:focus{border-color:var(--accent);}
.radio-group{display:flex;gap:12px;}
.radio-group label{
color:var(--text);font-size:.83rem;display:flex;align-items:center;gap:5px;
text-transform:none;letter-spacing:0;font-weight:400;
}
.field-info{
font-size:.75rem;color:var(--accent2);background:var(--surface2);
padding:7px 10px;border-radius:5px;border:1px solid var(--border);
}
/* redraw hint box */
.redraw-hint{
font-size:.72rem;color:var(--warn);background:#2a2010;
padding:7px 10px;border-radius:5px;border:1px solid #4a3a10;
margin-top:6px;display:none;
}
.modal-footer{
padding:12px 16px;border-top:1px solid var(--border);
display:flex;gap:8px;justify-content:flex-end;
}
.btn{padding:7px 16px;border-radius:6px;border:none;cursor:pointer;font-size:.82rem;font-weight:600;transition:opacity .15s;}
.btn:hover{opacity:.85;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-danger {background:var(--danger);color:#fff;}
.btn-muted {background:var(--surface2);color:var(--muted);border:1px solid var(--border);}
/* Toast */
#toast{
position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(60px);
background:var(--surface);border:1px solid var(--border);color:var(--text);
padding:10px 22px;border-radius:20px;font-size:.82rem;box-shadow:var(--shadow);
z-index:9999;transition:transform .3s;pointer-events:none;
}
#toast.show{transform:translateX(-50%) translateY(0);}
#toast.success{border-color:var(--accent2);color:var(--accent2);}
#toast.error {border-color:var(--danger);color:var(--danger);}
.leaflet-draw-toolbar a{background-color:var(--surface)!important;color:var(--text)!important;}
</style>
</head>
<body>
<div id="app">
<header>
<h1>🗺 <span>WebGIS</span> Spasial</h1>
<div class="tabs">
<button class="tab-btn active" data-layer="spbu">📍 SPBU</button>
<button class="tab-btn" data-layer="jalan">🛣 Jalan</button>
<button class="tab-btn" data-layer="parsil">🏡 Parsil</button>
</div>
<div id="mode-badge">Mode: Lihat</div>
<button class="btn btn-muted" id="btn-sidebar">☰ Daftar Data</button>
</header>
<div id="map"></div>
<!-- Sidebar -->
<div id="sidebar">
<div class="sidebar-header">
<h3 id="sidebar-title">Data</h3>
<button class="sidebar-close" id="sidebar-close-btn"></button>
</div>
<div class="record-list" id="record-list"></div>
</div>
<!-- Modal SPBU -->
<div class="modal-overlay" id="modal-spbu">
<div class="modal">
<div class="modal-header">
<h3 id="spbu-modal-title">Tambah SPBU</h3>
<button class="modal-close" data-close="modal-spbu"></button>
</div>
<div class="modal-body">
<input type="hidden" id="spbu-id" />
<div class="field">
<label>Nama SPBU</label>
<input type="text" id="spbu-nama" placeholder="cth. SPBU 64.762.01" />
</div>
<div class="field">
<label>Nomor WhatsApp</label>
<input type="text" id="spbu-wa" placeholder="cth. 08123456789" />
</div>
<div class="field">
<label>Buka 24 Jam?</label>
<div class="radio-group">
<label><input type="radio" name="buka24" value="1" /> Ya</label>
<label><input type="radio" name="buka24" value="0" checked /> Tidak</label>
</div>
</div>
<div class="field">
<div class="field-info" id="spbu-coords-info"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="btn-spbu-delete" style="display:none;margin-right:auto">🗑 Hapus</button>
<button class="btn btn-muted" data-close="modal-spbu">Batal</button>
<button class="btn btn-primary" id="btn-spbu-save">💾 Simpan</button>
</div>
</div>
</div>
<!-- Modal Jalan -->
<div class="modal-overlay" id="modal-jalan">
<div class="modal">
<div class="modal-header">
<h3 id="jalan-modal-title">Tambah Data Jalan</h3>
<button class="modal-close" data-close="modal-jalan"></button>
</div>
<div class="modal-body">
<input type="hidden" id="jalan-id" />
<div class="field">
<label>Nama Jalan</label>
<input type="text" id="jalan-nama" placeholder="cth. Jl. Ahmad Yani" />
</div>
<div class="field">
<label>Status Jalan</label>
<select id="jalan-status">
<option value="nasional">Jalan Nasional</option>
<option value="provinsi">Jalan Provinsi</option>
<option value="kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div class="field">
<label>Panjang Jalan (otomatis)</label>
<div class="field-info" id="jalan-panjang-info"></div>
<div class="redraw-hint" id="jalan-redraw-hint">
✏️ Tutup form ini, gambar ulang garis di peta, lalu simpan untuk mengubah geometri.
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="btn-jalan-delete" style="display:none;margin-right:auto">🗑 Hapus</button>
<button class="btn btn-muted" data-close="modal-jalan">Batal</button>
<button class="btn btn-primary" id="btn-jalan-save">💾 Simpan</button>
</div>
</div>
</div>
<!-- Modal Parsil -->
<div class="modal-overlay" id="modal-parsil">
<div class="modal">
<div class="modal-header">
<h3 id="parsil-modal-title">Tambah Parsil Tanah</h3>
<button class="modal-close" data-close="modal-parsil"></button>
</div>
<div class="modal-body">
<input type="hidden" id="parsil-id" />
<div class="field">
<label>Nama Pemilik</label>
<input type="text" id="parsil-nama" placeholder="cth. Budi Santoso" />
</div>
<div class="field">
<label>Status Sertifikat</label>
<select id="parsil-status">
<option value="SHM">SHM — Sertifikat Hak Milik</option>
<option value="HGB">HGB — Hak Guna Bangunan</option>
<option value="HGU">HGU — Hak Guna Usaha</option>
<option value="HP">HP — Hak Pakai</option>
</select>
</div>
<div class="field">
<label>Luas Tanah (otomatis)</label>
<div class="field-info" id="parsil-luas-info"></div>
<div class="redraw-hint" id="parsil-redraw-hint">
✏️ Tutup form ini, gambar ulang polygon di peta, lalu simpan untuk mengubah geometri.
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="btn-parsil-delete" style="display:none;margin-right:auto">🗑 Hapus</button>
<button class="btn btn-muted" data-close="modal-parsil">Batal</button>
<button class="btn btn-primary" id="btn-parsil-save">💾 Simpan</button>
</div>
</div>
</div>
<div id="toast"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script>
// ═══════════════════════════════════════════════════════
const API = { spbu:'api/spbu.php', jalan:'api/jalan.php', parsil:'api/parsil.php' };
// ── Map ─────────────────────────────────────────────────
const map = L.map('map').setView([-0.0263, 109.3425], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
attribution:'© OpenStreetMap contributors', maxZoom:19
}).addTo(map);
const layers = { spbu:L.layerGroup().addTo(map), jalan:L.layerGroup().addTo(map), parsil:L.layerGroup().addTo(map) };
// ── State ────────────────────────────────────────────────
let activeLayer = 'spbu';
let pendingLatLng = null;
let pendingGeojson= null; // geometri dari draw / dari data existing saat edit
let pendingLength = 0;
let pendingArea = 0;
let editingMarker = null;
let isEditMode = false; // true = edit atribut saja, false = tambah baru / redraw
// ── Helpers ──────────────────────────────────────────────
const JALAN_COLOR = { nasional:'#7fd4ff', provinsi:'#c07fff', kabupaten:'#7fffc0' };
const PARSIL_COLOR = { SHM:'#ffb84d', HGB:'#3b9eff', HGU:'#56d4a0', HP:'#ff9fdf' };
function spbuIcon(buka24){
const is24 = Number(buka24) === 1;
const c = is24 ? '#56d4a0' : '#ff5c5c';
return L.divIcon({
className:'',
html:`<svg width="22" height="30" viewBox="0 0 22 30" xmlns="http://www.w3.org/2000/svg">
<path d="M11 0C5 0 0 5 0 11c0 8 11 19 11 19s11-11 11-19C22 5 17 0 11 0z"
fill="${c}" stroke="#fff" stroke-width="1.5"/>
<circle cx="11" cy="11" r="5" fill="#fff" fill-opacity=".85"/>
</svg>`,
iconSize:[22,30], iconAnchor:[11,30], popupAnchor:[0,-32]
});
}
function toast(msg, type='success'){
const el=document.getElementById('toast');
el.textContent=msg; el.className=`show ${type}`;
setTimeout(()=>el.className='',3000);
}
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id){ document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('[data-close]').forEach(b=>
b.addEventListener('click',()=>closeModal(b.dataset.close))
);
document.querySelectorAll('.modal-overlay').forEach(m=>
m.addEventListener('click',e=>{ if(e.target===m) closeModal(m.id); })
);
const modeBadge = document.getElementById('mode-badge');
function setMode(label, active=false){
modeBadge.textContent=`Mode: ${label}`;
modeBadge.classList.toggle('active',active);
}
// ── Leaflet.Draw ─────────────────────────────────────────
const drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
const drawControl = new L.Control.Draw({
edit:{ featureGroup:drawnItems, edit:false, remove:false },
draw:{
marker:false, circle:false, rectangle:false, circlemarker:false,
polyline:{ shapeOptions:{ color:'#3b9eff', weight:4 } },
polygon :{ shapeOptions:{ color:'#ffb84d', weight:2, fillOpacity:.35 } },
}
});
function updateDrawControl(){
try{ map.removeControl(drawControl); }catch(e){}
if(activeLayer==='jalan'){
drawControl.options.draw.polyline = { shapeOptions:{ color:'#3b9eff', weight:4 } };
drawControl.options.draw.polygon = false;
map.addControl(drawControl);
} else if(activeLayer==='parsil'){
drawControl.options.draw.polyline = false;
drawControl.options.draw.polygon = { shapeOptions:{ color:'#ffb84d', weight:2, fillOpacity:.35 } };
map.addControl(drawControl);
}
}
map.on(L.Draw.Event.DRAWSTART, ()=>setMode('Menggambar…',true));
map.on(L.Draw.Event.DRAWSTOP, ()=>setMode('Lihat'));
map.on(L.Draw.Event.CREATED, function(e){
drawnItems.clearLayers();
drawnItems.addLayer(e.layer);
if(e.layerType==='polyline'){
const ll = e.layer.getLatLngs();
const m = calcPolylineLength(ll);
pendingGeojson = JSON.stringify(e.layer.toGeoJSON());
pendingLength = m;
isEditMode = false;
document.getElementById('jalan-id').value = '';
document.getElementById('jalan-nama').value = '';
document.getElementById('jalan-panjang-info').textContent =
`${m.toFixed(1)} m (${(m/1000).toFixed(3)} km)`;
document.getElementById('jalan-redraw-hint').style.display='none';
document.getElementById('jalan-modal-title').textContent='Tambah Data Jalan';
document.getElementById('btn-jalan-delete').style.display='none';
openModal('modal-jalan');
}
if(e.layerType==='polygon'){
const ll = e.layer.getLatLngs()[0];
const a = calcPolygonArea(ll);
pendingGeojson = JSON.stringify(e.layer.toGeoJSON());
pendingArea = a;
isEditMode = false;
document.getElementById('parsil-id').value = '';
document.getElementById('parsil-nama').value = '';
document.getElementById('parsil-luas-info').textContent =
`${a.toFixed(2)} m² (${(a/10000).toFixed(4)} ha)`;
document.getElementById('parsil-redraw-hint').style.display='none';
document.getElementById('parsil-modal-title').textContent='Tambah Parsil Tanah';
document.getElementById('btn-parsil-delete').style.display='none';
openModal('modal-parsil');
}
});
// ── Geometry calc ────────────────────────────────────────
function calcPolylineLength(latlngs){
const flat = latlngs.flat ? latlngs.flat(Infinity) : latlngs;
let t=0;
for(let i=0;i<flat.length-1;i++) t+=flat[i].distanceTo(flat[i+1]);
return t;
}
function calcPolygonArea(latlngs){
const R=6371000;
const pts=latlngs.map(ll=>({lat:ll.lat*Math.PI/180,lng:ll.lng*Math.PI/180}));
let a=0,n=pts.length;
for(let i=0;i<n;i++){const j=(i+1)%n;a+=pts[i].lng*pts[j].lat-pts[j].lng*pts[i].lat;}
return Math.abs(a/2)*R*R;
}
// ── Tabs ─────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn=>{
btn.addEventListener('click',()=>{
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
activeLayer=btn.dataset.layer;
updateDrawControl();
setMode('Lihat');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
});
// ── Map click → new SPBU ─────────────────────────────────
map.on('click',function(e){
if(activeLayer!=='spbu') return;
pendingLatLng=e.latlng;
document.getElementById('spbu-id').value='';
document.getElementById('spbu-nama').value='';
document.getElementById('spbu-wa').value='';
document.querySelectorAll('input[name="buka24"]').forEach(r=>r.checked=r.value==='0');
document.getElementById('spbu-coords-info').textContent=
`Lat: ${e.latlng.lat.toFixed(6)}, Lng: ${e.latlng.lng.toFixed(6)}`;
document.getElementById('spbu-modal-title').textContent='Tambah SPBU Baru';
document.getElementById('btn-spbu-delete').style.display='none';
openModal('modal-spbu');
});
// ═══════════════════════════════════════════════════════
// SPBU CRUD
// ═══════════════════════════════════════════════════════
async function loadSPBU(){
layers.spbu.clearLayers();
const data=await apiFetch(API.spbu,'GET');
data.forEach(row=>addSpbuMarker(row));
}
function addSpbuMarker(row){
const marker=L.marker([row.latitude,row.longitude],{
icon:spbuIcon(row.buka_24jam), draggable:true
}).addTo(layers.spbu);
marker._spbuData=row;
marker.bindTooltip(row.nama_spbu,{permanent:false,direction:'top'});
marker.on('click',()=>openEditSpbu(marker));
marker.on('dragend',()=>{
const ll=marker.getLatLng();
marker._spbuData.latitude =ll.lat;
marker._spbuData.longitude=ll.lng;
apiFetch(API.spbu+'?id='+row.id,'PUT',marker._spbuData)
.then(()=>toast('Posisi SPBU diperbarui'));
});
return marker;
}
function openEditSpbu(marker){
const d=marker._spbuData;
editingMarker=marker;
document.getElementById('spbu-id').value =d.id;
document.getElementById('spbu-nama').value =d.nama_spbu;
document.getElementById('spbu-wa').value =d.nomor_wa;
document.querySelectorAll('input[name="buka24"]').forEach(r=>r.checked=r.value==d.buka_24jam);
document.getElementById('spbu-coords-info').textContent=
`Lat: ${(+d.latitude).toFixed(6)}, Lng: ${(+d.longitude).toFixed(6)}`;
document.getElementById('spbu-modal-title').textContent='✏️ Edit SPBU';
document.getElementById('btn-spbu-delete').style.display='inline-block';
openModal('modal-spbu');
}
document.getElementById('btn-spbu-save').addEventListener('click',async()=>{
const id =document.getElementById('spbu-id').value;
const nama =document.getElementById('spbu-nama').value.trim();
const wa =document.getElementById('spbu-wa').value.trim();
const b24 =+document.querySelector('input[name="buka24"]:checked').value;
if(!nama||!wa){ toast('Nama dan nomor WA wajib diisi','error'); return; }
let lat,lng;
if(id){ lat=editingMarker._spbuData.latitude; lng=editingMarker._spbuData.longitude; }
else { lat=pendingLatLng.lat; lng=pendingLatLng.lng; }
const body={id:+id||undefined,nama_spbu:nama,nomor_wa:wa,buka_24jam:b24,latitude:lat,longitude:lng};
if(id){
await apiFetch(API.spbu+'?id='+id,'PUT',body);
editingMarker._spbuData={...editingMarker._spbuData,...body};
editingMarker.setIcon(spbuIcon(b24));
editingMarker.bindTooltip(nama,{permanent:false,direction:'top'});
toast('Data SPBU diperbarui ✓');
} else {
const res=await apiFetch(API.spbu,'POST',body);
body.id=res.id;
addSpbuMarker(body);
toast('SPBU berhasil ditambahkan ✓');
}
closeModal('modal-spbu');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
document.getElementById('btn-spbu-delete').addEventListener('click',async()=>{
const id=document.getElementById('spbu-id').value;
if(!id) return;
if(!confirm('Hapus data SPBU ini?')) return;
await apiFetch(API.spbu+'?id='+id,'DELETE');
layers.spbu.eachLayer(m=>{ if(m._spbuData?.id==id) layers.spbu.removeLayer(m); });
closeModal('modal-spbu');
toast('SPBU dihapus','error');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
// ═══════════════════════════════════════════════════════
// JALAN CRUD
// ═══════════════════════════════════════════════════════
async function loadJalan(){
layers.jalan.clearLayers();
const data=await apiFetch(API.jalan,'GET');
data.forEach(row=>addJalanLayer(row));
}
function addJalanLayer(row){
const gj =JSON.parse(row.geojson);
const col=JALAN_COLOR[row.status_jalan]||'#fff';
const line=L.geoJSON(gj,{style:{color:col,weight:4,opacity:.85}}).addTo(layers.jalan);
line._jalanData=row;
line.bindTooltip(
`<b>${row.nama_jalan}</b><br>${row.status_jalan} · ${(+row.panjang_m).toFixed(1)} m`,
{sticky:true}
);
line.on('click',()=>openEditJalan(line));
return line;
}
function openEditJalan(line){
const d=line._jalanData;
// Saat edit: pakai geojson yang sudah ada, tidak perlu redraw
pendingGeojson=d.geojson;
pendingLength =+d.panjang_m;
isEditMode=true;
document.getElementById('jalan-id').value =d.id;
document.getElementById('jalan-nama').value =d.nama_jalan;
document.getElementById('jalan-status').value=d.status_jalan;
document.getElementById('jalan-panjang-info').textContent=
`${(+d.panjang_m).toFixed(1)} m (${(+d.panjang_m/1000).toFixed(3)} km)`;
document.getElementById('jalan-redraw-hint').style.display='block';
document.getElementById('jalan-modal-title').textContent='✏️ Edit Data Jalan';
document.getElementById('btn-jalan-delete').style.display='inline-block';
openEditJalan._layer=line;
openModal('modal-jalan');
}
document.getElementById('btn-jalan-save').addEventListener('click',async()=>{
const id =document.getElementById('jalan-id').value;
const nama =document.getElementById('jalan-nama').value.trim();
const status=document.getElementById('jalan-status').value;
if(!nama){ toast('Nama jalan wajib diisi','error'); return; }
// Saat mode tambah baru (bukan edit), wajib ada geojson dari draw
if(!id && !pendingGeojson){ toast('Gambar ruas jalan terlebih dahulu','error'); return; }
const body={
id:+id||undefined, nama_jalan:nama, status_jalan:status,
panjang_m:pendingLength, geojson:pendingGeojson
};
if(id){
await apiFetch(API.jalan+'?id='+id,'PUT',body);
const old=openEditJalan._layer;
if(old) layers.jalan.removeLayer(old);
// Merge geojson lama jika tidak digambar ulang
body.geojson=pendingGeojson;
addJalanLayer(body);
toast('Data jalan diperbarui ✓');
} else {
const res=await apiFetch(API.jalan,'POST',body);
body.id=res.id;
addJalanLayer(body);
toast('Jalan berhasil ditambahkan ✓');
}
drawnItems.clearLayers();
pendingGeojson=null;
closeModal('modal-jalan');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
document.getElementById('btn-jalan-delete').addEventListener('click',async()=>{
const id=document.getElementById('jalan-id').value;
if(!id) return;
if(!confirm('Hapus data jalan ini?')) return;
await apiFetch(API.jalan+'?id='+id,'DELETE');
const old=openEditJalan._layer;
if(old) layers.jalan.removeLayer(old);
closeModal('modal-jalan');
toast('Jalan dihapus','error');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
// ═══════════════════════════════════════════════════════
// PARSIL CRUD
// ═══════════════════════════════════════════════════════
async function loadParsil(){
layers.parsil.clearLayers();
const data=await apiFetch(API.parsil,'GET');
data.forEach(row=>addParsilLayer(row));
}
function addParsilLayer(row){
const gj =JSON.parse(row.geojson);
const col=PARSIL_COLOR[row.status_sertifikat]||'#fff';
const poly=L.geoJSON(gj,{style:{color:col,weight:2,fillColor:col,fillOpacity:.35}}).addTo(layers.parsil);
poly._parsilData=row;
poly.bindTooltip(
`<b>${row.nama_pemilik}</b><br>${row.status_sertifikat} · ${(+row.luas_m2).toFixed(2)} m²`,
{sticky:true}
);
poly.on('click',()=>openEditParsil(poly));
return poly;
}
function openEditParsil(poly){
const d=poly._parsilData;
pendingGeojson=d.geojson;
pendingArea =+d.luas_m2;
isEditMode=true;
document.getElementById('parsil-id').value =d.id;
document.getElementById('parsil-nama').value =d.nama_pemilik;
document.getElementById('parsil-status').value=d.status_sertifikat;
document.getElementById('parsil-luas-info').textContent=
`${(+d.luas_m2).toFixed(2)} m² (${(+d.luas_m2/10000).toFixed(4)} ha)`;
document.getElementById('parsil-redraw-hint').style.display='block';
document.getElementById('parsil-modal-title').textContent='✏️ Edit Parsil Tanah';
document.getElementById('btn-parsil-delete').style.display='inline-block';
openEditParsil._layer=poly;
openModal('modal-parsil');
}
document.getElementById('btn-parsil-save').addEventListener('click',async()=>{
const id =document.getElementById('parsil-id').value;
const nama =document.getElementById('parsil-nama').value.trim();
const status=document.getElementById('parsil-status').value;
if(!nama){ toast('Nama pemilik wajib diisi','error'); return; }
if(!id && !pendingGeojson){ toast('Gambar polygon terlebih dahulu','error'); return; }
const body={
id:+id||undefined, nama_pemilik:nama, status_sertifikat:status,
luas_m2:pendingArea, geojson:pendingGeojson
};
if(id){
await apiFetch(API.parsil+'?id='+id,'PUT',body);
const old=openEditParsil._layer;
if(old) layers.parsil.removeLayer(old);
addParsilLayer(body);
toast('Parsil diperbarui ✓');
} else {
const res=await apiFetch(API.parsil,'POST',body);
body.id=res.id;
addParsilLayer(body);
toast('Parsil berhasil ditambahkan ✓');
}
drawnItems.clearLayers();
pendingGeojson=null;
closeModal('modal-parsil');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
document.getElementById('btn-parsil-delete').addEventListener('click',async()=>{
const id=document.getElementById('parsil-id').value;
if(!id) return;
if(!confirm('Hapus data parsil ini?')) return;
await apiFetch(API.parsil+'?id='+id,'DELETE');
const old=openEditParsil._layer;
if(old) layers.parsil.removeLayer(old);
closeModal('modal-parsil');
toast('Parsil dihapus','error');
if(document.getElementById('sidebar').classList.contains('open')) renderSidebar();
});
// ═══════════════════════════════════════════════════════
// SIDEBAR
// ═══════════════════════════════════════════════════════
document.getElementById('btn-sidebar').addEventListener('click',()=>{
document.getElementById('sidebar').classList.toggle('open');
renderSidebar();
});
document.getElementById('sidebar-close-btn').addEventListener('click',()=>
document.getElementById('sidebar').classList.remove('open')
);
async function renderSidebar(){
const list =document.getElementById('record-list');
const title=document.getElementById('sidebar-title');
list.innerHTML='<p style="padding:10px;color:var(--muted);font-size:.8rem">Memuat…</p>';
if(activeLayer==='spbu'){
title.textContent='Daftar SPBU';
const data=await apiFetch(API.spbu,'GET');
list.innerHTML='';
data.forEach(row=>{
const div=document.createElement('div');
div.className='record-item';
div.innerHTML=`
<div class="info">
<div class="name">${row.nama_spbu}</div>
<div class="sub">📞 ${row.nomor_wa}</div>
<div class="sub">📍 ${(+row.latitude).toFixed(5)}, ${(+row.longitude).toFixed(5)}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span class="badge ${Number(row.buka_24jam) === 1 ? 'badge-green' : 'badge-red'}">
${Number(row.buka_24jam) === 1 ? '24 Jam' : 'Terbatas'}
</span>
<div class="record-actions">
<button class="btn-icon" data-act="zoom-spbu" data-id="${row.id}"
data-lat="${row.latitude}" data-lng="${row.longitude}">🔍</button>
<button class="btn-icon" data-act="edit-spbu" data-id="${row.id}">✏️</button>
</div>
</div>`;
list.appendChild(div);
});
if(!data.length) list.innerHTML='<p style="padding:10px;color:var(--muted);font-size:.8rem">Belum ada data SPBU.</p>';
} else if(activeLayer==='jalan'){
title.textContent='Daftar Jalan';
const data=await apiFetch(API.jalan,'GET');
list.innerHTML='';
const bc={nasional:'badge-nat',provinsi:'badge-prov',kabupaten:'badge-kab'};
data.forEach(row=>{
const div=document.createElement('div');
div.className='record-item';
div.innerHTML=`
<div class="info">
<div class="name">${row.nama_jalan}</div>
<div class="sub">📏 ${(+row.panjang_m).toFixed(1)} m</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span class="badge ${bc[row.status_jalan]||''}">${row.status_jalan}</span>
<div class="record-actions">
<button class="btn-icon" data-act="zoom-jalan" data-id="${row.id}">🔍</button>
<button class="btn-icon" data-act="edit-jalan" data-id="${row.id}">✏️</button>
<button class="btn-icon danger" data-act="del-jalan" data-id="${row.id}">🗑</button>
</div>
</div>`;
list.appendChild(div);
});
if(!data.length) list.innerHTML='<p style="padding:10px;color:var(--muted);font-size:.8rem">Belum ada data jalan.</p>';
} else if(activeLayer==='parsil'){
title.textContent='Daftar Parsil Tanah';
const data=await apiFetch(API.parsil,'GET');
list.innerHTML='';
const bc={SHM:'badge-yellow',HGB:'badge-blue',HGU:'badge-green',HP:'badge-red'};
data.forEach(row=>{
const div=document.createElement('div');
div.className='record-item';
div.innerHTML=`
<div class="info">
<div class="name">${row.nama_pemilik}</div>
<div class="sub">📐 ${(+row.luas_m2).toFixed(2)} m²</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span class="badge ${bc[row.status_sertifikat]||''}">${row.status_sertifikat}</span>
<div class="record-actions">
<button class="btn-icon" data-act="zoom-parsil" data-id="${row.id}">🔍</button>
<button class="btn-icon" data-act="edit-parsil" data-id="${row.id}">✏️</button>
<button class="btn-icon danger" data-act="del-parsil" data-id="${row.id}">🗑</button>
</div>
</div>`;
list.appendChild(div);
});
if(!data.length) list.innerHTML='<p style="padding:10px;color:var(--muted);font-size:.8rem">Belum ada data parsil.</p>';
}
// Sidebar action delegate
list.onclick=function(e){
const btn=e.target.closest('[data-act]');
if(!btn) return;
const act=btn.dataset.act, id=btn.dataset.id;
if(act==='zoom-spbu'){
map.setView([+btn.dataset.lat,+btn.dataset.lng],17);
}
if(act==='edit-spbu'){
layers.spbu.eachLayer(m=>{ if(m._spbuData?.id==id) openEditSpbu(m); });
}
if(act==='zoom-jalan'){
layers.jalan.eachLayer(l=>{ if(l._jalanData?.id==id) map.fitBounds(l.getBounds(),{padding:[30,30]}); });
}
if(act==='edit-jalan'){
layers.jalan.eachLayer(l=>{ if(l._jalanData?.id==id) openEditJalan(l); });
}
if(act==='del-jalan'){
if(!confirm('Hapus jalan ini?')) return;
apiFetch(API.jalan+'?id='+id,'DELETE').then(()=>{
layers.jalan.eachLayer(l=>{ if(l._jalanData?.id==id) layers.jalan.removeLayer(l); });
toast('Jalan dihapus','error'); renderSidebar();
});
}
if(act==='zoom-parsil'){
layers.parsil.eachLayer(l=>{ if(l._parsilData?.id==id) map.fitBounds(l.getBounds(),{padding:[30,30]}); });
}
if(act==='edit-parsil'){
layers.parsil.eachLayer(l=>{ if(l._parsilData?.id==id) openEditParsil(l); });
}
if(act==='del-parsil'){
if(!confirm('Hapus parsil ini?')) return;
apiFetch(API.parsil+'?id='+id,'DELETE').then(()=>{
layers.parsil.eachLayer(l=>{ if(l._parsilData?.id==id) layers.parsil.removeLayer(l); });
toast('Parsil dihapus','error'); renderSidebar();
});
}
};
}
// ═══════════════════════════════════════════════════════
// API helper
// ═══════════════════════════════════════════════════════
async function apiFetch(url,method='GET',body=null){
const opts={method,headers:{'Content-Type':'application/json'}};
if(body) opts.body=JSON.stringify(body);
try{
const res =await fetch(url,opts);
const data=await res.json();
if(data.error) throw new Error(data.error);
return data;
}catch(e){ toast('Error: '+e.message,'error'); return []; }
}
// ═══════════════════════════════════════════════════════
// LEGEND
// ═══════════════════════════════════════════════════════
const legend=L.control({position:'bottomleft'});
legend.onAdd=function(){
const d=L.DomUtil.create('div');
d.style.cssText='background:rgba(26,37,53,.93);border:1px solid #2e3f57;border-radius:8px;padding:10px 14px;font-size:.71rem;color:#e2eaf5;min-width:155px;line-height:1.8;';
d.innerHTML=`
<b style="display:block;margin-bottom:4px;color:#3b9eff">Legenda</b>
<div><span style="background:#56d4a0;border-radius:50%;display:inline-block;width:10px;height:10px;margin-right:5px"></span>SPBU 24 Jam</div>
<div><span style="background:#ff5c5c;border-radius:50%;display:inline-block;width:10px;height:10px;margin-right:5px"></span>SPBU Terbatas</div>
<hr style="border-color:#2e3f57;margin:5px 0">
<div><span style="background:#7fd4ff;display:inline-block;width:18px;height:4px;margin-right:5px;vertical-align:middle"></span>Jalan Nasional</div>
<div><span style="background:#c07fff;display:inline-block;width:18px;height:4px;margin-right:5px;vertical-align:middle"></span>Jalan Provinsi</div>
<div><span style="background:#7fffc0;display:inline-block;width:18px;height:4px;margin-right:5px;vertical-align:middle"></span>Jalan Kabupaten</div>
<hr style="border-color:#2e3f57;margin:5px 0">
<div><span style="background:#ffb84d55;border:2px solid #ffb84d;display:inline-block;width:14px;height:10px;margin-right:5px"></span>SHM</div>
<div><span style="background:#3b9eff55;border:2px solid #3b9eff;display:inline-block;width:14px;height:10px;margin-right:5px"></span>HGB</div>
<div><span style="background:#56d4a055;border:2px solid #56d4a0;display:inline-block;width:14px;height:10px;margin-right:5px"></span>HGU</div>
<div><span style="background:#ff9fdf55;border:2px solid #ff9fdf;display:inline-block;width:14px;height:10px;margin-right:5px"></span>HP</div>`;
return d;
};
legend.addTo(map);
fetch('data/Admin_Kecamatan_lengkap.geojson')
.then(res => res.json())
.then(data => {
L.geoJSON(data, {
style: {
color: '#ffff00',
weight: 2,
fillOpacity: 0.12
},
onEachFeature: (feature, layer) => {
layer.bindTooltip(feature.properties.Ket || 'Kecamatan');
}
}).addTo(map);
});
// ═══════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════
(async function(){
await Promise.all([loadSPBU(),loadJalan(),loadParsil()]);
updateDrawControl();
})();
</script>
</body>
</html>