1408 lines
44 KiB
HTML
1408 lines
44 KiB
HTML
<!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>
|