chore: prepare docker webgis deployment
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# Project 01 - WebGIS Dasar
|
||||
|
||||
Project ini adalah tugas kelas WebGIS dasar berbasis Leaflet.js, PHP, dan MySQL/MariaDB. Fokusnya adalah pengelolaan layer geometri dasar: point, polyline, dan polygon.
|
||||
|
||||
## Status Project
|
||||
|
||||
Project ini disertakan sebagai artifact tugas kelas. Aplikasi final Tugas Besar ada di folder `../WebgisPovertyMapping`.
|
||||
|
||||
File utama untuk demo project ini adalah:
|
||||
|
||||
```text
|
||||
index.php
|
||||
```
|
||||
|
||||
File `index.html` dan endpoint root seperti `simpan.php`, `ambil_data.php`, `hapus.php`, dan `update_posisi.php` adalah peninggalan versi awal POI. Demo utama menggunakan `index.php` dan endpoint di folder `api/`.
|
||||
|
||||
## Fitur
|
||||
|
||||
| Fitur | Deskripsi |
|
||||
| --- | --- |
|
||||
| POI/lokasi usaha | Tambah, tampilkan, hapus, dan drag marker lokasi usaha |
|
||||
| Data jalan | Gambar polyline jalan, simpan status jalan, hitung panjang |
|
||||
| Data parsil | Gambar polygon bidang tanah, simpan status kepemilikan, hitung luas |
|
||||
| CRUD API | Endpoint per layer di `api/point/`, `api/jalan/`, dan `api/parsil/` |
|
||||
| Peta interaktif | Leaflet.js dengan tile OpenStreetMap |
|
||||
|
||||
## Struktur Utama
|
||||
|
||||
```text
|
||||
01/
|
||||
├── index.php
|
||||
├── koneksi.php
|
||||
├── setup_database.sql
|
||||
├── api/
|
||||
│ ├── point/
|
||||
│ ├── jalan/
|
||||
│ └── parsil/
|
||||
└── modules/
|
||||
├── point.js
|
||||
├── jalan.js
|
||||
└── parsil.js
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Project ini memakai database khusus:
|
||||
|
||||
```text
|
||||
db_webgis_01
|
||||
```
|
||||
|
||||
Import schema:
|
||||
|
||||
```powershell
|
||||
mysql -u root -P 3307 < setup_database.sql
|
||||
```
|
||||
|
||||
Jika MySQL/MariaDB lokal memakai port lain, sesuaikan `DB_PORT` pada `koneksi.php`.
|
||||
|
||||
## Konfigurasi
|
||||
|
||||
Konfigurasi database ada di `koneksi.php`:
|
||||
|
||||
```php
|
||||
define('DB_HOST', 'localhost');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', '');
|
||||
define('DB_NAME', 'db_webgis_01');
|
||||
define('DB_PORT', 3307);
|
||||
```
|
||||
|
||||
## Akses Lokal
|
||||
|
||||
```text
|
||||
http://localhost/webgis/01/
|
||||
```
|
||||
|
||||
## Dependensi
|
||||
|
||||
- Leaflet.js
|
||||
- OpenStreetMap tile
|
||||
- Google Fonts
|
||||
|
||||
Dependensi frontend dimuat dari CDN, sehingga demo membutuhkan koneksi internet.
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* ambil_data.php
|
||||
* Mengambil semua data dari tabel lokasi_usaha dan mengembalikannya sebagai JSON
|
||||
* Digunakan oleh frontend untuk menampilkan marker di peta saat halaman dimuat
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
require_once 'koneksi.php';
|
||||
|
||||
$result = $conn->query(
|
||||
"SELECT id, nama_tempat, no_wa, buka_24jam, latitude, longitude
|
||||
FROM lokasi_usaha
|
||||
ORDER BY id DESC"
|
||||
);
|
||||
|
||||
if (!$result) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'nama_tempat' => $row['nama_tempat'],
|
||||
'no_wa' => $row['no_wa'],
|
||||
'buka_24jam' => (int)$row['buka_24jam'],
|
||||
'latitude' => (float)$row['latitude'],
|
||||
'longitude' => (float)$row['longitude']
|
||||
];
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$result = $conn->query(
|
||||
"SELECT id, nama, deskripsi, attribute_key, palette, is_visible, created_at
|
||||
FROM choropleth_layers ORDER BY created_at ASC"
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Tabel belum ada. Jalankan setup_database.sql terlebih dahulu. (' . $conn->error . ')']);
|
||||
$conn->close(); exit;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while ($r = $result->fetch_assoc()) {
|
||||
$rows[] = [
|
||||
'id' => (int)$r['id'],
|
||||
'nama' => $r['nama'],
|
||||
'deskripsi' => $r['deskripsi'],
|
||||
'attribute_key' => $r['attribute_key'],
|
||||
'palette' => $r['palette'],
|
||||
'is_visible' => (bool)$r['is_visible'],
|
||||
'created_at' => $r['created_at'],
|
||||
];
|
||||
}
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'data' => $rows, 'total' => count($rows)]);
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$id = (int)($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("SELECT geojson FROM choropleth_layers WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit;
|
||||
}
|
||||
|
||||
echo $row['geojson'];
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM choropleth_layers WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
if ($affected === 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit;
|
||||
}
|
||||
|
||||
echo json_encode(['status' => 'success']);
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE choropleth_layers SET is_visible = 1 - is_visible WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
$stmt2 = $conn->prepare("SELECT is_visible FROM choropleth_layers WHERE id = ?");
|
||||
$stmt2->bind_param('i', $id);
|
||||
$stmt2->execute();
|
||||
$row = $stmt2->get_result()->fetch_assoc();
|
||||
$stmt2->close();
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'is_visible' => (bool)$row['is_visible']]);
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method tidak valid.']); exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama'] ?? '');
|
||||
$deskripsi = trim($_POST['deskripsi'] ?? '');
|
||||
$attr_key = trim($_POST['attribute_key'] ?? '');
|
||||
$palette = in_array($_POST['palette'] ?? '', ['blue', 'orange', 'green', 'purple'])
|
||||
? $_POST['palette'] : 'blue';
|
||||
|
||||
if (!$nama || !$attr_key) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama layer dan atribut wajib diisi.']); exit;
|
||||
}
|
||||
|
||||
if (empty($_FILES['geojson']['tmp_name']) || $_FILES['geojson']['error'] !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File GeoJSON tidak ditemukan atau gagal diupload.']); exit;
|
||||
}
|
||||
|
||||
if ($_FILES['geojson']['size'] > 10 * 1024 * 1024) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File terlalu besar (maksimum 10 MB).']); exit;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($_FILES['geojson']['tmp_name']);
|
||||
if ($raw === false) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal membaca file.']); exit;
|
||||
}
|
||||
|
||||
$gj = json_decode($raw, true);
|
||||
if (!$gj || !isset($gj['type']) || $gj['type'] !== 'FeatureCollection') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'File bukan GeoJSON FeatureCollection yang valid.']); exit;
|
||||
}
|
||||
if (empty($gj['features'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'GeoJSON tidak memiliki fitur/data.']); exit;
|
||||
}
|
||||
|
||||
$first_props = $gj['features'][0]['properties'] ?? [];
|
||||
if (!array_key_exists($attr_key, $first_props)) {
|
||||
$available = implode(', ', array_keys($first_props));
|
||||
echo json_encode(['status' => 'error', 'message' => "Atribut '{$attr_key}' tidak ditemukan. Tersedia: {$available}"]); exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO choropleth_layers (nama, deskripsi, geojson, attribute_key, palette) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssss', $nama, $deskripsi, $raw, $attr_key, $palette);
|
||||
$stmt->execute();
|
||||
$id = $conn->insert_id;
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
|
||||
echo json_encode(['status' => 'success', 'id' => $id, 'nama' => $nama]);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// api/jalan/ambil.php — Ambil semua data jalan
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$result = $conn->query("SELECT id, nama_jalan, status_jalan, geojson, panjang_meter FROM data_jalan ORDER BY id DESC");
|
||||
|
||||
if (!$result) {
|
||||
echo json_encode(['status' => 'error', 'message' => $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// api/jalan/hapus.php — Hapus data jalan
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM data_jalan WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// api/jalan/simpan.php — Simpan data jalan baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama_jalan'] ?? '');
|
||||
$status_jalan = trim($_POST['status_jalan'] ?? '');
|
||||
$geojson = trim($_POST['geojson'] ?? '');
|
||||
$panjang = (float) ($_POST['panjang_meter'] ?? 0);
|
||||
|
||||
$allowed_status = ['Nasional', 'Provinsi', 'Kabupaten'];
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama jalan tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($status_jalan, $allowed_status)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Status jalan tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi JSON
|
||||
json_decode($geojson);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO data_jalan (nama_jalan, status_jalan, geojson, panjang_meter) VALUES (?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssd', $nama, $status_jalan, $geojson, $panjang);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data jalan berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'nama_jalan' => $nama,
|
||||
'status_jalan' => $status_jalan,
|
||||
'geojson' => $geojson,
|
||||
'panjang_meter' => $panjang
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// api/parsil/ambil.php — Ambil semua data parsil tanah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$result = $conn->query("SELECT id, nama_parsil, status_kepemilikan, geojson, luas_m2 FROM data_parsil ORDER BY id DESC");
|
||||
|
||||
if (!$result) {
|
||||
echo json_encode(['status' => 'error', 'message' => $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// api/parsil/hapus.php — Hapus data parsil tanah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM data_parsil WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// api/parsil/simpan.php — Simpan data parsil tanah baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama_parsil'] ?? '');
|
||||
$status = trim($_POST['status_kepemilikan'] ?? '');
|
||||
$geojson = trim($_POST['geojson'] ?? '');
|
||||
$luas = (float) ($_POST['luas_m2'] ?? 0);
|
||||
|
||||
$allowed_status = ['SHM', 'HGB', 'HGU', 'HP'];
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama parsil tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!in_array($status, $allowed_status)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Status kepemilikan tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
json_decode($geojson);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO data_parsil (nama_parsil, status_kepemilikan, geojson, luas_m2) VALUES (?, ?, ?, ?)");
|
||||
$stmt->bind_param('sssd', $nama, $status, $geojson, $luas);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data parsil berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'nama_parsil' => $nama,
|
||||
'status_kepemilikan' => $status,
|
||||
'geojson' => $geojson,
|
||||
'luas_m2' => $luas
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// api/point/ambil.php — Ambil semua POI
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
$result = $conn->query("SELECT id, nama_tempat, no_wa, buka_24jam, latitude, longitude FROM lokasi_usaha ORDER BY id DESC");
|
||||
|
||||
if (!$result) {
|
||||
echo json_encode(['status' => 'error', 'message' => $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'total' => count($data),
|
||||
'data' => $data
|
||||
]);
|
||||
|
||||
$conn->close();
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// api/point/hapus.php — Hapus POI
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?");
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Data berhasil dihapus']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// api/point/simpan.php — Simpan POI baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = trim($_POST['nama_tempat'] ?? '');
|
||||
$wa = trim($_POST['no_wa'] ?? '');
|
||||
$buka24 = isset($_POST['buka_24jam']) ? (int) $_POST['buka_24jam'] : 0;
|
||||
$lat = (float) ($_POST['latitude'] ?? 0);
|
||||
$lng = (float) ($_POST['longitude'] ?? 0);
|
||||
|
||||
if (!$nama) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama tempat tidak boleh kosong']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->bind_param('ssidd', $nama, $wa, $buka24, $lat, $lng);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan',
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'nama_tempat' => $nama,
|
||||
'no_wa' => $wa,
|
||||
'buka_24jam' => $buka24,
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// api/point/update_posisi.php — Update koordinat POI (drag marker)
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$lat = (float) ($_POST['latitude'] ?? 0);
|
||||
$lng = (float) ($_POST['longitude'] ?? 0);
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare("UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?");
|
||||
$stmt->bind_param('ddi', $lat, $lng, $id);
|
||||
|
||||
if ($stmt->execute() && $stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi diperbarui']);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal memperbarui posisi']);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
function start_session(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params(['lifetime' => 0, 'httponly' => true, 'samesite' => 'Strict']);
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
function is_admin(): bool {
|
||||
start_session();
|
||||
return !empty($_SESSION['admin_id']);
|
||||
}
|
||||
|
||||
function require_admin_json(): void {
|
||||
if (!is_admin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak. Silahkan login terlebih dahulu.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$idRaw = $_POST['id'] ?? null;
|
||||
|
||||
if ($idRaw === null || !is_numeric($idRaw) || (int)$idRaw <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)$idRaw;
|
||||
|
||||
$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?");
|
||||
|
||||
if (!$stmt) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt->bind_param('i', $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
if ($stmt->affected_rows > 0) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Lokasi berhasil dihapus.']);
|
||||
} else {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Lokasi tidak ditemukan.']);
|
||||
}
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
+5
-5
@@ -334,7 +334,7 @@
|
||||
.radio-option input:checked+label {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-h);
|
||||
background: rgba(46, 160, 67, .1);
|
||||
background: rgba(13, 116, 144, .1);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
@@ -591,7 +591,7 @@
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: rgba(248, 81, 73, .15);
|
||||
background: rgba(239, 68, 68, .15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -745,10 +745,10 @@
|
||||
<header class="app-header">
|
||||
<div class="app-logo">
|
||||
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="30" height="30" rx="7" fill="rgba(46,160,67,.15)" />
|
||||
<rect width="30" height="30" rx="7" fill="rgba(13,116,144,.15)" />
|
||||
<path
|
||||
d="M15 5C11.134 5 8 8.134 8 12C8 17.25 15 25 15 25C15 25 22 17.25 22 12C22 8.134 18.866 5 15 5ZM15 15C13.343 15 12 13.657 12 12C12 10.343 13.343 9 15 9C16.657 9 18 10.343 18 12C18 13.657 16.657 15 15 15Z"
|
||||
fill="#3fb950" />
|
||||
fill="#0d7490" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="app-logo-title">WebGIS POI</div>
|
||||
@@ -1036,7 +1036,7 @@
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0d7490" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
|
||||
+1159
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||
define('DB_NAME', getenv('DB_01_NAME') ?: (getenv('DB_NAME') ?: 'db_webgis_01'));
|
||||
define('DB_PORT', (int)(getenv('DB_PORT') ?: 3307));
|
||||
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
start_session();
|
||||
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
$conn = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Koneksi database gagal: ' . $conn->connect_error]));
|
||||
}
|
||||
|
||||
$conn->set_charset('utf8mb4');
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/koneksi.php';
|
||||
|
||||
if (is_admin()) { header('Location: index.php'); exit; }
|
||||
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
if ($username && $password) {
|
||||
$stmt = $conn->prepare("SELECT id, password FROM users WHERE username = ?");
|
||||
$stmt->bind_param('s', $username);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if ($row && password_verify($password, $row['password'])) {
|
||||
$_SESSION['admin_id'] = $row['id'];
|
||||
$_SESSION['admin_username'] = $username;
|
||||
header('Location: index.php'); exit;
|
||||
}
|
||||
$error = 'Username atau password salah.';
|
||||
} else {
|
||||
$error = 'Username dan password wajib diisi.';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Masuk — WebGIS Pontianak</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--sb-bg: #1c1612;
|
||||
--body-bg: #f5f0eb;
|
||||
--card-bg: #fafaf9;
|
||||
--card-border:#ddd8d2;
|
||||
--accent: #0d7490;
|
||||
--accent-h: #0a5f7a;
|
||||
--accent-dim: rgba(13,116,144,.06);
|
||||
--text: #201515;
|
||||
--text-sec: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,.08);
|
||||
--danger-bdr: rgba(239,68,68,.25);
|
||||
--border: #ddd8d2;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 12px rgba(32,21,21,.08), 0 1px 2px rgba(32,21,21,.04);
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text); }
|
||||
.page { min-height: 100vh; display: grid; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 768px) { .page { grid-template-columns: 1fr; } .brand-side { display: none; } }
|
||||
|
||||
.brand-side {
|
||||
background: var(--sb-bg);
|
||||
display: flex; flex-direction: column; justify-content: space-between;
|
||||
padding: 48px 52px; position: relative; overflow: hidden;
|
||||
}
|
||||
.brand-side::before {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 30% 20%, rgba(14,165,233,.18) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 60% at 80% 80%, rgba(56,189,248,.12) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.brand-logo { display: flex; align-items: center; gap: 12px; position: relative; }
|
||||
.brand-logo-icon {
|
||||
width: 44px; height: 44px; border-radius: 12px; background: #0d7490;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.brand-logo-icon .lucide { width: 22px; height: 22px; color: #fff; }
|
||||
.brand-logo-name { font-size: 15px; font-weight: 600; color: #fff; line-height: 1.3; }
|
||||
.brand-logo-sub { font-size: 11px; color: #a89f96; }
|
||||
.brand-body { position: relative; }
|
||||
.brand-headline { font-size: 28px; font-weight: 600; color: #fff; line-height: 1.35; margin-bottom: 16px; }
|
||||
.brand-headline span { color: #a89f96; }
|
||||
.brand-desc { font-size: 14px; color: #a89f96; line-height: 1.7; max-width: 340px; }
|
||||
.brand-stats { display: flex; gap: 28px; margin-top: 36px; }
|
||||
.brand-stat-num { font-size: 20px; font-weight: 600; color: #fff; letter-spacing: -0.5px; }
|
||||
.brand-stat-label { font-size: 11px; color: #a89f96; margin-top: 2px; }
|
||||
.brand-footer { font-size: 11px; color: #a89f96; position: relative; }
|
||||
|
||||
.form-side { display: flex; align-items: center; justify-content: center; padding: 40px 32px; background: var(--body-bg); }
|
||||
.form-box { width: min(420px, 100%); }
|
||||
.form-header { margin-bottom: 32px; }
|
||||
.form-welcome { font-size: 24px; font-weight: 600; color: var(--text); margin-bottom: 6px; }
|
||||
.form-welcome-sub { font-size: 14px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.login-card { background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); padding: 28px 32px 32px; box-shadow: var(--shadow); }
|
||||
|
||||
.alert { display: none; align-items: flex-start; gap: 10px; background: var(--danger-dim); border: 1px solid var(--danger-bdr); border-radius: 10px; padding: 12px 14px; margin-bottom: 20px; font-size: 13px; color: var(--danger); line-height: 1.5; }
|
||||
.alert.show { display: flex; }
|
||||
.alert .lucide { width: 16px; height: 16px; color: var(--danger); flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-sec); margin-bottom: 7px; }
|
||||
.input-wrap { position: relative; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 11px 14px; background: #ede8e2;
|
||||
border: 1.5px solid var(--border); border-radius: 9px;
|
||||
color: var(--text); font-family: var(--font); font-size: 14px; outline: none;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
}
|
||||
.form-group input:focus { border-color: var(--accent); background: #fafaf9; box-shadow: 0 0 0 3px rgba(13,116,144,.08); }
|
||||
.form-group input::placeholder { color: var(--text-muted); }
|
||||
.input-wrap input { padding-right: 44px; }
|
||||
.pw-toggle {
|
||||
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--text-muted);
|
||||
padding: 4px; border-radius: 5px; transition: color .2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pw-toggle:hover { color: var(--text-sec); }
|
||||
.pw-toggle .lucide { width: 17px; height: 17px; }
|
||||
|
||||
.btn-login {
|
||||
width: 100%; padding: 12px; background: var(--accent); color: #fff; border: none;
|
||||
border-radius: 9px; font-family: var(--font); font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; margin-top: 6px; transition: background .2s, transform .1s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
}
|
||||
.btn-login:hover:not(:disabled) { background: var(--accent-h); transform: translateY(-1px); }
|
||||
.btn-login:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; display: none; }
|
||||
.btn-login.loading .btn-spinner { display: block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 22px 0; color: var(--text-muted); font-size: 12px; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
.public-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
width: 100%; padding: 11px; background: transparent; color: var(--text-sec);
|
||||
border: 1.5px solid var(--border); border-radius: 9px; font-family: var(--font);
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
transition: border-color .2s, color .2s, background .2s;
|
||||
}
|
||||
.public-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
|
||||
.public-btn .lucide { width: 16px; height: 16px; }
|
||||
.form-note { text-align: center; margin-top: 24px; font-size: 12px; color: var(--text-muted); line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="brand-side">
|
||||
<div class="brand-logo">
|
||||
<div class="brand-logo-icon"><i data-lucide="map"></i></div>
|
||||
<div>
|
||||
<div class="brand-logo-name">WebGIS Pontianak</div>
|
||||
<div class="brand-logo-sub">Visualisasi Data Wilayah</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-body">
|
||||
<div class="brand-headline">
|
||||
Pemetaan Wilayah<br>Berbasis <span>Data</span><br>ArcGIS
|
||||
</div>
|
||||
<div class="brand-desc">
|
||||
Platform visualisasi choropleth untuk menganalisis data spasial wilayah Pontianak
|
||||
melalui layer GeoJSON yang dapat dikonfigurasi.
|
||||
</div>
|
||||
<div class="brand-stats">
|
||||
<div><div class="brand-stat-num">GIS</div><div class="brand-stat-label">Berbasis Peta</div></div>
|
||||
<div><div class="brand-stat-num">ArcGIS</div><div class="brand-stat-label">Import Data</div></div>
|
||||
<div><div class="brand-stat-num">RT</div><div class="brand-stat-label">Real-time</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand-footer">© <?= date('Y') ?> WebGIS Pontianak</div>
|
||||
</div>
|
||||
|
||||
<div class="form-side">
|
||||
<div class="form-box">
|
||||
<div class="form-header">
|
||||
<div class="form-welcome">Selamat datang 👋</div>
|
||||
<div class="form-welcome-sub">Masuk untuk mengelola layer dan data peta.</div>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<?php if ($error): ?>
|
||||
<div class="alert show">
|
||||
<i data-lucide="alert-triangle"></i>
|
||||
<span><?= htmlspecialchars($error) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="POST" id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Masukkan username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="password" name="password" placeholder="Masukkan password" required autocomplete="current-password">
|
||||
<button type="button" class="pw-toggle" onclick="togglePw()" aria-label="Tampilkan password">
|
||||
<i data-lucide="eye" id="pwIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">
|
||||
<div class="btn-spinner"></div>
|
||||
<span>Masuk</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="divider">atau</div>
|
||||
<a href="index.php" class="public-btn">
|
||||
<i data-lucide="map"></i> Lihat Peta sebagai Pengunjung
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-note">Lupa password? Hubungi administrator sistem.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function togglePw() {
|
||||
const pw = document.getElementById('password');
|
||||
const ic = document.getElementById('pwIcon');
|
||||
const show = pw.type === 'password';
|
||||
pw.type = show ? 'text' : 'password';
|
||||
ic.setAttribute('data-lucide', show ? 'eye-off' : 'eye');
|
||||
lucide.createIcons();
|
||||
}
|
||||
document.getElementById('loginForm').addEventListener('submit', function() {
|
||||
const btn = this.querySelector('.btn-login');
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
});
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
start_session();
|
||||
session_destroy();
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
@@ -0,0 +1,311 @@
|
||||
// modules/choropleth.js — Choropleth visualizer untuk Project 01
|
||||
|
||||
(function () {
|
||||
const PALETTES = {
|
||||
blue: ['#c6dbef', '#9ecae1', '#6baed6', '#3182bd', '#08519c'],
|
||||
orange: ['#fdd0a2', '#fdae6b', '#fd8d3c', '#e6550d', '#a63603'],
|
||||
green: ['#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'],
|
||||
purple: ['#dadaeb', '#bcbddc', '#9e9ac8', '#6a51a3', '#3f007d'],
|
||||
};
|
||||
|
||||
// Qualitative palette — 10 distinct colors for categorical data
|
||||
const CAT_COLORS = [
|
||||
'#4e79a7','#f28e2b','#e15759','#76b7b2',
|
||||
'#59a14f','#edc948','#b07aa1','#ff9da7',
|
||||
'#9c755f','#bab0ac',
|
||||
];
|
||||
|
||||
let layers = []; // { meta, leafletLayer, colorMode, categoryMap, breaks, pal }
|
||||
let allMeta = []; // metadata rows from API (no geojson blob)
|
||||
|
||||
// ── Color scale helpers ──────────────────────────────────────────
|
||||
function quantileBreaks(values) {
|
||||
const sorted = values.filter(v => isFinite(v)).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [0, 0, 0, 0, 0, 0];
|
||||
const breaks = [];
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
breaks.push(sorted[Math.round(i * (sorted.length - 1) / 5)]);
|
||||
}
|
||||
return breaks;
|
||||
}
|
||||
|
||||
function getColor(val, breaks, pal) {
|
||||
for (let i = 0; i < pal.length; i++) {
|
||||
if (val <= breaks[i + 1]) return pal[i];
|
||||
}
|
||||
return pal[pal.length - 1];
|
||||
}
|
||||
|
||||
// Returns {colorMode:'categorical'|'quantile', categoryMap, breaks}
|
||||
function buildColorConfig(features, attrKey, pal) {
|
||||
const rawVals = features.map(f => f.properties[attrKey]);
|
||||
const numVals = rawVals.map(v => parseFloat(v));
|
||||
const allNumeric = numVals.every(v => isFinite(v));
|
||||
const uniqueRaw = [...new Set(rawVals.map(String))];
|
||||
|
||||
// Use categorical when: any non-numeric value, OR ≤ 10 unique values
|
||||
if (!allNumeric || uniqueRaw.length <= 10) {
|
||||
const categoryMap = {};
|
||||
uniqueRaw.sort().forEach((v, i) => { categoryMap[v] = CAT_COLORS[i % CAT_COLORS.length]; });
|
||||
return { colorMode: 'categorical', categoryMap, breaks: null };
|
||||
}
|
||||
const breaks = quantileBreaks(numVals);
|
||||
return { colorMode: 'quantile', categoryMap: null, breaks };
|
||||
}
|
||||
|
||||
function fmtVal(v) {
|
||||
const n = parseFloat(v);
|
||||
if (isFinite(n)) return n.toLocaleString('id-ID', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
return String(v ?? '—');
|
||||
}
|
||||
|
||||
function isClassicDrawingActive() {
|
||||
return typeof window.currentMode !== 'undefined' && Number(window.currentMode) !== 0;
|
||||
}
|
||||
|
||||
function setLayerHitTesting(leafletLayer, enabled) {
|
||||
if (!leafletLayer || typeof leafletLayer.eachLayer !== 'function') return;
|
||||
leafletLayer.eachLayer(layer => {
|
||||
if (layer._path && layer._path.style) {
|
||||
layer._path.style.pointerEvents = enabled ? '' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAllHitTesting(enabled) {
|
||||
layers.forEach(rendered => setLayerHitTesting(rendered.leafletLayer, enabled));
|
||||
}
|
||||
|
||||
// ── Render one GeoJSON layer on map ─────────────────────────────
|
||||
function renderLeafletLayer(meta, geojson) {
|
||||
const pal = PALETTES[meta.palette] || PALETTES.blue;
|
||||
const { colorMode, categoryMap, breaks } = buildColorConfig(geojson.features, meta.attribute_key, pal);
|
||||
|
||||
function featureColor(f) {
|
||||
const raw = f.properties[meta.attribute_key];
|
||||
if (colorMode === 'categorical') {
|
||||
return categoryMap[String(raw)] || '#cccccc';
|
||||
}
|
||||
const val = parseFloat(raw);
|
||||
return isFinite(val) ? getColor(val, breaks, pal) : '#cccccc';
|
||||
}
|
||||
|
||||
const lyr = L.geoJSON(geojson, {
|
||||
pane: 'choroplethPane',
|
||||
style: function (f) {
|
||||
return {
|
||||
fillColor: featureColor(f),
|
||||
weight: 1,
|
||||
color: '#ffffff',
|
||||
fillOpacity: 0.78,
|
||||
opacity: 0.9,
|
||||
};
|
||||
},
|
||||
onEachFeature: function (f, layer) {
|
||||
const props = f.properties || {};
|
||||
|
||||
function buildPopup(closeBtn) {
|
||||
const rows = Object.entries(props)
|
||||
.map(([k, v]) => `<tr><td class="pp-k">${escapeHTML(String(k))}</td><td class="pp-v">${escapeHTML(String(v ?? '—'))}</td></tr>`)
|
||||
.join('');
|
||||
return `<div class="choro-popup"><table>${rows}</table></div>`;
|
||||
}
|
||||
|
||||
layer.on('mouseover', function (e) {
|
||||
if (isClassicDrawingActive()) return;
|
||||
L.popup({ closeButton: false, className: 'choro-popup-wrap', offset: [0, -4] })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildPopup(false))
|
||||
.openOn(map);
|
||||
layer.setStyle({ weight: 2, color: '#1c1612' });
|
||||
});
|
||||
layer.on('mouseout', function () {
|
||||
if (isClassicDrawingActive()) return;
|
||||
map.closePopup();
|
||||
lyr.resetStyle(layer);
|
||||
});
|
||||
layer.on('click', function (e) {
|
||||
if (isClassicDrawingActive()) return;
|
||||
L.popup({ closeButton: true, className: 'choro-popup-wrap' })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildPopup(true))
|
||||
.openOn(map);
|
||||
L.DomEvent.stopPropagation(e);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { meta, leafletLayer: lyr, colorMode, categoryMap, breaks, pal };
|
||||
}
|
||||
|
||||
// ── Legend ───────────────────────────────────────────────────────
|
||||
function buildLegends() {
|
||||
const container = document.getElementById('legendContainer');
|
||||
if (!container) return;
|
||||
|
||||
const visible = layers.filter(l => l.meta.is_visible);
|
||||
if (visible.length === 0) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = visible.map(({ meta, colorMode, categoryMap, breaks, pal }) => {
|
||||
let classes;
|
||||
if (colorMode === 'categorical') {
|
||||
classes = Object.entries(categoryMap).map(([val, color]) => `
|
||||
<div class="leg-row">
|
||||
<span class="leg-swatch" style="background:${color}"></span>
|
||||
<span class="leg-range">${escapeHTML(String(val))}</span>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
classes = pal.map((color, i) => `
|
||||
<div class="leg-row">
|
||||
<span class="leg-swatch" style="background:${color}"></span>
|
||||
<span class="leg-range">${fmtVal(breaks[i])} – ${fmtVal(breaks[i + 1])}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
return `<div class="leg-block">
|
||||
<div class="leg-title">${escapeHTML(meta.nama)}</div>
|
||||
<div class="leg-attr">${escapeHTML(meta.attribute_key)}</div>
|
||||
${classes}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Layer panel ──────────────────────────────────────────────────
|
||||
function refreshPanel() {
|
||||
const list = document.getElementById('layerList');
|
||||
if (!list) return;
|
||||
|
||||
if (allMeta.length === 0) {
|
||||
list.innerHTML = '<div class="layer-empty">Belum ada layer.<br>Upload GeoJSON dari ArcGIS.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = allMeta.map(m => {
|
||||
const isVisible = m.is_visible;
|
||||
const eyeIcon = isVisible ? 'eye' : 'eye-off';
|
||||
const palDots = (PALETTES[m.palette] || PALETTES.blue)
|
||||
.map(c => `<span class="pal-dot" style="background:${c}"></span>`).join('');
|
||||
const safeNama = m.nama.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
const delBtn = window._IS_ADMIN
|
||||
? `<button class="layer-del" onclick="window._choro.deleteLayer(${m.id},'${safeNama}')" title="Hapus"><i data-lucide="trash-2"></i></button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="layer-item ${isVisible ? '' : 'is-hidden'}" id="li${m.id}">
|
||||
<div class="layer-item-row">
|
||||
<button class="layer-eye" onclick="window._choro.toggleLayer(${m.id})" title="${isVisible ? 'Sembunyikan' : 'Tampilkan'}">
|
||||
<i data-lucide="${eyeIcon}" id="eye${m.id}"></i>
|
||||
</button>
|
||||
<div class="layer-info">
|
||||
<div class="layer-name">${escapeHTML(m.nama)}</div>
|
||||
<div class="layer-attr">${escapeHTML(m.attribute_key)}</div>
|
||||
</div>
|
||||
${delBtn}
|
||||
</div>
|
||||
<div class="layer-pal">${palDots}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
// ── Load GeoJSON for one layer, add to map ───────────────────────
|
||||
function loadLayerGeojson(meta) {
|
||||
return fetch('api/choropleth/geojson.php?id=' + meta.id + '&_=' + Date.now())
|
||||
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(geojson => {
|
||||
const rendered = renderLeafletLayer(meta, geojson);
|
||||
if (meta.is_visible) rendered.leafletLayer.addTo(map);
|
||||
layers.push(rendered);
|
||||
setLayerHitTesting(rendered.leafletLayer, !isClassicDrawingActive());
|
||||
return rendered;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial load ─────────────────────────────────────────────────
|
||||
function loadAll() {
|
||||
layers = [];
|
||||
return fetch('api/choropleth/ambil.php?_=' + Date.now())
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message || 'Gagal memuat layer.');
|
||||
allMeta = j.data;
|
||||
refreshPanel();
|
||||
return Promise.all(allMeta.map(meta => loadLayerGeojson(meta)));
|
||||
})
|
||||
.then(() => buildLegends())
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data: ' + err.message, 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
window._choro = {
|
||||
|
||||
toggleLayer: function (id) {
|
||||
const rendered = layers.find(l => l.meta.id === id);
|
||||
const meta = allMeta.find(m => m.id === id);
|
||||
if (!meta) return;
|
||||
|
||||
const newVisible = !meta.is_visible;
|
||||
meta.is_visible = newVisible;
|
||||
if (rendered) {
|
||||
rendered.meta.is_visible = newVisible;
|
||||
if (newVisible) rendered.leafletLayer.addTo(map);
|
||||
else map.removeLayer(rendered.leafletLayer);
|
||||
}
|
||||
|
||||
refreshPanel();
|
||||
buildLegends();
|
||||
showToast(escapeHTML(meta.nama) + (newVisible ? ' ditampilkan.' : ' disembunyikan.'));
|
||||
|
||||
if (window._IS_ADMIN) {
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fetch('api/choropleth/toggle.php', { method: 'POST', body: fd }).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
deleteLayer: function (id, nama) {
|
||||
showDeleteConfirm('Yakin ingin menghapus layer "' + nama + '"?').then(ok => {
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fetch('api/choropleth/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
const idx = layers.findIndex(l => l.meta.id === id);
|
||||
if (idx !== -1) { map.removeLayer(layers[idx].leafletLayer); layers.splice(idx, 1); }
|
||||
allMeta = allMeta.filter(m => m.id !== id);
|
||||
refreshPanel();
|
||||
buildLegends();
|
||||
showToast('Layer "' + escapeHTML(nama) + '" berhasil dihapus.');
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal menghapus.', 'error'));
|
||||
});
|
||||
},
|
||||
|
||||
// Called after successful upload to add new layer without full reload
|
||||
addLayer: function (meta) {
|
||||
allMeta.push(meta);
|
||||
loadLayerGeojson(meta).then(() => {
|
||||
refreshPanel();
|
||||
buildLegends();
|
||||
// Zoom to new layer
|
||||
const rendered = layers.find(l => l.meta.id === meta.id);
|
||||
if (rendered) {
|
||||
try { map.fitBounds(rendered.leafletLayer.getBounds(), { padding: [40, 40] }); } catch (_) {}
|
||||
}
|
||||
}).catch(err => showToast('Layer tersimpan tapi gagal render: ' + err.message, 'error'));
|
||||
},
|
||||
|
||||
setHitTesting: setAllHitTesting,
|
||||
};
|
||||
|
||||
window.initChoropleth = loadAll;
|
||||
})();
|
||||
@@ -0,0 +1,432 @@
|
||||
// modules/jalan.js — Logika Pertemuan 2: Manajemen Data Jalan (Polyline)
|
||||
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
let allLayers = {}; // { id: polylineObject }
|
||||
let active = false;
|
||||
|
||||
// ── Warna per status jalan ────────────────────────
|
||||
const JALAN_COLORS = {
|
||||
'Nasional': '#ef4444',
|
||||
'Provinsi': '#f97316',
|
||||
'Kabupaten': '#eab308'
|
||||
};
|
||||
|
||||
function getColor(status) {
|
||||
return JALAN_COLORS[status] || '#888';
|
||||
}
|
||||
|
||||
// ── Render polyline ───────────────────────────────
|
||||
function addPolylineToMap(row) {
|
||||
let coords;
|
||||
try {
|
||||
const gj = typeof row.geojson === 'string' ? JSON.parse(row.geojson) : row.geojson;
|
||||
// GeoJSON coordinates: [lng, lat] → Leaflet: [lat, lng]
|
||||
coords = gj.coordinates.map(c => [c[1], c[0]]);
|
||||
} catch (e) {
|
||||
console.error('GeoJSON parse error (jalan id=' + row.id + ')', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const poly = L.polyline(coords, {
|
||||
color: getColor(row.status_jalan),
|
||||
weight: 5,
|
||||
opacity: 0.85,
|
||||
pane: 'jalanPane'
|
||||
});
|
||||
|
||||
poly._jalanId = row.id;
|
||||
poly._jalanNama = row.nama_jalan;
|
||||
poly._jalanStatus = row.status_jalan;
|
||||
allLayers[row.id] = poly;
|
||||
|
||||
poly.bindPopup(buildInfoPopup(row), { maxWidth: 300 });
|
||||
layerGroup.addLayer(poly);
|
||||
}
|
||||
|
||||
function buildInfoPopup(row) {
|
||||
const color = getColor(row.status_jalan);
|
||||
const panjang = row.panjang_meter
|
||||
? parseFloat(row.panjang_meter) >= 1000
|
||||
? (parseFloat(row.panjang_meter) / 1000).toFixed(2) + ' km'
|
||||
: parseFloat(row.panjang_meter).toFixed(1) + ' m'
|
||||
: '—';
|
||||
|
||||
return `
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${color}22;">🛣️</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(row.nama_jalan)}</div>
|
||||
<div class="info-popup-id">#${row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🏷️</div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Jalan</div>
|
||||
<div class="info-row-value">
|
||||
<span class="status-badge-inline"
|
||||
style="background:${color}22;color:${color};border:1px solid ${color}55;">
|
||||
Jalan ${escapeHTML(row.status_jalan)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📏</div>
|
||||
<div>
|
||||
<div class="info-row-label">Panjang Jalan</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);">${panjang}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-hapus" onclick="window._jalanHapus(${row.id}, this)">🗑 Hapus Jalan</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Load data ───────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allLayers = {};
|
||||
|
||||
fetch('api/jalan/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '🛣️');
|
||||
j.data.forEach(addPolylineToMap);
|
||||
refreshJalanList(j.data);
|
||||
})
|
||||
.catch(err => { showToast('Gagal memuat data jalan.', 'error'); console.error(err); });
|
||||
}
|
||||
|
||||
// ── Refresh panel list Data Jalan ──────────────────────────────────
|
||||
function refreshJalanList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const items = (data || []).map(row => ({
|
||||
id: row.id,
|
||||
name: row.nama_jalan,
|
||||
badge: row.status_jalan,
|
||||
badgeColor: COLORS[row.status_jalan] || '#888',
|
||||
dotColor: COLORS[row.status_jalan] || '#888'
|
||||
}));
|
||||
window.dlRefreshList('Jalan', items);
|
||||
}
|
||||
|
||||
// ── Hapus jalan ───────────────────────────────────
|
||||
window._jalanHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus data jalan ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/jalan/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
|
||||
updateCount(Object.keys(allLayers).length, '🛣️');
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const remaining = Object.values(allLayers).map(l => ({
|
||||
id: l._jalanId, name: l._jalanNama,
|
||||
badge: l._jalanStatus,
|
||||
badgeColor: COLORS[l._jalanStatus] || '#888',
|
||||
dotColor: COLORS[l._jalanStatus] || '#888'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Jalan', remaining);
|
||||
showToast('Data jalan berhasil dihapus.');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Jalan';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Drawing: gambar polyline baru ────────────────
|
||||
let drawingPoints = [];
|
||||
let drawingLayer = null; // polyline permanen per point click
|
||||
let liveLineLayer = null; // polyline preview kursor ke ujung terakhir
|
||||
let liveTooltip = null; // tooltip info ukuran
|
||||
let drawingMarkers = []; // titik-titik sementara
|
||||
let isDrawing = false;
|
||||
let lastClickAt = 0;
|
||||
|
||||
function startDrawing() {
|
||||
isDrawing = true;
|
||||
drawingPoints = [];
|
||||
lastClickAt = 0;
|
||||
document.getElementById('drawHint').classList.add('visible');
|
||||
map.doubleClickZoom.disable();
|
||||
}
|
||||
|
||||
function stopDrawing(save) {
|
||||
isDrawing = false;
|
||||
document.getElementById('drawHint').classList.remove('visible');
|
||||
map.doubleClickZoom.enable();
|
||||
|
||||
// Bersihkan preview
|
||||
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
|
||||
if (liveLineLayer) { map.removeLayer(liveLineLayer); liveLineLayer = null; }
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
drawingMarkers.forEach(m => map.removeLayer(m));
|
||||
drawingMarkers = [];
|
||||
|
||||
if (save && drawingPoints.length >= 2) {
|
||||
showFormJalan(drawingPoints.slice());
|
||||
}
|
||||
drawingPoints = [];
|
||||
|
||||
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (!isDrawing) return;
|
||||
if (drawingPoints.length >= 2) {
|
||||
stopDrawing(true);
|
||||
} else {
|
||||
showToast('Minimal 2 titik untuk membuat jalan (Polyline)', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan') return;
|
||||
if (!isDrawing) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Deteksi double-click: klik kedua dalam 350ms → finalisasi tanpa menambah titik
|
||||
if (now - lastClickAt < 350) {
|
||||
lastClickAt = 0;
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
lastClickAt = now;
|
||||
|
||||
const latlng = e.latlng;
|
||||
drawingPoints.push(latlng);
|
||||
|
||||
// Titik visual
|
||||
const m = L.circleMarker(latlng, { radius: 5, color: '#388bfd', fillColor: '#388bfd', fillOpacity: 1, pane: 'drawPane' });
|
||||
m.addTo(map);
|
||||
drawingMarkers.push(m);
|
||||
|
||||
// Preview polyline
|
||||
if (drawingLayer) map.removeLayer(drawingLayer);
|
||||
if (drawingPoints.length >= 2) {
|
||||
drawingLayer = L.polyline(drawingPoints, { color: '#388bfd', weight: 4, opacity: .9, pane: 'drawPane' }).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function onMapDoubleClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
|
||||
if (e.originalEvent) L.DomEvent.stop(e.originalEvent);
|
||||
finishDrawing();
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
|
||||
if (drawingPoints.length === 0) {
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPoint = drawingPoints[drawingPoints.length - 1];
|
||||
const currentPoint = e.latlng;
|
||||
|
||||
// Gambar garis tipis (preview) dari titik terakhir ke cursor
|
||||
if (liveLineLayer) map.removeLayer(liveLineLayer);
|
||||
liveLineLayer = L.polyline([lastPoint, currentPoint], {
|
||||
color: '#79c0ff', weight: 2, dashArray: '5,6', opacity: 0.6, pane: 'drawPane'
|
||||
}).addTo(map);
|
||||
|
||||
// Hitung total panjang sampai titik ini
|
||||
let totalMeter = 0;
|
||||
for (let i = 0; i < drawingPoints.length - 1; i++) {
|
||||
totalMeter += drawingPoints[i].distanceTo(drawingPoints[i + 1]);
|
||||
}
|
||||
totalMeter += lastPoint.distanceTo(currentPoint);
|
||||
|
||||
const txt = (totalMeter >= 1000)
|
||||
? (totalMeter / 1000).toFixed(2) + ' km'
|
||||
: totalMeter.toFixed(1) + ' m';
|
||||
|
||||
// Tampilkan tooltip berjalan
|
||||
if (!liveTooltip) {
|
||||
liveTooltip = L.tooltip({ permanent: true, className: 'live-tooltip', direction: 'auto', offset: [15, 15], pane: 'drawPane' })
|
||||
.setLatLng(currentPoint)
|
||||
.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai<br>Panjang: ' + txt)
|
||||
.addTo(map);
|
||||
} else {
|
||||
liveTooltip.setLatLng(currentPoint);
|
||||
liveTooltip.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai<br>Panjang: ' + txt);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'jalan' || !isDrawing) return;
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
}
|
||||
if (e.key === 'Escape') stopDrawing(false);
|
||||
}
|
||||
|
||||
// ── Form popup setelah gambar selesai ─────────────
|
||||
function showFormJalan(points) {
|
||||
// Hitung panjang via Leaflet
|
||||
let totalMeter = 0;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
totalMeter += points[i].distanceTo(points[i + 1]);
|
||||
}
|
||||
totalMeter = totalMeter.toFixed(2);
|
||||
|
||||
// Build GeoJSON LineString
|
||||
const geojson = JSON.stringify({
|
||||
type: 'LineString',
|
||||
coordinates: points.map(p => [p.lng, p.lat])
|
||||
});
|
||||
|
||||
// Popup di titik tengah
|
||||
const midIdx = Math.floor(points.length / 2);
|
||||
const midPt = points[midIdx];
|
||||
|
||||
L.popup({ maxWidth: 340, closeOnClick: false, closeButton: true })
|
||||
.setLatLng(midPt)
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon blue">🛣️</div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Data Jalan</div>
|
||||
<div class="form-popup-coords">${points.length} titik · ${totalMeter} m</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Jalan *</label>
|
||||
<input type="text" id="fj_nama" placeholder="cth. Jl. Ahmad Yani" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Jalan *</label>
|
||||
<select id="fj_status">
|
||||
<option value="Nasional">Jalan Nasional</option>
|
||||
<option value="Provinsi">Jalan Provinsi</option>
|
||||
<option value="Kabupaten" selected>Jalan Kabupaten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Panjang Jalan (otomatis)</label>
|
||||
<div class="input-readonly">${totalMeter} meter</div>
|
||||
</div>
|
||||
<input type="hidden" id="fj_geojson" value='${escapeHTML(geojson)}'>
|
||||
<input type="hidden" id="fj_panjang" value="${totalMeter}">
|
||||
<button class="btn-save" id="btnSimpanJalan" onclick="window._jalanSimpan()">💾 Simpan Jalan</button>
|
||||
<div class="form-status" id="jalanStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
.openOn(map);
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('fj_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Kalau popup ditutup tanpa simpan: hapus preview
|
||||
map.once('popupclose', () => {
|
||||
// preview sudah di-clear di stopDrawing
|
||||
});
|
||||
}
|
||||
|
||||
// ── Simpan jalan ──────────────────────────────────
|
||||
window._jalanSimpan = function () {
|
||||
const nama = document.getElementById('fj_nama').value.trim();
|
||||
const status = document.getElementById('fj_status').value;
|
||||
const geojson = document.getElementById('fj_geojson').value;
|
||||
const panjang = document.getElementById('fj_panjang').value;
|
||||
const statusEl = document.getElementById('jalanStatus');
|
||||
const btn = document.getElementById('btnSimpanJalan');
|
||||
|
||||
if (!nama) {
|
||||
statusEl.textContent = '⚠ Nama jalan wajib diisi.';
|
||||
statusEl.className = 'form-status error';
|
||||
document.getElementById('fj_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_jalan', nama);
|
||||
fd.append('status_jalan', status);
|
||||
fd.append('geojson', geojson);
|
||||
fd.append('panjang_meter', panjang);
|
||||
|
||||
fetch('api/jalan/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
addPolylineToMap(j.data);
|
||||
updateCount(Object.keys(allLayers).length, '🛣️');
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const allItems = Object.values(allLayers).map(l => ({
|
||||
id: l._jalanId, name: l._jalanNama,
|
||||
badge: l._jalanStatus,
|
||||
badgeColor: COLORS[l._jalanStatus] || '#888',
|
||||
dotColor: COLORS[l._jalanStatus] || '#888'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Jalan', allItems);
|
||||
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = '✕ ' + err.message;
|
||||
statusEl.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Jalan';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init / cleanup ───────────────────────────────────────────
|
||||
window.initJalan = function () {
|
||||
if (!active) {
|
||||
active = true;
|
||||
map.on('click', onMapClick);
|
||||
map.on('dblclick', onMapDoubleClick);
|
||||
map.on('mousemove', onMouseMove);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
// Daftarkan fungsi focus untuk panel list
|
||||
window._dlFocusFns['Jalan'] = function(id) {
|
||||
const layer = allLayers[id];
|
||||
if (!layer) return;
|
||||
map.fitBounds(layer.getBounds(), { padding: [40, 40], animate: true });
|
||||
setTimeout(() => layer.openPopup(layer.getBounds().getCenter()), 400);
|
||||
};
|
||||
|
||||
layerGroup.addTo(map);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Ekspos ke global agar bisa dipanggil dari index.php
|
||||
window._jalanStartDraw = startDrawing;
|
||||
window._jalanFinishDraw = finishDrawing;
|
||||
window._jalanStopDraw = function() { stopDrawing(false); };
|
||||
|
||||
// Filter Toggle
|
||||
window._jalanToggleLayer = function(visible) {
|
||||
if (visible) {
|
||||
map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
stopDrawing(false); // Batalkan mode draw jika layer disembunyikan
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,459 @@
|
||||
// modules/parsil.js — Logika Pertemuan 2: Manajemen Parsil Tanah (Polygon)
|
||||
|
||||
(function () {
|
||||
const layerGroup = L.layerGroup();
|
||||
let allLayers = {}; // { id: polygonObject }
|
||||
let active = false;
|
||||
|
||||
// ── Warna per status kepemilikan ──────────────────
|
||||
const PARSIL_COLORS = {
|
||||
'SHM': '#3b82f6',
|
||||
'HGB': '#8b5cf6',
|
||||
'HGU': '#ec4899',
|
||||
'HP': '#14b8a6'
|
||||
};
|
||||
|
||||
function getColor(status) {
|
||||
return PARSIL_COLORS[status] || '#888';
|
||||
}
|
||||
|
||||
// ── Hitung Luas Polygon (Spherical Area) ──────────
|
||||
function calculatePolygonArea(latLngs) {
|
||||
let radius = 6378137; // Earth's radius in meters
|
||||
let area = 0;
|
||||
let d2r = Math.PI / 180;
|
||||
|
||||
if (latLngs.length > 2) {
|
||||
for (let i = 0; i < latLngs.length; i++) {
|
||||
let p1 = latLngs[i];
|
||||
let p2 = latLngs[(i + 1) % latLngs.length];
|
||||
area += ((p2.lng - p1.lng) * d2r) * (2 + Math.sin(p1.lat * d2r) + Math.sin(p2.lat * d2r));
|
||||
}
|
||||
area = area * radius * radius / 2.0;
|
||||
}
|
||||
return Math.abs(area);
|
||||
}
|
||||
|
||||
// ── Render polygon ────────────────────────────────
|
||||
function addPolygonToMap(row) {
|
||||
let coords;
|
||||
try {
|
||||
const gj = typeof row.geojson === 'string' ? JSON.parse(row.geojson) : row.geojson;
|
||||
// GeoJSON Polygon coordinates are MultiArray: [[[lng, lat], [lng, lat]...]]
|
||||
// We just need the exterior ring [0] maps to latlng
|
||||
let extRing = gj.coordinates[0];
|
||||
coords = extRing.map(c => [c[1], c[0]]);
|
||||
} catch (e) {
|
||||
console.error('GeoJSON parse error (parsil id=' + row.id + ')', e);
|
||||
return;
|
||||
}
|
||||
|
||||
const poly = L.polygon(coords, {
|
||||
color: getColor(row.status_kepemilikan),
|
||||
fillColor: getColor(row.status_kepemilikan),
|
||||
weight: 2,
|
||||
fillOpacity: 0.4,
|
||||
pane: 'parsilPane'
|
||||
});
|
||||
|
||||
poly._parsilId = row.id;
|
||||
poly._parsilNama = row.nama_parsil;
|
||||
poly._parsilStatus = row.status_kepemilikan;
|
||||
allLayers[row.id] = poly;
|
||||
|
||||
poly.bindPopup(buildInfoPopup(row), { maxWidth: 300 });
|
||||
layerGroup.addLayer(poly);
|
||||
}
|
||||
|
||||
function buildInfoPopup(row) {
|
||||
const color = getColor(row.status_kepemilikan);
|
||||
const luas = row.luas_m2
|
||||
? parseFloat(row.luas_m2).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + ' m²'
|
||||
: '—';
|
||||
|
||||
return `
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${color}22;">🏘️</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(row.nama_parsil)}</div>
|
||||
<div class="info-popup-id">#${row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📜</div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Kepemilikan</div>
|
||||
<div class="info-row-value">
|
||||
<span class="status-badge-inline"
|
||||
style="background:${color}22;color:${color};border:1px solid ${color}55;">
|
||||
${escapeHTML(row.status_kepemilikan)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📐</div>
|
||||
<div>
|
||||
<div class="info-row-label">Luas Tanah</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);">${luas}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-hapus" onclick="window._parsilHapus(${row.id}, this)">🗑 Hapus Parsil</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Load data ───────────────────────────────────────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allLayers = {};
|
||||
|
||||
fetch('api/parsil/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '🏘️');
|
||||
j.data.forEach(addPolygonToMap);
|
||||
refreshParsilList(j.data);
|
||||
})
|
||||
.catch(err => { showToast('Gagal memuat data parsil.', 'error'); console.error(err); });
|
||||
}
|
||||
|
||||
// ── Refresh panel list Parsil Tanah ─────────────────────────────────
|
||||
function refreshParsilList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const items = (data || []).map(row => ({
|
||||
id: row.id,
|
||||
name: row.nama_parsil,
|
||||
badge: row.status_kepemilikan,
|
||||
badgeColor: getColor(row.status_kepemilikan),
|
||||
dotColor: getColor(row.status_kepemilikan)
|
||||
}));
|
||||
window.dlRefreshList('Parsil', items);
|
||||
}
|
||||
|
||||
// ── Hapus parsil ──────────────────────────────────
|
||||
window._parsilHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus data parsil tanah ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/parsil/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
|
||||
updateCount(Object.keys(allLayers).length, '🏘️');
|
||||
const remaining = Object.values(allLayers).map(l => ({
|
||||
id: l._parsilId, name: l._parsilNama,
|
||||
badge: l._parsilStatus,
|
||||
badgeColor: getColor(l._parsilStatus),
|
||||
dotColor: getColor(l._parsilStatus)
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Parsil', remaining);
|
||||
showToast('Data parsil berhasil dihapus.');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Parsil';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Drawing: gambar polygon baru ──────────────────
|
||||
let drawingPoints = [];
|
||||
let drawingLayer = null; // polyline/polygon sementara
|
||||
let liveLineLayer = null; // polyline preview kursor
|
||||
let liveTooltip = null; // tooltip info ukuran
|
||||
let drawingMarkers = []; // titik-titik sementara
|
||||
let isDrawing = false;
|
||||
let lastClickAt = 0;
|
||||
|
||||
function startDrawing() {
|
||||
isDrawing = true;
|
||||
drawingPoints = [];
|
||||
lastClickAt = 0;
|
||||
document.getElementById('drawHint').classList.add('visible');
|
||||
map.doubleClickZoom.disable();
|
||||
}
|
||||
|
||||
function stopDrawing(save) {
|
||||
isDrawing = false;
|
||||
document.getElementById('drawHint').classList.remove('visible');
|
||||
map.doubleClickZoom.enable();
|
||||
|
||||
// Bersihkan preview
|
||||
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
|
||||
if (liveLineLayer) { map.removeLayer(liveLineLayer); liveLineLayer = null; }
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
drawingMarkers.forEach(m => map.removeLayer(m));
|
||||
drawingMarkers = [];
|
||||
|
||||
if (save && drawingPoints.length >= 3) {
|
||||
showFormParsil(drawingPoints.slice());
|
||||
} else if (save && drawingPoints.length < 3) {
|
||||
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
|
||||
}
|
||||
drawingPoints = [];
|
||||
|
||||
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (!isDrawing) return;
|
||||
if (drawingPoints.length >= 3) {
|
||||
stopDrawing(true);
|
||||
} else {
|
||||
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil') return;
|
||||
if (!isDrawing) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Deteksi double-click: klik kedua dalam 350ms → finalisasi tanpa menambah titik
|
||||
if (now - lastClickAt < 350) {
|
||||
lastClickAt = 0;
|
||||
finishDrawing();
|
||||
return;
|
||||
}
|
||||
lastClickAt = now;
|
||||
|
||||
const latlng = e.latlng;
|
||||
drawingPoints.push(latlng);
|
||||
|
||||
// Titik visual
|
||||
const m = L.circleMarker(latlng, { radius: 5, color: '#eab308', fillColor: '#eab308', fillOpacity: 1, pane: 'drawPane' });
|
||||
m.addTo(map);
|
||||
drawingMarkers.push(m);
|
||||
|
||||
// Preview polygon
|
||||
if (drawingLayer) map.removeLayer(drawingLayer);
|
||||
if (drawingPoints.length >= 3) {
|
||||
drawingLayer = L.polygon(drawingPoints, { color: '#eab308', weight: 2, fillOpacity: 0.3, pane: 'drawPane' }).addTo(map);
|
||||
} else if (drawingPoints.length === 2) {
|
||||
drawingLayer = L.polyline(drawingPoints, { color: '#eab308', weight: 2, dashArray: '6,4', opacity: 0.7, pane: 'drawPane' }).addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
function onMapDoubleClick(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
|
||||
if (e.originalEvent) L.DomEvent.stop(e.originalEvent);
|
||||
finishDrawing();
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
|
||||
if (drawingPoints.length === 0) {
|
||||
if (liveTooltip) { map.removeLayer(liveTooltip); liveTooltip = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
const lastPoint = drawingPoints[drawingPoints.length - 1];
|
||||
const currentPoint = e.latlng;
|
||||
|
||||
// Gambar garis tipis preview dari titik terakhir ke cursor (dan dari kursor ke titik awal jika >= 2 titik)
|
||||
if (liveLineLayer) map.removeLayer(liveLineLayer);
|
||||
|
||||
const previewCoords = [lastPoint, currentPoint];
|
||||
if (drawingPoints.length >= 2) {
|
||||
previewCoords.push(drawingPoints[0]); // Garis penutup bayangan
|
||||
}
|
||||
|
||||
liveLineLayer = L.polyline(previewCoords, {
|
||||
color: '#eab308', weight: 2, dashArray: '5,6', opacity: 0.6, pane: 'drawPane'
|
||||
}).addTo(map);
|
||||
|
||||
// Hitung total luas jika + titik baru ini (minimal 3 titik)
|
||||
let txt = '';
|
||||
if (drawingPoints.length >= 2) {
|
||||
const tempPoints = [...drawingPoints, currentPoint];
|
||||
let luasArea = calculatePolygonArea(tempPoints);
|
||||
txt = '<br>Luas: ' + parseFloat(luasArea.toFixed(2)).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + ' m²';
|
||||
}
|
||||
|
||||
// Tampilkan tooltip berjalan
|
||||
if (!liveTooltip) {
|
||||
liveTooltip = L.tooltip({ permanent: true, className: 'live-tooltip', direction: 'auto', offset: [15, 15], pane: 'drawPane' })
|
||||
.setLatLng(currentPoint)
|
||||
.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai' + txt)
|
||||
.addTo(map);
|
||||
} else {
|
||||
liveTooltip.setLatLng(currentPoint);
|
||||
liveTooltip.setContent('<b>➕ Lanjut</b> · <kbd>Enter</kbd> / 2×klik selesai' + txt);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil' || !isDrawing) return;
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
e.preventDefault();
|
||||
finishDrawing();
|
||||
}
|
||||
if (e.key === 'Escape') stopDrawing(false);
|
||||
}
|
||||
|
||||
// ── Form popup setelah gambar selesai ─────────────
|
||||
function showFormParsil(points) {
|
||||
// Hitung luas area
|
||||
let luasArea = calculatePolygonArea(points);
|
||||
luasArea = luasArea.toFixed(2);
|
||||
|
||||
let luasStr = parseFloat(luasArea).toLocaleString('id-ID', { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||
|
||||
// Build GeoJSON Polygon
|
||||
let geojsonPoints = points.map(p => [p.lng, p.lat]);
|
||||
// GeoJSON First and last position must be strictly equivalent
|
||||
geojsonPoints.push([points[0].lng, points[0].lat]);
|
||||
|
||||
const geojson = JSON.stringify({
|
||||
type: 'Polygon',
|
||||
coordinates: [geojsonPoints]
|
||||
});
|
||||
|
||||
// Popup di bounds / center polygon
|
||||
const tempPoly = L.polygon(points);
|
||||
const centerPt = tempPoly.getBounds().getCenter();
|
||||
|
||||
L.popup({ maxWidth: 340, closeOnClick: false, closeButton: true })
|
||||
.setLatLng(centerPt)
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon amber">🏘️</div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Data Parsil (Kavling)</div>
|
||||
<div class="form-popup-coords">${points.length} titik · Luas : ${luasStr} m²</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama/Keterangan Parsil *</label>
|
||||
<input type="text" id="fp_nama" placeholder="cth. Kavling Blok C" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Status Kepemilikan *</label>
|
||||
<select id="fp_status">
|
||||
<option value="SHM" selected>Sertifikat Hak Milik (SHM)</option>
|
||||
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||
<option value="HP">Hak Pakai (HP)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Luas Tanah (otomatis)</label>
|
||||
<div class="input-readonly">${luasStr} m²</div>
|
||||
</div>
|
||||
<input type="hidden" id="fp_geojson" value='${escapeHTML(geojson)}'>
|
||||
<input type="hidden" id="fp_luas" value="${luasArea}">
|
||||
<button class="btn-save" id="btnSimpanParsil" style="background:var(--c-warn);color:#111" onclick="window._parsilSimpan()">💾 Simpan Parsil</button>
|
||||
<div class="form-status" id="parsilStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
.openOn(map);
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('fp_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Kalau popup ditutup tanpa simpan: hapus preview
|
||||
map.once('popupclose', () => {
|
||||
// preview sudah di-clear di stopDrawing
|
||||
});
|
||||
}
|
||||
|
||||
// ── Simpan parsil ─────────────────────────────────
|
||||
window._parsilSimpan = function () {
|
||||
const nama = document.getElementById('fp_nama').value.trim();
|
||||
const status = document.getElementById('fp_status').value;
|
||||
const geojson = document.getElementById('fp_geojson').value;
|
||||
const luas = document.getElementById('fp_luas').value;
|
||||
const statusEl = document.getElementById('parsilStatus');
|
||||
const btn = document.getElementById('btnSimpanParsil');
|
||||
|
||||
if (!nama) {
|
||||
statusEl.textContent = '⚠ Nama parsil wajib diisi.';
|
||||
statusEl.className = 'form-status error';
|
||||
document.getElementById('fp_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_parsil', nama);
|
||||
fd.append('status_kepemilikan', status);
|
||||
fd.append('geojson', geojson);
|
||||
fd.append('luas_m2', luas);
|
||||
|
||||
fetch('api/parsil/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
addPolygonToMap(j.data);
|
||||
updateCount(Object.keys(allLayers).length, '🏘️');
|
||||
const allItems = Object.values(allLayers).map(l => ({
|
||||
id: l._parsilId, name: l._parsilNama,
|
||||
badge: l._parsilStatus,
|
||||
badgeColor: getColor(l._parsilStatus),
|
||||
dotColor: getColor(l._parsilStatus)
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Parsil', allItems);
|
||||
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = '✕ ' + err.message;
|
||||
statusEl.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Parsil';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init / cleanup ────────────────────────────────────────────
|
||||
window.initParsil = function () {
|
||||
if (!active) {
|
||||
active = true;
|
||||
map.on('click', onMapClick);
|
||||
map.on('dblclick', onMapDoubleClick);
|
||||
map.on('mousemove', onMouseMove);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
}
|
||||
|
||||
// Daftarkan fungsi focus untuk panel list
|
||||
window._dlFocusFns['Parsil'] = function(id) {
|
||||
const layer = allLayers[id];
|
||||
if (!layer) return;
|
||||
map.fitBounds(layer.getBounds(), { padding: [60, 60], animate: true });
|
||||
setTimeout(() => layer.openPopup(layer.getBounds().getCenter()), 400);
|
||||
};
|
||||
|
||||
layerGroup.addTo(map);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Ekspos ke global agar bisa dipanggil dari index.php
|
||||
window._parsilStartDraw = startDrawing;
|
||||
window._parsilFinishDraw = finishDrawing;
|
||||
window._parsilStopDraw = function() { stopDrawing(false); };
|
||||
|
||||
// Filter Toggle
|
||||
window._parsilToggleLayer = function(visible) {
|
||||
if (visible) {
|
||||
map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
stopDrawing(false); // Batalkan mode draw jika layer disembunyikan
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,315 @@
|
||||
// modules/point.js — Logika Pertemuan 1: Point of Interest (POI)
|
||||
// Dipanggil oleh index.php saat mode = 1
|
||||
|
||||
(function () {
|
||||
// ── Layer group ──────────────────────────────────
|
||||
const layerGroup = L.layerGroup();
|
||||
let allMarkers = {}; // { id: markerObject }
|
||||
let tempMarker = null;
|
||||
let active = false;
|
||||
|
||||
// ── Custom Icons ─────────────────────────────────
|
||||
function createIcon(color = '#2ea043') {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">
|
||||
<filter id="sh"><feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity=".4"/></filter>
|
||||
<path d="M14 0C6.268 0 0 6.268 0 14C0 24.5 14 38 14 38C14 38 28 24.5 28 14C28 6.268 21.732 0 14 0Z"
|
||||
fill="${color}" filter="url(#sh)"/>
|
||||
<circle cx="14" cy="14" r="6" fill="white" opacity=".9"/>
|
||||
</svg>`,
|
||||
iconSize: [28, 38],
|
||||
iconAnchor: [14, 38],
|
||||
popupAnchor: [0, -40]
|
||||
});
|
||||
}
|
||||
|
||||
const iconDefault = createIcon('#2ea043');
|
||||
const iconClosed = createIcon('#f85149');
|
||||
const iconTemp = createIcon('#e3b341');
|
||||
|
||||
// ── Render marker on map ─────────────────────────
|
||||
function addMarkerToMap(poi) {
|
||||
const isOpen = Number(poi.buka_24jam) === 1;
|
||||
const marker = L.marker(
|
||||
[poi.latitude, poi.longitude],
|
||||
{ icon: isOpen ? iconDefault : iconClosed, draggable: true, pane: 'pointPane' }
|
||||
);
|
||||
|
||||
marker._poiId = poi.id;
|
||||
marker._poiNama = poi.nama_tempat;
|
||||
marker._poiBuka = Number(poi.buka_24jam) === 1;
|
||||
allMarkers[poi.id] = marker;
|
||||
|
||||
// Drag to update position
|
||||
marker.on('dragend', function (e) {
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
const fd = new FormData();
|
||||
fd.append('id', poi.id);
|
||||
fd.append('latitude', lat.toFixed(8));
|
||||
fd.append('longitude', lng.toFixed(8));
|
||||
|
||||
fetch('api/point/update_posisi.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') showToast(`Posisi "${escapeHTML(poi.nama_tempat)}" diperbarui!`);
|
||||
else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
|
||||
// Popup info
|
||||
const noWa = poi.no_wa ? poi.no_wa.replace(/\D/g, '') : '';
|
||||
const waBtn = noWa
|
||||
? `<a class="btn-wa" href="https://wa.me/${noWa}" target="_blank" rel="noopener"
|
||||
style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:14px;padding:9px;background:#25d366;color:#fff;border:none;border-radius:7px;font-family:var(--font-body);font-size:13px;font-weight:700;text-decoration:none;">
|
||||
💬 Chat WhatsApp
|
||||
</a>`
|
||||
: `<div style="font-size:12px;color:var(--c-muted);text-align:center;padding:8px 0;margin-top:8px;">Tidak ada nomor WA</div>`;
|
||||
|
||||
const buka24Badge = isOpen
|
||||
? `<span class="status-badge-inline" style="background:rgba(46,160,67,.2);color:#3fb950;border:1px solid rgba(46,160,67,.35);">🟢 Buka 24 Jam</span>`
|
||||
: `<span class="status-badge-inline" style="background:rgba(248,81,73,.12);color:#f85149;border:1px solid rgba(248,81,73,.3);">🔴 Tidak 24 Jam</span>`;
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon">📍</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(poi.nama_tempat)}</div>
|
||||
<div class="info-popup-id">#${poi.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📞</div>
|
||||
<div>
|
||||
<div class="info-row-label">No. WhatsApp</div>
|
||||
<div class="info-row-value">${poi.no_wa ? escapeHTML(poi.no_wa) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🕐</div>
|
||||
<div>
|
||||
<div class="info-row-label">Jam Operasional</div>
|
||||
<div class="info-row-value">${buka24Badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🌐</div>
|
||||
<div>
|
||||
<div class="info-row-label">Koordinat</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
|
||||
${parseFloat(poi.latitude).toFixed(6)}, ${parseFloat(poi.longitude).toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${waBtn}
|
||||
<button class="btn-hapus" onclick="window._pointHapus(${poi.id}, this)">🗑 Hapus Lokasi</button>
|
||||
</div>
|
||||
`, { maxWidth: 300 });
|
||||
|
||||
layerGroup.addLayer(marker);
|
||||
}
|
||||
|
||||
// ── Load semua POI dari server ──────────────────
|
||||
function loadData() {
|
||||
layerGroup.clearLayers();
|
||||
allMarkers = {};
|
||||
|
||||
fetch('api/point/ambil.php?_=' + Date.now(), { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '📍');
|
||||
j.data.forEach(addMarkerToMap);
|
||||
refreshPointList(j.data);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Gagal memuat data POI.', 'error');
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Refresh panel list Lokasi Usaha ──────────────────
|
||||
function refreshPointList(data) {
|
||||
if (typeof window.dlRefreshList !== 'function') return;
|
||||
const items = (data || []).map(poi => ({
|
||||
id: poi.id,
|
||||
name: poi.nama_tempat,
|
||||
badge: Number(poi.buka_24jam) === 1 ? '24 Jam' : 'Non-24',
|
||||
badgeColor: Number(poi.buka_24jam) === 1 ? '#2ea043' : '#f85149',
|
||||
dotColor: Number(poi.buka_24jam) === 1 ? '#2ea043' : '#f85149'
|
||||
}));
|
||||
window.dlRefreshList('Point', items);
|
||||
}
|
||||
|
||||
// ── Hapus marker ────────────────────────────────────
|
||||
window._pointHapus = function (id, btnEl) {
|
||||
showDeleteConfirm('Yakin ingin menghapus lokasi ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
|
||||
fetch('api/point/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) { layerGroup.removeLayer(allMarkers[id]); delete allMarkers[id]; }
|
||||
updateCount(Math.max(0, Object.keys(allMarkers).length), '📍');
|
||||
// rebuild list dari allMarkers (ambil nama dari marker)
|
||||
const remaining = Object.keys(allMarkers).map(k => ({
|
||||
id: k,
|
||||
name: allMarkers[k]._poiNama || ('#' + k),
|
||||
badge: allMarkers[k]._poiBuka ? '24 Jam' : 'Non-24',
|
||||
badgeColor: allMarkers[k]._poiBuka ? '#2ea043' : '#f85149',
|
||||
dotColor: allMarkers[k]._poiBuka ? '#2ea043' : '#f85149'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Point', remaining);
|
||||
showToast('Lokasi berhasil dihapus.');
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Lokasi';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// ── Klik peta = form tambah POI ──────────────────
|
||||
function onMapClick(e) {
|
||||
if (currentMode !== 1) return;
|
||||
|
||||
const lat = e.latlng.lat.toFixed(8);
|
||||
const lng = e.latlng.lng.toFixed(8);
|
||||
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([lat, lng], { icon: iconTemp, pane: 'drawPane' }).addTo(map);
|
||||
|
||||
L.popup({ maxWidth: 320, closeOnClick: false })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon">➕</div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Lokasi Baru</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Tempat *</label>
|
||||
<input type="text" id="f_nama" placeholder="cth. Warung Kopi Aroma" maxlength="100" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>No. WhatsApp</label>
|
||||
<input type="text" id="f_wa" placeholder="628xxxxxxxxxx" maxlength="15" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Buka 24 Jam?</label>
|
||||
<select id="f_buka24">
|
||||
<option value="0">❌ Tidak</option>
|
||||
<option value="1">✅ Ya</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" id="f_lat" value="${lat}">
|
||||
<input type="hidden" id="f_lng" value="${lng}">
|
||||
<button class="btn-save" id="btnSimpanPoi" onclick="window._pointSimpan()">💾 Simpan Lokasi</button>
|
||||
<div class="form-status" id="poiStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
.openOn(map);
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('f_nama'); if (el) el.focus(); }, 200);
|
||||
}
|
||||
|
||||
// ── Simpan POI ────────────────────────────────────
|
||||
window._pointSimpan = function () {
|
||||
const nama = document.getElementById('f_nama').value.trim();
|
||||
const wa = document.getElementById('f_wa').value.trim();
|
||||
const buka24 = document.getElementById('f_buka24').value;
|
||||
const lat = document.getElementById('f_lat').value;
|
||||
const lng = document.getElementById('f_lng').value;
|
||||
const status = document.getElementById('poiStatus');
|
||||
const btn = document.getElementById('btnSimpanPoi');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama tempat wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
document.getElementById('f_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_tempat', nama);
|
||||
fd.append('no_wa', wa);
|
||||
fd.append('buka_24jam', buka24);
|
||||
fd.append('latitude', lat);
|
||||
fd.append('longitude', lng);
|
||||
|
||||
fetch('api/point/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
addMarkerToMap(j.data);
|
||||
updateCount(Object.keys(allMarkers).length, '📍');
|
||||
// Tambahkan item baru ke list
|
||||
const allItems = Object.values(allMarkers).map(m => ({
|
||||
id: m._poiId,
|
||||
name: m._poiNama || ('#' + m._poiId),
|
||||
badge: m._poiBuka ? '24 Jam' : 'Non-24',
|
||||
badgeColor: m._poiBuka ? '#2ea043' : '#f85149',
|
||||
dotColor: m._poiBuka ? '#2ea043' : '#f85149'
|
||||
}));
|
||||
if (typeof window.dlRefreshList === 'function') window.dlRefreshList('Point', allItems);
|
||||
showToast(`"${escapeHTML(nama)}" berhasil disimpan!`);
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.textContent = '✕ ' + err.message;
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Lokasi';
|
||||
});
|
||||
};
|
||||
|
||||
// ── Init / cleanup ────────────────────────────────────
|
||||
window.initPoint = function () {
|
||||
if (active) { /* already added to map, just refresh data if needed */ loadData(); return; }
|
||||
active = true;
|
||||
|
||||
layerGroup.addTo(map);
|
||||
map.on('click', onMapClick);
|
||||
|
||||
// Daftarkan fungsi focus untuk panel list
|
||||
window._dlFocusFns['Point'] = function(id) {
|
||||
const marker = allMarkers[id];
|
||||
if (!marker) return;
|
||||
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 17), { animate: true });
|
||||
setTimeout(() => marker.openPopup(), 350);
|
||||
};
|
||||
|
||||
loadData();
|
||||
};
|
||||
|
||||
// Filter Toggle
|
||||
window._pointToggleLayer = function(visible) {
|
||||
if (visible) {
|
||||
map.addLayer(layerGroup);
|
||||
} else {
|
||||
map.removeLayer(layerGroup);
|
||||
// Juga tutup popup & bersihkan titik sementara jika ada
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
-- setup_database.sql — Project 01: WebGIS Choropleth Visualizer
|
||||
-- Jalankan: mysql -u root -p < setup_database.sql
|
||||
-- atau paste ke phpMyAdmin
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS db_webgis_01 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE db_webgis_01;
|
||||
|
||||
-- ── Admin users ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(100) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Default: admin / password
|
||||
INSERT IGNORE INTO users (username, password) VALUES (
|
||||
'admin',
|
||||
'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'
|
||||
);
|
||||
|
||||
-- ── Choropleth layers ─────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS choropleth_layers (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama VARCHAR(150) NOT NULL,
|
||||
deskripsi TEXT,
|
||||
geojson LONGTEXT NOT NULL,
|
||||
attribute_key VARCHAR(100) NOT NULL,
|
||||
palette ENUM('blue','orange','green','purple') DEFAULT 'blue',
|
||||
is_visible TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ── Point of Interest / Lokasi usaha ──────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS lokasi_usaha (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama_tempat VARCHAR(150) NOT NULL,
|
||||
no_wa VARCHAR(30) DEFAULT NULL,
|
||||
buka_24jam TINYINT(1) NOT NULL DEFAULT 0,
|
||||
latitude DECIMAL(10,8) NOT NULL,
|
||||
longitude DECIMAL(11,8) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Jalan polyline ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS data_jalan (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama_jalan VARCHAR(150) NOT NULL,
|
||||
status_jalan ENUM('Nasional','Provinsi','Kabupaten') NOT NULL DEFAULT 'Kabupaten',
|
||||
geojson LONGTEXT NOT NULL,
|
||||
panjang_meter DOUBLE NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Parsil polygon ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS data_parsil (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
nama_parsil VARCHAR(150) NOT NULL,
|
||||
status_kepemilikan ENUM('SHM','HGB','HGU','HP') NOT NULL DEFAULT 'SHM',
|
||||
geojson LONGTEXT NOT NULL,
|
||||
luas_m2 DOUBLE NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* simpan.php
|
||||
* Menerima data POST dari form dan menyimpannya ke tabel lokasi_usaha
|
||||
* Mengembalikan respons dalam format JSON
|
||||
*/
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
require_once 'koneksi.php';
|
||||
|
||||
// Hanya terima metode POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil dan sanitasi input
|
||||
$nama_tempat = trim($_POST['nama_tempat'] ?? '');
|
||||
$no_wa = trim($_POST['no_wa'] ?? '');
|
||||
$buka_24jam = isset($_POST['buka_24jam']) ? (int)$_POST['buka_24jam'] : 0;
|
||||
$latitude = $_POST['latitude'] ?? '';
|
||||
$longitude = $_POST['longitude'] ?? '';
|
||||
|
||||
// Validasi input wajib
|
||||
if (empty($nama_tempat) || empty($latitude) || empty($longitude)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama tempat, latitude, dan longitude wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi format angka koordinat
|
||||
if (!is_numeric($latitude) || !is_numeric($longitude)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Format koordinat tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$latitude = (float)$latitude;
|
||||
$longitude = (float)$longitude;
|
||||
$buka_24jam = ($buka_24jam === 1) ? 1 : 0;
|
||||
|
||||
// Prepared statement untuk mencegah SQL Injection
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
if (!$stmt) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// bind_param dengan tipe data yang benar
|
||||
$stmt->bind_param(
|
||||
'ssidd',
|
||||
$nama_tempat,
|
||||
$no_wa,
|
||||
$buka_24jam,
|
||||
$latitude,
|
||||
$longitude
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$new_id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan.',
|
||||
'data' => [
|
||||
'id' => $new_id,
|
||||
'nama_tempat' => $nama_tempat,
|
||||
'no_wa' => $no_wa,
|
||||
'buka_24jam' => $buka_24jam,
|
||||
'latitude' => $latitude,
|
||||
'longitude' => $longitude
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan data: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
function read_file_or_fail(string $path): string
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
fwrite(STDERR, "Missing file: {$path}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return file_get_contents($path);
|
||||
}
|
||||
|
||||
function assert_contains(string $haystack, string $needle, string $label): void
|
||||
{
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
fwrite(STDERR, "FAIL: {$label} must contain {$needle}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$index = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'index.php');
|
||||
$choropleth = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'choropleth.js');
|
||||
$point = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'point.js');
|
||||
$jalan = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'jalan.js');
|
||||
$parsil = read_file_or_fail($root . DIRECTORY_SEPARATOR . 'modules' . DIRECTORY_SEPARATOR . 'parsil.js');
|
||||
|
||||
foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'drawPane', 'pointPane'] as $pane) {
|
||||
assert_contains($index, "'{$pane}'", "index.php");
|
||||
}
|
||||
assert_contains($index, 'map.createPane(name)', 'index.php');
|
||||
assert_contains($index, '.leaflet-pane.is-hit-disabled *', 'choropleth disabled pane CSS');
|
||||
assert_contains($index, 'function setDrawBlockingLayerHitTesting(enabled)', 'draw blocking pane helper');
|
||||
foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane', 'drawPane'] as $pane) {
|
||||
assert_contains($index, "'{$pane}'", 'draw blocking pane list');
|
||||
}
|
||||
assert_contains($index, 'function setChoroplethHitTesting(enabled)', 'choropleth hit-testing helper');
|
||||
assert_contains($index, "pane.style.pointerEvents = enabled ? '' : 'none';", 'choropleth hit-testing toggle');
|
||||
assert_contains($index, "pane.classList.toggle('is-hit-disabled', !enabled);", 'choropleth disabled pane class toggle');
|
||||
assert_contains($index, 'window._choro.setHitTesting(enabled)', 'choropleth per-feature hit-testing toggle');
|
||||
assert_contains($index, 'setChoroplethHitTesting(false)', 'draw tool disables choropleth hit-testing');
|
||||
assert_contains($index, 'setChoroplethHitTesting(true)', 'draw tool restores choropleth hit-testing');
|
||||
assert_contains($index, 'function finishActiveDrawing()', 'global finish drawing helper');
|
||||
assert_contains($index, 'onclick="finishActiveDrawing()"', 'draw hint finish button');
|
||||
assert_contains($index, 'onclick="cancelActiveDrawing()"', 'draw hint cancel button');
|
||||
|
||||
assert_contains($choropleth, "pane: 'choroplethPane'", 'choropleth layer options');
|
||||
assert_contains($choropleth, 'function isClassicDrawingActive()', 'choropleth draw guard');
|
||||
assert_contains($choropleth, 'if (isClassicDrawingActive()) return;', 'choropleth draw guard usage');
|
||||
assert_contains($choropleth, 'function setLayerHitTesting(leafletLayer, enabled)', 'choropleth layer hit-testing helper');
|
||||
assert_contains($choropleth, "layer._path.style.pointerEvents = enabled ? '' : 'none';", 'choropleth path hit-testing toggle');
|
||||
assert_contains($choropleth, 'setHitTesting: setAllHitTesting', 'choropleth public hit-testing API');
|
||||
assert_contains($point, "pane: 'pointPane'", 'point marker options');
|
||||
assert_contains($point, "pane: 'drawPane'", 'temporary point marker options');
|
||||
assert_contains($jalan, "pane: 'jalanPane'", 'jalan polyline options');
|
||||
assert_contains($jalan, "pane: 'drawPane'", 'jalan drawing preview options');
|
||||
assert_contains($jalan, "map.on('dblclick', onMapDoubleClick)", 'jalan double click finish handler');
|
||||
assert_contains($jalan, 'if (e.originalEvent) L.DomEvent.stop(e.originalEvent);', 'jalan double click event stop');
|
||||
assert_contains($jalan, 'e.preventDefault()', 'jalan enter finish handler');
|
||||
assert_contains($jalan, 'window._jalanFinishDraw = finishDrawing;', 'jalan exposed finish handler');
|
||||
assert_contains($parsil, "pane: 'parsilPane'", 'parsil polygon options');
|
||||
assert_contains($parsil, "pane: 'drawPane'", 'parsil drawing preview options');
|
||||
assert_contains($parsil, "map.on('dblclick', onMapDoubleClick)", 'parsil double click finish handler');
|
||||
assert_contains($parsil, 'if (e.originalEvent) L.DomEvent.stop(e.originalEvent);', 'parsil double click event stop');
|
||||
assert_contains($parsil, 'e.preventDefault()', 'parsil enter finish handler');
|
||||
assert_contains($parsil, 'window._parsilFinishDraw = finishDrawing;', 'parsil exposed finish handler');
|
||||
|
||||
echo "PASS: layer panes are configured\n";
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once 'koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$idRaw = $_POST['id'] ?? null;
|
||||
$latitudeRaw = $_POST['latitude'] ?? null;
|
||||
$longitudeRaw = $_POST['longitude'] ?? null;
|
||||
|
||||
if ($idRaw === null || $latitudeRaw === null || $longitudeRaw === null) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_numeric($idRaw) || !is_numeric($latitudeRaw) || !is_numeric($longitudeRaw)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Format data tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)$idRaw;
|
||||
$latitude = (float)$latitudeRaw;
|
||||
$longitude = (float)$longitudeRaw;
|
||||
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('ddi', $latitude, $longitude, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi berhasil diperbarui.']);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
Reference in New Issue
Block a user