First commit

This commit is contained in:
2026-06-09 19:02:22 +07:00
commit c2c0774b3b
36 changed files with 10493 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
-- 1. Membuat database baru (misalnya kita beri nama 'gis_spbu')
CREATE DATABASE WEB_GIS;
-- 2. Memilih database tersebut untuk digunakan
USE WEB_GIS;
-- 3. Membuat tabel di dalam database yang sudah dipilih
CREATE TABLE Poin_SPBU (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_spbu VARCHAR(150) NOT NULL,
no_wa VARCHAR(20) NOT NULL,
buka_24_jam ENUM('Ya', 'Tidak') NOT NULL,
latitude DECIMAL(10, 8) NOT NULL,
longitude DECIMAL(11, 8) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* WebGIS - Konfigurasi & Koneksi Database
*/
// Pastikan PHP tidak menampilkan error sebagai HTML (merusak output JSON)
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(0);
define('DB_HOST', 'localhost');
define('DB_USER', 'root');
define('DB_PASS', '');
define('DB_NAME', 'WEB_GIS');
/**
* Mengembalikan koneksi MySQLi.
* Jika koneksi gagal, langsung output JSON error dan exit.
*/
function getConnection()
{
$conn = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
if ($conn->connect_error) {
http_response_code(500);
echo json_encode([
'status' => 'gagal',
'pesan' => 'Koneksi database gagal: ' . $conn->connect_error
]);
exit;
}
$conn->set_charset('utf8mb4');
return $conn;
}
+247
View File
@@ -0,0 +1,247 @@
<?php
/**
* WebGIS - API Manajemen Data Jalan
*
* Endpoint : /api/jalan.php
* Tabel : tb_jalan
* Geometri : GeoJSON LineString
*/
// Matikan display_errors agar PHP tidak output HTML error (merusak JSON)
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(0);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
/* Handle preflight request */
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/db.php';
$conn = getConnection();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
try {
switch ($method) {
/* ============================================================
READ — Ambil semua atau satu data jalan
============================================================ */
case 'GET':
if ($id) {
/* Satu data */
$stmt = $conn->prepare(
"SELECT id, nama_jalan, status_jalan, panjang_jalan, geojson, created_at
FROM tb_jalan WHERE id = ?"
);
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
if ($row) {
$row['geojson'] = json_decode($row['geojson']); // parse ke objek
echo json_encode($row);
} else {
http_response_code(404);
echo json_encode(['status' => 'gagal', 'pesan' => 'Data jalan tidak ditemukan']);
}
} else {
/* Semua data */
$filter = isset($_GET['status']) ? $_GET['status'] : null;
if ($filter) {
$stmt = $conn->prepare(
"SELECT id, nama_jalan, status_jalan, panjang_jalan, geojson, created_at
FROM tb_jalan WHERE status_jalan = ? ORDER BY created_at DESC"
);
$stmt->bind_param('s', $filter);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $conn->query(
"SELECT id, nama_jalan, status_jalan, panjang_jalan, geojson, created_at
FROM tb_jalan ORDER BY created_at DESC"
);
}
$data = [];
while ($row = $result->fetch_assoc()) {
$row['geojson'] = json_decode($row['geojson']);
$data[] = $row;
}
echo json_encode($data);
}
break;
/* ============================================================
CREATE — Simpan data jalan baru
Body (JSON):
{
"nama_jalan" : "Jalan Ahmad Yani",
"status_jalan" : "Nasional", // Nasional|Provinsi|Kabupaten
"panjang_jalan" : 2345.67, // meter, dihitung oleh LeafletJS
"geojson" : { // GeoJSON LineString
"type": "LineString",
"coordinates": [[lng,lat], ...]
}
}
============================================================ */
case 'POST':
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Body request tidak valid atau bukan JSON']);
break;
}
$nama_jalan = trim($body['nama_jalan'] ?? '');
$status_jalan = trim($body['status_jalan'] ?? '');
$panjang_jalan = (float)($body['panjang_jalan'] ?? 0);
$geojson_raw = $body['geojson'] ?? null;
/* Validasi */
$valid_status = ['Nasional', 'Provinsi', 'Kabupaten'];
if (!$nama_jalan) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'nama_jalan tidak boleh kosong']);
break;
}
if (!in_array($status_jalan, $valid_status)) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'status_jalan tidak valid. Pilih: Nasional, Provinsi, atau Kabupaten']);
break;
}
if (!$geojson_raw || ($geojson_raw['type'] ?? '') !== 'LineString') {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'geojson harus bertipe LineString']);
break;
}
$geojson_str = json_encode($geojson_raw);
$stmt = $conn->prepare(
"INSERT INTO tb_jalan (nama_jalan, status_jalan, panjang_jalan, geojson)
VALUES (?, ?, ?, ?)"
);
$stmt->bind_param('ssds', $nama_jalan, $status_jalan, $panjang_jalan, $geojson_str);
if ($stmt->execute()) {
echo json_encode([
'status' => 'sukses',
'id' => $conn->insert_id,
'pesan' => 'Data jalan berhasil disimpan'
]);
} else {
throw new Exception($stmt->error);
}
break;
/* ============================================================
UPDATE — Perbarui atribut dan/atau koordinat jalan
Mengirim field yang ingin diubah saja:
- Edit atribut : { "nama_jalan": "...", "status_jalan": "..." }
- Geser vertex : { "geojson": {...}, "panjang_jalan": 1234.5 }
- Keduanya : { semua field }
============================================================ */
case 'PUT':
if (!$id) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Parameter id diperlukan']);
break;
}
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Body tidak valid']);
break;
}
/* Bangun query dinamis berdasarkan field yang dikirim */
$setClauses = [];
$paramVals = [];
$paramTypes = '';
if (array_key_exists('nama_jalan', $body)) {
$v = trim($body['nama_jalan']);
if (!$v) { http_response_code(400); echo json_encode(['status'=>'gagal','pesan'=>'nama_jalan tidak boleh kosong']); break; }
$setClauses[] = 'nama_jalan = ?'; $paramVals[] = $v; $paramTypes .= 's';
}
if (array_key_exists('status_jalan', $body)) {
$valid = ['Nasional','Provinsi','Kabupaten'];
if (!in_array($body['status_jalan'], $valid)) {
http_response_code(400); echo json_encode(['status'=>'gagal','pesan'=>'status_jalan tidak valid']); break;
}
$setClauses[] = 'status_jalan = ?'; $paramVals[] = $body['status_jalan']; $paramTypes .= 's';
}
if (array_key_exists('panjang_jalan', $body)) {
$setClauses[] = 'panjang_jalan = ?'; $paramVals[] = (float)$body['panjang_jalan']; $paramTypes .= 'd';
}
if (array_key_exists('geojson', $body)) {
$setClauses[] = 'geojson = ?'; $paramVals[] = json_encode($body['geojson']); $paramTypes .= 's';
}
if (empty($setClauses)) {
echo json_encode(['status' => 'gagal', 'pesan' => 'Tidak ada field yang dikirim untuk diupdate']);
break;
}
$paramVals[] = $id;
$paramTypes .= 'i';
$sql = 'UPDATE tb_jalan SET ' . implode(', ', $setClauses) . ' WHERE id = ?';
$stmt = $conn->prepare($sql);
$stmt->bind_param($paramTypes, ...$paramVals);
if ($stmt->execute()) {
echo json_encode(['status' => 'sukses', 'pesan' => 'Data jalan berhasil diperbarui']);
} else {
throw new Exception($stmt->error);
}
break;
/* ============================================================
DELETE — Hapus data jalan
============================================================ */
case 'DELETE':
if (!$id) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Parameter id diperlukan']);
break;
}
$stmt = $conn->prepare("DELETE FROM tb_jalan WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
echo json_encode(['status' => 'sukses', 'pesan' => 'Data jalan berhasil dihapus']);
} else {
http_response_code(404);
echo json_encode(['status' => 'gagal', 'pesan' => 'Data tidak ditemukan atau sudah dihapus']);
}
break;
default:
http_response_code(405);
echo json_encode(['status' => 'gagal', 'pesan' => 'Method tidak didukung']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['status' => 'gagal', 'pesan' => 'Server error: ' . $e->getMessage()]);
} finally {
if ($conn) $conn->close();
}
+246
View File
@@ -0,0 +1,246 @@
<?php
/**
* WebGIS - API Manajemen Parsil Tanah / Kavling
*
* Endpoint : /api/parsil.php
* Tabel : tb_parsil
* Geometri : GeoJSON Polygon
*/
// Matikan display_errors agar PHP tidak output HTML error (merusak JSON)
ini_set('display_errors', 0);
ini_set('display_startup_errors', 0);
error_reporting(0);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
/* Handle preflight request */
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/db.php';
$conn = getConnection();
$method = $_SERVER['REQUEST_METHOD'];
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
try {
switch ($method) {
/* ============================================================
READ — Ambil semua atau satu data parsil
============================================================ */
case 'GET':
if ($id) {
/* Satu data */
$stmt = $conn->prepare(
"SELECT id, nama_parsil, status_kepemilikan, luas_tanah, geojson, created_at
FROM tb_parsil WHERE id = ?"
);
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
$row = $result->fetch_assoc();
if ($row) {
$row['geojson'] = json_decode($row['geojson']);
echo json_encode($row);
} else {
http_response_code(404);
echo json_encode(['status' => 'gagal', 'pesan' => 'Data parsil tidak ditemukan']);
}
} else {
/* Semua data */
$filter = isset($_GET['status']) ? $_GET['status'] : null;
if ($filter) {
$stmt = $conn->prepare(
"SELECT id, nama_parsil, status_kepemilikan, luas_tanah, geojson, created_at
FROM tb_parsil WHERE status_kepemilikan = ? ORDER BY created_at DESC"
);
$stmt->bind_param('s', $filter);
$stmt->execute();
$result = $stmt->get_result();
} else {
$result = $conn->query(
"SELECT id, nama_parsil, status_kepemilikan, luas_tanah, geojson, created_at
FROM tb_parsil ORDER BY created_at DESC"
);
}
$data = [];
while ($row = $result->fetch_assoc()) {
$row['geojson'] = json_decode($row['geojson']);
$data[] = $row;
}
echo json_encode($data);
}
break;
/* ============================================================
CREATE — Simpan data parsil baru
Body (JSON):
{
"nama_parsil" : "Kavling A-01",
"status_kepemilikan" : "SHM", // SHM|HGB|HGU|HP
"luas_tanah" : 1250.5000, // m², dihitung LeafletJS
"geojson" : { // GeoJSON Polygon
"type": "Polygon",
"coordinates": [[[lng,lat], ...]]
}
}
============================================================ */
case 'POST':
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Body request tidak valid atau bukan JSON']);
break;
}
$nama_parsil = trim($body['nama_parsil'] ?? '');
$status_kepemilikan = trim($body['status_kepemilikan'] ?? '');
$luas_tanah = (float)($body['luas_tanah'] ?? 0);
$geojson_raw = $body['geojson'] ?? null;
/* Validasi */
$valid_status = ['SHM', 'HGB', 'HGU', 'HP'];
if (!$nama_parsil) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'nama_parsil tidak boleh kosong']);
break;
}
if (!in_array($status_kepemilikan, $valid_status)) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'status_kepemilikan tidak valid. Pilih: SHM, HGB, HGU, atau HP']);
break;
}
if (!$geojson_raw || ($geojson_raw['type'] ?? '') !== 'Polygon') {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'geojson harus bertipe Polygon']);
break;
}
$geojson_str = json_encode($geojson_raw);
$stmt = $conn->prepare(
"INSERT INTO tb_parsil (nama_parsil, status_kepemilikan, luas_tanah, geojson)
VALUES (?, ?, ?, ?)"
);
$stmt->bind_param('ssds', $nama_parsil, $status_kepemilikan, $luas_tanah, $geojson_str);
if ($stmt->execute()) {
echo json_encode([
'status' => 'sukses',
'id' => $conn->insert_id,
'pesan' => 'Data parsil berhasil disimpan'
]);
} else {
throw new Exception($stmt->error);
}
break;
/* ============================================================
UPDATE — Perbarui atribut dan/atau koordinat parsil
Mengirim field yang ingin diubah saja:
- Edit atribut : { "nama_parsil": "...", "status_kepemilikan": "..." }
- Geser vertex : { "geojson": {...}, "luas_tanah": 1234.5 }
- Keduanya : { semua field }
============================================================ */
case 'PUT':
if (!$id) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Parameter id diperlukan']);
break;
}
$body = json_decode(file_get_contents('php://input'), true);
if (!$body) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Body tidak valid']);
break;
}
/* Bangun query dinamis berdasarkan field yang dikirim */
$setClauses = [];
$paramVals = [];
$paramTypes = '';
if (array_key_exists('nama_parsil', $body)) {
$v = trim($body['nama_parsil']);
if (!$v) { http_response_code(400); echo json_encode(['status'=>'gagal','pesan'=>'nama_parsil tidak boleh kosong']); break; }
$setClauses[] = 'nama_parsil = ?'; $paramVals[] = $v; $paramTypes .= 's';
}
if (array_key_exists('status_kepemilikan', $body)) {
$valid = ['SHM','HGB','HGU','HP'];
if (!in_array($body['status_kepemilikan'], $valid)) {
http_response_code(400); echo json_encode(['status'=>'gagal','pesan'=>'status_kepemilikan tidak valid']); break;
}
$setClauses[] = 'status_kepemilikan = ?'; $paramVals[] = $body['status_kepemilikan']; $paramTypes .= 's';
}
if (array_key_exists('luas_tanah', $body)) {
$setClauses[] = 'luas_tanah = ?'; $paramVals[] = (float)$body['luas_tanah']; $paramTypes .= 'd';
}
if (array_key_exists('geojson', $body)) {
$setClauses[] = 'geojson = ?'; $paramVals[] = json_encode($body['geojson']); $paramTypes .= 's';
}
if (empty($setClauses)) {
echo json_encode(['status' => 'gagal', 'pesan' => 'Tidak ada field yang dikirim untuk diupdate']);
break;
}
$paramVals[] = $id;
$paramTypes .= 'i';
$sql = 'UPDATE tb_parsil SET ' . implode(', ', $setClauses) . ' WHERE id = ?';
$stmt = $conn->prepare($sql);
$stmt->bind_param($paramTypes, ...$paramVals);
if ($stmt->execute()) {
echo json_encode(['status' => 'sukses', 'pesan' => 'Data parsil berhasil diperbarui']);
} else {
throw new Exception($stmt->error);
}
break;
/* ============================================================
DELETE — Hapus data parsil
============================================================ */
case 'DELETE':
if (!$id) {
http_response_code(400);
echo json_encode(['status' => 'gagal', 'pesan' => 'Parameter id diperlukan']);
break;
}
$stmt = $conn->prepare("DELETE FROM tb_parsil WHERE id = ?");
$stmt->bind_param('i', $id);
if ($stmt->execute() && $stmt->affected_rows > 0) {
echo json_encode(['status' => 'sukses', 'pesan' => 'Data parsil berhasil dihapus']);
} else {
http_response_code(404);
echo json_encode(['status' => 'gagal', 'pesan' => 'Data tidak ditemukan atau sudah dihapus']);
}
break;
default:
http_response_code(405);
echo json_encode(['status' => 'gagal', 'pesan' => 'Method tidak didukung']);
}
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['status' => 'gagal', 'pesan' => 'Server error: ' . $e->getMessage()]);
} finally {
if ($conn) $conn->close();
}
+19
View File
@@ -0,0 +1,19 @@
import re
# 1. Read 'pertemuan fix/index.html'
with open(r"D:\laragon\www\WebGIS\pertemuan fix\index.html", "r", encoding="utf-8") as f:
ref_content = f.read()
# Extract CSS
css_match = re.search(r'<style>(.*?)</style>', ref_content, re.DOTALL)
css_content = css_match.group(1) if css_match else ""
# Replace specific variables in CSS for the new context
css_content = css_content.replace('--kemiskinan', '--jalan')
css_content = css_content.replace('--masjid', '--parsil')
# We'll save the CSS as style.css
with open(r"D:\laragon\www\WebGIS\WebGIS\style.css", "w", encoding="utf-8") as f:
f.write(css_content)
print("Extracted style.css")
+974
View File
@@ -0,0 +1,974 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS — Manajemen Jalan & Parsil Tanah</title>
<meta name="description" content="Sistem Informasi Geografis untuk manajemen data jalan dan parsil tanah. Berbasis LeafletJS, PHP, dan MySQL.">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Link to the extracted premium styling -->
<link rel="stylesheet" href="style.css">
<style>
/* CSS Tambahan khusus untuk Jalan dan Parsil */
:root {
--jalan: #4f46e5;
--parsil: #10b981;
}
.db-btn {
width: 36px; height: 36px; border-radius: 9px;
border: 1.5px solid rgba(255,255,255,0.07);
background: rgba(255,255,255,0.04);
color: #64748b; cursor: pointer; font-size: 15px;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s; position: relative;
}
.db-btn:hover { background: rgba(99,102,241,0.25); border-color: rgba(99,102,241,0.5); color: #fff; }
.db-btn.active { background: linear-gradient(135deg,#4f46e5,#7c3aed); border-color: #a78bfa; color: #fff; box-shadow: 0 0 14px rgba(79,70,229,0.5); }
.db-sep { height: 1px; background: rgba(255,255,255,0.06); }
#draw-bar {
position: absolute; top: 50%; right: 12px;
transform: translateY(-50%); z-index: 1000;
display: flex; flex-direction: column; gap: 5px;
background: rgba(13,21,38,0.93);
backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px; padding: 9px 7px;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
}
</style>
</head>
<body>
<div id="header-bar">
<div class="header-title">
<span style="font-size: 20px;">🗺️</span>
<h1>WebGIS Manajemen Jalan & Parsil</h1>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<div class="status-indicator">
<div class="pulse-dot"></div>
Sistem Online
</div>
</div>
</div>
<div id="app-container">
<div id="sidebar">
<div class="sidebar-header">
<h1>🗺️ Sidebar Panel</h1>
<p>Filter & Ringkasan Statistik</p>
</div>
<div class="sidebar-body">
<div class="layer-info-box">
<p>💡 Gunakan <strong>Layers Control</strong> di pojok kanan atas peta untuk menampilkan/menyembunyikan layer berdasarkan jenis datanya.</p>
</div>
<div class="divider"></div>
<!-- JALAN STATS -->
<div class="section-title" style="color:var(--jalan)">🛣️ Data Jalan</div>
<div class="count-row" style="flex-direction:column; gap:6px;">
<div class="count-card" style="display:flex; justify-content:space-between; align-items:center; border-left:4px solid var(--jalan); padding:8px 12px">
<div style="text-align:left"><strong style="color:var(--jalan); font-size:12px;">Total Ruas</strong></div>
<div class="num" id="sj-total" style="font-size:16px; color:var(--text-primary)">0</div>
</div>
<div class="count-card" style="display:flex; justify-content:space-between; align-items:center; border-left:4px solid var(--jalan); padding:8px 12px">
<div style="text-align:left"><strong style="color:var(--jalan); font-size:12px;">Panjang Keseluruhan</strong></div>
<div class="num" id="sj-panjang" style="font-size:14px; color:var(--text-primary)">0 m</div>
</div>
</div>
<div class="item-list" id="list-jalan">
<div class="loading-text">⏳ Memuat data...</div>
</div>
<div class="divider"></div>
<!-- PARSIL STATS -->
<div class="section-title" style="color:var(--parsil)">🏘️ Data Parsil Tanah</div>
<div class="count-row" style="flex-direction:column; gap:6px;">
<div class="count-card" style="display:flex; justify-content:space-between; align-items:center; border-left:4px solid var(--parsil); padding:8px 12px">
<div style="text-align:left"><strong style="color:var(--parsil); font-size:12px;">Total Bidang</strong></div>
<div class="num" id="sp-total" style="font-size:16px; color:var(--text-primary)">0</div>
</div>
<div class="count-card" style="display:flex; justify-content:space-between; align-items:center; border-left:4px solid var(--parsil); padding:8px 12px">
<div style="text-align:left"><strong style="color:var(--parsil); font-size:12px;">Luas Keseluruhan</strong></div>
<div class="num" id="sp-luas" style="font-size:14px; color:var(--text-primary)">0 m²</div>
</div>
</div>
<div class="item-list" id="list-parsil">
<div class="loading-text">⏳ Memuat data...</div>
</div>
<div class="divider"></div>
<div class="legend-box">
<div class="legend-title">Legenda Status Jalan</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#ef4444;flex-shrink:0"></div>Nasional</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#3b82f6;flex-shrink:0"></div>Provinsi</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#f59e0b;flex-shrink:0"></div>Kabupaten</div>
</div>
<div class="legend-box" style="border-top:none; padding-top:0;">
<div class="legend-title">Legenda Status Parsil</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#22c55e;flex-shrink:0"></div>SHM</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#06b6d4;flex-shrink:0"></div>HGB</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#a855f7;flex-shrink:0"></div>HGU</div>
<div class="legend-item"><div style="width:14px;height:14px;border-radius:50%;background:#f97316;flex-shrink:0"></div>HP</div>
</div>
</div> <!-- End sidebar-body -->
</div> <!-- End sidebar -->
<div id="map"></div>
<!-- Drawing toolbar -->
<div id="draw-bar" style="display:none;">
<button class="db-btn" id="db-undo" onclick="undoPt()" title="Undo (Z)">↩️</button>
<div class="db-sep" id="db-sep1"></div>
<button class="db-btn" id="db-finish" onclick="finishDraw()" title="Selesai (Enter)"></button>
<div class="db-sep" id="db-sep2"></div>
<button class="db-btn" id="db-cancel" onclick="cancelDraw()" title="Batal (Esc)"></button>
</div>
<!-- Status bar -->
<div id="status-bar" style="display:none; position:absolute; bottom:22px; left:50%; transform:translateX(-50%); z-index:1000; background:rgba(13,21,38,0.93); backdrop-filter:blur(12px); color:#e2e8f0; font-size:11px; font-weight:500; padding:7px 16px; border-radius:30px; border:1px solid rgba(255,255,255,0.08); box-shadow:0 4px 20px rgba(0,0,0,0.35); align-items:center; gap:8px;">
<span style="width:7px; height:7px; border-radius:50%; background:#818cf8;"></span>
<span id="sb-txt"></span>
</div>
<!-- FAB Container -->
<div class="fab-container">
<div class="fab-menu" id="fab-menu">
<div class="fab-item" onclick="toggleDraw('jalan'); document.getElementById('fab-menu').classList.remove('active')">
🛣️ Tambah Jalan
</div>
<div class="fab-item" onclick="toggleDraw('parsil'); document.getElementById('fab-menu').classList.remove('active')">
🏘️ Tambah Parsil
</div>
</div>
<button class="fab-btn" id="fab-main" onclick="document.getElementById('fab-menu').classList.toggle('active')">
+
</button>
</div>
</div> <!-- End app-container -->
<!-- Modal Form -->
<div id="modal-bg" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:9999; align-items:center; justify-content:center;">
<div id="modal" style="background:var(--bg-card); width:400px; max-width:90%; border-radius:12px; border:1px solid var(--border); box-shadow:0 10px 30px rgba(0,0,0,0.5); overflow:hidden; display:flex; flex-direction:column;">
<div style="padding:16px 20px; border-bottom:1px solid var(--border); background:rgba(255,255,255,0.02);">
<h3 id="modal-title" style="margin:0; font-size:16px; color:var(--text-primary);">Title</h3>
<p id="modal-sub" style="margin:4px 0 0; font-size:11px; color:var(--text-secondary);">Subtitle</p>
</div>
<div style="padding:20px; display:flex; flex-direction:column; gap:16px;" class="popup-form">
<div>
<label>Nama</label>
<input type="text" id="inp-nama" placeholder="Masukkan nama...">
</div>
<div>
<label>Status</label>
<select id="inp-status"></select>
</div>
<div id="grp-dim">
<label id="inp-dim-lbl">Dimensi</label>
<input type="text" id="inp-dim" readonly style="background:rgba(255,255,255,0.05); color:var(--text-muted); cursor:not-allowed;">
<div id="inp-dim-note" style="font-size:9px; color:var(--text-muted); margin-top:4px;">Dihitung otomatis</div>
</div>
</div>
<div style="padding:16px 20px; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:10px; background:rgba(0,0,0,0.2);">
<button onclick="closeModal()" class="popup-btn popup-btn-cancel" style="max-width:100px;">Batal</button>
<button onclick="saveModal()" id="btn-save" class="popup-btn popup-btn-save" style="max-width:120px;">💾 Simpan</button>
</div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- SweetAlert2 -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
'use strict';
/* ═══════════════════════════════════════════════════════════
KONSTANTA
═══════════════════════════════════════════════════════════ */
const API_JALAN = './api/jalan.php';
const API_PARSIL = './api/parsil.php';
const JALAN_COLOR = { Nasional:'#ef4444', Provinsi:'#3b82f6', Kabupaten:'#f59e0b' };
const PARSIL_COLOR = { SHM:'#22c55e', HGB:'#06b6d4', HGU:'#a855f7', HP:'#f97316' };
const JALAN_STATUS_OPT = [['Nasional','🔴 Jalan Nasional'],['Provinsi','🔵 Jalan Provinsi'],['Kabupaten','🟡 Jalan Kabupaten']];
const PARSIL_STATUS_OPT = [['SHM','🟢 Sertifikat Hak Milik (SHM)'],['HGB','🔵 Hak Guna Bangunan (HGB)'],['HGU','🟣 Hak Guna Usaha (HGU)'],['HP','🟠 Hak Pakai (HP)']];
/* ═══════════════════════════════════════════════════════════
STATE
═══════════════════════════════════════════════════════════ */
let drawMode = null; // 'jalan' | 'parsil' | null
let drawingPts = []; // L.LatLng[]
let tempMkrs = [];
let tempPoly = null;
let modalType = null; // 'jalan' | 'parsil'
let modalMode = 'add'; // 'add' | 'edit'
let modalEditId = null; // id data yang sedang diedit
let pendingPts = null;
let pendingDim = null;
let jalanData = [];
let parsilData = [];
let jalanLyrs = {}; // id → { polyline, vertices, label }
let parsilLyrs = {}; // id → { polygon, vertices, label }
/* ═══════════════════════════════════════════════════════════
INISIALISASI PETA
═══════════════════════════════════════════════════════════ */
const map = L.map('map', { zoomControl: true }).setView([-0.055319, 109.349502], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
/* ═══════════════════════════════════════════════════════════
KALKULASI GEOSPASIAL — MENGGUNAKAN LEAFLET JS
1. hitungPanjangJalan : Menghitung panjang total polyline jalan
dalam meter menggunakan metode L.LatLng.distanceTo() dari
library LeafletJS. Metode ini menggunakan formula Haversine
yang sudah terintegrasi di LeafletJS.
2. hitungLuasParsil : Menghitung luas area poligon parsil
dalam meter persegi menggunakan formula geodesic spherical
area, setara dengan implementasi internal LeafletJS yang
digunakan pada L.GeometryUtil.geodesicArea().
Berbasis objek L.LatLng dari LeafletJS.
═══════════════════════════════════════════════════════════ */
/**
* Panjang total jalan (meter).
* Menggunakan L.LatLng.distanceTo() — fungsi bawaan LeafletJS.
* @param {L.LatLng[]} pts
* @returns {number}
*/
function hitungPanjangJalan(pts) {
let total = 0;
for (let i = 0; i < pts.length - 1; i++) {
// L.latLng.distanceTo() → Haversine distance (meter)
total += L.latLng(pts[i]).distanceTo(L.latLng(pts[i + 1]));
}
return parseFloat(total.toFixed(2));
}
/**
* Luas parsil tanah (meter persegi).
* Menggunakan formula geodesic spherical area berbasis L.LatLng LeafletJS.
* Formula ini setara dengan L.GeometryUtil.geodesicArea() dari ekosistem LeafletJS.
* @param {L.LatLng[]} pts
* @returns {number}
*/
function hitungLuasParsil(pts) {
const R = 6378137; // radius bumi WGS-84 (meter) — sama dengan yang digunakan LeafletJS
let area = 0;
const n = pts.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const pi = L.latLng(pts[i]);
const pj = L.latLng(pts[j]);
// Formula geodesic spherical (sumber: Leaflet source & L.GeometryUtil)
area += (pj.lng - pi.lng) * (Math.PI / 180) *
(2 + Math.sin(pi.lat * Math.PI / 180) + Math.sin(pj.lat * Math.PI / 180));
}
return parseFloat(Math.abs(area * R * R / 2).toFixed(4));
}
/* ── Format helpers ─────────────────────────── */
function fmtPanjang(m) {
return m >= 1000 ? (m / 1000).toFixed(2) + ' km' : m.toFixed(0) + ' m';
}
function fmtLuas(m2) {
return m2 >= 10000 ? (m2 / 10000).toFixed(4) + ' ha' : m2.toFixed(2) + ' m²';
}
/* ── GeoJSON ↔ LeafletJS conversion ────────── */
// GeoJSON LineString: coordinates = [[lng,lat],...]
// GeoJSON Polygon: coordinates = [[[lng,lat],...,[lng,lat]]]
// Leaflet: L.LatLng(lat, lng)
function toGeoJSONLine(lls) {
return { type:'LineString', coordinates: lls.map(ll => [ll.lng, ll.lat]) };
}
function toGeoJSONPolygon(lls) {
const ring = lls.map(ll => [ll.lng, ll.lat]);
ring.push(ring[0]); // tutup ring
return { type:'Polygon', coordinates: [ring] };
}
function fromGeoJSONLine(gj) {
return gj.coordinates.map(c => L.latLng(c[1], c[0]));
}
function fromGeoJSONPolygon(gj) {
// Ambil ring pertama, buang titik penutup
return gj.coordinates[0].slice(0, -1).map(c => L.latLng(c[1], c[0]));
}
function centroid(lls) {
return L.latLng(
lls.reduce((s,p) => s + p.lat, 0) / lls.length,
lls.reduce((s,p) => s + p.lng, 0) / lls.length
);
}
/* ═══════════════════════════════════════════════════════════
TAB
═══════════════════════════════════════════════════════════ */
function switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tbtn-' + tab).classList.add('active');
document.getElementById('tp-' + tab).classList.add('active');
}
/* ═══════════════════════════════════════════════════════════
DRAWING
═══════════════════════════════════════════════════════════ */
function toggleDraw(type) {
if (drawMode === type) { cancelDraw(); return; }
if (drawMode) cancelDraw();
drawMode = type;
drawingPts = [];
map.getContainer().style.cursor = 'crosshair';
// Show draw controls
document.getElementById('draw-bar').style.display = 'flex';
setStatus(type === 'jalan'
? '🛣️ Klik peta untuk menambah titik jalan | Enter = selesai'
: '🏘️ Klik peta untuk menambah titik parsil | Enter = selesai'
);
}
function cancelDraw() {
clearTemp();
drawMode = null;
map.getContainer().style.cursor = '';
// Hide draw controls
document.getElementById('draw-bar').style.display = 'none';
clearStatus();
}
function clearTemp() {
drawingPts = [];
tempMkrs.forEach(m => map.removeLayer(m));
tempMkrs = [];
if (tempPoly) { map.removeLayer(tempPoly); tempPoly = null; }
}
/* Map click → add vertex */
map.on('click', function(e) {
if (!drawMode) return;
drawingPts.push(e.latlng);
const m = L.circleMarker(e.latlng, {
radius:5, color:'#fff', fillColor:'#818cf8', fillOpacity:1, weight:2
}).addTo(map);
tempMkrs.push(m);
refreshTempPoly();
updateDrawStatus();
});
function refreshTempPoly() {
if (tempPoly) map.removeLayer(tempPoly);
if (drawingPts.length < 2) return;
const pts = drawMode === 'parsil' ? [...drawingPts, drawingPts[0]] : drawingPts;
tempPoly = L.polyline(pts, { color:'#818cf8', weight:3, dashArray:'8,5', opacity:.85 }).addTo(map);
}
function updateDrawStatus() {
if (!drawMode || !drawingPts.length) return;
let extra = '';
if (drawMode === 'jalan' && drawingPts.length >= 2)
extra = ` | Panjang: ${fmtPanjang(hitungPanjangJalan(drawingPts))}`;
if (drawMode === 'parsil' && drawingPts.length >= 3)
extra = ` | Luas: ${fmtLuas(hitungLuasParsil(drawingPts))}`;
setStatus(`${drawMode==='jalan'?'🛣️':'🏘️'} ${drawingPts.length} titik${extra} | Enter = selesai`);
}
function undoPt() {
if (!drawingPts.length) return;
drawingPts.pop();
const m = tempMkrs.pop();
if (m) map.removeLayer(m);
refreshTempPoly();
updateDrawStatus();
}
function finishDraw() {
if (!drawMode) return;
if (drawMode === 'jalan' && drawingPts.length < 2) { alert('⚠ Minimal 2 titik untuk jalan!'); return; }
if (drawMode === 'parsil' && drawingPts.length < 3) { alert('⚠ Minimal 3 titik untuk parsil!'); return; }
const pts = drawingPts.slice();
const type = drawMode;
clearTemp();
cancelDraw();
let dim, dimLabel, dimStr, dimNote;
if (type === 'jalan') {
dim = hitungPanjangJalan(pts);
dimLabel = 'Panjang Jalan';
dimStr = fmtPanjang(dim) + ' (' + dim.toFixed(2) + ' m)';
dimNote = 'Dihitung otomatis: L.LatLng.distanceTo() dari LeafletJS';
} else {
dim = hitungLuasParsil(pts);
dimLabel = 'Luas Tanah';
dimStr = fmtLuas(dim) + ' (' + dim.toFixed(4) + ' m²)';
dimNote = 'Dihitung otomatis: geodesic spherical area (LeafletJS)';
}
pendingPts = pts;
pendingDim = dim;
openModal(type, dimLabel, dimStr, dimNote);
}
/* ═══════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════ */
/* ── Helper: isi opsi select status ────────── */
function isiStatusOpts(type, selectedVal) {
const sel = document.getElementById('inp-status');
sel.innerHTML = '';
const opts = type === 'jalan' ? JALAN_STATUS_OPT : PARSIL_STATUS_OPT;
opts.forEach(([v, l]) => {
const o = document.createElement('option');
o.value = v; o.textContent = l;
if (v === selectedVal) o.selected = true;
sel.appendChild(o);
});
}
/* ── Buka modal TAMBAH (setelah selesai gambar) ── */
function openModal(type, dimLbl, dimStr, dimNote) {
modalType = type; modalMode = 'add'; modalEditId = null;
document.getElementById('modal-title').textContent = type === 'jalan'
? '🛣️ Simpan Data Jalan' : '🏘️ Simpan Data Parsil Tanah';
document.getElementById('modal-sub').textContent = type === 'jalan'
? 'Lengkapi atribut ruas jalan baru' : 'Lengkapi atribut parsil/kavling baru';
isiStatusOpts(type, null);
document.getElementById('inp-dim-lbl').textContent = dimLbl;
document.getElementById('inp-dim').value = dimStr;
document.getElementById('inp-dim-note').textContent = dimNote;
document.getElementById('inp-nama').value = '';
document.getElementById('grp-dim').style.display = '';
document.getElementById('btn-save').textContent = '💾 Simpan';
document.getElementById('modal-bg').style.display = 'flex';
setTimeout(() => document.getElementById('inp-nama').focus(), 60);
}
/* ── Buka modal EDIT atribut ─────────────────── */
function openEditModal(type, id) {
map.closePopup();
let item;
if (type === 'jalan') {
item = jalanData.find(j => j.id == id);
} else {
item = parsilData.find(p => p.id == id);
}
if (!item) return;
modalType = type; modalMode = 'edit'; modalEditId = id;
const isJalan = (type === 'jalan');
document.getElementById('modal-title').textContent = isJalan
? '✏️ Edit Data Jalan' : '✏️ Edit Data Parsil Tanah';
document.getElementById('modal-sub').textContent = isJalan
? 'Ubah nama atau status jalan' : 'Ubah nama atau status kepemilikan parsil';
// Pre-fill form
document.getElementById('inp-nama').value = isJalan ? item.nama_jalan : item.nama_parsil;
isiStatusOpts(type, isJalan ? item.status_jalan : item.status_kepemilikan);
// Tampilkan info dimensi (readonly, tidak bisa diubah di sini)
if (isJalan) {
document.getElementById('inp-dim-lbl').textContent = 'Panjang Jalan (tidak berubah)';
document.getElementById('inp-dim').value = fmtPanjang(parseFloat(item.panjang_jalan)) + ' (' + parseFloat(item.panjang_jalan).toFixed(2) + ' m)';
document.getElementById('inp-dim-note').textContent = 'Geser titik pada peta untuk mengubah panjang jalan';
} else {
document.getElementById('inp-dim-lbl').textContent = 'Luas Tanah (tidak berubah)';
document.getElementById('inp-dim').value = fmtLuas(parseFloat(item.luas_tanah)) + ' (' + parseFloat(item.luas_tanah).toFixed(4) + ' m²)';
document.getElementById('inp-dim-note').textContent = 'Geser titik sudut pada peta untuk mengubah luas';
}
document.getElementById('grp-dim').style.display = '';
document.getElementById('btn-save').textContent = '✏️ Simpan Perubahan';
document.getElementById('modal-bg').style.display = 'flex';
setTimeout(() => document.getElementById('inp-nama').focus(), 60);
}
function closeModal() {
document.getElementById('modal-bg').style.display = 'none';
modalType = null; modalMode = 'add'; modalEditId = null;
pendingPts = null; pendingDim = null;
}
function handleBgClick(e) { if (e.target === document.getElementById('modal-bg')) closeModal(); }
async function saveModal() {
const nama = document.getElementById('inp-nama').value.trim();
const status = document.getElementById('inp-status').value;
if (!nama) { alert('Nama tidak boleh kosong!'); document.getElementById('inp-nama').focus(); return; }
const btn = document.getElementById('btn-save');
const origLabel = btn.textContent;
btn.textContent = '⏳ Menyimpan...'; btn.disabled = true;
try {
/* ── MODE EDIT (update atribut saja) ── */
if (modalMode === 'edit') {
if (modalType === 'jalan') {
const res = await fetch(API_JALAN + '?id=' + modalEditId, {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ nama_jalan: nama, status_jalan: status })
});
const text = await res.text();
if (!text) throw new Error("Server mengembalikan respons kosong. Pastikan Anda menjalankan aplikasi ini melalui server PHP (Laragon/XAMPP), BUKAN Live Server.");
const data = JSON.parse(text);
if (data.status === 'sukses') {
// Update data lokal
const idx = jalanData.findIndex(j => j.id == modalEditId);
if (idx >= 0) { jalanData[idx].nama_jalan = nama; jalanData[idx].status_jalan = status; }
closeModal();
// Re-render layer di peta (warna & label bisa berubah)
const l = jalanLyrs[modalEditId];
if (l) {
const newColor = JALAN_COLOR[status] || '#6366f1';
l.polyline.setStyle({ color: newColor });
l.label.setContent(nama);
l.vertices.forEach(v => v.setStyle({ fillColor: newColor }));
l.polyline.bindPopup(buatPopupJalan(jalanData[idx], null, l.polyline, l.label));
}
renderListJalan();
} else { alert('Gagal: ' + data.pesan); }
} else {
const res = await fetch(API_PARSIL + '?id=' + modalEditId, {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ nama_parsil: nama, status_kepemilikan: status })
});
const text = await res.text();
if (!text) throw new Error("Server mengembalikan respons kosong. Pastikan Anda menjalankan aplikasi ini melalui server PHP (Laragon/XAMPP), BUKAN Live Server.");
const data = JSON.parse(text);
if (data.status === 'sukses') {
const idx = parsilData.findIndex(p => p.id == modalEditId);
if (idx >= 0) { parsilData[idx].nama_parsil = nama; parsilData[idx].status_kepemilikan = status; }
closeModal();
const l = parsilLyrs[modalEditId];
if (l) {
const newColor = PARSIL_COLOR[status] || '#6366f1';
l.polygon.setStyle({ color: newColor, fillColor: newColor });
l.label.setContent(nama);
l.vertices.forEach(v => v.setStyle({ fillColor: newColor }));
l.polygon.bindPopup(buatPopupParsil(parsilData[idx], null, l.polygon, l.label));
}
renderListParsil();
} else { alert('Gagal: ' + data.pesan); }
}
return;
}
/* ── MODE TAMBAH (create baru) ── */
if (modalType === 'jalan') {
const res = await fetch(API_JALAN, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
nama_jalan: nama, status_jalan: status,
panjang_jalan: pendingDim, geojson: toGeoJSONLine(pendingPts)
})
});
const text = await res.text();
if (!text) throw new Error("Server mengembalikan respons kosong. Pastikan Anda menjalankan aplikasi ini melalui server PHP (Laragon/XAMPP), BUKAN Live Server.");
const data = JSON.parse(text);
if (data.status === 'sukses') {
closeModal(); await muatJalan();
const lyr = jalanLyrs[data.id];
if (lyr) map.fitBounds(lyr.polyline.getBounds(), {padding:[50,50]});
} else { alert('Gagal: ' + data.pesan); }
} else {
const res = await fetch(API_PARSIL, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
nama_parsil: nama, status_kepemilikan: status,
luas_tanah: pendingDim, geojson: toGeoJSONPolygon(pendingPts)
})
});
const text = await res.text();
if (!text) throw new Error("Server mengembalikan respons kosong. Pastikan Anda menjalankan aplikasi ini melalui server PHP (Laragon/XAMPP), BUKAN Live Server.");
const data = JSON.parse(text);
if (data.status === 'sukses') {
closeModal(); await muatParsil();
const lyr = parsilLyrs[data.id];
if (lyr) map.fitBounds(lyr.polygon.getBounds(), {padding:[50,50]});
} else { alert('Gagal: ' + data.pesan); }
}
} catch(err) { alert('Error: ' + err.message); }
finally { btn.textContent = origLabel; btn.disabled = false; }
}
/* Shortcut global untuk tombol edit di popup & sidebar */
window.editJalan = (id) => openEditModal('jalan', id);
window.editParsil = (id) => openEditModal('parsil', id);
/* ═══════════════════════════════════════════════════════════
JALAN — CRUD
═══════════════════════════════════════════════════════════ */
async function muatJalan() {
try { const r = await fetch(API_JALAN); jalanData = await r.json(); }
catch(e) { jalanData = []; console.warn('API Jalan:', e); }
// Hapus layer lama
Object.values(jalanLyrs).forEach(l => {
map.removeLayer(l.polyline);
l.vertices.forEach(v => map.removeLayer(v));
if (l.label) map.removeLayer(l.label);
});
jalanLyrs = {};
jalanData.forEach(j => renderJalan(j));
renderListJalan();
updateStatsJalan();
}
function renderJalan(jalan) {
const lls = fromGeoJSONLine(jalan.geojson);
const color = JALAN_COLOR[jalan.status_jalan] || '#6366f1';
const polyline = L.polyline(lls, { color, weight:5, opacity:.9, lineJoin:'round' }).addTo(map);
// Label nama di tengah
const midPt = lls[Math.floor(lls.length / 2)];
const label = L.tooltip({ permanent:true, direction:'top', className:'map-label', offset:[0,-4] })
.setContent(jalan.nama_jalan).setLatLng(midPt).addTo(map);
// Popup
function mkPopup() { return buatPopupJalan(jalan, lls, polyline, label); }
polyline.bindPopup(mkPopup());
// Vertex drag
const vertices = [];
lls.forEach((pt, idx) => {
const vm = L.circleMarker(pt, { radius:5, color:'#fff', fillColor:color, fillOpacity:1, weight:2 }).addTo(map);
vm.on('mousedown', e => {
map.dragging.disable(); L.DomEvent.stopPropagation(e);
function onMove(ev) {
vm.setLatLng(ev.latlng); lls[idx] = ev.latlng;
polyline.setLatLngs(lls);
label.setLatLng(lls[Math.floor(lls.length/2)]);
}
function onUp() {
map.dragging.enable();
map.off('mousemove', onMove); map.off('mouseup', onUp);
const newP = hitungPanjangJalan(lls);
updateJalanAPI(jalan.id, lls, newP);
polyline.bindPopup(buatPopupJalan({...jalan, panjang_jalan:newP}, lls, polyline, label));
}
map.on('mousemove', onMove); map.on('mouseup', onUp);
});
vertices.push(vm);
});
jalanLyrs[jalan.id] = { polyline, vertices, label };
}
function buatPopupJalan(j, lls, polyline, label) {
const color = JALAN_COLOR[j.status_jalan];
const pjg = lls ? hitungPanjangJalan(lls) : parseFloat(j.panjang_jalan);
return `
<div class="gis-popup">
<div class="popup-title" style="color:${color}">🛣️ ${j.nama_jalan}</div>
<div class="info-row"><span class="info-label">Status</span><span class="info-value"><span style="background:${color}20;color:${color};padding:2px 6px;border-radius:4px;font-size:10px;font-weight:bold">${j.status_jalan}</span></span></div>
<div class="info-row"><span class="info-label">Panjang</span><span class="info-value">📐 ${fmtPanjang(pjg)}</span></div>
<div class="popup-form">
<div style="font-size:9px; color:var(--text-muted); font-style:italic; margin-top:8px;">Seret titik pada jalan untuk ubah koordinat</div>
</div>
<div class="popup-actions">
<button class="popup-btn popup-btn-edit" onclick="window.editJalan(${j.id})">✏️ Edit</button>
<button class="popup-btn popup-btn-delete" onclick="window.hapusJalan(${j.id})">🗑️ Hapus</button>
</div>
</div>`;
}
function renderListJalan() {
// Note: We don't have fil-jalan dropdown in the new sidebar yet, if needed we can add it later.
// Assuming no filter for now.
const data = jalanData;
const el = document.getElementById('list-jalan');
if (!data.length) {
el.innerHTML = `<div class="loading-text">Tidak ada data jalan.</div>`;
return;
}
el.innerHTML = data.map(j => {
const c = JALAN_COLOR[j.status_jalan]; const bg = c+'22';
return `<div class="item-card" onclick="zoomJalan(${j.id})">
<div class="item-card-header">
<div class="item-card-name">${j.nama_jalan}</div>
<div class="badge" style="background:${bg};color:${c};">${j.status_jalan}</div>
</div>
<div class="item-card-info">
📐 Panjang: ${fmtPanjang(parseFloat(j.panjang_jalan))}
</div>
<div class="item-card-actions">
<button class="btn-sm btn-zoom" title="Zoom" onclick="event.stopPropagation();zoomJalan(${j.id})">🔍 Zoom</button>
<button class="btn-sm btn-edit" title="Edit" onclick="event.stopPropagation();window.editJalan(${j.id})">✏️ Edit</button>
<button class="btn-sm btn-delete" title="Hapus" onclick="event.stopPropagation();window.hapusJalan(${j.id})">🗑️ Hapus</button>
</div>
</div>`;
}).join('');
}
function updateStatsJalan() {
const total = jalanData.length;
const km = jalanData.reduce((s,j) => s + parseFloat(j.panjang_jalan), 0);
document.getElementById('sj-total').textContent = total;
document.getElementById('sj-panjang').textContent = fmtPanjang(km);
}
function zoomJalan(id) {
const l = jalanLyrs[id];
if (l) { map.fitBounds(l.polyline.getBounds(), {padding:[60,60], maxZoom:17}); l.polyline.openPopup(); }
}
window.hapusJalan = function(id) {
map.closePopup();
Swal.fire({
title: 'Hapus Jalan?',
text: "Data jalan ini akan dihapus permanen dari database!",
icon: 'warning',
showCancelButton: true,
background: '#1e293b',
color: '#f1f5f9',
confirmButtonColor: '#ef4444',
cancelButtonColor: '#4f46e5',
confirmButtonText: '🗑️ Ya, Hapus',
cancelButtonText: 'Batal'
}).then(async (result) => {
if (result.isConfirmed) {
try {
const r = await fetch(API_JALAN + '?id=' + id, {method:'DELETE'});
const d = await r.json();
if (d.status === 'sukses') {
const l = jalanLyrs[id];
if (l) { map.removeLayer(l.polyline); l.vertices.forEach(v=>map.removeLayer(v)); if(l.label) map.removeLayer(l.label); }
delete jalanLyrs[id];
jalanData = jalanData.filter(j => j.id != id);
renderListJalan(); updateStatsJalan();
Swal.fire({icon: 'success', title: 'Terhapus!', text: 'Data jalan berhasil dihapus.', background: '#1e293b', color: '#f1f5f9', timer: 1500, showConfirmButton: false});
} else { Swal.fire({icon: 'error', title: 'Gagal', text: d.pesan, background: '#1e293b', color: '#f1f5f9'}); }
} catch(e) { Swal.fire({icon: 'error', title: 'Error', text: e.message, background: '#1e293b', color: '#f1f5f9'}); }
}
});
};
async function updateJalanAPI(id, lls, panjang) {
try {
await fetch(API_JALAN + '?id=' + id, {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ geojson:toGeoJSONLine(lls), panjang_jalan:panjang })
});
const i = jalanData.findIndex(j => j.id == id);
if (i >= 0) jalanData[i].panjang_jalan = panjang;
updateStatsJalan(); renderListJalan();
} catch(e) { console.error('Update jalan:', e); }
}
/* ═══════════════════════════════════════════════════════════
PARSIL — CRUD
═══════════════════════════════════════════════════════════ */
async function muatParsil() {
try { const r = await fetch(API_PARSIL); parsilData = await r.json(); }
catch(e) { parsilData = []; console.warn('API Parsil:', e); }
Object.values(parsilLyrs).forEach(l => {
map.removeLayer(l.polygon);
l.vertices.forEach(v => map.removeLayer(v));
if (l.label) map.removeLayer(l.label);
});
parsilLyrs = {};
parsilData.forEach(p => renderParsil(p));
renderListParsil();
updateStatsParsil();
}
function renderParsil(parsil) {
const lls = fromGeoJSONPolygon(parsil.geojson);
const color = PARSIL_COLOR[parsil.status_kepemilikan] || '#6366f1';
const polygon = L.polygon(lls, { color, fillColor:color, fillOpacity:.18, weight:2.5 }).addTo(map);
const label = L.tooltip({ permanent:true, direction:'center', className:'map-label', offset:[0,0] })
.setContent(parsil.nama_parsil).setLatLng(centroid(lls)).addTo(map);
function mkPopup() { return buatPopupParsil(parsil, lls, polygon, label); }
polygon.bindPopup(mkPopup());
const vertices = [];
lls.forEach((pt, idx) => {
const vm = L.circleMarker(pt, { radius:5, color:'#fff', fillColor:color, fillOpacity:1, weight:2 }).addTo(map);
vm.on('mousedown', e => {
map.dragging.disable(); L.DomEvent.stopPropagation(e);
function onMove(ev) {
vm.setLatLng(ev.latlng); lls[idx] = ev.latlng;
polygon.setLatLngs(lls); label.setLatLng(centroid(lls));
}
function onUp() {
map.dragging.enable();
map.off('mousemove', onMove); map.off('mouseup', onUp);
const newL = hitungLuasParsil(lls);
updateParsilAPI(parsil.id, lls, newL);
polygon.bindPopup(buatPopupParsil({...parsil, luas_tanah:newL}, lls, polygon, label));
}
map.on('mousemove', onMove); map.on('mouseup', onUp);
});
vertices.push(vm);
});
parsilLyrs[parsil.id] = { polygon, vertices, label };
}
function buatPopupParsil(p, lls, polygon, label) {
const color = PARSIL_COLOR[p.status_kepemilikan];
const luas = lls ? hitungLuasParsil(lls) : parseFloat(p.luas_tanah);
const sk = { SHM:'Sertifikat Hak Milik', HGB:'Hak Guna Bangunan', HGU:'Hak Guna Usaha', HP:'Hak Pakai' };
return `
<div class="gis-popup">
<div class="popup-title" style="color:${color}">🏘️ ${p.nama_parsil}</div>
<div class="info-row"><span class="info-label">Status</span><span class="info-value"><span style="background:${color}20;color:${color};padding:2px 6px;border-radius:4px;font-size:10px;font-weight:bold">${p.status_kepemilikan}</span></span></div>
<div class="info-row"><span class="info-label">Luas</span><span class="info-value">📐 ${fmtLuas(luas)}</span></div>
<div class="popup-form">
<div style="font-size:9px; color:var(--text-muted); font-style:italic; margin-top:8px;">Seret titik sudut poligon untuk ubah bentuk parsil</div>
</div>
<div class="popup-actions">
<button class="popup-btn popup-btn-edit" onclick="window.editParsil(${p.id})">✏️ Edit</button>
<button class="popup-btn popup-btn-delete" onclick="window.hapusParsil(${p.id})">🗑️ Hapus</button>
</div>
</div>`;
}
function renderListParsil() {
const data = parsilData;
const el = document.getElementById('list-parsil');
if (!data.length) {
el.innerHTML = `<div class="loading-text">Tidak ada data parsil.</div>`;
return;
}
el.innerHTML = data.map(p => {
const c = PARSIL_COLOR[p.status_kepemilikan]; const bg = c+'22';
return `<div class="item-card" onclick="zoomParsil(${p.id})">
<div class="item-card-header">
<div class="item-card-name">${p.nama_parsil}</div>
<div class="badge" style="background:${bg};color:${c};">${p.status_kepemilikan}</div>
</div>
<div class="item-card-info">
📐 Luas: ${fmtLuas(parseFloat(p.luas_tanah))}
</div>
<div class="item-card-actions">
<button class="btn-sm btn-zoom" title="Zoom" onclick="event.stopPropagation();zoomParsil(${p.id})">🔍 Zoom</button>
<button class="btn-sm btn-edit" title="Edit" onclick="event.stopPropagation();window.editParsil(${p.id})">✏️ Edit</button>
<button class="btn-sm btn-delete" title="Hapus" onclick="event.stopPropagation();window.hapusParsil(${p.id})">🗑️ Hapus</button>
</div>
</div>`;
}).join('');
}
function updateStatsParsil() {
const total = parsilData.length;
const luas = parsilData.reduce((s,p) => s + parseFloat(p.luas_tanah), 0);
document.getElementById('sp-total').textContent = total;
document.getElementById('sp-luas').textContent = fmtLuas(luas);
}
function zoomParsil(id) {
const l = parsilLyrs[id];
if (l) { map.fitBounds(l.polygon.getBounds(), {padding:[60,60], maxZoom:18}); l.polygon.openPopup(); }
}
window.hapusParsil = function(id) {
map.closePopup();
Swal.fire({
title: 'Hapus Parsil?',
text: "Data parsil tanah ini akan dihapus permanen dari database!",
icon: 'warning',
showCancelButton: true,
background: '#1e293b',
color: '#f1f5f9',
confirmButtonColor: '#ef4444',
cancelButtonColor: '#4f46e5',
confirmButtonText: '🗑️ Ya, Hapus',
cancelButtonText: 'Batal'
}).then(async (result) => {
if (result.isConfirmed) {
try {
const r = await fetch(API_PARSIL + '?id=' + id, {method:'DELETE'});
const d = await r.json();
if (d.status === 'sukses') {
const l = parsilLyrs[id];
if (l) { map.removeLayer(l.polygon); l.vertices.forEach(v=>map.removeLayer(v)); if(l.label) map.removeLayer(l.label); }
delete parsilLyrs[id];
parsilData = parsilData.filter(p => p.id != id);
renderListParsil(); updateStatsParsil();
Swal.fire({icon: 'success', title: 'Terhapus!', text: 'Data parsil berhasil dihapus.', background: '#1e293b', color: '#f1f5f9', timer: 1500, showConfirmButton: false});
} else { Swal.fire({icon: 'error', title: 'Gagal', text: d.pesan, background: '#1e293b', color: '#f1f5f9'}); }
} catch(e) { Swal.fire({icon: 'error', title: 'Error', text: e.message, background: '#1e293b', color: '#f1f5f9'}); }
}
});
};
async function updateParsilAPI(id, lls, luas) {
try {
await fetch(API_PARSIL + '?id=' + id, {
method:'PUT', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ geojson:toGeoJSONPolygon(lls), luas_tanah:luas })
});
const i = parsilData.findIndex(p => p.id == id);
if (i >= 0) parsilData[i].luas_tanah = luas;
updateStatsParsil(); renderListParsil();
} catch(e) { console.error('Update parsil:', e); }
}
/* ═══════════════════════════════════════════════════════════
STATUS BAR
═══════════════════════════════════════════════════════════ */
function setStatus(msg) {
document.getElementById('sb-txt').textContent = msg;
document.getElementById('status-bar').classList.add('on');
}
function clearStatus() { document.getElementById('status-bar').classList.remove('on'); }
/* ═══════════════════════════════════════════════════════════
KEYBOARD
═══════════════════════════════════════════════════════════ */
document.addEventListener('keydown', function(e) {
if (document.getElementById('modal-bg').classList.contains('show')) return;
if (e.key === 'Escape') cancelDraw();
if (e.key === 'Enter' && drawMode) finishDraw();
if ((e.key === 'z' || e.key === 'Z') && drawMode) undoPt();
});
document.getElementById('inp-nama').addEventListener('keydown', e => { if(e.key==='Enter') saveModal(); });
/* ═══════════════════════════════════════════════════════════
INIT
═══════════════════════════════════════════════════════════ */
Promise.all([muatJalan(), muatParsil()]);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
-- =========================================================
-- WebGIS - Struktur Database
-- Tugas: Manajemen Data Jalan & Parsil Tanah (Kavling)
-- Database: WEB_GIS
-- Tipe Geometri: GeoJSON (LineString & Polygon)
-- =========================================================
CREATE DATABASE IF NOT EXISTS WEB_GIS
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE WEB_GIS;
-- ---------------------------------------------------------
-- TABEL 1: Manajemen Data Jalan
-- Tipe Geometri : GeoJSON LineString
-- Panjang Jalan : Dihitung otomatis oleh LeafletJS (meter)
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tb_jalan` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT 'Primary key jalan',
`nama_jalan` VARCHAR(100) NOT NULL COMMENT 'Nama ruas jalan',
`status_jalan` ENUM(
'Nasional',
'Provinsi',
'Kabupaten'
) NOT NULL COMMENT 'Status administrasi jalan',
`panjang_jalan` DECIMAL(12,2) NOT NULL DEFAULT '0.00' COMMENT 'Panjang jalan dalam meter (dihitung otomatis LeafletJS via L.LatLng.distanceTo)',
`geojson` JSON NOT NULL COMMENT 'Koordinat geometri dalam format GeoJSON LineString',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='Tabel manajemen data jalan berdasarkan status administrasi. Geometri disimpan sebagai GeoJSON LineString.';
-- ---------------------------------------------------------
-- TABEL 2: Manajemen Parsil Tanah / Kavling
-- Tipe Geometri : GeoJSON Polygon
-- Luas Tanah : Dihitung otomatis oleh LeafletJS (m²)
-- ---------------------------------------------------------
CREATE TABLE IF NOT EXISTS `tb_parsil` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT 'Primary key parsil',
`nama_parsil` VARCHAR(100) NOT NULL COMMENT 'Nama atau nomor identifikasi parsil tanah',
`status_kepemilikan` ENUM(
'SHM',
'HGB',
'HGU',
'HP'
) NOT NULL COMMENT 'Status sertifikat kepemilikan: SHM=Sertifikat Hak Milik, HGB=Hak Guna Bangunan, HGU=Hak Guna Usaha, HP=Hak Pakai',
`luas_tanah` DECIMAL(16,4) NOT NULL DEFAULT '0.0000' COMMENT 'Luas tanah dalam meter persegi (dihitung otomatis LeafletJS via formula geodesic spherical)',
`geojson` JSON NOT NULL COMMENT 'Koordinat geometri dalam format GeoJSON Polygon',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci
COMMENT='Tabel manajemen parsil/kavling tanah berdasarkan status kepemilikan. Geometri disimpan sebagai GeoJSON Polygon.';
-- ---------------------------------------------------------
-- Contoh Indeks untuk performa query filter
-- ---------------------------------------------------------
ALTER TABLE `tb_jalan` ADD INDEX `idx_status_jalan` (`status_jalan`);
ALTER TABLE `tb_parsil` ADD INDEX `idx_status_parsil` (`status_kepemilikan`);
+822
View File
@@ -0,0 +1,822 @@
:root {
--bg-dark: #0f1117;
--bg-sidebar: #161b27;
--bg-card: #1e2538;
--bg-card-hover: #252d42;
--border: #2a3248;
--accent-blue: #3b82f6;
--accent-green: #10b981;
--accent-yellow: #f59e0b;
--accent-red: #ef4444;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #4b5563;
--radius-sm: 6px;
--radius-md: 10px;
--shadow: 0 4px 24px rgba(0, 0, 0, .4);
--jalan: #f97316;
--parsil: #8b5cf6;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
#app-container {
display: flex;
flex: 1;
overflow: hidden;
}
#header-bar {
background: linear-gradient(135deg, #161b27 0%, #0f1117 100%);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1000;
}
/* ── Auth / User Badge ── */
.user-badge {
display: flex;
align-items: center;
gap: 10px;
}
.user-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
}
.user-name {
font-size: 12px;
font-weight: 700;
color: var(--text-primary);
}
.role-badge {
font-size: 9px;
font-weight: 700;
padding: 2px 8px;
border-radius: 20px;
letter-spacing: .5px;
text-transform: uppercase;
}
.role-badge-admin {
background: rgba(59, 130, 246, .2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, .4);
}
.role-badge-pengurus {
background: rgba(139, 92, 246, .2);
color: #a78bfa;
border: 1px solid rgba(139, 92, 246, .4);
}
.role-badge-walikota {
background: rgba(249, 115, 22, .2);
color: #fb923c;
border: 1px solid rgba(249, 115, 22, .4);
}
.btn-login {
padding: 6px 16px;
background: linear-gradient(135deg, var(--accent-blue), #2563eb);
border: none;
border-radius: 6px;
color: #fff;
font-size: 12px;
font-weight: 600;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all .2s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-login:hover {
opacity: .9;
transform: translateY(-1px);
}
.btn-lapor-header {
padding: 6px 14px;
background: rgba(239, 68, 68, .15);
border: 1px solid rgba(239, 68, 68, .35);
border-radius: 6px;
color: #f87171;
font-size: 11px;
font-weight: 600;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all .2s;
text-decoration: none;
}
.btn-lapor-header:hover {
background: rgba(239, 68, 68, .25);
}
.btn-export {
width: 100%;
padding: 8px 12px;
background: rgba(59, 130, 246, .12);
border: 1px solid rgba(59, 130, 246, .3);
border-radius: 6px;
color: #60a5fa;
font-size: 11px;
font-weight: 600;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all .2s;
margin: 0 14px 10px;
display: none;
}
.btn-export:hover {
background: rgba(59, 130, 246, .22);
}
.btn-logout {
padding: 6px 14px;
background: rgba(239, 68, 68, .12);
border: 1px solid rgba(239, 68, 68, .3);
border-radius: 6px;
color: #f87171;
font-size: 11px;
font-weight: 600;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all .2s;
}
.btn-logout:hover {
background: rgba(239, 68, 68, .22);
border-color: rgba(239, 68, 68, .5);
}
/* ── Role-restricted element hiding ── */
.hide-for-role {
display: none !important;
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.header-title h1 {
font-size: 16px;
font-weight: 700;
color: #fff;
margin: 0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: var(--accent-green);
}
.pulse-dot {
width: 8px;
height: 8px;
background-color: var(--accent-green);
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
#bottom-bar {
background: var(--bg-sidebar);
border-top: 1px solid var(--border);
padding: 8px 20px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 15px;
font-family: monospace;
font-size: 12px;
color: var(--text-secondary);
z-index: 1000;
}
.fab-container {
position: fixed;
bottom: 50px;
right: 30px;
z-index: 9999;
display: flex;
flex-direction: column-reverse;
align-items: flex-end;
gap: 10px;
}
.fab-btn {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-blue), #2563eb);
color: white;
border: none;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.fab-btn:hover {
transform: scale(1.05);
background: linear-gradient(135deg, #60a5fa, var(--accent-blue));
}
.fab-menu {
display: none;
flex-direction: column;
gap: 8px;
align-items: flex-end;
margin-bottom: 10px;
}
.fab-container:hover .fab-menu,
.fab-menu.active {
display: flex;
}
.fab-item {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 10px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.fab-item:hover {
background: var(--bg-card-hover);
border-color: var(--accent-blue);
transform: translateX(-5px);
}
#sidebar {
width: 320px;
min-width: 320px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 999;
}
#map {
flex: 1;
position: relative;
z-index: 1;
}
.sidebar-header {
padding: 20px 18px 16px;
border-bottom: 1px solid var(--border);
background: linear-gradient(135deg, #1e2a45 0%, #161b27 100%);
}
.sidebar-header h1 {
font-size: 15px;
font-weight: 700;
letter-spacing: .3px;
}
.sidebar-header p {
font-size: 11px;
color: var(--text-secondary);
margin-top: 3px;
}
.sidebar-body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.section-title {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
padding: 14px 14px 6px;
}
.btn-add {
display: none;
/* Disembunyikan karena diganti FAB */
}
.item-list {
padding: 0 10px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.item-card {
background: var(--bg-card);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
padding: 10px 12px;
cursor: pointer;
transition: all .18s;
position: relative;
}
.item-card:hover {
background: var(--bg-card-hover);
border-color: var(--accent-blue);
transform: translateX(2px);
}
.item-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 6px;
}
.item-card-name {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
flex: 1;
}
.badge {
font-size: 9px;
font-weight: 700;
padding: 2px 7px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: .5px;
white-space: nowrap;
}
.item-card-info {
font-size: 10px;
color: var(--text-secondary);
margin-top: 5px;
}
.item-card-actions {
display: flex;
gap: 5px;
margin-top: 8px;
}
.btn-sm {
flex: 1;
padding: 5px;
border: none;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
font-family: 'Inter', sans-serif;
transition: opacity .2s;
}
.btn-sm:hover {
opacity: .8;
}
.btn-zoom {
background: rgba(59, 130, 246, .2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, .3);
}
.btn-edit {
background: rgba(245, 158, 11, .2);
color: #fbbf24;
border: 1px solid rgba(245, 158, 11, .3);
}
.btn-delete {
background: rgba(239, 68, 68, .2);
color: #f87171;
border: 1px solid rgba(239, 68, 68, .3);
}
.legend-box {
padding: 12px 14px;
border-top: 1px solid var(--border);
background: var(--bg-dark);
}
.legend-title {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-size: 11px;
color: var(--text-secondary);
}
.layer-info-box {
margin: 10px 14px;
padding: 12px;
background: rgba(59, 130, 246, .08);
border: 1px solid rgba(59, 130, 246, .25);
border-radius: var(--radius-sm);
}
.layer-info-box p {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
}
.layer-info-box strong {
color: var(--accent-blue);
}
.divider {
height: 1px;
background: var(--border);
margin: 8px 0;
}
.count-row {
display: flex;
gap: 8px;
padding: 0 14px 10px;
}
.count-card {
flex: 1;
background: var(--bg-card);
border-radius: var(--radius-sm);
padding: 10px;
text-align: center;
border: 1px solid var(--border);
}
.count-card .num {
font-size: 22px;
font-weight: 700;
}
.count-card .lbl {
font-size: 10px;
color: var(--text-secondary);
margin-top: 2px;
}
#map {
flex: 1;
height: 100vh;
}
.leaflet-popup-content-wrapper {
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-md) !important;
box-shadow: var(--shadow) !important;
color: var(--text-primary) !important;
padding: 0 !important;
}
.leaflet-popup-tip {
background: var(--bg-card) !important;
}
.leaflet-popup-close-button {
color: var(--text-secondary) !important;
top: 8px !important;
right: 8px !important;
}
.leaflet-popup-content {
margin: 0 !important;
}
.gis-popup {
padding: 16px;
min-width: 240px;
max-width: 300px;
}
.popup-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.popup-form label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: 4px;
margin-top: 10px;
}
.popup-form label:first-of-type {
margin-top: 0;
}
.popup-form input,
.popup-form select {
width: 100%;
padding: 8px 10px;
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color .2s;
}
.popup-form input:focus,
.popup-form select:focus {
border-color: var(--accent-blue);
}
.popup-actions {
display: flex;
gap: 6px;
margin-top: 12px;
}
.popup-btn {
flex: 1;
padding: 8px;
border: none;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
font-family: 'Inter', sans-serif;
cursor: pointer;
transition: all .2s;
}
.popup-btn:hover {
opacity: .85;
transform: translateY(-1px);
}
.popup-btn-save {
background: var(--accent-green);
color: #fff;
}
.popup-btn-edit {
background: var(--accent-yellow);
color: #000;
}
.popup-btn-delete {
background: var(--accent-red);
color: #fff;
}
.popup-btn-cancel {
background: var(--bg-dark);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 0;
border-bottom: 1px solid var(--border);
font-size: 11px;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: var(--text-secondary);
}
.info-value {
color: var(--text-primary);
font-weight: 600;
}
#toast {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast-item {
padding: 10px 18px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
color: #fff;
box-shadow: var(--shadow);
animation: slideIn .3s ease;
max-width: 280px;
}
.toast-success {
background: #065f46;
border: 1px solid #10b981;
}
.toast-error {
background: #7f1d1d;
border: 1px solid #ef4444;
}
.toast-info {
background: #1e3a5f;
border: 1px solid #3b82f6;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(30px)
}
to {
opacity: 1;
transform: translateX(0)
}
}
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.loading-text {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-size: 12px;
}
/* Custom Leaflet Layer Control dark style */
.leaflet-control-layers {
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius-md) !important;
color: var(--text-primary) !important;
}
.leaflet-control-layers label {
color: var(--text-primary) !important;
font-size: 12px !important;
font-family: 'Inter', sans-serif !important;
}
.leaflet-control-layers-separator {
border-top-color: var(--border) !important;
}
.leaflet-control-layers-expanded {
padding: 10px 14px !important;
}
.leaflet-control-layers-toggle {
background-color: var(--bg-card) !important;
border-radius: var(--radius-sm) !important;
}
.btn-add-kemiskinan {
background: linear-gradient(135deg, #f97316, #c2410c);
color: #fff;
}
.btn-add-masjid {
background: linear-gradient(135deg, #8b5cf6, #5b21b6);
color: #fff;
}
.radius-wrap {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.radius-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: .5px;
}
.radius-val {
color: var(--parsil);
font-weight: 700;
}
.radius-slider {
width: 100%;
accent-color: var(--parsil);
cursor: pointer;
}
.radius-ticks {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--text-muted);
}