commit 5cf0b01a36289f1706ff6019c3517dd5f067f3d6 Author: GuavaPopper Date: Thu Jun 11 02:42:37 2026 +0700 Add WebGIS project documentation diff --git a/.env b/.env new file mode 100644 index 0000000..98ad933 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +DB_HOST=127.0.0.1 +DB_NAME=webgis_jalan +DB_USER=root +DB_PASS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89d3f92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +uploads/* +!uploads/.gitkeep diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2a9c3c --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# WebGIS Jalan dan Jalan Rusak + +Aplikasi WebGIS berbasis PHP, MySQL, dan Leaflet untuk mengelola data jalan, parsil tanah, serta titik kerusakan jalan. Aplikasi ini menyediakan peta interaktif untuk menggambar geometri, menyimpan data spasial dalam format GeoJSON, melihat dashboard data, dan melaporkan titik kerusakan melalui foto. + +## Fitur + +- Peta interaktif berbasis Leaflet dan Google Maps tile. +- Input data jalan dalam bentuk polyline dengan perhitungan panjang otomatis. +- Input data parsil tanah dalam bentuk polygon dengan perhitungan luas otomatis. +- Input titik kerusakan jalan dalam bentuk marker dengan unggahan foto. +- Lapor titik kerusakan otomatis dari foto yang memiliki metadata GPS EXIF. +- Edit geometri jalan dan parsil langsung dari peta. +- Dashboard ringkasan jumlah data jalan, parsil, dan titik kerusakan. +- Tabel data dengan pencarian dan pratinjau lokasi pada minimap. +- API sederhana untuk operasi CRUD data. + +## Teknologi + +- PHP +- MySQL / MariaDB +- PDO +- HTML, CSS, JavaScript +- Leaflet +- Leaflet Draw +- Turf.js +- EXIF.js +- Font Awesome + +## Kebutuhan Sistem + +- PHP 7.4 atau lebih baru +- MySQL atau MariaDB +- Web server lokal seperti Laragon, XAMPP, atau Apache/Nginx dengan PHP +- Browser modern +- Koneksi internet untuk memuat library CDN dan tile peta + +## Instalasi + +1. Clone repository ini ke folder web server lokal. + + ```bash + git clone https://github.com/GuavaPopper/WebGIS_Jalan-JalanRusak.git + ``` + +2. Masuk ke folder project. + + ```bash + cd WebGIS_Jalan-JalanRusak + ``` + +3. Buat database dan tabel dari file `schema.sql`. + + ```bash + mysql -u root -p < schema.sql + ``` + + Jika memakai Laragon atau phpMyAdmin, import file `schema.sql` secara manual ke MySQL/MariaDB. + +4. Buat file `.env` di root project. + + ```env + DB_HOST=127.0.0.1 + DB_NAME=webgis_jalan + DB_USER=root + DB_PASS= + ``` + +5. Pastikan folder `uploads/` tersedia dan dapat ditulis oleh web server. + +6. Jalankan project melalui web server lokal, misalnya: + + ```text + http://localhost/WebgisJalan/ + ``` + + Sesuaikan URL dengan nama folder dan konfigurasi virtual host yang digunakan. + +## Struktur Project + +```text +. +|-- api/ +| |-- jalan.php +| |-- parsil.php +| `-- titik.php +|-- assets/ +| |-- css/ +| | `-- style.css +| `-- js/ +| `-- app.js +|-- uploads/ +|-- config.php +|-- index.php +|-- schema.sql +`-- README.md +``` + +## Konfigurasi Database + +File `config.php` membaca konfigurasi dari `.env`, lalu membuat koneksi database dengan PDO. + +Variabel yang digunakan: + +| Variabel | Keterangan | Default | +| --- | --- | --- | +| `DB_HOST` | Host database | `127.0.0.1` | +| `DB_NAME` | Nama database | `webgis_jalan` | +| `DB_USER` | Username database | `root` | +| `DB_PASS` | Password database | kosong | + +## Endpoint API + +### Jalan + +Endpoint: `api/jalan.php` + +| Method | Fungsi | +| --- | --- | +| `GET` | Mengambil semua data jalan | +| `POST` | Menambahkan data jalan | +| `PUT` | Memperbarui data jalan | +| `DELETE` | Menghapus data jalan | + +### Parsil + +Endpoint: `api/parsil.php` + +| Method | Fungsi | +| --- | --- | +| `GET` | Mengambil semua data parsil | +| `POST` | Menambahkan data parsil | +| `PUT` | Memperbarui data parsil | +| `DELETE` | Menghapus data parsil | + +### Titik Kerusakan + +Endpoint: `api/titik.php` + +| Method | Fungsi | +| --- | --- | +| `GET` | Mengambil semua titik kerusakan | +| `POST` | Menambahkan atau memperbarui titik kerusakan dengan foto | +| `PUT` | Memperbarui GeoJSON titik kerusakan | +| `DELETE` | Menghapus titik kerusakan dan file foto terkait | + +## Cara Penggunaan + +1. Buka halaman utama aplikasi. +2. Gunakan toolbar gambar di sisi kiri peta. +3. Pilih bentuk sesuai data: + - Polyline untuk data jalan. + - Polygon untuk data parsil tanah. + - Marker untuk titik kerusakan jalan. +4. Isi form yang muncul, lalu simpan data. +5. Buka tab `Lihat Data` untuk melihat ringkasan dan tabel data. +6. Klik baris pada tabel untuk melihat lokasi pada minimap. +7. Gunakan tombol laporan via foto EXIF jika foto memiliki metadata GPS. + +## Catatan + +- File `.env` sebaiknya tidak diunggah ke repository publik karena berisi konfigurasi lokal. +- File foto laporan disimpan di folder `uploads/`. +- Perhitungan panjang dan luas dilakukan di sisi frontend menggunakan Turf.js. +- Aplikasi membutuhkan koneksi internet untuk memuat CDN Leaflet, Font Awesome, Turf.js, EXIF.js, dan tile peta. + diff --git a/api/jalan.php b/api/jalan.php new file mode 100644 index 0000000..7e05444 --- /dev/null +++ b/api/jalan.php @@ -0,0 +1,35 @@ +query("SELECT * FROM jalan"); + echo json_encode($stmt->fetchAll()); +} elseif ($method === 'POST') { + $data = json_decode(file_get_contents("php://input"), true); + $stmt = $pdo->prepare("INSERT INTO jalan (nama_jalan, status, panjang, geojson) VALUES (?, ?, ?, ?)"); + if ($stmt->execute([$data['nama_jalan'], $data['status'], $data['panjang'], $data['geojson']])) { + echo json_encode(["status" => "success", "id" => $pdo->lastInsertId()]); + } else { + echo json_encode(["status" => "error", "message" => "Failed to add data"]); + } +} elseif ($method === 'PUT') { + $data = json_decode(file_get_contents("php://input"), true); + $stmt = $pdo->prepare("UPDATE jalan SET nama_jalan = ?, status = ?, panjang = ?, geojson = ? WHERE id = ?"); + if ($stmt->execute([$data['nama_jalan'], $data['status'], $data['panjang'], $data['geojson'], $data['id']])) { + echo json_encode(["status" => "success"]); + } else { + echo json_encode(["status" => "error", "message" => "Failed to update data"]); + } +} elseif ($method === 'DELETE') { + $data = json_decode(file_get_contents("php://input"), true); + $stmt = $pdo->prepare("DELETE FROM jalan WHERE id = ?"); + if ($stmt->execute([$data['id']])) { + echo json_encode(["status" => "success"]); + } else { + echo json_encode(["status" => "error", "message" => "Failed to delete data"]); + } +} +?> diff --git a/api/parsil.php b/api/parsil.php new file mode 100644 index 0000000..4499343 --- /dev/null +++ b/api/parsil.php @@ -0,0 +1,35 @@ +query("SELECT * FROM parsil"); + echo json_encode($stmt->fetchAll()); +} elseif ($method === 'POST') { + $data = json_decode(file_get_contents("php://input"), true); + $stmt = $pdo->prepare("INSERT INTO parsil (nama_pemilik, status, luas, geojson) VALUES (?, ?, ?, ?)"); + if ($stmt->execute([$data['nama_pemilik'], $data['status'], $data['luas'], $data['geojson']])) { + echo json_encode(["status" => "success", "id" => $pdo->lastInsertId()]); + } else { + echo json_encode(["status" => "error", "message" => "Failed to add data"]); + } +} elseif ($method === 'PUT') { + $data = json_decode(file_get_contents("php://input"), true); + $stmt = $pdo->prepare("UPDATE parsil SET nama_pemilik = ?, status = ?, luas = ?, geojson = ? WHERE id = ?"); + if ($stmt->execute([$data['nama_pemilik'], $data['status'], $data['luas'], $data['geojson'], $data['id']])) { + echo json_encode(["status" => "success"]); + } else { + echo json_encode(["status" => "error", "message" => "Failed to update data"]); + } +} elseif ($method === 'DELETE') { + $data = json_decode(file_get_contents("php://input"), true); + $stmt = $pdo->prepare("DELETE FROM parsil WHERE id = ?"); + if ($stmt->execute([$data['id']])) { + echo json_encode(["status" => "success"]); + } else { + echo json_encode(["status" => "error", "message" => "Failed to delete data"]); + } +} +?> diff --git a/api/titik.php b/api/titik.php new file mode 100644 index 0000000..9ef892d --- /dev/null +++ b/api/titik.php @@ -0,0 +1,78 @@ +prepare("SELECT foto_url FROM titik_rusak WHERE id = ?"); + $stmt->execute([$data['id']]); + $foto = $stmt->fetchColumn(); + if ($foto && file_exists('../' . $foto)) { + unlink('../' . $foto); + } + + $stmt = $pdo->prepare("DELETE FROM titik_rusak WHERE id = ?"); + echo json_encode(['success' => $stmt->execute([$data['id']])]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $stmt = $pdo->query("SELECT * FROM titik_rusak ORDER BY id DESC"); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'PUT') { + // Digunakan untuk memperbarui GeoJSON secara otomatis via Drag Handle Leaflet + $data = json_decode(file_get_contents('php://input'), true); + if (isset($data['id']) && isset($data['geojson'])) { + $stmt = $pdo->prepare("UPDATE titik_rusak SET geojson = ? WHERE id = ?"); + echo json_encode(['success' => $stmt->execute([$data['geojson'], $data['id']])]); + } + exit; +} + +// POST digunakan untuk Create dan Update (jika form dikirim dengan file) +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $keterangan = $_POST['keterangan'] ?? ''; + $geojson = $_POST['geojson'] ?? ''; + $id = $_POST['id'] ?? null; + $existing_foto = $_POST['existing_foto'] ?? ''; + + $foto_url = $existing_foto; + + if (isset($_FILES['foto']) && $_FILES['foto']['error'] === 0) { + if ($id) { // Hapus foto lama jika ada + $stmt = $pdo->prepare("SELECT foto_url FROM titik_rusak WHERE id = ?"); + $stmt->execute([$id]); + $oldFoto = $stmt->fetchColumn(); + if ($oldFoto && file_exists('../' . $oldFoto)) unlink('../' . $oldFoto); + } + + $uploadDir = '../uploads/'; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0777, true); + + $ext = pathinfo($_FILES['foto']['name'], PATHINFO_EXTENSION); + $fileName = time() . '_' . rand(1000, 9999) . '.' . $ext; + $targetFile = $uploadDir . $fileName; + + if (move_uploaded_file($_FILES['foto']['tmp_name'], $targetFile)) { + $foto_url = 'uploads/' . $fileName; + } + } + + if ($id) { + $stmt = $pdo->prepare("UPDATE titik_rusak SET keterangan = ?, foto_url = ?, geojson = ? WHERE id = ?"); + $stmt->execute([$keterangan, $foto_url, $geojson, $id]); + } else { + $stmt = $pdo->prepare("INSERT INTO titik_rusak (keterangan, foto_url, geojson) VALUES (?, ?, ?)"); + $stmt->execute([$keterangan, $foto_url, $geojson]); + } + + echo json_encode(['success' => true]); + exit; +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..3b22e6a --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,436 @@ +:root { + --primary: #4f46e5; + --primary-hover: #4338ca; + --bg-light: #f3f4f6; + --bg-white: #ffffff; + --text-main: #1f2937; + --text-muted: #6b7280; + --border: #e5e7eb; + + --jalan-nasional: #ef4444; /* Red */ + --jalan-provinsi: #f97316; /* Orange */ + --jalan-kabupaten: #eab308; /* Yellow */ + --parsil-shm: #22c55e; + --parsil-hgb: #3b82f6; + --parsil-hgu: #a855f7; + --parsil-hp: #06b6d4; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Inter', sans-serif; +} + +body { + background-color: var(--bg-light); + color: var(--text-main); + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* NAVBAR */ +.navbar { + background: var(--bg-white); + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 30px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + z-index: 1000; +} + +.nav-brand { + font-size: 20px; + font-weight: 700; + color: var(--primary); + display: flex; + align-items: center; + gap: 10px; +} + +.nav-menu { + display: flex; + gap: 10px; +} + +.nav-btn { + border: none; + background: transparent; + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + color: var(--text-muted); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.nav-btn:hover { + background: var(--bg-light); + color: var(--text-main); +} + +.nav-btn.active { + background: #eef2ff; + color: var(--primary); +} + +/* VIEW CONTROLLER */ +main { + flex: 1; + overflow-y: auto; + position: relative; +} + +.view-hidden { + display: none !important; +} + +.view-active { + display: block; +} + +/* VIEW 1: MAP INPUT */ +#view-input #map { + width: 100%; + height: 100%; + z-index: 1; +} + +.floating-info { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.95); + padding: 12px 24px; + border-radius: 50px; + box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); + font-size: 13px; + font-weight: 500; + z-index: 999; + pointer-events: none; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-main); +} +.floating-info i { color: var(--primary); } + +.floating-legend { + position: absolute; + bottom: 80px; + right: 20px; + background: white; + padding: 15px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 999; + font-size: 12px; + min-width: 180px; +} +.floating-legend h4 { margin-bottom: 10px; color: var(--text-main); font-size: 13px;} +.legend-grid div { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; color: var(--text-muted);} +.legend-grid span { display: inline-block; width: 14px; height: 14px; border-radius: 3px; } + +/* VIEW 2: DASHBOARD */ +.dashboard-container { + max-width: 1200px; + margin: 0 auto; + padding: 30px; +} + +.dashboard-header { + margin-bottom: 30px; +} +.dashboard-header h2 { font-size: 24px; color: var(--text-main); margin-bottom: 5px; } +.dashboard-header p { color: var(--text-muted); font-size: 14px; } + +.stats-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + background: var(--bg-white); + padding: 20px 25px; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--border); +} + +.stat-info h3 { font-size: 14px; color: var(--text-muted); font-weight: 500; margin-bottom: 5px; } +.stat-info h2 { font-size: 32px; color: var(--text-main); } +.stat-icon { width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; } + +.content-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; + align-items: start; +} + +/* Table Area */ +.table-card { + background: var(--bg-white); + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + border: 1px solid var(--border); + overflow: hidden; +} + +.table-header { + padding: 20px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.table-tabs { + display: flex; + gap: 10px; +} + +.tab-btn { + padding: 8px 16px; + border: 1px solid var(--border); + background: var(--bg-white); + border-radius: 8px; + font-size: 14px; + cursor: pointer; + font-weight: 500; + color: var(--text-muted); + transition: all 0.2s; +} + +.tab-btn.active { + background: #eef2ff; + color: var(--primary); + border-color: #c7d2fe; +} + +.search-box { + position: relative; +} +.search-box i { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + font-size: 13px; +} +.search-box input { + padding: 8px 12px 8px 32px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 13px; + width: 250px; + outline: none; + transition: border-color 0.2s; +} +.search-box input:focus { border-color: var(--primary); } + +.table-wrapper { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + background: #f9fafb; + text-align: left; + padding: 12px 20px; + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +td { + padding: 15px 20px; + font-size: 14px; + border-bottom: 1px solid var(--border); + color: var(--text-main); + cursor: pointer; +} + +tr:hover td { background-color: #f9fafb; } + +.status-badge { + padding: 4px 10px; + border-radius: 50px; + font-size: 12px; + font-weight: 600; + display: inline-block; +} + +.btn-sm-del { + background: #fee2e2; + color: #ef4444; + border: none; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + font-weight: 600; + display: flex; + align-items: center; + gap: 5px; +} +.btn-sm-del:hover { background: #fecaca; } + +/* Minimap Card */ +.minimap-card { + background: var(--bg-white); + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + border: 1px solid var(--border); + overflow: hidden; + position: sticky; + top: 20px; +} + +.minimap-header { + padding: 15px 20px; + font-size: 14px; + font-weight: 600; + border-bottom: 1px solid var(--border); + background: #f9fafb; +} + +#minimap { + height: 300px; + width: 100%; +} + +.minimap-footer { + padding: 12px; + font-size: 12px; + color: var(--text-muted); + text-align: center; +} + +/* Modals */ +.modal { + display: none; + position: fixed; + z-index: 2000; + left: 0; top: 0; width: 100%; height: 100%; + background-color: rgba(0,0,0,0.4); + align-items: center; justify-content: center; +} + +.modal-content { + background: var(--bg-white); + border-radius: 12px; + width: 420px; + box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); + border: 1px solid var(--border); + overflow: hidden; +} + +.modal-header { + padding: 20px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { font-size: 18px; } + +.close-btn { + background: transparent; + border: none; + font-size: 20px; + color: var(--text-muted); + cursor: pointer; +} + +.modal-body { + padding: 20px; +} + +.form-group { + margin-bottom: 18px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 14px; + font-weight: 500; +} + +.form-group input, .form-group select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + font-size: 14px; +} + +.form-group input:focus, .form-group select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.form-group input[readonly] { + background: #f9fafb; + color: var(--text-muted); +} + +.text-muted { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; + display: block; +} + +.form-actions { + margin-top: 25px; +} + +.btn-primary { + width: 100%; + background: var(--primary); + color: white; + border: none; + padding: 12px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 14px; +} +.btn-primary:hover { background: var(--primary-hover); } + +.btn-danger { + background: #ef4444; + color: white; + border: none; + padding: 12px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 14px; +} +.btn-danger:hover { background: #dc2626; } diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..8d60d1c --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,505 @@ +// Maps Initialization +const map = L.map('map').setView([-0.060586, 109.344989], 16); +L.tileLayer('https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', { + attribution: '© Google Maps', maxZoom: 20 +}).addTo(map); + +const minimap = L.map('minimap', { zoomControl: false }).setView([-0.060586, 109.344989], 14); +L.tileLayer('https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', { + attribution: '© Google Maps', maxZoom: 20 +}).addTo(minimap); + +// Feature Groups +const drawnItems = new L.FeatureGroup().addTo(map); +const minimapLayer = L.featureGroup().addTo(minimap); + +// Draw Control on Main Map +const drawControl = new L.Control.Draw({ + draw: { + marker: true, circle: false, circlemarker: false, rectangle: false, + polyline: { shapeOptions: { color: '#ffffff', weight: 4 } }, + polygon: { allowIntersection: false, showArea: true, shapeOptions: { color: '#ffffff', weight: 2 } } + }, + edit: { featureGroup: drawnItems, edit: true, remove: false } +}); +map.addControl(drawControl); + +// Configuration +const JALAN_STATUS = ['Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten']; +const PARSIL_STATUS = [ + { value: 'SHM', label: 'Sertifikat Hak Milik (SHM)' }, + { value: 'HGB', label: 'Sertifikat Hak Guna Bangunan (HGB)' }, + { value: 'HGU', label: 'Sertifikat Hak Guna Usaha (HGU)' }, + { value: 'HP', label: 'Sertifikat Hak Pakai (HP)' } +]; +const COLORS = { + 'Jalan Nasional': '#ef4444', 'Jalan Provinsi': '#f97316', 'Jalan Kabupaten': '#eab308', + 'SHM': '#22c55e', 'HGB': '#3b82f6', 'HGU': '#a855f7', 'HP': '#06b6d4' +}; +const BG_COLORS = { + 'Jalan Nasional': '#fef2f2', 'Jalan Provinsi': '#fff7ed', 'Jalan Kabupaten': '#fefce8', + 'SHM': '#f0fdf4', 'HGB': '#eff6ff', 'HGU': '#faf5ff', 'HP': '#ecfeff' +}; + +const moveIcon = L.divIcon({ + html: '
', + className: 'custom-move-icon', + iconSize: [24, 24], + iconAnchor: [12, 12] +}); + +let currentTempLayer = null; +let rawData = { jalan: [], parsil: [], titik: [] }; +let currentTableTab = 'jalan'; + +// View Navigation +function switchView(view) { + document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.remove('active')); + document.getElementById(`btn-tab-${view}`).classList.add('active'); + + document.getElementById('view-input').classList.add('view-hidden'); + document.getElementById('view-data').classList.add('view-hidden'); + + document.getElementById(`view-${view}`).classList.remove('view-hidden'); + + // Invalidate map sizes because of display:none toggling + setTimeout(() => { + map.invalidateSize(); + minimap.invalidateSize(); + if (view === 'data') renderTable(); + }, 100); +} + +// Table Tab Navigation +function switchTableTab(tab) { + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + document.getElementById(`tab-${tab}`).classList.add('active'); + currentTableTab = tab; + + document.getElementById('th-metric').innerText = tab === 'jalan' ? 'Panjang (m)' : (tab === 'titik' ? 'Foto' : 'Luas (m²)'); + document.getElementById('search-input').value = ''; + renderTable(); +} + +// Table Search Event +document.getElementById('search-input').addEventListener('input', renderTable); + +// Map Draw Event +map.on(L.Draw.Event.CREATED, function (e) { + currentTempLayer = e.layer; + const type = e.layerType; + let geojson = currentTempLayer.toGeoJSON(); + + const formType = type === 'polyline' ? 'jalan' : (type === 'polygon' ? 'parsil' : 'titik'); + document.getElementById('form-type').value = formType; + if (geojson.geometry) { + document.getElementById('data-geojson').value = JSON.stringify(geojson.geometry); + } + + // Reset data + document.getElementById('data-id').value = ''; + document.getElementById('data-nama').value = ''; + + // Setup Modal UI + document.getElementById('modal-title').innerText = type === 'polyline' ? 'Input Data Jalan' : (type === 'marker' ? 'Laporkan Titik Kerusakan' : 'Input Data Parsil Tanah'); + + // Toggle Fields + if (type === 'marker') { + document.getElementById('data-status').parentElement.style.display = 'none'; + document.getElementById('field-foto').style.display = 'block'; + document.getElementById('field-metric').style.display = 'none'; + document.getElementById('label-nama').innerText = 'Keterangan Kerusakan'; + document.getElementById('data-status').removeAttribute('required'); + document.getElementById('data-metric').removeAttribute('required'); + document.getElementById('foto-preview').innerHTML = ''; + + } else { + document.getElementById('data-status').parentElement.style.display = 'block'; + document.getElementById('field-foto').style.display = 'none'; + document.getElementById('field-metric').style.display = 'block'; + document.getElementById('label-nama').innerText = 'Nama'; + document.getElementById('data-status').setAttribute('required', 'true'); + document.getElementById('data-metric').setAttribute('required', 'true'); + + const statusSelect = document.getElementById('data-status'); + statusSelect.innerHTML = ''; + const options = type === 'polyline' ? JALAN_STATUS : PARSIL_STATUS; + options.forEach(opt => { + let val = opt.value || opt; + let label = opt.label || opt; + statusSelect.innerHTML += ``; + }); + + if (type === 'polyline') { + const lengthKm = turf.length(geojson, {units: 'meters'}); + document.getElementById('data-metric').value = lengthKm.toFixed(2); + } else { + const areaSqMeters = turf.area(geojson); + document.getElementById('data-metric').value = areaSqMeters.toFixed(2); + } + } + + document.getElementById('modal-form').style.display = 'flex'; +}); + +// Handle Geometry Drag/Vertex Edit Saves +async function saveGeomToDB(layer) { + if (!layer.dbId) return; + const geojson = layer.toGeoJSON(); + const payload = { + id: layer.dbId, + status: layer.dbItem.status, + geojson: JSON.stringify(geojson.geometry) + }; + + if (layer.dbType === 'jalan') { + payload.nama_jalan = layer.dbItem.nama_jalan; + payload.panjang = turf.length(geojson, {units: 'meters'}).toFixed(2); + } else { + payload.nama_pemilik = layer.dbItem.nama_pemilik; + payload.luas = turf.area(geojson).toFixed(2); + } + await fetch(`api/${layer.dbType}.php`, { method: 'PUT', body: JSON.stringify(payload) }); +} + +map.on(L.Draw.Event.EDITED, function (e) { + const layers = e.layers; + layers.eachLayer(saveGeomToDB); + setTimeout(loadData, 500); +}); + +// Custom Center Drag Handles for Total Shape Movement +let dragHandles = []; + +map.on('draw:editstart', function() { + drawnItems.eachLayer(layer => { + if (!layer.getBounds) return; // Skip if no bounds + let center = layer.getBounds().getCenter(); + let handle = L.marker(center, { icon: moveIcon, draggable: true, zIndexOffset: 2000 }).addTo(map); + + let startLatLng, initialLatLngs; + + handle.on('dragstart', () => { + startLatLng = handle.getLatLng(); + initialLatLngs = layer.getLatLngs(); + }); + + handle.on('drag', (e) => { + let currentLatLng = handle.getLatLng(); + let dLat = currentLatLng.lat - startLatLng.lat; + let dLng = currentLatLng.lng - startLatLng.lng; + + function shift(ll) { + if (Array.isArray(ll)) return ll.map(shift); + return new L.LatLng(ll.lat + dLat, ll.lng + dLng); + } + layer.setLatLngs(shift(initialLatLngs)); + }); + + handle.on('dragend', () => { + saveGeomToDB(layer); // Instantly save DB on drop + }); + + dragHandles.push(handle); + }); +}); + +map.on('draw:editstop', function() { + dragHandles.forEach(h => map.removeLayer(h)); + dragHandles = []; + setTimeout(loadData, 500); +}); + +// Load and Render Logic +async function loadData() { + drawnItems.clearLayers(); + minimapLayer.clearLayers(); + + let resJalan = await fetch('api/jalan.php'); + rawData.jalan = await resJalan.json(); + rawData.jalan.forEach(item => renderToMap(item, 'jalan')); + + let resParsil = await fetch('api/parsil.php'); + rawData.parsil = await resParsil.json(); + rawData.parsil.forEach(item => renderToMap(item, 'parsil')); + + let resTitik = await fetch('api/titik.php'); + rawData.titik = await resTitik.json(); + rawData.titik.forEach(item => renderToMap(item, 'titik')); + + // Update Dashboard Stats + document.getElementById('stat-jalan').innerText = rawData.jalan.length; + document.getElementById('stat-parsil').innerText = rawData.parsil.length; + document.getElementById('stat-titik').innerText = rawData.titik.length; + + if (!document.getElementById('view-data').classList.contains('view-hidden')) { + renderTable(); + } +} + +function renderToMap(item, type) { + try { + const geom = JSON.parse(item.geojson); + let displayStatus = item.status; + if (type === 'parsil') { + const found = PARSIL_STATUS.find(p => p.value === item.status); + if (found) displayStatus = found.label; + } + + let style = {}; + if (type === 'jalan') { + style = { color: COLORS[item.status] || '#333', weight: 4 }; + } else if (type === 'parsil') { + style = { color: COLORS[item.status] || '#333', weight: 2, fillColor: COLORS[item.status] || '#333', fillOpacity: 0.5 }; + } + + const layer = L.geoJSON(geom, { style }).getLayers()[0]; + + // Internal state for DB update on drag + layer.dbId = item.id; + layer.dbType = type; + layer.dbItem = item; + + // Click to open edit modal + layer.on('click', () => { + // Ignore click if Leaflet.Draw edit mode is active (pencil toolbar) + if (layer.editing && layer.editing.enabled()) return; + + currentTempLayer = null; + document.getElementById('form-type').value = type; + document.getElementById('data-id').value = item.id; + document.getElementById('data-geojson').value = item.geojson; + + document.getElementById('modal-title').innerText = type === 'jalan' ? 'Edit Data Jalan' : 'Edit Data Parsil Tanah'; + + // Toggle Fields + if (type === 'titik') { + document.getElementById('data-status').parentElement.style.display = 'none'; + document.getElementById('field-foto').style.display = 'block'; + document.getElementById('field-metric').style.display = 'none'; + document.getElementById('label-nama').innerText = 'Keterangan Kerusakan'; + document.getElementById('data-status').removeAttribute('required'); + document.getElementById('data-metric').removeAttribute('required'); + + document.getElementById('data-existing-foto').value = item.foto_url; + document.getElementById('foto-preview').innerHTML = ``; + } else { + document.getElementById('data-status').parentElement.style.display = 'block'; + document.getElementById('field-foto').style.display = 'none'; + document.getElementById('field-metric').style.display = 'block'; + document.getElementById('label-nama').innerText = 'Nama'; + + const statusSelect = document.getElementById('data-status'); + statusSelect.innerHTML = ''; + const options = type === 'jalan' ? JALAN_STATUS : PARSIL_STATUS; + options.forEach(opt => { + let val = opt.value || opt; + let label = opt.label || opt; + statusSelect.innerHTML += ``; + }); + statusSelect.value = item.status; + } + + document.getElementById('data-nama').value = type === 'titik' ? item.keterangan : (type === 'jalan' ? item.nama_jalan : item.nama_pemilik); + if (type !== 'titik') document.getElementById('data-metric').value = type === 'jalan' ? item.panjang : item.luas; + + document.getElementById('btn-delete').style.display = 'block'; + document.getElementById('modal-form').style.display = 'flex'; + }); + + if (type === 'jalan') { + layer.bindTooltip(`${item.nama_jalan}
${displayStatus}
${item.panjang} m`); + } else if (type === 'parsil') { + layer.bindTooltip(`${item.nama_pemilik}
${displayStatus}
${item.luas} m²`); + } else { + layer.bindTooltip(`
${item.keterangan}

