Files
WebGis_UAS_Project/04/index.html
T
2026-06-10 19:23:50 +07:00

1031 lines
45 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 — Layer Groups & Layers Control</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"/>
<style>
/* ══════════════════════════════════════
RESET & VARIABLES
══════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #f97316;
--accent-dk: #c2591a;
--green: #3fb950;
--red: #f85149;
--radius: 12px;
--shadow: 0 8px 32px rgba(0,0,0,.55);
}
html, body {
height: 100%;
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg);
color: var(--text);
}
/* ══════════════════════════════════════
LAYOUT
══════════════════════════════════════ */
.app {
display: grid;
grid-template-columns: 360px 1fr;
grid-template-rows: 60px 1fr;
height: 100vh;
}
/* ── Header ─────────────────────────── */
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 14px;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.logo-icon {
width: 34px; height: 34px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
font-size: 18px;
}
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -.2px; }
header h1 span { color: var(--accent); }
.header-badges {
margin-left: auto;
display: flex; gap: 8px; align-items: center;
}
.header-badge {
font-family: 'Space Mono', monospace;
font-size: 10px; font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
display: flex; align-items: center; gap: 5px;
}
.badge-24 { background: rgba(63,185,80,.15); color: var(--green); border: 1px solid var(--green); }
.badge-non24{ background: rgba(249,115,22,.15); color: var(--accent); border: 1px solid var(--accent); }
.dot-sm { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.header-meta {
font-family: 'Space Mono', monospace;
font-size: 11px; color: var(--muted);
background: var(--surface2);
padding: 5px 12px; border-radius: 20px;
border: 1px solid var(--border);
}
.dot-live {
display: inline-block;
width: 7px; height: 7px; border-radius: 50%;
background: var(--green); margin-right: 6px;
animation: livePulse 1.8s infinite;
}
@keyframes livePulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* ── Sidebar ─────────────────────────── */
aside {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 18px 20px 14px;
border-bottom: 1px solid var(--border);
}
.sidebar-header h2 {
font-size: 13px; font-weight: 600;
text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); margin-bottom: 4px;
}
.count-badge {
font-family: 'Space Mono', monospace;
font-size: 26px; font-weight: 700;
color: var(--accent); line-height: 1;
}
.count-badge small { font-size: 12px; color: var(--muted); font-family: inherit; }
/* stats row */
.stats-row {
display: flex; gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
}
.stat-pill {
flex: 1; text-align: center;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 4px;
font-size: 11px; color: var(--muted);
}
.stat-pill strong { display: block; font-size: 18px; font-weight: 700; margin-bottom: 2px; }
.stat-pill.green strong { color: var(--green); }
.stat-pill.orange strong { color: var(--accent); }
/* ── Layer Toggle Panel ──────────────── */
.layer-panel {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(249,115,22,.04);
}
.layer-panel-title {
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.layer-toggles { display: flex; gap: 8px; }
.layer-toggle {
flex: 1;
display: flex; align-items: center; gap: 8px;
background: var(--surface2);
border: 1.5px solid var(--border);
border-radius: 10px;
padding: 9px 12px;
cursor: pointer;
transition: all .2s;
user-select: none;
}
.layer-toggle:hover { border-color: var(--muted); }
.layer-toggle.active-24 { border-color: var(--green); background: rgba(63,185,80,.1); }
.layer-toggle.active-non24{ border-color: var(--accent); background: rgba(249,115,22,.1); }
.layer-toggle input[type="checkbox"] { display: none; }
.toggle-icon {
width: 22px; height: 22px; border-radius: 5px;
display: grid; place-items: center;
font-size: 12px; flex-shrink: 0;
}
.icon-24 { background: rgba(63,185,80,.2); }
.icon-non24{ background: rgba(249,115,22,.2); }
.toggle-info { flex: 1; }
.toggle-label { font-size: 11px; font-weight: 700; line-height: 1.2; }
.toggle-count { font-size: 10px; color: var(--muted); font-family: 'Space Mono', monospace; }
.toggle-check {
width: 16px; height: 16px; border-radius: 4px;
border: 1.5px solid var(--border);
display: grid; place-items: center;
flex-shrink: 0; transition: all .2s;
}
.layer-toggle.active-24 .toggle-check { background: var(--green); border-color: var(--green); }
.layer-toggle.active-non24 .toggle-check { background: var(--accent); border-color: var(--accent); }
.toggle-check svg { opacity: 0; transition: opacity .15s; }
.layer-toggle.active-24 .toggle-check svg,
.layer-toggle.active-non24 .toggle-check svg { opacity: 1; }
/* search */
.search-wrap {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.search-wrap input {
width: 100%;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 12px 8px 34px;
color: var(--text); font-size: 13px; font-family: inherit;
outline: none; transition: border-color .2s;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: 10px center;
}
.search-wrap input:focus { border-color: var(--accent); }
/* list */
#spbu-list { flex: 1; overflow-y: auto; padding: 8px 10px; }
#spbu-list::-webkit-scrollbar { width: 4px; }
#spbu-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.spbu-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 13px 15px;
margin-bottom: 8px; cursor: pointer;
transition: border-color .2s, transform .15s;
}
.spbu-card:hover { border-color: var(--accent); transform: translateX(3px); }
.spbu-card.active { border-color: var(--accent); background: #1d2029; }
/* dim kartu jika layer-nya disembunyikan */
.spbu-card.layer-hidden { opacity: .35; pointer-events: none; }
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.card-name { font-size: 13px; font-weight: 600; line-height: 1.35; }
.badge {
font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 20px; white-space: nowrap; flex-shrink: 0;
letter-spacing: .4px; text-transform: uppercase;
}
.badge.yes { background: rgba(63,185,80,.18); color: var(--green); border: 1px solid var(--green); }
.badge.no { background: rgba(249,115,22,.15); color: var(--accent); border: 1px solid var(--accent); }
.card-wa {
font-size: 11px; color: var(--muted); margin-top: 5px;
display: flex; align-items: center; gap: 5px;
}
.card-wa svg { flex-shrink: 0; }
.card-actions { display: flex; gap: 6px; margin-top: 10px; }
.btn-sm {
flex: 1; padding: 5px 0; font-size: 11px; font-weight: 600;
border-radius: 6px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface);
color: var(--muted); font-family: inherit; transition: all .18s;
}
.btn-sm:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-sm.danger:hover { background: var(--red); color: #fff; border-color: var(--red); }
.btn-sm.move:hover { background: #3b82f6; color: #fff; border-color: #3b82f6; }
/* CTA */
.cta-box { padding: 14px 16px; border-top: 1px solid var(--border); }
.btn-add {
width: 100%; background: var(--accent); color: #fff;
border: none; border-radius: var(--radius); padding: 11px;
font-size: 13px; font-weight: 700; font-family: inherit;
cursor: pointer; transition: background .2s, transform .15s;
display: flex; align-items: center; justify-content: center; gap: 7px;
}
.btn-add:hover { background: var(--accent-dk); transform: translateY(-1px); }
/* ── Map ─────────────────────────────── */
#map { width: 100%; height: 100%; background: #1a1a2e; }
/* ══════════════════════════════════════
LEAFLET LAYERS CONTROL — custom dark theme
══════════════════════════════════════ */
.leaflet-control-layers {
background: var(--surface) !important;
border: 1px solid var(--border) !important;
border-radius: 12px !important;
box-shadow: var(--shadow) !important;
padding: 4px !important;
min-width: 200px;
}
.leaflet-control-layers-toggle {
background-color: var(--surface2) !important;
border: 1px solid var(--border) !important;
border-radius: 8px !important;
width: 36px !important; height: 36px !important;
}
.leaflet-control-layers-expanded {
padding: 12px 14px !important;
}
.leaflet-control-layers-list { color: var(--text) !important; }
.leaflet-control-layers-separator { border-color: var(--border) !important; }
.leaflet-control-layers label {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 5px 0 !important;
font-size: 12px !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
font-weight: 600 !important;
cursor: pointer !important;
}
.leaflet-control-layers label:hover { color: var(--accent) !important; }
.leaflet-control-layers input[type="checkbox"] {
accent-color: var(--green);
width: 14px; height: 14px; cursor: pointer;
}
/* Title above overlay list */
.lc-title {
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); margin-bottom: 6px;
}
/* ══════════════════════════════════════
FLOATING MOVE BAR
══════════════════════════════════════ */
#move-bar {
position: absolute;
bottom: 32px; left: 50%; transform: translateX(-50%) translateY(80px);
z-index: 900;
background: var(--surface); border: 1.5px solid var(--accent);
border-radius: 40px; padding: 10px 16px 10px 20px;
display: flex; align-items: center; gap: 12px;
box-shadow: 0 8px 28px rgba(0,0,0,.6), 0 0 0 4px rgba(249,115,22,.12);
opacity: 0; pointer-events: none;
transition: transform .3s cubic-bezier(.34,1.56,.64,1), opacity .25s;
white-space: nowrap;
}
#move-bar.active { transform: translateX(-50%) translateY(0); opacity: 1; pointer-events: all; }
.move-bar-label { font-size: 12px; font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 7px; }
.pulse-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: livePulse 1.2s infinite; }
.btn-confirm { padding: 6px 16px; background: var(--green); border: none; border-radius: 20px; color: #fff; font-size: 12px; font-weight: 700; font-family: inherit; cursor: pointer; transition: opacity .2s; }
.btn-confirm:hover { opacity: .85; }
.btn-cancel-move { padding: 6px 14px; background: var(--surface2); border: 1px solid var(--border); border-radius: 20px; color: var(--muted); font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer; transition: all .2s; }
.btn-cancel-move:hover { border-color: var(--red); color: var(--red); }
/* ══════════════════════════════════════
MODAL
══════════════════════════════════════ */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 1000; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .25s;
}
.overlay.open { opacity: 1; pointer-events: all; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; padding: 28px 28px 24px;
width: 420px; max-width: 95vw; box-shadow: var(--shadow);
transform: translateY(12px); transition: transform .25s;
}
.overlay.open .modal { transform: translateY(0); }
.modal h3 { font-size: 16px; font-weight: 700; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; }
.modal h3 .icon { width: 32px; height: 32px; background: var(--accent); border-radius: 8px; display: grid; place-items: center; font-size: 16px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .5px; }
.form-group input, .form-group select {
width: 100%; background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 14px; color: var(--text);
font-size: 14px; font-family: inherit; outline: none;
transition: border-color .2s; appearance: none;
}
.form-group input:focus, .form-group select:focus { border-color: var(--accent); }
.coord-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-actions { display: flex; gap: 10px; margin-top: 20px; }
.btn-cancel { flex: 1; padding: 11px; background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); color: var(--muted); font-size: 13px; font-weight: 600; font-family: inherit; cursor: pointer; transition: all .2s; }
.btn-cancel:hover { border-color: var(--red); color: var(--red); }
.btn-save { flex: 2; padding: 11px; background: var(--accent); border: none; border-radius: var(--radius); color: #fff; font-size: 13px; font-weight: 700; font-family: inherit; cursor: pointer; transition: background .2s; }
.btn-save:hover { background: var(--accent-dk); }
.coord-hint { font-size: 11px; color: var(--muted); background: var(--surface2); border: 1px dashed var(--border); border-radius: 8px; padding: 8px 12px; margin-bottom: 16px; display: flex; align-items: center; gap: 7px; }
/* Toast */
#toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 12px 18px;
font-size: 13px; font-weight: 500; box-shadow: var(--shadow);
z-index: 9999; transform: translateY(20px); opacity: 0; transition: all .3s;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { border-color: var(--green); }
#toast.error { border-color: var(--red); }
/* Leaflet popup dark */
.leaflet-popup-content-wrapper {
background: var(--surface) !important; border: 1px solid var(--border) !important;
border-radius: 12px !important; box-shadow: var(--shadow) !important; color: var(--text) !important;
}
.leaflet-popup-tip { background: var(--surface) !important; }
.leaflet-popup-content { margin: 14px 16px !important; }
.popup-title { font-weight: 700; font-size: 14px; margin-bottom: 6px; }
.popup-wa { font-size: 12px; color: var(--muted); }
.popup-status { margin-top: 8px; }
/* empty state */
.empty-state { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; }
.empty-state .emoji { font-size: 36px; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="app">
<!-- ═══════════ HEADER ═══════════ -->
<header>
<div class="logo-icon"></div>
<h1>WebGIS <span>SPBU</span> Pontianak</h1>
<div class="header-badges">
<span class="header-badge badge-24">
<span class="dot-sm"></span>
SPBU 24 Jam
</span>
<span class="header-badge badge-non24">
<span class="dot-sm"></span>
Non-24 Jam
</span>
</div>
<div class="header-meta">
<span class="dot-live"></span>Layer Control Active
</div>
</header>
<!-- ═══════════ SIDEBAR ═══════════ -->
<aside>
<div class="sidebar-header">
<h2>Total SPBU</h2>
<div class="count-badge" id="total-count">0 <small>titik</small></div>
</div>
<div class="stats-row">
<div class="stat-pill green">
<strong id="stat-24">0</strong>
Buka 24 Jam
</div>
<div class="stat-pill orange">
<strong id="stat-non24">0</strong>
Non-24 Jam
</div>
</div>
<!-- ══ LAYER TOGGLE PANEL (sidebar) ══ -->
<div class="layer-panel">
<div class="layer-panel-title">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Layer Groups
</div>
<div class="layer-toggles">
<!-- Toggle: 24 Jam -->
<label class="layer-toggle active-24" id="toggle-24" onclick="toggleLayer('yes')">
<input type="checkbox" id="cb-24" checked/>
<div class="toggle-icon icon-24">🟢</div>
<div class="toggle-info">
<div class="toggle-label" style="color:var(--green)">SPBU 24 Jam</div>
<div class="toggle-count" id="lc-count-24">0 titik</div>
</div>
<div class="toggle-check">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="#0d1117" stroke-width="2.5"><path d="M1 6l3.5 3.5L11 2"/></svg>
</div>
</label>
<!-- Toggle: Non-24 Jam -->
<label class="layer-toggle active-non24" id="toggle-non24" onclick="toggleLayer('no')">
<input type="checkbox" id="cb-non24" checked/>
<div class="toggle-icon icon-non24">🟠</div>
<div class="toggle-info">
<div class="toggle-label" style="color:var(--accent)">Non-24 Jam</div>
<div class="toggle-count" id="lc-count-non24">0 titik</div>
</div>
<div class="toggle-check">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" stroke="#0d1117" stroke-width="2.5"><path d="M1 6l3.5 3.5L11 2"/></svg>
</div>
</label>
</div>
</div>
<div class="search-wrap">
<input type="text" id="search-input" placeholder="Cari nama SPBU…" oninput="filterList()"/>
</div>
<div id="spbu-list">
<div class="empty-state">
<div class="emoji">🗺️</div>
Memuat data…
</div>
</div>
<div class="cta-box">
<button class="btn-add" onclick="openAddMode()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Klik Peta untuk Tambah SPBU
</button>
</div>
</aside>
<!-- ═══════════ MAP ═══════════ -->
<div id="map" style="position:relative;">
<div id="move-bar">
<div class="move-bar-label">
<span class="pulse-dot"></span>
Geser pin ke lokasi baru
</div>
<button class="btn-confirm" onclick="confirmMove()">✅ Konfirmasi</button>
<button class="btn-cancel-move" onclick="cancelMove()">✕ Batal</button>
</div>
</div>
</div>
<!-- ═══════════ MODAL FORM ═══════════ -->
<div class="overlay" id="modal-overlay">
<div class="modal">
<h3>
<span class="icon"></span>
<span id="modal-title">Tambah SPBU</span>
</h3>
<div class="coord-hint" id="coord-hint">
📍 Koordinat diambil dari titik yang Anda klik di peta
</div>
<div class="form-group">
<label>Nama SPBU</label>
<input type="text" id="f-nama" placeholder="Contoh: SPBU Pertamina Jl. Ahmad Yani"/>
</div>
<div class="form-group">
<label>Nomor WhatsApp</label>
<input type="text" id="f-wa" placeholder="Contoh: 6281234567890"/>
</div>
<div class="form-group">
<label>Status 24 Jam</label>
<select id="f-status">
<option value="yes">✅ Ya Buka 24 Jam</option>
<option value="no">❌ Tidak Jam Terbatas</option>
</select>
</div>
<div class="coord-row">
<div class="form-group">
<label>Latitude</label>
<input type="number" id="f-lat" step="0.000001" placeholder="-0.0262619"/>
</div>
<div class="form-group">
<label>Longitude</label>
<input type="number" id="f-lng" step="0.000001" placeholder="109.3425923"/>
</div>
</div>
<div class="form-actions">
<button class="btn-cancel" onclick="closeModal()">Batal</button>
<button class="btn-save" onclick="saveSpbu()">💾 Simpan</button>
</div>
</div>
</div>
<div id="toast"></div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ═══════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════
let spbuData = [];
let markers = {}; // id → Leaflet marker
let editId = null;
let addModeActive = false;
let tempMarker = null;
// Layer visibility state
let layerVisible = { yes: true, no: true };
// ═══════════════════════════════════════════════════════════
// MAP INIT — Pontianak
// ═══════════════════════════════════════════════════════════
const map = L.map('map', {
center: [-0.0263, 109.3425],
zoom: 13,
zoomControl: false
});
L.control.zoom({ position: 'bottomright' }).addTo(map);
// ── Base layer ──────────────────────────────────────────────
const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors',
maxZoom: 19
});
osmLayer.addTo(map);
// ══════════════════════════════════════════════════════════
// LAYER GROUPS ← inti tugas ini
// Dua LayerGroup terpisah berdasarkan status_24jam
// ══════════════════════════════════════════════════════════
const layerGroup24 = L.layerGroup(); // SPBU buka 24 jam
const layerGroupNon24 = L.layerGroup(); // SPBU tidak buka 24 jam
// Tambahkan kedua layer ke peta saat awal (keduanya aktif)
layerGroup24.addTo(map);
layerGroupNon24.addTo(map);
// ── Leaflet Layers Control (pojok kanan atas peta) ─────────
// Label dengan dot berwarna sebagai penanda visual
const overlayMaps = {
'<span style="display:inline-flex;align-items:center;gap:6px;font-family:\'Plus Jakarta Sans\',sans-serif;font-size:12px;font-weight:700"><span style="width:10px;height:10px;border-radius:50%;background:#3fb950;display:inline-block"></span>SPBU Buka 24 Jam</span>' : layerGroup24,
'<span style="display:inline-flex;align-items:center;gap:6px;font-family:\'Plus Jakarta Sans\',sans-serif;font-size:12px;font-weight:700"><span style="width:10px;height:10px;border-radius:50%;background:#f97316;display:inline-block"></span>SPBU Tidak 24 Jam</span>': layerGroupNon24
};
const layersControl = L.control.layers(null, overlayMaps, {
collapsed: false, // langsung tampil terbuka
position : 'topright'
}).addTo(map);
// Sinkronkan state LayerGroup → toggle sidebar saat user
// centang/uncentang langsung dari Leaflet Layers Control
map.on('overlayremove', function(e) {
if (e.layer === layerGroup24) _setSidebarToggle('yes', false);
if (e.layer === layerGroupNon24) _setSidebarToggle('no', false);
});
map.on('overlayadd', function(e) {
if (e.layer === layerGroup24) _setSidebarToggle('yes', true);
if (e.layer === layerGroupNon24) _setSidebarToggle('no', true);
});
// ── Ikon marker ────────────────────────────────────────────
function makeIcon(is24) {
return L.divIcon({
className: '',
html: `<div style="
width:36px;height:36px;
background:${is24 ? '#3fb950' : '#f97316'};
border:3px solid #fff;
border-radius:50% 50% 50% 0;
transform:rotate(-45deg);
box-shadow:0 3px 10px rgba(0,0,0,.5);
"></div>`,
iconSize: [36,36], iconAnchor: [18,36], popupAnchor: [0,-38]
});
}
const tempIcon = L.divIcon({
className: '',
html: `<div style="
width:34px;height:34px;
background:#f97316;border:3px solid #fff;
border-radius:50% 50% 50% 0;transform:rotate(-45deg);
box-shadow:0 3px 14px rgba(249,115,22,.7);
animation:blink .7s infinite alternate;
"></div><style>@keyframes blink{from{opacity:1}to{opacity:.4}}</style>`,
iconSize:[34,34], iconAnchor:[17,34], popupAnchor:[0,-36]
});
// ═══════════════════════════════════════════════════════════
// API
// ═══════════════════════════════════════════════════════════
const API = 'api.php';
async function fetchAll() {
try {
const r = await fetch(API);
const j = await r.json();
if (j.status === 'success') {
spbuData = j.data;
renderMarkers();
renderList();
updateStats();
}
} catch(e) { showToast('Gagal memuat data. Pastikan server berjalan.', 'error'); }
}
async function saveSpbu() {
const nama = document.getElementById('f-nama').value.trim();
const wa = document.getElementById('f-wa').value.trim();
const status = document.getElementById('f-status').value;
const lat = parseFloat(document.getElementById('f-lat').value);
const lng = parseFloat(document.getElementById('f-lng').value);
if (!nama || !wa || isNaN(lat) || isNaN(lng)) {
showToast('Harap isi semua field dengan benar!', 'error'); return;
}
const payload = { nama_spbu:nama, nomor_wa:wa, status_24jam:status, latitude:lat, longitude:lng };
try {
const r = await fetch(API + (editId ? `?id=${editId}` : ''), {
method : editId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload)
});
const j = await r.json();
if (j.status === 'success') {
showToast(editId ? 'SPBU berhasil diperbarui! ✏️' : 'SPBU berhasil ditambahkan! ⛽', 'success');
closeModal();
fetchAll();
} else { showToast('Gagal menyimpan: ' + j.message, 'error'); }
} catch(e) { showToast('Error koneksi ke server.', 'error'); }
}
async function deleteSpbu(id) {
if (!confirm('Yakin hapus SPBU ini?')) return;
try {
const r = await fetch(`${API}?id=${id}`, { method:'DELETE' });
const j = await r.json();
if (j.status === 'success') {
showToast('SPBU berhasil dihapus! 🗑️', 'success');
fetchAll();
}
} catch(e) { showToast('Gagal menghapus.', 'error'); }
}
// ═══════════════════════════════════════════════════════════
// RENDER MARKERS — pisahkan ke LayerGroup masing-masing
// ═══════════════════════════════════════════════════════════
function renderMarkers() {
// Bersihkan kedua layer group & registry markers
layerGroup24.clearLayers();
layerGroupNon24.clearLayers();
markers = {};
spbuData.forEach(s => {
const is24 = s.status_24jam === 'yes';
const m = L.marker([s.latitude, s.longitude], { icon: makeIcon(is24) })
.bindPopup(popupHTML(s));
m.on('click', () => highlightCard(s.id));
// Tambahkan ke LayerGroup yang sesuai
if (is24) {
layerGroup24.addLayer(m);
} else {
layerGroupNon24.addLayer(m);
}
markers[s.id] = m;
});
}
function popupHTML(s) {
return `
<div class="popup-title">⛽ ${escHtml(s.nama_spbu)}</div>
<div class="popup-wa">📱 ${escHtml(s.nomor_wa)}</div>
<div class="popup-status">
<span class="badge ${s.status_24jam}">
${s.status_24jam === 'yes' ? '✅ Buka 24 Jam' : '❌ Jam Terbatas'}
</span>
</div>
<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">
<button onclick="openEdit(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #30363d;background:#21262d;color:#e6edf3;font-family:inherit">✏️ Edit</button>
<button onclick="startMovePin(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #3b82f6;background:rgba(59,130,246,.1);color:#3b82f6;font-family:inherit">📌 Pindahkan</button>
<button onclick="deleteSpbu(${s.id})" style="flex:1;padding:6px 0;font-size:11px;font-weight:600;border-radius:6px;cursor:pointer;border:1px solid #f85149;background:rgba(248,81,73,.1);color:#f85149;font-family:inherit">🗑️ Hapus</button>
</div>`;
}
// ═══════════════════════════════════════════════════════════
// RENDER LIST (sidebar)
// ═══════════════════════════════════════════════════════════
function renderList(filter = '') {
const list = document.getElementById('spbu-list');
const q = filter.toLowerCase();
const filtered = spbuData.filter(s => s.nama_spbu.toLowerCase().includes(q));
if (!filtered.length) {
list.innerHTML = `<div class="empty-state"><div class="emoji">${filter?'🔍':'📍'}</div>${filter?'Tidak ditemukan':'Belum ada data SPBU'}</div>`;
return;
}
list.innerHTML = filtered.map(s => {
const is24 = s.status_24jam === 'yes';
const hidden = !layerVisible[s.status_24jam];
return `
<div class="spbu-card${hidden?' layer-hidden':''}" id="card-${s.id}" onclick="flyTo(${s.id})">
<div class="card-top">
<div class="card-name">${escHtml(s.nama_spbu)}</div>
<span class="badge ${s.status_24jam}">${is24 ? '24 Jam' : 'Non-24'}</span>
</div>
<div class="card-wa">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07A19.5 19.5 0 013.07 9.8 19.79 19.79 0 01.01 1.18C0 .1.68-.15 1.91.08A2 2 0 013.9 2.05v3a2 2 0 01-1.45 1.93 16 16 0 006.39 6.39A2 2 0 0110.77 12h.1l.07.06"/></svg>
${escHtml(s.nomor_wa)}
</div>
<div class="card-actions">
<button class="btn-sm" onclick="event.stopPropagation();openEdit(${s.id})">✏️ Edit</button>
<button class="btn-sm move" onclick="event.stopPropagation();startMovePin(${s.id})">📌 Pindahkan</button>
<button class="btn-sm danger" onclick="event.stopPropagation();deleteSpbu(${s.id})">🗑️ Hapus</button>
</div>
</div>`;
}).join('');
}
function updateStats() {
const total = spbuData.length;
const yes = spbuData.filter(s => s.status_24jam === 'yes').length;
const no = total - yes;
document.getElementById('total-count').innerHTML = `${total} <small>titik</small>`;
document.getElementById('stat-24').textContent = yes;
document.getElementById('stat-non24').textContent = no;
// Update counter di toggle sidebar
document.getElementById('lc-count-24').textContent = `${yes} titik`;
document.getElementById('lc-count-non24').textContent = `${no} titik`;
}
// ═══════════════════════════════════════════════════════════
// LAYER TOGGLE (sidebar custom toggle)
// ═══════════════════════════════════════════════════════════
function toggleLayer(status) {
// status: 'yes' (24 jam) | 'no' (non-24)
const newVisible = !layerVisible[status];
layerVisible[status] = newVisible;
const group = status === 'yes' ? layerGroup24 : layerGroupNon24;
if (newVisible) {
group.addTo(map);
} else {
map.removeLayer(group);
}
// Update tampilan toggle di sidebar
_setSidebarToggle(status, newVisible);
// Rerender kartu (kartu layer tersembunyi jadi dim)
renderList(document.getElementById('search-input').value);
showToast(
newVisible
? `Layer "${status === 'yes' ? 'SPBU 24 Jam' : 'SPBU Non-24 Jam'}" ditampilkan ✅`
: `Layer "${status === 'yes' ? 'SPBU 24 Jam' : 'SPBU Non-24 Jam'}" disembunyikan 🙈`,
newVisible ? 'success' : 'error'
);
}
function _setSidebarToggle(status, visible) {
layerVisible[status] = visible;
const toggleEl = document.getElementById(status === 'yes' ? 'toggle-24' : 'toggle-non24');
const cbEl = document.getElementById(status === 'yes' ? 'cb-24' : 'cb-non24');
const activeClass = status === 'yes' ? 'active-24' : 'active-non24';
cbEl.checked = visible;
if (visible) toggleEl.classList.add(activeClass);
else toggleEl.classList.remove(activeClass);
}
// ═══════════════════════════════════════════════════════════
// MAP INTERACTIONS
// ═══════════════════════════════════════════════════════════
function openAddMode() {
addModeActive = true;
map.getContainer().style.cursor = 'crosshair';
showToast('🗺️ Klik pada peta untuk menentukan lokasi SPBU', 'success');
}
map.on('click', function(e) {
if (!addModeActive) return;
addModeActive = false;
map.getContainer().style.cursor = '';
if (tempMarker) map.removeLayer(tempMarker);
tempMarker = L.marker([e.latlng.lat, e.latlng.lng], { icon: tempIcon }).addTo(map);
openModal(null, e.latlng.lat, e.latlng.lng);
});
function flyTo(id) {
const s = spbuData.find(x => x.id === id);
if (!s) return;
// Pastikan layer yang bersangkutan aktif agar popup bisa muncul
const group = s.status_24jam === 'yes' ? layerGroup24 : layerGroupNon24;
if (!map.hasLayer(group)) {
group.addTo(map);
_setSidebarToggle(s.status_24jam, true);
showToast('Layer diaktifkan otomatis untuk menampilkan lokasi', 'success');
}
map.flyTo([s.latitude, s.longitude], 16, { duration: 1.2 });
setTimeout(() => { if (markers[id]) markers[id].openPopup(); }, 1300);
highlightCard(id);
}
function highlightCard(id) {
document.querySelectorAll('.spbu-card').forEach(c => c.classList.remove('active'));
const card = document.getElementById(`card-${id}`);
if (card) { card.classList.add('active'); card.scrollIntoView({behavior:'smooth',block:'nearest'}); }
}
// ═══════════════════════════════════════════════════════════
// MODAL
// ═══════════════════════════════════════════════════════════
function openModal(id = null, lat = '', lng = '') {
editId = id;
document.getElementById('modal-title').textContent = id ? 'Edit SPBU' : 'Tambah SPBU Baru';
if (id) {
const s = spbuData.find(x => x.id == id);
if (!s) { showToast('Data SPBU tidak ditemukan!', 'error'); return; }
document.getElementById('f-nama').value = s.nama_spbu;
document.getElementById('f-wa').value = s.nomor_wa;
document.getElementById('f-status').value = s.status_24jam;
document.getElementById('f-lat').value = s.latitude;
document.getElementById('f-lng').value = s.longitude;
document.getElementById('coord-hint').style.display = 'none';
} else {
document.getElementById('f-nama').value = '';
document.getElementById('f-wa').value = '';
document.getElementById('f-status').value = 'no';
document.getElementById('f-lat').value = lat;
document.getElementById('f-lng').value = lng;
document.getElementById('coord-hint').style.display = 'flex';
}
document.getElementById('modal-overlay').classList.add('open');
}
function openEdit(id) {
id = parseInt(id);
if (markers[id]) markers[id].closePopup();
openModal(id);
}
function closeModal() {
document.getElementById('modal-overlay').classList.remove('open');
editId = null;
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
}
document.getElementById('modal-overlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ═══════════════════════════════════════════════════════════
// PINDAHKAN TITIK (drag pin)
// ═══════════════════════════════════════════════════════════
let _moveId = null, _moveOriginal = null;
function startMovePin(id) {
id = parseInt(id);
const s = spbuData.find(x => x.id == id);
if (!s) return;
if (markers[id]) markers[id].closePopup();
_moveId = id;
_moveOriginal = { lat: s.latitude, lng: s.longitude };
const m = markers[id];
m.dragging.enable();
m.setIcon(L.divIcon({
className: '',
html: `<div style="width:40px;height:40px;background:#3b82f6;border:3px solid #fff;border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 0 0 6px rgba(59,130,246,.3),0 4px 14px rgba(0,0,0,.5);"></div>`,
iconSize:[40,40], iconAnchor:[20,40], popupAnchor:[0,-42]
}));
map.flyTo([s.latitude, s.longitude], 16, { duration: .8 });
document.getElementById('move-bar').classList.add('active');
showToast('📌 Geser pin biru ke lokasi baru lalu klik Konfirmasi', 'success');
}
async function confirmMove() {
if (!_moveId) return;
const m = markers[_moveId];
const ll = m.getLatLng();
const s = spbuData.find(x => x.id == _moveId);
try {
const r = await fetch(`api.php?id=${_moveId}`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nama_spbu:s.nama_spbu, nomor_wa:s.nomor_wa, status_24jam:s.status_24jam, latitude:ll.lat, longitude:ll.lng })
});
const j = await r.json();
if (j.status === 'success') {
showToast('📍 Lokasi SPBU berhasil dipindahkan!', 'success');
_endMoveMode(false);
fetchAll();
} else { showToast('Gagal menyimpan: ' + j.message, 'error'); }
} catch(e) { showToast('Error koneksi ke server.', 'error'); }
}
function cancelMove() { _endMoveMode(true); showToast('Pemindahan dibatalkan', 'error'); }
function _endMoveMode(restore) {
if (!_moveId) return;
const m = markers[_moveId];
m.dragging.disable();
if (restore && _moveOriginal) m.setLatLng([_moveOriginal.lat, _moveOriginal.lng]);
const s = spbuData.find(x => x.id == _moveId);
if (s) m.setIcon(makeIcon(s.status_24jam === 'yes'));
document.getElementById('move-bar').classList.remove('active');
_moveId = null; _moveOriginal = null;
}
// ═══════════════════════════════════════════════════════════
// SEARCH / FILTER
// ═══════════════════════════════════════════════════════════
function filterList() {
renderList(document.getElementById('search-input').value);
}
// ═══════════════════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════════════════
let toastTimer;
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `show ${type}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ''; }, 3200);
}
// ═══════════════════════════════════════════════════════════
// UTILS
// ═══════════════════════════════════════════════════════════
function escHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══════════════════════════════════════════════════════════
// GLOBALS (dipanggil dari dalam popup Leaflet)
// ═══════════════════════════════════════════════════════════
window.openEdit = openEdit;
window.deleteSpbu = deleteSpbu;
window.startMovePin = startMovePin;
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
fetchAll();
</script>
</body>
</html>