initial commit
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
.env
|
||||
.env.*
|
||||
api/config/database.local.php
|
||||
*.sql
|
||||
!database.sql
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.bak
|
||||
*.backup
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
@@ -0,0 +1,39 @@
|
||||
# WebGIS Poverty
|
||||
|
||||
Proyek WebGIS sederhana untuk visualisasi data kemiskinan dan fasilitas umum di wilayah tertentu.
|
||||
|
||||
## 🚀 Fitur Utama
|
||||
* **Analisis Proximity**: Perhitungan jangkauan radius/buffer antara lokasi penduduk miskin dan rumah ibadah terdekat.
|
||||
* **Log Bantuan**: Pencatatan riwayat penyaluran bantuan sosial.
|
||||
* **Pemetaan Spasial**: Visualisasi dan input data titik (SPBU/Rumah Ibadah), garis (Jalan), dan area (Parsil tanah).
|
||||
* **Kalkulasi Spasial**: Perhitungan otomatis panjang jalan dan luas area parsil tanah secara real-time menggunakan **Turf.js**.
|
||||
|
||||
## 🛠️ Teknologi yang Digunakan
|
||||
* **Backend**: PHP Native (REST API) & MySQL.
|
||||
* **Frontend**: Vanilla HTML/CSS/JS, Leaflet.js (peta interaktif), Leaflet Draw (alat menggambar di peta), dan Turf.js (kalkulasi spasial).
|
||||
|
||||
## 📋 Persyaratan
|
||||
* PHP + Apache (mis. XAMPP atau Laragon)
|
||||
* MySQL/MariaDB
|
||||
|
||||
## ⚙️ Cara Setup
|
||||
1. Tempatkan folder proyek di dalam direktori `htdocs` server lokal.
|
||||
2. Mulai modul Apache dan MySQL (di XAMPP Control Panel).
|
||||
3. Buat database bernama `webgis_poverty` dan import file `database.sql` ke dalamnya.
|
||||
4. Salin file `api/config/database.local.php.example` menjadi `api/config/database.local.php` lalu sesuaikan konfigurasi database.
|
||||
5. Buka webgis melalui browser (misal: `http://localhost/webgis_poverty_fixed/` atau langsung melalui `index.html`).
|
||||
|
||||
## 📁 Struktur File Penting
|
||||
|
||||
### Backend (REST API)
|
||||
* `api/config/database.local.php` — Konfigurasi koneksi database lokal.
|
||||
* `api/penduduk_miskin.php` — API data penduduk miskin (termasuk aksi hitung ulang kedekatan).
|
||||
* `api/rumah_ibadah.php` — API data rumah ibadah.
|
||||
* `api/log_bantuan.php` — API log bantuan sosial.
|
||||
* `api/data_jalan.php`, `api/data_parsil.php`, `api/spbu.php` — API data spasial lainnya.
|
||||
|
||||
### Frontend
|
||||
* `index.html` — Halaman dashboard utama.
|
||||
* `css/app.css` — File styling antarmuka.
|
||||
* `js/app-core.js` — Inisialisasi peta Leaflet dan fungsi umum.
|
||||
* `js/feature-*.js` — File logika interaktif per modul fitur.
|
||||
@@ -0,0 +1,9 @@
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Deny from all
|
||||
</IfModule>
|
||||
|
||||
# Disable directory listing
|
||||
Options -Indexes
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => 'localhost',
|
||||
'user' => 'root',
|
||||
'pass' => 'password',
|
||||
'name' => 'webgis_poverty',
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
// api/config/database.php
|
||||
|
||||
mysqli_report(MYSQLI_REPORT_OFF);
|
||||
|
||||
if (!function_exists('loadDatabaseConfig')) {
|
||||
function loadDatabaseConfig(): array {
|
||||
$config = [
|
||||
'host' => getenv('DB_HOST') ?: 'localhost',
|
||||
'user' => getenv('DB_USER') ?: 'root',
|
||||
'pass' => getenv('DB_PASS') ?: '',
|
||||
'name' => getenv('DB_NAME') ?: 'webgis_poverty',
|
||||
'charset' => getenv('DB_CHARSET') ?: 'utf8mb4',
|
||||
];
|
||||
|
||||
$localConfigFile = __DIR__ . '/database.local.php';
|
||||
if (is_file($localConfigFile)) {
|
||||
$localConfig = require $localConfigFile;
|
||||
if (is_array($localConfig)) {
|
||||
$config = array_merge($config, array_intersect_key($localConfig, $config));
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
$dbConfig = loadDatabaseConfig();
|
||||
|
||||
define('DB_HOST', $dbConfig['host']);
|
||||
define('DB_USER', $dbConfig['user']);
|
||||
define('DB_PASS', $dbConfig['pass']);
|
||||
define('DB_NAME', $dbConfig['name']);
|
||||
define('DB_CHARSET', $dbConfig['charset']);
|
||||
|
||||
function getDB(): mysqli {
|
||||
static $conn = null;
|
||||
if ($conn === null) {
|
||||
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
|
||||
if ($conn->connect_error) {
|
||||
error_log('DB connection failed: ' . $conn->connect_error);
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'Koneksi database gagal'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
$conn->set_charset(DB_CHARSET);
|
||||
}
|
||||
return $conn;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
// api/data_jalan.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id, nama_jalan, status_jalan,
|
||||
panjang_meter, keterangan,
|
||||
CAST(geojson AS CHAR) AS geojson,
|
||||
created_at
|
||||
FROM data_jalan WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||
// Parse geojson string menjadi object
|
||||
$row['geojson'] = json_decode($row['geojson']);
|
||||
$row['panjang_meter'] = (float)$row['panjang_meter'];
|
||||
sendSuccess($row);
|
||||
} else {
|
||||
$rows = [];
|
||||
$res = $db->query(
|
||||
"SELECT id, nama_jalan, status_jalan,
|
||||
panjang_meter, keterangan,
|
||||
CAST(geojson AS CHAR) AS geojson,
|
||||
created_at
|
||||
FROM data_jalan ORDER BY created_at DESC"
|
||||
);
|
||||
while ($r = $res->fetch_assoc()) {
|
||||
$r['geojson'] = json_decode($r['geojson']);
|
||||
$r['panjang_meter'] = (float)$r['panjang_meter'];
|
||||
$rows[] = $r;
|
||||
}
|
||||
sendSuccess($rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = getBody();
|
||||
foreach (['nama_jalan','status_jalan','geojson','panjang_meter'] as $f) {
|
||||
if (!isset($body[$f])) sendError("Field '$f' wajib diisi");
|
||||
}
|
||||
// Validasi GeoJSON LineString
|
||||
$geo = is_array($body['geojson'])
|
||||
? $body['geojson']
|
||||
: json_decode($body['geojson'], true);
|
||||
if (!$geo || $geo['type'] !== 'LineString') {
|
||||
sendError('GeoJSON harus bertipe LineString');
|
||||
}
|
||||
$geoStr = json_encode($geo);
|
||||
$panjang = (float)$body['panjang_meter'];
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO data_jalan
|
||||
(nama_jalan, status_jalan, panjang_meter, keterangan, geojson)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param('ssdss',
|
||||
$body['nama_jalan'], $body['status_jalan'],
|
||||
$panjang, $body['keterangan'], $geoStr
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal menyimpan data jalan');
|
||||
sendSuccess(['id' => $db->insert_id], 'Data jalan berhasil disimpan', 201);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$body = getBody();
|
||||
$geo = is_array($body['geojson'])
|
||||
? $body['geojson']
|
||||
: json_decode($body['geojson'], true);
|
||||
$geoStr = json_encode($geo);
|
||||
$panjang = (float)$body['panjang_meter'];
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE data_jalan
|
||||
SET nama_jalan=?, status_jalan=?,
|
||||
panjang_meter=?, keterangan=?, geojson=?
|
||||
WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('ssdssi',
|
||||
$body['nama_jalan'], $body['status_jalan'],
|
||||
$panjang, $body['keterangan'], $geoStr, $id
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal memperbarui data jalan');
|
||||
sendSuccess(null, 'Data jalan berhasil diperbarui');
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$stmt = $db->prepare("DELETE FROM data_jalan WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
executeOrFail($stmt, 'Gagal menghapus data jalan');
|
||||
$stmt->affected_rows > 0
|
||||
? sendSuccess(null, 'Data jalan berhasil dihapus')
|
||||
: sendError('Data tidak ditemukan', 404);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
// api/data_persil.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id, nama_pemilik, no_sertifikat, jenis_hak,
|
||||
luas_m2, keterangan,
|
||||
CAST(geojson AS CHAR) AS geojson,
|
||||
created_at
|
||||
FROM data_parsil WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||
$row['geojson'] = json_decode($row['geojson']);
|
||||
$row['luas_m2'] = (float)$row['luas_m2'];
|
||||
sendSuccess($row);
|
||||
} else {
|
||||
$rows = [];
|
||||
$res = $db->query(
|
||||
"SELECT id, nama_pemilik, no_sertifikat, jenis_hak,
|
||||
luas_m2, keterangan,
|
||||
CAST(geojson AS CHAR) AS geojson,
|
||||
created_at
|
||||
FROM data_parsil ORDER BY created_at DESC"
|
||||
);
|
||||
while ($r = $res->fetch_assoc()) {
|
||||
$r['geojson'] = json_decode($r['geojson']);
|
||||
$r['luas_m2'] = (float)$r['luas_m2'];
|
||||
$rows[] = $r;
|
||||
}
|
||||
sendSuccess($rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = getBody();
|
||||
foreach (['nama_pemilik','jenis_hak','geojson','luas_m2'] as $f) {
|
||||
if (!isset($body[$f])) sendError("Field '$f' wajib diisi");
|
||||
}
|
||||
$geo = is_array($body['geojson'])
|
||||
? $body['geojson']
|
||||
: json_decode($body['geojson'], true);
|
||||
if (!$geo || $geo['type'] !== 'Polygon') {
|
||||
sendError('GeoJSON harus bertipe Polygon');
|
||||
}
|
||||
$geoStr = json_encode($geo);
|
||||
$luas = (float)$body['luas_m2'];
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO data_parsil
|
||||
(nama_pemilik, no_sertifikat, jenis_hak, luas_m2, keterangan, geojson)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param('sssdss',
|
||||
$body['nama_pemilik'], $body['no_sertifikat'],
|
||||
$body['jenis_hak'], $luas,
|
||||
$body['keterangan'], $geoStr
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal menyimpan data persil');
|
||||
sendSuccess(['id' => $db->insert_id], 'Data persil berhasil disimpan', 201);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$body = getBody();
|
||||
$geo = is_array($body['geojson'])
|
||||
? $body['geojson']
|
||||
: json_decode($body['geojson'], true);
|
||||
$geoStr = json_encode($geo);
|
||||
$luas = (float)$body['luas_m2'];
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE data_parsil
|
||||
SET nama_pemilik=?, no_sertifikat=?, jenis_hak=?,
|
||||
luas_m2=?, keterangan=?, geojson=?
|
||||
WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('sssdssi',
|
||||
$body['nama_pemilik'], $body['no_sertifikat'],
|
||||
$body['jenis_hak'], $luas,
|
||||
$body['keterangan'], $geoStr, $id
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal memperbarui data persil');
|
||||
sendSuccess(null, 'Data persil berhasil diperbarui');
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$stmt = $db->prepare("DELETE FROM data_parsil WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
executeOrFail($stmt, 'Gagal menghapus data persil');
|
||||
$stmt->affected_rows > 0
|
||||
? sendSuccess(null, 'Data persil berhasil dihapus')
|
||||
: sendError('Data tidak ditemukan', 404);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
// api/helpers/response.php
|
||||
|
||||
function setCORSHeaders(): void {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Token');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = false;
|
||||
if ($origin !== '') {
|
||||
$origin = trim($origin);
|
||||
$allowedOrigins = parseAllowedOrigins(getenv('CORS_ALLOWED_ORIGINS') ?: '');
|
||||
if (!empty($allowedOrigins)) {
|
||||
$allowed = in_array($origin, $allowedOrigins, true);
|
||||
} else {
|
||||
$allowed = (bool)preg_match('/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/', $origin);
|
||||
}
|
||||
if ($allowed) {
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
}
|
||||
}
|
||||
|
||||
if (getMethod() === 'OPTIONS') {
|
||||
http_response_code(($origin !== '' && !$allowed) ? 403 : 204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedOrigins(string $raw): array {
|
||||
$parts = array_map('trim', explode(',', $raw));
|
||||
return array_values(array_filter($parts, static fn ($v) => $v !== ''));
|
||||
}
|
||||
|
||||
function sendJSON(mixed $data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function sendSuccess(mixed $data = null, string $message = 'OK', int $code = 200): void {
|
||||
sendJSON(['status' => 'success', 'message' => $message, 'data' => $data], $code);
|
||||
}
|
||||
|
||||
function sendError(string $message, int $code = 400): void {
|
||||
sendJSON(['status' => 'error', 'message' => $message], $code);
|
||||
}
|
||||
|
||||
function requireWriteAuth(string $method): void {
|
||||
if (!in_array($method, ['POST', 'PUT', 'DELETE'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expected = (string)(getenv('API_WRITE_TOKEN') ?: '');
|
||||
if ($expected === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$provided = (string)($_SERVER['HTTP_X_API_TOKEN'] ?? '');
|
||||
if ($provided === '') {
|
||||
$auth = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? '');
|
||||
if (preg_match('/Bearer\s+(.+)/i', $auth, $m)) {
|
||||
$provided = trim($m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($provided === '' || !hash_equals($expected, $provided)) {
|
||||
sendError('Unauthorized', 401);
|
||||
}
|
||||
}
|
||||
|
||||
function executeOrFail(mysqli_stmt $stmt, string $defaultMessage = 'Operasi database gagal'): void {
|
||||
if ($stmt->execute()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errno = $stmt->errno;
|
||||
$error = $stmt->error;
|
||||
error_log('DB stmt error [' . $errno . ']: ' . $error);
|
||||
|
||||
if ($errno === 1062) {
|
||||
sendError('Data duplikat terdeteksi', 409);
|
||||
}
|
||||
if ($errno === 1451 || $errno === 1452) {
|
||||
sendError('Relasi data tidak valid', 422);
|
||||
}
|
||||
sendError($defaultMessage, 500);
|
||||
}
|
||||
|
||||
function getBody(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
function getMethod(): string {
|
||||
return strtoupper($_SERVER['REQUEST_METHOD']);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
// api/log_bantuan.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$pmId = isset($_GET['penduduk_miskin_id']) ? (int)$_GET['penduduk_miskin_id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($pmId > 0) {
|
||||
// Semua log untuk satu penduduk miskin
|
||||
$stmt = $db->prepare(
|
||||
"SELECT lb.*,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
ri.jenis AS jenis_rumah_ibadah
|
||||
FROM log_bantuan lb
|
||||
JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
|
||||
WHERE lb.penduduk_miskin_id = ?
|
||||
ORDER BY lb.tanggal_bantuan DESC"
|
||||
);
|
||||
$stmt->bind_param('i', $pmId);
|
||||
$stmt->execute();
|
||||
$rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||
sendSuccess($rows);
|
||||
} elseif ($id > 0) {
|
||||
$stmt = $db->prepare(
|
||||
"SELECT lb.*,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
pm.nama AS nama_penduduk
|
||||
FROM log_bantuan lb
|
||||
JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
|
||||
JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id
|
||||
WHERE lb.id = ?"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404);
|
||||
} else {
|
||||
// Semua log bantuan dengan statistik
|
||||
$sql = "SELECT lb.*,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
pm.nama AS nama_penduduk
|
||||
FROM log_bantuan lb
|
||||
JOIN rumah_ibadah ri ON ri.id = lb.rumah_ibadah_id
|
||||
JOIN penduduk_miskin pm ON pm.id = lb.penduduk_miskin_id
|
||||
ORDER BY lb.tanggal_bantuan DESC
|
||||
LIMIT 200";
|
||||
$rows = $db->query($sql)->fetch_all(MYSQLI_ASSOC);
|
||||
sendSuccess($rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = getBody();
|
||||
foreach (['penduduk_miskin_id','rumah_ibadah_id','tipe_bantuan','tanggal_bantuan'] as $f) {
|
||||
if (empty($body[$f])) sendError("Field '$f' wajib diisi");
|
||||
}
|
||||
$nilai = isset($body['nilai_bantuan']) && $body['nilai_bantuan'] !== ''
|
||||
? (float)$body['nilai_bantuan'] : null;
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO log_bantuan
|
||||
(penduduk_miskin_id, rumah_ibadah_id, tipe_bantuan,
|
||||
sub_kategori, keterangan, tanggal_bantuan, nilai_bantuan)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'iissssd',
|
||||
$body['penduduk_miskin_id'],
|
||||
$body['rumah_ibadah_id'],
|
||||
$body['tipe_bantuan'],
|
||||
$body['sub_kategori'],
|
||||
$body['keterangan'],
|
||||
$body['tanggal_bantuan'],
|
||||
$nilai
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal mencatat log bantuan');
|
||||
sendSuccess(['id' => $db->insert_id], 'Log bantuan berhasil dicatat', 201);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$stmt = $db->prepare("DELETE FROM log_bantuan WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
executeOrFail($stmt, 'Gagal menghapus log bantuan');
|
||||
$stmt->affected_rows > 0
|
||||
? sendSuccess(null, 'Log bantuan berhasil dihapus')
|
||||
: sendError('Data tidak ditemukan', 404);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
// api/penduduk_miskin.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
// Endpoint khusus: recalc proximity semua penduduk miskin sekaligus
|
||||
if ($method === 'POST' && isset($_GET['action']) && $_GET['action'] === 'recalc_all') {
|
||||
requireWriteAuth('POST');
|
||||
$allPm = $db->query("SELECT id, latitude, longitude FROM penduduk_miskin");
|
||||
$updated = 0;
|
||||
while ($pm = $allPm->fetch_assoc()) {
|
||||
$prox = hitungProximity($db, (float)$pm['latitude'], (float)$pm['longitude']);
|
||||
$statusP = $prox['status'];
|
||||
$riId = $prox['rumah_ibadah_id'];
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE penduduk_miskin SET status_proximity=?, rumah_ibadah_id=? WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('sii', $statusP, $riId, $pm['id']);
|
||||
$stmt->execute();
|
||||
$updated++;
|
||||
}
|
||||
sendSuccess(['updated' => $updated], "Proximity $updated data diperbarui");
|
||||
}
|
||||
|
||||
switch ($method) {
|
||||
|
||||
// ── GET ───────────────────────────────────────────────────────────
|
||||
case 'GET':
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare(
|
||||
"SELECT pm.*,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
ri.jenis AS jenis_rumah_ibadah
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id
|
||||
WHERE pm.id = ?"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||
$row['latitude'] = (float)$row['latitude'];
|
||||
$row['longitude'] = (float)$row['longitude'];
|
||||
|
||||
// Ambil ringkasan log bantuan
|
||||
$stmtLog = $db->prepare(
|
||||
"SELECT tipe_bantuan, COUNT(*) AS jumlah
|
||||
FROM log_bantuan
|
||||
WHERE penduduk_miskin_id = ?
|
||||
GROUP BY tipe_bantuan"
|
||||
);
|
||||
$stmtLog->bind_param('i', $id);
|
||||
$stmtLog->execute();
|
||||
$logs = [];
|
||||
$resLog = $stmtLog->get_result();
|
||||
while ($l = $resLog->fetch_assoc()) $logs[] = $l;
|
||||
$row['ringkasan_bantuan'] = $logs;
|
||||
|
||||
sendSuccess($row);
|
||||
} else {
|
||||
// GET semua + proximity recalculation cache
|
||||
$sql = "SELECT pm.id, pm.nama, pm.no_wa, pm.nik, pm.alamat,
|
||||
pm.jumlah_anggota, pm.latitude, pm.longitude,
|
||||
pm.status_proximity, pm.rumah_ibadah_id,
|
||||
ri.nama AS nama_rumah_ibadah,
|
||||
-- hitung berapa kali dapat bantuan
|
||||
(SELECT COUNT(*) FROM log_bantuan lb
|
||||
WHERE lb.penduduk_miskin_id = pm.id) AS total_bantuan,
|
||||
(SELECT COUNT(*) FROM log_bantuan lb
|
||||
WHERE lb.penduduk_miskin_id = pm.id
|
||||
AND lb.tipe_bantuan = 'Pemberdayaan') AS bantuan_pemberdayaan,
|
||||
(SELECT COUNT(*) FROM log_bantuan lb
|
||||
WHERE lb.penduduk_miskin_id = pm.id
|
||||
AND lb.tipe_bantuan = 'Konsumtif') AS bantuan_konsumtif
|
||||
FROM penduduk_miskin pm
|
||||
LEFT JOIN rumah_ibadah ri ON ri.id = pm.rumah_ibadah_id
|
||||
ORDER BY pm.created_at DESC";
|
||||
$result = $db->query($sql);
|
||||
$rows = [];
|
||||
while ($r = $result->fetch_assoc()) {
|
||||
$r['latitude'] = (float)$r['latitude'];
|
||||
$r['longitude'] = (float)$r['longitude'];
|
||||
$r['total_bantuan'] = (int)$r['total_bantuan'];
|
||||
$r['bantuan_pemberdayaan'] = (int)$r['bantuan_pemberdayaan'];
|
||||
$r['bantuan_konsumtif'] = (int)$r['bantuan_konsumtif'];
|
||||
$rows[] = $r;
|
||||
}
|
||||
sendSuccess($rows);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── POST ──────────────────────────────────────────────────────────
|
||||
case 'POST':
|
||||
$body = getBody();
|
||||
foreach (['nama', 'latitude', 'longitude'] as $f) {
|
||||
if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi");
|
||||
}
|
||||
|
||||
// Hitung proximity ke semua rumah ibadah
|
||||
$proximity = hitungProximity($db, $body['latitude'], $body['longitude']);
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO penduduk_miskin
|
||||
(nama, no_wa, nik, alamat, jumlah_anggota,
|
||||
latitude, longitude, status_proximity, rumah_ibadah_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$jumlah = isset($body['jumlah_anggota']) ? (int)$body['jumlah_anggota'] : 1;
|
||||
$statusP = $proximity['status'];
|
||||
$riId = $proximity['rumah_ibadah_id'];
|
||||
|
||||
$stmt->bind_param(
|
||||
'ssssiddsi',
|
||||
$body['nama'], $body['no_wa'], $body['nik'],
|
||||
$body['alamat'], $jumlah,
|
||||
$body['latitude'], $body['longitude'],
|
||||
$statusP, $riId
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal menyimpan data penduduk miskin');
|
||||
$newId = $db->insert_id;
|
||||
sendSuccess(
|
||||
['id' => $newId, 'proximity' => $proximity],
|
||||
'Penduduk Miskin berhasil disimpan',
|
||||
201
|
||||
);
|
||||
break;
|
||||
|
||||
// ── PUT ───────────────────────────────────────────────────────────
|
||||
case 'PUT':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$body = getBody();
|
||||
$proximity = hitungProximity($db, $body['latitude'], $body['longitude']);
|
||||
$statusP = $proximity['status'];
|
||||
$riId = $proximity['rumah_ibadah_id'];
|
||||
$jumlah = isset($body['jumlah_anggota']) ? (int)$body['jumlah_anggota'] : 1;
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE penduduk_miskin
|
||||
SET nama=?, no_wa=?, nik=?, alamat=?, jumlah_anggota=?,
|
||||
latitude=?, longitude=?, status_proximity=?, rumah_ibadah_id=?
|
||||
WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssssiddsii',
|
||||
$body['nama'], $body['no_wa'], $body['nik'],
|
||||
$body['alamat'], $jumlah,
|
||||
$body['latitude'], $body['longitude'],
|
||||
$statusP, $riId, $id
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal memperbarui data penduduk miskin');
|
||||
sendSuccess(['proximity' => $proximity], 'Data berhasil diperbarui');
|
||||
break;
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────
|
||||
case 'DELETE':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$stmt = $db->prepare("DELETE FROM penduduk_miskin WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
executeOrFail($stmt, 'Gagal menghapus data penduduk miskin');
|
||||
$stmt->affected_rows > 0
|
||||
? sendSuccess(null, 'Data berhasil dihapus')
|
||||
: sendError('Data tidak ditemukan', 404);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPER: Hitung proximity penduduk miskin terhadap semua rumah ibadah
|
||||
// Menggunakan Haversine formula di PHP untuk validasi server-side
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
function hitungProximity(mysqli $db, float $lat, float $lng): array {
|
||||
$result = $db->query(
|
||||
"SELECT id, nama, latitude, longitude, radius_meter FROM rumah_ibadah"
|
||||
);
|
||||
$status = 'luar_radius';
|
||||
$riId = null;
|
||||
$riNama = null;
|
||||
$minDistAll = PHP_INT_MAX; // jarak ke RI terdekat (apapun statusnya)
|
||||
$minDistIn = PHP_INT_MAX; // jarak ke RI terdekat yang dalam radius
|
||||
|
||||
while ($ri = $result->fetch_assoc()) {
|
||||
$dist = haversine($lat, $lng, (float)$ri['latitude'], (float)$ri['longitude']);
|
||||
|
||||
// Lacak jarak terdekat secara keseluruhan (untuk field jarak_meter)
|
||||
if ($dist < $minDistAll) {
|
||||
$minDistAll = $dist;
|
||||
}
|
||||
|
||||
// Cek apakah titik ini masuk dalam radius RI ini
|
||||
if ($dist <= (int)$ri['radius_meter'] && $dist < $minDistIn) {
|
||||
$minDistIn = $dist;
|
||||
$status = 'dalam_radius';
|
||||
$riId = (int)$ri['id'];
|
||||
$riNama = $ri['nama'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'rumah_ibadah_id' => $riId,
|
||||
'nama_rumah_ibadah' => $riNama,
|
||||
'jarak_meter' => round($minDistAll, 2)
|
||||
];
|
||||
}
|
||||
|
||||
function haversine(float $lat1, float $lng1, float $lat2, float $lng2): float {
|
||||
$R = 6371000; // radius bumi dalam meter
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLng = deg2rad($lng2 - $lng1);
|
||||
$a = sin($dLat/2)**2
|
||||
+ cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng/2)**2;
|
||||
return $R * 2 * atan2(sqrt($a), sqrt(1-$a));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
// api/rumah_ibadah.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
// ── GET: ambil semua atau satu data ──────────────────────────────
|
||||
case 'GET':
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id, nama, jenis, alamat, no_wa,
|
||||
latitude, longitude, radius_meter,
|
||||
created_at
|
||||
FROM rumah_ibadah WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
if ($row) {
|
||||
$row['latitude'] = (float)$row['latitude'];
|
||||
$row['longitude'] = (float)$row['longitude'];
|
||||
$row['radius_meter'] = (int)$row['radius_meter'];
|
||||
}
|
||||
$row ? sendSuccess($row) : sendError('Data tidak ditemukan', 404);
|
||||
} else {
|
||||
$sql = "SELECT id, nama, jenis, alamat, no_wa,
|
||||
latitude, longitude, radius_meter,
|
||||
created_at
|
||||
FROM rumah_ibadah ORDER BY created_at DESC";
|
||||
$result = $db->query($sql);
|
||||
$rows = [];
|
||||
while ($r = $result->fetch_assoc()) {
|
||||
$r['latitude'] = (float)$r['latitude'];
|
||||
$r['longitude'] = (float)$r['longitude'];
|
||||
$r['radius_meter'] = (int)$r['radius_meter'];
|
||||
$rows[] = $r;
|
||||
}
|
||||
sendSuccess($rows);
|
||||
}
|
||||
break;
|
||||
|
||||
// ── POST: tambah data baru ────────────────────────────────────────
|
||||
case 'POST':
|
||||
$body = getBody();
|
||||
$required = ['nama', 'jenis', 'latitude', 'longitude', 'radius_meter'];
|
||||
foreach ($required as $field) {
|
||||
if (empty($body[$field]) && $body[$field] !== 0) {
|
||||
sendError("Field '$field' wajib diisi");
|
||||
}
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO rumah_ibadah
|
||||
(nama, jenis, alamat, no_wa, latitude, longitude, radius_meter)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssssddi',
|
||||
$body['nama'],
|
||||
$body['jenis'],
|
||||
$body['alamat'],
|
||||
$body['no_wa'],
|
||||
$body['latitude'],
|
||||
$body['longitude'],
|
||||
$body['radius_meter']
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal menyimpan rumah ibadah');
|
||||
$newId = $db->insert_id;
|
||||
sendSuccess(['id' => $newId], 'Rumah Ibadah berhasil disimpan', 201);
|
||||
break;
|
||||
|
||||
// ── PUT: update data ──────────────────────────────────────────────
|
||||
case 'PUT':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$body = getBody();
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE rumah_ibadah
|
||||
SET nama=?, jenis=?, alamat=?, no_wa=?,
|
||||
latitude=?, longitude=?, radius_meter=?
|
||||
WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param(
|
||||
'ssssddii',
|
||||
$body['nama'], $body['jenis'],
|
||||
$body['alamat'], $body['no_wa'],
|
||||
$body['latitude'], $body['longitude'],
|
||||
$body['radius_meter'], $id
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal memperbarui rumah ibadah');
|
||||
sendSuccess(null, 'Rumah Ibadah berhasil diperbarui');
|
||||
break;
|
||||
|
||||
// ── DELETE: hapus data ────────────────────────────────────────────
|
||||
case 'DELETE':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$stmt = $db->prepare("DELETE FROM rumah_ibadah WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
executeOrFail($stmt, 'Gagal menghapus rumah ibadah');
|
||||
$stmt->affected_rows > 0
|
||||
? sendSuccess(null, 'Rumah Ibadah berhasil dihapus')
|
||||
: sendError('Data tidak ditemukan', 404);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
// api/spbu.php
|
||||
require_once __DIR__ . '/config/database.php';
|
||||
require_once __DIR__ . '/helpers/response.php';
|
||||
setCORSHeaders();
|
||||
|
||||
$db = getDB();
|
||||
$method = getMethod();
|
||||
requireWriteAuth($method);
|
||||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
if ($id > 0) {
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id, nama, no_wa, alamat, buka_24jam,
|
||||
latitude, longitude, created_at
|
||||
FROM spbu WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
if (!$row) sendError('Data tidak ditemukan', 404);
|
||||
$row['latitude'] = (float)$row['latitude'];
|
||||
$row['longitude'] = (float)$row['longitude'];
|
||||
$row['buka_24jam']= (bool)$row['buka_24jam'];
|
||||
sendSuccess($row);
|
||||
} else {
|
||||
$rows = [];
|
||||
if (isset($_GET['buka_24jam'])) {
|
||||
$val = (int)$_GET['buka_24jam'];
|
||||
$stmt = $db->prepare(
|
||||
"SELECT id, nama, no_wa, alamat, buka_24jam,
|
||||
latitude, longitude, created_at
|
||||
FROM spbu WHERE buka_24jam = ? ORDER BY nama"
|
||||
);
|
||||
$stmt->bind_param('i', $val);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
} else {
|
||||
$res = $db->query(
|
||||
"SELECT id, nama, no_wa, alamat, buka_24jam,
|
||||
latitude, longitude, created_at
|
||||
FROM spbu ORDER BY nama"
|
||||
);
|
||||
}
|
||||
while ($r = $res->fetch_assoc()) {
|
||||
$r['latitude'] = (float)$r['latitude'];
|
||||
$r['longitude'] = (float)$r['longitude'];
|
||||
$r['buka_24jam']= (bool)$r['buka_24jam'];
|
||||
$rows[] = $r;
|
||||
}
|
||||
sendSuccess($rows);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = getBody();
|
||||
foreach (['nama','latitude','longitude'] as $f) {
|
||||
if (empty($body[$f]) && $body[$f] !== 0) sendError("Field '$f' wajib diisi");
|
||||
}
|
||||
$buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0;
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO spbu (nama, no_wa, alamat, buka_24jam, latitude, longitude)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->bind_param('sssidd',
|
||||
$body['nama'], $body['no_wa'], $body['alamat'],
|
||||
$buka, $body['latitude'], $body['longitude']
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal menyimpan data SPBU');
|
||||
sendSuccess(['id' => $db->insert_id], 'SPBU berhasil disimpan', 201);
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$body = getBody();
|
||||
$buka = isset($body['buka_24jam']) ? (int)(bool)$body['buka_24jam'] : 0;
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE spbu
|
||||
SET nama=?, no_wa=?, alamat=?, buka_24jam=?, latitude=?, longitude=?
|
||||
WHERE id=?"
|
||||
);
|
||||
$stmt->bind_param('sssiddi',
|
||||
$body['nama'], $body['no_wa'], $body['alamat'],
|
||||
$buka, $body['latitude'], $body['longitude'], $id
|
||||
);
|
||||
executeOrFail($stmt, 'Gagal memperbarui data SPBU');
|
||||
sendSuccess(null, 'SPBU berhasil diperbarui');
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
if ($id <= 0) sendError('ID wajib disertakan');
|
||||
$stmt = $db->prepare("DELETE FROM spbu WHERE id=?");
|
||||
$stmt->bind_param('i', $id);
|
||||
executeOrFail($stmt, 'Gagal menghapus data SPBU');
|
||||
$stmt->affected_rows > 0
|
||||
? sendSuccess(null, 'SPBU berhasil dihapus')
|
||||
: sendError('Data tidak ditemukan', 404);
|
||||
break;
|
||||
|
||||
default:
|
||||
sendError('Method tidak diizinkan', 405);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+895
@@ -0,0 +1,895 @@
|
||||
:root {
|
||||
--bg: #eef3f8;
|
||||
--panel: rgba(255, 255, 255, .92);
|
||||
--panel-strong: #ffffff;
|
||||
--line: #d7e0ea;
|
||||
--line-strong: #bcc9d7;
|
||||
--text: #0f172a;
|
||||
--muted: #5b6b82;
|
||||
--muted-2: #7d8ca2;
|
||||
--accent: #165dff;
|
||||
--accent-2: #0ea5e9;
|
||||
--accent-soft: #eaf1ff;
|
||||
--shadow: 0 12px 36px rgba(15, 23, 42, .12);
|
||||
--shadow-soft: 0 8px 18px rgba(15, 23, 42, .08);
|
||||
--radius: 18px;
|
||||
--radius-sm: 12px;
|
||||
--font: 'Inter', sans-serif;
|
||||
--font-head: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(22, 93, 255, .12), transparent 32%),
|
||||
radial-gradient(circle at right center, rgba(14, 165, 233, .10), transparent 28%),
|
||||
linear-gradient(180deg, #f6f9fc 0%, var(--bg) 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 64px 1fr;
|
||||
}
|
||||
|
||||
#header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 18px;
|
||||
background: rgba(255, 255, 255, .8);
|
||||
border-bottom: 1px solid rgba(215, 224, 234, .9);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, .7) inset;
|
||||
position: relative;
|
||||
z-index: 4000;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.04em;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
position: relative;
|
||||
width: min(500px, 100%);
|
||||
flex: 1 1 360px;
|
||||
max-width: 500px;
|
||||
z-index: 4001;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, .96);
|
||||
border-radius: 999px;
|
||||
padding: 9px 14px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted-2);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#search-input::placeholder {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 5000;
|
||||
display: none;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, .98);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, .16);
|
||||
backdrop-filter: blur(12px);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.search-results.show {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-results-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 4px 0;
|
||||
}
|
||||
|
||||
.search-results-hint {
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-results-title {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.search-results-count {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
background: #f8fbff;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
transition: all .14s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item.active {
|
||||
border-color: #cfe0ff;
|
||||
background: #eef5ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.search-result-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-result-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.search-result-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.search-result-type {
|
||||
flex: 0 0 auto;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
padding: 14px 12px;
|
||||
border: 1px dashed #cbd6e3;
|
||||
border-radius: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.search-empty strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all .16s ease;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: #f3f7fb;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: #cfe0ff;
|
||||
box-shadow: 0 0 0 1px rgba(22, 93, 255, .04) inset;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.coord-display,
|
||||
.status-pill {
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, .86);
|
||||
box-shadow: var(--shadow-soft);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coord-display {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.status-pill strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 362px;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#map-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#instruction-banner {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
top: 18px;
|
||||
z-index: 500;
|
||||
background: rgba(15, 23, 42, .92);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
pointer-events: none;
|
||||
transition: all .18s ease;
|
||||
}
|
||||
|
||||
#instruction-banner.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#panel {
|
||||
min-width: 0;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid rgba(215, 224, 234, .95);
|
||||
box-shadow: -1px 0 0 rgba(255, 255, 255, .75) inset;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#panel-head {
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(255, 255, 255, .82));
|
||||
}
|
||||
|
||||
#panel-head h2 {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-head);
|
||||
letter-spacing: -.04em;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#panel-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px;
|
||||
background: linear-gradient(180deg, rgba(247, 250, 253, .8), rgba(242, 246, 250, .88));
|
||||
}
|
||||
|
||||
#panel-body::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
#panel-body::-webkit-scrollbar-thumb {
|
||||
background: #c7d3e0;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.layer-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.layer-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-2);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.layer-item,
|
||||
.data-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
padding: 9px 11px;
|
||||
margin-bottom: 6px;
|
||||
transition: all .16s ease;
|
||||
}
|
||||
|
||||
.layer-item:hover,
|
||||
.data-item:hover {
|
||||
border-color: #bcd0ff;
|
||||
box-shadow: 0 10px 20px rgba(22, 93, 255, .08);
|
||||
}
|
||||
|
||||
.layer-item.checked {
|
||||
background: linear-gradient(180deg, #f8fbff, #eef5ff);
|
||||
border-color: #cfe0ff;
|
||||
}
|
||||
|
||||
.layer-item.checked .layer-check {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.layer-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
border: 1.5px solid #b8c5d4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: transparent;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.layer-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layer-count {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
background: #f4f7fb;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1.5px dashed #aebfda;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 12px;
|
||||
margin-top: 6px;
|
||||
transition: all .16s ease;
|
||||
}
|
||||
|
||||
.btn-add:hover,
|
||||
.btn-add.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.data-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-item {
|
||||
padding: 10px 11px;
|
||||
transition: all .16s ease;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f7fb;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-sub {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: #e6f8ec;
|
||||
color: #15803d;
|
||||
border-color: #b9ebc9;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
background: #feecec;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: #eaf1ff;
|
||||
color: #165dff;
|
||||
border-color: #cfe0ff;
|
||||
}
|
||||
|
||||
.badge-amber {
|
||||
background: #fff4df;
|
||||
color: #d97706;
|
||||
border-color: #f9deb0;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: #f1eaff;
|
||||
color: #7c3aed;
|
||||
border-color: #ddd2ff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 14px;
|
||||
border: 1px dashed #cbd6e3;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, .7);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid rgba(22, 93, 255, .18);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
margin-right: 8px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, .42);
|
||||
z-index: 2000;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
#modal-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#modal {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(90vh, 860px);
|
||||
background: var(--panel-strong);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, .28);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
padding: 18px 20px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||
}
|
||||
|
||||
.modal-head h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-head);
|
||||
font-size: 18px;
|
||||
letter-spacing: -.03em;
|
||||
}
|
||||
|
||||
#modal-body {
|
||||
padding: 18px 20px;
|
||||
overflow: auto;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
#modal-footer {
|
||||
padding: 14px 20px 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 11px 12px;
|
||||
outline: none;
|
||||
transition: all .14s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
border-color: #8eb3ff;
|
||||
box-shadow: 0 0 0 4px rgba(22, 93, 255, .10);
|
||||
}
|
||||
|
||||
.form-coord,
|
||||
.form-calc {
|
||||
margin-bottom: 14px;
|
||||
padding: 11px 12px;
|
||||
background: #eef5ff;
|
||||
color: #1140b8;
|
||||
border: 1px solid #d7e5ff;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.popup-drag-hint,
|
||||
.proximity-info {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.popup-drag-hint {
|
||||
background: #f3f7fb;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.proximity-info.dalam {
|
||||
background: #e6f8ec;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.proximity-info.luar {
|
||||
background: #feecec;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
transition: all .16s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 22px rgba(22, 93, 255, .18);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0d4ae0;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: #fff;
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow);
|
||||
animation: toast-in .2s ease;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
#main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#panel {
|
||||
grid-template-rows: auto 360px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#header {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-search {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#panel {
|
||||
grid-template-rows: auto minmax(320px, 48vh);
|
||||
}
|
||||
|
||||
#modal {
|
||||
width: 100%;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/* css/landing.css */
|
||||
:root {
|
||||
--bg: #eef3f8;
|
||||
--text: #0f172a;
|
||||
--muted: #5b6b82;
|
||||
--accent: #165dff;
|
||||
--accent-2: #0ea5e9;
|
||||
--accent-soft: #eaf1ff;
|
||||
--shadow-soft: 0 12px 36px rgba(15, 23, 42, .06);
|
||||
--shadow-hover: 0 24px 48px rgba(22, 93, 255, .15);
|
||||
--radius-lg: 24px;
|
||||
--font: 'Inter', sans-serif;
|
||||
--font-head: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 10% 20%, rgba(22, 93, 255, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(14, 165, 233, 0.12) 0%, transparent 40%),
|
||||
linear-gradient(135deg, #f8fbff 0%, var(--bg) 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.landing-header {
|
||||
padding: 24px 48px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.8);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: var(--font-head);
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.05em;
|
||||
margin: 0 0 16px;
|
||||
max-width: 800px;
|
||||
line-height: 1.1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
max-width: 600px;
|
||||
margin: 0 0 48px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Grid Menu */
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 32px 24px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(22,93,255,0.05), rgba(14,165,233,0.02));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: rgba(22, 93, 255, 0.2);
|
||||
}
|
||||
|
||||
.feature-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, var(--accent-soft), #fff);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 8px 16px rgba(22, 93, 255, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover .feature-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: var(--font-head);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero h1 { font-size: 36px; }
|
||||
.features-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
CREATE DATABASE IF NOT EXISTS webgis_poverty;
|
||||
USE webgis_poverty;
|
||||
|
||||
CREATE TABLE `rumah_ibadah` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(255) NOT NULL,
|
||||
`jenis` varchar(100) NOT NULL,
|
||||
`alamat` text,
|
||||
`no_wa` varchar(20),
|
||||
`latitude` double NOT NULL,
|
||||
`longitude` double NOT NULL,
|
||||
`radius_meter` int(11) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE `penduduk_miskin` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(255) NOT NULL,
|
||||
`no_wa` varchar(20),
|
||||
`nik` varchar(20),
|
||||
`alamat` text,
|
||||
`jumlah_anggota` int(11) NOT NULL DEFAULT 1,
|
||||
`latitude` double NOT NULL,
|
||||
`longitude` double NOT NULL,
|
||||
`status_proximity` varchar(50),
|
||||
`rumah_ibadah_id` int(11),
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE `log_bantuan` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`penduduk_miskin_id` int(11) NOT NULL,
|
||||
`rumah_ibadah_id` int(11) NOT NULL,
|
||||
`tipe_bantuan` varchar(100) NOT NULL,
|
||||
`sub_kategori` varchar(100),
|
||||
`keterangan` text,
|
||||
`tanggal_bantuan` date NOT NULL,
|
||||
`nilai_bantuan` double,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
FOREIGN KEY (`penduduk_miskin_id`) REFERENCES `penduduk_miskin` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`rumah_ibadah_id`) REFERENCES `rumah_ibadah` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE `data_jalan` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama_jalan` varchar(255) NOT NULL,
|
||||
`status_jalan` varchar(100) NOT NULL,
|
||||
`panjang_meter` double NOT NULL,
|
||||
`keterangan` text,
|
||||
`geojson` longtext NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE `data_parsil` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama_pemilik` varchar(255) NOT NULL,
|
||||
`no_sertifikat` varchar(100),
|
||||
`jenis_hak` varchar(100) NOT NULL,
|
||||
`luas_m2` double NOT NULL,
|
||||
`keterangan` text,
|
||||
`geojson` longtext NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE `spbu` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(255) NOT NULL,
|
||||
`no_wa` varchar(20),
|
||||
`alamat` text,
|
||||
`buka_24jam` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`latitude` double NOT NULL,
|
||||
`longitude` double NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS - Portal Sistem Informasi Geografis</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;700;800&family=Space+Grotesk:wght@500;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="css/landing.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="landing-header">
|
||||
<a href="index.html" class="brand">
|
||||
<div class="brand-title">WebGIS Portal</div>
|
||||
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main class="hero">
|
||||
<h1>Eksplorasi Data Spasial Terpadu</h1>
|
||||
<p>Akses informasi geografis mengenai tingkat kemiskinan, persebaran SPBU, infrastruktur jalan, dan data bidang persil tanah dalam satu platform interaktif yang memukau.</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<a href="kemiskinan.html" class="feature-card">
|
||||
<div class="feature-icon">👥</div>
|
||||
<h2 class="feature-title">Kemiskinan & Rumah Ibadah</h2>
|
||||
<p class="feature-desc">Peta sebaran penduduk dan fasilitas rumah ibadah di berbagai wilayah.</p>
|
||||
</a>
|
||||
|
||||
<a href="spbu.html" class="feature-card">
|
||||
<div class="feature-icon">⛽</div>
|
||||
<h2 class="feature-title">Persebaran SPBU</h2>
|
||||
<p class="feature-desc">Lokasi stasiun pengisian bahan bakar umum beserta status layanannya.</p>
|
||||
</a>
|
||||
|
||||
<a href="jalan.html" class="feature-card">
|
||||
<div class="feature-icon">🛣️</div>
|
||||
<h2 class="feature-title">Infrastruktur Jalan</h2>
|
||||
<p class="feature-desc">Data jaringan jalan raya nasional, provinsi, dan kabupaten terintegrasi.</p>
|
||||
</a>
|
||||
|
||||
<a href="persil.html" class="feature-card">
|
||||
<div class="feature-icon">🗺️</div>
|
||||
<h2 class="feature-title">Bidang Persil Tanah</h2>
|
||||
<p class="feature-desc">Pemetaan bidang tanah bersertifikat lengkap dengan informasi status kepemilikan.</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
© 2026 WebGIS Sistem Informasi Geografis. Semua Hak Cipta Dilindungi.
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS - Poverty Mapping & Spatial Management</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;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Turf.js/6.5.0/turf.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<a href="index.html" style="text-decoration:none; color:inherit"><div class="brand-title">WebGIS Poverty</div></a>
|
||||
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="header-search" id="header-search">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="search-input" type="search" autocomplete="off" placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||
</div>
|
||||
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="map-wrap">
|
||||
<div id="instruction-banner"></div>
|
||||
<div id="map"></div>
|
||||
</section>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-head">
|
||||
<a href="index.html" class="btn btn-ghost btn-sm" style="margin-bottom: 12px; display: inline-flex; align-items: center; gap: 6px; text-decoration: none;">
|
||||
<span style="font-size: 14px;">←</span> Kembali ke Beranda
|
||||
</a>
|
||||
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||
<!-- <div class="panel-meta">
|
||||
<span>Drag marker untuk update lokasi</span>
|
||||
<span>Draw polyline untuk jalan</span>
|
||||
<span>Draw polygon untuk persil</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" aria-hidden="true">
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div class="modal-head"><h3 id="modal-title">Modal</h3></div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="js/app-core.js"></script>
|
||||
<script src="js/feature-kemiskinan.js"></script>
|
||||
<script src="js/feature-spbu.js"></script>
|
||||
<script src="js/feature-jalan.js"></script>
|
||||
<script src="js/feature-persil.js"></script>
|
||||
<script>
|
||||
initPage('jalan');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
'use strict';
|
||||
|
||||
const API_BASE = 'api';
|
||||
const CENTER = [-0.0263, 109.3425];
|
||||
|
||||
const map = L.map('map', { zoomControl: false }).setView(CENTER, 13);
|
||||
L.control.zoom({ position: 'topleft' }).addTo(map);
|
||||
|
||||
const baseLayers = {
|
||||
'OSM Standard': L.tileLayer(
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
{ attribution: '© OpenStreetMap', maxZoom: 19 }
|
||||
),
|
||||
'CartoDB Light': L.tileLayer(
|
||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
{ attribution: '© CartoDB', maxZoom: 19 }
|
||||
),
|
||||
'Satellite (Esri)': L.tileLayer(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
{ attribution: '© Esri', maxZoom: 19 }
|
||||
)
|
||||
};
|
||||
baseLayers['OSM Standard'].addTo(map);
|
||||
L.control.layers(baseLayers, {}, { position: 'bottomright', collapsed: true }).addTo(map);
|
||||
|
||||
const layers = {
|
||||
rumahIbadah: L.layerGroup(),
|
||||
ibadahRadius: L.layerGroup(),
|
||||
pendudukMiskin: L.layerGroup(),
|
||||
spbu24: L.layerGroup(),
|
||||
spbuNon24: L.layerGroup(),
|
||||
jalanNasional: L.layerGroup(),
|
||||
jalanProvinsi: L.layerGroup(),
|
||||
jalanKabupaten: L.layerGroup(),
|
||||
persilSHM: L.layerGroup(),
|
||||
persilHGB: L.layerGroup(),
|
||||
persilHGU: L.layerGroup(),
|
||||
persilHP: L.layerGroup()
|
||||
};
|
||||
|
||||
const layerVisible = {
|
||||
rumahIbadah: true, ibadahRadius: true, pendudukMiskin: true,
|
||||
spbu24: true, spbuNon24: true,
|
||||
jalanNasional: true, jalanProvinsi: true, jalanKabupaten: true,
|
||||
persilSHM: true, persilHGB: true, persilHGU: true, persilHP: true
|
||||
};
|
||||
|
||||
const searchEntries = [];
|
||||
let searchActiveIndex = -1;
|
||||
const searchPointZoom = 15;
|
||||
const searchIntentPrefix = {
|
||||
jalan: 'jalan',
|
||||
spbu: 'spbu',
|
||||
rumahibadah: 'ri',
|
||||
pendudukmiskin: 'pm',
|
||||
persil: 'persil'
|
||||
};
|
||||
|
||||
const drawnItems = new L.FeatureGroup().addTo(map);
|
||||
const drawControl = new L.Control.Draw({
|
||||
draw: {
|
||||
polyline: { shapeOptions: { color: '#2563eb', weight: 3 } },
|
||||
polygon: { shapeOptions: { color: '#7c3aed', weight: 2, fillOpacity: 0.15 } },
|
||||
rectangle: false, circle: false, circlemarker: false, marker: false
|
||||
},
|
||||
edit: { featureGroup: drawnItems, remove: false }
|
||||
});
|
||||
|
||||
let pendingGeoJSON = null;
|
||||
let pendingLength = null;
|
||||
let pendingArea = null;
|
||||
let currentTab = 'kemiskinan';
|
||||
let geometryEditState = null;
|
||||
let _mapClickCb = null;
|
||||
|
||||
function toast(msg, type = 'info') {
|
||||
const t = document.createElement('div');
|
||||
t.className = `toast ${type}`;
|
||||
t.textContent = msg;
|
||||
document.getElementById('toast-container').appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
function openModal(title, bodyHTML, footerHTML) {
|
||||
document.getElementById('modal-title').textContent = title;
|
||||
document.getElementById('modal-body').innerHTML = bodyHTML;
|
||||
document.getElementById('modal-footer').innerHTML = footerHTML;
|
||||
document.getElementById('modal-overlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('modal-overlay').addEventListener('click', e => {
|
||||
if (e.target.id === 'modal-overlay') closeModal();
|
||||
});
|
||||
|
||||
async function callAPI(ep, method = 'GET', body = null) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
try {
|
||||
return await (await fetch(`${API_BASE}/${ep}`, opts)).json();
|
||||
} catch (err) {
|
||||
return { status: 'error', message: 'Koneksi gagal' };
|
||||
}
|
||||
}
|
||||
|
||||
function showInstruction(msg) {
|
||||
const el = document.getElementById('instruction-banner');
|
||||
el.textContent = '🖱 ' + msg;
|
||||
el.classList.add('show');
|
||||
}
|
||||
|
||||
function hideInstruction() {
|
||||
document.getElementById('instruction-banner').classList.remove('show');
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function normalizeSearchText(text) {
|
||||
return String(text || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getSearchIntent(query) {
|
||||
const q = normalizeSearchText(query);
|
||||
if (!q) return null;
|
||||
const aliases = [
|
||||
{ key: 'jalan', terms: ['jalan', 'road', 'street'] },
|
||||
{ key: 'spbu', terms: ['spbu', 'pom', 'bensin', 'bbm'] },
|
||||
{ key: 'rumahibadah', terms: ['masjid', 'gereja', 'pura', 'vihara', 'klenteng', 'ibadah'] },
|
||||
{ key: 'pendudukmiskin', terms: ['miskin', 'penduduk', 'warga', 'keluarga'] },
|
||||
{ key: 'persil', terms: ['persil', 'tanah', 'sertifikat', 'bidang', 'lahan'] }
|
||||
];
|
||||
for (const alias of aliases) {
|
||||
if (alias.terms.some(term => q.includes(term))) return alias.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearSearchEntries(prefix) {
|
||||
for (let i = searchEntries.length - 1; i >= 0; i--) {
|
||||
if (searchEntries[i].key.startsWith(prefix + ':')) searchEntries.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function registerSearchEntries(prefix, items) {
|
||||
clearSearchEntries(prefix);
|
||||
items.forEach(item => searchEntries.push(item));
|
||||
}
|
||||
|
||||
function getSearchMatches(query) {
|
||||
const q = normalizeSearchText(query);
|
||||
if (!q) return [];
|
||||
const tokens = q.split(' ');
|
||||
const intent = getSearchIntent(q);
|
||||
const prefix = intent ? searchIntentPrefix[intent] : null;
|
||||
const pool = prefix
|
||||
? searchEntries.filter(entry => entry.key.startsWith(prefix + ':'))
|
||||
: searchEntries;
|
||||
|
||||
return pool
|
||||
.map(entry => {
|
||||
let score = 0;
|
||||
const entryLabel = normalizeSearchText(entry.label);
|
||||
const entryType = normalizeSearchText(entry.typeLabel);
|
||||
const entryText = entry.searchText;
|
||||
if (entry.searchText === q) score += 120;
|
||||
if (entry.searchText.startsWith(q)) score += 80;
|
||||
if (entryLabel.startsWith(q)) score += 90;
|
||||
if (entryType.startsWith(q)) score += 60;
|
||||
if (entry.searchText.includes(q)) score += 50;
|
||||
const tokenHits = tokens.filter(token => entryText.includes(token) || entryLabel.includes(token)).length;
|
||||
score += tokenHits * 12;
|
||||
if (tokens.every(token => entryText.includes(token) || entryLabel.includes(token))) score += 40;
|
||||
if (tokens[0] && (entryText.split(' ').some(word => word.startsWith(tokens[0])) || entryLabel.split(' ').some(word => word.startsWith(tokens[0])))) score += 15;
|
||||
if (intent) {
|
||||
if (entry.key.startsWith(intent + ':')) score += 120;
|
||||
if (entryType.includes(intent)) score += 110;
|
||||
if (intent === 'jalan' && entryLabel.startsWith('jalan')) score += 40;
|
||||
if (intent === 'rumahibadah' && /(masjid|gereja|pura|vihara|klenteng)/.test(entryText)) score += 35;
|
||||
if (intent === 'spbu' && /(spbu|pom|bensin|bbm)/.test(entryText)) score += 35;
|
||||
if (intent === 'pendudukmiskin' && /(miskin|penduduk|warga)/.test(entryText)) score += 35;
|
||||
if (intent === 'persil' && /(persil|tanah|sertifikat|bidang)/.test(entryText)) score += 35;
|
||||
}
|
||||
return { ...entry, score };
|
||||
})
|
||||
.filter(entry => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.label.localeCompare(b.label))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function renderSearchResults(query) {
|
||||
const panel = document.getElementById('search-results');
|
||||
if (!panel) return;
|
||||
const matches = getSearchMatches(query);
|
||||
if (!matches.length || !normalizeSearchText(query)) {
|
||||
searchActiveIndex = -1;
|
||||
panel.classList.remove('show');
|
||||
panel.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchActiveIndex < 0 || searchActiveIndex >= matches.length) searchActiveIndex = 0;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="search-results-head">
|
||||
<div class="search-results-title">Rekomendasi</div>
|
||||
</div>
|
||||
<div class="search-results-list">
|
||||
${matches.map((item, index) => `
|
||||
<button type="button" class="search-result-item ${index === searchActiveIndex ? 'active' : ''}" data-index="${index}">
|
||||
<div class="search-result-icon">${item.icon}</div>
|
||||
<div class="search-result-info">
|
||||
<div class="search-result-name">${esc(item.label)}</div>
|
||||
<div class="search-result-sub">${esc(item.subtitle)}</div>
|
||||
</div>
|
||||
<div class="search-result-type">${esc(item.typeLabel)}</div>
|
||||
</button>`).join('')}
|
||||
</div>`;
|
||||
panel.classList.add('show');
|
||||
}
|
||||
|
||||
function closeSearchResults() {
|
||||
const panel = document.getElementById('search-results');
|
||||
if (!panel) return;
|
||||
panel.classList.remove('show');
|
||||
}
|
||||
|
||||
function selectSearchResult(item) {
|
||||
const input = document.getElementById('search-input');
|
||||
if (input) input.value = item.label;
|
||||
closeSearchResults();
|
||||
item.focus();
|
||||
}
|
||||
|
||||
function initSearchBar() {
|
||||
const input = document.getElementById('search-input');
|
||||
const panel = document.getElementById('search-results');
|
||||
const wrapper = document.getElementById('header-search');
|
||||
if (!input || !panel || !wrapper) return;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
searchActiveIndex = 0;
|
||||
renderSearchResults(input.value);
|
||||
});
|
||||
input.addEventListener('focus', () => renderSearchResults(input.value));
|
||||
input.addEventListener('keydown', e => {
|
||||
const matches = getSearchMatches(input.value);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const active = matches[searchActiveIndex] || matches[0];
|
||||
if (active) selectSearchResult(active);
|
||||
return;
|
||||
}
|
||||
if (!matches.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
searchActiveIndex = Math.min(searchActiveIndex + 1, matches.length - 1);
|
||||
renderSearchResults(input.value);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
searchActiveIndex = Math.max(searchActiveIndex - 1, 0);
|
||||
renderSearchResults(input.value);
|
||||
}
|
||||
});
|
||||
|
||||
panel.addEventListener('mousedown', e => {
|
||||
const item = e.target.closest('.search-result-item');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const matches = getSearchMatches(input.value);
|
||||
const selected = matches[Number(item.dataset.index)];
|
||||
if (selected) selectSearchResult(selected);
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!wrapper.contains(e.target)) closeSearchResults();
|
||||
});
|
||||
}
|
||||
|
||||
function onceMapClick(cb) {
|
||||
if (_mapClickCb) map.off('click', _mapClickCb);
|
||||
_mapClickCb = function (e) {
|
||||
_mapClickCb = null;
|
||||
cb(e.latlng.lat, e.latlng.lng);
|
||||
};
|
||||
map.once('click', _mapClickCb);
|
||||
}
|
||||
|
||||
function cancelMapClick() {
|
||||
if (_mapClickCb) {
|
||||
map.off('click', _mapClickCb);
|
||||
_mapClickCb = null;
|
||||
}
|
||||
hideInstruction();
|
||||
document.querySelectorAll('.btn-add.active').forEach(b => b.classList.remove('active'));
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
window.location.href = tab + '.html';
|
||||
}
|
||||
|
||||
function initPage(tab) {
|
||||
currentTab = tab;
|
||||
try { map.removeControl(drawControl); } catch (e) { }
|
||||
|
||||
const tabLayers = {
|
||||
kemiskinan: ['rumahIbadah', 'ibadahRadius', 'pendudukMiskin'],
|
||||
spbu: ['spbu24', 'spbuNon24'],
|
||||
jalan: ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten'],
|
||||
persil: ['persilSHM', 'persilHGB', 'persilHGU', 'persilHP']
|
||||
};
|
||||
|
||||
if (tabLayers[tab]) {
|
||||
tabLayers[tab].forEach(key => {
|
||||
if (layerVisible[key]) layers[key].addTo(map);
|
||||
});
|
||||
}
|
||||
|
||||
const titles = { kemiskinan: 'Ibadah & Kemiskinan', spbu: 'SPBU', jalan: 'Data Jalan', persil: 'Persil Tanah' };
|
||||
document.getElementById('panel-title').textContent = titles[tab];
|
||||
renderPanel(tab);
|
||||
}
|
||||
|
||||
window.startDrawJalan = function() {
|
||||
showInstruction("Klik titik pada peta untuk menggambar rute jalan. Double-klik untuk selesai.");
|
||||
new L.Draw.Polyline(map, drawControl.options.draw.polyline).enable();
|
||||
};
|
||||
|
||||
window.startDrawPersil = function() {
|
||||
showInstruction("Klik titik pada peta untuk batas persil. Klik titik awal untuk menutup area.");
|
||||
new L.Draw.Polygon(map, drawControl.options.draw.polygon).enable();
|
||||
};
|
||||
|
||||
async function renderPanel(tab) {
|
||||
const pb = document.getElementById('panel-body');
|
||||
pb.innerHTML = '<div class="loading"><span class="spinner"></span>Memuat data…</div>';
|
||||
if (tab === 'kemiskinan') await renderKemiskinanPanel(pb);
|
||||
if (tab === 'spbu') await renderSpbuPanel(pb);
|
||||
if (tab === 'jalan') await renderJalanPanel(pb);
|
||||
if (tab === 'persil') await renderPersilPanel(pb);
|
||||
}
|
||||
|
||||
function findGeomLayer(type, id) {
|
||||
const keys = type === 'jalan'
|
||||
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
|
||||
: ['persilSHM', 'persilHGB', 'persilHGU', 'persilHP'];
|
||||
for (const key of keys) {
|
||||
let found = null;
|
||||
layers[key].eachLayer(layer => {
|
||||
if (layer._dataId === id) found = layer;
|
||||
});
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGeometryEditHandler() {
|
||||
return drawControl?._toolbars?.edit?._modes?.edit?.handler || null;
|
||||
}
|
||||
|
||||
function getGeometryGroupKey(type, data) {
|
||||
return type === 'jalan' ? `jalan${data.status_jalan}` : `persil${data.jenis_hak}`;
|
||||
}
|
||||
|
||||
function cloneEditableLayer(type, sourceLayer) {
|
||||
if (type === 'jalan') {
|
||||
const coords = sourceLayer.getLatLngs().map(ll => [ll.lat, ll.lng]);
|
||||
return L.polyline(coords, { ...sourceLayer.options });
|
||||
}
|
||||
const ring = sourceLayer.getLatLngs()[0].map(ll => [ll.lat, ll.lng]);
|
||||
return L.polygon(ring, { ...sourceLayer.options });
|
||||
}
|
||||
|
||||
function buildGeometryLayer(type, data) {
|
||||
if (type === 'jalan') {
|
||||
const cfg = jalanCfg[data.status_jalan] || jalanCfg.Kabupaten;
|
||||
const coords = (data.geojson?.coordinates || []).map(c => [c[1], c[0]]);
|
||||
return L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 });
|
||||
}
|
||||
const cfg = persilCfg[data.jenis_hak] || persilCfg.SHM;
|
||||
const coords = ((data.geojson?.coordinates || [])[0] || []).map(c => [c[1], c[0]]);
|
||||
return L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 });
|
||||
}
|
||||
|
||||
function cancelGeometryEdit() {
|
||||
const handler = getGeometryEditHandler();
|
||||
if (handler) handler.disable();
|
||||
drawnItems.clearLayers();
|
||||
geometryEditState = null;
|
||||
switchTab(currentTab);
|
||||
}
|
||||
|
||||
function focusGeom(id, type) {
|
||||
const keys = type === 'jalan'
|
||||
? ['jalanNasional', 'jalanProvinsi', 'jalanKabupaten']
|
||||
: ['persilSHM', 'persilHGB', 'persilHGU', 'persilHP'];
|
||||
for (const k of keys) {
|
||||
layers[k].eachLayer(l => {
|
||||
if (l._dataId == id) {
|
||||
map.flyToBounds(l.getBounds(), { padding: [50, 50], duration: 1 });
|
||||
map.once('moveend', () => l.openPopup());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function focusMarkerById(layerKey, id, zoom = searchPointZoom) {
|
||||
const layer = layers[layerKey];
|
||||
if (!layer) return;
|
||||
let target = null;
|
||||
layer.eachLayer(marker => {
|
||||
if (marker._dataId === id) target = marker;
|
||||
});
|
||||
if (!target) return;
|
||||
map.flyTo(target.getLatLng(), zoom, { duration: 1 });
|
||||
map.once('moveend', () => target.openPopup());
|
||||
}
|
||||
|
||||
function toggleLayer(key, el) {
|
||||
layerVisible[key] = !layerVisible[key];
|
||||
el.classList.toggle('checked', layerVisible[key]);
|
||||
el.querySelector('.layer-check').textContent = layerVisible[key] ? '✓' : '';
|
||||
layerVisible[key] ? map.addLayer(layers[key]) : map.removeLayer(layers[key]);
|
||||
}
|
||||
|
||||
function updateStatusCount() {
|
||||
let total = 0;
|
||||
Object.values(layers).forEach(lg => { if (map.hasLayer(lg)) lg.eachLayer(() => total++); });
|
||||
document.getElementById('status-count').textContent = `Total fitur: ${total}`;
|
||||
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
|
||||
}
|
||||
|
||||
map.on('mousemove', e => {
|
||||
document.getElementById('coord-display').textContent = `Lat: ${e.latlng.lat.toFixed(6)} | Lng: ${e.latlng.lng.toFixed(6)}`;
|
||||
});
|
||||
map.on('zoomend', () => {
|
||||
document.getElementById('status-zoom').textContent = `Zoom: ${map.getZoom()}`;
|
||||
});
|
||||
map.on(L.Draw.Event.CREATED, function (e) {
|
||||
const { layer, layerType: type } = e;
|
||||
if (type === 'polyline') {
|
||||
const lls = layer.getLatLngs();
|
||||
pendingLength = turf.length(turf.lineString(lls.map(c => [c.lng, c.lat])), { units: 'meters' });
|
||||
pendingGeoJSON = { type: 'LineString', coordinates: lls.map(c => [c.lng, c.lat]) };
|
||||
drawnItems.addLayer(layer);
|
||||
openJalanForm();
|
||||
}
|
||||
if (type === 'polygon') {
|
||||
const lls = layer.getLatLngs()[0];
|
||||
const ring = [...lls.map(c => [c.lng, c.lat]), [lls[0].lng, lls[0].lat]];
|
||||
pendingArea = turf.area(turf.polygon([ring]));
|
||||
pendingGeoJSON = { type: 'Polygon', coordinates: [ring] };
|
||||
drawnItems.addLayer(layer);
|
||||
openPersilForm();
|
||||
}
|
||||
});
|
||||
|
||||
initSearchBar();
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
const jalanCfg = {
|
||||
Nasional: { color: '#dc2626', weight: 5 },
|
||||
Provinsi: { color: '#d97706', weight: 3.5 },
|
||||
Kabupaten: { color: '#2563eb', weight: 2.5 }
|
||||
};
|
||||
|
||||
function renderJalanPanel(pb) {
|
||||
return callAPI('data_jalan.php').then(res => {
|
||||
const data = res.data || [];
|
||||
const cnt = k => data.filter(d => d.status_jalan === k).length;
|
||||
|
||||
pb.innerHTML = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Filter Layer</div>
|
||||
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `
|
||||
<div class="layer-item ${layerVisible['jalan' + s] ? 'checked' : ''}" onclick="toggleLayer('jalan${s}',this)">
|
||||
<div class="layer-check">${layerVisible['jalan' + s] ? '✓' : ''}</div>
|
||||
<div class="layer-dot" style="background:${jalanCfg[s].color};border-radius:2px;height:4px;width:18px;border-radius:2px"></div>
|
||||
<div class="layer-label">Jalan ${s}</div>
|
||||
<div class="layer-count">${cnt(s)}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add" onclick="startDrawJalan()">+ Tambah Jalan</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Data Jalan (${data.length})</div>
|
||||
<div class="data-list">
|
||||
${data.length ? data.map(d => `
|
||||
<div class="data-item" onclick="focusGeom(${d.id},'jalan')">
|
||||
<div class="item-icon">🛣</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(d.nama_jalan)}</div>
|
||||
<div class="item-sub">${(d.panjang_meter / 1000).toFixed(3)} km</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div class="item-badge" style="background:${jalanCfg[d.status_jalan].color}18;color:${jalanCfg[d.status_jalan].color};border:1px solid ${jalanCfg[d.status_jalan].color}44">${d.status_jalan.toUpperCase()}</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editJalan(${d.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteJalan(${d.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data jalan</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
loadJalanLayer(data);
|
||||
registerSearchEntries('jalan', data.map(d => ({
|
||||
key: `jalan:${d.id}`,
|
||||
label: d.nama_jalan,
|
||||
subtitle: `Jalan ${d.status_jalan} · ${(d.panjang_meter / 1000).toFixed(3)} km${d.keterangan ? ` · ${d.keterangan}` : ''}`,
|
||||
typeLabel: 'Jalan',
|
||||
icon: '🛣',
|
||||
searchText: normalizeSearchText(`${d.nama_jalan} ${d.status_jalan} jalan`),
|
||||
focus: () => focusGeom(d.id, 'jalan')
|
||||
})));
|
||||
updateStatusCount();
|
||||
});
|
||||
}
|
||||
|
||||
function loadJalanLayer(data) {
|
||||
['Nasional', 'Provinsi', 'Kabupaten'].forEach(k => layers['jalan' + k].clearLayers());
|
||||
data.forEach(d => {
|
||||
const cfg = jalanCfg[d.status_jalan], coords = d.geojson.coordinates.map(c => [c[1], c[0]]);
|
||||
const line = L.polyline(coords, { color: cfg.color, weight: cfg.weight, opacity: 0.9 })
|
||||
.bindPopup(`<h4>🛣 ${esc(d.nama_jalan)}</h4>
|
||||
<p><b>Status:</b> Jalan ${d.status_jalan}</p>
|
||||
<p><b>Panjang:</b> ${(d.panjang_meter / 1000).toFixed(3)} km</p>
|
||||
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
|
||||
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editJalan(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deleteJalan(${d.id})">✕ Hapus</button></div>`);
|
||||
line._dataId = d.id;
|
||||
line._groupKey = 'jalan' + d.status_jalan;
|
||||
layers['jalan' + d.status_jalan].addLayer(line);
|
||||
});
|
||||
}
|
||||
|
||||
function openJalanForm() {
|
||||
openModal('Simpan Data Jalan', `
|
||||
<div class="form-calc">📏 Panjang: <b>${pendingLength.toFixed(1)} m</b> = ${(pendingLength / 1000).toFixed(3)} km </div>
|
||||
<div class="form-group"><label class="form-label">Nama Jalan *</label>
|
||||
<input class="form-input" id="jl-nama" placeholder="Jl. Ahmad Yani…"/></div>
|
||||
<div class="form-group"><label class="form-label">Status Jalan *</label>
|
||||
<select class="form-select" id="jl-status">
|
||||
<option value="Nasional">Jalan Nasional</option>
|
||||
<option value="Provinsi">Jalan Provinsi</option>
|
||||
<option value="Kabupaten">Jalan Kabupaten</option>
|
||||
</select></div>
|
||||
<div class="form-group"><label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="jl-ket" placeholder="Opsional…"/></div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveJalan()">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
async function saveJalan() {
|
||||
const body = {
|
||||
nama_jalan: document.getElementById('jl-nama').value.trim(),
|
||||
status_jalan: document.getElementById('jl-status').value,
|
||||
panjang_meter: pendingLength,
|
||||
keterangan: document.getElementById('jl-ket').value,
|
||||
geojson: pendingGeoJSON
|
||||
};
|
||||
if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI('data_jalan.php', 'POST', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
closeModal();
|
||||
drawnItems.clearLayers();
|
||||
pendingGeoJSON = null;
|
||||
pendingLength = null;
|
||||
switchTab('jalan');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editJalan(id) {
|
||||
map.closePopup();
|
||||
const res = await callAPI(`data_jalan.php?id=${id}`);
|
||||
const data = res.data;
|
||||
const sourceLayer = findGeomLayer('jalan', id);
|
||||
if (!data) return toast('Data jalan tidak ditemukan', 'error');
|
||||
|
||||
const groupKey = getGeometryGroupKey('jalan', data);
|
||||
const editLayer = sourceLayer ? cloneEditableLayer('jalan', sourceLayer) : buildGeometryLayer('jalan', data);
|
||||
editLayer._dataId = id;
|
||||
editLayer._groupKey = groupKey;
|
||||
|
||||
if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
|
||||
drawnItems.clearLayers();
|
||||
drawnItems.addLayer(editLayer);
|
||||
|
||||
geometryEditState = { type: 'jalan', id, groupKey, sourceLayer, editLayer };
|
||||
if (editLayer.editing && editLayer.editing.enable) {
|
||||
editLayer.editing.enable();
|
||||
} else {
|
||||
const handler = getGeometryEditHandler();
|
||||
if (!handler) {
|
||||
drawnItems.clearLayers();
|
||||
if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
|
||||
geometryEditState = null;
|
||||
return toast('Mode edit geometri belum siap', 'error');
|
||||
}
|
||||
handler.enable();
|
||||
}
|
||||
map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
|
||||
document.getElementById('panel-title').textContent = 'Edit Data Jalan';
|
||||
document.getElementById('panel-body').innerHTML = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Edit Jalan</div>
|
||||
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
|
||||
Geser titik-titik garis di peta lalu simpan perubahan.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Jalan *</label>
|
||||
<input class="form-input" id="jl-edit-nama" value="${esc(data.nama_jalan)}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status Jalan *</label>
|
||||
<select class="form-select" id="jl-edit-status">
|
||||
${['Nasional', 'Provinsi', 'Kabupaten'].map(s => `<option value="${s}"${data.status_jalan === s ? ' selected' : ''}>Jalan ${s}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Panjang Otomatis</label>
|
||||
<input class="form-input" id="jl-edit-panjang" value="${(data.panjang_meter / 1000).toFixed(3)} km" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="jl-edit-ket" value="${esc(data.keterangan || '')}" />
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
|
||||
<button class="btn btn-primary" style="flex:1" onclick="saveJalanEdit(${id})">Simpan Perubahan</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
async function saveJalanEdit(id) {
|
||||
const state = geometryEditState;
|
||||
if (!state || state.type !== 'jalan') return;
|
||||
const layer = state.editLayer;
|
||||
const coords = layer.getLatLngs().map(ll => [ll.lng, ll.lat]);
|
||||
const panjang_meter = turf.length(turf.lineString(coords), { units: 'meters' });
|
||||
const body = {
|
||||
nama_jalan: document.getElementById('jl-edit-nama').value.trim(),
|
||||
status_jalan: document.getElementById('jl-edit-status').value,
|
||||
panjang_meter,
|
||||
keterangan: document.getElementById('jl-edit-ket').value,
|
||||
geojson: { type: 'LineString', coordinates: coords }
|
||||
};
|
||||
if (!body.nama_jalan) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI(`data_jalan.php?id=${id}`, 'PUT', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
if (layer.editing && layer.editing.disable) layer.editing.disable();
|
||||
const handler = getGeometryEditHandler();
|
||||
if (handler) handler.disable();
|
||||
drawnItems.clearLayers();
|
||||
geometryEditState = null;
|
||||
switchTab('jalan');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJalan(id) {
|
||||
if (!confirm('Hapus?')) return;
|
||||
const r = await callAPI(`data_jalan.php?id=${id}`, 'DELETE');
|
||||
if (r.status === 'success') {
|
||||
toast('Dihapus', 'success');
|
||||
map.closePopup();
|
||||
switchTab('jalan');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
function renderKemiskinanPanel(pb) {
|
||||
return Promise.all([callAPI('rumah_ibadah.php'), callAPI('penduduk_miskin.php')]).then(([riRes, pmRes]) => {
|
||||
const ri = riRes.data || [];
|
||||
const pm = pmRes.data || [];
|
||||
|
||||
pb.innerHTML = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Visibilitas Layer</div>
|
||||
${mkLayerItem('rumahIbadah', '#f59e0b', 'Rumah Ibadah', ri.length)}
|
||||
${mkLayerItem('ibadahRadius', '#fbbf24', 'Radius Jangkauan', ri.length, true)}
|
||||
${mkLayerItem('pendudukMiskin', '#16a34a', 'Penduduk Miskin', pm.length)}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add" id="btn-add-ri" onclick="startAdd('ri')">+ Tambah Rumah Ibadah</button>
|
||||
<button class="btn-add" id="btn-add-pm" onclick="startAdd('pm')" style="margin-top:5px">+ Tambah Penduduk Miskin</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Rumah Ibadah (${ri.length})</div>
|
||||
<div class="data-list">
|
||||
${ri.length ? ri.map(r => `
|
||||
<div class="data-item" onclick="map.flyTo([${r.latitude},${r.longitude}],17)">
|
||||
<div class="item-icon">⛪</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(r.nama)}</div>
|
||||
<div class="item-sub">${r.jenis} · R=${r.radius_meter}m</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editRI(${r.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteRI(${r.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Penduduk Miskin (${pm.length})</div>
|
||||
<div class="data-list">
|
||||
${pm.length ? pm.map(p => `
|
||||
<div class="data-item" onclick="map.flyTo([${p.latitude},${p.longitude}],17)">
|
||||
<div class="item-icon">${pmEmoji(p)}</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(p.nama)}</div>
|
||||
<div class="item-sub">${p.total_bantuan} bantuan · ${p.jumlah_anggota} jiwa</div>
|
||||
</div>
|
||||
<div class="item-badge ${p.status_proximity === 'dalam_radius' ? 'badge-green' : 'badge-red'}">
|
||||
${p.status_proximity === 'dalam_radius' ? 'DALAM' : 'LUAR'}
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadRILayer(ri);
|
||||
loadPMLayer(pm);
|
||||
registerSearchEntries('ri', ri.map(r => ({
|
||||
key: `ri:${r.id}`,
|
||||
label: r.nama,
|
||||
subtitle: `Rumah Ibadah · ${r.jenis}${r.alamat ? ` · ${r.alamat}` : ''}`,
|
||||
typeLabel: 'Rumah Ibadah',
|
||||
icon: '⛪',
|
||||
searchText: normalizeSearchText(`${r.nama} ${r.jenis} rumah ibadah`),
|
||||
focus: () => focusMarkerById('rumahIbadah', r.id)
|
||||
})));
|
||||
registerSearchEntries('pm', pm.map(p => ({
|
||||
key: `pm:${p.id}`,
|
||||
label: p.nama,
|
||||
subtitle: `Penduduk Miskin · ${p.jumlah_anggota} jiwa${p.alamat ? ` · ${p.alamat}` : ''}`,
|
||||
typeLabel: 'Penduduk Miskin',
|
||||
icon: '👤',
|
||||
searchText: normalizeSearchText(`${p.nama} ${p.nik || ''} penduduk miskin`),
|
||||
focus: () => focusMarkerById('pendudukMiskin', p.id)
|
||||
})));
|
||||
updateStatusCount();
|
||||
});
|
||||
}
|
||||
|
||||
function mkLayerItem(key, color, label, count, isRing = false) {
|
||||
const dot = isRing
|
||||
? `<div class="layer-dot" style="background:transparent;border:2px solid ${color}"></div>`
|
||||
: `<div class="layer-dot" style="background:${color}"></div>`;
|
||||
return `<div class="layer-item ${layerVisible[key] ? 'checked' : ''}" onclick="toggleLayer('${key}',this)">
|
||||
<div class="layer-check">${layerVisible[key] ? '✓' : ''}</div>${dot}
|
||||
<div class="layer-label">${label}</div>
|
||||
<div class="layer-count">${count}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function pmEmoji(p) {
|
||||
if (p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0) return '🔗';
|
||||
if (p.bantuan_pemberdayaan > 0) return '📚';
|
||||
if (p.bantuan_konsumtif > 0) return '🛍';
|
||||
return '👤';
|
||||
}
|
||||
|
||||
function loadRILayer(data) {
|
||||
layers.rumahIbadah.clearLayers();
|
||||
layers.ibadahRadius.clearLayers();
|
||||
|
||||
data.forEach(r => {
|
||||
const circle = L.circle([r.latitude, r.longitude], {
|
||||
radius: r.radius_meter,
|
||||
color: '#f59e0b', fillColor: '#fef3c7', fillOpacity: 0.18,
|
||||
weight: 1.5, dashArray: '6,4'
|
||||
});
|
||||
layers.ibadahRadius.addLayer(circle);
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:32px;height:32px;background:#f59e0b;border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛪</div>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
const marker = L.marker([r.latitude, r.longitude], { icon, draggable: true }).bindPopup(riPopup(r));
|
||||
marker._dataId = r.id;
|
||||
marker.on('dragend', async function (ev) {
|
||||
const { lat, lng } = ev.target.getLatLng();
|
||||
circle.setLatLng([lat, lng]);
|
||||
const res = await callAPI(`rumah_ibadah.php?id=${r.id}`, 'PUT', {
|
||||
nama: r.nama, jenis: r.jenis, alamat: r.alamat,
|
||||
no_wa: r.no_wa, latitude: lat, longitude: lng,
|
||||
radius_meter: r.radius_meter
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
r.latitude = lat;
|
||||
r.longitude = lng;
|
||||
toast(`📍 ${r.nama} dipindahkan & koordinat diperbarui`, 'success');
|
||||
await recalcAllProximity();
|
||||
} else {
|
||||
toast('Gagal update lokasi: ' + res.message, 'error');
|
||||
marker.setLatLng([r.latitude, r.longitude]);
|
||||
}
|
||||
});
|
||||
|
||||
layers.rumahIbadah.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function riPopup(r) {
|
||||
return `<h4>⛪ ${esc(r.nama)}</h4>
|
||||
<p><b>Jenis:</b> ${r.jenis}</p>
|
||||
<p><b>Radius:</b> ${r.radius_meter} m</p>
|
||||
${r.alamat ? `<p><b>Alamat:</b> ${esc(r.alamat)}</p>` : ''}
|
||||
${r.no_wa ? `<p><b>WA:</b> ${r.no_wa}</p>` : ''}
|
||||
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="editRI(${r.id})">✏ Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteRI(${r.id})">✕ Hapus</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function loadPMLayer(data) {
|
||||
layers.pendudukMiskin.clearLayers();
|
||||
|
||||
data.forEach(p => {
|
||||
const inR = p.status_proximity === 'dalam_radius';
|
||||
const color = inR ? '#16a34a' : '#dc2626';
|
||||
const hasBoth = p.bantuan_pemberdayaan > 0 && p.bantuan_konsumtif > 0;
|
||||
const hasPemb = p.bantuan_pemberdayaan > 0;
|
||||
const hasKons = p.bantuan_konsumtif > 0;
|
||||
|
||||
let dot = '';
|
||||
if (hasBoth || hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#2563eb;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||
if (hasKons && !hasPemb) dot = `<div style="position:absolute;top:-3px;right:-3px;width:9px;height:9px;background:#f59e0b;border:1.5px solid #fff;border-radius:50%"></div>`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="position:relative"><div style="width:26px;height:26px;background:${color};border:3px solid #fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 3px 10px ${color}66;cursor:grab;">👤</div>${dot}</div>`,
|
||||
iconSize: [26, 26], iconAnchor: [13, 13]
|
||||
});
|
||||
|
||||
const marker = L.marker([p.latitude, p.longitude], { icon, draggable: true }).bindPopup(pmPopup(p));
|
||||
marker._dataId = p.id;
|
||||
marker.on('dragend', async function (ev) {
|
||||
const { lat, lng } = ev.target.getLatLng();
|
||||
const res = await callAPI(`penduduk_miskin.php?id=${p.id}`, 'PUT', {
|
||||
nama: p.nama, no_wa: p.no_wa, nik: p.nik,
|
||||
alamat: p.alamat, jumlah_anggota: p.jumlah_anggota,
|
||||
latitude: lat, longitude: lng
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
const prox = res.data?.proximity;
|
||||
const statusMsg = prox?.status === 'dalam_radius' ? '✅ Dalam radius' : '❌ Luar radius';
|
||||
toast(`📍 ${p.nama} dipindahkan — ${statusMsg}`, 'success');
|
||||
switchTab('kemiskinan');
|
||||
} else {
|
||||
toast('Gagal update: ' + res.message, 'error');
|
||||
marker.setLatLng([p.latitude, p.longitude]);
|
||||
}
|
||||
});
|
||||
|
||||
layers.pendudukMiskin.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function pmPopup(p) {
|
||||
const inR = p.status_proximity === 'dalam_radius';
|
||||
return `<h4>👤 ${esc(p.nama)}</h4>
|
||||
<div class="proximity-info ${inR ? 'dalam' : 'luar'}">
|
||||
${inR ? `✅ Dalam radius — ${esc(p.nama_rumah_ibadah || '')}` : '❌ Di luar radius rumah ibadah'}
|
||||
</div>
|
||||
${p.no_wa ? `<p><b>WA:</b> ${p.no_wa}</p>` : ''}
|
||||
${p.nik ? `<p><b>NIK:</b> ${p.nik}</p>` : ''}
|
||||
${p.alamat ? `<p><b>Alamat:</b> ${esc(p.alamat)}</p>` : ''}
|
||||
<p><b>Keluarga:</b> ${p.jumlah_anggota} jiwa</p>
|
||||
<p><b>Bantuan:</b> 📚${p.bantuan_pemberdayaan} · 🛍${p.bantuan_konsumtif}</p>
|
||||
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="openLogBantuan(${p.id},'${esc(p.nama)}')">📋 Log Bantuan</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="editPM(${p.id})">✏ Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deletePM(${p.id})">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startAdd(type) {
|
||||
cancelMapClick();
|
||||
const btnId = type === 'ri' ? 'btn-add-ri' : 'btn-add-pm';
|
||||
const label = type === 'ri' ? 'lokasi Rumah Ibadah' : 'lokasi Penduduk Miskin';
|
||||
document.getElementById(btnId).classList.add('active');
|
||||
showInstruction(`Klik di peta untuk menentukan ${label}`);
|
||||
onceMapClick((lat, lng) => {
|
||||
document.getElementById(btnId).classList.remove('active');
|
||||
hideInstruction();
|
||||
if (type === 'ri') openRIForm(null, lat, lng);
|
||||
else openPMForm(null, lat, lng);
|
||||
});
|
||||
}
|
||||
|
||||
function openRIForm(data, lat, lng) {
|
||||
const isEdit = !!data;
|
||||
const fLat = isEdit ? data.latitude : lat;
|
||||
const fLng = isEdit ? data.longitude : lng;
|
||||
openModal(isEdit ? 'Edit Rumah Ibadah' : 'Tambah Rumah Ibadah', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Rumah Ibadah *</label>
|
||||
<input class="form-input" id="ri-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Masjid Al-Ikhlas…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jenis *</label>
|
||||
<select class="form-select" id="ri-jenis">
|
||||
${['Masjid', 'Gereja', 'Pura', 'Vihara', 'Klenteng', 'Lainnya'].map(j =>
|
||||
`<option value="${j}"${isEdit && data.jenis === j ? ' selected' : ''}>${j}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Radius (meter) *</label>
|
||||
<input class="form-input" id="ri-radius" type="number" value="${isEdit ? data.radius_meter : 500}" min="50" max="10000"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="ri-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="ri-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveRI(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
async function saveRI(id, lat, lng) {
|
||||
const body = {
|
||||
nama: document.getElementById('ri-nama').value.trim(),
|
||||
jenis: document.getElementById('ri-jenis').value,
|
||||
alamat: document.getElementById('ri-alamat').value.trim(),
|
||||
no_wa: document.getElementById('ri-nowa').value.trim(),
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
radius_meter: parseInt(document.getElementById('ri-radius').value)
|
||||
};
|
||||
if (!body.nama) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI(id ? `rumah_ibadah.php?id=${id}` : 'rumah_ibadah.php', id ? 'PUT' : 'POST', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
closeModal();
|
||||
await recalcAllProximity();
|
||||
switchTab('kemiskinan');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openPMForm(data, lat, lng) {
|
||||
const isEdit = !!data;
|
||||
const fLat = isEdit ? data.latitude : lat;
|
||||
const fLng = isEdit ? data.longitude : lng;
|
||||
openModal(isEdit ? 'Edit Penduduk Miskin' : 'Tambah Penduduk Miskin', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Lengkap *</label>
|
||||
<input class="form-input" id="pm-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="Ahmad Subarjo…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="pm-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">NIK</label>
|
||||
<input class="form-input" id="pm-nik" value="${esc(isEdit ? data.nik || '' : '')}" placeholder="617101…"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="pm-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jumlah Anggota Keluarga</label>
|
||||
<input class="form-input" id="pm-jml" type="number" value="${isEdit ? data.jumlah_anggota : 1}" min="1" max="20"/>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="savePM(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
async function savePM(id, lat, lng) {
|
||||
const body = {
|
||||
nama: document.getElementById('pm-nama').value.trim(),
|
||||
no_wa: document.getElementById('pm-nowa').value.trim(),
|
||||
nik: document.getElementById('pm-nik').value.trim(),
|
||||
alamat: document.getElementById('pm-alamat').value.trim(),
|
||||
jumlah_anggota: parseInt(document.getElementById('pm-jml').value) || 1,
|
||||
latitude: lat,
|
||||
longitude: lng
|
||||
};
|
||||
if (!body.nama) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI(id ? `penduduk_miskin.php?id=${id}` : 'penduduk_miskin.php', id ? 'PUT' : 'POST', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
closeModal();
|
||||
switchTab('kemiskinan');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editRI(id) {
|
||||
map.closePopup();
|
||||
const res = await callAPI(`rumah_ibadah.php?id=${id}`);
|
||||
if (res.data) openRIForm(res.data, res.data.latitude, res.data.longitude);
|
||||
}
|
||||
|
||||
async function deleteRI(id) {
|
||||
if (!confirm('Hapus rumah ibadah ini?')) return;
|
||||
const res = await callAPI(`rumah_ibadah.php?id=${id}`, 'DELETE');
|
||||
if (res.status === 'success') {
|
||||
toast('Dihapus', 'success');
|
||||
map.closePopup();
|
||||
await recalcAllProximity();
|
||||
switchTab('kemiskinan');
|
||||
}
|
||||
}
|
||||
|
||||
async function editPM(id) {
|
||||
map.closePopup();
|
||||
const res = await callAPI(`penduduk_miskin.php?id=${id}`);
|
||||
if (res.data) openPMForm(res.data, res.data.latitude, res.data.longitude);
|
||||
}
|
||||
|
||||
async function deletePM(id) {
|
||||
if (!confirm('Hapus data ini?')) return;
|
||||
const res = await callAPI(`penduduk_miskin.php?id=${id}`, 'DELETE');
|
||||
if (res.status === 'success') {
|
||||
toast('Dihapus', 'success');
|
||||
map.closePopup();
|
||||
await recalcAllProximity();
|
||||
switchTab('kemiskinan');
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogBantuan(pmId, pmNama) {
|
||||
map.closePopup();
|
||||
const [logRes, riRes] = await Promise.all([
|
||||
callAPI(`log_bantuan.php?penduduk_miskin_id=${pmId}`),
|
||||
callAPI('rumah_ibadah.php')
|
||||
]);
|
||||
const logs = logRes.data || [];
|
||||
const ris = riRes.data || [];
|
||||
|
||||
openModal(`Log Bantuan — ${pmNama}`, `
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="layer-section-title">Riwayat Bantuan</div>
|
||||
${logs.length ? logs.map(l => `
|
||||
<div class="log-item ${l.tipe_bantuan === 'Pemberdayaan' ? 'pemberdayaan' : 'konsumtif'}">
|
||||
<div class="log-type">${l.tipe_bantuan === 'Pemberdayaan' ? '📚 Pemberdayaan' : '🛍 Konsumtif'}</div>
|
||||
<div class="log-detail">${esc(l.sub_kategori) || ''} ${l.keterangan ? '— ' + esc(l.keterangan) : ''}</div>
|
||||
<div class="log-date">📅 ${l.tanggal_bantuan} · ${esc(l.nama_rumah_ibadah)}</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada riwayat bantuan</div>'}
|
||||
</div>
|
||||
<div class="layer-section-title">Tambah Bantuan Baru</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dari Rumah Ibadah *</label>
|
||||
<select class="form-select" id="lb-ri">
|
||||
${ris.map(r => `<option value="${r.id}">${esc(r.nama)} (${r.jenis})</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tipe Bantuan *</label>
|
||||
<select class="form-select" id="lb-tipe">
|
||||
<option value="Pemberdayaan">📚 Pemberdayaan (Pelatihan)</option>
|
||||
<option value="Konsumtif">🛍 Konsumtif (Makan/Barang)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tanggal *</label>
|
||||
<input class="form-input" id="lb-tgl" type="date" value="${new Date().toISOString().slice(0, 10)}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sub Kategori</label>
|
||||
<input class="form-input" id="lb-sub" placeholder="Pelatihan menjahit / Sembako…"/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="lb-ket" placeholder="Opsional"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nilai (Rp)</label>
|
||||
<input class="form-input" id="lb-nilai" type="number" placeholder="0"/>
|
||||
</div>
|
||||
</div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Tutup</button>
|
||||
<button class="btn btn-primary" onclick="saveLog(${pmId})">➕ Tambah Log</button>`);
|
||||
}
|
||||
|
||||
async function saveLog(pmId) {
|
||||
const body = {
|
||||
penduduk_miskin_id: pmId,
|
||||
rumah_ibadah_id: parseInt(document.getElementById('lb-ri').value),
|
||||
tipe_bantuan: document.getElementById('lb-tipe').value,
|
||||
tanggal_bantuan: document.getElementById('lb-tgl').value,
|
||||
sub_kategori: document.getElementById('lb-sub').value,
|
||||
keterangan: document.getElementById('lb-ket').value,
|
||||
nilai_bantuan: document.getElementById('lb-nilai').value || null
|
||||
};
|
||||
const res = await callAPI('log_bantuan.php', 'POST', body);
|
||||
if (res.status === 'success') {
|
||||
toast('Log dicatat!', 'success');
|
||||
closeModal();
|
||||
switchTab('kemiskinan');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function recalcAllProximity() {
|
||||
await callAPI('penduduk_miskin.php?action=recalc_all', 'POST');
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
const persilCfg = {
|
||||
SHM: { color: '#16a34a', label: 'Sertifikat Hak Milik' },
|
||||
HGB: { color: '#2563eb', label: 'Hak Guna Bangunan' },
|
||||
HGU: { color: '#d97706', label: 'Hak Guna Usaha' },
|
||||
HP: { color: '#7c3aed', label: 'Hak Pakai' }
|
||||
};
|
||||
|
||||
function renderPersilPanel(pb) {
|
||||
return callAPI('data_persil.php').then(res => {
|
||||
const data = res.data || [];
|
||||
const cnt = k => data.filter(d => d.jenis_hak === k).length;
|
||||
|
||||
pb.innerHTML = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Filter Layer</div>
|
||||
${Object.entries(persilCfg).map(([k, v]) => `
|
||||
<div class="layer-item ${layerVisible['persil' + k] ? 'checked' : ''}" onclick="toggleLayer('persil${k}',this)">
|
||||
<div class="layer-check">${layerVisible['persil' + k] ? '✓' : ''}</div>
|
||||
<div class="layer-dot" style="background:${v.color}"></div>
|
||||
<div class="layer-label">${k} — ${v.label}</div>
|
||||
<div class="layer-count">${cnt(k)}</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add" onclick="startDrawPersil()">+ Tambah Persil</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Data Persil (${data.length})</div>
|
||||
<div class="data-list">
|
||||
${data.length ? data.map(d => `
|
||||
<div class="data-item" onclick="focusGeom(${d.id},'persil')">
|
||||
<div class="item-icon">📐</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(d.nama_pemilik)}</div>
|
||||
<div class="item-sub">${d.luas_m2.toFixed(1)} m² · ${d.no_sertifikat || '—'}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div class="item-badge" style="background:${persilCfg[d.jenis_hak].color}18;color:${persilCfg[d.jenis_hak].color};border:1px solid ${persilCfg[d.jenis_hak].color}44">${d.jenis_hak}</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editPersil(${d.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deletePersil(${d.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data persil</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
loadPersilLayer(data);
|
||||
registerSearchEntries('persil', data.map(d => ({
|
||||
key: `persil:${d.id}`,
|
||||
label: d.nama_pemilik,
|
||||
subtitle: `Persil ${d.jenis_hak} · ${d.luas_m2.toFixed(1)} m²${d.no_sertifikat ? ` · ${d.no_sertifikat}` : ''}`,
|
||||
typeLabel: 'Persil',
|
||||
icon: '📐',
|
||||
searchText: normalizeSearchText(`${d.nama_pemilik} ${d.jenis_hak} persil tanah`),
|
||||
focus: () => focusGeom(d.id, 'persil')
|
||||
})));
|
||||
updateStatusCount();
|
||||
});
|
||||
}
|
||||
|
||||
function loadPersilLayer(data) {
|
||||
['SHM', 'HGB', 'HGU', 'HP'].forEach(k => layers['persil' + k].clearLayers());
|
||||
data.forEach(d => {
|
||||
const cfg = persilCfg[d.jenis_hak], coords = d.geojson.coordinates[0].map(c => [c[1], c[0]]);
|
||||
const poly = L.polygon(coords, { color: cfg.color, fillColor: cfg.color, fillOpacity: 0.18, weight: 2 })
|
||||
.bindPopup(`<h4>📐 ${esc(d.nama_pemilik)}</h4>
|
||||
<p><b>Jenis Hak:</b> ${d.jenis_hak} — ${cfg.label}</p>
|
||||
<p><b>Luas:</b> ${d.luas_m2.toFixed(2)} m² (${(d.luas_m2 / 10000).toFixed(4)} ha)</p>
|
||||
${d.no_sertifikat ? `<p><b>No. Sertifikat:</b> ${d.no_sertifikat}</p>` : ''}
|
||||
${d.keterangan ? `<p>${esc(d.keterangan)}</p>` : ''}
|
||||
<div class="popup-actions"><button class="btn btn-ghost btn-sm" onclick="editPersil(${d.id})">✏ Edit</button><button class="btn btn-danger btn-sm" onclick="deletePersil(${d.id})">✕ Hapus</button></div>`);
|
||||
poly._dataId = d.id;
|
||||
poly._groupKey = 'persil' + d.jenis_hak;
|
||||
layers['persil' + d.jenis_hak].addLayer(poly);
|
||||
});
|
||||
}
|
||||
|
||||
function openPersilForm() {
|
||||
openModal('Simpan Persil Tanah', `
|
||||
<div class="form-calc">📐 Luas: <b>${pendingArea.toFixed(2)} m²</b> = ${(pendingArea / 10000).toFixed(4)} ha </div>
|
||||
<div class="form-group"><label class="form-label">Nama Pemilik *</label>
|
||||
<input class="form-input" id="ps-nama" placeholder="Budi Santoso…"/></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label">Jenis Hak *</label>
|
||||
<select class="form-select" id="ps-hak">
|
||||
<option value="SHM">SHM — Hak Milik</option>
|
||||
<option value="HGB">HGB — Guna Bangunan</option>
|
||||
<option value="HGU">HGU — Guna Usaha</option>
|
||||
<option value="HP">HP — Hak Pakai</option>
|
||||
</select></div>
|
||||
<div class="form-group"><label class="form-label">No. Sertifikat</label>
|
||||
<input class="form-input" id="ps-sert" placeholder="00001/Kel/…"/></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="ps-ket" placeholder="Opsional…"/></div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal();drawnItems.clearLayers()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="savePersil()">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
async function savePersil() {
|
||||
const body = {
|
||||
nama_pemilik: document.getElementById('ps-nama').value.trim(),
|
||||
jenis_hak: document.getElementById('ps-hak').value,
|
||||
no_sertifikat: document.getElementById('ps-sert').value,
|
||||
luas_m2: pendingArea,
|
||||
keterangan: document.getElementById('ps-ket').value,
|
||||
geojson: pendingGeoJSON
|
||||
};
|
||||
if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI('data_persil.php', 'POST', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
closeModal();
|
||||
drawnItems.clearLayers();
|
||||
pendingGeoJSON = null;
|
||||
pendingArea = null;
|
||||
switchTab('persil');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editPersil(id) {
|
||||
map.closePopup();
|
||||
const res = await callAPI(`data_persil.php?id=${id}`);
|
||||
const data = res.data;
|
||||
const sourceLayer = findGeomLayer('persil', id);
|
||||
if (!data) return toast('Data persil tidak ditemukan', 'error');
|
||||
|
||||
const groupKey = getGeometryGroupKey('persil', data);
|
||||
const editLayer = sourceLayer ? cloneEditableLayer('persil', sourceLayer) : buildGeometryLayer('persil', data);
|
||||
editLayer._dataId = id;
|
||||
editLayer._groupKey = groupKey;
|
||||
|
||||
if (sourceLayer) layers[groupKey].removeLayer(sourceLayer);
|
||||
drawnItems.clearLayers();
|
||||
drawnItems.addLayer(editLayer);
|
||||
|
||||
geometryEditState = { type: 'persil', id, groupKey, sourceLayer, editLayer };
|
||||
if (editLayer.editing && editLayer.editing.enable) {
|
||||
editLayer.editing.enable();
|
||||
} else {
|
||||
const handler = getGeometryEditHandler();
|
||||
if (!handler) {
|
||||
drawnItems.clearLayers();
|
||||
if (sourceLayer) layers[groupKey].addLayer(sourceLayer);
|
||||
geometryEditState = null;
|
||||
return toast('Mode edit geometri belum siap', 'error');
|
||||
}
|
||||
handler.enable();
|
||||
}
|
||||
map.fitBounds(editLayer.getBounds(), { padding: [50, 50], duration: 1 });
|
||||
document.getElementById('panel-title').textContent = 'Edit Persil Tanah';
|
||||
document.getElementById('panel-body').innerHTML = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Edit Persil</div>
|
||||
<div style="font-size:11px;line-height:1.7;color:var(--text2);padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px">
|
||||
Geser titik-titik batas bidang di peta lalu simpan perubahan.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nama Pemilik *</label>
|
||||
<input class="form-input" id="ps-edit-nama" value="${esc(data.nama_pemilik)}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Jenis Hak *</label>
|
||||
<select class="form-select" id="ps-edit-hak">
|
||||
${Object.entries(persilCfg).map(([k, v]) => `<option value="${k}"${data.jenis_hak === k ? ' selected' : ''}>${k} — ${v.label}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Luas Otomatis</label>
|
||||
<input class="form-input" id="ps-edit-luas" value="${data.luas_m2.toFixed(2)} m²" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. Sertifikat</label>
|
||||
<input class="form-input" id="ps-edit-sert" value="${esc(data.no_sertifikat || '')}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Keterangan</label>
|
||||
<input class="form-input" id="ps-edit-ket" value="${esc(data.keterangan || '')}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('panel-body').insertAdjacentHTML('beforeend', `
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" style="flex:1" onclick="cancelGeometryEdit()">Batal</button>
|
||||
<button class="btn btn-primary" style="flex:1" onclick="savePersilEdit(${id})">Simpan Perubahan</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
async function savePersilEdit(id) {
|
||||
const state = geometryEditState;
|
||||
if (!state || state.type !== 'persil') return;
|
||||
const layer = state.editLayer;
|
||||
const ringLatLngs = layer.getLatLngs()[0];
|
||||
const ring = ringLatLngs.map(ll => [ll.lng, ll.lat]);
|
||||
ring.push([ringLatLngs[0].lng, ringLatLngs[0].lat]);
|
||||
const luas_m2 = turf.area(turf.polygon([ring]));
|
||||
const body = {
|
||||
nama_pemilik: document.getElementById('ps-edit-nama').value.trim(),
|
||||
jenis_hak: document.getElementById('ps-edit-hak').value,
|
||||
no_sertifikat: document.getElementById('ps-edit-sert').value,
|
||||
luas_m2,
|
||||
keterangan: document.getElementById('ps-edit-ket').value,
|
||||
geojson: { type: 'Polygon', coordinates: [ring] }
|
||||
};
|
||||
if (!body.nama_pemilik) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI(`data_persil.php?id=${id}`, 'PUT', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
if (layer.editing && layer.editing.disable) layer.editing.disable();
|
||||
const handler = getGeometryEditHandler();
|
||||
if (handler) handler.disable();
|
||||
drawnItems.clearLayers();
|
||||
geometryEditState = null;
|
||||
switchTab('persil');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePersil(id) {
|
||||
if (!confirm('Hapus?')) return;
|
||||
const r = await callAPI(`data_persil.php?id=${id}`, 'DELETE');
|
||||
if (r.status === 'success') {
|
||||
toast('Dihapus', 'success');
|
||||
map.closePopup();
|
||||
switchTab('persil');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
function renderSpbuPanel(pb) {
|
||||
return callAPI('spbu.php').then(res => {
|
||||
const data = res.data || [];
|
||||
const d24 = data.filter(s => s.buka_24jam);
|
||||
const dn24 = data.filter(s => !s.buka_24jam);
|
||||
|
||||
pb.innerHTML = `
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Filter Layer</div>
|
||||
${mkLayerItem('spbu24', '#16a34a', 'SPBU Buka 24 Jam', d24.length)}
|
||||
${mkLayerItem('spbuNon24', '#94a3b8', 'SPBU Tidak 24 Jam', dn24.length)}
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Tambah Data</div>
|
||||
<button class="btn-add" id="btn-add-spbu" onclick="startAddSPBU()">+ Tambah SPBU</button>
|
||||
</div>
|
||||
<div class="layer-section">
|
||||
<div class="layer-section-title">Daftar SPBU (${data.length})</div>
|
||||
<div class="data-list">
|
||||
${data.length ? data.map(s => `
|
||||
<div class="data-item" onclick="map.flyTo([${s.latitude},${s.longitude}],17)">
|
||||
<div class="item-icon">⛽</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${esc(s.nama)}</div>
|
||||
<div class="item-sub">${s.no_wa || '—'}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div class="item-badge ${s.buka_24jam ? 'badge-green' : 'badge-amber'}">${s.buka_24jam ? '24 JAM' : 'TERBATAS'}</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editSPBU(${s.id})">✏</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteSPBU(${s.id})">✕</button>
|
||||
</div>
|
||||
</div>`).join('') : '<div class="loading">Belum ada data</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
loadSPBULayer(data);
|
||||
registerSearchEntries('spbu', data.map(s => ({
|
||||
key: `spbu:${s.id}`,
|
||||
label: s.nama,
|
||||
subtitle: `SPBU · ${s.buka_24jam ? '24 Jam' : 'Tidak 24 Jam'}${s.alamat ? ` · ${s.alamat}` : ''}`,
|
||||
typeLabel: 'SPBU',
|
||||
icon: '⛽',
|
||||
searchText: normalizeSearchText(`${s.nama} ${s.no_wa || ''} spbu`),
|
||||
focus: () => focusMarkerById(s.buka_24jam ? 'spbu24' : 'spbuNon24', s.id)
|
||||
})));
|
||||
updateStatusCount();
|
||||
});
|
||||
}
|
||||
|
||||
function loadSPBULayer(data) {
|
||||
layers.spbu24.clearLayers();
|
||||
layers.spbuNon24.clearLayers();
|
||||
data.forEach(s => {
|
||||
const is24 = s.buka_24jam;
|
||||
const color = is24 ? '#16a34a' : '#64748b';
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:30px;height:30px;background:${color};border:3px solid #fff;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 3px 10px rgba(0,0,0,.2);cursor:grab;">⛽</div>`,
|
||||
iconSize: [30, 30], iconAnchor: [15, 15]
|
||||
});
|
||||
const marker = L.marker([s.latitude, s.longitude], { icon, draggable: true }).bindPopup(`<h4>⛽ ${esc(s.nama)}</h4>
|
||||
<p><b>Status:</b> ${is24 ? '<span style="color:#16a34a;font-weight:600">✅ Buka 24 Jam</span>' : '⏰ Tidak 24 Jam'}</p>
|
||||
${s.no_wa ? `<p><b>WA:</b> ${s.no_wa}</p>` : ''}
|
||||
${s.alamat ? `<p><b>Alamat:</b> ${esc(s.alamat)}</p>` : ''}
|
||||
<div class="popup-drag-hint">↔ Seret marker untuk pindahkan lokasi — auto-save</div>
|
||||
<div class="popup-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="editSPBU(${s.id})">✏ Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteSPBU(${s.id})">✕ Hapus</button>
|
||||
</div>`);
|
||||
marker._dataId = s.id;
|
||||
|
||||
marker.on('dragend', async function (ev) {
|
||||
const { lat, lng } = ev.target.getLatLng();
|
||||
const res = await callAPI(`spbu.php?id=${s.id}`, 'PUT', {
|
||||
nama: s.nama, no_wa: s.no_wa, alamat: s.alamat,
|
||||
buka_24jam: s.buka_24jam, latitude: lat, longitude: lng
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
s.latitude = lat;
|
||||
s.longitude = lng;
|
||||
toast(`📍 ${s.nama} dipindahkan`, 'success');
|
||||
} else {
|
||||
toast('Gagal update: ' + res.message, 'error');
|
||||
marker.setLatLng([s.latitude, s.longitude]);
|
||||
}
|
||||
});
|
||||
|
||||
is24 ? layers.spbu24.addLayer(marker) : layers.spbuNon24.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function startAddSPBU() {
|
||||
cancelMapClick();
|
||||
document.getElementById('btn-add-spbu').classList.add('active');
|
||||
showInstruction('Klik di peta untuk menentukan lokasi SPBU');
|
||||
onceMapClick((lat, lng) => {
|
||||
document.getElementById('btn-add-spbu').classList.remove('active');
|
||||
hideInstruction();
|
||||
openSPBUForm(null, lat, lng);
|
||||
});
|
||||
}
|
||||
|
||||
function openSPBUForm(data, lat, lng) {
|
||||
const isEdit = !!data;
|
||||
const fLat = isEdit ? data.latitude : lat;
|
||||
const fLng = isEdit ? data.longitude : lng;
|
||||
openModal(isEdit ? 'Edit SPBU' : 'Tambah SPBU', `
|
||||
<div class="form-coord">📍 Koordinat: ${fLat.toFixed(7)}, ${fLng.toFixed(7)}</div>
|
||||
<div class="form-group"><label class="form-label">Nama SPBU *</label>
|
||||
<input class="form-input" id="spbu-nama" value="${esc(isEdit ? data.nama : '')}" placeholder="SPBU 64.751.01"/></div>
|
||||
<div class="form-group"><label class="form-label">No. WhatsApp</label>
|
||||
<input class="form-input" id="spbu-nowa" value="${esc(isEdit ? data.no_wa || '' : '')}" placeholder="081234…"/></div>
|
||||
<div class="form-group"><label class="form-label">Alamat</label>
|
||||
<input class="form-input" id="spbu-alamat" value="${esc(isEdit ? data.alamat || '' : '')}" placeholder="Jl. …"/></div>
|
||||
<div class="form-group"><label class="form-label">Status Operasional</label>
|
||||
<select class="form-select" id="spbu-24">
|
||||
<option value="1"${isEdit && data.buka_24jam ? ' selected' : ''}>✅ Buka 24 Jam</option>
|
||||
<option value="0"${isEdit && !data.buka_24jam ? ' selected' : ''}>⏰ Tidak 24 Jam</option>
|
||||
</select></div>`,
|
||||
`<button class="btn btn-ghost" onclick="closeModal()">Batal</button>
|
||||
<button class="btn btn-primary" onclick="saveSPBU(${isEdit ? data.id : 'null'},${fLat},${fLng})">💾 Simpan</button>`);
|
||||
}
|
||||
|
||||
async function saveSPBU(id, lat, lng) {
|
||||
const body = {
|
||||
nama: document.getElementById('spbu-nama').value.trim(),
|
||||
no_wa: document.getElementById('spbu-nowa').value.trim(),
|
||||
alamat: document.getElementById('spbu-alamat').value.trim(),
|
||||
buka_24jam: document.getElementById('spbu-24').value === '1',
|
||||
latitude: lat,
|
||||
longitude: lng
|
||||
};
|
||||
if (!body.nama) return toast('Nama wajib diisi', 'error');
|
||||
const res = await callAPI(id ? `spbu.php?id=${id}` : 'spbu.php', id ? 'PUT' : 'POST', body);
|
||||
if (res.status === 'success') {
|
||||
toast(res.message, 'success');
|
||||
closeModal();
|
||||
switchTab('spbu');
|
||||
} else {
|
||||
toast(res.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function editSPBU(id) {
|
||||
map.closePopup();
|
||||
const r = await callAPI(`spbu.php?id=${id}`);
|
||||
if (r.data) openSPBUForm(r.data, r.data.latitude, r.data.longitude);
|
||||
}
|
||||
|
||||
async function deleteSPBU(id) {
|
||||
if (!confirm('Hapus SPBU?')) return;
|
||||
const r = await callAPI(`spbu.php?id=${id}`, 'DELETE');
|
||||
if (r.status === 'success') {
|
||||
toast('Dihapus', 'success');
|
||||
map.closePopup();
|
||||
switchTab('spbu');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS - Poverty Mapping & Spatial Management</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;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Turf.js/6.5.0/turf.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<a href="index.html" style="text-decoration:none; color:inherit"><div class="brand-title">WebGIS Poverty</div></a>
|
||||
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="header-search" id="header-search">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="search-input" type="search" autocomplete="off" placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||
</div>
|
||||
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="map-wrap">
|
||||
<div id="instruction-banner"></div>
|
||||
<div id="map"></div>
|
||||
</section>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-head">
|
||||
<a href="index.html" class="btn btn-ghost btn-sm" style="margin-bottom: 12px; display: inline-flex; align-items: center; gap: 6px; text-decoration: none;">
|
||||
<span style="font-size: 14px;">←</span> Kembali ke Beranda
|
||||
</a>
|
||||
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||
<!-- <div class="panel-meta">
|
||||
<span>Drag marker untuk update lokasi</span>
|
||||
<span>Draw polyline untuk jalan</span>
|
||||
<span>Draw polygon untuk persil</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" aria-hidden="true">
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div class="modal-head"><h3 id="modal-title">Modal</h3></div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="js/app-core.js"></script>
|
||||
<script src="js/feature-kemiskinan.js"></script>
|
||||
<script src="js/feature-spbu.js"></script>
|
||||
<script src="js/feature-jalan.js"></script>
|
||||
<script src="js/feature-persil.js"></script>
|
||||
<script>
|
||||
initPage('kemiskinan');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS - Poverty Mapping & Spatial Management</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;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Turf.js/6.5.0/turf.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<a href="index.html" style="text-decoration:none; color:inherit"><div class="brand-title">WebGIS Poverty</div></a>
|
||||
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="header-search" id="header-search">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="search-input" type="search" autocomplete="off" placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||
</div>
|
||||
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="map-wrap">
|
||||
<div id="instruction-banner"></div>
|
||||
<div id="map"></div>
|
||||
</section>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-head">
|
||||
<a href="index.html" class="btn btn-ghost btn-sm" style="margin-bottom: 12px; display: inline-flex; align-items: center; gap: 6px; text-decoration: none;">
|
||||
<span style="font-size: 14px;">←</span> Kembali ke Beranda
|
||||
</a>
|
||||
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||
<!-- <div class="panel-meta">
|
||||
<span>Drag marker untuk update lokasi</span>
|
||||
<span>Draw polyline untuk jalan</span>
|
||||
<span>Draw polygon untuk persil</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" aria-hidden="true">
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div class="modal-head"><h3 id="modal-title">Modal</h3></div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="js/app-core.js"></script>
|
||||
<script src="js/feature-kemiskinan.js"></script>
|
||||
<script src="js/feature-spbu.js"></script>
|
||||
<script src="js/feature-jalan.js"></script>
|
||||
<script src="js/feature-persil.js"></script>
|
||||
<script>
|
||||
initPage('persil');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WebGIS - Poverty Mapping & Spatial Management</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;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Turf.js/6.5.0/turf.min.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="header">
|
||||
<div class="brand">
|
||||
<a href="index.html" style="text-decoration:none; color:inherit"><div class="brand-title">WebGIS Poverty</div></a>
|
||||
<div class="brand-sub">Sistem Informasi Geografis Berbasis Web</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="header-search" id="header-search">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input id="search-input" type="search" autocomplete="off" placeholder="Telusuri Webgis Poverty" aria-label="Cari objek peta" />
|
||||
</div>
|
||||
<div class="search-results" id="search-results" aria-label="Hasil pencarian"></div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="coord-display" id="coord-display">Lat: - | Lng: -</div>
|
||||
<div class="status-pill"><span id="status-zoom">Zoom: 13</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="map-wrap">
|
||||
<div id="instruction-banner"></div>
|
||||
<div id="map"></div>
|
||||
</section>
|
||||
|
||||
<aside id="panel">
|
||||
<div id="panel-head">
|
||||
<a href="index.html" class="btn btn-ghost btn-sm" style="margin-bottom: 12px; display: inline-flex; align-items: center; gap: 6px; text-decoration: none;">
|
||||
<span style="font-size: 14px;">←</span> Kembali ke Beranda
|
||||
</a>
|
||||
<h2 id="panel-title">Rumah Ibadah & Kemiskinan</h2>
|
||||
<!-- <div class="panel-meta">
|
||||
<span>Drag marker untuk update lokasi</span>
|
||||
<span>Draw polyline untuk jalan</span>
|
||||
<span>Draw polygon untuk persil</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div id="panel-body">
|
||||
<div class="loading"><span class="spinner"></span>Memuat data...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-overlay" aria-hidden="true">
|
||||
<div id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div class="modal-head"><h3 id="modal-title">Modal</h3></div>
|
||||
<div id="modal-body"></div>
|
||||
<div id="modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script src="js/app-core.js"></script>
|
||||
<script src="js/feature-kemiskinan.js"></script>
|
||||
<script src="js/feature-spbu.js"></script>
|
||||
<script src="js/feature-jalan.js"></script>
|
||||
<script src="js/feature-persil.js"></script>
|
||||
<script>
|
||||
initPage('spbu');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
|
||||
files = ['kemiskinan.html', 'spbu.html', 'jalan.html', 'persil.html']
|
||||
tabs = ['kemiskinan', 'spbu', 'jalan', 'persil']
|
||||
|
||||
old_nav = ''' <div class="nav-tabs" role="tablist" aria-label="Fitur utama">
|
||||
<button class="nav-tab active" onclick="switchTab('kemiskinan')">Kemiskinan</button>
|
||||
<button class="nav-tab" onclick="switchTab('spbu')">SPBU</button>
|
||||
<button class="nav-tab" onclick="switchTab('jalan')">Jalan</button>
|
||||
<button class="nav-tab" onclick="switchTab('persil')">Persil</button>
|
||||
</div>'''
|
||||
|
||||
for file, active_tab in zip(files, tabs):
|
||||
with open(file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
new_nav_lines = [' <div class="nav-tabs" role="tablist" aria-label="Fitur utama">']
|
||||
labels = ['Kemiskinan', 'SPBU', 'Jalan', 'Persil']
|
||||
for t, l in zip(tabs, labels):
|
||||
cls = 'nav-tab active' if t == active_tab else 'nav-tab'
|
||||
new_nav_lines.append(f' <a href="{t}.html" style="text-decoration: none;" class="{cls}">{l}</a>')
|
||||
new_nav_lines.append(' </div>')
|
||||
new_nav = '\n'.join(new_nav_lines)
|
||||
|
||||
content = content.replace(old_nav, new_nav)
|
||||
|
||||
old_init = '''<script>
|
||||
renderPanel('kemiskinan');
|
||||
preloadFeatureLayers();
|
||||
</script>'''
|
||||
new_init = f'''<script>
|
||||
initPage('{active_tab}');
|
||||
</script>'''
|
||||
content = content.replace(old_init, new_init)
|
||||
|
||||
content = content.replace('<div class="brand-title">WebGIS Poverty</div>', '<a href="index.html" style="text-decoration:none; color:inherit"><div class="brand-title">WebGIS Poverty</div></a>')
|
||||
|
||||
with open(file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
files = ['kemiskinan.html', 'spbu.html', 'jalan.html', 'persil.html']
|
||||
|
||||
for file in files:
|
||||
with open(file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 1. Remove nav-tabs block
|
||||
# Use regex to find and remove the div with class="nav-tabs" and its contents
|
||||
nav_tabs_pattern = re.compile(r'<div class="nav-tabs"[^>]*>.*?</div>', re.DOTALL)
|
||||
content = nav_tabs_pattern.sub('', content)
|
||||
|
||||
# 2. Add back button in panel-head
|
||||
panel_head_str = '<div id="panel-head">\n <h2 id="panel-title">'
|
||||
back_btn_html = '''<div id="panel-head">
|
||||
<a href="index.html" class="btn btn-ghost btn-sm" style="margin-bottom: 12px; display: inline-flex; align-items: center; gap: 6px; text-decoration: none;">
|
||||
<span style="font-size: 14px;">←</span> Kembali ke Beranda
|
||||
</a>
|
||||
<h2 id="panel-title">'''
|
||||
|
||||
# Only replace if not already replaced
|
||||
if 'Kembali ke Beranda' not in content:
|
||||
content = content.replace(panel_head_str, back_btn_html)
|
||||
|
||||
with open(file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
Reference in New Issue
Block a user