Laporkan: Titik Rusak
`, {direction: 'top'}); + } + drawnItems.addLayer(layer); + + } catch (e) { + console.error("GeoJSON parsing err", e); + } +} + +// Table Rendering +function renderTable() { + const list = rawData[currentTableTab]; + const tbody = document.getElementById('table-body'); + const search = document.getElementById('search-input').value.toLowerCase(); + + tbody.innerHTML = ''; + + let filteredList = list.filter(item => { + const name = currentTableTab === 'titik' ? item.keterangan : (currentTableTab === 'jalan' ? item.nama_jalan : item.nama_pemilik); + return name.toLowerCase().includes(search); + }); + + if (filteredList.length === 0) { + tbody.innerHTML = `Data kosong atau tidak ditemukan.`; + return; + } + + filteredList.forEach((item, index) => { + const name = currentTableTab === 'titik' ? item.keterangan : (currentTableTab === 'jalan' ? item.nama_jalan : item.nama_pemilik); + const metric = currentTableTab === 'titik' ? `` : (currentTableTab === 'jalan' ? item.panjang : item.luas); + const color = currentTableTab === 'titik' ? '#ef4444' : COLORS[item.status]; + const bg = currentTableTab === 'titik' ? '#fef2f2' : BG_COLORS[item.status]; + + let displayStatus = currentTableTab === 'titik' ? 'Titik Rusak' : item.status; + if (currentTableTab === 'parsil') { + const found = PARSIL_STATUS.find(p => p.value === item.status); + if (found) displayStatus = found.label; + } + + const tr = document.createElement('tr'); + tr.onclick = () => showOnMinimap(item, currentTableTab); + tr.innerHTML = ` + ${index + 1} + ${name} + ${displayStatus} + ${metric} + + + + `; + tbody.appendChild(tr); + }); +} + +function showOnMinimap(item, type) { + minimapLayer.clearLayers(); + try { + const geom = JSON.parse(item.geojson); + const style = type === 'jalan' + ? { color: COLORS[item.status] || '#333', weight: 4 } + : { color: COLORS[item.status] || '#333', weight: 2, fillColor: COLORS[item.status] || '#333', fillOpacity: 0.5 }; + + const layer = L.geoJSON(geom, { style }); + minimapLayer.addLayer(layer); + minimap.fitBounds(layer.getBounds(), { padding: [20, 20], maxZoom: 17 }); + } catch (e) {} +} + +// Form Submission +document.getElementById('main-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const type = document.getElementById('form-type').value; + const dataId = document.getElementById('data-id').value; + + if (type === 'titik') { + const fd = new FormData(); + if (dataId) fd.append('id', dataId); + fd.append('keterangan', document.getElementById('data-nama').value); + fd.append('geojson', document.getElementById('data-geojson').value); + fd.append('existing_foto', document.getElementById('data-existing-foto').value); + + const photoFile = document.getElementById('data-foto').files[0]; + if (photoFile) fd.append('foto', photoFile); + + await fetch(`api/titik.php`, { method: 'POST', body: fd }); + } else { + let payload = { + status: document.getElementById('data-status').value, + geojson: document.getElementById('data-geojson').value + }; + + if (type === 'jalan') { + payload.nama_jalan = document.getElementById('data-nama').value; + payload.panjang = document.getElementById('data-metric').value; + } else { + payload.nama_pemilik = document.getElementById('data-nama').value; + payload.luas = document.getElementById('data-metric').value; + } + + const method = dataId ? 'PUT' : 'POST'; + if (dataId) payload.id = dataId; + + await fetch(`api/${type}.php`, { method: method, body: JSON.stringify(payload) }); + } + + closeModal(); + loadData(); +}); + +function deleteData(type, id) { + if (!confirm('Hapus data ini dari sistem?')) return; + fetch(`api/${type}.php`, { method: 'DELETE', body: JSON.stringify({ id }) }).then(() => loadData()); +} + +function deleteFromModal() { + const type = document.getElementById('form-type').value; + const id = document.getElementById('data-id').value; + if (!id) return; + deleteData(type, id); + closeModal(); +} + +function closeModal() { + document.getElementById('modal-form').style.display = 'none'; + document.getElementById('main-form').reset(); + document.getElementById('data-id').value = ''; + document.getElementById('btn-delete').style.display = 'none'; + if (currentTempLayer) { + map.removeLayer(currentTempLayer); + currentTempLayer = null; + } +} + +// EXIF Auto Extractor +document.getElementById('auto-upload-exif').addEventListener('change', function(e) { + const file = e.target.files[0]; + if (!file) return; + + EXIF.getData(file, function() { + const latRef = EXIF.getTag(this, "GPSLatitudeRef"); + const lat = EXIF.getTag(this, "GPSLatitude"); + const lngRef = EXIF.getTag(this, "GPSLongitudeRef"); + const lng = EXIF.getTag(this, "GPSLongitude"); + + if (lat && lng && latRef && lngRef) { + const getDec = (rational) => rational.numerator / rational.denominator; + const decimalLat = (getDec(lat[0]) + getDec(lat[1])/60 + getDec(lat[2])/3600) * (latRef === "S" ? -1 : 1); + const decimalLng = (getDec(lng[0]) + getDec(lng[1])/60 + getDec(lng[2])/3600) * (lngRef === "W" ? -1 : 1); + + const geojsonPoint = { type: "Point", coordinates: [decimalLng, decimalLat] }; + + currentTempLayer = L.marker([decimalLat, decimalLng]); + drawnItems.addLayer(currentTempLayer); + map.flyTo([decimalLat, decimalLng], 18); + + document.getElementById('form-type').value = 'titik'; + document.getElementById('data-geojson').value = JSON.stringify(geojsonPoint); + document.getElementById('data-id').value = ''; + document.getElementById('data-nama').value = ''; + document.getElementById('modal-title').innerText = 'Laporan Kerusakan via GPS Kamera'; + + document.getElementById('data-status').parentElement.style.display = 'none'; + document.getElementById('field-foto').style.display = 'block'; + document.getElementById('field-metric').style.display = 'none'; + document.getElementById('label-nama').innerText = 'Keterangan Kerusakan'; + document.getElementById('data-status').removeAttribute('required'); + document.getElementById('data-metric').removeAttribute('required'); + + // Transfer file silently to form + const dt = new DataTransfer(); + dt.items.add(file); + document.getElementById('data-foto').files = dt.files; + + const reader = new FileReader(); + reader.onload = function(evt) { + document.getElementById('foto-preview').innerHTML = ``; + } + reader.readAsDataURL(file); + + document.getElementById('modal-form').style.display = 'flex'; + } else { + alert('Gagal mendeteksi koordinat: Gambar ini tidak memiliki meta GPS (EXIF). Silakan masukan Pin secara manual di peta.'); + document.getElementById('auto-upload-exif').value = ''; + } + }); +}); + +// Init +loadData(); diff --git a/config.php b/config.php new file mode 100644 index 0000000..311295d --- /dev/null +++ b/config.php @@ -0,0 +1,40 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +try { + $pdo = new PDO($dsn, $user, $pass, $options); +} catch (\PDOException $e) { + throw new \PDOException($e->getMessage(), (int)$e->getCode()); +} +?> diff --git a/index.php b/index.php new file mode 100644 index 0000000..8b207df --- /dev/null +++ b/index.php @@ -0,0 +1,216 @@ + + + + + + WebGIS Manager + + + + + + + + + + + + + + + + +
+
+ + +
+
+ Gunakan Toolbar di kiri peta untuk menggambar (Jalan/Parsil/Titik). +
+
+ + +
+
+ + +
+

Legenda

+
+
Jalan Nasional
+
Jalan Provinsi
+
Jalan Kabupaten
+
Sertifikat Hak Milik (SHM)
+
Sertifikat Hak Guna Bangunan (HGB)
+
Sertifikat Hak Guna Usaha (HGU)
+
Sertifikat Hak Pakai (HP)
+
+
+ + + +
+ + +
+
+ +
+

Dashboard Data

+

Ringkasan dan daftar seluruh entri jalan dan parsil tanah yang tersimpan di sistem.

+
+ + +
+
+
+

Total Jalan

+

0

+
+
+ +
+
+
+
+

Total Parsil Tanah

+

0

+
+
+ +
+
+
+
+

Total Jalan Rusak

+

0

+
+
+ +
+
+
+ + +
+ +
+
+
+ + + +
+ +
+ +
+ + + + + + + + + + + + + +
#NamaStatusDimensiAksi
+
+
+ + +
+
+ Pratinjau Peta +
+
+ +
+
+ +
+
+ + + + + + + + + + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..1f0c485 --- /dev/null +++ b/schema.sql @@ -0,0 +1,28 @@ +CREATE DATABASE IF NOT EXISTS webgis_jalan; +USE webgis_jalan; + +CREATE TABLE IF NOT EXISTS jalan ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_jalan VARCHAR(255) NOT NULL, + status ENUM('Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten') NOT NULL, + panjang DECIMAL(10, 2) NOT NULL, + geojson LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS parsil ( + id INT AUTO_INCREMENT PRIMARY KEY, + nama_pemilik VARCHAR(255) NOT NULL, + status ENUM('SHM', 'HGB', 'HGU', 'HP') NOT NULL, + luas DECIMAL(12, 2) NOT NULL, + geojson LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS titik_rusak ( + id INT AUTO_INCREMENT PRIMARY KEY, + keterangan VARCHAR(255) NOT NULL, + foto_url VARCHAR(255) NOT NULL, + geojson LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..b255f84 --- /dev/null +++ b/uploads/.gitkeep @@ -0,0 +1 @@ +# Keep this directory available for uploaded report photos.