Files
2026-06-11 13:54:32 +07:00

463 lines
21 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 Parsil & Jalan</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<!-- Leaflet Geoman CSS -->
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; height: 100vh; display: flex; flex-direction: column; background: #f0f2f5; }
header { background: linear-gradient(135deg, #1976d2, #42a5f5); color: #fff; padding: 10px 18px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0, 0, 0, .3); z-index: 1000; flex-shrink: 0; }
header .title-area { display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 1rem; font-weight: 700; }
header small { font-size: .7rem; opacity: .85; }
.back-btn { color: white; text-decoration: none; font-size: 0.9rem; border: 1px solid rgba(255,255,255,0.5); padding: 5px 10px; border-radius: 5px; transition: background 0.2s;}
.back-btn:hover { background: rgba(255,255,255,0.2); }
.app-body { display: flex; flex: 1; overflow: hidden; position: relative; }
#sidebar { width: 320px; background: #fff; display: flex; flex-direction: column; border-right: 1px solid #dde; z-index: 900; transition: transform .3s; flex-shrink: 0; }
#sidebar.hide { transform: translateX(-100%); position: absolute; top: 0; left: 0; height: 100%; box-shadow: 4px 0 12px rgba(0, 0, 0, .2); }
/* Sidebar Tabs */
.sb-tabs { display: flex; background: #fff; border-bottom: 1px solid #ddd; flex-shrink: 0; }
.sb-tab { flex: 1; text-align: center; padding: 12px 0; font-size: 0.85rem; font-weight: 700; color: #777; cursor: pointer; border-bottom: 3px solid transparent; transition: all 0.2s; }
.sb-tab:hover { background: #f8f8f8; color: #333; }
.sb-tab.active { color: #1976d2; border-bottom: 3px solid #1976d2; background: #f0f7ff; }
.sb-head { background: #1976d2; color: #fff; padding: 10px 14px; font-weight: 700; font-size: .88rem; }
.list-wrap { padding: 10px; flex: 1; overflow-y: auto; background: #fafafa; }
.list-item { background: #fff; border: 1px solid #eee; padding: 10px; border-radius: 6px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.list-item h4 { margin-bottom: 4px; color: #333; font-size: 0.95rem; }
.list-item .attr { font-size: 0.8rem; color: #666; margin-bottom: 2px; }
.list-item .actions { margin-top: 8px; text-align: right; }
#map { flex: 1; z-index: 1; }
#btn-toggle { position: absolute; top: 10px; left: 0; z-index: 950; background: #1976d2; color: #fff; border: none; padding: 8px 7px; border-radius: 0 6px 6px 0; cursor: pointer; font-size: 1rem; box-shadow: 2px 0 6px rgba(0, 0, 0, .25); }
/* Buttons & Forms */
.btn { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: .8rem; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; transition: opacity .2s; }
.btn:hover { opacity: .85; }
.btn-red { background: #d93025; color: #fff; }
.btn-blue { background: #1976d2; color: #fff; }
.btn-green { background: #1a6b3c; color: #fff; }
.btn-gray { background: #e0e0e0; color: #333; }
.fg { margin-bottom: 12px; }
.fg label { display: block; font-size: .8rem; font-weight: 600; margin-bottom: 4px; color: #555; }
.fg input, .fg select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-family: inherit; font-size: .85rem; }
.fg input[readonly] { background: #eee; color: #555; }
/* Modal Overlay */
.overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.5); display: none; align-items: center; justify-content: center; z-index: 9999; }
.overlay.on { display: flex; }
.modal { background: #fff; padding: 20px; border-radius: 8px; width: 90%; max-width: 400px; box-shadow: 0 4px 15px rgba(0,0,0,.3); }
.modal h2 { margin-bottom: 15px; font-size: 1.2rem; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; }
.modal-footer { margin-top: 20px; text-align: right; border-top: 1px solid #eee; padding-top: 15px; }
/* Toast */
#toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 20px; border-radius: 5px; color: #fff; font-size: .9rem; opacity: 0; pointer-events: none; transition: opacity .3s; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,.3); }
#toast.on { opacity: 1; pointer-events: auto; }
#toast.ok { background: #1a6b3c; }
#toast.err { background: #d93025; }
.empty { text-align: center; color: #888; font-size: 0.9rem; padding: 20px 0; }
</style>
</head>
<body>
<header>
<div class="title-area">
<i class="fa-solid fa-road fa-lg"></i>
<div>
<h1>WebGIS Parsil & Jalan</h1>
<small>Pemetaan Bidang Tanah & Jaringan Jalan</small>
</div>
</div>
<a href="index.html" class="back-btn"><i class="fa-solid fa-arrow-left"></i> Kembali ke Dashboard</a>
</header>
<div class="app-body">
<div id="sidebar">
<div class="sb-tabs">
<div class="sb-tab active" id="tab-jalan" onclick="gantiTab('jalan')"><i class="fa-solid fa-road"></i> Data Jalan</div>
<div class="sb-tab" id="tab-parsil" onclick="gantiTab('parsil')"><i class="fa-solid fa-draw-polygon"></i> Data Parsil</div>
</div>
<!-- Konten Jalan -->
<div id="content-jalan" style="display: flex; flex-direction: column; flex: 1; overflow: hidden;">
<div class="sb-head"><i class="fa-solid fa-list"></i> Daftar Jalan</div>
<div class="list-wrap" id="list-jalan">
<div class="empty"><i class="fa-solid fa-spinner fa-spin"></i> Memuat...</div>
</div>
</div>
<!-- Konten Parsil -->
<div id="content-parsil" style="display: none; flex-direction: column; flex: 1; overflow: hidden;">
<div class="sb-head" style="background:#27ae60;"><i class="fa-solid fa-list"></i> Daftar Parsil (Kavling)</div>
<div class="list-wrap" id="list-parsil">
<div class="empty"><i class="fa-solid fa-spinner fa-spin"></i> Memuat...</div>
</div>
</div>
</div>
<div id="map"></div>
<button id="btn-toggle" onclick="toggleSidebar()"><i class="fa-solid fa-bars"></i></button>
</div>
<!-- MODAL FORM JALAN -->
<div class="overlay" id="ovl-jalan">
<div class="modal">
<h2><i class="fa-solid fa-road"></i> Data Jalan</h2>
<div class="fg">
<label>Nama Jalan</label>
<input type="text" id="j-nama" placeholder="Contoh: Jl. Ahmad Yani">
</div>
<div class="fg">
<label>Status Jalan</label>
<select id="j-status">
<option value="Nasional">Jalan Nasional</option>
<option value="Provinsi">Jalan Provinsi</option>
<option value="Kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div class="fg">
<label>Panjang (Otomatis)</label>
<input type="text" id="j-panjang" readonly>
</div>
<div class="modal-footer">
<button class="btn btn-gray" onclick="batalSimpan('jalan')">Batal</button>
<button class="btn btn-blue" onclick="simpanData('jalan')"><i class="fa-solid fa-save"></i> Simpan</button>
</div>
</div>
</div>
<!-- MODAL FORM PARSIL -->
<div class="overlay" id="ovl-parsil">
<div class="modal">
<h2><i class="fa-solid fa-draw-polygon"></i> Data Parsil Tanah</h2>
<div class="fg">
<label>Nama Pemilik</label>
<input type="text" id="p-nama" placeholder="Nama pemilik tanah">
</div>
<div class="fg">
<label>Status Kepemilikan</label>
<select id="p-status">
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
<option value="HP">Sertifikat Hak Pakai (HP)</option>
</select>
</div>
<div class="fg">
<label>Luas (Otomatis)</label>
<input type="text" id="p-luas" readonly>
</div>
<div class="modal-footer">
<button class="btn btn-gray" onclick="batalSimpan('parsil')">Batal</button>
<button class="btn btn-green" onclick="simpanData('parsil')"><i class="fa-solid fa-save"></i> Simpan</button>
</div>
</div>
</div>
<div id="toast"></div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@latest/dist/leaflet-geoman.min.js"></script>
<script src="https://unpkg.com/@turf/turf/turf.min.js"></script>
<script>
const API = 'api.php';
let map;
let layerJalan = L.layerGroup();
let layerParsil = L.layerGroup();
// State
let tempLayer = null;
let tempGeoJSON = null;
let activeId = null;
// Config warna
const warnaJalan = {
'Nasional': '#d32f2f', // Merah
'Provinsi': '#1976d2', // Biru
'Kabupaten': '#388e3c' // Hijau
};
const warnaParsil = {
'SHM': '#27ae60', // Hijau
'HGB': '#2980b9', // Biru
'HGU': '#e67e22', // Orange
'HP': '#8e44ad' // Ungu
};
function initMap() {
map = L.map('map').setView([-0.0380, 109.3440], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap'
}).addTo(map);
layerJalan.addTo(map);
layerParsil.addTo(map);
// Inisialisasi Leaflet-Geoman (Drawing toolbar)
map.pm.addControls({
position: 'topright',
drawMarker: false,
drawCircleMarker: false,
drawCircle: false,
drawRectangle: false,
drawText: false,
drawPolygon: true, // Untuk Parsil
drawLine: true, // Untuk Jalan
editMode: true,
dragMode: true,
cutPolygon: false,
removalMode: true
});
// Translate geoman
map.pm.setLang('id');
// Event saat selesai menggambar
map.on('pm:create', (e) => {
const layer = e.layer;
const shape = e.shape;
const geojson = layer.toGeoJSON().geometry;
tempLayer = layer;
tempGeoJSON = geojson;
activeId = null;
if (shape === 'Line') {
// Hitung panjang dengan turf
const length = turf.length(geojson, {units: 'meters'});
document.getElementById('j-nama').value = '';
document.getElementById('j-status').value = 'Nasional';
document.getElementById('j-panjang').value = length.toFixed(2);
document.getElementById('ovl-jalan').classList.add('on');
} else if (shape === 'Polygon') {
// Hitung luas dengan turf
const area = turf.area(geojson); // in square meters
document.getElementById('p-nama').value = '';
document.getElementById('p-status').value = 'SHM';
document.getElementById('p-luas').value = area.toFixed(2);
document.getElementById('ovl-parsil').classList.add('on');
}
});
// Event saat menghapus via Geoman toolbar
map.on('pm:remove', async (e) => {
const layer = e.layer;
if (layer.feature && layer.feature.properties) {
const props = layer.feature.properties;
const res = await fetch(`${API}?resource=${props.type}&action=delete&id=${props.id}`, { method: 'DELETE' });
if(res.ok) {
toast('Data berhasil dihapus dari database.', 'ok');
muatData();
}
}
});
// Event saat mengedit (drag/vertex)
map.on('pm:globaleditmodetoggled', (e) => {
if(!e.enabled) {
// Selesai edit, kita harus simpan perubahan yang terjadi.
// Cara paling mudah: tambahkan event layer.on('pm:update') ke setiap layer saat diload
}
});
}
async function muatData() {
try {
// Muat Jalan
const resJ = await fetch(`${API}?resource=jalan&action=list`);
const dataJ = await resJ.json();
layerJalan.clearLayers();
let htmlJ = '';
dataJ.forEach(j => {
if(!j.geom) return;
const l = L.geoJSON(j.geom, {
style: {
color: warnaJalan[j.status] || '#333',
weight: 5,
opacity: 0.8
}
});
// Bind properties for geoman removal/update
l.eachLayer(lyr => {
lyr.feature = { properties: { type: 'jalan', id: j.id, nama: j.nama, status: j.status, panjang: j.panjang } };
lyr.bindPopup(`<b>${j.nama}</b><br>Status: ${j.status}<br>Panjang: ${j.panjang} m`);
lyr.on('pm:update', async (e) => {
updateGeom('jalan', j.id, j.nama, j.status, e.layer.toGeoJSON().geometry);
});
});
l.addTo(layerJalan);
htmlJ += `
<div class="list-item">
<h4>${j.nama}</h4>
<div class="attr"><i class="fa-solid fa-tag"></i> ${j.status}</div>
<div class="attr"><i class="fa-solid fa-ruler"></i> ${j.panjang} m</div>
</div>`;
});
document.getElementById('list-jalan').innerHTML = htmlJ || '<div class="empty">Belum ada data jalan.</div>';
// Muat Parsil
const resP = await fetch(`${API}?resource=parsil&action=list`);
const dataP = await resP.json();
layerParsil.clearLayers();
let htmlP = '';
dataP.forEach(p => {
if(!p.geom) return;
const l = L.geoJSON(p.geom, {
style: {
color: warnaParsil[p.status] || '#333',
fillColor: warnaParsil[p.status] || '#333',
fillOpacity: 0.4,
weight: 2
}
});
l.eachLayer(lyr => {
lyr.feature = { properties: { type: 'parsil', id: p.id, nama: p.nama_pemilik, status: p.status, luas: p.luas } };
lyr.bindPopup(`<b>Pemilik: ${p.nama_pemilik}</b><br>Status: ${p.status}<br>Luas: ${p.luas} m²`);
lyr.on('pm:update', async (e) => {
updateGeom('parsil', p.id, p.nama_pemilik, p.status, e.layer.toGeoJSON().geometry);
});
});
l.addTo(layerParsil);
htmlP += `
<div class="list-item">
<h4>${p.nama_pemilik}</h4>
<div class="attr"><i class="fa-solid fa-file-signature"></i> ${p.status}</div>
<div class="attr"><i class="fa-solid fa-maximize"></i> ${p.luas} m²</div>
</div>`;
});
document.getElementById('list-parsil').innerHTML = htmlP || '<div class="empty">Belum ada data parsil.</div>';
} catch (e) {
console.error(e);
}
}
async function updateGeom(type, id, nama, status, geojson) {
let payload = {};
if(type === 'jalan') {
const len = turf.length(geojson, {units: 'meters'});
payload = { nama, status, panjang: len.toFixed(2), geom: geojson };
} else {
const area = turf.area(geojson);
payload = { nama_pemilik: nama, status, luas: area.toFixed(2), geom: geojson };
}
try {
const res = await fetch(`${API}?resource=${type}&action=update&id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if(res.ok) {
toast('Geometri diperbarui!', 'ok');
muatData();
}
} catch(e) { toast('Gagal update', 'err'); }
}
async function simpanData(type) {
let payload = {};
if (type === 'jalan') {
const nama = document.getElementById('j-nama').value.trim();
const status = document.getElementById('j-status').value;
const panjang = document.getElementById('j-panjang').value;
if(!nama) return alert('Nama wajib diisi');
payload = { nama, status, panjang, geom: tempGeoJSON };
} else {
const nama = document.getElementById('p-nama').value.trim();
const status = document.getElementById('p-status').value;
const luas = document.getElementById('p-luas').value;
if(!nama) return alert('Nama pemilik wajib diisi');
payload = { nama_pemilik: nama, status, luas, geom: tempGeoJSON };
}
try {
const res = await fetch(`${API}?resource=${type}&action=create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
if(res.ok) {
toast('Berhasil disimpan!', 'ok');
document.getElementById(`ovl-${type}`).classList.remove('on');
if (tempLayer) { map.removeLayer(tempLayer); tempLayer = null; }
muatData();
} else {
toast(json.error || 'Gagal menyimpan', 'err');
}
} catch(e) {
toast('Network error', 'err');
}
}
function batalSimpan(type) {
document.getElementById(`ovl-${type}`).classList.remove('on');
if (tempLayer) {
map.removeLayer(tempLayer);
tempLayer = null;
}
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('hide');
}
function gantiTab(tab) {
document.getElementById('tab-jalan').classList.remove('active');
document.getElementById('tab-parsil').classList.remove('active');
document.getElementById('content-jalan').style.display = 'none';
document.getElementById('content-parsil').style.display = 'none';
document.getElementById(`tab-${tab}`).classList.add('active');
document.getElementById(`content-${tab}`).style.display = 'flex';
}
let toastT;
function toast(msg, tipe = '') {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'on ' + tipe;
clearTimeout(toastT);
toastT = setTimeout(() => el.className = '', 3000);
}
window.onload = () => {
initMap();
muatData();
};
</script>
</body>
</html>