add files
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$query = mysqli_query($koneksi, "SELECT id, nama_spbu, no_wa, status_24_jam, lat, lng FROM `$nama_tabel`");
|
||||
$data = [];
|
||||
|
||||
while ($row = mysqli_fetch_assoc($query)) {
|
||||
$row['lat'] = floatval($row['lat']);
|
||||
$row['lng'] = floatval($row['lng']);
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode($data);
|
||||
?>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo "<style>body{font-family:monospace;padding:20px;background:#111;color:#0f0;} .ok{color:#0f0} .err{color:#f44} .warn{color:#fa0}</style>";
|
||||
echo "<h2>🔍 Cek Koneksi & Database SPBU</h2>";
|
||||
|
||||
if ($koneksi) {
|
||||
echo "<p class='ok'>✅ Koneksi ke MySQL berhasil</p>";
|
||||
} else {
|
||||
echo "<p class='err'>❌ Koneksi GAGAL: " . mysqli_connect_error() . "</p>"; exit;
|
||||
}
|
||||
|
||||
$db_cek = mysqli_query($koneksi, "SELECT DATABASE() AS db");
|
||||
$db_row = mysqli_fetch_assoc($db_cek);
|
||||
echo "<p class='ok'>✅ Database aktif: <b>{$db_row['db']}</b></p>";
|
||||
echo "<p class='ok'>✅ Nama tabel: <b>$nama_tabel</b></p>";
|
||||
|
||||
$tabel_cek = mysqli_query($koneksi, "SHOW TABLES LIKE '$nama_tabel'");
|
||||
if (mysqli_num_rows($tabel_cek) > 0) {
|
||||
echo "<p class='ok'>✅ Tabel <b>$nama_tabel</b> ditemukan</p>";
|
||||
} else {
|
||||
echo "<p class='err'>❌ Tabel <b>$nama_tabel</b> TIDAK ADA!</p>";
|
||||
echo "<p class='warn'>⚠️ Jalankan schema_mysql.sql di phpMyAdmin</p>"; exit;
|
||||
}
|
||||
|
||||
$count_cek = mysqli_query($koneksi, "SELECT COUNT(*) AS total FROM `$nama_tabel`");
|
||||
$count_row = mysqli_fetch_assoc($count_cek);
|
||||
echo "<p class='ok'>✅ Jumlah data: <b>{$count_row['total']}</b> baris</p>";
|
||||
|
||||
$test = mysqli_query($koneksi, "INSERT INTO `$nama_tabel` (nama_spbu, no_wa, status_24_jam, lat, lng) VALUES ('TEST_DELETE_ME', '000', 'no', 0, 0)");
|
||||
if ($test) {
|
||||
$ins_id = mysqli_insert_id($koneksi);
|
||||
mysqli_query($koneksi, "DELETE FROM `$nama_tabel` WHERE id = $ins_id");
|
||||
echo "<p class='ok'>✅ Test INSERT & DELETE berhasil — database siap digunakan!</p>";
|
||||
} else {
|
||||
echo "<p class='err'>❌ Test INSERT gagal: " . mysqli_error($koneksi) . "</p>";
|
||||
}
|
||||
echo "<hr><p>File ini bisa dihapus setelah tidak diperlukan.</p>";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$id = mysqli_real_escape_string($koneksi, $_POST['id'] ?? '');
|
||||
$nama = mysqli_real_escape_string($koneksi, $_POST['nama_spbu'] ?? '');
|
||||
$wa = mysqli_real_escape_string($koneksi, $_POST['no_wa'] ?? '');
|
||||
$status = mysqli_real_escape_string($koneksi, $_POST['status_24_jam'] ?? 'no');
|
||||
|
||||
if (!$id || !$nama || !$wa) {
|
||||
echo "Gagal: Data tidak lengkap!";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "UPDATE `$nama_tabel` SET nama_spbu='$nama', no_wa='$wa', status_24_jam='$status' WHERE id='$id'";
|
||||
|
||||
if (mysqli_query($koneksi, $query)) {
|
||||
echo "Data SPBU berhasil diperbarui!";
|
||||
} else {
|
||||
echo "Gagal memperbarui data: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$id = mysqli_real_escape_string($koneksi, $_POST['id'] ?? '');
|
||||
|
||||
if (!$id) {
|
||||
echo "Gagal: ID tidak ditemukan!";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "DELETE FROM `$nama_tabel` WHERE id='$id'";
|
||||
|
||||
if (mysqli_query($koneksi, $query)) {
|
||||
echo "Data berhasil dihapus!";
|
||||
} else {
|
||||
echo "Gagal menghapus data: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,790 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS SPBU Pontianak</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- ✅ FIX 1: Leaflet CSS & JS dipindah ke <head> agar sudah siap sebelum script dijalankan -->
|
||||
<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>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f14;
|
||||
--surface: #161923;
|
||||
--surface2: #1e2330;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--accent-green: #00e5a0;
|
||||
--accent-amber: #ffb830;
|
||||
--accent-blue: #3d8bff;
|
||||
--text: #e8ecf4;
|
||||
--text-muted: #6b7385;
|
||||
--danger: #ff4d6a;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── SIDEBAR ─── */
|
||||
#sidebar {
|
||||
width: 340px;
|
||||
min-width: 340px;
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 28px 24px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, rgba(61,139,255,0.12), rgba(0,229,160,0.06));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40px; right: -40px;
|
||||
width: 130px; height: 130px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,229,160,0.15), transparent 70%);
|
||||
}
|
||||
.logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 40px; height: 40px;
|
||||
background: linear-gradient(135deg, var(--accent-green), var(--accent-blue));
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sidebar-header h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.sidebar-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 52px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stat-box {
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
padding: 12px 10px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.stat-box:hover { transform: translateY(-2px); }
|
||||
.stat-num {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label { font-size: 10px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-box.total .stat-num { color: var(--accent-blue); }
|
||||
.stat-box.jam24 .stat-num { color: var(--accent-green); }
|
||||
.stat-box.reguler .stat-num { color: var(--accent-amber); }
|
||||
|
||||
/* Layer Control Panel */
|
||||
.layer-panel {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.layer-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.layer-toggle:hover { background: var(--surface2); }
|
||||
.toggle-dot {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-dot.green { background: var(--accent-green); box-shadow: 0 0 8px rgba(0,229,160,0.5); }
|
||||
.toggle-dot.amber { background: var(--accent-amber); box-shadow: 0 0 8px rgba(255,184,48,0.5); }
|
||||
.layer-toggle span { font-size: 13px; flex-grow: 1; }
|
||||
.toggle-count {
|
||||
font-size: 11px;
|
||||
background: var(--surface2);
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Custom Checkbox */
|
||||
.layer-toggle input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.layer-toggle input[type="checkbox"]:checked {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
.layer-toggle input[type="checkbox"]:checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
top: 0px; left: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* SPBU List */
|
||||
.list-header {
|
||||
padding: 14px 20px 8px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#spbu-list {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface2) transparent;
|
||||
}
|
||||
.spbu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.spbu-item:hover {
|
||||
background: var(--surface2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.spbu-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spbu-dot.green { background: var(--accent-green); }
|
||||
.spbu-dot.amber { background: var(--accent-amber); }
|
||||
.spbu-item-name { font-size: 13px; font-weight: 500; flex-grow: 1; }
|
||||
.badge-pill {
|
||||
font-size: 9px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-pill.green { background: rgba(0,229,160,0.15); color: var(--accent-green); }
|
||||
.badge-pill.amber { background: rgba(255,184,48,0.15); color: var(--accent-amber); }
|
||||
|
||||
/* ─── MAP ─── */
|
||||
#map-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
/* ✅ FIX 2: Pastikan tinggi eksplisit agar peta punya dimensi */
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
}
|
||||
#map {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Leaflet overrides for dark theme */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-popup-tip { background: var(--surface) !important; }
|
||||
.leaflet-popup-content { margin: 0 !important; }
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
}
|
||||
.leaflet-control-zoom a:hover { background: var(--surface2) !important; }
|
||||
|
||||
/* Popup Form */
|
||||
.popup-wrap { padding: 18px; min-width: 220px; }
|
||||
.popup-type {
|
||||
font-size: 9px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.popup-name {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.popup-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.popup-row strong { color: var(--text); }
|
||||
.popup-divider { height: 1px; background: var(--border); margin: 14px 0; }
|
||||
|
||||
.popup-input {
|
||||
width: 100%;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.popup-input:focus { border-color: var(--accent-blue); }
|
||||
|
||||
.popup-actions { display: flex; gap: 7px; margin-top: 12px; }
|
||||
.pop-btn {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.pop-btn:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
.pop-btn.blue { background: var(--accent-blue); color: white; }
|
||||
.pop-btn.amber { background: var(--accent-amber); color: #1a1000; }
|
||||
.pop-btn.red { background: var(--danger); color: white; }
|
||||
.pop-btn.gray { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
|
||||
/* Map hint badge */
|
||||
.map-hint {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(13,15,20,0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 30px;
|
||||
padding: 8px 18px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-hint span { color: var(--accent-blue); font-weight: 600; }
|
||||
|
||||
/* ✅ FIX 3: Loading overlay HTML ditambahkan */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
||||
.loader-ring {
|
||||
width: 50px; height: 50px;
|
||||
border: 3px solid var(--surface2);
|
||||
border-top-color: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Animations */
|
||||
.spbu-item { animation: fadeUp 0.3s ease backwards; }
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-row">
|
||||
<div class="logo-icon">⛽</div>
|
||||
<h1>SPBU Pontianak</h1>
|
||||
</div>
|
||||
<p>WebGIS — Sistem Informasi Geografis</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box total">
|
||||
<div class="stat-num" id="stat-total">–</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-box jam24">
|
||||
<div class="stat-num" id="stat-24">–</div>
|
||||
<div class="stat-label">24 Jam</div>
|
||||
</div>
|
||||
<div class="stat-box reguler">
|
||||
<div class="stat-num" id="stat-reg">–</div>
|
||||
<div class="stat-label">Reguler</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer-panel">
|
||||
<div class="panel-title">Layer Control</div>
|
||||
|
||||
<label class="layer-toggle">
|
||||
<div class="toggle-dot green"></div>
|
||||
<span>SPBU Buka 24 Jam</span>
|
||||
<span class="toggle-count" id="cnt-24">0</span>
|
||||
<input type="checkbox" id="chk-24" checked>
|
||||
</label>
|
||||
|
||||
<label class="layer-toggle">
|
||||
<div class="toggle-dot amber"></div>
|
||||
<span>SPBU Buka Reguler</span>
|
||||
<span class="toggle-count" id="cnt-reg">0</span>
|
||||
<input type="checkbox" id="chk-reg" checked>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="list-header">Daftar SPBU</div>
|
||||
<div id="spbu-list">
|
||||
<p style="text-align:center;color:var(--text-muted);font-size:13px;padding:20px;">Memuat data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container">
|
||||
<!-- ✅ FIX 3: Loading overlay ditambahkan ke HTML -->
|
||||
<div class="loading-overlay" id="loading-overlay">
|
||||
<div class="loader-ring"></div>
|
||||
<p style="color:var(--text-muted);font-size:13px;">Memuat peta...</p>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div class="map-hint">Klik <span>di mana saja</span> pada peta untuk menambah SPBU baru</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ FIX 1: Script Leaflet DIHAPUS dari sini karena sudah dipindah ke <head> -->
|
||||
<script>
|
||||
|
||||
// ── MAP INIT ──
|
||||
// ✅ FIX 4: Inisialisasi map dibungkus dalam DOMContentLoaded agar aman
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
const map = L.map('map', { zoomControl: false }).setView([-0.026330, 109.342504], 13);
|
||||
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
// ✅ FIX 5: Tile layer dengan 2 pilihan — CartoDB dark (utama) + OSM (fallback)
|
||||
const tileCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_matter/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CartoDB</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
});
|
||||
|
||||
const tileOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19
|
||||
});
|
||||
|
||||
// Coba CartoDB dulu, jika tile gagal load pakai OSM
|
||||
tileCartoDB.addTo(map);
|
||||
tileCartoDB.on('tileerror', function() {
|
||||
map.removeLayer(tileCartoDB);
|
||||
tileOSM.addTo(map);
|
||||
});
|
||||
|
||||
// ── LAYER GROUPS ──
|
||||
// Dibuat dulu, belum ditambah ke map — ditambah sesuai state checkbox
|
||||
const layer24 = L.layerGroup();
|
||||
const layerReg = L.layerGroup();
|
||||
|
||||
// ── EVENT LAYER CONTROL (FIX UTAMA) ──
|
||||
document.getElementById('chk-24').addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
layer24.addTo(map);
|
||||
} else {
|
||||
map.removeLayer(layer24);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('chk-reg').addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
layerReg.addTo(map);
|
||||
} else {
|
||||
map.removeLayer(layerReg);
|
||||
}
|
||||
});
|
||||
|
||||
// ── ICONS ──
|
||||
function makeIcon(color) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="42" viewBox="0 0 32 42">
|
||||
<defs>
|
||||
<filter id="s${color.replace('#','')}"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="${color}" flood-opacity="0.4"/></filter>
|
||||
</defs>
|
||||
<path fill="${color}" filter="url(#s${color.replace('#','')})" d="M16 0C7.163 0 0 7.163 0 16c0 12 16 26 16 26s16-14 16-26C32 7.163 24.837 0 16 0z"/>
|
||||
<circle cx="16" cy="16" r="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="16" cy="16" r="5" fill="${color}"/>
|
||||
</svg>`;
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [32, 42],
|
||||
iconAnchor: [16, 42],
|
||||
popupAnchor: [0, -42]
|
||||
});
|
||||
}
|
||||
|
||||
const iconGreen = makeIcon('#00e5a0');
|
||||
const iconAmber = makeIcon('#ffb830');
|
||||
const iconTemp = makeIcon('#3d8bff');
|
||||
|
||||
// ── DATA ──
|
||||
let allData = [];
|
||||
let tempMarker = null;
|
||||
|
||||
function muatData() {
|
||||
fetch('ambil_data.php')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
allData = data;
|
||||
renderAll(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Demo mode jika tidak ada server
|
||||
renderAll(demoData());
|
||||
});
|
||||
}
|
||||
|
||||
function demoData() {
|
||||
return [
|
||||
{id:'1', nama_spbu:'SPBU Jl. Ahmad Yani', no_wa:'08123456789', status_24_jam:'yes', lat:-0.0202, lng:109.3423},
|
||||
{id:'2', nama_spbu:'SPBU Jl. Gajah Mada', no_wa:'08234567890', status_24_jam:'no', lat:-0.0280, lng:109.3310},
|
||||
{id:'3', nama_spbu:'SPBU Sui Raya', no_wa:'08345678901', status_24_jam:'yes', lat:-0.0450, lng:109.3580},
|
||||
{id:'4', nama_spbu:'SPBU Jl. Tanjungpura', no_wa:'08456789012', status_24_jam:'no', lat:-0.0120, lng:109.3390},
|
||||
{id:'5', nama_spbu:'SPBU Jl. Imam Bonjol', no_wa:'08567890123', status_24_jam:'yes', lat:-0.0330, lng:109.3500},
|
||||
{id:'6', nama_spbu:'SPBU Siantan', no_wa:'08678901234', status_24_jam:'no', lat:0.0050, lng:109.3350},
|
||||
];
|
||||
}
|
||||
|
||||
function renderAll(data) {
|
||||
layer24.clearLayers();
|
||||
layerReg.clearLayers();
|
||||
|
||||
let c24 = 0, cReg = 0;
|
||||
let listHTML = '';
|
||||
|
||||
data.forEach((item, i) => {
|
||||
const is24 = item.status_24_jam === 'yes';
|
||||
const icon = is24 ? iconGreen : iconAmber;
|
||||
const layer = is24 ? layer24 : layerReg;
|
||||
if (is24) c24++; else cReg++;
|
||||
|
||||
// Tambah marker ke layerGroup (bukan langsung ke map)
|
||||
const marker = L.marker([parseFloat(item.lat), parseFloat(item.lng)], { icon, draggable: true });
|
||||
layer.addLayer(marker);
|
||||
marker.bindPopup(buildInfoPopup(item), { maxWidth: 280 });
|
||||
|
||||
marker.on('dragend', function() {
|
||||
const pos = marker.getLatLng();
|
||||
if (confirm('Simpan koordinat baru?')) updateLokasi(item.id, pos.lat, pos.lng);
|
||||
else muatData();
|
||||
});
|
||||
|
||||
const delay = i * 40;
|
||||
listHTML += `
|
||||
<div class="spbu-item" style="animation-delay:${delay}ms" onclick="flyTo(${item.lat},${item.lng})">
|
||||
<div class="spbu-dot ${is24 ? 'green' : 'amber'}"></div>
|
||||
<span class="spbu-item-name">${item.nama_spbu}</span>
|
||||
<span class="badge-pill ${is24 ? 'green' : 'amber'}">${is24 ? '24 Jam' : 'Reguler'}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('stat-total').textContent = data.length;
|
||||
document.getElementById('stat-24').textContent = c24;
|
||||
document.getElementById('stat-reg').textContent = cReg;
|
||||
document.getElementById('cnt-24').textContent = c24;
|
||||
document.getElementById('cnt-reg').textContent = cReg;
|
||||
document.getElementById('spbu-list').innerHTML = listHTML || '<p style="text-align:center;color:var(--text-muted);font-size:13px;padding:20px;">Belum ada data.</p>';
|
||||
|
||||
// Terapkan state checkbox saat ini ke masing-masing layer
|
||||
const show24 = document.getElementById('chk-24').checked;
|
||||
const showReg = document.getElementById('chk-reg').checked;
|
||||
show24 ? layer24.addTo(map) : map.removeLayer(layer24);
|
||||
showReg ? layerReg.addTo(map) : map.removeLayer(layerReg);
|
||||
|
||||
// Sembunyikan loading overlay setelah data selesai dirender
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
window.flyTo = function flyTo(lat, lng) {
|
||||
map.flyTo([lat, lng], 16, { duration: 1 });
|
||||
};
|
||||
|
||||
// ── POPUP BUILDERS ──
|
||||
function buildInfoPopup(item) {
|
||||
const is24 = item.status_24_jam === 'yes';
|
||||
return `
|
||||
<div class="popup-wrap">
|
||||
<div class="popup-type">Stasiun Bahan Bakar</div>
|
||||
<div class="popup-name">${item.nama_spbu}</div>
|
||||
<div class="popup-row">📞 <strong>${item.no_wa}</strong></div>
|
||||
<div class="popup-row">🕐 Status: <span class="badge-pill ${is24?'green':'amber'}" style="margin-left:4px">${is24?'Buka 24 Jam':'Buka Reguler'}</span></div>
|
||||
<div class="popup-divider"></div>
|
||||
<div class="popup-actions">
|
||||
<button class="pop-btn amber" onclick="bukaEdit('${item.id}','${item.nama_spbu}','${item.no_wa}','${item.status_24_jam}',${item.lat},${item.lng})">✏ Edit</button>
|
||||
<button class="pop-btn red" onclick="hapusData('${item.id}')">🗑 Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildAddPopup(lat, lng) {
|
||||
return `
|
||||
<div class="popup-wrap">
|
||||
<div class="popup-type">Tambah Lokasi Baru</div>
|
||||
<div class="popup-name" style="font-size:14px;margin-bottom:14px;">Data SPBU</div>
|
||||
<input class="popup-input" id="add_nama" placeholder="Nama SPBU" />
|
||||
<input class="popup-input" id="add_wa" placeholder="Nomor WhatsApp" />
|
||||
<select class="popup-input" id="add_status">
|
||||
<option value="no">Buka Reguler</option>
|
||||
<option value="yes">Buka 24 Jam</option>
|
||||
</select>
|
||||
<div class="popup-actions">
|
||||
<button class="pop-btn blue" onclick="prosesSimpan(${lat},${lng},'tambah')">💾 Simpan</button>
|
||||
<button class="pop-btn gray" onclick="batalTambah()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildEditPopup(id, nama, wa, status, lat, lng) {
|
||||
const sY = status==='yes'?'selected':'';
|
||||
const sN = status==='no'?'selected':'';
|
||||
return `
|
||||
<div class="popup-wrap">
|
||||
<div class="popup-type">Edit Data</div>
|
||||
<input type="hidden" id="edit_id" value="${id}">
|
||||
<input class="popup-input" id="edit_nama" value="${nama}">
|
||||
<input class="popup-input" id="edit_wa" value="${wa}">
|
||||
<select class="popup-input" id="edit_status">
|
||||
<option value="no" ${sN}>Buka Reguler</option>
|
||||
<option value="yes" ${sY}>Buka 24 Jam</option>
|
||||
</select>
|
||||
<div class="popup-actions">
|
||||
<button class="pop-btn blue" onclick="prosesSimpan(${lat},${lng},'edit')">✓ Update</button>
|
||||
<button class="pop-btn gray" onclick="map.closePopup()">✕ Batal</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── MAP CLICK – ADD ──
|
||||
map.on('click', function(e) {
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([e.latlng.lat, e.latlng.lng], { icon: iconTemp }).addTo(map);
|
||||
tempMarker.bindPopup(buildAddPopup(e.latlng.lat, e.latlng.lng), { maxWidth: 280 }).openPopup();
|
||||
});
|
||||
|
||||
window.batalTambah = function() {
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
map.closePopup();
|
||||
};
|
||||
|
||||
// ── EDIT ──
|
||||
window.bukaEdit = function(id, nama, wa, status, lat, lng) {
|
||||
L.popup({ maxWidth: 280 })
|
||||
.setLatLng([lat, lng])
|
||||
.setContent(buildEditPopup(id, nama, wa, status, lat, lng))
|
||||
.openOn(map);
|
||||
};
|
||||
|
||||
// ── SIMPAN (POST) ──
|
||||
window.prosesSimpan = function prosesSimpan(lat, lng, mode) {
|
||||
const pfx = mode === 'edit' ? 'edit_' : 'add_';
|
||||
const id = mode === 'edit' ? document.getElementById('edit_id').value : '';
|
||||
const nama = document.getElementById(pfx + 'nama').value.trim();
|
||||
const wa = document.getElementById(pfx + 'wa').value.trim();
|
||||
const stat = document.getElementById(pfx + 'status').value;
|
||||
|
||||
if (!nama || !wa) return alert('Nama dan nomor WA wajib diisi!');
|
||||
|
||||
const fd = new FormData();
|
||||
if (mode === 'edit') fd.append('id', id);
|
||||
fd.append('nama_spbu', nama);
|
||||
fd.append('no_wa', wa);
|
||||
fd.append('status_24_jam', stat);
|
||||
fd.append('lat', lat);
|
||||
fd.append('lng', lng);
|
||||
|
||||
const url = mode === 'edit' ? 'edit_data.php' : 'simpan_data.php';
|
||||
|
||||
fetch(url, { method: 'POST', body: fd })
|
||||
.then(r => r.text())
|
||||
.then(msg => {
|
||||
showToast(msg, 'success');
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
muatData();
|
||||
})
|
||||
.catch(() => {
|
||||
// Demo mode: update state lokal agar data langsung tampil tanpa server
|
||||
if (mode === 'tambah') {
|
||||
const newItem = {
|
||||
id: Date.now().toString(),
|
||||
nama_spbu: nama,
|
||||
no_wa: wa,
|
||||
status_24_jam: stat,
|
||||
lat: lat,
|
||||
lng: lng
|
||||
};
|
||||
allData.push(newItem);
|
||||
} else {
|
||||
const idx = allData.findIndex(d => d.id === id);
|
||||
if (idx !== -1) {
|
||||
allData[idx].nama_spbu = nama;
|
||||
allData[idx].no_wa = wa;
|
||||
allData[idx].status_24_jam = stat;
|
||||
}
|
||||
}
|
||||
renderAll(allData);
|
||||
showToast(mode === 'tambah' ? 'Data berhasil disimpan! (demo mode)' : 'Data berhasil diperbarui! (demo mode)', 'success');
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── HAPUS ──
|
||||
window.hapusData = function hapusData(id) {
|
||||
if (!confirm('Hapus data SPBU ini?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fetch('hapus_data.php', { method: 'POST', body: fd })
|
||||
.then(r => r.text())
|
||||
.then(msg => { showToast(msg, 'danger'); muatData(); })
|
||||
.catch(() => {
|
||||
allData = allData.filter(d => d.id !== id);
|
||||
renderAll(allData);
|
||||
showToast('Data dihapus! (demo mode)', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// ── UPDATE LOKASI ──
|
||||
window.updateLokasi = function updateLokasi(id, lat, lng) {
|
||||
const fd = new FormData();
|
||||
fd.append('id', id); fd.append('lat', lat); fd.append('lng', lng);
|
||||
fetch('update_lokasi.php', { method: 'POST', body: fd })
|
||||
.then(() => { showToast('Lokasi diperbarui!', 'success'); muatData(); })
|
||||
.catch(() => { showToast('Lokasi diperbarui! (demo mode)', 'success'); muatData(); });
|
||||
}
|
||||
|
||||
// ── TOAST ──
|
||||
function showToast(msg, type) {
|
||||
const t = document.createElement('div');
|
||||
t.style.cssText = `
|
||||
position:fixed; bottom:30px; right:30px; z-index:9999;
|
||||
background:${type==='success'?'#00e5a0':'#ff4d6a'};
|
||||
color:${type==='success'?'#001a0d':'#fff'};
|
||||
padding:12px 20px; border-radius:10px; font-size:13px; font-weight:600;
|
||||
font-family:'Syne',sans-serif; box-shadow:0 8px 30px rgba(0,0,0,0.4);
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
// ── START ──
|
||||
muatData();
|
||||
|
||||
// ✅ FIX 6: invalidateSize dipanggil beberapa kali untuk memastikan peta terrender sempurna
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
setTimeout(() => map.invalidateSize(), 500);
|
||||
|
||||
}); // tutup DOMContentLoaded
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
$host = "localhost";
|
||||
$user = "root";
|
||||
$pass = "";
|
||||
|
||||
// Berdasarkan gambar phpMyAdmin Anda:
|
||||
$nama_database = "spbulayer";
|
||||
|
||||
$nama_tabel = "tbl_spbu";
|
||||
|
||||
$koneksi = mysqli_connect($host, $user, $pass, $nama_database);
|
||||
|
||||
// Cek koneksi agar tidak muncul error Fatal lagi
|
||||
if (!$koneksi) {
|
||||
die("Koneksi ke database gagal: " . mysqli_connect_error());
|
||||
}
|
||||
?>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "spbu' - Copy",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ============================================================
|
||||
-- Schema MySQL untuk WebGIS SPBU Pontianak
|
||||
-- ============================================================
|
||||
-- CARA PAKAI:
|
||||
-- 1. Buka phpMyAdmin
|
||||
-- 2. Pilih database "data_spbu" (atau buat baru jika belum ada)
|
||||
-- 3. Klik tab "SQL"
|
||||
-- 4. Copy-paste seluruh isi file ini, lalu klik "Go"
|
||||
-- ============================================================
|
||||
|
||||
-- CATATAN: nama tabel sengaja dibuat "tbl_spbu" (bukan "data_spbu")
|
||||
-- karena MySQL tidak boleh memiliki nama tabel sama dengan nama database
|
||||
-- (menyebabkan error errno: 121)
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS `data_spbu`
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE `data_spbu`;
|
||||
|
||||
-- Hapus tabel lama jika ada
|
||||
DROP TABLE IF EXISTS `tbl_spbu`;
|
||||
DROP TABLE IF EXISTS `data_spbu`;
|
||||
|
||||
-- Buat tabel dengan nama tbl_spbu
|
||||
CREATE TABLE `tbl_spbu` (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nama_spbu VARCHAR(255) NOT NULL,
|
||||
no_wa VARCHAR(20) NOT NULL,
|
||||
status_24_jam ENUM('yes','no') DEFAULT 'no',
|
||||
lat DOUBLE NOT NULL,
|
||||
lng DOUBLE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Data contoh
|
||||
INSERT INTO `tbl_spbu` (nama_spbu, no_wa, status_24_jam, lat, lng) VALUES
|
||||
('SPBU Jl. Ahmad Yani', '08123456789', 'yes', -0.0202, 109.3423),
|
||||
('SPBU Jl. Gajah Mada', '08234567890', 'no', -0.0280, 109.3310),
|
||||
('SPBU Sui Raya', '08345678901', 'yes', -0.0450, 109.3580),
|
||||
('SPBU Jl. Tanjungpura', '08456789012', 'no', -0.0120, 109.3390),
|
||||
('SPBU Jl. Imam Bonjol', '08567890123', 'yes', -0.0330, 109.3500),
|
||||
('SPBU Siantan', '08678901234', 'no', 0.0050, 109.3350);
|
||||
|
||||
-- Cek hasil
|
||||
SELECT 'Tabel tbl_spbu berhasil dibuat!' AS status, COUNT(*) AS jumlah_data FROM `tbl_spbu`;
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$nama = mysqli_real_escape_string($koneksi, $_POST['nama_spbu'] ?? '');
|
||||
$wa = mysqli_real_escape_string($koneksi, $_POST['no_wa'] ?? '');
|
||||
$status = mysqli_real_escape_string($koneksi, $_POST['status_24_jam'] ?? 'no');
|
||||
$lat = floatval($_POST['lat'] ?? 0);
|
||||
$lng = floatval($_POST['lng'] ?? 0);
|
||||
|
||||
if (!$nama || !$wa) {
|
||||
echo "Gagal: Nama dan nomor WA wajib diisi!";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "INSERT INTO `$nama_tabel` (nama_spbu, no_wa, status_24_jam, lat, lng)
|
||||
VALUES ('$nama', '$wa', '$status', '$lat', '$lng')";
|
||||
|
||||
if (mysqli_query($koneksi, $query)) {
|
||||
echo "Data SPBU berhasil ditambahkan!";
|
||||
} else {
|
||||
echo "Gagal menambahkan data: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
$id = mysqli_real_escape_string($koneksi, $_POST['id']);
|
||||
$lat = mysqli_real_escape_string($koneksi, $_POST['lat']);
|
||||
$lng = mysqli_real_escape_string($koneksi, $_POST['lng']);
|
||||
|
||||
$query = "UPDATE $nama_tabel SET lat='$lat', lng='$lng' WHERE id='$id'";
|
||||
|
||||
if(mysqli_query($koneksi, $query)) {
|
||||
echo "Lokasi berhasil dipindahkan!";
|
||||
} else {
|
||||
echo "Gagal memindahkan lokasi: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
# 🗺️ WebGIS Persebaran Penduduk Miskin
|
||||
|
||||
**Sistem Pemetaan Geospasial berbasis React + Node.js + PostgreSQL/PostGIS**
|
||||
|
||||
---
|
||||
|
||||
## 📋 DAFTAR ISI
|
||||
|
||||
1. [Prasyarat](#prasyarat)
|
||||
2. [Instalasi PostgreSQL + PostGIS di XAMPP](#instalasi-postgresql--postgis)
|
||||
3. [Setup Database](#setup-database)
|
||||
4. [Setup Backend (Node.js)](#setup-backend)
|
||||
5. [Setup Frontend (React)](#setup-frontend)
|
||||
6. [Menjalankan Aplikasi](#menjalankan-aplikasi)
|
||||
7. [Struktur Folder](#struktur-folder)
|
||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||
9. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## ✅ PRASYARAT
|
||||
|
||||
Sebelum mulai, pastikan sudah terinstal:
|
||||
|
||||
| Software | Versi Minimum | Link Download |
|
||||
|----------|--------------|---------------|
|
||||
| **XAMPP** | 8.0+ | https://www.apachefriends.org |
|
||||
| **Node.js** | 18.x LTS | https://nodejs.org |
|
||||
| **PostgreSQL** | 14+ | https://www.postgresql.org/download |
|
||||
| **PostGIS** | 3.x | Diinstal via Stack Builder |
|
||||
| **Git** (opsional) | any | https://git-scm.com |
|
||||
|
||||
> ⚠️ **PENTING**: PostgreSQL dan PostGIS TIDAK termasuk dalam XAMPP standar.
|
||||
> XAMPP hanya menyediakan MySQL/MariaDB. Kita install PostgreSQL secara terpisah.
|
||||
|
||||
---
|
||||
|
||||
## 🐘 INSTALASI POSTGRESQL + POSTGIS
|
||||
|
||||
### Langkah 1: Install PostgreSQL
|
||||
|
||||
1. Download PostgreSQL dari https://www.postgresql.org/download/windows/
|
||||
2. Jalankan installer, ikuti wizard:
|
||||
- Port: **5432** (default)
|
||||
- Password superuser: catat dengan baik (contoh: `postgres123`)
|
||||
- Locale: sesuaikan (Indonesian)
|
||||
3. ✅ Centang "Stack Builder" di akhir instalasi
|
||||
|
||||
### Langkah 2: Install PostGIS via Stack Builder
|
||||
|
||||
1. Stack Builder otomatis terbuka setelah instalasi PostgreSQL
|
||||
2. Pilih server PostgreSQL Anda
|
||||
3. Expand kategori **Spatial Extensions**
|
||||
4. Centang **PostGIS** (versi terbaru)
|
||||
5. Klik Next dan ikuti proses download + instalasi
|
||||
6. Saat diminta "Create spatial database", **LEWATI** (kita buat manual)
|
||||
|
||||
### Langkah 3: Verifikasi Instalasi
|
||||
|
||||
Buka **SQL Shell (psql)** dari Start Menu:
|
||||
```sql
|
||||
-- Login dengan user postgres
|
||||
Password: [password yang Anda buat]
|
||||
|
||||
-- Cek PostGIS
|
||||
SELECT PostGIS_version();
|
||||
-- Output: "3.x USE_GEOS=1 ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ SETUP DATABASE
|
||||
|
||||
### Langkah 1: Buat Database
|
||||
|
||||
Buka **pgAdmin 4** (tersedia di Start Menu setelah install PostgreSQL):
|
||||
|
||||
1. Klik kanan **Databases** → **Create** → **Database**
|
||||
2. Isi nama: `webgis_kemiskinan`
|
||||
3. Klik **Save**
|
||||
|
||||
**ATAU** via SQL Shell (psql):
|
||||
```sql
|
||||
CREATE DATABASE webgis_kemiskinan;
|
||||
\c webgis_kemiskinan
|
||||
```
|
||||
|
||||
### Langkah 2: Jalankan Script SQL
|
||||
|
||||
**Via pgAdmin 4:**
|
||||
1. Klik dua kali database `webgis_kemiskinan`
|
||||
2. Klik kanan → **Query Tool**
|
||||
3. Buka file: `database/schema.sql` (dari folder project)
|
||||
4. Klik tombol **▶ Execute** (F5)
|
||||
|
||||
**Via psql:**
|
||||
```bash
|
||||
# Pastikan Anda ada di folder project
|
||||
psql -U postgres -d webgis_kemiskinan -f database/schema.sql
|
||||
```
|
||||
|
||||
**Via Command Prompt:**
|
||||
```cmd
|
||||
cd C:\xampp\htdocs\webgis-kemiskinan
|
||||
"C:\Program Files\PostgreSQL\14\bin\psql.exe" -U postgres -d webgis_kemiskinan -f database\schema.sql
|
||||
```
|
||||
|
||||
### Langkah 3: Verifikasi Database
|
||||
|
||||
```sql
|
||||
-- Di pgAdmin Query Tool atau psql:
|
||||
\c webgis_kemiskinan
|
||||
|
||||
-- Cek tabel
|
||||
\dt
|
||||
|
||||
-- Output yang diharapkan:
|
||||
-- tabel_rumah_ibadah
|
||||
-- tabel_penduduk
|
||||
-- tabel_anggota_keluarga
|
||||
|
||||
-- Cek data dummy
|
||||
SELECT nama, jenis FROM tabel_rumah_ibadah;
|
||||
SELECT nama_kepala_keluarga FROM tabel_penduduk;
|
||||
|
||||
-- Cek PostGIS aktif
|
||||
SELECT PostGIS_version();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ SETUP BACKEND
|
||||
|
||||
### Langkah 1: Masuk ke Folder Backend
|
||||
|
||||
```bash
|
||||
cd webgis-kemiskinan\backend
|
||||
```
|
||||
|
||||
### Langkah 2: Install Dependensi
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Langkah 3: Konfigurasi Environment
|
||||
|
||||
Salin file `.env.example` menjadi `.env`:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
copy .env.example .env
|
||||
|
||||
# Linux/Mac
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit file `.env` sesuai konfigurasi Anda:
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database PostgreSQL
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=webgis_kemiskinan
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres123 ← GANTI dengan password Anda
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=uploads
|
||||
MAX_FILE_SIZE=5242880
|
||||
|
||||
# Nominatim
|
||||
NOMINATIM_URL=https://nominatim.openstreetmap.org
|
||||
NOMINATIM_USER_AGENT=WebGIS-Kemiskinan/1.0
|
||||
```
|
||||
|
||||
### Langkah 4: Buat Folder Upload
|
||||
|
||||
```bash
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
### Langkah 5: Test Koneksi
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Output yang diharapkan:
|
||||
```
|
||||
🚀 Server berjalan di http://localhost:5000
|
||||
✅ Terhubung ke PostgreSQL + PostGIS
|
||||
📡 Mode: development
|
||||
```
|
||||
|
||||
Test via browser atau Postman:
|
||||
```
|
||||
GET http://localhost:5000/api/health
|
||||
```
|
||||
Response: `{"status":"ok","timestamp":"..."}`
|
||||
|
||||
---
|
||||
|
||||
## 🎨 SETUP FRONTEND
|
||||
|
||||
### Langkah 1: Masuk ke Folder Frontend
|
||||
|
||||
Buka terminal/CMD **baru**:
|
||||
|
||||
```bash
|
||||
cd webgis-kemiskinan\frontend
|
||||
```
|
||||
|
||||
### Langkah 2: Install Dependensi
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Langkah 3: Jalankan Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
VITE v5.x.x ready in 800ms
|
||||
|
||||
➜ Local: http://localhost:3000/
|
||||
➜ Network: http://192.168.x.x:3000/
|
||||
```
|
||||
|
||||
### Langkah 4: Buka di Browser
|
||||
|
||||
Buka: **http://localhost:3000**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 MENJALANKAN APLIKASI (RINGKASAN)
|
||||
|
||||
Setiap kali ingin menjalankan aplikasi, buka **2 terminal terpisah**:
|
||||
|
||||
### Terminal 1 – Backend
|
||||
```bash
|
||||
cd webgis-kemiskinan\backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Terminal 2 – Frontend
|
||||
```bash
|
||||
cd webgis-kemiskinan\frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Lalu buka browser: **http://localhost:3000**
|
||||
|
||||
---
|
||||
|
||||
## 📁 STRUKTUR FOLDER
|
||||
|
||||
```
|
||||
webgis-kemiskinan/
|
||||
│
|
||||
├── 📂 database/
|
||||
│ └── schema.sql # Script SQL lengkap (jalankan pertama kali)
|
||||
│
|
||||
├── 📂 backend/
|
||||
│ ├── package.json
|
||||
│ ├── .env.example # Salin ke .env dan isi konfigurasi
|
||||
│ ├── .env # Konfigurasi lokal Anda (jangan di-commit)
|
||||
│ └── src/
|
||||
│ ├── server.js # Entry point Express
|
||||
│ ├── config/
|
||||
│ │ └── database.js # Koneksi PostgreSQL Pool
|
||||
│ ├── controllers/
|
||||
│ │ ├── rumahIbadahController.js # CRUD + geospasial ibadah
|
||||
│ │ ├── pendudukController.js # CRUD + auto-assign ibadah
|
||||
│ │ ├── geocodeController.js # Reverse geocoding Nominatim
|
||||
│ │ └── dashboardController.js # Statistik & agregasi
|
||||
│ ├── routes/
|
||||
│ │ ├── rumahIbadah.js
|
||||
│ │ ├── penduduk.js
|
||||
│ │ ├── geocode.js
|
||||
│ │ ├── upload.js
|
||||
│ │ └── dashboard.js
|
||||
│ ├── middleware/
|
||||
│ │ └── upload.js # Multer file upload config
|
||||
│ └── uploads/ # Folder penyimpanan file upload
|
||||
│
|
||||
└── 📂 frontend/
|
||||
├── package.json
|
||||
├── vite.config.js # Proxy API ke backend
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.jsx # React entry point
|
||||
├── App.jsx # Router + Layout utama
|
||||
├── index.css # Global styles (dark theme)
|
||||
├── services/
|
||||
│ └── api.js # Axios API client
|
||||
└── pages/
|
||||
├── DashboardPage.jsx # Statistik & grafik
|
||||
├── MapPage.jsx # Peta interaktif Leaflet
|
||||
├── RumahIbadahPage.jsx # CRUD rumah ibadah
|
||||
└── PendudukPage.jsx # CRUD penduduk miskin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API ENDPOINT REFERENCE
|
||||
|
||||
### Rumah Ibadah
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
|--------|----------|-----------|
|
||||
| GET | `/api/rumah-ibadah` | Semua rumah ibadah. Query: `?jenis=Masjid` |
|
||||
| GET | `/api/rumah-ibadah/:id` | Detail + statistik penduduk |
|
||||
| GET | `/api/rumah-ibadah/terdekat` | Query: `?lat=X&lng=Y&limit=5` |
|
||||
| POST | `/api/rumah-ibadah` | Tambah baru |
|
||||
| PUT | `/api/rumah-ibadah/:id` | Update |
|
||||
| DELETE | `/api/rumah-ibadah/:id` | Hapus |
|
||||
|
||||
### Penduduk Miskin
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
|--------|----------|-----------|
|
||||
| GET | `/api/penduduk` | Semua. Query: `?status_bantuan=true&limit=50` |
|
||||
| GET | `/api/penduduk/:id` | Detail + anggota keluarga |
|
||||
| POST | `/api/penduduk` | Tambah (multipart/form-data, auto-assign ibadah) |
|
||||
| PUT | `/api/penduduk/:id` | Update (multipart/form-data) |
|
||||
| PATCH | `/api/penduduk/:id/status` | Update status bantuan saja |
|
||||
| DELETE | `/api/penduduk/:id` | Hapus |
|
||||
|
||||
### Lainnya
|
||||
|
||||
| Method | Endpoint | Deskripsi |
|
||||
|--------|----------|-----------|
|
||||
| GET | `/api/geocode/reverse` | Query: `?lat=X&lng=Y` |
|
||||
| POST | `/api/upload` | Upload file (form field: `file`) |
|
||||
| GET | `/api/dashboard/statistik` | Data statistik dashboard |
|
||||
| GET | `/api/health` | Health check server |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 TROUBLESHOOTING
|
||||
|
||||
### ❌ Error: "ECONNREFUSED – Cannot connect to database"
|
||||
|
||||
**Penyebab:** PostgreSQL tidak berjalan atau password salah.
|
||||
|
||||
**Solusi:**
|
||||
1. Buka **Services** Windows (services.msc)
|
||||
2. Cari **postgresql-x64-14** → Klik **Start**
|
||||
3. Atau di Command Prompt: `net start postgresql-x64-14`
|
||||
4. Periksa password di file `.env`
|
||||
|
||||
---
|
||||
|
||||
### ❌ Error: "extension 'postgis' does not exist"
|
||||
|
||||
**Penyebab:** PostGIS belum terinstal atau belum diaktifkan di database.
|
||||
|
||||
**Solusi:**
|
||||
```sql
|
||||
-- Di pgAdmin, koneksi ke database webgis_kemiskinan
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Error: "npm: command not found"
|
||||
|
||||
**Penyebab:** Node.js belum terinstal atau belum ada di PATH.
|
||||
|
||||
**Solusi:**
|
||||
1. Download Node.js LTS dari https://nodejs.org
|
||||
2. Install dengan pilih "Add to PATH"
|
||||
3. Restart Command Prompt / terminal
|
||||
4. Test: `node --version` dan `npm --version`
|
||||
|
||||
---
|
||||
|
||||
### ❌ Error: "Port 5000 already in use"
|
||||
|
||||
**Solusi:**
|
||||
```cmd
|
||||
# Temukan proses yang menggunakan port 5000
|
||||
netstat -ano | findstr :5000
|
||||
|
||||
# Kill proses (ganti XXXX dengan PID)
|
||||
taskkill /PID XXXX /F
|
||||
```
|
||||
ATAU ganti port di `.env`: `PORT=5001`
|
||||
|
||||
---
|
||||
|
||||
### ❌ Peta tidak muncul (blank/grey)
|
||||
|
||||
**Penyebab:** Leaflet CSS tidak termuat dengan benar.
|
||||
|
||||
**Solusi:**
|
||||
Pastikan di file yang menggunakan Leaflet ada:
|
||||
```js
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❌ Reverse Geocoding gagal
|
||||
|
||||
**Penyebab:** Nominatim rate limit (max 1 request/detik) atau tidak ada internet.
|
||||
|
||||
**Solusi:**
|
||||
- Tunggu beberapa detik lalu coba kembali
|
||||
- Nominatim memerlukan koneksi internet aktif
|
||||
- Isi alamat secara manual jika offline
|
||||
|
||||
---
|
||||
|
||||
### ❌ Upload file gagal
|
||||
|
||||
**Penyebab:** Folder `uploads` tidak ada.
|
||||
|
||||
**Solusi:**
|
||||
```bash
|
||||
cd backend
|
||||
mkdir uploads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 INTEGRASI XAMPP (Apache sebagai Reverse Proxy Opsional)
|
||||
|
||||
Jika ingin mengakses via `http://localhost/webgis` (tanpa port):
|
||||
|
||||
1. Aktifkan **mod_proxy** di XAMPP:
|
||||
Edit `C:\xampp\apache\conf\httpd.conf`, hilangkan tanda `#` dari:
|
||||
```
|
||||
LoadModule proxy_module modules/mod_proxy.so
|
||||
LoadModule proxy_http_module modules/mod_proxy_http.so
|
||||
```
|
||||
|
||||
2. Tambahkan konfigurasi di `httpd-vhosts.conf`:
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ProxyPass /api http://localhost:5000/api
|
||||
ProxyPassReverse /api http://localhost:5000/api
|
||||
DocumentRoot "C:/xampp/htdocs/webgis-kemiskinan/frontend/dist"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
3. Build frontend: `cd frontend && npm run build`
|
||||
|
||||
4. Restart Apache di XAMPP Control Panel
|
||||
|
||||
---
|
||||
|
||||
## 📦 BUILD UNTUK PRODUCTION
|
||||
|
||||
### Frontend (Build Statis)
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
# Output: folder dist/ siap di-deploy
|
||||
```
|
||||
|
||||
### Backend (Jalankan Tanpa nodemon)
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
# Atau dengan PM2: pm2 start src/server.js --name webgis-backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 TIPS PENGGUNAAN
|
||||
|
||||
1. **Menambah Rumah Ibadah**: Masuk ke menu "Rumah Ibadah" → Klik "Tambah" → Klik peta untuk memilih lokasi → Alamat otomatis terisi → Simpan
|
||||
|
||||
2. **Menambah Penduduk**: Masuk ke menu "Penduduk Miskin" → Klik "Tambah" → Klik peta untuk memilih lokasi rumah → Sistem otomatis menentukan rumah ibadah terdekat
|
||||
|
||||
3. **Melihat di Peta**: Masuk ke menu "Peta Interaktif" → Gunakan filter untuk menyaring tampilan → Klik marker untuk melihat detail
|
||||
|
||||
4. **Update Status Bantuan**: Di tabel Penduduk, klik tombol ✓ untuk toggle status sudah/belum dibantu
|
||||
|
||||
5. **Lokasi Saya**: Di halaman peta, klik tombol kompas 📍 untuk menampilkan posisi Anda
|
||||
|
||||
---
|
||||
|
||||
## 📄 LISENSI
|
||||
|
||||
MIT License – Bebas digunakan untuk keperluan pendidikan dan sosial.
|
||||
|
||||
---
|
||||
|
||||
*Dibuat dengan ❤️ untuk membantu pemetaan dan penanganan kemiskinan di Indonesia*
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$query = mysqli_query($koneksi, "SELECT id, nama_spbu, no_wa, status_24_jam, lat, lng FROM `$nama_tabel`");
|
||||
$data = [];
|
||||
|
||||
while ($row = mysqli_fetch_assoc($query)) {
|
||||
$row['lat'] = floatval($row['lat']);
|
||||
$row['lng'] = floatval($row['lng']);
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode($data);
|
||||
?>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo "<style>body{font-family:monospace;padding:20px;background:#111;color:#0f0;} .ok{color:#0f0} .err{color:#f44} .warn{color:#fa0}</style>";
|
||||
echo "<h2>🔍 Cek Koneksi & Database SPBU</h2>";
|
||||
|
||||
if ($koneksi) {
|
||||
echo "<p class='ok'>✅ Koneksi ke MySQL berhasil</p>";
|
||||
} else {
|
||||
echo "<p class='err'>❌ Koneksi GAGAL: " . mysqli_connect_error() . "</p>"; exit;
|
||||
}
|
||||
|
||||
$db_cek = mysqli_query($koneksi, "SELECT DATABASE() AS db");
|
||||
$db_row = mysqli_fetch_assoc($db_cek);
|
||||
echo "<p class='ok'>✅ Database aktif: <b>{$db_row['db']}</b></p>";
|
||||
echo "<p class='ok'>✅ Nama tabel: <b>$nama_tabel</b></p>";
|
||||
|
||||
$tabel_cek = mysqli_query($koneksi, "SHOW TABLES LIKE '$nama_tabel'");
|
||||
if (mysqli_num_rows($tabel_cek) > 0) {
|
||||
echo "<p class='ok'>✅ Tabel <b>$nama_tabel</b> ditemukan</p>";
|
||||
} else {
|
||||
echo "<p class='err'>❌ Tabel <b>$nama_tabel</b> TIDAK ADA!</p>";
|
||||
echo "<p class='warn'>⚠️ Jalankan schema_mysql.sql di phpMyAdmin</p>"; exit;
|
||||
}
|
||||
|
||||
$count_cek = mysqli_query($koneksi, "SELECT COUNT(*) AS total FROM `$nama_tabel`");
|
||||
$count_row = mysqli_fetch_assoc($count_cek);
|
||||
echo "<p class='ok'>✅ Jumlah data: <b>{$count_row['total']}</b> baris</p>";
|
||||
|
||||
$test = mysqli_query($koneksi, "INSERT INTO `$nama_tabel` (nama_spbu, no_wa, status_24_jam, lat, lng) VALUES ('TEST_DELETE_ME', '000', 'no', 0, 0)");
|
||||
if ($test) {
|
||||
$ins_id = mysqli_insert_id($koneksi);
|
||||
mysqli_query($koneksi, "DELETE FROM `$nama_tabel` WHERE id = $ins_id");
|
||||
echo "<p class='ok'>✅ Test INSERT & DELETE berhasil — database siap digunakan!</p>";
|
||||
} else {
|
||||
echo "<p class='err'>❌ Test INSERT gagal: " . mysqli_error($koneksi) . "</p>";
|
||||
}
|
||||
echo "<hr><p>File ini bisa dihapus setelah tidak diperlukan.</p>";
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$id = mysqli_real_escape_string($koneksi, $_POST['id'] ?? '');
|
||||
$nama = mysqli_real_escape_string($koneksi, $_POST['nama_spbu'] ?? '');
|
||||
$wa = mysqli_real_escape_string($koneksi, $_POST['no_wa'] ?? '');
|
||||
$status = mysqli_real_escape_string($koneksi, $_POST['status_24_jam'] ?? 'no');
|
||||
|
||||
if (!$id || !$nama || !$wa) {
|
||||
echo "Gagal: Data tidak lengkap!";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "UPDATE `$nama_tabel` SET nama_spbu='$nama', no_wa='$wa', status_24_jam='$status' WHERE id='$id'";
|
||||
|
||||
if (mysqli_query($koneksi, $query)) {
|
||||
echo "Data SPBU berhasil diperbarui!";
|
||||
} else {
|
||||
echo "Gagal memperbarui data: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$id = mysqli_real_escape_string($koneksi, $_POST['id'] ?? '');
|
||||
|
||||
if (!$id) {
|
||||
echo "Gagal: ID tidak ditemukan!";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "DELETE FROM `$nama_tabel` WHERE id='$id'";
|
||||
|
||||
if (mysqli_query($koneksi, $query)) {
|
||||
echo "Data berhasil dihapus!";
|
||||
} else {
|
||||
echo "Gagal menghapus data: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
+790
@@ -0,0 +1,790 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS SPBU Pontianak</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- ✅ FIX 1: Leaflet CSS & JS dipindah ke <head> agar sudah siap sebelum script dijalankan -->
|
||||
<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>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0f14;
|
||||
--surface: #161923;
|
||||
--surface2: #1e2330;
|
||||
--border: rgba(255,255,255,0.07);
|
||||
--accent-green: #00e5a0;
|
||||
--accent-amber: #ffb830;
|
||||
--accent-blue: #3d8bff;
|
||||
--text: #e8ecf4;
|
||||
--text-muted: #6b7385;
|
||||
--danger: #ff4d6a;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ─── SIDEBAR ─── */
|
||||
#sidebar {
|
||||
width: 340px;
|
||||
min-width: 340px;
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 28px 24px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, rgba(61,139,255,0.12), rgba(0,229,160,0.06));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -40px; right: -40px;
|
||||
width: 130px; height: 130px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,229,160,0.15), transparent 70%);
|
||||
}
|
||||
.logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 40px; height: 40px;
|
||||
background: linear-gradient(135deg, var(--accent-green), var(--accent-blue));
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sidebar-header h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.sidebar-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 52px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stat-box {
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
padding: 12px 10px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.stat-box:hover { transform: translateY(-2px); }
|
||||
.stat-num {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-label { font-size: 10px; color: var(--text-muted); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-box.total .stat-num { color: var(--accent-blue); }
|
||||
.stat-box.jam24 .stat-num { color: var(--accent-green); }
|
||||
.stat-box.reguler .stat-num { color: var(--accent-amber); }
|
||||
|
||||
/* Layer Control Panel */
|
||||
.layer-panel {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.layer-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.layer-toggle:hover { background: var(--surface2); }
|
||||
.toggle-dot {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle-dot.green { background: var(--accent-green); box-shadow: 0 0 8px rgba(0,229,160,0.5); }
|
||||
.toggle-dot.amber { background: var(--accent-amber); box-shadow: 0 0 8px rgba(255,184,48,0.5); }
|
||||
.layer-toggle span { font-size: 13px; flex-grow: 1; }
|
||||
.toggle-count {
|
||||
font-size: 11px;
|
||||
background: var(--surface2);
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Custom Checkbox */
|
||||
.layer-toggle input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.layer-toggle input[type="checkbox"]:checked {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
.layer-toggle input[type="checkbox"]:checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
top: 0px; left: 2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* SPBU List */
|
||||
.list-header {
|
||||
padding: 14px 20px 8px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
#spbu-list {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 12px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--surface2) transparent;
|
||||
}
|
||||
.spbu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.spbu-item:hover {
|
||||
background: var(--surface2);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.spbu-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.spbu-dot.green { background: var(--accent-green); }
|
||||
.spbu-dot.amber { background: var(--accent-amber); }
|
||||
.spbu-item-name { font-size: 13px; font-weight: 500; flex-grow: 1; }
|
||||
.badge-pill {
|
||||
font-size: 9px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge-pill.green { background: rgba(0,229,160,0.15); color: var(--accent-green); }
|
||||
.badge-pill.amber { background: rgba(255,184,48,0.15); color: var(--accent-amber); }
|
||||
|
||||
/* ─── MAP ─── */
|
||||
#map-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
/* ✅ FIX 2: Pastikan tinggi eksplisit agar peta punya dimensi */
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
}
|
||||
#map {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Leaflet overrides for dark theme */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-popup-tip { background: var(--surface) !important; }
|
||||
.leaflet-popup-content { margin: 0 !important; }
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
border-radius: 10px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--surface) !important;
|
||||
color: var(--text) !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
}
|
||||
.leaflet-control-zoom a:hover { background: var(--surface2) !important; }
|
||||
|
||||
/* Popup Form */
|
||||
.popup-wrap { padding: 18px; min-width: 220px; }
|
||||
.popup-type {
|
||||
font-size: 9px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.popup-name {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.popup-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.popup-row strong { color: var(--text); }
|
||||
.popup-divider { height: 1px; background: var(--border); margin: 14px 0; }
|
||||
|
||||
.popup-input {
|
||||
width: 100%;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.popup-input:focus { border-color: var(--accent-blue); }
|
||||
|
||||
.popup-actions { display: flex; gap: 7px; margin-top: 12px; }
|
||||
.pop-btn {
|
||||
flex: 1;
|
||||
padding: 9px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.pop-btn:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
.pop-btn.blue { background: var(--accent-blue); color: white; }
|
||||
.pop-btn.amber { background: var(--accent-amber); color: #1a1000; }
|
||||
.pop-btn.red { background: var(--danger); color: white; }
|
||||
.pop-btn.gray { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
|
||||
/* Map hint badge */
|
||||
.map-hint {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(13,15,20,0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 30px;
|
||||
padding: 8px 18px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.map-hint span { color: var(--accent-blue); font-weight: 600; }
|
||||
|
||||
/* ✅ FIX 3: Loading overlay HTML ditambahkan */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.loading-overlay.hidden { opacity: 0; pointer-events: none; }
|
||||
.loader-ring {
|
||||
width: 50px; height: 50px;
|
||||
border: 3px solid var(--surface2);
|
||||
border-top-color: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Animations */
|
||||
.spbu-item { animation: fadeUp 0.3s ease backwards; }
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-row">
|
||||
<div class="logo-icon">⛽</div>
|
||||
<h1>SPBU Pontianak</h1>
|
||||
</div>
|
||||
<p>WebGIS — Sistem Informasi Geografis</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box total">
|
||||
<div class="stat-num" id="stat-total">–</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-box jam24">
|
||||
<div class="stat-num" id="stat-24">–</div>
|
||||
<div class="stat-label">24 Jam</div>
|
||||
</div>
|
||||
<div class="stat-box reguler">
|
||||
<div class="stat-num" id="stat-reg">–</div>
|
||||
<div class="stat-label">Reguler</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer-panel">
|
||||
<div class="panel-title">Layer Control</div>
|
||||
|
||||
<label class="layer-toggle">
|
||||
<div class="toggle-dot green"></div>
|
||||
<span>SPBU Buka 24 Jam</span>
|
||||
<span class="toggle-count" id="cnt-24">0</span>
|
||||
<input type="checkbox" id="chk-24" checked>
|
||||
</label>
|
||||
|
||||
<label class="layer-toggle">
|
||||
<div class="toggle-dot amber"></div>
|
||||
<span>SPBU Buka Reguler</span>
|
||||
<span class="toggle-count" id="cnt-reg">0</span>
|
||||
<input type="checkbox" id="chk-reg" checked>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="list-header">Daftar SPBU</div>
|
||||
<div id="spbu-list">
|
||||
<p style="text-align:center;color:var(--text-muted);font-size:13px;padding:20px;">Memuat data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-container">
|
||||
<!-- ✅ FIX 3: Loading overlay ditambahkan ke HTML -->
|
||||
<div class="loading-overlay" id="loading-overlay">
|
||||
<div class="loader-ring"></div>
|
||||
<p style="color:var(--text-muted);font-size:13px;">Memuat peta...</p>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div class="map-hint">Klik <span>di mana saja</span> pada peta untuk menambah SPBU baru</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ FIX 1: Script Leaflet DIHAPUS dari sini karena sudah dipindah ke <head> -->
|
||||
<script>
|
||||
|
||||
// ── MAP INIT ──
|
||||
// ✅ FIX 4: Inisialisasi map dibungkus dalam DOMContentLoaded agar aman
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
const map = L.map('map', { zoomControl: false }).setView([-0.026330, 109.342504], 13);
|
||||
|
||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||
|
||||
// ✅ FIX 5: Tile layer dengan 2 pilihan — CartoDB dark (utama) + OSM (fallback)
|
||||
const tileCartoDB = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_matter/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CartoDB</a>',
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
});
|
||||
|
||||
const tileOSM = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19
|
||||
});
|
||||
|
||||
// Coba CartoDB dulu, jika tile gagal load pakai OSM
|
||||
tileCartoDB.addTo(map);
|
||||
tileCartoDB.on('tileerror', function() {
|
||||
map.removeLayer(tileCartoDB);
|
||||
tileOSM.addTo(map);
|
||||
});
|
||||
|
||||
// ── LAYER GROUPS ──
|
||||
// Dibuat dulu, belum ditambah ke map — ditambah sesuai state checkbox
|
||||
const layer24 = L.layerGroup();
|
||||
const layerReg = L.layerGroup();
|
||||
|
||||
// ── EVENT LAYER CONTROL (FIX UTAMA) ──
|
||||
document.getElementById('chk-24').addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
layer24.addTo(map);
|
||||
} else {
|
||||
map.removeLayer(layer24);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('chk-reg').addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
layerReg.addTo(map);
|
||||
} else {
|
||||
map.removeLayer(layerReg);
|
||||
}
|
||||
});
|
||||
|
||||
// ── ICONS ──
|
||||
function makeIcon(color) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="42" viewBox="0 0 32 42">
|
||||
<defs>
|
||||
<filter id="s${color.replace('#','')}"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="${color}" flood-opacity="0.4"/></filter>
|
||||
</defs>
|
||||
<path fill="${color}" filter="url(#s${color.replace('#','')})" d="M16 0C7.163 0 0 7.163 0 16c0 12 16 26 16 26s16-14 16-26C32 7.163 24.837 0 16 0z"/>
|
||||
<circle cx="16" cy="16" r="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="16" cy="16" r="5" fill="${color}"/>
|
||||
</svg>`;
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [32, 42],
|
||||
iconAnchor: [16, 42],
|
||||
popupAnchor: [0, -42]
|
||||
});
|
||||
}
|
||||
|
||||
const iconGreen = makeIcon('#00e5a0');
|
||||
const iconAmber = makeIcon('#ffb830');
|
||||
const iconTemp = makeIcon('#3d8bff');
|
||||
|
||||
// ── DATA ──
|
||||
let allData = [];
|
||||
let tempMarker = null;
|
||||
|
||||
function muatData() {
|
||||
fetch('ambil_data.php')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
allData = data;
|
||||
renderAll(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// Demo mode jika tidak ada server
|
||||
renderAll(demoData());
|
||||
});
|
||||
}
|
||||
|
||||
function demoData() {
|
||||
return [
|
||||
{id:'1', nama_spbu:'SPBU Jl. Ahmad Yani', no_wa:'08123456789', status_24_jam:'yes', lat:-0.0202, lng:109.3423},
|
||||
{id:'2', nama_spbu:'SPBU Jl. Gajah Mada', no_wa:'08234567890', status_24_jam:'no', lat:-0.0280, lng:109.3310},
|
||||
{id:'3', nama_spbu:'SPBU Sui Raya', no_wa:'08345678901', status_24_jam:'yes', lat:-0.0450, lng:109.3580},
|
||||
{id:'4', nama_spbu:'SPBU Jl. Tanjungpura', no_wa:'08456789012', status_24_jam:'no', lat:-0.0120, lng:109.3390},
|
||||
{id:'5', nama_spbu:'SPBU Jl. Imam Bonjol', no_wa:'08567890123', status_24_jam:'yes', lat:-0.0330, lng:109.3500},
|
||||
{id:'6', nama_spbu:'SPBU Siantan', no_wa:'08678901234', status_24_jam:'no', lat:0.0050, lng:109.3350},
|
||||
];
|
||||
}
|
||||
|
||||
function renderAll(data) {
|
||||
layer24.clearLayers();
|
||||
layerReg.clearLayers();
|
||||
|
||||
let c24 = 0, cReg = 0;
|
||||
let listHTML = '';
|
||||
|
||||
data.forEach((item, i) => {
|
||||
const is24 = item.status_24_jam === 'yes';
|
||||
const icon = is24 ? iconGreen : iconAmber;
|
||||
const layer = is24 ? layer24 : layerReg;
|
||||
if (is24) c24++; else cReg++;
|
||||
|
||||
// Tambah marker ke layerGroup (bukan langsung ke map)
|
||||
const marker = L.marker([parseFloat(item.lat), parseFloat(item.lng)], { icon, draggable: true });
|
||||
layer.addLayer(marker);
|
||||
marker.bindPopup(buildInfoPopup(item), { maxWidth: 280 });
|
||||
|
||||
marker.on('dragend', function() {
|
||||
const pos = marker.getLatLng();
|
||||
if (confirm('Simpan koordinat baru?')) updateLokasi(item.id, pos.lat, pos.lng);
|
||||
else muatData();
|
||||
});
|
||||
|
||||
const delay = i * 40;
|
||||
listHTML += `
|
||||
<div class="spbu-item" style="animation-delay:${delay}ms" onclick="flyTo(${item.lat},${item.lng})">
|
||||
<div class="spbu-dot ${is24 ? 'green' : 'amber'}"></div>
|
||||
<span class="spbu-item-name">${item.nama_spbu}</span>
|
||||
<span class="badge-pill ${is24 ? 'green' : 'amber'}">${is24 ? '24 Jam' : 'Reguler'}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('stat-total').textContent = data.length;
|
||||
document.getElementById('stat-24').textContent = c24;
|
||||
document.getElementById('stat-reg').textContent = cReg;
|
||||
document.getElementById('cnt-24').textContent = c24;
|
||||
document.getElementById('cnt-reg').textContent = cReg;
|
||||
document.getElementById('spbu-list').innerHTML = listHTML || '<p style="text-align:center;color:var(--text-muted);font-size:13px;padding:20px;">Belum ada data.</p>';
|
||||
|
||||
// Terapkan state checkbox saat ini ke masing-masing layer
|
||||
const show24 = document.getElementById('chk-24').checked;
|
||||
const showReg = document.getElementById('chk-reg').checked;
|
||||
show24 ? layer24.addTo(map) : map.removeLayer(layer24);
|
||||
showReg ? layerReg.addTo(map) : map.removeLayer(layerReg);
|
||||
|
||||
// Sembunyikan loading overlay setelah data selesai dirender
|
||||
const overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
window.flyTo = function flyTo(lat, lng) {
|
||||
map.flyTo([lat, lng], 16, { duration: 1 });
|
||||
};
|
||||
|
||||
// ── POPUP BUILDERS ──
|
||||
function buildInfoPopup(item) {
|
||||
const is24 = item.status_24_jam === 'yes';
|
||||
return `
|
||||
<div class="popup-wrap">
|
||||
<div class="popup-type">Stasiun Bahan Bakar</div>
|
||||
<div class="popup-name">${item.nama_spbu}</div>
|
||||
<div class="popup-row">📞 <strong>${item.no_wa}</strong></div>
|
||||
<div class="popup-row">🕐 Status: <span class="badge-pill ${is24?'green':'amber'}" style="margin-left:4px">${is24?'Buka 24 Jam':'Buka Reguler'}</span></div>
|
||||
<div class="popup-divider"></div>
|
||||
<div class="popup-actions">
|
||||
<button class="pop-btn amber" onclick="bukaEdit('${item.id}','${item.nama_spbu}','${item.no_wa}','${item.status_24_jam}',${item.lat},${item.lng})">✏ Edit</button>
|
||||
<button class="pop-btn red" onclick="hapusData('${item.id}')">🗑 Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildAddPopup(lat, lng) {
|
||||
return `
|
||||
<div class="popup-wrap">
|
||||
<div class="popup-type">Tambah Lokasi Baru</div>
|
||||
<div class="popup-name" style="font-size:14px;margin-bottom:14px;">Data SPBU</div>
|
||||
<input class="popup-input" id="add_nama" placeholder="Nama SPBU" />
|
||||
<input class="popup-input" id="add_wa" placeholder="Nomor WhatsApp" />
|
||||
<select class="popup-input" id="add_status">
|
||||
<option value="no">Buka Reguler</option>
|
||||
<option value="yes">Buka 24 Jam</option>
|
||||
</select>
|
||||
<div class="popup-actions">
|
||||
<button class="pop-btn blue" onclick="prosesSimpan(${lat},${lng},'tambah')">💾 Simpan</button>
|
||||
<button class="pop-btn gray" onclick="batalTambah()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildEditPopup(id, nama, wa, status, lat, lng) {
|
||||
const sY = status==='yes'?'selected':'';
|
||||
const sN = status==='no'?'selected':'';
|
||||
return `
|
||||
<div class="popup-wrap">
|
||||
<div class="popup-type">Edit Data</div>
|
||||
<input type="hidden" id="edit_id" value="${id}">
|
||||
<input class="popup-input" id="edit_nama" value="${nama}">
|
||||
<input class="popup-input" id="edit_wa" value="${wa}">
|
||||
<select class="popup-input" id="edit_status">
|
||||
<option value="no" ${sN}>Buka Reguler</option>
|
||||
<option value="yes" ${sY}>Buka 24 Jam</option>
|
||||
</select>
|
||||
<div class="popup-actions">
|
||||
<button class="pop-btn blue" onclick="prosesSimpan(${lat},${lng},'edit')">✓ Update</button>
|
||||
<button class="pop-btn gray" onclick="map.closePopup()">✕ Batal</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── MAP CLICK – ADD ──
|
||||
map.on('click', function(e) {
|
||||
if (tempMarker) map.removeLayer(tempMarker);
|
||||
tempMarker = L.marker([e.latlng.lat, e.latlng.lng], { icon: iconTemp }).addTo(map);
|
||||
tempMarker.bindPopup(buildAddPopup(e.latlng.lat, e.latlng.lng), { maxWidth: 280 }).openPopup();
|
||||
});
|
||||
|
||||
window.batalTambah = function() {
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
map.closePopup();
|
||||
};
|
||||
|
||||
// ── EDIT ──
|
||||
window.bukaEdit = function(id, nama, wa, status, lat, lng) {
|
||||
L.popup({ maxWidth: 280 })
|
||||
.setLatLng([lat, lng])
|
||||
.setContent(buildEditPopup(id, nama, wa, status, lat, lng))
|
||||
.openOn(map);
|
||||
};
|
||||
|
||||
// ── SIMPAN (POST) ──
|
||||
window.prosesSimpan = function prosesSimpan(lat, lng, mode) {
|
||||
const pfx = mode === 'edit' ? 'edit_' : 'add_';
|
||||
const id = mode === 'edit' ? document.getElementById('edit_id').value : '';
|
||||
const nama = document.getElementById(pfx + 'nama').value.trim();
|
||||
const wa = document.getElementById(pfx + 'wa').value.trim();
|
||||
const stat = document.getElementById(pfx + 'status').value;
|
||||
|
||||
if (!nama || !wa) return alert('Nama dan nomor WA wajib diisi!');
|
||||
|
||||
const fd = new FormData();
|
||||
if (mode === 'edit') fd.append('id', id);
|
||||
fd.append('nama_spbu', nama);
|
||||
fd.append('no_wa', wa);
|
||||
fd.append('status_24_jam', stat);
|
||||
fd.append('lat', lat);
|
||||
fd.append('lng', lng);
|
||||
|
||||
const url = mode === 'edit' ? 'edit_data.php' : 'simpan_data.php';
|
||||
|
||||
fetch(url, { method: 'POST', body: fd })
|
||||
.then(r => r.text())
|
||||
.then(msg => {
|
||||
showToast(msg, 'success');
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
muatData();
|
||||
})
|
||||
.catch(() => {
|
||||
// Demo mode: update state lokal agar data langsung tampil tanpa server
|
||||
if (mode === 'tambah') {
|
||||
const newItem = {
|
||||
id: Date.now().toString(),
|
||||
nama_spbu: nama,
|
||||
no_wa: wa,
|
||||
status_24_jam: stat,
|
||||
lat: lat,
|
||||
lng: lng
|
||||
};
|
||||
allData.push(newItem);
|
||||
} else {
|
||||
const idx = allData.findIndex(d => d.id === id);
|
||||
if (idx !== -1) {
|
||||
allData[idx].nama_spbu = nama;
|
||||
allData[idx].no_wa = wa;
|
||||
allData[idx].status_24_jam = stat;
|
||||
}
|
||||
}
|
||||
renderAll(allData);
|
||||
showToast(mode === 'tambah' ? 'Data berhasil disimpan! (demo mode)' : 'Data berhasil diperbarui! (demo mode)', 'success');
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── HAPUS ──
|
||||
window.hapusData = function hapusData(id) {
|
||||
if (!confirm('Hapus data SPBU ini?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fetch('hapus_data.php', { method: 'POST', body: fd })
|
||||
.then(r => r.text())
|
||||
.then(msg => { showToast(msg, 'danger'); muatData(); })
|
||||
.catch(() => {
|
||||
allData = allData.filter(d => d.id !== id);
|
||||
renderAll(allData);
|
||||
showToast('Data dihapus! (demo mode)', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// ── UPDATE LOKASI ──
|
||||
window.updateLokasi = function updateLokasi(id, lat, lng) {
|
||||
const fd = new FormData();
|
||||
fd.append('id', id); fd.append('lat', lat); fd.append('lng', lng);
|
||||
fetch('update_lokasi.php', { method: 'POST', body: fd })
|
||||
.then(() => { showToast('Lokasi diperbarui!', 'success'); muatData(); })
|
||||
.catch(() => { showToast('Lokasi diperbarui! (demo mode)', 'success'); muatData(); });
|
||||
}
|
||||
|
||||
// ── TOAST ──
|
||||
function showToast(msg, type) {
|
||||
const t = document.createElement('div');
|
||||
t.style.cssText = `
|
||||
position:fixed; bottom:30px; right:30px; z-index:9999;
|
||||
background:${type==='success'?'#00e5a0':'#ff4d6a'};
|
||||
color:${type==='success'?'#001a0d':'#fff'};
|
||||
padding:12px 20px; border-radius:10px; font-size:13px; font-weight:600;
|
||||
font-family:'Syne',sans-serif; box-shadow:0 8px 30px rgba(0,0,0,0.4);
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
// ── START ──
|
||||
muatData();
|
||||
|
||||
// ✅ FIX 6: invalidateSize dipanggil beberapa kali untuk memastikan peta terrender sempurna
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
setTimeout(() => map.invalidateSize(), 500);
|
||||
|
||||
}); // tutup DOMContentLoaded
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
$host = getenv('DB_HOST') ?: 'localhost';
|
||||
$user = getenv('DB_USER') ?: 'root';
|
||||
$pass = getenv('DB_PASSWORD') ?: '';
|
||||
|
||||
$nama_database = getenv('DB_NAME') ?: 'spbulayer';
|
||||
|
||||
$nama_tabel = "tbl_spbu";
|
||||
|
||||
$koneksi = mysqli_connect($host, $user, $pass, $nama_database);
|
||||
|
||||
// Cek koneksi agar tidak muncul error Fatal lagi
|
||||
if (!$koneksi) {
|
||||
die("Koneksi ke database gagal: " . mysqli_connect_error());
|
||||
}
|
||||
?>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "spbu' - Copy",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ============================================================
|
||||
-- Schema MySQL untuk WebGIS SPBU Pontianak
|
||||
-- ============================================================
|
||||
-- CARA PAKAI:
|
||||
-- 1. Buka phpMyAdmin
|
||||
-- 2. Pilih database "data_spbu" (atau buat baru jika belum ada)
|
||||
-- 3. Klik tab "SQL"
|
||||
-- 4. Copy-paste seluruh isi file ini, lalu klik "Go"
|
||||
-- ============================================================
|
||||
|
||||
-- CATATAN: nama tabel sengaja dibuat "tbl_spbu" (bukan "data_spbu")
|
||||
-- karena MySQL tidak boleh memiliki nama tabel sama dengan nama database
|
||||
-- (menyebabkan error errno: 121)
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS `data_spbu`
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE `data_spbu`;
|
||||
|
||||
-- Hapus tabel lama jika ada
|
||||
DROP TABLE IF EXISTS `tbl_spbu`;
|
||||
DROP TABLE IF EXISTS `data_spbu`;
|
||||
|
||||
-- Buat tabel dengan nama tbl_spbu
|
||||
CREATE TABLE `tbl_spbu` (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
nama_spbu VARCHAR(255) NOT NULL,
|
||||
no_wa VARCHAR(20) NOT NULL,
|
||||
status_24_jam ENUM('yes','no') DEFAULT 'no',
|
||||
lat DOUBLE NOT NULL,
|
||||
lng DOUBLE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Data contoh
|
||||
INSERT INTO `tbl_spbu` (nama_spbu, no_wa, status_24_jam, lat, lng) VALUES
|
||||
('SPBU Jl. Ahmad Yani', '08123456789', 'yes', -0.0202, 109.3423),
|
||||
('SPBU Jl. Gajah Mada', '08234567890', 'no', -0.0280, 109.3310),
|
||||
('SPBU Sui Raya', '08345678901', 'yes', -0.0450, 109.3580),
|
||||
('SPBU Jl. Tanjungpura', '08456789012', 'no', -0.0120, 109.3390),
|
||||
('SPBU Jl. Imam Bonjol', '08567890123', 'yes', -0.0330, 109.3500),
|
||||
('SPBU Siantan', '08678901234', 'no', 0.0050, 109.3350);
|
||||
|
||||
-- Cek hasil
|
||||
SELECT 'Tabel tbl_spbu berhasil dibuat!' AS status, COUNT(*) AS jumlah_data FROM `tbl_spbu`;
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$nama = mysqli_real_escape_string($koneksi, $_POST['nama_spbu'] ?? '');
|
||||
$wa = mysqli_real_escape_string($koneksi, $_POST['no_wa'] ?? '');
|
||||
$status = mysqli_real_escape_string($koneksi, $_POST['status_24_jam'] ?? 'no');
|
||||
$lat = floatval($_POST['lat'] ?? 0);
|
||||
$lng = floatval($_POST['lng'] ?? 0);
|
||||
|
||||
if (!$nama || !$wa) {
|
||||
echo "Gagal: Nama dan nomor WA wajib diisi!";
|
||||
exit;
|
||||
}
|
||||
|
||||
$query = "INSERT INTO `$nama_tabel` (nama_spbu, no_wa, status_24_jam, lat, lng)
|
||||
VALUES ('$nama', '$wa', '$status', '$lat', '$lng')";
|
||||
|
||||
if (mysqli_query($koneksi, $query)) {
|
||||
echo "Data SPBU berhasil ditambahkan!";
|
||||
} else {
|
||||
echo "Gagal menambahkan data: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
include 'koneksi.php';
|
||||
|
||||
$id = mysqli_real_escape_string($koneksi, $_POST['id']);
|
||||
$lat = mysqli_real_escape_string($koneksi, $_POST['lat']);
|
||||
$lng = mysqli_real_escape_string($koneksi, $_POST['lng']);
|
||||
|
||||
$query = "UPDATE $nama_tabel SET lat='$lat', lng='$lng' WHERE id='$id'";
|
||||
|
||||
if(mysqli_query($koneksi, $query)) {
|
||||
echo "Lokasi berhasil dipindahkan!";
|
||||
} else {
|
||||
echo "Gagal memindahkan lokasi: " . mysqli_error($koneksi);
|
||||
}
|
||||
?>
|
||||
Reference in New Issue
Block a user