Add WebGIS project documentation

This commit is contained in:
GuavaPopper
2026-06-11 02:42:37 +07:00
commit 5cf0b01a36
12 changed files with 1545 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
DB_HOST=127.0.0.1
DB_NAME=webgis_jalan
DB_USER=root
DB_PASS=
+2
View File
@@ -0,0 +1,2 @@
uploads/*
!uploads/.gitkeep
+165
View File
@@ -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.
+35
View File
@@ -0,0 +1,35 @@
<?php
require_once dirname(__DIR__) . '/config.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$stmt = $pdo->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"]);
}
}
?>
+35
View File
@@ -0,0 +1,35 @@
<?php
require_once dirname(__DIR__) . '/config.php';
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') {
$stmt = $pdo->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"]);
}
}
?>
+78
View File
@@ -0,0 +1,78 @@
<?php
require_once '../config.php';
header("Content-Type: application/json");
if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
$data = json_decode(file_get_contents('php://input'), true);
if (!isset($data['id'])) exit;
// Find file to delete
$stmt = $pdo->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;
}
+436
View File
@@ -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; }
+505
View File
@@ -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: '&copy; 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: '&copy; 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: '<div style="background:white; border:2px solid #333; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; cursor:grab; box-shadow:0 2px 4px rgba(0,0,0,0.3);"><i class="fa-solid fa-arrows-up-down-left-right" style="color:#333; font-size:12px;"></i></div>',
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 += `<option value="${val}">${label}</option>`;
});
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 = `<img src="${item.foto_url}" style="max-height:150px; border-radius:5px;">`;
} 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 += `<option value="${val}">${label}</option>`;
});
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(`<b>${item.nama_jalan}</b><br>${displayStatus}<br>${item.panjang} m`);
} else if (type === 'parsil') {
layer.bindTooltip(`<b>${item.nama_pemilik}</b><br>${displayStatus}<br>${item.luas}`);
} else {
layer.bindTooltip(`<div style="text-align:center;"><b>${item.keterangan}</b><br><img src="${item.foto_url}" style="width:120px; height:auto; margin-top:5px; border-radius:5px;"><br>Laporkan: Titik Rusak</div>`, {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 = `<tr><td colspan="5" style="text-align:center; color:#9ca3af;">Data kosong atau tidak ditemukan.</td></tr>`;
return;
}
filteredList.forEach((item, index) => {
const name = currentTableTab === 'titik' ? item.keterangan : (currentTableTab === 'jalan' ? item.nama_jalan : item.nama_pemilik);
const metric = currentTableTab === 'titik' ? `<img src="${item.foto_url}" style="height:30px; border-radius:3px;">` : (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 = `
<td>${index + 1}</td>
<td style="font-weight:500;">${name}</td>
<td><span class="status-badge" style="color:${color}; background:${bg};">${displayStatus}</span></td>
<td>${metric}</td>
<td>
<button class="btn-sm-del" onclick="event.stopPropagation(); deleteData('${currentTableTab}', ${item.id})">
<i class="fa-solid fa-trash"></i> Hapus
</button>
</td>
`;
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 = `<img src="${evt.target.result}" style="max-height:150px; border-radius:5px;">`;
}
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();
+40
View File
@@ -0,0 +1,40 @@
<?php
// Simple function to load .env variables into $_ENV
function loadEnv($file) {
if (!file_exists($file)) return false;
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) continue;
list($name, $value) = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) {
putenv(sprintf('%s=%s', $name, $value));
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
return true;
}
loadEnv(__DIR__ . '/.env');
$host = $_ENV['DB_HOST'] ?? '127.0.0.1';
$db = $_ENV['DB_NAME'] ?? 'webgis_jalan';
$user = $_ENV['DB_USER'] ?? 'root';
$pass = $_ENV['DB_PASS'] ?? '';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
PDO::ATTR_ERRMODE => 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());
}
?>
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Manager</title>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- FontAwesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
<!-- Custom CSS -->
<link rel="stylesheet" href="assets/css/style.css?v=<?php echo time(); ?>">
</head>
<body>
<!-- Top Navbar -->
<nav class="navbar">
<div class="nav-brand">
<i class="fa-solid fa-map-location-dot"></i> WebGIS Manager
</div>
<div class="nav-menu">
<button class="nav-btn active" id="btn-tab-input" onclick="switchView('input')">
<i class="fa-solid fa-pen-to-square"></i> Input Data
</button>
<button class="nav-btn" id="btn-tab-data" onclick="switchView('data')">
<i class="fa-solid fa-list"></i> Lihat Data
</button>
</div>
</nav>
<!-- VIEW 1: Input Data (Fullscreen Map) -->
<main id="view-input" class="view-active">
<div id="map"></div>
<!-- Floating Info Bar -->
<div class="floating-info" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<i class="fa-solid fa-circle-info"></i> Gunakan Toolbar di kiri peta untuk menggambar (Jalan/Parsil/Titik).
</div>
<div style="margin-left:20px; pointer-events:auto;">
<button class="btn btn-danger" style="padding:8px 15px; font-size:13px; border-radius:30px; box-shadow:0 4px 6px rgba(239,68,68,0.3);" onclick="document.getElementById('auto-upload-exif').click()">
<i class="fa-solid fa-camera"></i> Lapor Lacak Otomatis via Foto EXIF
</button>
<input type="file" id="auto-upload-exif" style="display:none;" accept="image/jpeg, image/png, image/jpg">
</div>
</div>
<!-- Float Legend Input -->
<div class="floating-legend">
<h4>Legenda</h4>
<div class="legend-grid">
<div><span style="background:#ef4444"></span> Jalan Nasional</div>
<div><span style="background:#f97316"></span> Jalan Provinsi</div>
<div><span style="background:#eab308"></span> Jalan Kabupaten</div>
<div><span style="background:#22c55e"></span> Sertifikat Hak Milik (SHM)</div>
<div><span style="background:#3b82f6"></span> Sertifikat Hak Guna Bangunan (HGB)</div>
<div><span style="background:#a855f7"></span> Sertifikat Hak Guna Usaha (HGU)</div>
<div><span style="background:#06b6d4"></span> Sertifikat Hak Pakai (HP)</div>
</div>
</div>
<!-- Modern Modal for Data Input/Edit -->
<div id="modal-form" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Form Data</h3>
<button class="close-btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="modal-body">
<form id="main-form" enctype="multipart/form-data">
<input type="hidden" id="form-type">
<input type="hidden" id="data-id">
<input type="hidden" id="data-geojson">
<div class="form-group">
<label id="label-nama">Nama</label>
<input type="text" id="data-nama" required placeholder="Masukkan nama...">
</div>
<div class="form-group">
<label>Status</label>
<select id="data-status" required>
<!-- Injected via JS based on type -->
</select>
</div>
<div class="form-group" id="field-foto" style="display:none;">
<label for="data-foto">Foto Kerusakan (.jpg, .png)</label>
<input type="file" id="data-foto" class="form-control" accept="image/*">
<input type="hidden" id="data-existing-foto">
<div id="foto-preview" style="margin-top:10px; text-align:center;"></div>
</div>
<div class="form-group" id="field-metric">
<label id="label-metric">Dimensi (Otomatis)</label>
<input type="number" step="0.01" id="data-metric" readonly required>
<small class="text-muted" id="metric-helper">Terisi otomatis dari gambar peta.</small>
</div>
<div class="form-actions" style="display:flex; gap:10px;">
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fa-solid fa-save"></i> Simpan</button>
<button type="button" class="btn btn-danger" id="btn-delete" style="display:none; flex:1" onclick="deleteFromModal()"><i class="fa-solid fa-trash"></i> Hapus</button>
</div>
</form>
</div>
</div>
</div>
</main>
<!-- VIEW 2: Lihat Data (Dashboard) -->
<main id="view-data" class="view-hidden">
<div class="dashboard-container">
<div class="dashboard-header">
<h2><i class="fa-solid fa-chart-simple"></i> Dashboard Data</h2>
<p>Ringkasan dan daftar seluruh entri jalan dan parsil tanah yang tersimpan di sistem.</p>
</div>
<!-- Stats Row -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-info">
<h3>Total Jalan</h3>
<h2 id="stat-jalan">0</h2>
</div>
<div class="stat-icon" style="color: #3b82f6; background: #eff6ff;">
<i class="fa-solid fa-road"></i>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<h3>Total Parsil Tanah</h3>
<h2 id="stat-parsil">0</h2>
</div>
<div class="stat-icon" style="color: #22c55e; background: #f0fdf4;">
<i class="fa-solid fa-map"></i>
</div>
</div>
<div class="stat-card">
<div class="stat-info">
<h3>Total Jalan Rusak</h3>
<h2 id="stat-titik">0</h2>
</div>
<div class="stat-icon" style="color: #ef4444; background: #fef2f2;">
<i class="fa-solid fa-triangle-exclamation"></i>
</div>
</div>
</div>
<!-- Content Split Row -->
<div class="content-row">
<!-- Left: Table Area -->
<div class="table-card">
<div class="table-header">
<div class="table-tabs">
<button class="tab-btn active" id="tab-jalan" onclick="switchTableTab('jalan')">
<i class="fa-solid fa-road"></i> Data Jalan
</button>
<button class="tab-btn" id="tab-parsil" onclick="switchTableTab('parsil')">
<i class="fa-solid fa-map"></i> Data Parsil
</button>
<button class="tab-btn" id="tab-titik" onclick="switchTableTab('titik')">
<i class="fa-solid fa-triangle-exclamation"></i> Kerusakan
</button>
</div>
<div class="search-box">
<i class="fa-solid fa-magnifying-glass"></i>
<input type="text" id="search-input" placeholder="Cari nama...">
</div>
</div>
<div class="table-wrapper">
<table id="data-table">
<thead>
<tr>
<th>#</th>
<th>Nama</th>
<th>Status</th>
<th id="th-metric">Dimensi</th>
<th>Aksi</th>
</tr>
</thead>
<tbody id="table-body">
<!-- Rendered via JS -->
</tbody>
</table>
</div>
</div>
<!-- Right: Mini Map -->
<div class="minimap-card">
<div class="minimap-header">
<i class="fa-solid fa-location-crosshairs"></i> Pratinjau Peta
</div>
<div id="minimap"></div>
<div class="minimap-footer">
Klik baris tabel di samping untuk melihat lokasi pada peta ini.
</div>
</div>
</div>
</div>
</main>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-path-drag/dist/L.Path.Drag.js"></script>
<script src="https://cdn.jsdelivr.net/npm/exif-js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>
+28
View File
@@ -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
);
+1
View File
@@ -0,0 +1 @@
# Keep this directory available for uploaded report photos.