463 lines
21 KiB
HTML
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: '© 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>
|