Files
WEBGIS_Project/frontend/index.html
T
tistopandita 08b84fea98 first commit
2026-06-10 20:41:25 +07:00

1408 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossorigin=""
/>
<style>
:root {
--bg-dark: #0f172a;
--bg-panel: #111827;
--bg-soft: #1f2937;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--border: rgba(148, 163, 184, 0.22);
--accent: #2563eb;
--accent-light: #38bdf8;
--open: #16a34a;
--closed: #dc2626;
--warning: #f59e0b;
--map-bg: #eef2f7;
--shadow: 0 18px 45px rgba(15, 23, 42, 0.24);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", system-ui, sans-serif;
color: var(--text-main);
background: var(--map-bg);
overflow: hidden;
}
button,
input,
select {
font: inherit;
}
.app {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
height: 100vh;
width: 100vw;
}
/* ── Sidebar ── */
.sidebar {
position: relative;
z-index: 900;
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
background:
radial-gradient(
circle at top left,
rgba(37, 99, 235, 0.24),
transparent 36%
),
linear-gradient(180deg, var(--bg-dark), var(--bg-panel));
box-shadow: var(--shadow);
overflow-y: auto;
}
.brand {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.brand h1 {
margin: 0;
font-size: 1.45rem;
line-height: 1.15;
letter-spacing: -0.04em;
}
.brand p {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.45;
}
.brand-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.icon-btn {
display: inline-grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid var(--border);
border-radius: 14px;
color: var(--text-main);
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: 0.2s ease;
text-decoration: none;
font-size: 1rem;
}
.icon-btn:hover {
border-color: rgba(56, 189, 248, 0.75);
background: rgba(37, 99, 235, 0.32);
}
.mobile-toggle {
display: none;
}
/* ── Summary cards ── */
.summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.summary-card {
padding: 14px;
border: 1px solid var(--border);
border-radius: 18px;
background: rgba(15, 23, 42, 0.62);
}
.summary-card span {
display: block;
color: var(--text-muted);
font-size: 0.77rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.summary-card strong {
display: block;
margin-top: 5px;
font-size: 1.8rem;
line-height: 1;
}
/* ── Panel sections ── */
.panel-section {
padding: 16px;
border: 1px solid var(--border);
border-radius: 20px;
background: rgba(17, 24, 39, 0.76);
backdrop-filter: blur(16px);
}
.panel-section h2 {
margin: 0 0 12px;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: #dbeafe;
}
.field {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.field:last-child {
margin-bottom: 0;
}
label {
color: var(--text-muted);
font-size: 0.84rem;
font-weight: 600;
}
input[type="text"],
select {
width: 100%;
min-height: 42px;
border: 1px solid var(--border);
border-radius: 13px;
padding: 9px 11px;
color: var(--text-main);
background: rgba(15, 23, 42, 0.88);
outline: none;
}
input[type="text"]:focus,
select:focus {
border-color: var(--accent-light);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.16);
}
.fuel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.check-row {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 9px;
border: 1px solid var(--border);
border-radius: 13px;
background: rgba(15, 23, 42, 0.5);
color: #e5e7eb;
font-size: 0.88rem;
}
.check-row input {
accent-color: var(--accent);
}
/* ── Toggle switch ── */
.switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #e5e7eb;
font-size: 0.88rem;
min-height: 44px;
padding: 10px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
}
.switch-row:last-child {
border-bottom: 0;
}
.switch-row .row-label {
display: flex;
align-items: center;
gap: 8px;
}
.switch {
position: relative;
width: 48px;
height: 26px;
flex: 0 0 auto;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
inset: 0;
border-radius: 999px;
background: #475569;
cursor: pointer;
transition: 0.2s ease;
}
.slider::before {
content: "";
position: absolute;
width: 20px;
height: 20px;
left: 3px;
top: 3px;
border-radius: 50%;
background: white;
transition: 0.2s ease;
}
.switch input:checked + .slider {
background: var(--accent);
}
.switch input:checked + .slider::before {
transform: translateX(22px);
}
/* Layer legend dots */
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
}
.layer-message {
display: none;
margin-top: 10px;
padding: 10px;
border-radius: 12px;
color: #dbeafe;
background: rgba(37, 99, 235, 0.16);
font-size: 0.82rem;
line-height: 1.35;
}
.layer-message.is-visible {
display: block;
}
/* ── Map ── */
#map {
width: 100%;
height: 100vh;
background: var(--map-bg);
}
/* ── Toast ── */
.toast {
position: fixed;
z-index: 1300;
top: 18px;
left: 50%;
transform: translate(-50%, -16px);
display: flex;
align-items: center;
gap: 10px;
max-width: calc(100vw - 32px);
padding: 12px 16px;
border-radius: 999px;
color: #0f172a;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow);
font-weight: 800;
opacity: 0;
pointer-events: none;
transition: 0.28s ease;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.toast.error {
background: #fee2e2;
color: #7f1d1d;
}
/* ── Admin Override Panel ── */
.admin-panel {
display: none;
position: fixed;
z-index: 1250;
right: 22px;
top: 76px;
width: min(720px, calc(100vw - 44px));
max-height: calc(100vh - 110px);
overflow: auto;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 22px;
background: rgba(255, 255, 255, 0.96);
color: #0f172a;
box-shadow: var(--shadow);
}
.admin-panel.is-open {
display: block;
}
.admin-header {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid #e5e7eb;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
}
.admin-header h2 {
margin: 0;
font-size: 1rem;
}
.close-admin {
width: 34px;
height: 34px;
border: 0;
border-radius: 10px;
background: #e5e7eb;
cursor: pointer;
font-weight: 900;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.admin-table th,
.admin-table td {
padding: 12px 14px;
border-bottom: 1px solid #e5e7eb;
text-align: left;
vertical-align: middle;
}
.admin-table th {
color: #475569;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.08em;
background: #f8fafc;
}
.admin-table select {
min-width: 148px;
color: #0f172a;
background: white;
border-color: #cbd5e1;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 999px;
padding: 5px 9px;
color: white;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.badge.open {
background: var(--open);
}
.badge.closed {
background: var(--closed);
}
.badge.warning {
color: #422006;
background: var(--warning);
}
.fuel-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.fuel-badge {
display: inline-flex;
border-radius: 999px;
padding: 4px 8px;
color: #0f172a;
background: #dbeafe;
font-size: 0.72rem;
font-weight: 800;
}
.fuel-badge.solar,
.fuel-badge.dexlite,
.fuel-badge.pertamina-dex {
background: #dcfce7;
}
.fuel-badge.pertamax,
.fuel-badge.pertamax-turbo {
background: #fee2e2;
}
/* ── Popup ── */
.popup-card {
min-width: 230px;
color: #0f172a;
}
.popup-card h3 {
margin: 0 0 5px;
font-size: 1rem;
}
.popup-card p {
margin: 0 0 8px;
color: #475569;
line-height: 1.35;
}
.popup-meta {
display: grid;
gap: 8px;
margin: 10px 0;
}
.direction-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 38px;
margin-top: 10px;
border-radius: 11px;
color: white !important;
background: var(--accent);
font-weight: 800;
text-decoration: none;
}
/* ── SPBU marker ── */
.spbu-marker {
position: relative;
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 3px solid white;
border-radius: 50% 50% 50% 10%;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.28);
transform: rotate(-45deg);
}
.spbu-marker span {
transform: rotate(45deg);
font-size: 20px;
}
.spbu-marker.open {
background: var(--open);
}
.spbu-marker.closed {
background: linear-gradient(135deg, var(--closed), #64748b);
}
.spbu-marker.is-warning::after {
content: "!";
position: absolute;
top: -10px;
right: -10px;
display: grid;
width: 20px;
height: 20px;
place-items: center;
border: 2px solid white;
border-radius: 50%;
color: #422006;
background: var(--warning);
font-size: 12px;
font-weight: 900;
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper {
border-radius: 16px;
}
.leaflet-control-layers {
border: 0 !important;
border-radius: 14px !important;
box-shadow: var(--shadow) !important;
overflow: hidden;
}
/* ── Loading ── */
.loading-overlay {
position: absolute;
inset: 0;
z-index: 800;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(4px);
}
.loading-spinner {
padding: 20px 32px;
border-radius: 16px;
background: var(--bg-panel);
color: var(--text-main);
font-weight: 600;
font-size: 0.95rem;
}
/* ── Mobile ── */
@media (max-width: 860px) {
body {
overflow: hidden;
}
.app {
display: block;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: min(88vw, 370px);
transform: translateX(-101%);
transition: transform 0.24s ease;
}
.sidebar.is-open {
transform: translateX(0);
}
.mobile-toggle {
position: fixed;
z-index: 1200;
top: 14px;
left: 14px;
display: inline-grid;
background: rgba(15, 23, 42, 0.9);
}
#map {
height: 100vh;
}
.toast {
top: 66px;
}
.fuel-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<button
class="icon-btn mobile-toggle"
id="mobileToggle"
type="button"
aria-label="Buka panel"
>
</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<main class="app">
<aside class="sidebar" id="sidebar">
<div class="brand">
<div>
<h1>WebGIS SPBU Pontianak</h1>
<p>
Pemetaan status operasional dan ketersediaan BBM SPBU di Kota
Pontianak.
</p>
</div>
<div class="brand-actions">
<a class="icon-btn" href="/admin.html" title="Kelola data">✏️</a>
<button
class="icon-btn"
id="adminToggle"
type="button"
title="Panel override status"
>
</button>
</div>
</div>
<section class="summary" aria-label="Ringkasan status SPBU">
<div class="summary-card">
<span>Sedang Buka</span><strong id="openCount"></strong>
</div>
<div class="summary-card">
<span>Sedang Tutup</span><strong id="closedCount"></strong>
</div>
</section>
<section class="panel-section">
<h2>Filter SPBU</h2>
<div class="field">
<label for="statusFilter">Status operasional</label>
<select id="statusFilter">
<option value="all">Tampilkan Semua</option>
<option value="open">Buka Saja</option>
<option value="closed">Tutup Saja</option>
</select>
</div>
<div class="field">
<label>Jenis BBM</label>
<div class="fuel-grid" id="fuelFilters"></div>
</div>
</section>
<section class="panel-section">
<h2>Pencarian</h2>
<div class="field">
<label for="spbuSearch">Cari nama atau alamat SPBU</label>
<input
id="spbuSearch"
type="text"
placeholder="Contoh: Ahmad Yani, Gajah Mada..."
autocomplete="off"
/>
</div>
</section>
<section class="panel-section">
<h2>Layer Peta</h2>
<!-- OSM basemap -->
<div class="switch-row">
<span class="row-label">
<span
class="legend-dot"
style="background: #94a3b8; border: 2px solid #64748b"
></span>
Peta dasar OSM
</span>
<label class="switch"
><input id="roadToggle" type="checkbox" checked /><span
class="slider"
></span
></label>
</div>
<!-- Marker SPBU -->
<div class="switch-row">
<span class="row-label">
<span class="legend-dot" style="background: #16a34a"></span>
Marker SPBU
</span>
<label class="switch"
><input id="spbuToggle" type="checkbox" checked /><span
class="slider"
></span
></label>
</div>
<!-- Jalan dari DB -->
<div class="switch-row">
<span class="row-label">
<span class="legend-dot" style="background: #2563eb"></span>
Jaringan Jalan (DB)
</span>
<label class="switch"
><input id="jalanToggle" type="checkbox" /><span
class="slider"
></span
></label>
</div>
<!-- Bangunan dari DB -->
<div class="switch-row">
<span class="row-label">
<span
class="legend-dot"
style="background: #16a34a; border-radius: 3px"
></span>
Bangunan (DB)
</span>
<label class="switch"
><input id="bangunanToggle" type="checkbox" /><span
class="slider"
></span
></label>
</div>
<div class="layer-message" id="layerMessage"></div>
<!-- Legenda jalan (ditampilkan kalau layer jalan aktif) -->
<div
id="jalanLegend"
style="
display: none;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
"
>
<div
style="
font-size: 0.78rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 8px;
"
>
Legenda Jalan
</div>
<div
style="
display: grid;
gap: 6px;
font-size: 0.82rem;
color: #e5e7eb;
"
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 28px;
height: 4px;
background: #dc2626;
border-radius: 2px;
display: inline-block;
"
></span>
Nasional</span
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 28px;
height: 4px;
background: #ea580c;
border-radius: 2px;
display: inline-block;
"
></span>
Provinsi</span
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 28px;
height: 4px;
background: #2563eb;
border-radius: 2px;
display: inline-block;
"
></span>
Kota/Kabupaten</span
>
</div>
</div>
<!-- Legenda bangunan (ditampilkan kalau layer bangunan aktif) -->
<div
id="bangunanLegend"
style="
display: none;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
"
>
<div
style="
font-size: 0.78rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.07em;
margin-bottom: 8px;
"
>
Legenda Bangunan
</div>
<div
style="
display: grid;
gap: 6px;
font-size: 0.82rem;
color: #e5e7eb;
"
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 14px;
height: 14px;
background: #16a34a;
border-radius: 3px;
display: inline-block;
opacity: 0.7;
"
></span>
SHM</span
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 14px;
height: 14px;
background: #7c3aed;
border-radius: 3px;
display: inline-block;
opacity: 0.7;
"
></span>
HGB</span
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 14px;
height: 14px;
background: #b45309;
border-radius: 3px;
display: inline-block;
opacity: 0.7;
"
></span>
HGU</span
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 14px;
height: 14px;
background: #0891b2;
border-radius: 3px;
display: inline-block;
opacity: 0.7;
"
></span>
HP</span
>
<span style="display: flex; align-items: center; gap: 8px"
><span
style="
width: 14px;
height: 14px;
background: #be185d;
border-radius: 3px;
display: inline-block;
opacity: 0.7;
"
></span>
HPL</span
>
</div>
</div>
</section>
</aside>
<section id="map" aria-label="Peta SPBU Pontianak">
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner">⏳ Memuat data SPBU...</div>
</div>
</section>
</main>
<!-- Override Status Panel -->
<section
class="admin-panel"
id="adminPanel"
aria-label="Panel override status"
>
<div class="admin-header">
<h2>Panel Override Status SPBU</h2>
<button class="close-admin" id="closeAdmin" type="button">×</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Nama SPBU</th>
<th>Status Auto</th>
<th>Override</th>
</tr>
</thead>
<tbody id="adminTableBody"></tbody>
</table>
</section>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
crossorigin=""
></script>
<script>
// ─── Config ───────────────────────────────────────────────────────────────
const API_BASE = "/api";
const fuelTypes = [
"Pertalite",
"Pertamax",
"Pertamax Turbo",
"Solar",
"Dexlite",
"Pertamina Dex",
];
// Warna jalan per jenis
const JALAN_STYLE = {
nasional: { color: "#dc2626", weight: 5, opacity: 0.85 },
provinsi: { color: "#ea580c", weight: 4, opacity: 0.85 },
kota_kabupaten: { color: "#2563eb", weight: 3, opacity: 0.85 },
};
// Warna bangunan per jenis hak
const BANGUNAN_STYLE = {
SHM: {
color: "#16a34a",
fillColor: "#86efac",
weight: 2,
fillOpacity: 0.35,
},
HGB: {
color: "#7c3aed",
fillColor: "#c4b5fd",
weight: 2,
fillOpacity: 0.35,
},
HGU: {
color: "#b45309",
fillColor: "#fcd34d",
weight: 2,
fillOpacity: 0.35,
},
HP: {
color: "#0891b2",
fillColor: "#67e8f9",
weight: 2,
fillOpacity: 0.35,
},
HPL: {
color: "#be185d",
fillColor: "#fbcfe8",
weight: 2,
fillOpacity: 0.35,
},
};
// ─── State ────────────────────────────────────────────────────────────────
const state = {
spbuData: [],
markers: new Map(),
showSpbu: true,
};
// ─── Map setup ────────────────────────────────────────────────────────────
const map = L.map("map").setView([-0.0263, 109.3425], 13);
const osmLayer = L.tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
maxZoom: 19,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
).addTo(map);
const spbuLayer = L.layerGroup().addTo(map);
const jalanLayerGrp = L.layerGroup(); // Jalan dari DB
const bangunanLayerGrp = L.layerGroup(); // Bangunan dari DB
const el = {
adminPanel: document.getElementById("adminPanel"),
adminTableBody: document.getElementById("adminTableBody"),
adminToggle: document.getElementById("adminToggle"),
closeAdmin: document.getElementById("closeAdmin"),
jalanToggle: document.getElementById("jalanToggle"),
bangunanToggle: document.getElementById("bangunanToggle"),
closedCount: document.getElementById("closedCount"),
fuelFilters: document.getElementById("fuelFilters"),
layerMessage: document.getElementById("layerMessage"),
loadingOverlay: document.getElementById("loadingOverlay"),
mobileToggle: document.getElementById("mobileToggle"),
openCount: document.getElementById("openCount"),
roadToggle: document.getElementById("roadToggle"),
sidebar: document.getElementById("sidebar"),
spbuSearch: document.getElementById("spbuSearch"),
spbuToggle: document.getElementById("spbuToggle"),
statusFilter: document.getElementById("statusFilter"),
toast: document.getElementById("toast"),
jalanLegend: document.getElementById("jalanLegend"),
bangunanLegend: document.getElementById("bangunanLegend"),
};
// ─── API ──────────────────────────────────────────────────────────────────
async function fetchSpbu() {
const res = await fetch(`${API_BASE}/spbu`);
const json = await res.json();
if (!json.success) throw new Error(json.message);
return json.data;
}
async function fetchJalan() {
const res = await fetch(`${API_BASE}/jalan`);
const json = await res.json();
if (!json.success) throw new Error(json.message);
return json.data;
}
async function fetchBangunan() {
const res = await fetch(`${API_BASE}/bangunan`);
const json = await res.json();
if (!json.success) throw new Error(json.message);
return json.data;
}
async function patchOverride(id, value) {
const res = await fetch(`${API_BASE}/spbu/${id}/override`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ manual_override: value }),
});
const json = await res.json();
if (!json.success) throw new Error(json.message);
}
// ─── Time helpers ─────────────────────────────────────────────────────────
function getWibMinutes() {
const d = new Date();
const wib = new Date(
d.getTime() + d.getTimezoneOffset() * 60000 + 7 * 3600000,
);
return wib.getHours() * 60 + wib.getMinutes();
}
function parseMin(t) {
const [h, m] = t.split(":").map(Number);
return h * 60 + m;
}
function isOpen(spbu, now = getWibMinutes()) {
const o = parseMin(spbu.jam_buka),
c = parseMin(spbu.jam_tutup);
return o <= c ? now >= o && now <= c : now >= o || now <= c;
}
function getAutoStatus(spbu) {
return isOpen(spbu) ? "open" : "closed";
}
function getStatus(spbu) {
if (spbu.manual_override === "open") return "open";
if (spbu.manual_override === "closed") return "closed";
return getAutoStatus(spbu);
}
function minUntilClose(spbu) {
if (getStatus(spbu) !== "open" || spbu.manual_override === "open")
return null;
const now = getWibMinutes(),
o = parseMin(spbu.jam_buka),
c = parseMin(spbu.jam_tutup);
let diff = c - now;
if (o > c && now >= o) diff = c + 1440 - now;
if (o > c && now <= c) diff = c - now;
return diff >= 0 ? diff : null;
}
function closeSoon(spbu) {
const m = minUntilClose(spbu);
return m !== null && m <= 60;
}
// ─── Map helpers ──────────────────────────────────────────────────────────
function slugFuel(n) {
return n.toLowerCase().replace(/\s+/g, "-");
}
function createIcon(spbu) {
const s = getStatus(spbu),
w = closeSoon(spbu) ? "is-warning" : "";
return L.divIcon({
className: "",
html: `<div class="spbu-marker ${s} ${w}"><span>⛽</span></div>`,
iconSize: [40, 40],
iconAnchor: [20, 38],
popupAnchor: [0, -34],
});
}
function createPopup(spbu) {
const s = getStatus(spbu);
const fuels = spbu.jenis_bbm
.map((f) => `<span class="fuel-badge ${slugFuel(f)}">${f}</span>`)
.join("");
const dirUrl = `https://www.google.com/maps/dir/?api=1&destination=${spbu.lat},${spbu.lng}&travelmode=driving`;
return `<div class="popup-card">
<h3>${spbu.nama}</h3>
<p>${spbu.alamat}</p>
<div class="popup-meta">
<span class="badge ${s}">${s === "open" ? "BUKA" : "TUTUP"}</span>
${closeSoon(spbu) ? '<span class="badge warning">Tutup ≤ 1 Jam</span>' : ""}
<strong>Jam operasional: ${spbu.jam_buka}${spbu.jam_tutup} WIB</strong>
</div>
<div class="fuel-badges">${fuels}</div>
<a class="direction-btn" href="${dirUrl}" target="_blank" rel="noopener">Petunjuk Arah</a>
</div>`;
}
// ─── Filters ──────────────────────────────────────────────────────────────
function getActiveFuels() {
return [...el.fuelFilters.querySelectorAll("input:checked")].map(
(i) => i.value,
);
}
function matchFilter(spbu) {
const sf = el.statusFilter.value;
const search = el.spbuSearch.value.trim().toLowerCase();
const fuels = getActiveFuels();
const status = getStatus(spbu);
if (sf !== "all" && status !== sf) return false;
if (fuels.length && !fuels.every((f) => spbu.jenis_bbm.includes(f)))
return false;
if (
search &&
!`${spbu.nama} ${spbu.alamat}`.toLowerCase().includes(search)
)
return false;
return true;
}
// ─── Refresh markers ──────────────────────────────────────────────────────
function refreshMarkers() {
spbuLayer.clearLayers();
state.markers.clear();
if (!state.showSpbu) {
updateSummary();
return;
}
state.spbuData.forEach((spbu) => {
if (!matchFilter(spbu)) return;
const m = L.marker([spbu.lat, spbu.lng], {
icon: createIcon(spbu),
title: spbu.nama,
}).bindPopup(createPopup(spbu));
m.addTo(spbuLayer);
state.markers.set(spbu.id, m);
});
updateSummary();
}
function updateSummary(toast = false) {
const open = state.spbuData.filter(
(s) => getStatus(s) === "open",
).length;
const closed = state.spbuData.length - open;
el.openCount.textContent = open;
el.closedCount.textContent = closed;
if (toast)
showToast(`${open} SPBU sedang buka • ${closed} SPBU sedang tutup`);
}
// ─── Toast ───────────────────────────────────────────────────────────────
let toastTimer;
function showToast(msg, isError = false) {
el.toast.textContent = msg;
el.toast.classList.toggle("error", isError);
el.toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.toast.classList.remove("show"), 5200);
}
// ─── Fuel filter UI ──────────────────────────────────────────────────────
function renderFuelFilters() {
el.fuelFilters.innerHTML = fuelTypes
.map(
(f) =>
`<label class="check-row"><input type="checkbox" value="${f}" /><span>${f}</span></label>`,
)
.join("");
el.fuelFilters
.querySelectorAll("input")
.forEach((i) => i.addEventListener("change", refreshMarkers));
}
// ─── Admin override table ─────────────────────────────────────────────────
function renderAdminTable() {
el.adminTableBody.innerHTML = state.spbuData
.map((spbu) => {
const auto = getAutoStatus(spbu),
sel = spbu.manual_override || "auto";
return `<tr>
<td><strong>${spbu.nama}</strong><br><small>${spbu.alamat}</small></td>
<td><span class="badge ${auto}">${auto === "open" ? "BUKA" : "TUTUP"}</span></td>
<td><select data-id="${spbu.id}">
<option value="auto" ${sel === "auto" ? "selected" : ""}>Auto</option>
<option value="open" ${sel === "open" ? "selected" : ""}>Force Open</option>
<option value="closed" ${sel === "closed" ? "selected" : ""}>Force Closed</option>
</select></td>
</tr>`;
})
.join("");
el.adminTableBody.querySelectorAll("select").forEach((s) => {
s.addEventListener("change", async (e) => {
const id = Number(e.target.dataset.id),
val = e.target.value;
try {
await patchOverride(id, val);
const spbu = state.spbuData.find((x) => x.id === id);
spbu.manual_override = val === "auto" ? null : val;
refreshMarkers();
renderAdminTable();
} catch (err) {
showToast("Gagal menyimpan override: " + err.message, true);
}
});
});
}
// ─── Layer: Jalan dari DB ─────────────────────────────────────────────────
async function loadJalanLayer() {
showLayerMsg("Memuat data jalan dari database...");
try {
const data = await fetchJalan();
jalanLayerGrp.clearLayers();
if (!data.length) {
showLayerMsg(
"Belum ada data jalan di database. Tambahkan di Admin.",
);
return;
}
data.forEach((j) => {
const coords = j.koordinat.coordinates || j.koordinat;
if (!coords || coords.length < 2) return;
const style =
JALAN_STYLE[j.jenis_jalan] || JALAN_STYLE.kota_kabupaten;
const latLngs = coords.map((c) => [c[1], c[0]]);
const pl = L.polyline(latLngs, style);
pl.bindPopup(`<div style="color:#0f172a">
<strong>${j.nama_jalan}</strong><br>
<span style="font-size:.82rem;color:#475569">${
j.jenis_jalan === "nasional"
? "Jalan Nasional"
: j.jenis_jalan === "provinsi"
? "Jalan Provinsi"
: "Jalan Kota/Kabupaten"
}</span>
</div>`);
jalanLayerGrp.addLayer(pl);
});
showLayerMsg(`${data.length} ruas jalan berhasil dimuat.`);
setTimeout(() => showLayerMsg("", false), 3000);
} catch (err) {
el.jalanToggle.checked = false;
map.removeLayer(jalanLayerGrp);
el.jalanLegend.style.display = "none";
showLayerMsg(`Gagal memuat data jalan: ${err.message}`);
}
}
// ─── Layer: Bangunan dari DB ──────────────────────────────────────────────
async function loadBangunanLayer() {
showLayerMsg("Memuat data bangunan dari database...");
try {
const data = await fetchBangunan();
bangunanLayerGrp.clearLayers();
if (!data.length) {
showLayerMsg(
"Belum ada data bangunan di database. Tambahkan di Admin.",
);
return;
}
data.forEach((b) => {
const coords = b.koordinat.coordinates
? b.koordinat.coordinates
: b.koordinat;
if (!coords || !coords[0] || coords[0].length < 3) return;
const style =
BANGUNAN_STYLE[b.jenis_bangunan] || BANGUNAN_STYLE.SHM;
const latLngs = coords[0].map((c) => [c[1], c[0]]);
const pg = L.polygon(latLngs, style);
const desc = {
SHM: "Sertifikat Hak Milik",
HGB: "Hak Guna Bangunan",
HGU: "Hak Guna Usaha",
HP: "Hak Pakai",
HPL: "Hak Pengelolaan",
};
pg.bindPopup(`<div style="color:#0f172a">
<strong>${b.nama_bangunan}</strong><br>
<span style="font-size:.82rem;color:#475569">${desc[b.jenis_bangunan] || b.jenis_bangunan}</span>
${b.nama_jalan ? `<br><span style="font-size:.82rem;color:#475569">📍 ${b.nama_jalan}</span>` : ""}
</div>`);
bangunanLayerGrp.addLayer(pg);
});
showLayerMsg(`${data.length} bangunan berhasil dimuat.`);
setTimeout(() => showLayerMsg("", false), 3000);
} catch (err) {
el.bangunanToggle.checked = false;
map.removeLayer(bangunanLayerGrp);
el.bangunanLegend.style.display = "none";
showLayerMsg(`Gagal memuat data bangunan: ${err.message}`);
}
}
// ─── Layer: Bangunan OSM ──────────────────────────────────────────────────
function showLayerMsg(msg, visible = true) {
el.layerMessage.textContent = msg;
el.layerMessage.classList.toggle("is-visible", visible);
}
// ─── Events ──────────────────────────────────────────────────────────────
function attachEvents() {
el.statusFilter.addEventListener("change", refreshMarkers);
el.spbuSearch.addEventListener("input", refreshMarkers);
el.roadToggle.addEventListener("change", (e) =>
e.target.checked ? map.addLayer(osmLayer) : map.removeLayer(osmLayer),
);
el.spbuToggle.addEventListener("change", (e) => {
state.showSpbu = e.target.checked;
e.target.checked
? map.addLayer(spbuLayer)
: map.removeLayer(spbuLayer);
refreshMarkers();
});
// Jalan dari DB
el.jalanToggle.addEventListener("change", async (e) => {
if (e.target.checked) {
map.addLayer(jalanLayerGrp);
el.jalanLegend.style.display = "";
await loadJalanLayer();
} else {
map.removeLayer(jalanLayerGrp);
el.jalanLegend.style.display = "none";
showLayerMsg("", false);
}
});
// Bangunan dari DB
el.bangunanToggle.addEventListener("change", async (e) => {
if (e.target.checked) {
map.addLayer(bangunanLayerGrp);
el.bangunanLegend.style.display = "";
await loadBangunanLayer();
} else {
map.removeLayer(bangunanLayerGrp);
el.bangunanLegend.style.display = "none";
showLayerMsg("", false);
}
});
el.adminToggle.addEventListener("click", () => {
el.adminPanel.classList.toggle("is-open");
renderAdminTable();
});
el.closeAdmin.addEventListener("click", () =>
el.adminPanel.classList.remove("is-open"),
);
el.mobileToggle.addEventListener("click", () =>
el.sidebar.classList.toggle("is-open"),
);
window.addEventListener("resize", () =>
setTimeout(() => map.invalidateSize(), 120),
);
}
// ─── Init ────────────────────────────────────────────────────────────────
async function init() {
renderFuelFilters();
attachEvents();
try {
state.spbuData = await fetchSpbu();
el.loadingOverlay.style.display = "none";
refreshMarkers();
renderAdminTable();
updateSummary(true);
} catch (err) {
el.loadingOverlay.querySelector(".loading-spinner").textContent =
"❌ Gagal memuat data: " + err.message;
showToast("Gagal memuat data SPBU dari server.", true);
}
// Refresh setiap 60 detik
setInterval(async () => {
try {
state.spbuData = await fetchSpbu();
refreshMarkers();
if (el.adminPanel.classList.contains("is-open")) renderAdminTable();
} catch (_) {}
}, 60000);
}
init();
</script>
</body>
</html>