First Commit

This commit is contained in:
Araya's Project
2026-06-10 12:12:51 +07:00
commit 6462f2e396
34 changed files with 5884 additions and 0 deletions
+51
View File
@@ -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).
+15
View File
@@ -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),
];
},
]);
+17
View File
@@ -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),
];
},
]);
+477
View File
@@ -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: '&copy; 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 &amp; 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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/* --------------------------- Start --------------------------- */
loadAll().catch((err) => {
console.error(err);
alert('Gagal memuat data. Pastikan database sudah dibuat (import schema.sql).');
});
+25
View File
@@ -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;
}
+165
View File
@@ -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]);
}
+109
View File
@@ -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']];
}
+180
View File
@@ -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 &mdash; Mapping Tanah &amp; 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 &amp; Jalan &mdash; point &amp; 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 &amp; 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">&times;</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 </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">&times;</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>
+69
View File
@@ -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
View File
@@ -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.";
}