Files
2026-06-10 19:36:52 +07:00

1832 lines
72 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPBU Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.15.0/dist/leaflet-geoman.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.15.0/dist/leaflet-geoman.min.js"></script>
<script src="https://unpkg.com/@turf/turf@6.5.0/turf.min.js"></script>
<!-- Google Fonts for premium typography -->
<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&display=swap" rel="stylesheet">
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--success-hover: #059669;
--danger: #ef4444;
--danger-hover: #dc2626;
--warning: #f59e0b;
--info: #06b6d4;
--bg-main: #f8fafc;
--bg-card: #ffffff;
--border-color: #e2e8f0;
--text-primary: #0f172a;
--text-secondary: #64748b;
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
body {
margin: 0;
padding: 0;
font-family: var(--font-family);
background-color: var(--bg-main);
color: var(--text-primary);
overflow: hidden;
}
#app {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* 320px Fixed Sidebar Data Hub */
#sidebar {
width: 320px;
min-width: 320px;
max-width: 320px;
background: #ffffff;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
z-index: 10;
}
.sidebar-header {
padding: 24px 20px 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h1 {
font-size: 18px;
font-weight: 700;
margin: 0 0 6px 0;
letter-spacing: -0.025em;
color: var(--text-primary);
}
.sidebar-header p {
font-size: 12px;
color: var(--text-secondary);
margin: 0;
line-height: 1.4;
}
/* Modern Tabs Component */
.tab-headers {
display: flex;
background: #f1f5f9;
padding: 4px;
margin: 16px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
gap: 2px;
}
.tab-btn {
flex: 1;
text-align: center;
padding: 8px 6px;
font-family: var(--font-family);
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
background: #ffffff;
color: var(--text-primary);
box-shadow: var(--shadow-sm);
font-weight: 600;
}
/* Tab Content List Hub */
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 0 20px 20px 20px;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
.side-list {
list-style: none;
padding: 0;
margin: 0;
}
.side-list li {
margin: 0 0 8px 0;
}
/* Compact Cards (Typography Hierarchical) */
.side-item {
width: 100%;
text-align: left;
border: 1px solid transparent;
background: #ffffff;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: var(--transition);
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
font-family: var(--font-family);
}
.side-item:hover {
background: #f8fafc;
border-color: var(--border-color);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.side-item .text-wrap {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
flex: 1;
}
.side-item .item-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.side-item .item-desc {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
/* Map Canvas */
#map-container {
position: relative;
flex: 1;
height: 100%;
}
#map {
height: 100%;
width: 100%;
}
/* Floating Toolbars over Map */
.floating-toolbar {
position: absolute;
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-md);
z-index: 1000;
padding: 4px;
display: flex;
transition: var(--transition);
}
/* Top-Right Drawing Toolbar */
#drawing-toolbar {
top: 20px;
right: 20px;
flex-direction: column;
gap: 2px;
}
.toolbar-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
transition: var(--transition);
position: relative;
}
.toolbar-btn:hover {
background: #f1f5f9;
color: var(--text-primary);
}
/* Tooltip style */
.toolbar-btn::after {
content: attr(data-tooltip);
position: absolute;
right: 48px;
top: 50%;
transform: translateY(-50%) translateX(5px);
background: #0f172a;
color: #ffffff;
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: var(--transition);
box-shadow: var(--shadow-md);
}
.toolbar-btn:hover::after {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.toolbar-btn.active {
background: #eff6ff;
color: var(--primary);
border: 1px solid #bfdbfe;
}
.toolbar-btn.danger-btn {
color: var(--danger);
}
.toolbar-btn.danger-btn:hover {
background: #fee2e2;
color: var(--danger-hover);
}
/* Bottom-Right Controls Toolbar */
#controls-toolbar {
bottom: 20px;
right: 20px;
gap: 4px;
}
.control-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-family: var(--font-family);
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
transition: var(--transition);
}
.control-btn:hover {
background: #f1f5f9;
color: var(--text-primary);
}
.control-btn.active {
background: #eff6ff;
color: var(--primary);
border-color: #bfdbfe;
font-weight: 600;
}
/* Floating Status Pill */
#status-pill {
position: absolute;
top: 20px;
left: 50%;
transform: translate(-50%, -10px);
background: #0f172a;
color: #ffffff;
padding: 8px 16px;
border-radius: 30px;
font-size: 12px;
font-weight: 500;
box-shadow: var(--shadow-lg);
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
pointer-events: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
#status-pill.active {
opacity: 1;
transform: translate(-50%, 0);
pointer-events: auto;
}
.status-pulse {
width: 8px;
height: 8px;
background-color: var(--success);
border-radius: 50%;
animation: pulse-ring 1.5s infinite;
}
@keyframes pulse-ring {
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
/* Modernized Modals & Dialogs */
#modalOverlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
z-index: 1500;
opacity: 0;
transition: opacity 0.25s ease;
}
#modalOverlay.active {
display: block;
opacity: 1;
}
#editModal, #shapeModal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.95);
z-index: 2000;
background: #ffffff;
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-lg);
width: 320px;
border: 1px solid var(--border-color);
opacity: 0;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
font-family: var(--font-family);
}
#editModal.active, #shapeModal.active {
display: block;
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
#editModal h3, #shapeModal h3 {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 16px 0;
letter-spacing: -0.01em;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-group label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
input[type="text"], select {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
font-family: var(--font-family);
color: var(--text-primary);
background: #ffffff;
outline: none;
transition: var(--transition);
}
input[type="text"]:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
input[readonly] {
background-color: #f8fafc;
color: var(--text-secondary);
cursor: not-allowed;
}
.form-info {
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
margin-top: -4px;
}
/* Modal Button Group */
.btn-group {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.modal-btn {
font-family: var(--font-family);
font-size: 13px;
font-weight: 600;
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border-color);
cursor: pointer;
transition: var(--transition);
background: #ffffff;
color: var(--text-primary);
}
.modal-btn:hover {
background: #f1f5f9;
}
.modal-btn.primary {
background: var(--primary);
color: #ffffff;
border-color: var(--primary);
}
.modal-btn.primary:hover {
background: var(--primary-hover);
}
.modal-btn.danger {
background: #fee2e2;
color: var(--danger-hover);
border-color: transparent;
}
.modal-btn.danger:hover {
background: #fecaca;
}
/* Leaflet Popup overrides for modern feel */
.leaflet-popup-content-wrapper {
border-radius: 12px !important;
box-shadow: var(--shadow-lg) !important;
border: 1px solid var(--border-color);
padding: 4px;
font-family: var(--font-family);
}
.leaflet-popup-content {
margin: 12px 14px !important;
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
.leaflet-popup-content b {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
display: block;
margin-bottom: 4px;
letter-spacing: -0.01em;
}
.leaflet-popup-content hr {
border: 0;
border-top: 1px solid var(--border-color);
margin: 8px 0;
}
/* Leaflet Popup Action buttons styling */
.leaflet-popup-content .btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-family);
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
border: none;
cursor: pointer;
transition: var(--transition);
margin-right: 4px;
}
.leaflet-popup-content .btn-edit {
background: #eff6ff;
color: var(--primary);
}
.leaflet-popup-content .btn-edit:hover {
background: #dbeafe;
}
.leaflet-popup-content .btn-delete {
background: #fee2e2;
color: var(--danger);
}
.leaflet-popup-content .btn-delete:hover {
background: #fecaca;
}
</style>
</head>
<body>
<div id="app">
<!-- 320px Data Management Sidebar -->
<aside id="sidebar">
<div class="sidebar-header">
<h1>GIS Workspace</h1>
<p>Kelola data spasial secara dinamis di peta. Pilih tab untuk melihat daftar data saat ini.</p>
</div>
<!-- Tabbed Navigation System -->
<div class="tab-headers">
<button class="tab-btn active" onclick="switchTab('spbu-pane', this)">SPBU (<span id="spbuCount">0</span>)</button>
<button class="tab-btn" onclick="switchTab('jalan-pane', this)">Jalan (<span id="jalanCount">0</span>)</button>
<button class="tab-btn" onclick="switchTab('parsil-pane', this)">Parsil (<span id="parsilCount">0</span>)</button>
</div>
<!-- Scrollable lists -->
<div class="sidebar-content">
<!-- SPBU List Tab Pane -->
<div class="tab-pane active" id="spbu-pane">
<ul class="side-list" id="spbuList"></ul>
</div>
<!-- Jalan List Tab Pane -->
<div class="tab-pane" id="jalan-pane">
<ul class="side-list" id="jalanList"></ul>
</div>
<!-- Parsil List Tab Pane -->
<div class="tab-pane" id="parsil-pane">
<ul class="side-list" id="parsilList"></ul>
</div>
</div>
</aside>
<!-- Expansive Interactive Map Canvas -->
<div id="map-container">
<!-- Sleek Top-Center Floating Status Pill -->
<div id="status-pill">
<span class="status-pulse"></span>
<span id="toolStatusText">Status: Siap.</span>
</div>
<!-- Top-Right Spatial Drawing Floating Toolbar -->
<div class="floating-toolbar" id="drawing-toolbar">
<button class="toolbar-btn" id="btnAddSpbu" type="button" data-tooltip="Tambah SPBU (Marker)">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
</button>
<button class="toolbar-btn" id="btnAddJalan" type="button" data-tooltip="Tambah Jalan (Polyline)">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 20 4-4 8 4 4-16"/></svg>
</button>
<button class="toolbar-btn" id="btnAddParsil" type="button" data-tooltip="Tambah Parsil (Polygon)">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 14 8 4l13 3-3 12-10 2Z"/></svg>
</button>
<button class="toolbar-btn" id="btnAddParsilCircle" type="button" data-tooltip="Parsil Lingkaran">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>
</button>
<button class="toolbar-btn" id="btnAddParsilEllipse" type="button" data-tooltip="Parsil Ellipse">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/></svg>
</button>
<button class="toolbar-btn danger-btn" id="btnStopTool" type="button" data-tooltip="Batal / Stop Tool">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
</button>
</div>
<!-- Bottom-Right Spatial Toggle Modes Floating Toolbar -->
<div class="floating-toolbar" id="controls-toolbar">
<button class="control-btn" id="btnEnableEdit" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
Edit Vertices
</button>
<button class="control-btn" id="btnEnableDrag" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 9-3-3-3 3"/><path d="M12 6v12"/><path d="m9 15 3 3 3-3"/><path d="m15 12 3-3 3 3"/><path d="M18 12H6"/><path d="m9 12-3-3-3 3"/></svg>
Drag
</button>
<button class="control-btn" id="btnDisableModes" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>
Lock Modes
</button>
<button class="control-btn" id="btnRefreshAll" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
Refresh
</button>
</div>
<!-- Map Target -->
<div id="map"></div>
</div>
</div>
<!-- Dialog overlay -->
<div id="modalOverlay" onclick="closeModal()"></div>
<!-- Sleek SPBU Edit Modal -->
<div id="editModal">
<h3>Edit SPBU</h3>
<form id="editForm" onsubmit="updateData(event)">
<input type="hidden" id="editId">
<div class="form-group">
<label for="editNama">Nama SPBU</label>
<input type="text" id="editNama" required autocomplete="off">
</div>
<div class="form-group">
<label for="editNomor">Nomor SPBU</label>
<input type="text" id="editNomor" required autocomplete="off">
</div>
<div class="form-group">
<label for="editLat">Latitude</label>
<input type="text" id="editLat" readonly>
</div>
<div class="form-group">
<label for="editLng">Longitude</label>
<input type="text" id="editLng" readonly>
</div>
<div class="form-info">
Latitude &amp; Longitude are read-only. Gunakan Drag Mode di toolbar kanan bawah untuk memindahkan marker di peta.
</div>
<div class="form-group">
<label for="editStatus">Status Operasional</label>
<select id="editStatus" required>
<option value="Buka 24 Jam">Buka 24 Jam</option>
<option value="Tidak">Tidak</option>
</select>
</div>
<div class="btn-group">
<button type="button" class="modal-btn" onclick="closeModal()">Batal</button>
<button type="submit" class="modal-btn primary">Simpan</button>
</div>
</form>
</div>
<!-- Sleek Shape (Jalan / Parsil) Dialog Modal -->
<div id="shapeModal">
<h3 id="shapeModalTitle"></h3>
<form id="shapeForm" onsubmit="submitShape(event)">
<input type="hidden" id="shapeAction" value="create">
<input type="hidden" id="shapeKind" value="">
<input type="hidden" id="shapeId" value="">
<div class="form-group">
<label id="shapeNameLabel" for="shapeName"></label>
<input type="text" id="shapeName" required autocomplete="off">
</div>
<div class="form-group">
<label id="shapeStatusLabel" for="shapeStatus"></label>
<select id="shapeStatus" required></select>
</div>
<div class="form-group">
<label id="shapeMetricLabel" for="shapeMetric"></label>
<input type="text" id="shapeMetric" readonly>
</div>
<div class="form-info">
Nilai kalkulasi di atas dihitung otomatis oleh peta dari hasil geometri Anda.
</div>
<div class="btn-group">
<button type="button" class="modal-btn" onclick="closeModal()">Batal</button>
<button type="button" id="shapeDeleteBtn" class="modal-btn danger" onclick="deleteShape()" style="display:none;">Hapus</button>
<button type="submit" id="shapeSubmitBtn" class="modal-btn primary">Simpan</button>
</div>
</form>
</div>
<script>
// Simple and robust modular tab switcher globally defined before other code
window.switchTab = function(paneId, btn) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById(paneId).classList.add('active');
btn.classList.add('active');
};
</script>
<script>
const map = L.map('map').setView([-0.055, 109.34], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap'
}).addTo(map);
const popup = L.popup();
// Define Green and Red Icons
const greenIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
});
const redIcon = new L.Icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [1, -34], shadowSize: [41, 41]
});
const markersLayer = L.layerGroup().addTo(map);
const roadsLayer = L.layerGroup().addTo(map);
const parcelsLayer = L.layerGroup().addTo(map);
// Sidebar state
const spbuMarkersById = new Map();
const roadLayersById = new Map();
const parcelLayersById = new Map();
let lastSpbuRows = [];
let lastRoadRows = [];
let lastParcelRows = [];
function setToolStatus(text) {
const el = document.getElementById('toolStatusText');
const pill = document.getElementById('status-pill');
if (el) el.textContent = 'Status: ' + text;
if (pill) {
// Show pill only when tool status is not "Siap." or general feedback
if (text && text !== 'Siap.' && text !== 'Mode dimatikan.' && text !== 'Data diperbarui.' && text !== 'Gagal refresh (cek console).') {
pill.classList.add('active');
} else {
pill.classList.remove('active');
}
}
}
function safeDisableAllTools() {
try { map.pm.disableDraw(); } catch {}
try { map.pm.disableGlobalEditMode(); } catch {}
try { map.pm.disableGlobalDragMode(); } catch {}
// Remove active classes on toolbar buttons
document.querySelectorAll('.toolbar-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.control-btn').forEach(btn => btn.classList.remove('active'));
}
function renderSidebarLists() {
// Update Tab Counts
const spbuCountEl = document.getElementById('spbuCount');
const jalanCountEl = document.getElementById('jalanCount');
const parsilCountEl = document.getElementById('parsilCount');
if (spbuCountEl) spbuCountEl.textContent = String(lastSpbuRows.length);
if (jalanCountEl) jalanCountEl.textContent = String(lastRoadRows.length);
if (parsilCountEl) parsilCountEl.textContent = String(lastParcelRows.length);
// Lists
const spbuList = document.getElementById('spbuList');
const jalanList = document.getElementById('jalanList');
const parsilList = document.getElementById('parsilList');
const mkItem = (label, sublabel, status, kind, onClick) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'side-item';
// Color-coded status dot indicator
const dot = document.createElement('span');
dot.className = 'status-dot';
let color = '#64748b'; // default slate
if (kind === 'spbu') {
color = (status === 'Buka 24 Jam') ? '#10b981' : '#ef4444';
} else if (kind === 'jalan') {
if (status === 'Jalan Nasional') color = '#ef4444';
else if (status === 'Jalan Provinsi') color = '#10b981';
else if (status === 'Jalan Kabupaten') color = '#3b82f6';
} else if (kind === 'parsil') {
if (status === 'SHM') color = '#f59e0b';
else if (status === 'HGB') color = '#8b5cf6';
else if (status === 'HGU') color = '#f97316';
else if (status === 'HP') color = '#6b7280';
}
dot.style.backgroundColor = color;
btn.appendChild(dot);
const textWrap = document.createElement('div');
textWrap.className = 'text-wrap';
const titleSpan = document.createElement('span');
titleSpan.className = 'item-title';
titleSpan.textContent = label;
textWrap.appendChild(titleSpan);
if (sublabel) {
const descSpan = document.createElement('span');
descSpan.className = 'item-desc';
descSpan.textContent = sublabel;
textWrap.appendChild(descSpan);
}
btn.appendChild(textWrap);
btn.addEventListener('click', onClick);
li.appendChild(btn);
return li;
};
spbuList.innerHTML = '';
lastSpbuRows.slice(0, 50).forEach(spbu => {
spbuList.appendChild(mkItem(
spbu.nama || ('SPBU #' + spbu.id),
'Nomor: ' + (spbu.nomor ?? '-') + ' • ' + (spbu.status ?? '-'),
spbu.status,
'spbu',
() => {
const marker = spbuMarkersById.get(spbu.id);
if (!marker) return;
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 16));
marker.openPopup();
}
));
});
jalanList.innerHTML = '';
lastRoadRows.slice(0, 50).forEach(row => {
jalanList.appendChild(mkItem(
row.nama_jalan || ('Jalan #' + row.id),
(row.status_jalan ?? '-') + ' • ' + Number(row.panjang_meter || 0).toFixed(2) + ' m',
row.status_jalan,
'jalan',
() => {
const layer = roadLayersById.get(row.id);
if (!layer) return;
try { map.fitBounds(layer.getBounds(), { padding: [20, 20] }); } catch {}
try { layer.openPopup(); } catch {}
}
));
});
parsilList.innerHTML = '';
lastParcelRows.slice(0, 50).forEach(row => {
parsilList.appendChild(mkItem(
row.nama_pemilik || ('Parsil #' + row.id),
(row.status_kepemilikan ?? '-') + ' • ' + Number(row.luas_m2 || 0).toFixed(2) + ' m²',
row.status_kepemilikan,
'parsil',
() => {
const layer = parcelLayersById.get(row.id);
if (!layer) return;
try { map.fitBounds(layer.getBounds(), { padding: [20, 20] }); } catch {}
try { layer.openPopup(); } catch {}
}
));
});
}
let isDrawing = false;
let activeShapeContext = null; // { action, kind, layer, id, saved }
let activeSpbuMarker = null; // marker instance when editing SPBU
let activeSpbuCreateMarker = null; // temp marker created from Draw Marker until saved
const SHAPE_STATUS_OPTIONS = {
jalan: ['Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten'],
parsil: ['SHM', 'HGB', 'HGU', 'HP']
};
function roadColor(status) {
if (status === 'Jalan Nasional') return '#FF0000';
if (status === 'Jalan Provinsi') return '#00FF00';
if (status === 'Jalan Kabupaten') return '#0000FF';
return '#666666';
}
function parcelFillColor(status) {
if (status === 'SHM') return '#FFD700';
if (status === 'HGB') return '#800080';
if (status === 'HGU') return '#FFA500';
if (status === 'HP') return '#808080';
return '#666666';
}
function applyShapeStyle(layer, kind, status) {
if (!layer || typeof layer.setStyle !== 'function') return;
if (kind === 'jalan') {
layer.setStyle({ color: roadColor(status), weight: 4 });
} else {
layer.setStyle({ color: '#333333', weight: 2, fillColor: parcelFillColor(status), fillOpacity: 0.45 });
}
}
function calcPolylineLengthMeters(latlngs) {
if (!Array.isArray(latlngs) || latlngs.length < 2) return 0;
let total = 0;
for (let i = 1; i < latlngs.length; i++) {
total += map.distance(latlngs[i - 1], latlngs[i]);
}
return total;
}
function calcPolygonAreaM2(layer) {
try {
const gj = layer.toGeoJSON();
return turf.area(gj);
} catch (e) {
return 0;
}
}
function updateShapeMetricFromLayer(layer, kind) {
let value = 0;
if (kind === 'jalan') {
value = calcPolylineLengthMeters(layer.getLatLngs());
} else {
value = calcPolygonAreaM2(layer);
}
document.getElementById('shapeMetric').value = value.toFixed(2);
return value;
}
function generateEllipseLatLngs(centerLatLng, radiusXMeters, radiusYMeters, steps = 72) {
const lat0 = centerLatLng.lat;
const lng0 = centerLatLng.lng;
const lat0Rad = lat0 * Math.PI / 180;
const R = 6378137; // WGS84 (meters)
const points = [];
for (let i = 0; i < steps; i++) {
const t = (i / steps) * (Math.PI * 2);
const dx = radiusXMeters * Math.cos(t);
const dy = radiusYMeters * Math.sin(t);
const dLat = (dy / R) * (180 / Math.PI);
const dLng = (dx / (R * Math.cos(lat0Rad))) * (180 / Math.PI);
points.push([lat0 + dLat, lng0 + dLng]);
}
return points;
}
function setShapeStatusOptions(kind, selected) {
const select = document.getElementById('shapeStatus');
select.innerHTML = '';
(SHAPE_STATUS_OPTIONS[kind] || []).forEach(val => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
select.appendChild(opt);
});
if (selected) select.value = selected;
}
function isShapeModalOpen() {
return document.getElementById('shapeModal').style.display === 'block';
}
function wireSpbuMarkerEvents(marker) {
marker.on('dragend', function() {
const spbu = marker.__spbu;
if (!spbu || !spbu.id) return;
const ll = marker.getLatLng();
spbu.Latitude = ll.lat.toFixed(8);
spbu.Longitude = ll.lng.toFixed(8);
if (activeSpbuMarker === marker && document.getElementById('editModal').style.display === 'block') {
document.getElementById('editLat').value = spbu.Latitude;
document.getElementById('editLng').value = spbu.Longitude;
}
let nama = spbu.nama;
let nomor = spbu.nomor;
let status = spbu.status;
// If we're editing this marker, prefer the live values from the modal
if (activeSpbuMarker === marker && document.getElementById('editModal').style.display === 'block') {
nama = document.getElementById('editNama').value;
nomor = document.getElementById('editNomor').value;
status = document.getElementById('editStatus').value;
}
spbu.nama = nama;
spbu.nomor = nomor;
spbu.status = status;
const formData = new FormData();
formData.append('id', spbu.id);
formData.append('nama', nama);
formData.append('nomor', nomor);
formData.append('status', status);
formData.append('latitude', spbu.Latitude);
formData.append('longitude', spbu.Longitude);
fetch('update_spbu.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
console.error('Failed to update SPBU location:', data);
}
})
.catch(err => console.error('Error updating SPBU location:', err));
});
}
function wireRoadLayerEvents(layer) {
const save = () => {
if (isShapeModalOpen() && activeShapeContext && activeShapeContext.layer === layer) return;
const meta = layer.__shape;
if (!meta || !meta.id) return;
const length = calcPolylineLengthMeters(layer.getLatLngs());
const geometry = layer.toGeoJSON().geometry;
const formData = new FormData();
formData.append('id', meta.id);
formData.append('nama_jalan', meta.nama);
formData.append('status_jalan', meta.status);
formData.append('panjang_meter', length);
formData.append('koordinat', JSON.stringify(geometry));
fetch('update_jalan.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
console.error('Failed to update jalan geometry:', data);
}
})
.catch(err => console.error('Error updating jalan:', err));
};
layer.on('pm:update', save);
layer.on('pm:dragend', save);
}
function wireParcelLayerEvents(layer) {
const save = () => {
if (isShapeModalOpen() && activeShapeContext && activeShapeContext.layer === layer) return;
const meta = layer.__shape;
if (!meta || !meta.id) return;
const area = calcPolygonAreaM2(layer);
const geometry = layer.toGeoJSON().geometry;
const formData = new FormData();
formData.append('id', meta.id);
formData.append('nama_pemilik', meta.nama);
formData.append('status_kepemilikan', meta.status);
formData.append('luas_m2', area);
formData.append('koordinat', JSON.stringify(geometry));
fetch('update_parsil.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
console.error('Failed to update parsil geometry:', data);
}
})
.catch(err => console.error('Error updating parsil:', err));
};
layer.on('pm:update', save);
layer.on('pm:dragend', save);
}
function refreshMarkers() {
return fetch('get_spbu.php')
.then(response => response.json())
.then(data => {
markersLayer.clearLayers();
spbuMarkersById.clear();
lastSpbuRows = Array.isArray(data) ? data : [];
lastSpbuRows.forEach(spbu => {
const markerIcon = (spbu.status === 'Buka 24 Jam') ? greenIcon : redIcon;
const lat = spbu.Latitude;
const lng = spbu.Longitude;
const popupContent = `
<b>${spbu.nama}</b><br>
Nomor: ${spbu.nomor}<br>
Buka: ${spbu.status}<br>
Latitude: ${lat}<br>
Longitude: ${lng}<br>
<hr style="margin: 8px 0;">
<button type="button" class="btn-action btn-edit js-edit">Edit</button>
<button type="button" class="btn-action btn-delete js-delete">Delete</button>
`;
const marker = L.marker([lat, lng], { icon: markerIcon });
marker.__spbu = spbu;
wireSpbuMarkerEvents(marker);
marker.bindPopup(popupContent);
marker.addTo(markersLayer);
if (spbu && spbu.id != null) spbuMarkersById.set(spbu.id, marker);
});
renderSidebarLists();
})
.catch(error => console.error('Error fetching data:', error));
}
function refreshRoads() {
return fetch('get_jalan.php')
.then(r => r.json())
.then(rows => {
roadsLayer.clearLayers();
roadLayersById.clear();
lastRoadRows = Array.isArray(rows) ? rows : [];
lastRoadRows.forEach(row => {
let geom;
try {
geom = JSON.parse(row.koordinat);
} catch {
return;
}
const gj = L.geoJSON(geom, {
style: { color: roadColor(row.status_jalan), weight: 4 }
});
let firstLayer = null;
gj.eachLayer(l => {
if (!firstLayer) firstLayer = l;
l.__shape = {
kind: 'jalan',
id: row.id,
nama: row.nama_jalan,
status: row.status_jalan
};
l.bindPopup('<div>Loading...</div>');
wireRoadLayerEvents(l);
});
gj.addTo(roadsLayer);
if (row && row.id != null && firstLayer) roadLayersById.set(row.id, firstLayer);
});
renderSidebarLists();
})
.catch(err => console.error('Error fetching jalan:', err));
}
function refreshParcels() {
return fetch('get_parsil.php')
.then(r => r.json())
.then(rows => {
parcelsLayer.clearLayers();
parcelLayersById.clear();
lastParcelRows = Array.isArray(rows) ? rows : [];
lastParcelRows.forEach(row => {
let geom;
try {
geom = JSON.parse(row.koordinat);
} catch {
return;
}
const gj = L.geoJSON(geom, {
style: { color: '#333333', weight: 2, fillColor: parcelFillColor(row.status_kepemilikan), fillOpacity: 0.45 }
});
let firstLayer = null;
gj.eachLayer(l => {
if (!firstLayer) firstLayer = l;
l.__shape = {
kind: 'parsil',
id: row.id,
nama: row.nama_pemilik,
status: row.status_kepemilikan
};
l.bindPopup('<div>Loading...</div>');
wireParcelLayerEvents(l);
});
gj.addTo(parcelsLayer);
if (row && row.id != null && firstLayer) parcelLayersById.set(row.id, firstLayer);
});
renderSidebarLists();
})
.catch(err => console.error('Error fetching parsil:', err));
}
function deleteShapeById(kind, id) {
if (!confirm('Are you sure you want to delete this shape?')) return;
const formData = new FormData();
formData.append('id', id);
const url = (kind === 'jalan') ? 'delete_jalan.php' : 'delete_parsil.php';
fetch(url, { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
console.error('Delete failed:', data);
return;
}
map.closePopup();
return (kind === 'jalan') ? refreshRoads() : refreshParcels();
})
.catch(err => console.error('Error deleting shape:', err));
}
// Wire popup buttons (avoids inline onclick bugs)
map.on('popupopen', function(e) {
const source = e.popup._source;
if (!source) return;
const el = e.popup.getElement();
if (!el) return;
// SPBU markers
const spbu = source.__spbu;
if (spbu) {
const editBtn = el.querySelector('.js-edit');
const deleteBtn = el.querySelector('.js-delete');
if (editBtn) {
editBtn.addEventListener('click', function(ev) {
ev.preventDefault();
openModal(spbu, source);
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', function(ev) {
ev.preventDefault();
deleteData(spbu.id);
});
}
return;
}
// Roads/Parcels
const meta = source.__shape;
if (!meta || !meta.kind || !meta.id) return;
const kind = meta.kind;
const metric = (kind === 'jalan')
? calcPolylineLengthMeters(source.getLatLngs())
: calcPolygonAreaM2(source);
const metricLabel = (kind === 'jalan') ? 'Panjang (m)' : 'Luas (m²)';
const statusLabel = (kind === 'jalan') ? 'Status Jalan' : 'Status Kepemilikan';
e.popup.setContent(`
<b>${meta.nama}</b><br>
${statusLabel}: ${meta.status}<br>
${metricLabel}: ${metric.toFixed(2)}<br>
<hr style="margin: 8px 0;">
<button type="button" class="btn-action btn-edit js-shape-edit">Edit</button>
<button type="button" class="btn-action btn-delete js-shape-delete">Delete</button>
`);
const el2 = e.popup.getElement();
if (!el2) return;
const editBtn2 = el2.querySelector('.js-shape-edit');
const deleteBtn2 = el2.querySelector('.js-shape-delete');
if (editBtn2) {
editBtn2.addEventListener('click', function(ev) {
ev.preventDefault();
openShapeModal({ action: 'update', kind, layer: source, id: meta.id });
});
}
if (deleteBtn2) {
deleteBtn2.addEventListener('click', function(ev) {
ev.preventDefault();
deleteShapeById(kind, meta.id);
});
}
});
// Leaflet-Geoman controls are disabled/hidden as we use our custom top-right & bottom-right floating toolbars
map.pm.addControls({
drawMarker: false,
drawCircle: false,
drawPolyline: false,
drawRectangle: false,
drawPolygon: false,
editMode: false,
dragMode: false,
cutPolygon: false,
removalMode: false,
rotateMode: false,
oneBlock: false
});
// Make snapping less aggressive (default snapDistance is 20px)
// - snapSegment=false prevents snapping to the middle of lines (common cause of “always snaps onto polyline”)
// - snapVertex=true keeps vertex-to-vertex snapping available
map.pm.setGlobalOptions({
snappable: true,
snapDistance: 8,
snapMiddle: false,
snapSegment: false,
snapVertex: true
});
map.pm.applyGlobalOptions();
// If the "Add SPBU" popup is closed without saving, remove the temp marker.
map.on('popupclose', function(e) {
if (!e || e.popup !== popup) return;
if (!activeSpbuCreateMarker) return;
try { map.removeLayer(activeSpbuCreateMarker); } catch {}
try { markersLayer.removeLayer(activeSpbuCreateMarker); } catch {}
activeSpbuCreateMarker = null;
});
map.on('pm:drawstart', () => {
isDrawing = true;
map.closePopup();
});
map.on('pm:drawend', () => {
isDrawing = false;
});
map.on('pm:create', (e) => {
const { layer, shape } = e;
const isCircle = (shape === 'Circle') || (layer instanceof L.Circle);
const isRectangle = (shape === 'Rectangle') || (layer instanceof L.Rectangle);
const isLine = (shape === 'Line') || (layer instanceof L.Polyline && !(layer instanceof L.Polygon));
const isPolygon = (shape === 'Polygon') || (layer instanceof L.Polygon && !(layer instanceof L.Rectangle));
const isMarker = (shape === 'Marker') || (layer instanceof L.Marker);
if (!isLine && !isPolygon && !isMarker && !isCircle && !isRectangle) return;
map.closePopup();
if (isLine) {
roadsLayer.addLayer(layer);
openShapeModal({ action: 'create', kind: 'jalan', layer, id: null });
}
if (isPolygon) {
parcelsLayer.addLayer(layer);
openShapeModal({ action: 'create', kind: 'parsil', layer, id: null });
}
if (isCircle) {
// Convert circle -> polygon (stored as Polygon GeoJSON)
const center = layer.getLatLng();
const r = layer.getRadius();
const ring = generateEllipseLatLngs(center, r, r, 72);
const poly = L.polygon(ring);
parcelsLayer.addLayer(poly);
try { map.removeLayer(layer); } catch {}
openShapeModal({ action: 'create', kind: 'parsil', layer: poly, id: null });
}
if (isRectangle) {
// Draw rectangle to define an ellipse, then convert -> polygon ellipse
const bounds = layer.getBounds();
const center = bounds.getCenter();
const west = bounds.getWest();
const east = bounds.getEast();
const north = bounds.getNorth();
const south = bounds.getSouth();
const widthMeters = map.distance([center.lat, west], [center.lat, east]);
const heightMeters = map.distance([south, center.lng], [north, center.lng]);
const rx = Math.max(0, widthMeters / 2);
const ry = Math.max(0, heightMeters / 2);
const ring = generateEllipseLatLngs(center, rx, ry, 72);
const poly = L.polygon(ring);
parcelsLayer.addLayer(poly);
try { map.removeLayer(layer); } catch {}
openShapeModal({ action: 'create', kind: 'parsil', layer: poly, id: null });
}
if (isMarker) {
// Geoman Draw Marker == Add SPBU workflow
activeSpbuCreateMarker = layer;
const ll = layer.getLatLng();
const lat = ll.lat.toFixed(8);
const lng = ll.lng.toFixed(8);
const formHTML = `
<form id="spbuForm" onsubmit="saveData(event)">
<h4 style="margin: 0 0 6px 0;">Tambah SPBU</h4>
<div style="font-size:12px; color:#6c757d; margin-bottom:8px;">Isi data berikut, lalu klik Save.</div>
<label style="font-size:12px; color:#495057;">Nama SPBU</label>
<input type="text" id="nama" required>
<label style="font-size:12px; color:#495057;">Nomor SPBU</label>
<input type="text" id="nomor" required>
<label style="font-size:12px; color:#495057;">Status</label>
<select id="status" required>
<option value="Buka 24 Jam">Buka 24 Jam</option>
<option value="Tidak">Tidak</option>
</select>
<div>Latitude: ${lat}</div>
<div>Longitude: ${lng}</div>
<input type="hidden" id="lat" value="${lat}">
<input type="hidden" id="lng" value="${lng}">
<button type="submit" style="background:#28a745; color:white; border:none; padding:5px;">Save</button>
</form>
`;
popup.setLatLng(ll).setContent(formHTML).openOn(map);
// Single-click add behavior (disable draw after placing one marker)
try { map.pm.disableDraw('Marker'); } catch {}
}
});
map.on('pm:edit', (e) => {
if (!activeShapeContext || !activeShapeContext.layer) return;
if (e && e.layer && e.layer !== activeShapeContext.layer) return;
updateShapeMetricFromLayer(activeShapeContext.layer, activeShapeContext.kind);
});
map.on('pm:update', (e) => {
if (!activeShapeContext || !activeShapeContext.layer) return;
if (e && e.layer && e.layer !== activeShapeContext.layer) return;
updateShapeMetricFromLayer(activeShapeContext.layer, activeShapeContext.kind);
});
// Save (create)
function saveData(event) {
event.preventDefault();
const formData = new FormData();
formData.append('nama', document.getElementById('nama').value);
formData.append('nomor', document.getElementById('nomor').value);
formData.append('status', document.getElementById('status').value);
formData.append('latitude', document.getElementById('lat').value);
formData.append('longitude', document.getElementById('lng').value);
fetch('save_spbu.php', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
map.closePopup();
if (activeSpbuCreateMarker) {
try { map.removeLayer(activeSpbuCreateMarker); } catch {}
try { markersLayer.removeLayer(activeSpbuCreateMarker); } catch {}
activeSpbuCreateMarker = null;
}
return refreshMarkers();
}
console.error('Save failed:', data);
})
.catch(error => console.error('Error:', error));
}
function setActiveDrawButton(btnId) {
document.querySelectorAll('.toolbar-btn').forEach(btn => btn.classList.remove('active'));
if (btnId) {
const activeBtn = document.getElementById(btnId);
if (activeBtn) activeBtn.classList.add('active');
}
}
function setActiveControlButton(btnId) {
document.querySelectorAll('.control-btn').forEach(btn => btn.classList.remove('active'));
if (btnId) {
const activeBtn = document.getElementById(btnId);
if (activeBtn) activeBtn.classList.add('active');
}
}
// Custom Toolbar button wiring
document.getElementById('btnAddSpbu').addEventListener('click', () => {
safeDisableAllTools();
setActiveDrawButton('btnAddSpbu');
try { map.pm.enableDraw('Marker'); } catch {}
setToolStatus('Tambah SPBU: klik di peta untuk meletakkan marker.');
});
document.getElementById('btnAddJalan').addEventListener('click', () => {
safeDisableAllTools();
setActiveDrawButton('btnAddJalan');
try { map.pm.enableDraw('Line'); } catch {}
setToolStatus('Tambah Jalan: klik beberapa titik, lalu double-click untuk selesai.');
});
document.getElementById('btnAddParsil').addEventListener('click', () => {
safeDisableAllTools();
setActiveDrawButton('btnAddParsil');
try { map.pm.enableDraw('Polygon'); } catch {}
setToolStatus('Tambah Parsil: klik beberapa titik, lalu click titik awal untuk selesai.');
});
document.getElementById('btnAddParsilCircle').addEventListener('click', () => {
safeDisableAllTools();
setActiveDrawButton('btnAddParsilCircle');
try { map.pm.enableDraw('Circle'); } catch {}
setToolStatus('Parsil Lingkaran: klik-drag untuk ukuran. Akan disimpan sebagai polygon.');
});
document.getElementById('btnAddParsilEllipse').addEventListener('click', () => {
safeDisableAllTools();
setActiveDrawButton('btnAddParsilEllipse');
try { map.pm.enableDraw('Rectangle'); } catch {}
setToolStatus('Parsil Ellipse: gambar rectangle, akan dikonversi jadi ellipse polygon.');
});
document.getElementById('btnStopTool').addEventListener('click', () => {
safeDisableAllTools();
setActiveDrawButton(null);
setToolStatus('Siap.');
});
document.getElementById('btnEnableEdit').addEventListener('click', () => {
safeDisableAllTools();
setActiveControlButton('btnEnableEdit');
try { map.pm.enableGlobalEditMode(); } catch {}
setToolStatus('Edit Mode aktif: klik shape lalu edit vertex.');
});
document.getElementById('btnEnableDrag').addEventListener('click', () => {
safeDisableAllTools();
setActiveControlButton('btnEnableDrag');
try { map.pm.enableGlobalDragMode(); } catch {}
setToolStatus('Drag Mode aktif: geser marker/shape untuk pindah lokasi.');
});
document.getElementById('btnDisableModes').addEventListener('click', () => {
safeDisableAllTools();
setActiveControlButton('btnDisableModes');
setToolStatus('Mode dimatikan.');
});
document.getElementById('btnRefreshAll').addEventListener('click', () => {
// Refresh doesn't hold an active mode state on buttons permanently
Promise.all([refreshMarkers(), refreshRoads(), refreshParcels()])
.then(() => setToolStatus('Data diperbarui.'))
.catch(() => setToolStatus('Gagal refresh (cek console).'));
});
// Delete
function deleteData(id) {
if (!confirm("Are you sure you want to delete this SPBU?")) return;
const formData = new FormData();
formData.append('id', id);
fetch('delete_spbu.php', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
map.closePopup();
return refreshMarkers();
}
console.error('Delete failed:', data);
})
.catch(error => console.error('Error:', error));
}
// Modal Controllers
function openModal(spbu, marker) {
activeSpbuMarker = marker || null;
document.getElementById('editId').value = spbu.id;
document.getElementById('editNama').value = spbu.nama;
document.getElementById('editNomor').value = spbu.nomor;
document.getElementById('editStatus').value = spbu.status;
document.getElementById('editLat').value = spbu.Latitude;
document.getElementById('editLng').value = spbu.Longitude;
if (activeSpbuMarker && activeSpbuMarker.dragging) {
try { activeSpbuMarker.dragging.enable(); } catch {}
}
document.getElementById('modalOverlay').classList.add('active');
document.getElementById('editModal').classList.add('active');
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('active');
document.getElementById('editModal').classList.remove('active');
if (activeSpbuMarker && activeSpbuMarker.dragging) {
try { activeSpbuMarker.dragging.disable(); } catch {}
}
activeSpbuMarker = null;
const shapeModal = document.getElementById('shapeModal');
if (shapeModal && shapeModal.classList.contains('active')) {
shapeModal.classList.remove('active');
if (activeShapeContext && activeShapeContext.layer) {
try { activeShapeContext.layer.pm && activeShapeContext.layer.pm.disable(); } catch {}
try { activeShapeContext.layer.pm && activeShapeContext.layer.pm.disableLayerDrag && activeShapeContext.layer.pm.disableLayerDrag(); } catch {}
if (activeShapeContext.action === 'create' && !activeShapeContext.saved) {
// User cancelled a newly drawn shape: remove it.
if (activeShapeContext.kind === 'jalan') roadsLayer.removeLayer(activeShapeContext.layer);
if (activeShapeContext.kind === 'parsil') parcelsLayer.removeLayer(activeShapeContext.layer);
}
}
activeShapeContext = null;
}
}
function openShapeModal({ action, kind, layer, id }) {
// Close SPBU modal if open
document.getElementById('editModal').classList.remove('active');
if (activeSpbuMarker && activeSpbuMarker.dragging) {
try { activeSpbuMarker.dragging.disable(); } catch {}
}
activeSpbuMarker = null;
activeShapeContext = { action, kind, layer, id, saved: action === 'update' };
document.getElementById('shapeAction').value = action;
document.getElementById('shapeKind').value = kind;
document.getElementById('shapeId').value = id ?? '';
document.getElementById('shapeModalTitle').textContent = (kind === 'jalan')
? (action === 'create' ? 'Tambah Jalan' : 'Edit Jalan')
: (action === 'create' ? 'Tambah Parsil' : 'Edit Parsil');
document.getElementById('shapeNameLabel').textContent = (kind === 'jalan') ? 'Nama Jalan' : 'Nama Pemilik';
document.getElementById('shapeStatusLabel').textContent = (kind === 'jalan') ? 'Status Jalan' : 'Status Kepemilikan';
document.getElementById('shapeMetricLabel').textContent = (kind === 'jalan') ? 'Panjang (meter)' : 'Luas (m²)';
// Prefill fields from layer metadata if available
const currentName = layer.__shape && layer.__shape.nama ? layer.__shape.nama : '';
const currentStatus = layer.__shape && layer.__shape.status ? layer.__shape.status : (SHAPE_STATUS_OPTIONS[kind][0] || '');
document.getElementById('shapeName').value = currentName;
setShapeStatusOptions(kind, currentStatus);
// Metric is auto calculated from geometry
updateShapeMetricFromLayer(layer, kind);
// Update style live when status changes
const statusEl = document.getElementById('shapeStatus');
statusEl.onchange = () => applyShapeStyle(layer, kind, statusEl.value);
applyShapeStyle(layer, kind, statusEl.value);
// Enable vertex editing while modal is open
try { layer.pm && layer.pm.enable(); } catch {}
try { layer.pm && layer.pm.enableLayerDrag && layer.pm.enableLayerDrag(); } catch {}
// Delete visible only for update
document.getElementById('shapeDeleteBtn').style.display = (action === 'update') ? 'inline-block' : 'none';
document.getElementById('shapeSubmitBtn').textContent = (action === 'update') ? 'Update' : 'Save';
document.getElementById('modalOverlay').classList.add('active');
document.getElementById('shapeModal').classList.add('active');
}
function submitShape(event) {
event.preventDefault();
if (!activeShapeContext || !activeShapeContext.layer) return;
const action = document.getElementById('shapeAction').value;
const kind = document.getElementById('shapeKind').value;
const id = document.getElementById('shapeId').value;
const name = document.getElementById('shapeName').value;
const status = document.getElementById('shapeStatus').value;
const metric = updateShapeMetricFromLayer(activeShapeContext.layer, kind);
const geometry = activeShapeContext.layer.toGeoJSON().geometry;
const formData = new FormData();
if (kind === 'jalan') {
formData.append('nama_jalan', name);
formData.append('status_jalan', status);
formData.append('panjang_meter', metric);
} else {
formData.append('nama_pemilik', name);
formData.append('status_kepemilikan', status);
formData.append('luas_m2', metric);
}
formData.append('koordinat', JSON.stringify(geometry));
if (action === 'update') formData.append('id', id);
const url = (kind === 'jalan')
? (action === 'update' ? 'update_jalan.php' : 'save_jalan.php')
: (action === 'update' ? 'update_parsil.php' : 'save_parsil.php');
fetch(url, { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
console.error('Shape save/update failed:', data);
return;
}
// persist metadata locally so immediate re-open works without refresh
activeShapeContext.layer.__shape = {
kind,
id: action === 'update' ? parseInt(id, 10) : (activeShapeContext.layer.__shape && activeShapeContext.layer.__shape.id) || null,
nama: name,
status
};
applyShapeStyle(activeShapeContext.layer, kind, status);
activeShapeContext.saved = true;
closeModal();
return (kind === 'jalan') ? refreshRoads() : refreshParcels();
})
.catch(err => console.error('Error submitting shape:', err));
}
function deleteShape() {
if (!activeShapeContext || !activeShapeContext.layer) return;
if (activeShapeContext.action !== 'update') return;
if (!confirm('Are you sure you want to delete this shape?')) return;
const kind = activeShapeContext.kind;
const id = document.getElementById('shapeId').value;
const formData = new FormData();
formData.append('id', id);
const url = (kind === 'jalan') ? 'delete_jalan.php' : 'delete_parsil.php';
fetch(url, { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
console.error('Delete failed:', data);
return;
}
closeModal();
return (kind === 'jalan') ? refreshRoads() : refreshParcels();
})
.catch(err => console.error('Error deleting shape:', err));
}
// Update
function updateData(event) {
event.preventDefault();
// If user dragged the marker, reflect latest location
if (activeSpbuMarker && activeSpbuMarker.__spbu) {
document.getElementById('editLat').value = activeSpbuMarker.__spbu.Latitude;
document.getElementById('editLng').value = activeSpbuMarker.__spbu.Longitude;
}
const formData = new FormData();
formData.append('id', document.getElementById('editId').value);
formData.append('nama', document.getElementById('editNama').value);
formData.append('nomor', document.getElementById('editNomor').value);
formData.append('status', document.getElementById('editStatus').value);
formData.append('latitude', document.getElementById('editLat').value);
formData.append('longitude', document.getElementById('editLng').value);
fetch('update_spbu.php', { method: 'POST', body: formData })
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
closeModal();
return refreshMarkers();
}
console.error('Update failed:', data);
})
.catch(error => console.error('Error:', error));
}
// Initial load
Promise.all([refreshMarkers(), refreshRoads(), refreshParcels()])
.then(() => setToolStatus('Siap.'));
</script>
</body>
</html>