Files
2026-06-11 21:15:09 +07:00

1459 lines
68 KiB
PHP
Raw Permalink 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="en" class="dark">
<head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>WebGIS Dashboard</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-surface": "#dae2fd",
"outline": "#8d90a2",
"on-primary-container": "#dfe3ff",
"inverse-on-surface": "#283044",
"primary": "#b7c4ff",
"surface-container": "#171f33",
"on-tertiary-fixed-variant": "#5a00c6",
"on-secondary": "#263143",
"tertiary-fixed": "#eaddff",
"on-error": "#690005",
"on-primary-fixed-variant": "#0038b6",
"primary-fixed": "#dde1ff",
"on-error-container": "#ffdad6",
"on-tertiary-fixed": "#25005a",
"on-primary": "#002682",
"surface": "#0b1326",
"secondary-container": "#3e495d",
"on-background": "#dae2fd",
"on-surface-variant": "#c3c5d9",
"on-secondary-fixed": "#111c2d",
"error-container": "#93000a",
"inverse-primary": "#004ced",
"secondary-fixed": "#d8e3fb",
"on-primary-fixed": "#001452",
"on-secondary-container": "#aeb9d0",
"on-tertiary-container": "#ecdfff",
"surface-container-lowest": "#060e20",
"surface-container-high": "#222a3d",
"surface-dim": "#0b1326",
"secondary": "#bcc7de",
"on-tertiary": "#3f008e",
"primary-container": "#0052ff",
"tertiary": "#d2bbff",
"surface-variant": "#2d3449",
"surface-tint": "#b7c4ff",
"surface-container-low": "#131b2e",
"surface-bright": "#31394d",
"background": "#0b1326",
"on-secondary-fixed-variant": "#3c475a",
"inverse-surface": "#dae2fd",
"tertiary-fixed-dim": "#d2bbff",
"primary-fixed-dim": "#b7c4ff",
"outline-variant": "#434656",
"tertiary-container": "#7b39ec",
"secondary-fixed-dim": "#bcc7de",
"surface-container-highest": "#2d3449",
"error": "#ffb4ab"
},
borderRadius: {
DEFAULT: '0.25rem',
lg: '0.5rem',
xl: '0.75rem',
full: '9999px'
},
fontFamily: {
headline: ['Manrope'],
body: ['Inter'],
label: ['Inter']
}
}
}
}
</script>
<style>
body { font-family: "Inter", sans-serif; }
.font-manrope { font-family: "Manrope", sans-serif; }
.glass { background: rgba(23, 31, 51, 0.7); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); }
.celestial-shadow { box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5), 0 0 20px rgba(183, 196, 255, 0.05); }
.map-bg { background-image: url(https://lh3.googleusercontent.com/aida-public/AB6AXuCyPXdDASv9Rbub1TekuMzrRsMuR_hPwbNxnzcHdd8c7BZiH0hZhoGZG98H6dzlhx-72Yh2etKw42boJCR9KDD6cYTbPgRyY1BIRbx_2uEPiPXqYslErPWsHtCtHb1pON_9dWiRPqnOvRGCtb0zBUw5bPhy9qhMnyoqjD-DrT_9bpKKWh3vRT6OfxGKkM6ohEdGE1zpUG56oZhhO0N-LVbSxZq8YLF3sH2pWYyHU9vdcu__gCmEl9oTAzJQF6oQzQj6kb8AyriHeGM); background-size: cover; background-position: center; }
.tonal-stepping { background: linear-gradient(180deg, rgba(11, 19, 38, 0.4) 0%, rgba(6, 14, 32, 0.4) 100%); }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(141, 144, 162, 0.2); border-radius: 10px; }
#map { width: 100%; height: 100%; z-index: 10; }
#map .leaflet-tile-pane { filter: brightness(1.05) contrast(1.08); }
#map .leaflet-control { z-index: 30; }
.popup-modern {
width: 230px;
padding: 18px;
border-radius: 20px;
background: linear-gradient(145deg, #1e293b, #0f172a);
color: #e2e8f0;
text-align: center;
box-shadow:
0 20px 40px rgba(0,0,0,0.5),
inset 0 1px 1px rgba(255,255,255,0.05);
}
.popup-form {
width: 280px;
padding: 18px;
border-radius: 20px;
background: linear-gradient(145deg, #1e293b, #0f172a);
color: #e2e8f0;
text-align: left;
box-shadow:
0 20px 40px rgba(0,0,0,0.5),
inset 0 1px 1px rgba(255,255,255,0.05);
}
/* TITLE */
.popup-modern h3 {
font-size: 18px;
font-weight: 700;
margin-bottom: 10px;
text-transform: capitalize;
}
/* TEXT */
.popup-modern p {
font-size: 13px;
color: #cbd5e1;
margin: 3px 0;
}
/* BUTTON BASE */
.popup-modern button {
width: 100%;
padding: 10px;
margin-top: 8px;
border: none;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
/* BUTTON HOVER */
.popup-modern button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0,0,0,0.3);
}
/* COLORS */
.btn-edit {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.btn-lokasi {
background: linear-gradient(135deg, #9333ea, #7c3aed);
}
.btn-hapus {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.leaflet-popup-content-wrapper {
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
}
.leaflet-popup-tip {
display: none;
}
.leaflet-popup-content {
margin: 0 !important;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw/dist/leaflet.draw.css"/>
<script src="https://unpkg.com/leaflet-draw/dist/leaflet.draw.js"></script>
<script src="https://unpkg.com/leaflet-geometryutil"></script>
</head>
<body class="bg-surface text-on-surface overflow-hidden">
<main class="flex h-screen">
<aside class="w-[340px] h-full bg-[#0b1326]/95 backdrop-blur-2xl border-r border-slate-800/20 celestial-shadow z-40 flex flex-col overflow-y-auto custom-scrollbar">
<div class="p-6 border-b border-outline-variant/10">
<div class="flex items-center gap-3 mb-1">
<span class="text-blue-400 uppercase tracking-widest text-[10px] font-manrope font-bold">Spatial Metrics</span>
</div>
<h2 class="text-on-surface font-manrope font-bold text-lg">WEBGIS</h2>
<p class="text-outline text-[11px] font-medium tracking-wide">LIVE GIS DATA FEED SYSTEM ACTIVE</p>
</div>
<div class="p-5 space-y-6">
<div class="grid grid-cols-1 gap-3">
<div class="bg-surface-container-high/40 p-4 rounded-xl border border-outline-variant/5">
<div class="flex justify-between items-start">
<div>
<p class="text-outline text-[10px] uppercase font-bold tracking-wider mb-1">Total SPBU</p>
<h3 class="text-2xl font-manrope font-extrabold text-on-surface" id="count-spbu">0</h3>
</div>
<span class="material-symbols-outlined text-primary text-xl">local_gas_station</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-surface-container-high/40 p-4 rounded-xl border border-outline-variant/5">
<p class="text-outline text-[10px] uppercase font-bold tracking-wider mb-1">Total Jalan</p>
<h3 class="text-xl font-manrope font-bold text-on-surface" id="count-jalan">0</h3>
</div>
<div class="bg-surface-container-high/40 p-4 rounded-xl border border-outline-variant/5">
<p class="text-outline text-[10px] uppercase font-bold tracking-wider mb-1">Total Parsil</p>
<h3 class="text-xl font-manrope font-bold text-on-surface" id="count-parsil">0</h3>
</div>
</div>
</div>
<div class="space-y-2">
<button id="btn-point" class="w-full bg-primary-container text-white py-3 rounded-xl font-manrope font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-primary-container/20 hover:scale-[1.02] transition-transform" onclick="setMode('point')">
<span class="material-symbols-outlined text-sm">add_location_alt</span>
Tambah Point
</button>
<div class="grid grid-cols-2 gap-2">
<button id="btn-jalan" class="bg-surface-container-highest text-on-surface py-2.5 rounded-xl font-manrope font-bold text-xs flex items-center justify-center gap-2 hover:bg-surface-bright transition-colors" onclick="setMode('jalan')">
<span class="material-symbols-outlined text-sm">route</span>
Tambah Jalan
</button>
<button id="btn-parsil" class="bg-surface-container-highest text-on-surface py-2.5 rounded-xl font-manrope font-bold text-xs flex items-center justify-center gap-2 hover:bg-surface-bright transition-colors" onclick="setMode('parsil')">
<span class="material-symbols-outlined text-sm">grid_view</span>
Tambah Parsil
</button>
</div>
<button id="btn-cancel" class="w-full bg-error-container/20 text-error border border-error/20 py-2.5 rounded-xl font-manrope font-bold text-xs flex items-center justify-center gap-2 hover:bg-error-container/30 transition-colors" onclick="setMode(null)">
<span class="material-symbols-outlined text-sm">cancel</span>
Batal Mode
</button>
</div>
<div>
<h4 class="text-outline text-[10px] uppercase font-bold tracking-widest mb-3 px-1">Recent Assets</h4>
<div id="recent-assets" class="space-y-2"></div>
</div>
<div class="bg-surface-container-lowest/40 rounded-2xl p-4 border border-outline-variant/10">
<h4 class="text-on-surface font-manrope font-bold text-xs mb-4 flex items-center gap-2">
<span class="material-symbols-outlined text-sm">legend_toggle</span>
Map Legend
</h4>
<div class="space-y-4">
<div class="space-y-2">
<p class="text-[9px] uppercase font-bold text-outline tracking-widest">Petroleum Assets</p>
<div class="flex items-center gap-3 text-[11px] font-medium text-on-surface-variant">
<span class="w-2.5 h-2.5 rounded-full bg-primary shadow-[0_0_8px_rgba(183,196,255,0.6)]"></span>
SPBU 24 Jam
</div>
<div class="flex items-center gap-3 text-[11px] font-medium text-on-surface-variant">
<span class="w-2.5 h-2.5 rounded-full bg-outline"></span>
SPBU Terbatas
</div>
</div>
<div class="space-y-2">
<p class="text-[9px] uppercase font-bold text-outline tracking-widest">Infrastructure</p>
<div class="flex items-center gap-3 text-[11px] font-medium text-on-surface-variant">
<span class="w-6 h-[2px] bg-secondary-fixed shadow-[0_0_8px_rgba(216,227,251,0.6)]"></span>
Arteri Utama
</div>
<div class="flex items-center gap-3 text-[11px] font-medium text-on-surface-variant">
<span class="w-6 h-[2px] bg-tertiary shadow-[0_0_8px_rgba(210,187,255,0.6)]"></span>
Parsil Area
</div>
</div>
</div>
</div>
</div>
</aside>
<section class="flex-1 relative overflow-hidden">
<div id="map" class="absolute inset-0 z-10"></div>
<div id="location-edit-panel" class="hidden absolute bottom-10 right-6 z-50 w-[320px] glass rounded-3xl border border-outline-variant/20 p-4 celestial-shadow shadow-xl">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-[10px] text-primary uppercase font-bold tracking-widest mb-1">Edit Lokasi Aktif</p>
<p class="text-[11px] leading-5 text-on-surface-variant">Gunakan tombol Simpan untuk menyimpan perubahan lokasi, atau Batal untuk mengembalikan posisi sebelumnya.</p>
</div>
<button class="text-slate-300 hover:text-white" onclick="cancelLocationEdit()">×</button>
</div>
<div class="mt-4 grid grid-cols-2 gap-3">
<button id="location-save-btn" class="bg-primary text-surface rounded-2xl py-3 text-sm font-semibold">Simpan Lokasi</button>
<button id="location-cancel-btn" class="bg-surface-container-high text-on-surface rounded-2xl py-3 text-sm font-semibold">Batal</button>
</div>
</div>
<div id="toast-container" class="fixed top-6 right-6 z-50 flex flex-col gap-3 pointer-events-none"></div>
<div class="absolute top-6 left-6 z-40">
<div class="bg-secondary-container/80 backdrop-blur-md px-4 py-2 rounded-full border border-secondary/30 flex items-center gap-2 celestial-shadow">
<span class="w-2 h-2 rounded-full bg-secondary-fixed animate-pulse"></span>
<span class="text-[11px] font-manrope font-bold text-on-secondary-container tracking-wider uppercase" id="mode-label">Mode: Idle</span>
</div>
</div>
<div class="absolute top-6 right-6 z-40">
<div class="glass p-4 rounded-2xl border border-outline-variant/10 celestial-shadow w-56">
<h5 class="text-[10px] font-bold text-outline uppercase tracking-widest mb-3">Layer Controls</h5>
<div class="space-y-3">
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-xs font-medium text-on-surface group-hover:text-primary transition-colors">SPBU 24 Jam</span>
<div class="relative inline-flex items-center">
<input id="toggle-spbu24" class="sr-only peer" type="checkbox" checked onchange="setLayerVisibility('spbu24', this.checked)"/>
<div class="w-8 h-4 bg-surface-container-highest rounded-full peer-checked:bg-primary-container transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</div>
</label>
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-xs font-medium text-on-surface group-hover:text-primary transition-colors">SPBU Tidak 24 Jam</span>
<div class="relative inline-flex items-center">
<input id="toggle-spbuNo24" class="sr-only peer" type="checkbox" checked onchange="setLayerVisibility('spbuNo24', this.checked)"/>
<div class="w-8 h-4 bg-surface-container-highest rounded-full peer-checked:bg-primary-container transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</div>
</label>
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-xs font-medium text-on-surface group-hover:text-primary transition-colors">Show Parsil Borders</span>
<div class="relative inline-flex items-center">
<input id="toggle-parsil" class="sr-only peer" type="checkbox" checked onchange="setLayerVisibility('parsil', this.checked)"/>
<div class="w-8 h-4 bg-surface-container-highest rounded-full peer-checked:bg-primary-container transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</div>
</label>
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-xs font-medium text-on-surface group-hover:text-primary transition-colors">Show Kecamatan</span>
<div class="relative inline-flex items-center">
<input id="toggle-kecamatan" class="sr-only peer" type="checkbox" checked onchange="setLayerVisibility('kecamatan', this.checked)"/>
<div class="w-8 h-4 bg-surface-container-highest rounded-full peer-checked:bg-primary-container transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</div>
</label>
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-xs font-medium text-on-surface group-hover:text-primary transition-colors">Show Jalan Borders</span>
<div class="relative inline-flex items-center">
<input id="toggle-jalan" class="sr-only peer" type="checkbox" checked onchange="setLayerVisibility('jalan', this.checked)"/>
<div class="w-8 h-4 bg-surface-container-highest rounded-full peer-checked:bg-primary-container transition-colors"></div>
<div class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
</div>
</label>
</div>
</div>
</div>
<div id="selected-panel" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-40 hidden">
<div class="w-0.5 h-16 bg-primary/40 absolute -bottom-16 left-1/2 -translate-x-1/2"></div>
<div class="glass p-5 rounded-2xl border border-primary/20 celestial-shadow w-[280px]">
<div class="flex justify-between items-start mb-4">
<div>
<span class="text-[9px] font-bold text-primary uppercase tracking-tighter">Selected Asset</span>
<h3 class="text-sm font-bold text-on-surface font-manrope" id="selected-title">Segment Arteri Gatot 04</h3>
</div>
<button class="material-symbols-outlined text-outline hover:text-on-surface text-lg" onclick="hideSelectedPanel()">close</button>
</div>
<div class="grid grid-cols-2 gap-3 mb-5">
<div class="bg-surface-container-lowest/50 p-2.5 rounded-xl">
<p class="text-[8px] text-outline uppercase font-bold">Status</p>
<p class="text-[11px] font-semibold text-secondary-fixed" id="selected-status">Verified</p>
</div>
<div class="bg-surface-container-lowest/50 p-2.5 rounded-xl">
<p class="text-[8px] text-outline uppercase font-bold">Panjang</p>
<p class="text-[11px] font-semibold text-on-surface" id="selected-length">1.42 km</p>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2">
<button class="bg-primary/10 text-primary py-2 rounded-lg text-[11px] font-bold hover:bg-primary/20 transition-colors flex items-center justify-center gap-1" onclick="editSelected()">
<span class="material-symbols-outlined text-xs">edit</span> Edit Data
</button>
<button class="bg-surface-container-highest text-on-surface py-2 rounded-lg text-[11px] font-bold hover:bg-surface-bright transition-colors flex items-center justify-center gap-1" onclick="editLocation()">
<span class="material-symbols-outlined text-xs">location_searching</span> Lokasi
</button>
</div>
<button class="w-full bg-error-container/10 text-error py-2 rounded-lg text-[11px] font-bold hover:bg-error-container/20 transition-colors flex items-center justify-center gap-1" onclick="deleteSelected()">
<span class="material-symbols-outlined text-xs">delete</span> Hapus Feature
</button>
</div>
</div>
</div>
<div class="absolute bottom-10 right-10 z-40 flex flex-col gap-2">
<button class="w-12 h-12 glass rounded-xl flex items-center justify-center text-on-surface hover:text-primary transition-all hover:scale-105 border border-outline-variant/10" onclick="map.locate({setView:true, maxZoom:16})">
<span class="material-symbols-outlined">my_location</span>
</button>
<button class="w-12 h-12 glass rounded-xl flex items-center justify-center text-on-surface hover:text-primary transition-all hover:scale-105 border border-outline-variant/10" onclick="location.reload()">
<span class="material-symbols-outlined">restart_alt</span>
</button>
<div class="flex flex-col rounded-xl glass border border-outline-variant/10 overflow-hidden">
<button class="w-12 h-12 flex items-center justify-center text-on-surface hover:bg-surface-bright transition-colors" onclick="map.zoomIn()">
<span class="material-symbols-outlined">add</span>
</button>
<button class="w-12 h-12 flex items-center justify-center text-on-surface hover:bg-surface-bright transition-colors" onclick="map.zoomOut()">
<span class="material-symbols-outlined">remove</span>
</button>
</div>
</div>
<div class="absolute bottom-4 left-6 z-40">
<p class="text-[10px] font-mono text-outline/60 tracking-widest bg-surface/40 px-3 py-1 rounded-full backdrop-blur-sm" id="latlng-display">LAT: - LONG: - ZOOM: -</p>
</div>
</section>
</main>
<div class="fixed inset-0 pointer-events-none z-0" style="display:none;">
<svg class="w-full h-full opacity-30">
<path d="M 500 400 L 700 550 L 900 450" fill="none" stroke="#b7c4ff" stroke-dasharray="8 4" stroke-width="3"></path>
<circle cx="700" cy="550" fill="#0052ff" r="6"></circle>
<circle cx="900" cy="450" fill="#b7c4ff" r="4"></circle>
</svg>
</div>
<script>
const layers = {
spbu24: new L.LayerGroup(),
spbuNo24: new L.LayerGroup(),
jalan: new L.LayerGroup(),
parsil: new L.LayerGroup(),
kecamatan: new L.LayerGroup()
};
const map = L.map('map').setView([-0.02055556, 109.34138889], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
Object.values(layers).forEach(layer => layer.addTo(map));
const drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
let currentDrawer = null;
let mode = null;
let selectedFeature = null;
let selectedType = null;
let selectedLayer = null;
let featureLayers = { spbu: {}, jalan: {}, parsil: {}, kecamatan: {} };
let activeLocationEditType = null;
let activeLocationEditId = null;
let spbuData = [];
let jalanData = [];
let parsilData = [];
let kecamatanData = [];
function utmToLatLng(easting, northing, zone = 49, southernHemisphere = true) {
const a = 6378137.0;
const f = 1 / 298.257223563;
const k0 = 0.9996;
const e = Math.sqrt(f * (2 - f));
const e1 = (1 - Math.sqrt(1 - e * e)) / (1 + Math.sqrt(1 - e * e));
const x = easting - 500000.0;
let y = northing;
if (southernHemisphere) y -= 10000000.0;
const m = y / k0;
const mu = m / (a * (1 - e * e / 4 - 3 * Math.pow(e, 4) / 64 - 5 * Math.pow(e, 6) / 256));
const j1 = 3 * e1 / 2 - 27 * Math.pow(e1, 3) / 32;
const j2 = 21 * Math.pow(e1, 2) / 16 - 55 * Math.pow(e1, 4) / 32;
const j3 = 151 * Math.pow(e1, 3) / 96;
const j4 = 1097 * Math.pow(e1, 4) / 512;
const fp = mu + j1 * Math.sin(2 * mu) + j2 * Math.sin(4 * mu) + j3 * Math.sin(6 * mu) + j4 * Math.sin(8 * mu);
const e2 = e * e / (1 - e * e);
const c1 = e2 * Math.pow(Math.cos(fp), 2);
const t1 = Math.pow(Math.tan(fp), 2);
const n1 = a / Math.sqrt(1 - Math.pow(e * Math.sin(fp), 2));
const r1 = a * (1 - e * e) / Math.pow(1 - Math.pow(e * Math.sin(fp), 2), 1.5);
const d = x / (n1 * k0);
const q1 = n1 * Math.tan(fp) / r1;
const q2 = Math.pow(d, 2) / 2;
const q3 = (5 + 3 * t1 + 10 * c1 - 4 * Math.pow(c1, 2) - 9 * e2) * Math.pow(d, 4) / 24;
const q4 = (61 + 90 * t1 + 298 * c1 + 45 * Math.pow(t1, 2) - 252 * e2 - 3 * Math.pow(c1, 2)) * Math.pow(d, 6) / 720;
const lat = fp - q1 * (q2 - q3 + q4);
const q5 = d;
const q6 = (1 + 2 * t1 + c1) * Math.pow(d, 3) / 6;
const q7 = (5 - 2 * c1 + 28 * t1 - 3 * Math.pow(c1, 2) + 8 * e2 + 24 * Math.pow(t1, 2)) * Math.pow(d, 5) / 120;
const lon = (q5 - q6 + q7) / Math.cos(fp);
const lonOrigin = (zone - 1) * 6 - 180 + 3;
return { lat: lat * 180 / Math.PI, lng: lonOrigin + lon * 180 / Math.PI };
}
function transformUtmCoordinates(coords, zone = 49, southernHemisphere = true) {
if (typeof coords[0] === 'number' && typeof coords[1] === 'number') {
const ll = utmToLatLng(coords[0], coords[1], zone, southernHemisphere);
return [ll.lng, ll.lat];
}
return coords.map(coord => transformUtmCoordinates(coord, zone, southernHemisphere));
}
function convertUtmFeature(feature, zone = 49, southernHemisphere = true) {
return {
...feature,
geometry: {
...feature.geometry,
coordinates: transformUtmCoordinates(feature.geometry.coordinates, zone, southernHemisphere)
}
};
}
function setMode(m) {
mode = m;
document.getElementById('mode-label').textContent = m ? `Mode: Tambah ${m === 'point' ? 'Point' : m === 'jalan' ? 'Jalan' : 'Parsil'}` : 'Mode: Idle';
['btn-point','btn-jalan','btn-parsil','btn-cancel'].forEach(id => {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle('bg-primary-container', id === `btn-${m}` && m !== null);
btn.classList.toggle('text-white', id === `btn-${m}` && m !== null);
}
});
// Berikan feedback visual kursor dan toast panduan agar user tahu cara menambah data
const mapElement = document.getElementById('map');
if (mapElement) {
if (m) {
mapElement.style.cursor = 'crosshair';
if (m === 'point') {
showToast('📍 Mode Tambah SPBU aktif. Silakan KLIK di peta untuk menempatkan titik SPBU baru.', 'info');
} else if (m === 'jalan') {
showToast('🛣️ Mode Tambah Jalan aktif. Silakan KLIK di peta untuk mulai menggambar garis jalan.', 'info');
} else if (m === 'parsil') {
showToast('🗺️ Mode Tambah Parsil aktif. Silakan KLIK di peta untuk mulai menggambar batas area parsil.', 'info');
}
} else {
mapElement.style.cursor = '';
}
}
if (!m) cancelPendingShape(false);
updateDrawControl();
}
function updateDrawControl() {
if (currentDrawer && currentDrawer.disable) currentDrawer.disable();
currentDrawer = null;
if (mode === 'jalan') {
currentDrawer = new L.Draw.Polyline(map, { shapeOptions: { color:'#ef4444', weight: 5 } });
currentDrawer.enable();
}
if (mode === 'parsil') {
currentDrawer = new L.Draw.Polygon(map, { allowIntersection:false, showArea:true, shapeOptions: { color:'#d2bbff', fillOpacity: 0.12 } });
currentDrawer.enable();
}
}
let pendingLayer = null;
function setLayerVisibility(key, visible) {
visible ? map.addLayer(layers[key]) : map.removeLayer(layers[key]);
}
function cancelPendingShape(clearMode = true) {
if (pendingLayer) {
drawnItems.removeLayer(pendingLayer);
pendingLayer = null;
}
if (clearMode) setMode(null);
}
function formTambah(lat, lng) {
return `
<div class="popup-form">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:12px">Tambah SPBU</p>
<form id="formTambah" class="space-y-4 text-sm text-on-surface">
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Nama SPBU</label>
<input name="nama_spbu" required class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">No WA</label>
<input name="no_wa" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Buka 24 Jam</label>
<select name="buka_24jam" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface">
<option>Ya</option>
<option>Tidak</option>
</select>
</div>
<input type="hidden" name="latitude" value="${lat}" />
<input type="hidden" name="longitude" value="${lng}" />
<button type="submit" class="w-full bg-primary text-surface py-3 rounded-xl font-bold">Simpan SPBU</button>
</form>
</div>`;
}
function showSelectedPanel(type, item) {
selectedType = type;
selectedFeature = item;
selectedLayer = featureLayers[type]?.[item.id] || null;
document.getElementById('selected-title').textContent = type === 'spbu' ? item.nama_spbu : type === 'jalan' ? item.nama_jalan : item.nama_pemilik;
document.getElementById('selected-status').textContent = type === 'spbu' ? item.buka_24jam : type === 'jalan' ? item.status_jalan : item.status;
document.getElementById('selected-length').textContent = type === 'jalan' ? `${item.panjang} m` : type === 'spbu' ? `${item.latitude.toFixed(6)}, ${item.longitude.toFixed(6)}` : `${Math.round(item.luas)} m²`;
document.getElementById('selected-panel').classList.remove('hidden');
}
function hideSelectedPanel() {
document.getElementById('selected-panel').classList.add('hidden');
selectedFeature = null;
selectedType = null;
}
function getFeatureItem(type, id) {
const source = type === 'spbu' ? spbuData : type === 'jalan' ? jalanData : parsilData;
return source.find(v => Number(v.id) === Number(id)) || null;
}
function selectFeature(type, id, layer = null) {
selectedType = type;
selectedFeature = getFeatureItem(type, id);
selectedLayer = layer || featureLayers[type]?.[id] || null;
}
function getFeaturePopup(type, item) {
if(type === 'spbu'){
return `
<div class="popup-modern">
<h3>${item.nama_spbu}</h3>
<p class="status">Status: ${item.buka_24jam}</p>
<button class="btn-edit" onclick="editFeatureData('spbu', ${item.id})">
Edit Data
</button>
<button class="btn-lokasi" onclick="editFeatureLocation('spbu', ${item.id})">
Edit Lokasi
</button>
<button class="btn-hapus" onclick="deleteFeature('spbu', ${item.id})">
Hapus
</button>
</div>`;
}
if(type === 'jalan'){
return `
<div class="popup-modern">
<h3>${item.nama_jalan}</h3>
<p class="status">Status: ${item.status_jalan}</p>
<p class="info">Panjang: ${item.panjang} m</p>
<button class="btn-edit" onclick="editFeatureData('jalan', ${item.id})">
Edit Data
</button>
<button class="btn-lokasi" onclick="editFeatureLocation('jalan', ${item.id})">
Edit Lokasi
</button>
<button class="btn-hapus" onclick="deleteFeature('jalan', ${item.id})">
Hapus
</button>
</div>`;
}
if(type === 'parsil'){
return `
<div class="popup-modern">
<h3>${item.nama_pemilik}</h3>
<p class="status">Status: ${item.status}</p>
<p class="info">Luas: ${Math.round(item.luas)} m²</p>
<button class="btn-edit" onclick="editFeatureData('parsil', ${item.id})">
Edit Data
</button>
<button class="btn-lokasi" onclick="editFeatureLocation('parsil', ${item.id})">
Edit Lokasi
</button>
<button class="btn-hapus" onclick="deleteFeature('parsil', ${item.id})">
Hapus
</button>
</div>`;
}
}
function editFeatureData(type, id) {
const item = getFeatureItem(type, id);
if (!item) return;
if (type === 'spbu') {
return openSpbuEditPopup(featureLayers.spbu[id], item);
}
if (type === 'jalan') {
return openJalanEditPopup(featureLayers.jalan[id], item);
}
if (type === 'parsil') {
return openParsilEditPopup(featureLayers.parsil[id], item);
}
let fd = new FormData();
fd.append('id', id);
fd.append('type', type);
const nama = prompt('Nama Pemilik', item.nama_pemilik);
if (nama === null) return;
const status = prompt('Status', item.status);
if (status === null) return;
fd.append('nama_pemilik', nama);
fd.append('status', status);
fetch('update.php', { method:'POST', body:fd }).then(() => location.reload());
}
function openSpbuEditPopup(layer, item) {
const popupContent = `
<div class="popup-form">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:12px">Edit SPBU</p>
<form id="formEditSpbu" class="space-y-4 text-sm text-on-surface">
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Nama SPBU</label>
<input name="nama_spbu" value="${item.nama_spbu}" required class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">No WA</label>
<input name="no_wa" value="${item.no_wa || ''}" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Buka 24 Jam</label>
<select name="buka_24jam" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface">
<option${item.buka_24jam === 'Ya' ? ' selected' : ''}>Ya</option>
<option${item.buka_24jam !== 'Ya' ? ' selected' : ''}>Tidak</option>
</select>
</div>
<input type="hidden" name="id" value="${item.id}" />
<input type="hidden" name="type" value="spbu" />
<button type="submit" class="w-full bg-primary text-surface py-3 rounded-xl font-bold">Simpan SPBU</button>
</form>
</div>`;
const center = layer && layer.getLatLng ? layer.getLatLng() : map.getCenter();
if (layer && layer.bindPopup) {
layer.bindPopup(popupContent, { closeButton: true, minWidth: 280 }).openPopup(center);
} else {
L.popup({ minWidth: 280 }).setLatLng(center).setContent(popupContent).openOn(map);
}
setTimeout(() => {
const form = document.getElementById('formEditSpbu');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
fetch('update.php', { method:'POST', body:new FormData(this) }).then(() => location.reload());
});
}, 50);
}
function openJalanEditPopup(layer, item) {
const popupContent = `
<div class="popup-form">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:12px">Edit Jalan</p>
<form id="formEditJalan" class="space-y-4 text-sm text-on-surface">
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Nama Jalan</label>
<input name="nama_jalan" value="${item.nama_jalan}" required class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Status Jalan</label>
<select name="status_jalan" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface">
<option${item.status_jalan === 'Nasional' ? ' selected' : ''}>Nasional</option>
<option${item.status_jalan === 'Provinsi' ? ' selected' : ''}>Provinsi</option>
<option${item.status_jalan === 'Kabupaten' ? ' selected' : ''}>Kabupaten</option>
</select>
</div>
<input type="hidden" name="id" value="${item.id}" />
<input type="hidden" name="type" value="jalan" />
<button type="submit" class="w-full bg-primary text-surface py-3 rounded-xl font-bold">Simpan Jalan</button>
</form>
</div>`;
const center = layer && layer.getBounds ? layer.getBounds().getCenter() : map.getCenter();
if (layer && layer.bindPopup) {
layer.bindPopup(popupContent, { closeButton: true, minWidth: 300 }).openPopup(center);
} else {
L.popup({ minWidth: 300 }).setLatLng(center).setContent(popupContent).openOn(map);
}
setTimeout(() => {
const form = document.getElementById('formEditJalan');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
fetch('update.php', { method:'POST', body:new FormData(this) }).then(() => location.reload());
});
}, 50);
}
function openParsilEditPopup(layer, item) {
const popupContent = `
<div class="popup-form">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:12px">Edit Parsil</p>
<form id="formEditParsil" class="space-y-4 text-sm text-on-surface">
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Nama Pemilik</label>
<input name="nama_pemilik" value="${item.nama_pemilik}" required class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Status</label>
<select name="status" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface">
<option${item.status === 'SHM' ? ' selected' : ''}>SHM</option>
<option${item.status === 'HGB' ? ' selected' : ''}>HGB</option>
<option${item.status === 'HGU' ? ' selected' : ''}>HGU</option>
<option${item.status === 'HP' ? ' selected' : ''}>HP</option>
</select>
</div>
<input type="hidden" name="id" value="${item.id}" />
<input type="hidden" name="type" value="parsil" />
<button type="submit" class="w-full bg-primary text-surface py-3 rounded-xl font-bold">Simpan Parsil</button>
</form>
</div>`;
const center = layer && layer.getBounds ? layer.getBounds().getCenter() : map.getCenter();
if (layer && layer.bindPopup) {
layer.bindPopup(popupContent, { closeButton: true, minWidth: 300 }).openPopup(center);
} else {
L.popup({ minWidth: 300 }).setLatLng(center).setContent(popupContent).openOn(map);
}
setTimeout(() => {
const form = document.getElementById('formEditParsil');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
fetch('update.php', { method:'POST', body:new FormData(this) }).then(() => location.reload());
});
}, 50);
}
function openSpbuLocationEditor(layer, item) {
if (!layer || !layer.getLatLng) return;
if (layer.dragging) {
layer.dragging.enable();
}
layer._originalLatLng = layer.getLatLng();
activeLocationEditType = 'spbu';
activeLocationEditId = item.id;
showLocationEditPanel(item.id, 'spbu');
const popupContent = `
<div class="space-y-3 text-sm text-on-surface">
<p class="text-[11px] text-outline uppercase font-bold tracking-wide">Edit Lokasi SPBU</p>
<p class="text-[11px] text-on-surface-variant">Tahan lalu geser marker ke lokasi baru. Gunakan tombol Simpan Lokasi untuk menyimpan.</p>
</div>`;
layer.bindPopup(popupContent, { closeButton: true, minWidth: 280 }).openPopup(layer.getLatLng());
}
function openJalanLocationEditor(layer, item) {
if (!layer || !layer.getLatLngs) return;
if (!drawnItems.hasLayer(layer)) {
drawnItems.addLayer(layer);
}
layer._originalLatLngs = JSON.parse(JSON.stringify(layer.getLatLngs()));
if (layer.editing && layer.editing.enable) {
layer.editing.enable();
} else if (L.Edit && L.Edit.Poly) {
layer.editing = new L.Edit.Poly(layer);
if (layer.editing.enable) layer.editing.enable();
}
showLocationEditPanel(item.id, 'jalan');
showToast('Mode edit lokasi jalan aktif. Vertex putih dapat digeser atau dihapus.', 'info');
}
function openParsilLocationEditor(layer, item) {
if (!layer || !layer.getLatLngs) return;
if (!drawnItems.hasLayer(layer)) {
drawnItems.addLayer(layer);
}
layer._originalLatLngs = JSON.parse(JSON.stringify(layer.getLatLngs()));
if (layer.editing && layer.editing.enable) {
layer.editing.enable();
} else if (L.Edit && L.Edit.Poly) {
layer.editing = new L.Edit.Poly(layer);
if (layer.editing.enable) layer.editing.enable();
}
showLocationEditPanel(item.id, 'parsil');
showToast('Mode edit lokasi parsil aktif. Vertex putih dapat digeser atau dihapus.', 'info');
}
function saveJalanLocation(id) {
const layer = featureLayers.jalan[id];
if (!layer || !layer.toGeoJSON) return;
const geo = layer.toGeoJSON().geometry;
const latlngs = layer.getLatLngs();
const panjang = Math.round(calcLength(latlngs));
const fd = new FormData();
fd.append('id', id);
fd.append('type', 'jalan');
fd.append('geom', JSON.stringify(geo));
fd.append('panjang', panjang);
if (layer.editing && layer.editing.disable) layer.editing.disable();
hideLocationEditPanel();
fetch('update_lokasi.php', { method:'POST', body:fd })
.then(response => response.text())
.then(result => {
if (result.trim() === 'success') {
showToast('Lokasi jalan berhasil disimpan.', 'success');
} else {
showToast('Gagal menyimpan lokasi jalan.', 'error');
}
setTimeout(() => location.reload(), 800);
});
}
function cancelJalanLocation(id) {
const layer = featureLayers.jalan[id];
if (!layer || !layer.setLatLngs || !layer._originalLatLngs) return;
layer.setLatLngs(layer._originalLatLngs);
if (layer.editing && layer.editing.disable) layer.editing.disable();
hideLocationEditPanel();
showToast('Perubahan lokasi jalan dibatalkan.', 'warning');
}
function saveParsilLocation(id) {
const layer = featureLayers.parsil[id];
if (!layer || !layer.toGeoJSON) return;
const geo = layer.toGeoJSON().geometry;
const latlngs = layer.getLatLngs()[0];
const luas = Math.round(L.GeometryUtil.geodesicArea(latlngs));
const fd = new FormData();
fd.append('id', id);
fd.append('type', 'parsil');
fd.append('geom', JSON.stringify(geo));
fd.append('luas', luas);
if (layer.editing && layer.editing.disable) layer.editing.disable();
hideLocationEditPanel();
fetch('update_lokasi.php', { method:'POST', body:fd })
.then(response => response.text())
.then(result => {
if (result.trim() === 'success') {
showToast('Lokasi parsil berhasil disimpan.', 'success');
} else {
showToast('Gagal menyimpan lokasi parsil.', 'error');
}
setTimeout(() => location.reload(), 800);
});
}
function cancelParsilLocation(id) {
const layer = featureLayers.parsil[id];
if (!layer || !layer.setLatLngs || !layer._originalLatLngs) return;
layer.setLatLngs(layer._originalLatLngs);
if (layer.editing && layer.editing.disable) layer.editing.disable();
hideLocationEditPanel();
showToast('Perubahan lokasi parsil dibatalkan.', 'warning');
}
function showLocationEditPanel(id, type) {
const panel = document.getElementById('location-edit-panel');
if (!panel) return;
panel.classList.remove('hidden');
activeLocationEditType = type;
activeLocationEditId = id;
document.getElementById('location-save-btn').onclick = () => {
if (type === 'spbu') return saveSpbuLocation(id);
if (type === 'jalan') return saveJalanLocation(id);
if (type === 'parsil') return saveParsilLocation(id);
};
document.getElementById('location-cancel-btn').onclick = () => cancelLocationEdit();
}
function hideLocationEditPanel() {
const panel = document.getElementById('location-edit-panel');
if (!panel) return;
panel.classList.add('hidden');
activeLocationEditType = null;
activeLocationEditId = null;
}
function cancelLocationEdit() {
if (!activeLocationEditType || !activeLocationEditId) return;
if (activeLocationEditType === 'spbu') return cancelSpbuLocation(activeLocationEditId);
if (activeLocationEditType === 'jalan') return cancelJalanLocation(activeLocationEditId);
if (activeLocationEditType === 'parsil') return cancelParsilLocation(activeLocationEditId);
}
function saveSpbuLocation(id) {
const layer = featureLayers.spbu[id];
if (!layer || !layer.getLatLng) return;
const latlng = layer.getLatLng();
const fd = new FormData();
fd.append('id', id);
fd.append('type', 'spbu');
fd.append('latitude', latlng.lat);
fd.append('longitude', latlng.lng);
if (layer.dragging) layer.dragging.disable();
hideLocationEditPanel();
fetch('update_lokasi.php', { method:'POST', body:fd })
.then(response => response.text())
.then(result => {
if (result.trim() === 'success') {
showToast('Lokasi SPBU berhasil disimpan.', 'success');
} else {
showToast('Gagal menyimpan lokasi SPBU.', 'error');
}
setTimeout(() => location.reload(), 800);
});
}
function cancelSpbuLocation(id) {
const layer = featureLayers.spbu[id];
if (!layer || !layer.getLatLng) return;
if (layer._originalLatLng) {
layer.setLatLng(layer._originalLatLng);
}
if (layer.dragging) layer.dragging.disable();
layer.closePopup();
}
function editFeatureLocation(type, id) {
const item = getFeatureItem(type, id);
if (!item) return;
if (type === 'spbu') {
return openSpbuLocationEditor(featureLayers.spbu[id], item);
}
if (type === 'jalan') {
return openJalanLocationEditor(featureLayers.jalan[id], item);
}
if (type === 'parsil') {
return openParsilLocationEditor(featureLayers.parsil[id], item);
}
let fd = new FormData();
fd.append('id', id);
fd.append('type', type);
const geo = JSON.parse(item.geom);
let initial = '';
if (type === 'jalan') {
initial = geo.coordinates.map(coord => `${coord[1]},${coord[0]}`).join(';');
} else {
initial = geo.coordinates[0].map(coord => `${coord[1]},${coord[0]}`).join(';');
}
const coordsString = prompt('Ubah koordinat ke format lat,lng;lat,lng;...', initial);
if (coordsString === null) return;
const coords = coordsString.split(';').map(pair => pair.trim()).filter(Boolean).map(pair => {
const [lat, lng] = pair.split(',').map(Number);
return [lng, lat];
});
if (!coords.length) return;
if (type === 'parsil') {
const first = coords[0];
const last = coords[coords.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) coords.push(first);
fd.append('geom', JSON.stringify({ type:'Polygon', coordinates:[coords] }));
const area = Math.round(L.GeometryUtil.geodesicArea(coords.map(c => L.latLng(c[1], c[0]))));
fd.append('luas', area);
} else {
fd.append('geom', JSON.stringify({ type:'LineString', coordinates:coords }));
const latlngs = coords.map(c => L.latLng(c[1], c[0]));
const panjang = Math.round(calcLength(latlngs));
fd.append('panjang', panjang);
}
fetch('update_lokasi.php', { method:'POST', body:fd }).then(() => location.reload());
}
function deleteFeature(type, id) {
if (!confirm('Yakin ingin menghapus fitur ini?')) return;
if (type === 'spbu') return hapusPoint(id);
if (type === 'jalan') return hapusJalan(id);
if (type === 'parsil') return hapusParsil(id);
}
function editSelected() {
if (!selectedFeature || !selectedType) return;
editFeatureData(selectedType, selectedFeature.id);
}
function editLocation() {
if (!selectedFeature || !selectedType) return;
editFeatureLocation(selectedType, selectedFeature.id);
}
function deleteSelected() {
if (!selectedFeature || !selectedType) return;
deleteFeature(selectedType, selectedFeature.id);
}
function calcLength(latlngs) {
let total = 0;
for (let i = 0; i < latlngs.length - 1; i++) {
total += latlngs[i].distanceTo(latlngs[i + 1]);
}
return total;
}
function openPointPopup(marker, latlng) {
const popupContent = formTambah(latlng.lat, latlng.lng);
marker.bindPopup(popupContent, { closeButton: true, minWidth: 280 }).openPopup();
setTimeout(() => {
const form = document.getElementById('formTambah');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
fetch('simpan.php', { method:'POST', body:new FormData(this) }).then(() => location.reload());
});
}, 50);
}
function formJalan(geojson, panjang) {
return `
<div class="popup-form">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:12px">Tambah Jalan</p>
<form id="formJalan" class="space-y-3 text-sm text-on-surface">
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Nama Jalan</label>
<input name="nama_jalan" required class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Status Jalan</label>
<select name="status_jalan" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface">
<option>Nasional</option>
<option>Provinsi</option>
<option>Kabupaten</option>
</select>
</div>
<div class="grid grid-cols-2 gap-2 text-[11px] text-on-surface-variant">
<div class="rounded-xl bg-surface-container-low px-3 py-2">
<p class="font-medium">Panjang</p>
<p class="mt-1 text-sm">${panjang} m</p>
</div>
</div>
<input type="hidden" name="geom" value='${JSON.stringify(geojson.geometry)}' />
<input type="hidden" name="panjang" value="${panjang}" />
<button type="submit" class="w-full bg-primary text-surface py-3 rounded-xl font-bold">Simpan Jalan</button>
</form>
</div>`;
}
function formParsil(geojson, luas) {
return `
<div class="popup-form">
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;margin-bottom:12px">Tambah Parsil</p>
<form id="formParsil" class="space-y-3 text-sm text-on-surface">
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Nama Pemilik</label>
<input name="nama_pemilik" required class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface" />
</div>
<div>
<label class="block text-[11px] text-outline uppercase font-bold mb-1">Status</label>
<select name="status" class="w-full rounded-xl border border-outline-variant/20 bg-surface-container-low px-3 py-2 text-on-surface">
<option>SHM</option>
<option>HGB</option>
<option>HGU</option>
<option>HP</option>
</select>
</div>
<div class="rounded-xl bg-surface-container-low px-3 py-2 text-[11px] text-on-surface-variant">
<p class="font-medium">Luas</p>
<p class="mt-1 text-sm">${luas} m²</p>
</div>
<input type="hidden" name="geom" value='${JSON.stringify(geojson.geometry)}' />
<input type="hidden" name="luas" value="${luas}" />
<button type="submit" class="w-full bg-primary text-surface py-3 rounded-xl font-bold">Simpan Parsil</button>
</form>
</div>`;
}
function openJalanPopup(layer, geojson, panjang) {
if (currentDrawer && currentDrawer.disable) currentDrawer.disable();
currentDrawer = null;
const center = layer.getBounds ? layer.getBounds().getCenter() : layer.getLatLngs()[0];
const popupContent = formJalan(geojson, panjang);
layer.bindPopup(popupContent, { closeButton: true, minWidth: 300 }).openPopup(center);
setTimeout(() => {
const form = document.getElementById('formJalan');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
fetch('simpan_jalan.php', { method:'POST', body:new FormData(this) })
.then(r => r.text())
.then(res => {
if (res.trim() === 'success') {
map.closePopup();
cancelPendingShape(false);
setMode(null);
loadData();
showToast('Jalan berhasil ditambahkan!', 'success');
} else {
showToast('Gagal simpan jalan: ' + res, 'error');
}
})
.catch(() => showToast('Error koneksi ke server.', 'error'));
});
}, 50);
}
function openParsilPopup(layer, geojson, luas) {
const center = layer.getBounds ? layer.getBounds().getCenter() : layer.getLatLngs()[0][0];
const popupContent = formParsil(geojson, luas);
layer.bindPopup(popupContent, { closeButton: true, minWidth: 300 }).openPopup(center);
setTimeout(() => {
const form = document.getElementById('formParsil');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
fetch('simpan_parsil.php', { method:'POST', body:new FormData(this) })
.then(r => r.text())
.then(res => {
if (res.trim() === 'success') {
map.closePopup();
cancelPendingShape(false);
setMode(null);
loadData();
showToast('Parsil berhasil ditambahkan!', 'success');
} else {
showToast('Gagal simpan parsil: ' + res, 'error');
}
})
.catch(() => showToast('Error koneksi ke server.', 'error'));
});
}, 50);
}
function openPointPopup(layer, latlng) {
const popupContent = formTambah(latlng.lat.toFixed(7), latlng.lng.toFixed(7));
layer.bindPopup(popupContent, { closeButton: true, minWidth: 300 }).openPopup(latlng);
setTimeout(() => {
const form = document.getElementById('formTambah');
if (!form) return;
form.addEventListener('submit', function(ev) {
ev.preventDefault();
const fd = new FormData(this);
fetch('simpan.php', { method: 'POST', body: fd })
.then(r => r.text())
.then(res => {
if (res.trim() === 'success') {
map.closePopup();
cancelPendingShape(false);
setMode(null);
loadData();
showToast('SPBU berhasil ditambahkan!', 'success');
} else {
showToast('Gagal menyimpan SPBU. Coba lagi.', 'error');
}
})
.catch(() => showToast('Error koneksi ke server.', 'error'));
});
}, 50);
}
map.on('click', function(e) {
if (mode !== 'point') return;
if (pendingLayer) {
drawnItems.removeLayer(pendingLayer);
pendingLayer = null;
}
pendingLayer = L.marker(e.latlng).addTo(drawnItems);
openPointPopup(pendingLayer, e.latlng);
});
map.on(L.Draw.Event.CREATED, function(e) {
const layer = e.layer;
const geojson = layer.toGeoJSON();
if (mode === 'jalan' && e.layerType === 'polyline') {
if (pendingLayer) drawnItems.removeLayer(pendingLayer);
pendingLayer = layer;
drawnItems.addLayer(pendingLayer);
const latlngs = layer.getLatLngs();
let panjang = 0;
for (let i = 0; i < latlngs.length - 1; i++) panjang += latlngs[i].distanceTo(latlngs[i+1]);
openJalanPopup(layer, geojson, Math.round(panjang));
}
if (mode === 'parsil' && e.layerType === 'polygon') {
if (pendingLayer) drawnItems.removeLayer(pendingLayer);
pendingLayer = layer;
drawnItems.addLayer(pendingLayer);
const luas = L.GeometryUtil.geodesicArea(layer.getLatLngs()[0]);
openParsilPopup(layer, geojson, Math.round(luas));
}
});
function renderList(type, data) {
const list = document.getElementById(`list-${type}`);
if (!list) return;
list.innerHTML = data.map(item => {
const title = type === 'spbu' ? item.nama_spbu : type === 'jalan' ? item.nama_jalan : item.nama_pemilik;
return `<div class="group bg-surface-container-low/50 hover:bg-surface-container-high/80 p-3 rounded-xl cursor-pointer transition-all duration-300 border border-transparent hover:border-outline-variant/20" onclick="flyToFeature('${type}', ${item.id})">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center text-secondary">
<span class="material-symbols-outlined text-lg">${type === 'spbu' ? 'local_gas_station' : type === 'jalan' ? 'route' : 'grid_view'}</span>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-on-surface">${title}</p>
<p class="text-[10px] text-outline">${type === 'spbu' ? (item.buka_24jam === 'Ya' ? 'SPBU 24 Jam' : 'SPBU Tidak 24 Jam') : type === 'jalan' ? item.status_jalan : item.status}</p>
</div>
<span class="material-symbols-outlined text-outline">chevron_right</span>
</div>
</div>`;
}).join('');
}
function flyToFeature(type, id) {
const source = type === 'spbu' ? spbuData : type === 'jalan' ? jalanData : parsilData;
const item = source.find(v => Number(v.id) === Number(id));
if (!item) return;
const layer = featureLayers[type]?.[id];
if (type === 'spbu') {
map.flyTo([item.latitude, item.longitude], 15, { duration: 0.8 });
} else {
const geo = JSON.parse(item.geom);
map.fitBounds(L.geoJSON(geo).getBounds(), { padding: [40, 40] });
}
selectFeature(type, id, layer);
if (layer && layer.openPopup) {
layer.openPopup();
}
}
function hapusPoint(id) { fetch('hapus.php', { method:'POST', body:new URLSearchParams({id}) }).then(() => location.reload()); }
function hapusJalan(id) { fetch('hapus_jalan.php', { method:'POST', body:new URLSearchParams({id}) }).then(() => location.reload()); }
function hapusParsil(id) { fetch('hapus_parsil.php', { method:'POST', body:new URLSearchParams({id}) }).then(() => location.reload()); }
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const colors = {
info: 'bg-slate-900 text-white',
success: 'bg-emerald-600 text-white',
warning: 'bg-amber-500 text-slate-900',
error: 'bg-red-600 text-white'
};
const toast = document.createElement('div');
toast.className = `pointer-events-auto rounded-2xl px-4 py-3 shadow-2xl shadow-black/20 ${colors[type] || colors.info}`;
toast.innerHTML = `<div class="text-sm font-medium">${message}</div>`;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
function updateLatLngDisplay() {
const center = map.getCenter();
document.getElementById('latlng-display').textContent = `LAT: ${center.lat.toFixed(4)} • LONG: ${center.lng.toFixed(4)} • ZOOM: ${map.getZoom()}z`;
}
map.on('moveend', updateLatLngDisplay);
map.on('zoomend', updateLatLngDisplay);
const iconHijau = L.icon({
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/green-dot.png',
iconSize: [32, 32],
iconAnchor: [16, 32]
});
const iconMerah = L.icon({
iconUrl: 'https://maps.google.com/mapfiles/ms/icons/red-dot.png',
iconSize: [32, 32],
iconAnchor: [16, 32]
});
function loadData() {
// Clear existing SPBU layers
layers.spbu24.clearLayers();
layers.spbuNo24.clearLayers();
featureLayers.spbu = {};
Promise.all([
fetch('ambil_data.php').then(r => r.json()),
fetch('ambil_jalan.php').then(r => r.json()),
fetch('ambil_parsil.php').then(r => r.json())
]).then(([spbu, jalan, parsil]) => {
spbuData = spbu;
jalanData = jalan;
parsilData = parsil;
const spbu24 = spbu.filter(item => item.buka_24jam === 'Ya');
const spbuNo24 = spbu.filter(item => item.buka_24jam !== 'Ya');
document.getElementById('count-spbu').textContent = spbu.length;
document.getElementById('count-jalan').textContent = jalan.length;
document.getElementById('count-parsil').textContent = parsil.length;
spbu24.forEach(item => {
const marker = L.marker([parseFloat(item.latitude), parseFloat(item.longitude)], { icon: iconHijau }).addTo(layers.spbu24);
featureLayers.spbu[item.id] = marker;
marker.on('click', () => selectFeature('spbu', item.id, marker));
marker.bindPopup(getFeaturePopup('spbu', item));
});
spbuNo24.forEach(item => {
const marker = L.marker([parseFloat(item.latitude), parseFloat(item.longitude)], { icon: iconMerah }).addTo(layers.spbuNo24);
featureLayers.spbu[item.id] = marker;
marker.on('click', () => selectFeature('spbu', item.id, marker));
marker.bindPopup(getFeaturePopup('spbu', item));
});
renderList('spbu', spbu);
renderList('jalan', jalan);
renderList('parsil', parsil);
const recentItems = [];
spbu.slice(-2).reverse().forEach(item => recentItems.push({ type:'spbu', id:item.id, title:item.nama_spbu, subtitle:item.buka_24jam === 'Ya' ? 'SPBU 24 Jam' : 'SPBU Tidak 24 Jam'}));
jalan.slice(-2).reverse().forEach(item => recentItems.push({ type:'jalan', id:item.id, title:item.nama_jalan, subtitle:item.status_jalan }));
parsil.slice(-1).reverse().forEach(item => recentItems.push({ type:'parsil', id:item.id, title:item.nama_pemilik, subtitle:item.status }));
document.getElementById('recent-assets').innerHTML = recentItems.map(item => `
<div class="group bg-surface-container-low/50 hover:bg-surface-container-high/80 p-3 rounded-xl cursor-pointer transition-all duration-300 border border-transparent hover:border-outline-variant/20" onclick="flyToFeature('${item.type}', ${item.id})">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-lg">${item.type === 'spbu' ? 'local_gas_station' : item.type === 'jalan' ? 'route' : 'grid_view'}</span>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-on-surface">${item.title}</p>
<p class="text-[10px] text-outline">Updated recently • ${item.type === 'spbu' ? 'SPBU' : item.type === 'jalan' ? 'Jalan' : 'Parsil'}</p>
</div>
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors">chevron_right</span>
</div>
</div>`).join('');
});
}
Promise.all([
fetch('ambil_data.php').then(r => r.json()),
fetch('ambil_jalan.php').then(r => r.json()),
fetch('ambil_parsil.php').then(r => r.json()),
fetch('Admin_Kecamatan.json').then(r => r.json())
]).then(([spbu, jalan, parsil, kecamatan]) => {
spbuData = spbu;
jalanData = jalan;
parsilData = parsil;
const spbu24 = spbu.filter(item => item.buka_24jam === 'Ya');
const spbuNo24 = spbu.filter(item => item.buka_24jam !== 'Ya');
document.getElementById('count-spbu').textContent = spbu.length;
document.getElementById('count-jalan').textContent = jalan.length;
document.getElementById('count-parsil').textContent = parsil.length;
const countSpbu24 = document.getElementById('count-spbu24');
if (countSpbu24) countSpbu24.textContent = spbu24.length;
const bounds = L.latLngBounds([]);
spbu24.forEach(item => {
const marker = L.marker(
[parseFloat(item.latitude), parseFloat(item.longitude)],
{ icon: iconHijau }
).addTo(layers.spbu24);
featureLayers.spbu[item.id] = marker;
marker.on('click', () => selectFeature('spbu', item.id, marker));
marker.bindPopup(getFeaturePopup('spbu', item));
bounds.extend([parseFloat(item.latitude), parseFloat(item.longitude)]);
});
spbuNo24.forEach(item => {
const marker = L.marker(
[parseFloat(item.latitude), parseFloat(item.longitude)],
{ icon: iconMerah }
).addTo(layers.spbuNo24);
featureLayers.spbu[item.id] = marker;
marker.on('click', () => selectFeature('spbu', item.id, marker));
marker.bindPopup(getFeaturePopup('spbu', item));
bounds.extend([parseFloat(item.latitude), parseFloat(item.longitude)]);
});
jalan.forEach(item => {
const geo = JSON.parse(item.geom);
const layer = L.geoJSON(geo, {
style: { color: item.status_jalan === 'Nasional' ? '#ef4444' : item.status_jalan === 'Provinsi' ? '#2563eb' : '#10b981', weight: 5 },
onEachFeature: function(feature, featureLayer) {
featureLayers.jalan[item.id] = featureLayer;
featureLayer.on('click', () => selectFeature('jalan', item.id, featureLayer));
featureLayer.bindPopup(getFeaturePopup('jalan', item));
}
}).addTo(layers.jalan);
if (layer.getBounds && layer.getBounds().isValid()) bounds.extend(layer.getBounds());
});
parsil.forEach(item => {
const geo = JSON.parse(item.geom);
const layer = L.geoJSON(geo, {
style: { color: '#d2bbff', fillOpacity: 0.16, weight: 3 },
onEachFeature: function(feature, featureLayer) {
featureLayers.parsil[item.id] = featureLayer;
featureLayer.on('click', () => selectFeature('parsil', item.id, featureLayer));
featureLayer.bindPopup(getFeaturePopup('parsil', item));
}
}).addTo(layers.parsil);
if (layer.getBounds && layer.getBounds().isValid()) bounds.extend(layer.getBounds());
});
kecamatanData = (kecamatan.features || []).map(f => convertUtmFeature(f, 49, true));
const kecamatanPalette = ['#f0abfc', '#fb7185', '#facc15', '#4ade80', '#38bdf8', '#fdba74', '#a78bfa'];
kecamatanData.forEach((feature, index) => {
const layer = L.geoJSON(feature, {
style: {
color: '#1f2937',
weight: 2,
fillColor: kecamatanPalette[index % kecamatanPalette.length],
fillOpacity: 0.24,
dashArray: '4 4'
},
onEachFeature: function(_, featureLayer) {
featureLayers.kecamatan[index] = featureLayer;
const label = feature.properties?.Ket || feature.properties?.name || 'Kecamatan';
featureLayer.bindPopup(`<div class="text-sm font-semibold">${label}</div>`);
}
}).addTo(layers.kecamatan);
if (layer.getBounds && layer.getBounds().isValid()) bounds.extend(layer.getBounds());
});
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [40, 40] });
} else {
map.setView([-0.0284, 109.3350], 13);
}
renderList('spbu', spbu);
renderList('jalan', jalan);
renderList('parsil', parsil);
const recentItems = [];
spbu.slice(-2).reverse().forEach(item => recentItems.push({ type:'spbu', id:item.id, title:item.nama_spbu, subtitle:item.buka_24jam === 'Ya' ? 'SPBU 24 Jam' : 'SPBU Tidak 24 Jam'}));
jalan.slice(-2).reverse().forEach(item => recentItems.push({ type:'jalan', id:item.id, title:item.nama_jalan, subtitle:item.status_jalan }));
parsil.slice(-1).reverse().forEach(item => recentItems.push({ type:'parsil', id:item.id, title:item.nama_pemilik, subtitle:item.status }));
document.getElementById('recent-assets').innerHTML = recentItems.map(item => `
<div class="group bg-surface-container-low/50 hover:bg-surface-container-high/80 p-3 rounded-xl cursor-pointer transition-all duration-300 border border-transparent hover:border-outline-variant/20" onclick="flyToFeature('${item.type}', ${item.id})">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-lg">${item.type === 'spbu' ? 'local_gas_station' : item.type === 'jalan' ? 'route' : 'grid_view'}</span>
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-on-surface">${item.title}</p>
<p class="text-[10px] text-outline">Updated recently • ${item.type === 'spbu' ? 'SPBU' : item.type === 'jalan' ? 'Jalan' : 'Parsil'}</p>
</div>
<span class="material-symbols-outlined text-outline group-hover:text-primary transition-colors">chevron_right</span>
</div>
</div>`).join('');
updateLatLngDisplay();
});
setMode(null);
</script>
</body>
</html>