Add WebGIS SPBU project documentation

This commit is contained in:
GuavaPopper
2026-06-11 02:29:29 +07:00
commit 529d8bcc70
13 changed files with 1905 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
DB_HOST=127.0.0.1
DB_NAME=spbu_baru
DB_USER=root
DB_PASS=
+4
View File
@@ -0,0 +1,4 @@
DB_HOST=localhost
DB_NAME=spbu_baru
DB_USER=root
DB_PASS=
+8
View File
@@ -0,0 +1,8 @@
# OS/editor files
.DS_Store
Thumbs.db
.vscode/
.idea/
# Logs
*.log
+128
View File
@@ -0,0 +1,128 @@
# WebGIS SPBU
WebGIS SPBU adalah aplikasi Sistem Informasi Geografis sederhana untuk memetakan dan mengelola titik Stasiun Pengisian Bahan Bakar Umum (SPBU). Aplikasi ini memakai Leaflet untuk peta interaktif, PHP untuk API, dan MySQL/MariaDB untuk penyimpanan data.
## Fitur
- Menampilkan peta interaktif dengan pilihan layer peta standar dan satelit.
- Menambah data SPBU dengan klik lokasi langsung di peta.
- Mengedit data SPBU dan memindahkan marker dengan drag.
- Menghapus data SPBU dari halaman input maupun halaman daftar data.
- Menampilkan daftar SPBU, statistik total, statistik status operasional, pencarian, dan mini map sebaran.
- Mengelompokkan marker berdasarkan status `Buka 24 Jam` dan `Tidak Buka 24 Jam`.
## Teknologi
- HTML, CSS, JavaScript
- PHP PDO
- MySQL atau MariaDB
- Leaflet
- Tailwind CSS CDN
- DaisyUI CDN
- Font Awesome CDN
## Struktur Project
```text
.
|-- api/
| |-- config.php
| |-- delete_spbu.php
| |-- get_spbu.php
| |-- save_spbu.php
| `-- update_spbu.php
|-- database/
| `-- schema.sql
|-- pages/
| |-- input.html
| `-- showdata.html
|-- .env.example
|-- index.html
`-- README.md
```
## Prasyarat
- Web server lokal seperti Laragon, XAMPP, atau Apache/Nginx dengan PHP.
- PHP dengan ekstensi PDO MySQL aktif.
- MySQL atau MariaDB.
- Koneksi internet untuk memuat library CDN dan tile map.
## Instalasi
1. Clone repository ini ke direktori web server lokal.
```bash
git clone https://github.com/GuavaPopper/WebGIS_SPBU.git
```
2. Masuk ke folder project.
```bash
cd WebGIS_SPBU
```
3. Buat database dan tabel dengan menjalankan script:
```sql
database/schema.sql
```
Script tersebut akan membuat database `spbu_baru`, tabel `spbu`, dan contoh data awal.
4. Salin file `.env.example` menjadi `.env`.
```bash
cp .env.example .env
```
5. Sesuaikan konfigurasi database di file `.env`.
```env
DB_HOST=localhost
DB_NAME=spbu_baru
DB_USER=root
DB_PASS=
```
6. Jalankan project melalui web server lokal, misalnya:
```text
http://localhost/SPBU_Enhanced/
```
## Halaman Aplikasi
- `index.html`: halaman awal dan peta latar.
- `pages/input.html`: halaman input, edit, hapus, dan pemindahan titik SPBU.
- `pages/showdata.html`: halaman daftar data, statistik, pencarian, dan mini map.
## Endpoint API
| Endpoint | Method | Fungsi |
| --- | --- | --- |
| `api/get_spbu.php` | `GET` | Mengambil semua data SPBU |
| `api/save_spbu.php` | `POST` | Menyimpan data SPBU baru |
| `api/update_spbu.php` | `POST` atau `PUT` | Memperbarui data SPBU |
| `api/delete_spbu.php` | `DELETE` atau `POST` dengan `_method=DELETE` | Menghapus data SPBU |
## Format Data SPBU
```json
{
"nama_spbu": "SPBU Jl. Ahmad Yani",
"nomor_spbu": "64.751.01",
"status": "Buka 24 Jam",
"latitude": -0.0600366,
"longitude": 109.3458023
}
```
Nilai `status` yang valid:
- `Buka 24 Jam`
- `Tidak Buka 24 Jam`
## Catatan Konfigurasi
File `.env` ikut disertakan untuk kebutuhan setup repository private. Jika project dipindahkan ke environment lain, sesuaikan nilai database di `.env` atau gunakan `.env.example` sebagai template konfigurasi.
+38
View File
@@ -0,0 +1,38 @@
<?php
// ── SHARED DATABASE CONFIGURATION ───────────────────────────────────────────
// Digunakan oleh semua endpoint API. File .env berada di root project (satu
// level di atas folder api/).
$envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'File .env tidak ditemukan']);
exit();
}
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$key, $value] = explode('=', $line, 2);
$_ENV[trim($key)] = trim($value);
}
$host = $_ENV['DB_HOST'] ?? 'localhost';
$dbname = $_ENV['DB_NAME'] ?? '';
$username = $_ENV['DB_USER'] ?? 'root';
$password = $_ENV['DB_PASS'] ?? '';
// ── KONEKSI PDO ───────────────────────────────────────────────────────────────
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Koneksi database gagal: ' . $e->getMessage()]);
exit();
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// ── DELETE SPBU ───────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: DELETE, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// Terima DELETE atau POST dengan _method=DELETE
$method = $_SERVER['REQUEST_METHOD'];
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if ($method !== 'DELETE' && !($method === 'POST' && isset($data['_method']) && $data['_method'] === 'DELETE')) {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit();
}
// ── VALIDASI ID ───────────────────────────────────────────────────────────────
$id = intval($data['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'ID tidak valid']);
exit();
}
// ── HAPUS DATA ────────────────────────────────────────────────────────────────
try {
$stmt = $pdo->prepare("DELETE FROM spbu WHERE id = :id");
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan']);
} else {
echo json_encode(['success' => true, 'message' => 'Data SPBU berhasil dihapus']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal menghapus data: ' . $e->getMessage()]);
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// ── GET SPBU ──────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// ── AMBIL DATA ────────────────────────────────────────────────────────────────
try {
$stmt = $pdo->query("SELECT * FROM spbu ORDER BY created_at DESC");
$rows = $stmt->fetchAll();
echo json_encode($rows);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Gagal mengambil data: ' . $e->getMessage()]);
}
+69
View File
@@ -0,0 +1,69 @@
<?php
// ── SAVE SPBU ─────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit();
}
// ── BACA JSON BODY ─────────────────────────────────────────────────────────────
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak valid (JSON parse error)']);
exit();
}
// ── VALIDASI ──────────────────────────────────────────────────────────────────
$nama_spbu = trim($data['nama_spbu'] ?? '');
$nomor_spbu = trim($data['nomor_spbu'] ?? '');
$status = trim($data['status'] ?? '');
$latitude = floatval($data['latitude'] ?? 0);
$longitude = floatval($data['longitude'] ?? 0);
$valid_status = ['Buka 24 Jam', 'Tidak Buka 24 Jam'];
if (empty($nama_spbu)) { echo json_encode(['success' => false, 'message' => 'Nama SPBU tidak boleh kosong']); exit(); }
if (empty($nomor_spbu)) { echo json_encode(['success' => false, 'message' => 'Nomor SPBU tidak boleh kosong']); exit(); }
if (!in_array($status, $valid_status)) { echo json_encode(['success' => false, 'message' => 'Status tidak valid']); exit(); }
if ($latitude === 0.0 && $longitude === 0.0) { echo json_encode(['success' => false, 'message' => 'Koordinat tidak valid']); exit(); }
// ── INSERT DATA ───────────────────────────────────────────────────────────────
try {
$stmt = $pdo->prepare("
INSERT INTO spbu (nama_spbu, nomor_spbu, status, latitude, longitude, created_at)
VALUES (:nama_spbu, :nomor_spbu, :status, :latitude, :longitude, NOW())
");
$stmt->execute([
':nama_spbu' => $nama_spbu,
':nomor_spbu' => $nomor_spbu,
':status' => $status,
':latitude' => $latitude,
':longitude' => $longitude,
]);
echo json_encode([
'success' => true,
'message' => 'Data SPBU berhasil disimpan',
'id' => $pdo->lastInsertId()
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal menyimpan data: ' . $e->getMessage()]);
}
+79
View File
@@ -0,0 +1,79 @@
<?php
// ── UPDATE SPBU ───────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, PUT, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST' && $method !== 'PUT') {
http_response_code(405);
echo json_encode(['success' => false, 'message' => 'Method not allowed']);
exit();
}
// ── BACA JSON BODY ─────────────────────────────────────────────────────────────
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (!$data) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Data tidak valid (JSON parse error)']);
exit();
}
// ── VALIDASI ──────────────────────────────────────────────────────────────────
$id = intval($data['id'] ?? 0);
$nama_spbu = trim($data['nama_spbu'] ?? '');
$nomor_spbu = trim($data['nomor_spbu'] ?? '');
$status = trim($data['status'] ?? '');
$latitude = floatval($data['latitude'] ?? 0);
$longitude = floatval($data['longitude'] ?? 0);
$valid_status = ['Buka 24 Jam', 'Tidak Buka 24 Jam'];
if ($id <= 0) { echo json_encode(['success' => false, 'message' => 'ID tidak valid']); exit(); }
if (empty($nama_spbu)) { echo json_encode(['success' => false, 'message' => 'Nama SPBU tidak boleh kosong']); exit(); }
if (empty($nomor_spbu)) { echo json_encode(['success' => false, 'message' => 'Nomor SPBU tidak boleh kosong']); exit(); }
if (!in_array($status, $valid_status)) { echo json_encode(['success' => false, 'message' => 'Status tidak valid']); exit(); }
if ($latitude === 0.0 && $longitude === 0.0) { echo json_encode(['success' => false, 'message' => 'Koordinat tidak valid']); exit(); }
// ── UPDATE DATA ───────────────────────────────────────────────────────────────
try {
$stmt = $pdo->prepare("
UPDATE spbu
SET nama_spbu = :nama_spbu,
nomor_spbu = :nomor_spbu,
status = :status,
latitude = :latitude,
longitude = :longitude
WHERE id = :id
");
$stmt->execute([
':id' => $id,
':nama_spbu' => $nama_spbu,
':nomor_spbu' => $nomor_spbu,
':status' => $status,
':latitude' => $latitude,
':longitude' => $longitude,
]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Data tidak ditemukan atau tidak ada perubahan']);
} else {
echo json_encode(['success' => true, 'message' => 'Data SPBU berhasil diperbarui']);
}
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Gagal memperbarui data: ' . $e->getMessage()]);
}
+33
View File
@@ -0,0 +1,33 @@
-- ============================================================
-- WebGIS SPBU - Database Schema
-- Jalankan script ini di phpMyAdmin atau MySQL CLI
-- ============================================================
-- Buat database (jika belum ada)
CREATE DATABASE IF NOT EXISTS spbu_baru
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE spbu_baru;
-- Buat tabel spbu
CREATE TABLE IF NOT EXISTS spbu (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nama_spbu VARCHAR(255) NOT NULL,
nomor_spbu VARCHAR(50) NOT NULL,
status ENUM('Buka 24 Jam', 'Tidak Buka 24 Jam') NOT NULL,
latitude DECIMAL(10, 7) NOT NULL,
longitude DECIMAL(10, 7) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_status (status),
INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ============================================================
-- Contoh data awal (opsional, hapus jika tidak diperlukan)
-- ============================================================
INSERT INTO spbu (nama_spbu, nomor_spbu, status, latitude, longitude) VALUES
('SPBU Jl. Ahmad Yani', '64.751.01', 'Buka 24 Jam', -0.0600366, 109.3458023),
('SPBU Jl. Gajah Mada', '64.751.02', 'Tidak Buka 24 Jam', -0.0596729, 109.3469671);
+212
View File
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS SPBU</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- DaisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<!-- Plus Jakarta Sans Font -->
<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=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
body {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
font-weight: 700 !important;
letter-spacing: -0.02em;
}
#bgMap {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
opacity: 0.5;
filter: saturate(1.2) brightness(0.7);
}
.hero {
position: relative;
z-index: 10;
background: transparent !important;
}
/* Glass effect for content */
.hero-content {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
padding: 3rem;
border-radius: 2rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.3);
}
[data-theme="dark"] .hero-content {
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Custom Leaflet Control Layers Styling */
.leaflet-control-layers {
border-radius: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.85) !important;
padding: 8px 12px !important;
font-family: inherit !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
transition: all 0.3s ease !important;
}
.leaflet-control-layers:hover {
background: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25) !important;
}
.leaflet-control-layers-list label {
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-weight: 600 !important;
}
.leaflet-control-layers-list label:hover {
background: rgba(0, 0, 0, 0.05);
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-layers-selector {
accent-color: #2563eb;
width: 16px;
height: 16px;
margin-top: 0 !important;
}
</style>
</head>
<body class="min-h-screen bg-base-200 flex flex-col items-center justify-center overflow-hidden">
<!-- Background Map -->
<div id="bgMap"></div>
<div class="hero min-h-screen">
<div class="hero-content text-center flex-col gap-2">
<!-- Logo -->
<div class="text-7xl mb-2"></div>
<h1 class="text-4xl font-bold tracking-tight">WebGIS SPBU</h1>
<p class="max-w-sm text-base-content/60 mt-2 mb-6 leading-relaxed">
Sistem Informasi Geografis untuk pemetaan dan pengelolaan
titik-titik Stasiun Pengisian Bahan Bakar Umum.
</p>
<!-- Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<a href="pages/input.html" class="btn btn-primary btn-lg gap-2">
<i class="fa-solid fa-map-location-dot"></i>
Input Data SPBU
</a>
<a href="pages/showdata.html" class="btn btn-outline btn-lg gap-2">
<i class="fa-solid fa-table-list"></i>
Lihat Data &amp; Peta
</a>
</div>
<!-- Redirect progress -->
<div class="mt-8 w-72">
<p class="text-xs text-base-content/40 mb-2">Mengarahkan ke halaman Input dalam 3 detik...</p>
<progress class="progress progress-primary w-full" id="redirectProgress" value="0" max="100"></progress>
</div>
</div>
</div>
<script>
// Progress bar logic
let val = 0;
const bar = document.getElementById('redirectProgress');
const tick = setInterval(() => {
val += 100 / 50; // Slower for effect (5s)
bar.value = val;
if (val >= 100) clearInterval(tick);
}, 100);
setTimeout(() => { window.location.href = 'pages/input.html'; }, 5000);
// Background Map logic
const map = L.map('bgMap', {
zoomControl: false,
attributionControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false
}).setView([-0.0606, 109.345], 15);
// Satellite Base
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
}).addTo(map);
// Standard Base (Optional)
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19
});
// Layer Groups
const layer24h = L.layerGroup().addTo(map);
const layerRegular = L.layerGroup().addTo(map);
// Control (visible but subtle)
L.control.layers({ "Satelit": satellite, "Street": osm },
{ "Buka 24 Jam": layer24h, "Normal": layerRegular },
{ position: 'bottomleft' }).addTo(map);
// Fetch SPBU points to show on background
fetch('api/get_spbu.php')
.then(r => r.json())
.then(data => {
const markers = [];
data.forEach(spbu => {
const is24 = spbu.status === 'Buka 24 Jam';
const group = is24 ? layer24h : layerRegular;
const color = is24 ? "#22c55e" : "#3b82f6";
L.circleMarker([+spbu.latitude, +spbu.longitude], {
radius: 6,
fillColor: color,
color: "#fff",
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(group);
markers.push([+spbu.latitude, +spbu.longitude]);
});
if (markers.length) {
map.fitBounds(markers, { padding: [50, 50] });
}
// Subtle animation: slow pan
let lat = map.getCenter().lat;
setInterval(() => {
lat += 0.00001;
map.panTo([lat, map.getCenter().lng], { animate: true, duration: 1 });
}, 1000);
})
.catch(() => {});
</script>
</body>
</html>
+683
View File
@@ -0,0 +1,683 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Input Data SPBU - WebGIS</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- DaisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<!-- Plus Jakarta Sans Font -->
<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=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
body {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
font-weight: 700 !important;
letter-spacing: -0.01em;
}
html, body { height: 100%; }
body { display: flex; flex-direction: column; overflow: hidden; }
.map-wrapper { flex: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
/* Drag cursor for Leaflet marker */
.leaflet-marker-draggable { cursor: grab !important; }
.leaflet-dragging .leaflet-marker-draggable { cursor: grabbing !important; }
/* Spinner inside leaflet popup */
@keyframes _spin { to { transform: rotate(360deg); } }
._spin { animation: _spin .7s linear infinite; }
/* Custom Leaflet Control Layers Styling */
.leaflet-control-layers {
border-radius: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.85) !important;
padding: 8px 12px !important;
font-family: inherit !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
transition: all 0.3s ease !important;
}
.leaflet-control-layers:hover {
background: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25) !important;
}
.leaflet-control-layers-list label {
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-weight: 600 !important;
}
.leaflet-control-layers-list label:hover {
background: rgba(0, 0, 0, 0.05);
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-layers-selector {
accent-color: #2563eb;
width: 16px;
height: 16px;
margin-top: 0 !important;
}
</style>
</head>
<body>
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm flex-shrink-0 z-[1000]">
<div class="flex-1">
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-gas-pump"></i>
WebGIS SPBU
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li>
<a href="input.html"
class="font-semibold bg-primary/10 text-primary rounded-lg">
<i class="fa-solid fa-map-location-dot"></i> Input Data
</a>
</li>
<li>
<a href="showdata.html" class="font-medium">
<i class="fa-solid fa-table-list"></i> Lihat Data
</a>
</li>
</ul>
</div>
</div>
<!-- ═══ MAP ══════════════════════════════════════════════════════════════ -->
<div class="map-wrapper">
<div id="map"></div>
<!-- Normal hint -->
<div id="mapHint"
class="absolute bottom-8 left-1/2 -translate-x-1/2 z-[999] pointer-events-none
badge badge-primary badge-lg py-3 px-4 gap-2 shadow-md">
<i class="fa-solid fa-hand-pointer"></i>
Klik peta untuk tambah SPBU &bull; Drag marker untuk pindah lokasi
</div>
<!-- Drag hint -->
<div id="dragHint"
class="absolute bottom-8 left-1/2 -translate-x-1/2 z-[999] pointer-events-none hidden
badge badge-warning badge-lg py-3 px-4 gap-2 shadow-md">
<i class="fa-solid fa-arrows-up-down-left-right"></i>
Lepaskan untuk menyimpan lokasi baru
</div>
</div>
<!-- ═══ MODAL: INPUT (dialog DaisyUI) ════════════════════════════════════ -->
<dialog id="inputModal" class="modal">
<div class="modal-box">
<!-- Header -->
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
id="btnCloseModal"></button>
<h3 class="font-bold text-lg flex items-center gap-2 mb-1">
<i class="fa-solid fa-gas-pump text-primary"></i>
Tambah Data SPBU
</h3>
<p class="text-sm text-base-content/50 mb-4">Isi atribut titik lokasi baru</p>
<!-- Koordinat -->
<div class="alert alert-info py-2 mb-4 text-sm">
<i class="fa-solid fa-location-crosshairs"></i>
<span id="coordText">Memuat koordinat...</span>
</div>
<!-- Form -->
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nama SPBU</span></div>
<input type="text" id="namaSPBU" class="input input-bordered w-full"
placeholder="Contoh: SPBU Jl. Ahmad Yani" autocomplete="off">
</label>
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nomor SPBU</span></div>
<input type="text" id="nomorSPBU" class="input input-bordered w-full"
placeholder="Contoh: 64.751.01" autocomplete="off">
</label>
<label class="form-control mb-4">
<div class="label"><span class="label-text font-semibold">Status Operasional</span></div>
<select id="statusSPBU" class="select select-bordered w-full">
<option value="">-- Pilih Status --</option>
<option value="Buka 24 Jam">☀️🌙 Buka 24 Jam</option>
<option value="Tidak Buka 24 Jam">⏰ Tidak Buka 24 Jam</option>
</select>
</label>
<div class="modal-action">
<button class="btn" id="btnCancel">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-primary" id="btnSimpan">
<i class="fa-solid fa-floppy-disk"></i> Simpan
</button>
</div>
</div>
<!-- Backdrop click = close -->
<form method="dialog" class="modal-backdrop">
<button id="btnBackdropInput">close</button>
</form>
</dialog>
<!-- ═══ MODAL: EDIT ═══════════════════════════════════════════════════════ -->
<dialog id="editModal" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
id="btnCloseEditModal"></button>
<h3 class="font-bold text-lg flex items-center gap-2 mb-1 text-warning">
<i class="fa-solid fa-pen-to-square"></i>
Edit Data SPBU
</h3>
<p class="text-sm text-base-content/50 mb-4">Perbarui atribut titik lokasi</p>
<div class="alert alert-info py-2 mb-4 text-sm">
<i class="fa-solid fa-location-crosshairs"></i>
<span id="editCoordText">Memuat koordinat...</span>
</div>
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nama SPBU</span></div>
<input type="text" id="editNamaSPBU" class="input input-bordered w-full"
placeholder="Contoh: SPBU Jl. Ahmad Yani" autocomplete="off">
</label>
<label class="form-control mb-3">
<div class="label"><span class="label-text font-semibold">Nomor SPBU</span></div>
<input type="text" id="editNomorSPBU" class="input input-bordered w-full"
placeholder="Contoh: 64.751.01" autocomplete="off">
</label>
<label class="form-control mb-4">
<div class="label"><span class="label-text font-semibold">Status Operasional</span></div>
<select id="editStatusSPBU" class="select select-bordered w-full">
<option value="">-- Pilih Status --</option>
<option value="Buka 24 Jam">☀️🌙 Buka 24 Jam</option>
<option value="Tidak Buka 24 Jam">⏰ Tidak Buka 24 Jam</option>
</select>
</label>
<div class="modal-action">
<button class="btn" id="btnCancelEdit">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-warning text-white" id="btnUpdate">
<i class="fa-solid fa-floppy-disk"></i> Perbarui
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button id="btnBackdropEdit">close</button>
</form>
</dialog>
<!-- ═══ MODAL: KONFIRMASI HAPUS ══════════════════════════════════════════ -->
<dialog id="deleteModal" class="modal">
<div class="modal-box text-center max-w-sm">
<div class="text-5xl mb-3">🗑️</div>
<h3 class="font-bold text-lg mb-1">Hapus Data SPBU?</h3>
<p class="text-sm text-base-content/60 mb-1">
Data <strong id="deleteSpbuName"></strong> akan dihapus secara permanen.
</p>
<p class="text-sm text-error mb-5">⚠️ Tindakan ini tidak dapat dibatalkan.</p>
<div class="modal-action justify-center">
<button class="btn" id="btnCancelDelete">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-error" id="btnConfirmDelete">
<i class="fa-solid fa-trash"></i> Ya, Hapus
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button id="btnBackdropDelete">close</button>
</form>
</dialog>
<!-- ═══ TOAST CONTAINER ════════════════════════════════════════════════════ -->
<div class="toast toast-end toast-bottom z-[99999]" id="toastContainer"></div>
<!-- ═══ JAVASCRIPT ════════════════════════════════════════════════════════ -->
<script>
// ── CONFIG ──────────────────────────────────────────────────────────────
const API = '../api';
// ── MODAL REFS ─────────────────────────────────────────────────────────
const inputModal = document.getElementById('inputModal');
const editModal = document.getElementById('editModal');
const deleteModal = document.getElementById('deleteModal');
// ── MAP INIT ────────────────────────────────────────────────────────────
const map = L.map('map', {
center: [-0.06058602521208944, 109.34498961534207],
zoom: 16
});
// 1. Base Maps
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
});
const esriSatellite = L.layerGroup([
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
})
]).addTo(map);
// 2. Layer Groups (Overlays)
const layer24h = L.layerGroup().addTo(map);
const layerRegular = L.layerGroup().addTo(map);
const baseMaps = {
"Satelit (Hybrid)": esriSatellite,
"Standard Map": osm
};
const overlays = {
"SPBU Buka 24 Jam": layer24h,
"SPBU Tidak Buka 24 Jam": layerRegular
};
L.control.layers(baseMaps, overlays, { collapsed: false }).addTo(map);
function createSpbuIcon(color = '#2563eb') {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 42" width="32" height="42">
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 26 16 26S32 28 32 16C32 7.163 24.837 0 16 0z"
fill="${color}" stroke="white" stroke-width="2"/>
<text x="16" y="21" text-anchor="middle" fill="white"
font-size="13" font-family="Arial" font-weight="bold">⛽</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize:[32,42], iconAnchor:[16,42], popupAnchor:[0,-44] });
}
// ── STATE ───────────────────────────────────────────────────────────────
let pendingLatLng = null;
let tempMarker = null;
const spbuMap = new Map(); // id => { marker, data }
// ── POPUP CONTENT ───────────────────────────────────────────────────────
function buildPopupContent(spbu) {
const isOpen = spbu.status === 'Buka 24 Jam';
const badgeCls = isOpen ? 'badge-success' : 'badge-error';
const color = isOpen ? '#16a34a' : '#dc2626';
const lat = parseFloat(spbu.latitude).toFixed(6);
const lng = parseFloat(spbu.longitude).toFixed(6);
return `
<div style="font-family:ui-sans-serif,system-ui,sans-serif;min-width:210px">
<div style="font-weight:700;font-size:0.95rem;margin-bottom:5px">⛽ ${spbu.nama_spbu}</div>
<div style="font-size:0.8rem;color:#6b7280;margin-bottom:6px">
<b>No:</b> ${spbu.nomor_spbu}
</div>
<span class="badge ${badgeCls}" style="font-size:0.72rem">
${isOpen ? '🟢' : '🔴'} ${spbu.status}
</span>
<div style="font-size:0.7rem;color:#9ca3af;margin-top:5px">${lat}, ${lng}</div>
<div style="display:flex;gap:6px;margin-top:10px;padding-top:8px;border-top:1px solid #f3f4f6">
<button class="btn btn-xs btn-outline btn-primary"
onclick="openEditModal(${spbu.id})">
<i class="fa-solid fa-pen-to-square"></i> Edit
</button>
<button class="btn btn-xs btn-outline btn-error"
onclick="openDeleteConfirm(${spbu.id})">
<i class="fa-solid fa-trash"></i> Hapus
</button>
</div>
</div>`;
}
// ── LOAD DATA ───────────────────────────────────────────────────────────
function loadExistingData() {
fetch(`${API}/get_spbu.php`)
.then(r => r.json())
.then(data => data.forEach(addSpbuMarker))
.catch(() => console.log('Tidak dapat memuat data dari server.'));
}
function addSpbuMarker(spbu) {
const isOpen = spbu.status === 'Buka 24 Jam';
const color = isOpen ? '#16a34a' : '#dc2626';
const group = isOpen ? layer24h : layerRegular;
const m = L.marker([parseFloat(spbu.latitude), parseFloat(spbu.longitude)], {
icon: createSpbuIcon(color),
draggable: true
})
.addTo(group)
.bindPopup(buildPopupContent(spbu), { maxWidth: 270 });
const id = parseInt(spbu.id);
spbuMap.set(id, { marker: m, data: { ...spbu } });
// ── DRAG START
m.on('dragstart', function () {
this.setOpacity(0.55);
map.closePopup();
document.getElementById('dragHint').classList.remove('hidden');
document.getElementById('mapHint').classList.add('hidden');
});
// ── DRAG END → update DB
m.on('dragend', function (e) {
this.setOpacity(1);
document.getElementById('dragHint').classList.add('hidden');
document.getElementById('mapHint').classList.remove('hidden');
const newLatLng = e.target.getLatLng();
const entry = spbuMap.get(id);
if (!entry) return;
// Loading popup
this.bindPopup(`
<div style="font-family:ui-sans-serif,system-ui,sans-serif;
padding:12px;text-align:center;min-width:160px">
<span class="loading loading-spinner loading-sm text-warning"></span>
<span style="font-size:0.84rem;margin-left:6px;vertical-align:middle">
Menyimpan lokasi baru...
</span>
</div>`, { maxWidth: 270 }).openPopup();
fetch(`${API}/update_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
nama_spbu : entry.data.nama_spbu,
nomor_spbu : entry.data.nomor_spbu,
status : entry.data.status,
latitude : newLatLng.lat,
longitude : newLatLng.lng
})
})
.then(r => r.json())
.then(res => {
if (res.success) {
entry.data.latitude = newLatLng.lat;
entry.data.longitude = newLatLng.lng;
this.setPopupContent(buildPopupContent(entry.data)).openPopup();
showToast(`📍 Lokasi ${entry.data.nama_spbu} berhasil dipindahkan!`, 'success');
} else {
this.setLatLng([parseFloat(entry.data.latitude), parseFloat(entry.data.longitude)]);
this.setPopupContent(buildPopupContent(entry.data)).openPopup();
showToast('Gagal memperbarui lokasi: ' + (res.message || 'Error'), 'error');
}
})
.catch(() => {
this.setLatLng([parseFloat(entry.data.latitude), parseFloat(entry.data.longitude)]);
this.setPopupContent(buildPopupContent(entry.data)).openPopup();
showToast('Tidak dapat terhubung ke server!', 'error');
});
});
}
loadExistingData();
// ── MAP CLICK → buka modal tambah ──────────────────────────────────────
map.on('click', function (e) {
pendingLatLng = e.latlng;
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker(e.latlng, {
icon: createSpbuIcon('#d97706'), opacity: 0.85
}).addTo(map);
document.getElementById('coordText').textContent =
`Lat: ${e.latlng.lat.toFixed(6)}, Lng: ${e.latlng.lng.toFixed(6)}`;
document.getElementById('namaSPBU').value = '';
document.getElementById('nomorSPBU').value = '';
document.getElementById('statusSPBU').value = '';
inputModal.showModal();
setTimeout(() => document.getElementById('namaSPBU').focus(), 300);
});
// ── CLOSE INPUT MODAL ──────────────────────────────────────────────────
function closeModal(removeTemp = true) {
inputModal.close();
if (removeTemp && tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
pendingLatLng = null;
}
document.getElementById('btnCloseModal').addEventListener('click', () => closeModal());
document.getElementById('btnCancel').addEventListener('click', () => closeModal());
// Backdrop closes via method=dialog; cleanup state via 'close' event
inputModal.addEventListener('close', () => {
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
pendingLatLng = null;
});
// ── SIMPAN DATA ────────────────────────────────────────────────────────
document.getElementById('btnSimpan').addEventListener('click', function () {
const nama = document.getElementById('namaSPBU').value.trim();
const nomor = document.getElementById('nomorSPBU').value.trim();
const status = document.getElementById('statusSPBU').value;
if (!nama) { document.getElementById('namaSPBU').focus(); showToast('Nama SPBU tidak boleh kosong!', 'error'); return; }
if (!nomor) { document.getElementById('nomorSPBU').focus(); showToast('Nomor SPBU tidak boleh kosong!', 'error'); return; }
if (!status) { document.getElementById('statusSPBU').focus(); showToast('Pilih status operasional!', 'error'); return; }
const btn = document.getElementById('btnSimpan');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Menyimpan...';
fetch(`${API}/save_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nama_spbu: nama, nomor_spbu: nomor, status,
latitude: pendingLatLng.lat, longitude: pendingLatLng.lng })
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Simpan';
if (res.success) {
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = null;
addSpbuMarker({ id: res.id, nama_spbu: nama, nomor_spbu: nomor, status,
latitude: pendingLatLng.lat, longitude: pendingLatLng.lng });
const entry = spbuMap.get(parseInt(res.id));
if (entry) entry.marker.openPopup();
inputModal.close();
pendingLatLng = null;
showToast('Data SPBU berhasil disimpan! ✅', 'success');
} else {
showToast('Gagal menyimpan: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Simpan';
showToast('Tidak dapat terhubung ke server PHP!', 'error');
});
});
// ── TOAST ──────────────────────────────────────────────────────────────
function showToast(msg, type = 'info') {
const t = document.createElement('div');
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
t.className = `alert ${cls} shadow-md`;
t.innerHTML = `<span>${msg}</span>`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
// ── EDIT MODAL ─────────────────────────────────────────────────────────
let editingId = null;
function openEditModal(id) {
const entry = spbuMap.get(id);
if (!entry) return;
editingId = id;
const { data } = entry;
document.getElementById('editNamaSPBU').value = data.nama_spbu;
document.getElementById('editNomorSPBU').value = data.nomor_spbu;
document.getElementById('editStatusSPBU').value = data.status;
document.getElementById('editCoordText').textContent =
`Lat: ${parseFloat(data.latitude).toFixed(6)}, Lng: ${parseFloat(data.longitude).toFixed(6)}`;
map.closePopup();
editModal.showModal();
setTimeout(() => document.getElementById('editNamaSPBU').focus(), 300);
}
function closeEditModal() { editModal.close(); editingId = null; }
document.getElementById('btnCloseEditModal').addEventListener('click', closeEditModal);
document.getElementById('btnCancelEdit').addEventListener('click', closeEditModal);
editModal.addEventListener('close', () => { editingId = null; });
document.getElementById('btnUpdate').addEventListener('click', function () {
const nama = document.getElementById('editNamaSPBU').value.trim();
const nomor = document.getElementById('editNomorSPBU').value.trim();
const status = document.getElementById('editStatusSPBU').value;
if (!nama) { showToast('Nama SPBU tidak boleh kosong!', 'error'); document.getElementById('editNamaSPBU').focus(); return; }
if (!nomor) { showToast('Nomor SPBU tidak boleh kosong!', 'error'); document.getElementById('editNomorSPBU').focus(); return; }
if (!status) { showToast('Pilih status operasional!', 'error'); document.getElementById('editStatusSPBU').focus(); return; }
const entry = spbuMap.get(editingId);
if (!entry) return;
const btn = document.getElementById('btnUpdate');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Menyimpan...';
fetch(`${API}/update_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingId, nama_spbu: nama, nomor_spbu: nomor, status,
latitude: parseFloat(entry.data.latitude),
longitude: parseFloat(entry.data.longitude)
})
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Perbarui';
if (res.success) {
const oldStatus = entry.data.status;
entry.data.nama_spbu = nama;
entry.data.nomor_spbu = nomor;
entry.data.status = status;
const color = status === 'Buka 24 Jam' ? '#16a34a' : '#dc2626';
entry.marker.setIcon(createSpbuIcon(color));
entry.marker.setPopupContent(buildPopupContent(entry.data));
// Move to correct layer group if status changed
if (oldStatus !== status) {
const oldGroup = oldStatus === 'Buka 24 Jam' ? layer24h : layerRegular;
const newGroup = status === 'Buka 24 Jam' ? layer24h : layerRegular;
oldGroup.removeLayer(entry.marker);
newGroup.addLayer(entry.marker);
}
closeEditModal();
showToast('Data SPBU berhasil diperbarui! ✏️', 'success');
} else {
showToast('Gagal memperbarui: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Perbarui';
showToast('Tidak dapat terhubung ke server PHP!', 'error');
});
});
// ── DELETE CONFIRM ─────────────────────────────────────────────────────
let deletingId = null;
function openDeleteConfirm(id) {
const entry = spbuMap.get(id);
if (!entry) return;
deletingId = id;
document.getElementById('deleteSpbuName').textContent = entry.data.nama_spbu;
map.closePopup();
deleteModal.showModal();
}
function closeDeleteModal() { deleteModal.close(); deletingId = null; }
document.getElementById('btnCancelDelete').addEventListener('click', closeDeleteModal);
deleteModal.addEventListener('close', () => { deletingId = null; });
document.getElementById('btnConfirmDelete').addEventListener('click', function () {
if (!deletingId) return;
const entry = spbuMap.get(deletingId);
if (!entry) return;
const btn = document.getElementById('btnConfirmDelete');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Menghapus...';
fetch(`${API}/delete_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: deletingId, _method: 'DELETE' })
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
if (res.success) {
map.removeLayer(entry.marker);
spbuMap.delete(deletingId);
closeDeleteModal();
showToast('Data SPBU berhasil dihapus! 🗑️', 'success');
} else {
showToast('Gagal menghapus: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
showToast('Tidak dapat terhubung ke server!', 'error');
});
});
</script>
</body>
</html>
+575
View File
@@ -0,0 +1,575 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Data SPBU - WebGIS</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<!-- DaisyUI + Tailwind -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<!-- Plus Jakarta Sans Font -->
<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=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
body {
font-weight: 500;
}
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value {
font-weight: 700 !important;
letter-spacing: -0.01em;
}
#previewMap { height: 380px; width: 100%; }
/* Custom Leaflet Control Layers Styling */
.leaflet-control-layers {
border-radius: 12px !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(255, 255, 255, 0.85) !important;
padding: 8px 12px !important;
font-family: inherit !important;
font-weight: 700 !important;
font-size: 0.85rem !important;
transition: all 0.3s ease !important;
}
.leaflet-control-layers:hover {
background: rgba(255, 255, 255, 0.95) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.25) !important;
}
.leaflet-control-layers-list label {
margin-bottom: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-weight: 600 !important;
}
.leaflet-control-layers-list label:hover {
background: rgba(0, 0, 0, 0.05);
}
.leaflet-control-layers-separator {
margin: 8px 0 !important;
border-top: 1px solid rgba(0, 0, 0, 0.1) !important;
}
.leaflet-control-layers-selector {
accent-color: #2563eb;
width: 16px;
height: 16px;
margin-top: 0 !important;
}
</style>
</head>
<body class="min-h-screen bg-base-200">
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
<div class="flex-1">
<a href="../index.html" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-gas-pump"></i>
WebGIS SPBU
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li>
<a href="input.html" class="font-medium">
<i class="fa-solid fa-map-location-dot"></i> Input Data
</a>
</li>
<li>
<a href="showdata.html"
class="font-semibold bg-primary/10 text-primary rounded-lg">
<i class="fa-solid fa-table-list"></i> Lihat Data
</a>
</li>
</ul>
</div>
</div>
<!-- ═══ MAIN ══════════════════════════════════════════════════════════════ -->
<main class="max-w-screen-xl mx-auto px-4 py-6">
<!-- Page header -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div>
<h1 class="text-2xl font-bold">📊 Data SPBU</h1>
<p class="text-sm text-base-content/50 mt-1">
Daftar seluruh titik SPBU yang telah terdata dalam sistem
</p>
</div>
<a href="input.html" class="btn btn-primary gap-2">
<i class="fa-solid fa-plus"></i> Tambah SPBU
</a>
</div>
<!-- Stats -->
<div class="stats stats-horizontal shadow bg-base-100 w-full mb-6 overflow-x-auto">
<div class="stat">
<div class="stat-figure text-primary">
<i class="fa-solid fa-gas-pump text-3xl"></i>
</div>
<div class="stat-title">Total SPBU</div>
<div class="stat-value text-primary" id="statTotal"></div>
</div>
<div class="stat">
<div class="stat-figure text-success">
<i class="fa-solid fa-clock-rotate-left text-3xl"></i>
</div>
<div class="stat-title">Buka 24 Jam</div>
<div class="stat-value text-success" id="stat24j"></div>
</div>
<div class="stat">
<div class="stat-figure text-error">
<i class="fa-solid fa-circle-xmark text-3xl"></i>
</div>
<div class="stat-title">Tidak 24 Jam</div>
<div class="stat-value text-error" id="statNon24"></div>
</div>
</div>
<!-- Content grid -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_380px] gap-5 items-start">
<!-- ── TABEL ─────────────────────────────────────────────────── -->
<div class="card bg-base-100 shadow">
<div class="card-body p-0">
<!-- Card header -->
<div class="flex items-center justify-between px-5 pt-5 pb-3 border-b border-base-200">
<h2 class="card-title text-base">
<i class="fa-solid fa-list text-primary"></i>
Daftar SPBU
</h2>
<span class="text-xs text-base-content/50" id="totalBadge">Memuat...</span>
</div>
<!-- Search -->
<div class="px-5 py-3 border-b border-base-200">
<label class="input input-bordered input-sm flex items-center gap-2 w-full">
<i class="fa-solid fa-magnifying-glass text-base-content/40"></i>
<input type="text" id="searchInput" class="grow"
placeholder="Cari nama atau nomor SPBU...">
</label>
</div>
<!-- Table -->
<div id="loadingTable" class="flex items-center justify-center gap-3 py-16
text-sm text-base-content/50">
<span class="loading loading-spinner loading-md"></span>
Memuat data...
</div>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm" id="dataTable" style="display:none">
<thead>
<tr class="text-xs text-base-content/60">
<th>#</th>
<th>Nama SPBU</th>
<th>Nomor</th>
<th>Status</th>
<th>Koordinat</th>
<th class="text-center">Aksi</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
<!-- Empty state -->
<div id="emptyState" class="text-center py-16 hidden">
<div class="text-4xl mb-3">📭</div>
<p class="font-semibold text-base-content/60">Belum ada data SPBU</p>
<p class="text-sm text-base-content/40 mt-1">
Tambahkan SPBU melalui halaman Input Data
</p>
</div>
</div>
</div>
<!-- ── SIDEBAR ───────────────────────────────────────────────── -->
<div class="flex flex-col gap-5">
<!-- Mini Map -->
<div class="card bg-base-100 shadow overflow-hidden">
<div class="card-body p-0">
<div class="flex items-center gap-2 px-4 py-3 border-b border-base-200">
<i class="fa-solid fa-map text-primary"></i>
<span class="font-semibold text-sm">Peta Sebaran</span>
</div>
<div id="previewMap"></div>
<div class="flex gap-4 px-4 py-2 border-t border-base-200 text-xs text-base-content/60">
<span class="flex items-center gap-1">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-success"></span>
Buka 24 Jam
</span>
<span class="flex items-center gap-1">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-error"></span>
Tidak 24 Jam
</span>
</div>
</div>
</div>
<!-- Quick List -->
<div class="card bg-base-100 shadow">
<div class="card-body p-0">
<div class="flex items-center gap-2 px-4 py-3 border-b border-base-200">
<i class="fa-solid fa-list-ul text-primary"></i>
<span class="font-semibold text-sm">Daftar Cepat</span>
</div>
<div class="max-h-80 overflow-y-auto" id="quickList">
<div class="flex items-center justify-center gap-2 py-10
text-sm text-base-content/40">
<span class="loading loading-spinner loading-sm"></span>
Memuat...
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- ═══ MODAL: KONFIRMASI HAPUS ══════════════════════════════════════════ -->
<dialog id="confirmModal" class="modal">
<div class="modal-box max-w-sm">
<!-- Header merah -->
<div class="flex items-center gap-3 mb-5">
<div class="bg-error/10 text-error rounded-full w-11 h-11 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-triangle-exclamation text-lg"></i>
</div>
<div>
<h3 class="font-bold">Hapus Data SPBU</h3>
<p class="text-xs text-base-content/50">Tindakan ini tidak dapat dibatalkan</p>
</div>
<button class="btn btn-sm btn-circle btn-ghost ml-auto" id="btnTutupKonfirmasi"></button>
</div>
<p class="text-sm text-base-content/70 mb-2">
Apakah kamu yakin ingin menghapus data SPBU berikut?
</p>
<div class="bg-base-200 border-l-4 border-error rounded px-3 py-2 font-semibold mb-4"
id="confirmName"></div>
<div class="alert alert-error alert-sm py-2 text-xs mb-4">
<i class="fa-solid fa-circle-info"></i>
Data yang dihapus tidak bisa dikembalikan.
</div>
<div class="modal-action mt-0">
<button class="btn btn-sm" id="btnBatal">
<i class="fa-solid fa-xmark"></i> Batal
</button>
<button class="btn btn-sm btn-error" id="btnYaHapus">
<i class="fa-solid fa-trash"></i> Ya, Hapus
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- ═══ TOAST ══════════════════════════════════════════════════════════════ -->
<div class="toast toast-end toast-bottom z-[99999]" id="toastContainer"></div>
<!-- ═══ JAVASCRIPT ════════════════════════════════════════════════════════ -->
<script>
const API = '../api';
// ── MAP ─────────────────────────────────────────────────────────────────
const previewMap = L.map('previewMap', {
center: [-0.0606, 109.345],
zoom: 14
});
// 1. Base Maps
const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
});
const esriSatellite = L.layerGroup([
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
}),
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19
})
]).addTo(previewMap);
// 2. Layer Groups (Overlays)
const layer24h = L.layerGroup().addTo(previewMap);
const layerRegular = L.layerGroup().addTo(previewMap);
const baseMaps = {
"Satelit (Hybrid)": esriSatellite,
"Standard Map": osm
};
const overlays = {
"SPBU Buka 24 Jam": layer24h,
"SPBU Tidak Buka 24 Jam": layerRegular
};
L.control.layers(baseMaps, overlays, { collapsed: true }).addTo(previewMap);
function makeIcon(color) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 42" width="32" height="42">
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 26 16 26S32 28 32 16C32 7.163 24.837 0 16 0z"
fill="${color}" stroke="white" stroke-width="2"/>
<text x="16" y="21" text-anchor="middle" fill="white"
font-size="13" font-family="Arial" font-weight="bold">⛽</text>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize:[32,42], iconAnchor:[16,42], popupAnchor:[0,-44] });
}
// ── STATE ────────────────────────────────────────────────────────────────
let allData = [];
let mapMarkers = {};
// ── MODAL ────────────────────────────────────────────────────────────────
const confirmModal = document.getElementById('confirmModal');
// ── HELPERS ──────────────────────────────────────────────────────────────
function esc(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(str));
return d.innerHTML;
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
t.className = `alert ${cls} shadow-md`;
t.innerHTML = `<span>${msg}</span>`;
document.getElementById('toastContainer').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
// ── LOAD ─────────────────────────────────────────────────────────────────
function loadData() {
document.getElementById('loadingTable').style.display = 'flex';
document.getElementById('dataTable').style.display = 'none';
document.getElementById('emptyState').classList.add('hidden');
// Clear previous layer groups
layer24h.clearLayers();
layerRegular.clearLayers();
mapMarkers = {};
fetch(`${API}/get_spbu.php`)
.then(r => r.json())
.then(data => {
allData = data;
renderStats(data);
renderTable(data);
renderMarkers(data);
renderQuickList(data);
})
.catch(() => {
document.getElementById('loadingTable').style.display = 'none';
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('totalBadge').textContent = 'Gagal memuat data';
renderStats([]);
});
}
// ── STATS ────────────────────────────────────────────────────────────────
function renderStats(data) {
const total = data.length;
const buka = data.filter(d => d.status === 'Buka 24 Jam').length;
document.getElementById('statTotal').textContent = total;
document.getElementById('stat24j').textContent = buka;
document.getElementById('statNon24').textContent = total - buka;
document.getElementById('totalBadge').textContent = total + ' data';
}
// ── TABLE ────────────────────────────────────────────────────────────────
function renderTable(data) {
document.getElementById('loadingTable').style.display = 'none';
if (!data.length) {
document.getElementById('emptyState').classList.remove('hidden');
document.getElementById('dataTable').style.display = 'none';
return;
}
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('dataTable').style.display = 'table';
document.getElementById('tableBody').innerHTML = data.map((spbu, i) => {
const is24 = spbu.status === 'Buka 24 Jam';
const badgeCls = is24 ? 'badge-success' : 'badge-error';
const badgeIco = is24 ? '🟢' : '🔴';
const lat = parseFloat(spbu.latitude).toFixed(5);
const lng = parseFloat(spbu.longitude).toFixed(5);
const namaJS = spbu.nama_spbu.replace(/'/g, "\\'");
return `
<tr class="hover cursor-pointer">
<td class="text-base-content/50 text-xs font-semibold"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">${i+1}</td>
<td onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
<span class="font-semibold">${esc(spbu.nama_spbu)}</span>
</td>
<td class="text-xs text-base-content/60"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
${esc(spbu.nomor_spbu)}
</td>
<td onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
<span class="badge ${badgeCls} badge-sm gap-1">${badgeIco} ${esc(spbu.status)}</span>
</td>
<td class="font-mono text-xs text-base-content/50"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
${lat}<br>${lng}
</td>
<td class="text-center">
<button class="btn btn-xs btn-outline btn-error gap-1"
onclick="bukaKonfirmasi(${spbu.id}, '${namaJS}')">
<i class="fa-solid fa-trash"></i> Hapus
</button>
</td>
</tr>`;
}).join('');
}
// ── MARKERS ───────────────────────────────────────────────────────────────
function renderMarkers(data) {
const bounds = [];
data.forEach(spbu => {
const is24 = spbu.status === 'Buka 24 Jam';
const color = is24 ? '#16a34a' : '#dc2626';
const group = is24 ? layer24h : layerRegular;
const m = L.marker([+spbu.latitude, +spbu.longitude], { icon: makeIcon(color) })
.addTo(group)
.bindPopup(`
<div style="font-family:ui-sans-serif,system-ui,sans-serif;min-width:150px">
<b>⛽ ${esc(spbu.nama_spbu)}</b><br>
<span style="font-size:0.78rem;color:#6b7280">No: ${esc(spbu.nomor_spbu)}</span><br>
<span class="badge ${spbu.status === 'Buka 24 Jam' ? 'badge-success' : 'badge-error'}"
style="font-size:0.7rem;margin-top:5px">${esc(spbu.status)}</span>
</div>`);
mapMarkers[spbu.id] = m;
bounds.push([+spbu.latitude, +spbu.longitude]);
});
if (bounds.length) previewMap.fitBounds(bounds, { padding: [28, 28] });
}
function focusMap(lat, lng, id) {
previewMap.flyTo([lat, lng], 17, { animate: true, duration: 1 });
if (mapMarkers[id]) mapMarkers[id].openPopup();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ── QUICK LIST ─────────────────────────────────────────────────────────
function renderQuickList(data) {
const el = document.getElementById('quickList');
if (!data.length) {
el.innerHTML = `<div class="text-center py-10 text-sm text-base-content/40">
📭 Belum ada data</div>`;
return;
}
el.innerHTML = data.map(spbu => {
const is24 = spbu.status === 'Buka 24 Jam';
return `
<div class="flex gap-3 items-start px-4 py-3 border-b border-base-200
hover:bg-base-200 cursor-pointer transition-colors"
onclick="focusMap(${spbu.latitude},${spbu.longitude},${spbu.id})">
<span class="text-xl mt-0.5">⛽</span>
<div>
<div class="font-semibold text-sm">${esc(spbu.nama_spbu)}</div>
<div class="text-xs text-base-content/50">${esc(spbu.nomor_spbu)}</div>
<span class="badge ${is24 ? 'badge-success' : 'badge-error'} badge-sm mt-1">
${is24 ? '🟢' : '🔴'} ${esc(spbu.status)}
</span>
</div>
</div>`;
}).join('');
}
// ── SEARCH ────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', function () {
const q = this.value.toLowerCase();
const filtered = allData.filter(d =>
d.nama_spbu.toLowerCase().includes(q) ||
d.nomor_spbu.toLowerCase().includes(q)
);
renderTable(filtered);
document.getElementById('totalBadge').textContent = filtered.length + ' data';
});
// ── HAPUS ─────────────────────────────────────────────────────────────
let pendingId = null;
function bukaKonfirmasi(id, nama) {
pendingId = id;
document.getElementById('confirmName').textContent = '⛽ ' + nama;
confirmModal.showModal();
}
function tutupKonfirmasi() { confirmModal.close(); pendingId = null; }
document.getElementById('btnBatal').addEventListener('click', tutupKonfirmasi);
document.getElementById('btnTutupKonfirmasi').addEventListener('click', tutupKonfirmasi);
confirmModal.addEventListener('close', () => { pendingId = null; });
document.getElementById('btnYaHapus').addEventListener('click', function () {
if (!pendingId) return;
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-xs"></span> Menghapus...';
fetch(`${API}/delete_spbu.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: pendingId, _method: 'DELETE' })
})
.then(r => r.json())
.then(res => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
if (res.success) {
tutupKonfirmasi();
showToast('Data SPBU berhasil dihapus! 🗑️', 'success');
loadData();
} else {
showToast('Gagal: ' + (res.message || 'Error server'), 'error');
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-trash"></i> Ya, Hapus';
showToast('Tidak dapat terhubung ke server!', 'error');
});
});
// ── INIT ─────────────────────────────────────────────────────────────
loadData();
</script>
</body>
</html>