First Commit
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# SIG Mapping Tanah & Jalan
|
||||
|
||||
Aplikasi pemetaan **point & click**: gambar bidang **tanah** (polygon) dan **jalan** (garis)
|
||||
langsung di peta. **Luas, keliling, dan panjang dihitung otomatis** (di klien saat menggambar,
|
||||
dan diverifikasi ulang di server saat disimpan).
|
||||
|
||||
## Teknologi
|
||||
- **Frontend:** TailwindCSS (CDN), Leaflet.js + Leaflet.draw
|
||||
- **Backend:** Vanilla PHP (PDO), pola REST sederhana
|
||||
- **Database:** MySQL 8
|
||||
|
||||
## Struktur
|
||||
```
|
||||
sig-02/
|
||||
├── index.php UI peta + sidebar + modal form
|
||||
├── assets/app.js Logika peta, CRUD, perhitungan klien
|
||||
├── api/
|
||||
│ ├── tanah.php Endpoint CRUD tanah (Polygon)
|
||||
│ └── jalan.php Endpoint CRUD jalan (LineString)
|
||||
├── includes/
|
||||
│ ├── crud.php Handler CRUD generik
|
||||
│ └── geo.php Haversine + luas geodesik (perhitungan server)
|
||||
├── config/database.php Koneksi PDO
|
||||
└── schema.sql Skema database
|
||||
```
|
||||
|
||||
## Cara Menjalankan (Laragon)
|
||||
1. **Import database** (sekali saja):
|
||||
```
|
||||
mysql -u root < schema.sql
|
||||
```
|
||||
atau via HeidiSQL/phpMyAdmin: jalankan isi `schema.sql`.
|
||||
2. Pastikan kredensial di `config/database.php` sesuai (default Laragon: `root`, tanpa password).
|
||||
3. Buka di browser:
|
||||
- Laragon (Apache): `http://sig-02.test` atau `http://localhost/sig-02`
|
||||
- Atau server bawaan PHP: `php -S 127.0.0.1:8000` lalu buka `http://127.0.0.1:8000`
|
||||
|
||||
## Cara Pakai
|
||||
- Pakai toolbar gambar di **kanan-atas peta**:
|
||||
- **Polygon** → tambah **tanah** (luas & keliling muncul otomatis).
|
||||
- **Garis (polyline)** → tambah **jalan** (panjang muncul otomatis).
|
||||
- Selesai menggambar → muncul form untuk nama/pemilik/jenis/deskripsi/warna → **Simpan**.
|
||||
- **Sidebar kiri:** daftar tanah & jalan (klik untuk fokus, ✎ edit atribut, 🗑 hapus).
|
||||
- **Edit geometri:** klik ikon edit (toolbar gambar), geser titik, lalu *Save* → tersimpan otomatis.
|
||||
- Ganti basemap (Peta Jalan / Satelit) di pojok kanan-bawah.
|
||||
|
||||
## Catatan Perhitungan
|
||||
- **Panjang jalan:** jumlah jarak haversine antar titik (meter, ditampilkan m / km).
|
||||
- **Luas tanah:** rumus area geodesik bola (m², ditampilkan m² / ha).
|
||||
- **Keliling tanah:** haversine keliling ring polygon.
|
||||
- Radius bumi: WGS84 (6.378.137 m).
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/crud.php';
|
||||
|
||||
handle_crud([
|
||||
'table' => 'jalan',
|
||||
'geomType' => 'LineString',
|
||||
'fields' => ['nama', 'jenis', 'kategori', 'deskripsi', 'warna'],
|
||||
'measure' => function (array $geom): array {
|
||||
// LineString: coordinates = [ [lng,lat], ... ]
|
||||
[, $coords] = $geom;
|
||||
return [
|
||||
'panjang' => round(line_length($coords), 2),
|
||||
];
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/crud.php';
|
||||
|
||||
handle_crud([
|
||||
'table' => 'tanah',
|
||||
'geomType' => 'Polygon',
|
||||
'fields' => ['nama', 'pemilik', 'kategori', 'deskripsi', 'warna'],
|
||||
'measure' => function (array $geom): array {
|
||||
// Polygon: coordinates = [ring, ...]; ring pertama = outer ring.
|
||||
[, $coords] = $geom;
|
||||
$outer = $coords[0] ?? [];
|
||||
return [
|
||||
'luas' => round(ring_area($outer), 2),
|
||||
'keliling' => round(ring_perimeter($outer), 2),
|
||||
];
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,477 @@
|
||||
/* =========================================================================
|
||||
* SIG Mapping Tanah & Jalan
|
||||
* Leaflet + Leaflet.draw, backend vanilla PHP.
|
||||
* ========================================================================= */
|
||||
|
||||
const API = {
|
||||
tanah: 'api/tanah.php',
|
||||
jalan: 'api/jalan.php',
|
||||
};
|
||||
|
||||
const THEME = {
|
||||
tanah: { color: '#22c55e', label: 'Tanah', geom: 'Polygon' },
|
||||
jalan: { color: '#ef4444', label: 'Jalan', geom: 'LineString' },
|
||||
};
|
||||
|
||||
// Penyimpanan layer & data per id
|
||||
const store = {
|
||||
tanah: { items: [], layers: new Map() },
|
||||
jalan: { items: [], layers: new Map() },
|
||||
};
|
||||
|
||||
let activeTab = 'tanah';
|
||||
let searchQuery = '';
|
||||
|
||||
/* --------------------------- Util format --------------------------- */
|
||||
function fmtArea(m2) {
|
||||
if (m2 >= 10000) return (m2 / 10000).toFixed(2) + ' ha (' + Math.round(m2).toLocaleString('id') + ' m²)';
|
||||
return Math.round(m2).toLocaleString('id') + ' m²';
|
||||
}
|
||||
function fmtLen(m) {
|
||||
if (m >= 1000) return (m / 1000).toFixed(2) + ' km (' + Math.round(m).toLocaleString('id') + ' m)';
|
||||
return Math.round(m).toLocaleString('id') + ' m';
|
||||
}
|
||||
|
||||
/* --------------- Perhitungan klien (mirror dari PHP) --------------- */
|
||||
const R = 6378137.0;
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
|
||||
function haversine(a, b) {
|
||||
const dLat = rad(b[1] - a[1]);
|
||||
const dLon = rad(b[0] - a[0]);
|
||||
const h = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
function lineLength(coords) {
|
||||
let t = 0;
|
||||
for (let i = 1; i < coords.length; i++) t += haversine(coords[i - 1], coords[i]);
|
||||
return t;
|
||||
}
|
||||
function ringArea(ring) {
|
||||
const n = ring.length;
|
||||
if (n < 3) return 0;
|
||||
let area = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p1 = ring[i], p2 = ring[(i + 1) % n];
|
||||
area += rad(p2[0] - p1[0]) * (2 + Math.sin(rad(p1[1])) + Math.sin(rad(p2[1])));
|
||||
}
|
||||
return Math.abs((area * R * R) / 2);
|
||||
}
|
||||
function ringPerimeter(ring) {
|
||||
if (ring.length < 2) return 0;
|
||||
const closed = ring.slice();
|
||||
const f = closed[0], l = closed[closed.length - 1];
|
||||
if (f[0] !== l[0] || f[1] !== l[1]) closed.push(f);
|
||||
return lineLength(closed);
|
||||
}
|
||||
|
||||
/* Hitung ukuran dari sebuah GeoJSON geometry */
|
||||
function measure(kind, geometry) {
|
||||
if (kind === 'tanah') {
|
||||
const outer = geometry.coordinates[0] || [];
|
||||
return { luas: ringArea(outer), keliling: ringPerimeter(outer) };
|
||||
}
|
||||
return { panjang: lineLength(geometry.coordinates) };
|
||||
}
|
||||
|
||||
/* --------------------------- Peta --------------------------- */
|
||||
const map = L.map('map', { center: [-0.0263, 109.3425], zoom: 13 }); // Pontianak
|
||||
|
||||
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, attribution: '© OpenStreetMap',
|
||||
}).addTo(map);
|
||||
|
||||
const sat = L.tileLayer(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
{ maxZoom: 19, attribution: 'Esri World Imagery' }
|
||||
);
|
||||
L.control.layers({ 'Peta Jalan': osm, 'Satelit': sat }, null, { position: 'bottomright' }).addTo(map);
|
||||
|
||||
// Group yang bisa diedit oleh Leaflet.draw
|
||||
const drawnItems = new L.FeatureGroup().addTo(map);
|
||||
|
||||
const drawControl = new L.Control.Draw({
|
||||
position: 'topright',
|
||||
draw: {
|
||||
polygon: {
|
||||
showArea: true, metric: true, allowIntersection: false,
|
||||
shapeOptions: { color: THEME.tanah.color, weight: 2, fillOpacity: 0.3 },
|
||||
},
|
||||
polyline: {
|
||||
metric: true, showLength: true,
|
||||
shapeOptions: { color: THEME.jalan.color, weight: 4 },
|
||||
},
|
||||
rectangle: false, circle: false, marker: false, circlemarker: false,
|
||||
},
|
||||
edit: { featureGroup: drawnItems, remove: false },
|
||||
});
|
||||
map.addControl(drawControl);
|
||||
|
||||
/* --- Tombol "Tambah" di sidebar: aktifkan mode menggambar Leaflet.draw --- */
|
||||
let activeDrawer = null;
|
||||
function startDraw(kind) {
|
||||
if (activeDrawer) { activeDrawer.disable(); activeDrawer = null; }
|
||||
activeDrawer = kind === 'tanah'
|
||||
? new L.Draw.Polygon(map, drawControl.options.draw.polygon)
|
||||
: new L.Draw.Polyline(map, drawControl.options.draw.polyline);
|
||||
activeDrawer.enable();
|
||||
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (hint) {
|
||||
hint.innerHTML = kind === 'tanah'
|
||||
? 'Klik titik-titik batas <b>tanah</b> di peta, klik titik awal untuk menutup.'
|
||||
: 'Klik titik-titik <b>jalan</b> di peta, klik dua kali untuk mengakhiri.';
|
||||
hint.classList.add('text-emerald-700', 'font-medium');
|
||||
}
|
||||
}
|
||||
function resetDrawHint() {
|
||||
const hint = document.getElementById('draw-hint');
|
||||
if (hint) {
|
||||
hint.innerHTML = 'Klik tombol lalu gambar di peta. <span class="text-slate-400">Luas & panjang otomatis.</span>';
|
||||
hint.classList.remove('text-emerald-700', 'font-medium');
|
||||
}
|
||||
}
|
||||
document.getElementById('add-tanah').onclick = () => startDraw('tanah');
|
||||
document.getElementById('add-jalan').onclick = () => startDraw('jalan');
|
||||
map.on(L.Draw.Event.DRAWSTOP, () => { activeDrawer = null; resetDrawHint(); });
|
||||
|
||||
/* --------------------------- API helpers --------------------------- */
|
||||
async function apiGet(kind) {
|
||||
const res = await fetch(API[kind]);
|
||||
const j = await res.json();
|
||||
return j.data || [];
|
||||
}
|
||||
async function apiSave(kind, payload, id) {
|
||||
const url = id ? `${API[kind]}?id=${id}` : API[kind];
|
||||
const res = await fetch(url, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || 'Gagal menyimpan');
|
||||
return j.data;
|
||||
}
|
||||
async function apiDelete(kind, id) {
|
||||
const res = await fetch(`${API[kind]}?id=${id}`, { method: 'DELETE' });
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j.error || 'Gagal menghapus');
|
||||
return true;
|
||||
}
|
||||
|
||||
/* --------------------------- Render layer --------------------------- */
|
||||
function styleFor(kind, item) {
|
||||
const c = item.warna || THEME[kind].color;
|
||||
return kind === 'tanah'
|
||||
? { color: c, weight: 2, fillColor: c, fillOpacity: 0.35 }
|
||||
: { color: c, weight: 5, opacity: 0.9 };
|
||||
}
|
||||
|
||||
function popupHtml(kind, item) {
|
||||
if (kind === 'tanah') {
|
||||
return `<div class="text-sm">
|
||||
<b>${escapeHtml(item.nama)}</b><br>
|
||||
${item.pemilik ? 'Pemilik: ' + escapeHtml(item.pemilik) + '<br>' : ''}
|
||||
${item.kategori ? 'Status: ' + escapeHtml(item.kategori) + '<br>' : ''}
|
||||
Luas: <b>${fmtArea(+item.luas)}</b><br>
|
||||
Keliling: ${fmtLen(+item.keliling)}
|
||||
${item.deskripsi ? '<br><i>' + escapeHtml(item.deskripsi) + '</i>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="text-sm">
|
||||
<b>${escapeHtml(item.nama)}</b><br>
|
||||
${item.kategori ? 'Kategori: ' + escapeHtml(item.kategori) + '<br>' : ''}
|
||||
${item.jenis ? 'Jenis: ' + escapeHtml(item.jenis) + '<br>' : ''}
|
||||
Panjang: <b>${fmtLen(+item.panjang)}</b>
|
||||
${item.deskripsi ? '<br><i>' + escapeHtml(item.deskripsi) + '</i>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function addFeature(kind, item) {
|
||||
const layer = L.geoJSON(item.geojson, { style: styleFor(kind, item) });
|
||||
// geoJSON membuat group; ambil layer dalamnya & masukkan ke drawnItems
|
||||
layer.eachLayer((l) => {
|
||||
l.feature_id = item.id;
|
||||
l.kind = kind;
|
||||
l.bindPopup(popupHtml(kind, item));
|
||||
drawnItems.addLayer(l);
|
||||
store[kind].layers.set(item.id, l);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFeatureLayer(kind, id) {
|
||||
const l = store[kind].layers.get(id);
|
||||
if (l) { drawnItems.removeLayer(l); store[kind].layers.delete(id); }
|
||||
}
|
||||
|
||||
/* --------------------------- Sidebar --------------------------- */
|
||||
function matchQuery(kind, it) {
|
||||
if (!searchQuery) return true;
|
||||
const hay = [it.nama, it.deskripsi, it.kategori, kind === 'tanah' ? it.pemilik : it.jenis]
|
||||
.filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(searchQuery);
|
||||
}
|
||||
|
||||
function renderList(kind) {
|
||||
const ul = document.getElementById('list-' + kind);
|
||||
const all = store[kind].items;
|
||||
const items = all.filter((it) => matchQuery(kind, it));
|
||||
// tampilkan "cocok/total" saat mencari, atau total saja saat tidak
|
||||
document.getElementById('count-' + kind).textContent =
|
||||
searchQuery ? `${items.length}/${all.length}` : all.length;
|
||||
|
||||
if (!all.length) {
|
||||
ul.innerHTML = `<li class="p-6 text-center text-sm text-slate-400">Belum ada data.<br>Gambar di peta untuk menambahkan.</li>`;
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
ul.innerHTML = `<li class="p-6 text-center text-sm text-slate-400">Tidak ada hasil untuk<br>"<b>${escapeHtml(searchQuery)}</b>".</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
ul.innerHTML = items.map((it) => {
|
||||
const ukuran = kind === 'tanah' ? fmtArea(+it.luas) : fmtLen(+it.panjang);
|
||||
const sub = kind === 'tanah'
|
||||
? (it.pemilik ? escapeHtml(it.pemilik) : '—')
|
||||
: (it.jenis ? escapeHtml(it.jenis) : '—');
|
||||
return `<li class="group p-3 hover:bg-slate-50 cursor-pointer flex items-start gap-3" data-id="${it.id}">
|
||||
<span class="mt-1 w-3 h-3 rounded-sm shrink-0" style="background:${it.warna || THEME[kind].color}"></span>
|
||||
<div class="flex-1 min-w-0" data-act="focus">
|
||||
<div class="font-medium text-sm truncate">${escapeHtml(it.nama)}</div>
|
||||
<div class="text-xs text-slate-500 truncate">${sub}</div>
|
||||
<div class="text-xs font-semibold text-slate-700 mt-0.5">${ukuran}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button data-act="edit" title="Edit" class="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700 hover:bg-amber-200">✎</button>
|
||||
<button data-act="del" title="Hapus" class="text-xs px-2 py-0.5 rounded bg-red-100 text-red-700 hover:bg-red-200">🗑</button>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join('');
|
||||
|
||||
ul.querySelectorAll('li').forEach((li) => {
|
||||
const id = +li.dataset.id;
|
||||
li.querySelector('[data-act="focus"]').onclick = () => focusFeature(kind, id);
|
||||
li.querySelector('[data-act="edit"]').onclick = (e) => { e.stopPropagation(); openEdit(kind, id); };
|
||||
li.querySelector('[data-act="del"]').onclick = (e) => { e.stopPropagation(); doDelete(kind, id); };
|
||||
});
|
||||
}
|
||||
|
||||
// Redupkan fitur di peta yang tidak cocok dengan pencarian.
|
||||
function applyMapFilter() {
|
||||
['tanah', 'jalan'].forEach((kind) => {
|
||||
store[kind].items.forEach((it) => {
|
||||
const l = store[kind].layers.get(it.id);
|
||||
if (!l) return;
|
||||
if (matchQuery(kind, it)) {
|
||||
l.setStyle(styleFor(kind, it));
|
||||
} else {
|
||||
l.setStyle(kind === 'tanah' ? { opacity: 0.15, fillOpacity: 0.04 } : { opacity: 0.15 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const luas = store.tanah.items.reduce((s, i) => s + (+i.luas || 0), 0);
|
||||
const panjang = store.jalan.items.reduce((s, i) => s + (+i.panjang || 0), 0);
|
||||
document.getElementById('sum-luas').textContent = fmtArea(luas);
|
||||
document.getElementById('sum-panjang').textContent = fmtLen(panjang);
|
||||
}
|
||||
|
||||
function focusFeature(kind, id) {
|
||||
const l = store[kind].layers.get(id);
|
||||
if (!l) return;
|
||||
if (l.getBounds) map.fitBounds(l.getBounds(), { maxZoom: 18, padding: [40, 40] });
|
||||
l.openPopup();
|
||||
}
|
||||
|
||||
/* --------------------------- Load data --------------------------- */
|
||||
async function loadAll() {
|
||||
for (const kind of ['tanah', 'jalan']) {
|
||||
store[kind].items = await apiGet(kind);
|
||||
store[kind].layers.forEach((l) => drawnItems.removeLayer(l));
|
||||
store[kind].layers.clear();
|
||||
store[kind].items.forEach((it) => addFeature(kind, it));
|
||||
renderList(kind);
|
||||
}
|
||||
updateSummary();
|
||||
if (searchQuery) applyMapFilter();
|
||||
}
|
||||
|
||||
/* --------------------------- Modal form --------------------------- */
|
||||
const modal = document.getElementById('modal');
|
||||
const form = document.getElementById('form');
|
||||
let pendingLayer = null; // layer baru hasil gambar (belum tersimpan)
|
||||
|
||||
function openModal(kind, mode, item) {
|
||||
const t = THEME[kind];
|
||||
document.getElementById('f-kind').value = kind;
|
||||
document.getElementById('f-id').value = item?.id || '';
|
||||
document.getElementById('modal-title').textContent =
|
||||
(mode === 'edit' ? 'Edit ' : 'Tambah ') + t.label;
|
||||
|
||||
const head = document.getElementById('modal-head');
|
||||
const saveBtn = document.getElementById('modal-save');
|
||||
const accent = kind === 'tanah' ? 'bg-emerald-600' : 'bg-red-600';
|
||||
head.className = 'px-5 py-4 text-white font-semibold flex items-center justify-between ' + accent;
|
||||
saveBtn.className = 'flex-1 py-2 rounded-lg text-white text-sm font-semibold ' +
|
||||
(kind === 'tanah' ? 'bg-emerald-600 hover:bg-emerald-700' : 'bg-red-600 hover:bg-red-700');
|
||||
|
||||
// tampilkan field sesuai jenis
|
||||
document.querySelectorAll('.kind-tanah').forEach((el) => el.classList.toggle('hidden', kind !== 'tanah'));
|
||||
document.querySelectorAll('.kind-jalan').forEach((el) => el.classList.toggle('hidden', kind !== 'jalan'));
|
||||
|
||||
// isi nilai
|
||||
document.getElementById('f-nama').value = item?.nama || '';
|
||||
document.getElementById('f-pemilik').value = item?.pemilik || '';
|
||||
document.getElementById('f-jenis').value = item?.jenis || '';
|
||||
document.getElementById('f-kategori-tanah').value = item?.kategori || '';
|
||||
document.getElementById('f-kategori-jalan').value = item?.kategori || '';
|
||||
document.getElementById('f-deskripsi').value = item?.deskripsi || '';
|
||||
document.getElementById('f-warna').value = item?.warna || t.color;
|
||||
document.getElementById('f-geojson').value = JSON.stringify(item.geojson);
|
||||
|
||||
// ukuran otomatis
|
||||
const m = measure(kind, item.geojson);
|
||||
const box = document.getElementById('f-measure');
|
||||
box.innerHTML = kind === 'tanah'
|
||||
? `<div><div class="text-xs text-slate-500">Luas</div><div class="font-bold text-emerald-700">${fmtArea(m.luas)}</div></div>
|
||||
<div><div class="text-xs text-slate-500">Keliling</div><div class="font-bold">${fmtLen(m.keliling)}</div></div>`
|
||||
: `<div class="col-span-2"><div class="text-xs text-slate-500">Panjang</div><div class="font-bold text-red-700">${fmtLen(m.panjang)}</div></div>`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
setTimeout(() => document.getElementById('f-nama').focus(), 50);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.add('hidden');
|
||||
// bila ada layer gambar yang belum disimpan -> buang
|
||||
if (pendingLayer) { drawnItems.removeLayer(pendingLayer); pendingLayer = null; }
|
||||
}
|
||||
document.getElementById('modal-close').onclick = closeModal;
|
||||
document.getElementById('modal-cancel').onclick = closeModal;
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const kind = document.getElementById('f-kind').value;
|
||||
const id = document.getElementById('f-id').value;
|
||||
const payload = {
|
||||
nama: document.getElementById('f-nama').value.trim(),
|
||||
deskripsi: document.getElementById('f-deskripsi').value.trim(),
|
||||
warna: document.getElementById('f-warna').value,
|
||||
geojson: JSON.parse(document.getElementById('f-geojson').value),
|
||||
};
|
||||
if (kind === 'tanah') {
|
||||
payload.pemilik = document.getElementById('f-pemilik').value.trim();
|
||||
payload.kategori = document.getElementById('f-kategori-tanah').value;
|
||||
} else {
|
||||
payload.jenis = document.getElementById('f-jenis').value;
|
||||
payload.kategori = document.getElementById('f-kategori-jalan').value;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('modal-save');
|
||||
btn.disabled = true; btn.textContent = 'Menyimpan...';
|
||||
try {
|
||||
await apiSave(kind, payload, id || null);
|
||||
pendingLayer = null; // sudah tersimpan; akan dirender ulang dari server
|
||||
modal.classList.add('hidden');
|
||||
await loadAll();
|
||||
switchTab(kind);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Simpan';
|
||||
}
|
||||
};
|
||||
|
||||
/* --------------------------- Aksi CRUD --------------------------- */
|
||||
function openEdit(kind, id) {
|
||||
const item = store[kind].items.find((i) => i.id === id);
|
||||
if (item) openModal(kind, 'edit', item);
|
||||
}
|
||||
|
||||
async function doDelete(kind, id) {
|
||||
const item = store[kind].items.find((i) => i.id === id);
|
||||
if (!confirm(`Hapus "${item?.nama}"?`)) return;
|
||||
try {
|
||||
await apiDelete(kind, id);
|
||||
removeFeatureLayer(kind, id);
|
||||
await loadAll();
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
|
||||
/* --------------------------- Event Leaflet.draw --------------------------- */
|
||||
map.on(L.Draw.Event.CREATED, (e) => {
|
||||
const layer = e.layer;
|
||||
const kind = e.layerType === 'polygon' ? 'tanah' : 'jalan';
|
||||
pendingLayer = layer;
|
||||
drawnItems.addLayer(layer);
|
||||
const geojson = layer.toGeoJSON().geometry;
|
||||
openModal(kind, 'create', { geojson });
|
||||
});
|
||||
|
||||
// Edit geometri (toolbar edit Leaflet.draw)
|
||||
map.on(L.Draw.Event.EDITED, async (e) => {
|
||||
const jobs = [];
|
||||
e.layers.eachLayer((l) => {
|
||||
if (!l.feature_id) return;
|
||||
const kind = l.kind;
|
||||
const item = store[kind].items.find((i) => i.id === l.feature_id);
|
||||
if (!item) return;
|
||||
const geojson = l.toGeoJSON().geometry;
|
||||
jobs.push(apiSave(kind, {
|
||||
nama: item.nama, deskripsi: item.deskripsi, warna: item.warna,
|
||||
pemilik: item.pemilik, jenis: item.jenis, kategori: item.kategori, geojson,
|
||||
}, item.id));
|
||||
});
|
||||
try { await Promise.all(jobs); await loadAll(); }
|
||||
catch (err) { alert('Gagal menyimpan perubahan geometri: ' + err.message); await loadAll(); }
|
||||
});
|
||||
|
||||
/* --------------------------- Tab --------------------------- */
|
||||
function switchTab(kind) {
|
||||
activeTab = kind;
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => {
|
||||
const on = b.dataset.tab === kind;
|
||||
b.classList.toggle('border-emerald-500', on && kind === 'tanah');
|
||||
b.classList.toggle('border-red-500', on && kind === 'jalan');
|
||||
b.classList.toggle('text-emerald-600', on && kind === 'tanah');
|
||||
b.classList.toggle('text-red-600', on && kind === 'jalan');
|
||||
b.classList.toggle('border-transparent', !on);
|
||||
b.classList.toggle('text-slate-500', !on);
|
||||
});
|
||||
document.getElementById('list-tanah').classList.toggle('hidden', kind !== 'tanah');
|
||||
document.getElementById('list-jalan').classList.toggle('hidden', kind !== 'jalan');
|
||||
}
|
||||
document.querySelectorAll('.tab-btn').forEach((b) => (b.onclick = () => switchTab(b.dataset.tab)));
|
||||
|
||||
/* --------------------------- Pencarian --------------------------- */
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchClear = document.getElementById('search-clear');
|
||||
|
||||
function runSearch() {
|
||||
searchQuery = searchInput.value.trim().toLowerCase();
|
||||
searchClear.classList.toggle('hidden', !searchQuery);
|
||||
renderList('tanah');
|
||||
renderList('jalan');
|
||||
applyMapFilter();
|
||||
}
|
||||
searchInput.addEventListener('input', runSearch);
|
||||
searchClear.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
searchInput.focus();
|
||||
runSearch();
|
||||
});
|
||||
|
||||
/* --------------------------- Util --------------------------- */
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/* --------------------------- Start --------------------------- */
|
||||
loadAll().catch((err) => {
|
||||
console.error(err);
|
||||
alert('Gagal memuat data. Pastikan database sudah dibuat (import schema.sql).');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* Koneksi database (PDO).
|
||||
* Sesuaikan kredensial bila berbeda dari default Laragon.
|
||||
*/
|
||||
|
||||
const DB_HOST = '127.0.0.1';
|
||||
const DB_PORT = '3306';
|
||||
const DB_NAME = 'sig_mapping';
|
||||
const DB_USER = 'root';
|
||||
const DB_PASS = '';
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_PORT, DB_NAME);
|
||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* Handler CRUD generik untuk fitur peta (tanah / jalan).
|
||||
*
|
||||
* $cfg = [
|
||||
* 'table' => nama tabel,
|
||||
* 'fields' => daftar kolom teks yang boleh diisi user (selain geometri/ukuran),
|
||||
* 'measure' => fungsi(array $geometry): array ukuran-ukuran yang dihitung server,
|
||||
* 'geomType' => 'Polygon' | 'LineString' (untuk validasi),
|
||||
* ]
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
require_once __DIR__ . '/geo.php';
|
||||
|
||||
function handle_crud(array $cfg): void
|
||||
{
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type');
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if ($method === 'OPTIONS') {
|
||||
json_response(['ok' => true]);
|
||||
}
|
||||
|
||||
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
$id ? crud_show($cfg, $id) : crud_list($cfg);
|
||||
break;
|
||||
case 'POST':
|
||||
crud_create($cfg);
|
||||
break;
|
||||
case 'PUT':
|
||||
crud_update($cfg, $id);
|
||||
break;
|
||||
case 'DELETE':
|
||||
crud_delete($cfg, $id);
|
||||
break;
|
||||
default:
|
||||
json_response(['error' => 'Method tidak didukung'], 405);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
json_response(['error' => 'Server error: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
function crud_list(array $cfg): void
|
||||
{
|
||||
$rows = db()->query("SELECT * FROM {$cfg['table']} ORDER BY id DESC")->fetchAll();
|
||||
foreach ($rows as &$r) {
|
||||
$r['geojson'] = json_decode($r['geojson'], true);
|
||||
}
|
||||
json_response(['data' => $rows]);
|
||||
}
|
||||
|
||||
function crud_show(array $cfg, int $id): void
|
||||
{
|
||||
$stmt = db()->prepare("SELECT * FROM {$cfg['table']} WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
json_response(['error' => 'Data tidak ditemukan'], 404);
|
||||
}
|
||||
$row['geojson'] = json_decode($row['geojson'], true);
|
||||
json_response(['data' => $row]);
|
||||
}
|
||||
|
||||
/** Validasi input & hitung ukuran. Mengembalikan [values, geojsonString]. */
|
||||
function crud_prepare(array $cfg, array $body): array
|
||||
{
|
||||
$geom = parse_geometry($body['geojson'] ?? null);
|
||||
if ($geom === null) {
|
||||
json_response(['error' => 'Geometri (geojson) tidak valid'], 422);
|
||||
}
|
||||
[$type, $coords] = $geom;
|
||||
if ($type !== $cfg['geomType']) {
|
||||
json_response(['error' => "Geometri harus bertipe {$cfg['geomType']}, diterima {$type}"], 422);
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($cfg['fields'] as $f) {
|
||||
$values[$f] = isset($body[$f]) ? trim((string) $body[$f]) : null;
|
||||
}
|
||||
if (empty($values['nama'])) {
|
||||
json_response(['error' => 'Nama wajib diisi'], 422);
|
||||
}
|
||||
|
||||
// Warna default bila kosong.
|
||||
if (array_key_exists('warna', $values) && empty($values['warna'])) {
|
||||
$values['warna'] = $cfg['geomType'] === 'Polygon' ? '#22c55e' : '#ef4444';
|
||||
}
|
||||
|
||||
// Ukuran dihitung server (otoritatif).
|
||||
$measures = $cfg['measure']($geom);
|
||||
|
||||
$geojsonStr = json_encode(['type' => $type, 'coordinates' => $coords], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return [array_merge($values, $measures), $geojsonStr];
|
||||
}
|
||||
|
||||
function crud_create(array $cfg): void
|
||||
{
|
||||
[$values, $geojsonStr] = crud_prepare($cfg, read_json_body());
|
||||
|
||||
$cols = array_keys($values);
|
||||
$cols[] = 'geojson';
|
||||
$placeholders = implode(', ', array_fill(0, count($cols), '?'));
|
||||
$colList = implode(', ', $cols);
|
||||
|
||||
$params = array_values($values);
|
||||
$params[] = $geojsonStr;
|
||||
|
||||
$stmt = db()->prepare("INSERT INTO {$cfg['table']} ($colList) VALUES ($placeholders)");
|
||||
$stmt->execute($params);
|
||||
|
||||
crud_show($cfg, (int) db()->lastInsertId());
|
||||
}
|
||||
|
||||
function crud_update(array $cfg, int $id): void
|
||||
{
|
||||
if (!$id) {
|
||||
json_response(['error' => 'ID tidak valid'], 400);
|
||||
}
|
||||
[$values, $geojsonStr] = crud_prepare($cfg, read_json_body());
|
||||
|
||||
$sets = [];
|
||||
foreach (array_keys($values) as $c) {
|
||||
$sets[] = "$c = ?";
|
||||
}
|
||||
$sets[] = 'geojson = ?';
|
||||
|
||||
$params = array_values($values);
|
||||
$params[] = $geojsonStr;
|
||||
$params[] = $id;
|
||||
|
||||
$stmt = db()->prepare("UPDATE {$cfg['table']} SET " . implode(', ', $sets) . " WHERE id = ?");
|
||||
$stmt->execute($params);
|
||||
|
||||
if ($stmt->rowCount() === 0) {
|
||||
// Tetap kembalikan data terkini (mungkin tidak ada perubahan nilai).
|
||||
$check = db()->prepare("SELECT id FROM {$cfg['table']} WHERE id = ?");
|
||||
$check->execute([$id]);
|
||||
if (!$check->fetch()) {
|
||||
json_response(['error' => 'Data tidak ditemukan'], 404);
|
||||
}
|
||||
}
|
||||
crud_show($cfg, $id);
|
||||
}
|
||||
|
||||
function crud_delete(array $cfg, int $id): void
|
||||
{
|
||||
if (!$id) {
|
||||
json_response(['error' => 'ID tidak valid'], 400);
|
||||
}
|
||||
$stmt = db()->prepare("DELETE FROM {$cfg['table']} WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
if ($stmt->rowCount() === 0) {
|
||||
json_response(['error' => 'Data tidak ditemukan'], 404);
|
||||
}
|
||||
json_response(['ok' => true, 'deleted' => $id]);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* Helper geometri & respons JSON.
|
||||
*
|
||||
* Perhitungan dilakukan di sisi server agar otoritatif:
|
||||
* - Panjang LineString -> haversine (meter)
|
||||
* - Luas / keliling Polygon -> rumus area geodesik bola (meter & m2)
|
||||
*
|
||||
* Koordinat mengikuti format GeoJSON: [longitude, latitude].
|
||||
*/
|
||||
|
||||
const EARTH_RADIUS = 6378137.0; // radius WGS84 (meter)
|
||||
|
||||
/** Kirim respons JSON lalu hentikan eksekusi. */
|
||||
function json_response($data, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Ambil & decode body JSON dari request. */
|
||||
function read_json_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/** Jarak haversine antar dua titik [lng, lat] dalam meter. */
|
||||
function haversine(array $a, array $b): float
|
||||
{
|
||||
$lon1 = deg2rad($a[0]);
|
||||
$lat1 = deg2rad($a[1]);
|
||||
$lon2 = deg2rad($b[0]);
|
||||
$lat2 = deg2rad($b[1]);
|
||||
|
||||
$dLat = $lat2 - $lat1;
|
||||
$dLon = $lon2 - $lon1;
|
||||
|
||||
$h = sin($dLat / 2) ** 2
|
||||
+ cos($lat1) * cos($lat2) * sin($dLon / 2) ** 2;
|
||||
|
||||
return 2 * EARTH_RADIUS * asin(min(1.0, sqrt($h)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Panjang total sebuah LineString (array of [lng, lat]) dalam meter.
|
||||
*/
|
||||
function line_length(array $coords): float
|
||||
{
|
||||
$total = 0.0;
|
||||
$n = count($coords);
|
||||
for ($i = 1; $i < $n; $i++) {
|
||||
$total += haversine($coords[$i - 1], $coords[$i]);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Luas polygon geodesik (ring tunggal, array of [lng, lat]) dalam meter persegi.
|
||||
* Memakai pendekatan integral bola (mirip Google Maps computeSignedArea).
|
||||
*/
|
||||
function ring_area(array $ring): float
|
||||
{
|
||||
$n = count($ring);
|
||||
if ($n < 3) {
|
||||
return 0.0;
|
||||
}
|
||||
$area = 0.0;
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$p1 = $ring[$i];
|
||||
$p2 = $ring[($i + 1) % $n];
|
||||
$area += deg2rad($p2[0] - $p1[0])
|
||||
* (2 + sin(deg2rad($p1[1])) + sin(deg2rad($p2[1])));
|
||||
}
|
||||
$area = $area * EARTH_RADIUS * EARTH_RADIUS / 2.0;
|
||||
return abs($area);
|
||||
}
|
||||
|
||||
/** Keliling polygon (ring tunggal) dalam meter. */
|
||||
function ring_perimeter(array $ring): float
|
||||
{
|
||||
if (count($ring) < 2) {
|
||||
return 0.0;
|
||||
}
|
||||
// Pastikan ring tertutup untuk perhitungan keliling.
|
||||
$closed = $ring;
|
||||
if ($closed[0] !== $closed[count($closed) - 1]) {
|
||||
$closed[] = $closed[0];
|
||||
}
|
||||
return line_length($closed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validasi & ekstrak geometry GeoJSON.
|
||||
* Mengembalikan [type, coordinates] atau null bila tidak valid.
|
||||
*/
|
||||
function parse_geometry($geojson): ?array
|
||||
{
|
||||
if (is_string($geojson)) {
|
||||
$geojson = json_decode($geojson, true);
|
||||
}
|
||||
if (!is_array($geojson) || empty($geojson['type']) || !isset($geojson['coordinates'])) {
|
||||
return null;
|
||||
}
|
||||
return [$geojson['type'], $geojson['coordinates']];
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SIG — Mapping Tanah & Jalan</title>
|
||||
|
||||
<!-- TailwindCSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Leaflet.draw (point & click drawing) -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
|
||||
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||||
|
||||
<style>
|
||||
#map { height: 100%; width: 100%; }
|
||||
.leaflet-draw-toolbar a { background-color: #fff; }
|
||||
/* Tooltip pengukuran saat menggambar */
|
||||
.leaflet-tooltip.measure-tip {
|
||||
background: #111827; color: #fff; border: 0; font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden bg-slate-100 text-slate-800">
|
||||
|
||||
<div class="flex h-full">
|
||||
|
||||
<!-- ============ SIDEBAR ============ -->
|
||||
<aside class="w-96 shrink-0 bg-white border-r border-slate-200 flex flex-col shadow-lg z-[1000]">
|
||||
<header class="px-5 py-4 bg-slate-900 text-white">
|
||||
<h1 class="text-lg font-bold flex items-center gap-2">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-emerald-400"></span>
|
||||
SIG Mapping
|
||||
</h1>
|
||||
<p class="text-xs text-slate-300 mt-0.5">Pemetaan Tanah & Jalan — point & click</p>
|
||||
</header>
|
||||
|
||||
<!-- Tombol Tambah -->
|
||||
<div class="px-4 py-3 border-b border-slate-200">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button id="add-tanah" type="button"
|
||||
class="flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm transition">
|
||||
<span class="text-base leading-none">+</span> Tambah Tanah
|
||||
</button>
|
||||
<button id="add-jalan" type="button"
|
||||
class="flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-red-600 hover:bg-red-700 active:bg-red-800 text-white text-sm font-semibold shadow-sm transition">
|
||||
<span class="text-base leading-none">+</span> Tambah Jalan
|
||||
</button>
|
||||
</div>
|
||||
<p id="draw-hint" class="mt-2 text-xs text-slate-500 text-center">
|
||||
Klik tombol lalu gambar di peta. <span class="text-slate-400">Luas & panjang otomatis.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pencarian -->
|
||||
<div class="px-4 py-3 border-b border-slate-200">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm">🔍</span>
|
||||
<input id="search" type="text" placeholder="Cari nama, pemilik, jenis…"
|
||||
class="w-full pl-9 pr-8 py-2 text-sm rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 outline-none">
|
||||
<button id="search-clear" class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab -->
|
||||
<div class="flex border-b border-slate-200 text-sm font-medium">
|
||||
<button data-tab="tanah" class="tab-btn flex-1 py-2.5 border-b-2 border-emerald-500 text-emerald-600">
|
||||
🟩 Tanah <span id="count-tanah" class="ml-1 text-xs bg-emerald-100 text-emerald-700 rounded-full px-1.5">0</span>
|
||||
</button>
|
||||
<button data-tab="jalan" class="tab-btn flex-1 py-2.5 border-b-2 border-transparent text-slate-500 hover:text-slate-700">
|
||||
🟥 Jalan <span id="count-jalan" class="ml-1 text-xs bg-red-100 text-red-700 rounded-full px-1.5">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Daftar -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ul id="list-tanah" class="tab-panel divide-y divide-slate-100"></ul>
|
||||
<ul id="list-jalan" class="tab-panel hidden divide-y divide-slate-100"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Ringkasan -->
|
||||
<footer class="px-5 py-3 border-t border-slate-200 bg-slate-50 text-xs text-slate-600 space-y-1">
|
||||
<div class="flex justify-between"><span>Total luas tanah</span><b id="sum-luas">0 m²</b></div>
|
||||
<div class="flex justify-between"><span>Total panjang jalan</span><b id="sum-panjang">0 m</b></div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- ============ PETA ============ -->
|
||||
<main class="relative flex-1">
|
||||
<div id="map"></div>
|
||||
<!-- badge pengukuran live -->
|
||||
<div id="live-measure"
|
||||
class="hidden absolute top-3 left-1/2 -translate-x-1/2 z-[1000] bg-slate-900 text-white text-sm font-semibold px-4 py-2 rounded-lg shadow-lg">
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============ MODAL FORM ============ -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-[2000] flex items-center justify-center bg-black/40 p-4">
|
||||
<div class="bg-white w-full max-w-md rounded-xl shadow-2xl overflow-hidden">
|
||||
<div id="modal-head" class="px-5 py-4 text-white font-semibold flex items-center justify-between">
|
||||
<span id="modal-title">Simpan Data</span>
|
||||
<button id="modal-close" class="text-white/80 hover:text-white text-xl leading-none">×</button>
|
||||
</div>
|
||||
<form id="form" class="p-5 space-y-4">
|
||||
<input type="hidden" id="f-id">
|
||||
<input type="hidden" id="f-kind">
|
||||
<input type="hidden" id="f-geojson">
|
||||
|
||||
<!-- Ukuran otomatis -->
|
||||
<div id="f-measure" class="rounded-lg bg-slate-50 border border-slate-200 p-3 text-sm grid grid-cols-2 gap-2"></div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Nama <span class="text-red-500">*</span></label>
|
||||
<input id="f-nama" required class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 outline-none">
|
||||
</div>
|
||||
|
||||
<!-- field khusus tanah -->
|
||||
<div class="kind-tanah">
|
||||
<label class="block text-sm font-medium mb-1">Pemilik</label>
|
||||
<input id="f-pemilik" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 outline-none">
|
||||
</div>
|
||||
<div class="kind-tanah">
|
||||
<label class="block text-sm font-medium mb-1">Kategori / Status Tanah</label>
|
||||
<select id="f-kategori-tanah" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 outline-none">
|
||||
<option value="">- pilih -</option>
|
||||
<option value="Sertifikat Hak Milik (SHM)">Sertifikat Hak Milik (SHM)</option>
|
||||
<option value="Sertifikat Hak Guna Bangunan (HGB)">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||
<option value="Sertifikat Hak Guna Usaha (HGU)">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||
<option value="Sertifikat Hak Pakai (HP)">Sertifikat Hak Pakai (HP)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- field khusus jalan -->
|
||||
<div class="kind-jalan hidden">
|
||||
<label class="block text-sm font-medium mb-1">Kategori Jalan</label>
|
||||
<select id="f-kategori-jalan" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-red-400 outline-none">
|
||||
<option value="">- pilih -</option>
|
||||
<option value="Jalan Nasional">Jalan Nasional</option>
|
||||
<option value="Jalan Provinsi">Jalan Provinsi</option>
|
||||
<option value="Jalan Kabupaten">Jalan Kabupaten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kind-jalan hidden">
|
||||
<label class="block text-sm font-medium mb-1">Jenis Jalan</label>
|
||||
<select id="f-jenis" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-red-400 outline-none">
|
||||
<option value="">- pilih -</option>
|
||||
<option>Aspal</option>
|
||||
<option>Beton / Cor</option>
|
||||
<option>Paving</option>
|
||||
<option>Tanah / Kerikil</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Deskripsi</label>
|
||||
<textarea id="f-deskripsi" rows="2" class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-400 outline-none"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Warna</label>
|
||||
<input id="f-warna" type="color" value="#22c55e" class="h-9 w-16 rounded border border-slate-300 cursor-pointer">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button type="button" id="modal-cancel" class="flex-1 py-2 rounded-lg border border-slate-300 text-slate-600 hover:bg-slate-50 text-sm font-medium">Batal</button>
|
||||
<button type="submit" id="modal-save" class="flex-1 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Simpan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/app.js?v=<?php echo filemtime(__DIR__ . '/assets/app.js'); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ============================================================
|
||||
-- SIG Mapping Tanah & Jalan - Skema Database
|
||||
-- MySQL 8.x
|
||||
-- ============================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS sig_mapping
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE sig_mapping;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tabel TANAH (disimpan sebagai polygon)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tanah (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
pemilik VARCHAR(150) NULL,
|
||||
kategori VARCHAR(80) NULL, -- status: SHM, HGB, HGU, HP
|
||||
deskripsi TEXT NULL,
|
||||
luas DOUBLE NOT NULL DEFAULT 0, -- meter persegi (m2)
|
||||
keliling DOUBLE NOT NULL DEFAULT 0, -- meter (m)
|
||||
warna VARCHAR(20) NOT NULL DEFAULT '#22c55e',
|
||||
geojson JSON NOT NULL, -- GeoJSON geometry (Polygon)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tabel JALAN (disimpan sebagai polyline / linestring)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS jalan (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
jenis VARCHAR(50) NULL, -- mis. Aspal, Beton, Tanah
|
||||
kategori VARCHAR(80) NULL, -- Jalan Nasional, Jalan Provinsi, Jalan Kabupaten
|
||||
deskripsi TEXT NULL,
|
||||
panjang DOUBLE NOT NULL DEFAULT 0, -- meter (m)
|
||||
warna VARCHAR(20) NOT NULL DEFAULT '#ef4444',
|
||||
geojson JSON NOT NULL, -- GeoJSON geometry (LineString)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Migrasi: tambah kolom kategori bila tabel lama belum punya.
|
||||
-- MySQL 8 tidak mendukung "ADD COLUMN IF NOT EXISTS", jadi dicek dulu
|
||||
-- via information_schema. Aman dijalankan berulang.
|
||||
-- ------------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS add_kategori_columns;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE add_kategori_columns()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tanah' AND COLUMN_NAME = 'kategori'
|
||||
) THEN
|
||||
ALTER TABLE tanah ADD COLUMN kategori VARCHAR(80) NULL AFTER pemilik;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'jalan' AND COLUMN_NAME = 'kategori'
|
||||
) THEN
|
||||
ALTER TABLE jalan ADD COLUMN kategori VARCHAR(80) NULL AFTER jenis;
|
||||
END IF;
|
||||
END //
|
||||
DELIMITER ;
|
||||
CALL add_kategori_columns();
|
||||
DROP PROCEDURE add_kategori_columns;
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
/**
|
||||
* Seeder data contoh — area Pontianak, Kalimantan Barat.
|
||||
* Jalankan: php seed.php (atau buka http://localhost/sig-02/seed.php)
|
||||
*
|
||||
* Menghitung luas/keliling/panjang memakai fungsi yang sama dengan aplikasi
|
||||
* agar konsisten, lalu insert ke database.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/includes/geo.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
// Bersihkan data lama (opsional). Komentari bila ingin menambah saja.
|
||||
$pdo->exec('DELETE FROM tanah');
|
||||
$pdo->exec('DELETE FROM jalan');
|
||||
$pdo->exec('ALTER TABLE tanah AUTO_INCREMENT = 1');
|
||||
$pdo->exec('ALTER TABLE jalan AUTO_INCREMENT = 1');
|
||||
|
||||
/* ============================ TANAH (Polygon) ============================
|
||||
* Koordinat GeoJSON: [longitude, latitude]
|
||||
*/
|
||||
$tanah = [
|
||||
[
|
||||
'nama' => 'Kawasan Tugu Khatulistiwa',
|
||||
'pemilik' => 'Pemkot Pontianak',
|
||||
'kategori' => 'Sertifikat Hak Pakai (HP)',
|
||||
'deskripsi' => 'Area landmark Tugu Khatulistiwa, Pontianak Utara.',
|
||||
'warna' => '#16a34a',
|
||||
'ring' => [
|
||||
[109.3215, -0.0005], [109.3232, -0.0005],
|
||||
[109.3232, -0.0022], [109.3215, -0.0022], [109.3215, -0.0005],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Lahan Alun-Alun Kapuas',
|
||||
'pemilik' => 'Pemkot Pontianak',
|
||||
'kategori' => 'Sertifikat Hak Pakai (HP)',
|
||||
'deskripsi' => 'Ruang terbuka di tepi Sungai Kapuas, dekat Pelabuhan.',
|
||||
'warna' => '#22c55e',
|
||||
'ring' => [
|
||||
[109.3438, -0.0265], [109.3452, -0.0263],
|
||||
[109.3454, -0.0276], [109.3440, -0.0279], [109.3438, -0.0265],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Blok Permukiman Sungai Jawi',
|
||||
'pemilik' => 'Warga RW 04',
|
||||
'kategori' => 'Sertifikat Hak Milik (SHM)',
|
||||
'deskripsi' => 'Petak permukiman padat di Kecamatan Pontianak Kota.',
|
||||
'warna' => '#15803d',
|
||||
'ring' => [
|
||||
[109.3290, -0.0360], [109.3312, -0.0358],
|
||||
[109.3314, -0.0378], [109.3292, -0.0380], [109.3290, -0.0360],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Kawasan Komersial Jl. Gajah Mada',
|
||||
'pemilik' => 'Swasta',
|
||||
'kategori' => 'Sertifikat Hak Guna Bangunan (HGB)',
|
||||
'deskripsi' => 'Blok pertokoan / ruko di koridor Jalan Gajah Mada.',
|
||||
'warna' => '#65a30d',
|
||||
'ring' => [
|
||||
[109.3360, -0.0310], [109.3378, -0.0312],
|
||||
[109.3376, -0.0328], [109.3358, -0.0326], [109.3360, -0.0310],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/* ============================ JALAN (LineString) ============================ */
|
||||
$jalan = [
|
||||
[
|
||||
'nama' => 'Jl. Ahmad Yani',
|
||||
'jenis' => 'Aspal',
|
||||
'kategori' => 'Jalan Nasional',
|
||||
'deskripsi' => 'Jalan arteri utama Kota Pontianak.',
|
||||
'warna' => '#ef4444',
|
||||
'coords' => [
|
||||
[109.3260, -0.0540], [109.3300, -0.0470],
|
||||
[109.3345, -0.0400], [109.3390, -0.0330],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Jl. Gajah Mada',
|
||||
'jenis' => 'Aspal',
|
||||
'kategori' => 'Jalan Kabupaten',
|
||||
'deskripsi' => 'Koridor pusat kuliner & perdagangan.',
|
||||
'warna' => '#dc2626',
|
||||
'coords' => [
|
||||
[109.3352, -0.0300], [109.3372, -0.0335], [109.3390, -0.0368],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Jl. Tanjungpura',
|
||||
'jenis' => 'Aspal',
|
||||
'kategori' => 'Jalan Provinsi',
|
||||
'deskripsi' => 'Menghubungkan pusat kota ke kawasan Kapuas.',
|
||||
'warna' => '#f97316',
|
||||
'coords' => [
|
||||
[109.3400, -0.0270], [109.3415, -0.0300], [109.3428, -0.0330],
|
||||
],
|
||||
],
|
||||
[
|
||||
'nama' => 'Jl. Sultan Abdurrahman',
|
||||
'jenis' => 'Beton / Cor',
|
||||
'kategori' => 'Jalan Kabupaten',
|
||||
'deskripsi' => 'Akses menuju kawasan Sungai Jawi.',
|
||||
'warna' => '#b91c1c',
|
||||
'coords' => [
|
||||
[109.3280, -0.0350], [109.3320, -0.0345], [109.3360, -0.0342],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/* ============================ INSERT ============================ */
|
||||
$stmtT = $pdo->prepare(
|
||||
'INSERT INTO tanah (nama, pemilik, kategori, deskripsi, luas, keliling, warna, geojson)
|
||||
VALUES (?,?,?,?,?,?,?,?)'
|
||||
);
|
||||
foreach ($tanah as $t) {
|
||||
$geo = ['type' => 'Polygon', 'coordinates' => [$t['ring']]];
|
||||
$stmtT->execute([
|
||||
$t['nama'], $t['pemilik'], $t['kategori'], $t['deskripsi'],
|
||||
round(ring_area($t['ring']), 2),
|
||||
round(ring_perimeter($t['ring']), 2),
|
||||
$t['warna'],
|
||||
json_encode($geo, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
$stmtJ = $pdo->prepare(
|
||||
'INSERT INTO jalan (nama, jenis, kategori, deskripsi, panjang, warna, geojson)
|
||||
VALUES (?,?,?,?,?,?,?)'
|
||||
);
|
||||
foreach ($jalan as $j) {
|
||||
$geo = ['type' => 'LineString', 'coordinates' => $j['coords']];
|
||||
$stmtJ->execute([
|
||||
$j['nama'], $j['jenis'], $j['kategori'], $j['deskripsi'],
|
||||
round(line_length($j['coords']), 2),
|
||||
$j['warna'],
|
||||
json_encode($geo, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
|
||||
$msg = sprintf("Seeding selesai: %d tanah, %d jalan (area Pontianak).", count($tanah), count($jalan));
|
||||
|
||||
if (PHP_SAPI === 'cli') {
|
||||
echo $msg . PHP_EOL;
|
||||
} else {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $msg . "\nBuka kembali index.php untuk melihat hasilnya.";
|
||||
}
|
||||
Reference in New Issue
Block a user