Files
UAS_WEBGIS_MaghfiraIzzati_D…/WEBGIS/01/index.html
T
2026-06-11 15:21:13 +07:00

2160 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS SPBU & Tata Ruang</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<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=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#ece9df;
--panel:#fffdf8;
--panel2:#f6f1e8;
--panel-soft:#f2ede3;
--border:#d7cfc1;
--navy:#1f3d63;
--navy-light:#e4eef9;
--green:#186a3a;
--green-light:#d6eddf;
--red:#ad352b;
--red-light:#f9e3e0;
--amber:#9f6500;
--blue:#1f5b84;
--blue-light:#dbe9f3;
--purple:#65418d;
--purple-light:#ecdef8;
--teal:#0f6250;
--teal-light:#d4f0ea;
--text:#1d252f;
--muted:#667381;
--muted2:#9aa5b1;
}
body{
font-family:'Plus Jakarta Sans',sans-serif;
background:
radial-gradient(1200px 550px at -10% -20%, #ffffff 0%, #ece9df 52%, #e8e4d9 100%),
linear-gradient(140deg, #e7e3d7 0%, #efece4 45%, #e7e2d7 100%);
color:var(--text);
display:flex;
height:100vh;
overflow:hidden;
padding:14px;
gap:14px;
position:relative;
}
body::before,
body::after{
content:'';
position:absolute;
border-radius:50%;
pointer-events:none;
filter:blur(2px);
}
body::before{
width:280px;height:280px;
right:18%;top:-90px;
background:radial-gradient(circle, rgba(31,61,99,0.16), rgba(31,61,99,0));
}
body::after{
width:260px;height:260px;
left:24%;bottom:-120px;
background:radial-gradient(circle, rgba(24,106,58,0.14), rgba(24,106,58,0));
}
@keyframes riseIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
@keyframes pulseGlow{0%{box-shadow:0 0 0 0 rgba(31,61,99,0.18)}100%{box-shadow:0 0 0 14px rgba(31,61,99,0)}}
/* ── SIDEBAR ── */
#sidebar{
width:336px;min-width:336px;
background:rgba(255,252,246,0.82);
backdrop-filter:blur(8px);
-webkit-backdrop-filter:blur(8px);
display:flex;flex-direction:column;
border:1px solid rgba(255,255,255,0.55);
overflow:hidden;
box-shadow:0 18px 46px rgba(47,58,73,0.16);
z-index:10;
border-radius:18px;
animation:riseIn 0.5s ease;
}
.brand{
padding:20px 18px 16px;
border-bottom:1px solid rgba(255,255,255,0.25);
background:
linear-gradient(155deg, #1f3d63 0%, #1a4f7a 60%, #145a3c 100%);
color:white;
}
.brand-row{display:flex;align-items:center;gap:11px}
.brand-icon{
width:36px;height:36px;background:rgba(255,255,255,0.16);
border:1px solid rgba(255,255,255,0.26);
border-radius:10px;display:flex;align-items:center;justify-content:center;
font-size:15px;
animation:pulseGlow 2.5s ease-out infinite;
}
.brand-title{
font-family:'Space Grotesk',sans-serif;
font-size:18px;
font-weight:700;
letter-spacing:0.2px;
}
.brand-sub{font-size:11px;opacity:0.78;margin-top:3px;margin-left:47px}
.sec{
padding:14px 15px;
border-bottom:1px dashed rgba(154,166,178,0.35);
animation:riseIn 0.45s ease both;
}
.sec-lbl{font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:1.6px;color:var(--muted);margin-bottom:10px}
/* Mode buttons */
.mode-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.mbtn{
background:linear-gradient(180deg, #fffefb 0%, #f4efe6 100%);
border:1px solid #d9cfbe;
border-radius:12px;
padding:11px 6px 10px;
cursor:pointer;
color:var(--muted);
font-size:10px;font-weight:800;
letter-spacing:0.3px;
text-align:center;
transition:all 0.2s;
display:flex;flex-direction:column;align-items:center;gap:6px;
box-shadow:0 2px 8px rgba(33,43,54,0.05);
}
.mbtn i{font-size:17px}
.mbtn:hover{border-color:#9db6d4;color:var(--navy);background:var(--navy-light);transform:translateY(-1px)}
.mbtn.ap{border-color:var(--green);color:var(--green);background:var(--green-light)}
.mbtn.al{border-color:var(--red);color:var(--red);background:var(--red-light)}
.mbtn.ay{border-color:var(--blue);color:var(--blue);background:var(--blue-light)}
.mbtn.ak{border-color:var(--amber);color:var(--amber);background:#fcf0da}
.mbtn.am{border-color:var(--teal);color:var(--teal);background:var(--teal-light)}
/* Data list */
.list-wrap{flex:1;overflow-y:auto;padding:0 0 12px}
.list-wrap::-webkit-scrollbar{width:3px}
.list-wrap::-webkit-scrollbar-track{background:transparent}
.list-wrap::-webkit-scrollbar-thumb{background:#b9b1a6;border-radius:2px}
.grp{padding:12px 14px 0}
.grp-hd{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1.4px;
color:var(--muted);margin-bottom:7px;display:flex;align-items:center;justify-content:space-between}
.grp-cnt{background:var(--navy);color:white;border-radius:999px;padding:2px 8px;font-size:10px;font-weight:700}
.dcard{
background:linear-gradient(180deg, #fffefb 0%, #faf5ec 100%);
border:1px solid #ddd2c1;
border-radius:11px;padding:9px 11px 8px 13px;
margin-bottom:6px;cursor:pointer;transition:all 0.16s;
position:relative;overflow:hidden;
}
.dcard:hover{border-color:#b7a98f;box-shadow:0 6px 15px rgba(34,43,54,0.12);transform:translateX(2px) translateY(-1px)}
.dc-name{font-size:12px;font-weight:600;color:var(--text)}
.dc-sub{font-size:11px;color:var(--muted);margin-top:1px}
.dc-badge{display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;margin-top:5px}
/* Legend */
.leg-sec{padding:12px 15px;border-top:1px dashed rgba(154,166,178,0.35);background:var(--panel-soft)}
.leg-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-top:7px}
.leg-item{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted);font-weight:500}
.leg-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.leg-line{width:14px;height:3px;border-radius:2px;flex-shrink:0}
.leg-sq{width:10px;height:10px;border-radius:2px;flex-shrink:0;opacity:0.85}
/* Map */
#map{
flex:1;
z-index:1;
border-radius:18px;
overflow:hidden;
border:1px solid rgba(255,255,255,0.58);
box-shadow:0 20px 50px rgba(48,59,76,0.14);
animation:riseIn 0.6s ease;
}
/* Hint bar */
#hint{
position:absolute;bottom:32px;left:50%;transform:translateX(-50%);
z-index:1000;
background:rgba(21,45,73,0.92);color:white;
border:1px solid rgba(255,255,255,0.2);
border-radius:999px;padding:9px 20px;
font-size:12px;font-weight:600;
display:none;pointer-events:none;
box-shadow:0 8px 20px rgba(27,58,92,0.35);
white-space:nowrap;
}
#hint.show{display:block}
/* Leaflet popup */
.leaflet-popup-content-wrapper{
background:var(--panel) !important;
border:1px solid #e2d8c8 !important;
border-radius:14px !important;
box-shadow:0 16px 35px rgba(23,32,44,0.16) !important;
color:var(--text) !important;
}
.leaflet-popup-tip{background:var(--panel) !important}
.leaflet-popup-content{margin:0 !important;width:auto !important}
/* Popup form */
.pf{padding:16px;width:245px}
.pf h4{font-size:14px;font-weight:700;margin-bottom:13px;color:var(--text);display:flex;align-items:center;gap:7px}
.pf label{font-size:11px;color:var(--muted);display:block;margin-bottom:3px}
.pf input,.pf select{
width:100%;background:var(--panel2);
border:1.5px solid #d8ccb8;
border-radius:7px;padding:7px 10px;
color:var(--text);font-size:12px;
margin-bottom:9px;outline:none;
transition:border-color 0.2s;
font-family:inherit;
}
.pf textarea{
width:100%;background:var(--panel2);
border:1.5px solid #d8ccb8;
border-radius:7px;padding:7px 10px;
color:var(--text);font-size:12px;
margin-bottom:9px;outline:none;
transition:border-color 0.2s;
font-family:monospace;
resize:vertical;
min-height:84px;
}
.pf input:focus,.pf select:focus{border-color:#2f6599;box-shadow:0 0 0 3px rgba(47,101,153,0.1)}
.pf textarea:focus{border-color:#2f6599;box-shadow:0 0 0 3px rgba(47,101,153,0.1)}
.pf input[readonly]{color:var(--muted);cursor:default;background:#fafaf9}
.pf select option{background:var(--panel)}
.pbtn{
width:100%;padding:10px;border:none;border-radius:9px;
font-size:13px;font-weight:700;cursor:pointer;
transition:all 0.15s;font-family:inherit;
}
.pbtn:hover{opacity:0.9;transform:translateY(-1px)}
.pbtn-g{background:var(--green);color:white}
.pbtn-r{background:var(--red);color:white}
.pbtn-b{background:var(--blue);color:white}
.pbtn-y{background:var(--amber);color:white}
.hint-txt{font-size:10px;color:var(--amber);margin-bottom:9px}
.coord-row{display:flex;gap:6px;margin-bottom:4px}
.act-row{display:flex;gap:7px;margin-top:10px}
.act-row .pbtn{padding:8px;font-size:11px}
/* Popup info */
.pi{padding:16px;min-width:250px}
.pi h4{font-size:15px;font-weight:800;margin-bottom:12px;display:flex;align-items:flex-start;gap:8px;line-height:1.35}
.pi h4 i{margin-top:2px;font-size:16px;flex-shrink:0}
.pi-row{display:grid;grid-template-columns:75px 1fr;gap:8px;align-items:start;font-size:12px;padding:7px 0;border-bottom:1px solid #ebe2d4}
.pi-row:last-child{border-bottom:none}
.pi-k{color:var(--muted)}
.pi-v{color:var(--text);font-weight:600;line-height:1.45;word-break:break-word}
/* ── MODERN MARKER PIN ── */
.modern-marker {
position: relative;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.modern-marker .pin-body {
width: 32px;
height: 32px;
background: var(--marker-color);
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 2px;
left: 4px;
box-shadow: 0 4px 10px rgba(0,0,0,0.25), inset 0 2px 4px rgba(255,255,255,0.4);
border: 2px solid white;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.modern-marker:hover .pin-body {
transform: rotate(-45deg) scale(1.1);
}
.modern-marker .icon-container {
transform: rotate(45deg);
color: white;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.modern-marker .pulse {
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
width: 14px;
height: 4px;
background: var(--marker-color);
border-radius: 50%;
filter: blur(1px);
animation: markerPulse 2s infinite;
}
@keyframes markerPulse {
0% { transform: translateX(-50%) scale(0.8); opacity: 0.8; }
50% { transform: translateX(-50%) scale(1.8); opacity: 0; }
100% { transform: translateX(-50%) scale(0.8); opacity: 0; }
}
.modern-marker .pin-label {
position: absolute;
bottom: 48px;
left: 50%;
transform: translateX(-50%) translateY(8px);
background: var(--navy);
color: white;
padding: 5px 12px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
pointer-events: none;
box-shadow: 0 4px 12px rgba(31,61,99,0.25);
border: 1px solid rgba(255,255,255,0.15);
z-index: 100;
}
.modern-marker .pin-label::after {
content: '';
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
border-width: 5px 5px 0;
border-style: solid;
border-color: var(--navy) transparent transparent transparent;
}
.modern-marker:hover .pin-label {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Leaflet controls */
.leaflet-control-zoom a{
background:rgba(255,253,248,0.96) !important;color:var(--navy) !important;
border:1px solid #dbcfbf !important;
font-weight:700 !important;
}
.leaflet-bar{box-shadow:0 8px 18px rgba(35,44,57,0.14) !important;border:none !important;border-radius:10px !important}
.leaflet-bar a:first-child{border-top-left-radius:10px !important;border-top-right-radius:10px !important}
.leaflet-bar a:last-child{border-bottom-left-radius:10px !important;border-bottom-right-radius:10px !important}
.leaflet-control-zoom{border:none !important}
.leaflet-control-attribution{
font-size:10px !important;
background:rgba(255,252,246,0.85) !important;
border-top-left-radius:8px;
padding:2px 6px !important;
}
.leaflet-control-layers{
background:rgba(255,252,246,0.96) !important;
border:1px solid #d6c9b4 !important;
border-radius:14px !important;
box-shadow:0 14px 30px rgba(30,41,53,0.18) !important;
padding:4px 2px;
}
.leaflet-control-layers-expanded{
padding:12px 12px 10px 12px !important;
min-width:260px;
}
/* Hapus ::before bawaan kita pakai header custom */
.leaflet-control-layers-expanded::before{
display:none;
}
/* Header toggle panel filter */
.lc-header{
display:flex;
align-items:center;
justify-content:space-between;
margin-bottom:10px;
padding-bottom:8px;
border-bottom:1px dashed #d7c9b2;
cursor:pointer;
user-select:none;
}
.lc-header-title{
font-size:11px;
font-weight:800;
letter-spacing:1.1px;
color:#5c6a78;
text-transform:uppercase;
}
.lc-toggle-btn{
width:22px;height:22px;
background:rgba(31,61,99,0.08);
border:1px solid rgba(31,61,99,0.18);
border-radius:6px;
display:flex;align-items:center;justify-content:center;
color:#1f3d63;
font-size:11px;
transition:all 0.2s;
flex-shrink:0;
}
.lc-toggle-btn:hover{
background:rgba(31,61,99,0.16);
border-color:rgba(31,61,99,0.32);
}
.lc-toggle-btn i{
transition:transform 0.3s cubic-bezier(0.4,0,0.2,1);
}
.lc-toggle-btn.collapsed i{
transform:rotate(-180deg);
}
/* Animasi buka/tutup konten layer */
.lc-body{
overflow:hidden;
transition:max-height 0.32s cubic-bezier(0.4,0,0.2,1), opacity 0.28s ease;
max-height:600px;
opacity:1;
}
.lc-body.collapsed{
max-height:0;
opacity:0;
}
.leaflet-control-layers label{
display:flex;
align-items:center;
gap:9px;
font-size:12px;
color:#2b3440;
font-weight:600;
margin-bottom:6px;
background:linear-gradient(180deg,#fffdfa 0%,#f8f2e9 100%);
border:1px solid #decfb9;
border-radius:10px;
padding:8px 10px;
transition:all 0.15s ease;
}
.leaflet-control-layers label:hover{
border-color:#c8b394;
box-shadow:0 4px 10px rgba(42,56,73,0.1);
transform:translateY(-1px);
}
.leaflet-control-layers-selector{
accent-color:#245f90;
width:16px;
height:16px;
margin:0;
}
.leaflet-control-layers label > span{
flex:1;
}
.leaflet-control-layers-overlays{
display:flex;
flex-direction:column;
gap:6px;
}
.lc-group-title{
font-size:10px;
font-weight:800;
letter-spacing:1px;
text-transform:uppercase;
color:#667381;
margin:2px 2px 0 2px;
}
.lc-group-title:not(:first-child){
margin-top:8px;
padding-top:8px;
border-top:1px dashed #d9ccb7;
}
.lc-item{
display:flex;
align-items:center;
gap:8px;
}
.lc-dot{
width:11px;
height:11px;
border-radius:50%;
flex-shrink:0;
box-shadow:0 0 0 2px rgba(255,255,255,0.8), 0 0 0 3px rgba(32,49,68,0.09);
}
.lc-dot-24{background:#186a3a}
.lc-dot-non24{background:#ad352b}
.lc-dot-jalan{background:#e74c3c}
.lc-dot-parsil{background:#1f5b84}
.lc-dot-kec{background:#d4841c}
.lc-dot-kemiskinan{background:#9f6500}
.lc-dot-masjid{background:#0f6250}
.lc-text{
display:flex;
flex-direction:column;
line-height:1.2;
}
.lc-text strong{
font-size:12px;
font-weight:700;
color:#243343;
}
.lc-text small{
font-size:10px;
color:#667788;
font-weight:600;
}
@media (max-width:920px){
.leaflet-control-layers-expanded{min-width:220px}
}
@media (max-width:1180px){
body{padding:10px;gap:10px}
#sidebar{width:312px;min-width:312px}
}
@media (max-width:920px){
body{flex-direction:column;height:100dvh}
#sidebar{
width:100%;min-width:0;
height:44vh;
border-radius:16px;
}
#map{height:calc(56vh - 10px);min-height:300px;border-radius:16px}
.brand-title{font-size:17px}
.leg-grid{grid-template-columns:1fr}
.leaflet-control-layers-expanded{min-width:180px}
}
@media (max-width:620px){
.mode-grid{grid-template-columns:1fr 1fr 1fr}
.mbtn{font-size:9px;padding:10px 4px}
.sec{padding:12px}
.grp{padding:10px 12px 0}
.brand-sub{margin-left:0;margin-top:6px}
}
</style>
</head>
<body>
<div id="sidebar">
<div class="brand">
<div class="brand-row">
<div class="brand-icon"><i class="fas fa-map-marked-alt"></i></div>
<div class="brand-title">WebGis</div>
</div>
<div class="brand-sub">Sistem Informasi Tata Ruang Kota</div>
</div>
<div class="sec">
<div class="sec-lbl">Mode Input</div>
<div class="mode-grid">
<button class="mbtn" id="btn-point" onclick="setMode('point')">
<i class="fas fa-gas-pump"></i>SPBU
</button>
<button class="mbtn" id="btn-line" onclick="setMode('line')">
<i class="fas fa-road"></i>Jalan
</button>
<button class="mbtn" id="btn-poly" onclick="setMode('poly')">
<i class="fas fa-draw-polygon"></i>Parsil
</button>
</div>
</div>
<div class="list-wrap">
<div class="grp" id="list-spbu">
<div class="grp-hd">SPBU <span class="grp-cnt" id="cnt-spbu">0</span></div>
</div>
<div class="grp" id="list-jalan">
<div class="grp-hd">Jalan <span class="grp-cnt" id="cnt-jalan">0</span></div>
</div>
<div class="grp" id="list-parsil">
<div class="grp-hd">Parsil Tanah <span class="grp-cnt" id="cnt-parsil">0</span></div>
</div>
</div>
<div class="leg-sec">
<div class="sec-lbl" style="margin-bottom:0">Legenda</div>
<div class="leg-grid">
<div class="leg-item"><div class="leg-dot" style="background:var(--green)"></div>SPBU Buka 24 Jam</div>
<div class="leg-item"><div class="leg-dot" style="background:var(--red)"></div>SPBU Tidak Buka 24 Jam</div>
<div class="leg-item"><div class="leg-line" style="background:#e74c3c"></div>Nasional</div>
<div class="leg-item"><div class="leg-line" style="background:#e6a817"></div>Provinsi</div>
<div class="leg-item"><div class="leg-line" style="background:#27ae60"></div>Kabupaten</div>
<div class="leg-item"><div class="leg-sq" style="background:var(--blue)"></div>SHM</div>
<div class="leg-item"><div class="leg-sq" style="background:var(--purple)"></div>HGB</div>
<div class="leg-item"><div class="leg-sq" style="background:#9a6300"></div>HGU</div>
<div class="leg-item"><div class="leg-sq" style="background:var(--teal)"></div>HP</div>
<div class="leg-item"><div class="leg-sq" style="background:#d4841c"></div>Batas Kecamatan</div>
</div>
</div>
</div>
<div id="map"></div>
<div id="hint"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.11.0/proj4.min.js"></script>
<script>
/* ── STATE ── */
const state={
mode:null,
spbu:[],jalan:[],parsil:[],
featureRefs:{spbu:{},jalan:{},parsil:{}},
geometryEdit:null,
activeCard:null,
spbuFilterVis:{open24:true,non24:true},
drawingLine:false,drawingPoly:false,
tempPoints:[],tempMarkers:[],
tempPolyline:null,tempPolygon:null
};
/* ── MAP ── */
const map=L.map('map',{zoomControl:false,preferCanvas:true}).setView([-0.0263,109.3425],14);
L.control.zoom({position:'bottomright'}).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
attribution:'© OpenStreetMap contributors',maxZoom:19
}).addTo(map);
const layers={
spbu24:L.layerGroup().addTo(map),
spbuNon24:L.layerGroup().addTo(map),
jalan:L.layerGroup().addTo(map),
parsil:L.layerGroup().addTo(map),
kecamatan:L.layerGroup().addTo(map)
};
const layersControl=L.control.layers(null,{
'<span class="lc-item"><span class="lc-dot lc-dot-24"></span><span class="lc-text"><strong>SPBU Buka 24 Jam</strong><small>Operasional penuh</small></span></span>':layers.spbu24,
'<span class="lc-item"><span class="lc-dot lc-dot-non24"></span><span class="lc-text"><strong>SPBU Tidak Buka 24 Jam</strong><small>Jam layanan terbatas</small></span></span>':layers.spbuNon24,
'<span class="lc-item"><span class="lc-dot lc-dot-jalan"></span><span class="lc-text"><strong>Layer Jalan</strong><small>Semua segmen jalan</small></span></span>':layers.jalan,
'<span class="lc-item"><span class="lc-dot lc-dot-parsil"></span><span class="lc-text"><strong>Layer Parsil</strong><small>Semua area parsil tanah</small></span></span>':layers.parsil,
'<span class="lc-item"><span class="lc-dot lc-dot-kec"></span><span class="lc-text"><strong>Batas Kecamatan</strong><small>Wilayah administrasi</small></span></span>':layers.kecamatan
},{collapsed:false,position:'topright'}).addTo(map);
decorateLayersControl();
function decorateLayersControl(){
const container=layersControl.getContainer();
if(!container)return;
const overlays=container.querySelector('.leaflet-control-layers-overlays');
if(!overlays)return;
const labels=Array.from(overlays.querySelectorAll('label'));
if(labels.length<5)return;
overlays.innerHTML='';
const addGroup=(title,items)=>{
const head=document.createElement('div');
head.className='lc-group-title';
head.textContent=title;
overlays.appendChild(head);
items.forEach(item=>overlays.appendChild(item));
};
addGroup('SPBU',[labels[0],labels[1]]);
addGroup('Jaringan Jalan',[labels[2]]);
addGroup('Bidang Tanah',[labels[3]]);
addGroup('Administrasi',[labels[4]]);
/* ── Sisipkan header toggle di atas konten overlays ── */
// overlays.parentNode adalah .leaflet-control-layers-list (inner wrapper)
const listWrapper=overlays.parentNode;
// Bungkus overlays dalam div.lc-body
const body=document.createElement('div');
body.className='lc-body';
listWrapper.insertBefore(body,overlays);
body.appendChild(overlays);
// Buat header + tombol toggle
const header=document.createElement('div');
header.className='lc-header';
header.innerHTML=
'<span class="lc-header-title">Filter Layer Peta</span>'+
'<span class="lc-toggle-btn" id="lc-toggle-btn" title="Buka/Tutup">'+
'<i class="fas fa-chevron-up"></i>'+
'</span>';
listWrapper.insertBefore(header,body);
const btn=header.querySelector('#lc-toggle-btn');
let isOpen=true;
function togglePanel(){
isOpen=!isOpen;
body.classList.toggle('collapsed',!isOpen);
btn.classList.toggle('collapsed',!isOpen);
}
header.addEventListener('click',togglePanel);
// Cegah klik pada panel menutup/membuka Leaflet secara tidak sengaja
L.DomEvent.disableClickPropagation(container);
}
map.on('overlayadd',function(e){
if(e.layer===layers.spbu24)state.spbuFilterVis.open24=true;
if(e.layer===layers.spbuNon24)state.spbuFilterVis.non24=true;
});
map.on('overlayremove',function(e){
if(e.layer===layers.spbu24)state.spbuFilterVis.open24=false;
if(e.layer===layers.spbuNon24)state.spbuFilterVis.non24=false;
});
const jColor={'Nasional':'#e74c3c','Provinsi':'#e6a817','Kabupaten':'#27ae60'};
const pColor={'SHM':'#1a5276','HGB':'#5d2d82','HGU':'#9a6300','HP':'#0d5c4a'};
const kecColorMap={
'Pontianak Timur':'#ef6b73',
'Pontianak Selatan':'#f0c86a',
'Pontianak Utara':'#e8c45d',
'Pontianak Barat':'#8f6cc6',
'Pontianak Kota':'#83be86',
'Pontianak Tenggara':'#eec8d8'
};
const utm49s='+proj=utm +zone=49 +south +datum=WGS84 +units=m +no_defs';
const wgs84='WGS84';
function utm49sToLngLat(easting,northing){
// Fallback konversi UTM 49S -> WGS84 ketika proj4 tidak tersedia.
const a=6378137.0;
const f=1/298.257223563;
const e2=f*(2-f);
const ep2=e2/(1-e2);
const k0=0.9996;
const falseEasting=500000;
const falseNorthing=10000000;
const zone=49;
const x=Number(easting)-falseEasting;
const y=Number(northing)-falseNorthing;
const m=y/k0;
const mu=m/(a*(1-e2/4-(3*e2*e2)/64-(5*e2*e2*e2)/256));
const e1=(1-Math.sqrt(1-e2))/(1+Math.sqrt(1-e2));
const j1=(3*e1/2)-(27*Math.pow(e1,3)/32);
const j2=(21*e1*e1/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 sinFp=Math.sin(fp);
const cosFp=Math.cos(fp);
const tanFp=Math.tan(fp);
const c1=ep2*cosFp*cosFp;
const t1=tanFp*tanFp;
const n1=a/Math.sqrt(1-e2*sinFp*sinFp);
const r1=(a*(1-e2))/Math.pow(1-e2*sinFp*sinFp,1.5);
const d=x/(n1*k0);
const lat=fp-(n1*tanFp/r1)*(
(d*d)/2-
((5+3*t1+10*c1-4*c1*c1-9*ep2)*Math.pow(d,4))/24+
((61+90*t1+298*c1+45*t1*t1-252*ep2-3*c1*c1)*Math.pow(d,6))/720
);
const lon0=((zone-1)*6-180+3)*Math.PI/180;
const lon=lon0+(
d-
((1+2*t1+c1)*Math.pow(d,3))/6+
((5-2*c1+28*t1-3*c1*c1+8*ep2+24*t1*t1)*Math.pow(d,5))/120
)/cosFp;
return [lon*180/Math.PI,lat*180/Math.PI];
}
function isProjectedCoordinate(coord){
if(!Array.isArray(coord) || coord.length<2)return false;
return Math.abs(coord[0])>180 || Math.abs(coord[1])>90;
}
function convertProjectedPointToLngLat(coord){
if(!Array.isArray(coord) || coord.length<2)return coord;
if(isProjectedCoordinate(coord)){
if(typeof proj4==='function'){
try{
const converted=proj4(utm49s,wgs84,[Number(coord[0]),Number(coord[1])]);
if(Array.isArray(converted) && Number.isFinite(converted[0]) && Number.isFinite(converted[1])){
return [converted[0],converted[1]];
}
}catch(_){
// fallback ke konversi manual
}
}
return utm49sToLngLat(coord[0],coord[1]);
}
return [Number(coord[0]),Number(coord[1])];
}
function reprojectGeoJSONCoordinates(coords){
if(!Array.isArray(coords) || coords.length===0)return coords;
if(typeof coords[0]==='number')return convertProjectedPointToLngLat(coords);
return coords.map(reprojectGeoJSONCoordinates);
}
function normalizeAdminKecamatanGeoJSON(geojson){
return {
...geojson,
features:(geojson.features||[]).map(feature=>({
...feature,
geometry:{
...feature.geometry,
coordinates:reprojectGeoJSONCoordinates(feature.geometry.coordinates)
}
}))
};
}
function getKecamatanColor(name){
return kecColorMap[name] || '#d9b26f';
}
function loadAdminKecamatan(){
fetch('Admin_Kecamatan.json')
.then(res=>res.json())
.then(raw=>{
const geojson=normalizeAdminKecamatanGeoJSON(raw);
const kecLayer=L.geoJSON(geojson,{
interactive:false,
style:feature=>{
const name=(feature.properties&&feature.properties.Ket)||'Kecamatan';
const fill=getKecamatanColor(name);
return {
color:'#232831',
weight:3,
fillColor:fill,
fillOpacity:0.4
};
}
});
kecLayer.addTo(layers.kecamatan);
kecLayer.bringToBack();
const bounds=kecLayer.getBounds();
if(bounds && bounds.isValid()){
map.fitBounds(bounds,{padding:[24,24],maxZoom:13});
}
console.log('Layer kecamatan berhasil dimuat:',kecLayer.getLayers().length,'fitur');
})
.catch(err=>console.log('Admin Kecamatan Error:',err));
}
/* ── MODE ── */
function setMode(m){
cancelDrawing();
state.mode = state.mode===m ? null : m;
['point','line','poly'].forEach(x=>{
const el = document.getElementById('btn-'+x);
if(el) el.className='mbtn';
});
const hint=document.getElementById('hint');
hint.classList.remove('show');
if(state.mode){
const ac={point:'ap',line:'al',poly:'ay'};
const el = document.getElementById('btn-'+(m==='point'?'point':m));
if(el) el.classList.add(ac[m]);
const msgs={
point:'📍 Klik peta untuk menambah titik SPBU',
line:'🛣️ Klik untuk gambar jalan — klik ganda untuk selesai',
poly:'🗺️ Klik untuk gambar parsil — klik titik awal atau dbl-klik selesai'
};
hint.textContent=msgs[m];
hint.classList.add('show');
map.getContainer().style.cursor='crosshair';
} else {
map.getContainer().style.cursor='';
}
}
/* ── MAP EVENTS ── */
map.on('click',function(e){
if(!state.mode)return;
if(state.mode==='point')handlePointClick(e);
else if(state.mode==='line')handleLineClick(e);
else if(state.mode==='poly')handlePolyClick(e);
});
map.on('dblclick',function(e){
if(state.mode==='line'&&state.drawingLine)finishLine(e);
if(state.mode==='poly'&&state.drawingPoly)finishPoly(e);
if(state.mode==='line'||state.mode==='poly')L.DomEvent.stopPropagation(e);
});
/* ── REVERSE GEOCODE ── */
function getAddressFromMap(lat, lng, targetId) {
const el = document.getElementById(targetId);
if(!el) return;
el.value = "Memuat alamat...";
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`)
.then(res => res.json())
.then(data => {
if(data && data.display_name) el.value = data.display_name;
else el.value = "Alamat tidak ditemukan";
})
.catch(err => {
console.log(err);
el.value = "Gagal memuat alamat";
});
}
/* ── MODERN MARKER ICON ── */
function createModernMarker(color, iconClass, label){
const html = `
<div class="modern-marker" style="--marker-color: ${color}">
<div class="pulse"></div>
<div class="pin-body">
<div class="icon-container">
<i class="${iconClass}"></i>
</div>
</div>
<div class="pin-label">${label}</div>
</div>
`;
return L.divIcon({
html: html,
className: '',
iconSize: [40, 40],
iconAnchor: [20, 38],
popupAnchor: [0, -42]
});
}
/* ── POINT SPBU ── */
function handlePointClick(e){
const tmpM=L.marker(e.latlng,{draggable:true}).addTo(map);
const upd=ll=>{
const a=document.getElementById('p-lat'),b=document.getElementById('p-lng');
if(a)a.value=ll.lat.toFixed(6);
if(b)b.value=ll.lng.toFixed(6);
getAddressFromMap(ll.lat, ll.lng, 'p-alamat');
};
tmpM.on('dragend',ev=>upd(ev.target.getLatLng()));
const form=`<div class="pf">
<h4><i class="fas fa-gas-pump" style="color:var(--green)"></i> Tambah SPBU</h4>
<label>Nama SPBU</label>
<input id="p-nama" placeholder="Pertamina Ahmad Yani">
<label>Nomor SPBU</label>
<input id="p-no" placeholder="64.xxx.xx">
<label>Status Operasional</label>
<select id="p-status">
<option value="Ya">Buka 24 Jam</option>
<option value="Tidak">Tidak Buka 24 Jam</option>
</select>
<label>Alamat</label>
<textarea id="p-alamat" placeholder="Alamat lengkap"></textarea>
<div class="coord-row">
<input id="p-lat" value="${e.latlng.lat.toFixed(6)}" readonly>
<input id="p-lng" value="${e.latlng.lng.toFixed(6)}" readonly>
</div>
<div class="hint-txt">* Geser marker untuk menyesuaikan posisi</div>
<button class="pbtn pbtn-g" onclick="savePoint()">Simpan SPBU</button>
</div>`;
tmpM.bindPopup(form,{maxWidth:280}).openPopup();
setTimeout(()=>getAddressFromMap(e.latlng.lat, e.latlng.lng, 'p-alamat'), 100);
tmpM.on('popupclose',()=>{if(map.hasLayer(tmpM))map.removeLayer(tmpM);});
window._tmpM=tmpM;
}
function savePoint(){
const nama=document.getElementById('p-nama').value.trim();
const no=document.getElementById('p-no').value.trim();
const status=document.getElementById('p-status').value;
const alamat=document.getElementById('p-alamat').value.trim();
const lat=parseFloat(document.getElementById('p-lat').value);
const lng=parseFloat(document.getElementById('p-lng').value);
if(!nama||!no||!alamat){
alert('Lengkapi nama, nomor, dan alamat SPBU!');
return;
}
fetch("api/save_data.php",{
method:"POST",
headers:{
"Content-Type":"application/json"
},
body:JSON.stringify({
nama:nama,
no:no,
status:status,
alamat:alamat,
lat:lat,
lng:lng
})
})
.then(res=>res.json())
.then(response=>{
if(response.status=="success"){
const d={
id:response.id || Date.now(),
nama,
no,
status,
alamat,
lat,
lng
};
state.spbu.push(d);
renderSPBU(d);
updateCount('spbu',state.spbu.length);
addCard(
'spbu',
d.id,
nama,
'No: '+no,
status==='Ya'?'Buka 24 Jam':'Tidak Buka 24 Jam',
status==='Ya'?'#1a6b3c':'#b93328'
);
if(window._tmpM){
map.closePopup();
map.removeLayer(window._tmpM);
window._tmpM=null;
}
setMode(null);
}else{
alert("Gagal simpan database");
}
})
.catch(err=>{
console.log(err);
alert("Terjadi error");
});
}
function renderSPBU(d){
const color = d.status==='Ya' ? '#1a6b3c' : '#b93328';
const icon = createModernMarker(color, 'fas fa-gas-pump', d.nama);
const m = L.marker([d.lat,d.lng],{icon,draggable:false});
m.bindPopup(`<div class="pi">
<h4><i class="fas fa-gas-pump" style="color:${color}"></i>${d.nama}</h4>
<div class="pi-row"><span class="pi-k">No. SPBU</span><span class="pi-v">${d.no}</span></div>
<div class="pi-row"><span class="pi-k">Status</span><span class="pi-v" style="color:${color}">${d.status==='Ya'?'✓ Buka 24 Jam':'✗ Tidak Buka 24 Jam'}</span></div>
<div class="pi-row"><span class="pi-k">Alamat</span><span class="pi-v">${d.alamat || '-'}</span></div>
<div class="pi-row"><span class="pi-k">Koordinat</span><span class="pi-v" style="font-size:11px;font-family:monospace">${d.lat.toFixed(5)}, ${d.lng.toFixed(5)}</span></div>
<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditSpbu(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('spbu',${d.id})">Hapus</button>
</div>
</div>`, {minWidth: 260, maxWidth: 300});
m.addTo(d.status==='Ya' ? layers.spbu24 : layers.spbuNon24);
state.featureRefs.spbu[String(d.id)] = m;
}
/* ── JALAN ── */
function handleLineClick(e){
if(!state.drawingLine)state.drawingLine=true;
state.tempPoints.push(e.latlng);
const vm=L.circleMarker(e.latlng,{radius:4,color:'#e74c3c',fillColor:'#e74c3c',fillOpacity:1,weight:2}).addTo(map);
state.tempMarkers.push(vm);
if(state.tempPolyline)map.removeLayer(state.tempPolyline);
state.tempPolyline=L.polyline(state.tempPoints,{color:'#e74c3c',weight:3,dashArray:'7,5',opacity:0.8}).addTo(map);
document.getElementById('hint').textContent=`🛣️ ${state.tempPoints.length} titik — klik ganda untuk selesai`;
}
function finishLine(e){
if(state.tempPoints.length<2){alert('Minimal 2 titik!');return;}
const pts=[...state.tempPoints];
const len=calcLen(pts);
clearTemp();
L.popup({closeOnClick:false,keepInView:true})
.setLatLng(pts[Math.floor(pts.length/2)])
.setContent(`<div class="pf">
<h4><i class="fas fa-road" style="color:var(--red)"></i> Data Jalan</h4>
<label>Nama Jalan</label>
<input id="j-nama" placeholder="Jl. Ahmad Yani">
<label>Status Jalan</label>
<select id="j-status">
<option value="Nasional">Jalan Nasional</option>
<option value="Provinsi">Jalan Provinsi</option>
<option value="Kabupaten">Jalan Kabupaten</option>
</select>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:11px">
<span style="font-size:11px;color:var(--muted)">Panjang otomatis:</span>
<span style="font-size:13px;font-weight:700;color:var(--red)">${fmtLen(len)}</span>
</div>
<button class="pbtn pbtn-r" onclick="saveLine(${JSON.stringify(pts).replace(/"/g,"'")},${len})">Simpan Jalan</button>
</div>`).addTo(map);
}
function saveLine(pts,len){
const nama=document.getElementById('j-nama').value.trim();
const status=document.getElementById('j-status').value;
if(!nama){
alert('Isi nama jalan!');
return;
}
fetch("api/save_jalan.php",{
method:"POST",
headers:{
"Content-Type":"application/json"
},
body:JSON.stringify({
nama:nama,
status:status,
panjang:len,
koordinat:pts
})
})
.then(res=>res.json())
.then(response=>{
if(response.status=="success"){
const d={
id:response.id || Date.now(),
nama,
status,
pts,
len
};
state.jalan.push(d);
renderJalan(d);
updateCount('jalan',state.jalan.length);
addCard(
'jalan',
d.id,
nama,
status+' · '+fmtLen(len),
status,
jColor[status]
);
map.closePopup();
setMode(null);
}else{
console.log(response);
alert("Gagal simpan jalan");
}
})
.catch(err=>{
console.log(err);
alert("Error simpan jalan");
});
}
function renderJalan(d){
const pts = normalizeLinePoints(d.pts);
if(pts.length<2){
console.log('Format koordinat jalan tidak valid:', d.pts);
return;
}
const c = jColor[d.status] || '#e74c3c';
const w =
d.status === 'Nasional' ? 5 :
d.status === 'Provinsi' ? 4 : 3;
const line = L.polyline(pts,{
color:c,
weight:w,
opacity:0.9
})
.bindPopup(`
<div class="pi">
<h4><i class="fas fa-road" style="color:${c}"></i>${d.nama}</h4>
<div class="pi-row">
<span class="pi-k">Status</span>
<span class="pi-v">${d.status}</span>
</div>
<div class="pi-row">
<span class="pi-k">Panjang</span>
<span class="pi-v">${fmtLen(d.len)}</span>
</div>
<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditJalan(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('jalan',${d.id})">Hapus</button>
</div>
</div>
`)
.addTo(layers.jalan);
state.featureRefs.jalan[String(d.id)] = line;
}
function normalizeLinePoints(raw){
let pts = raw;
if(typeof pts === 'string'){
try{
pts = JSON.parse(pts);
}catch(_){
return [];
}
}
// Kompatibilitas data lama: koordinat sempat tersimpan sebagai JSON string di dalam JSON string.
if(typeof pts === 'string'){
try{
pts = JSON.parse(pts);
}catch(_){
return [];
}
}
if(!Array.isArray(pts) || pts.length===0)return [];
if(Array.isArray(pts[0])){
return pts
.map(p=>[parseFloat(p[0]),parseFloat(p[1])])
.filter(p=>Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
if(pts[0] && typeof pts[0] === 'object' && pts[0].lat !== undefined){
return pts
.map(p=>[parseFloat(p.lat),parseFloat(p.lng)])
.filter(p=>Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
return [];
}
function normalizePolyPoints(raw){
let pts = raw;
if(typeof pts === 'string'){
try{
pts = JSON.parse(pts);
}catch(_){
return [];
}
}
if(typeof pts === 'string'){
try{
pts = JSON.parse(pts);
}catch(_){
return [];
}
}
if(!Array.isArray(pts) || pts.length===0)return [];
if(Array.isArray(pts[0])){
return pts
.map(p=>[parseFloat(p[0]),parseFloat(p[1])])
.filter(p=>Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
if(pts[0] && typeof pts[0] === 'object' && pts[0].lat !== undefined){
return pts
.map(p=>[parseFloat(p.lat),parseFloat(p.lng)])
.filter(p=>Number.isFinite(p[0]) && Number.isFinite(p[1]));
}
return [];
}
/* ── PARSIL ── */
function handlePolyClick(e){
if(!state.drawingPoly)state.drawingPoly=true;
if(state.tempPoints.length>=3){
const dist=map.distance(e.latlng,state.tempPoints[0]);
if(dist<15){finishPoly(null);return;}
}
state.tempPoints.push(e.latlng);
const vm=L.circleMarker(e.latlng,{radius:4,color:'#1a5276',fillColor:'#1a5276',fillOpacity:1,weight:2}).addTo(map);
state.tempMarkers.push(vm);
if(state.tempPolygon)map.removeLayer(state.tempPolygon);
if(state.tempPolyline)map.removeLayer(state.tempPolyline);
if(state.tempPoints.length>=2)
state.tempPolyline=L.polyline([...state.tempPoints,state.tempPoints[0]],
{color:'#1a5276',weight:2,dashArray:'5,4',opacity:0.7}).addTo(map);
if(state.tempPoints.length>=3)
state.tempPolygon=L.polygon(state.tempPoints,
{color:'#1a5276',fillColor:'#1a5276',fillOpacity:0.1,weight:2,dashArray:'5,4'}).addTo(map);
document.getElementById('hint').textContent=
`🗺️ ${state.tempPoints.length} titik — klik titik awal atau dbl-klik selesai`;
}
function finishPoly(e){
if(state.tempPoints.length<3){alert('Minimal 3 titik!');return;}
const pts=[...state.tempPoints];
const area=calcArea(pts);
clearTemp();
const center=L.polygon(pts).getBounds().getCenter();
L.popup({closeOnClick:false,keepInView:true})
.setLatLng(center)
.setContent(`<div class="pf">
<h4><i class="fas fa-draw-polygon" style="color:var(--blue)"></i> Data Parsil Tanah</h4>
<label>Nama / ID Parsil</label>
<input id="ps-nama" placeholder="Parsil A-01">
<label>Pemilik</label>
<input id="ps-pemilik" placeholder="Nama pemilik">
<label>Status Sertifikat</label>
<select id="ps-status">
<option value="SHM">SHM Hak Milik</option>
<option value="HGB">HGB Hak Guna Bangunan</option>
<option value="HGU">HGU Hak Guna Usaha</option>
<option value="HP">HP Hak Pakai</option>
</select>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:11px">
<span style="font-size:11px;color:var(--muted)">Luas otomatis:</span>
<span style="font-size:13px;font-weight:700;color:var(--blue)">${fmtArea(area)}</span>
</div>
<button class="pbtn pbtn-b" onclick="saveParsil(${JSON.stringify(pts).replace(/"/g,"'")},${area})">Simpan Parsil</button>
</div>`).addTo(map);
}
function saveParsil(pts,area){
const nama=document.getElementById('ps-nama').value.trim();
const pemilik=document.getElementById('ps-pemilik').value.trim();
const status=document.getElementById('ps-status').value;
if(!nama){
alert('Isi nama parsil!');
return;
}
fetch("api/save_parsil.php",{
method:"POST",
headers:{
"Content-Type":"application/json"
},
body:JSON.stringify({
nama:nama,
pemilik:pemilik,
status:status,
luas:area,
koordinat:pts
})
})
.then(res=>res.json())
.then(response=>{
if(response.status=="success"){
const parsil={
id:response.id || Date.now(),
nama,
pemilik,
status,
pts,
area
};
state.parsil.push(parsil);
renderParsil(parsil);
updateCount('parsil',state.parsil.length);
addCard(
'parsil',
parsil.id,
parsil.nama,
parsil.status,
parsil.status,
'#1a5276'
);
map.closePopup();
setMode(null);
}
else{
console.log(response);
alert("Gagal simpan parsil");
}
})
.catch(err=>console.log("Parsil Error:",err));
}
function renderParsil(d){
const pts = normalizePolyPoints(d.pts);
if(pts.length<3){
console.log('Format koordinat parsil tidak valid:', d.pts);
return;
}
const c=pColor[d.status];
const poly = L.polygon(pts,{
color:c,
fillColor:c,
fillOpacity:0.18,
weight:2
})
.bindPopup(`
<div class="pi">
<h4><i class="fas fa-draw-polygon" style="color:${c}"></i>${d.nama}</h4>
<div class="pi-row">
<span class="pi-k">Pemilik</span>
<span class="pi-v">${d.pemilik||'-'}</span>
</div>
<div class="pi-row">
<span class="pi-k">Status</span>
<span class="pi-v">${d.status}</span>
</div>
<div class="pi-row">
<span class="pi-k">Luas</span>
<span class="pi-v">${fmtArea(d.area)}</span>
</div>
<div class="act-row">
<button class="pbtn pbtn-y" onclick="openEditParsil(${d.id})">Edit</button>
<button class="pbtn pbtn-r" onclick="deleteFeature('parsil',${d.id})">Hapus</button>
</div>
</div>
`)
.addTo(layers.parsil);
state.featureRefs.parsil[String(d.id)] = poly;
}
function getById(type,id){
return state[type].find(x=>String(x.id)===String(id));
}
function pointsToText(points){
return points.map(p=>`${Number(p[0]).toFixed(6)},${Number(p[1]).toFixed(6)}`).join('\n');
}
function textToPoints(text,minPoints){
const rows=text.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
const pts=[];
for(const row of rows){
const parts=row.split(',').map(x=>x.trim());
if(parts.length!==2)return null;
const lat=parseFloat(parts[0]);
const lng=parseFloat(parts[1]);
if(!Number.isFinite(lat) || !Number.isFinite(lng))return null;
pts.push([lat,lng]);
}
if(pts.length<minPoints)return null;
return pts;
}
function toLatLngObjects(pts){
return pts.map(p=>({lat:parseFloat(p[0]),lng:parseFloat(p[1])}));
}
function makeVertexIcon(){
return L.divIcon({
className:'',
html:'<div style="width:12px;height:12px;border-radius:50%;background:#fdfdfd;border:2px solid #1b3a5c;box-shadow:0 1px 4px rgba(0,0,0,0.35)"></div>',
iconSize:[12,12],
iconAnchor:[6,6]
});
}
function syncEditTextarea(type,points){
const id=type==='jalan' ? 'e-jalan-pts' : 'e-parsil-pts';
const el=document.getElementById(id);
if(el)el.value=pointsToText(points);
}
function stopGeometryEdit(){
if(!state.geometryEdit)return;
const g=state.geometryEdit;
if(g.overlay && map.hasLayer(g.overlay))map.removeLayer(g.overlay);
(g.vertexMarkers||[]).forEach(m=>{if(map.hasLayer(m))map.removeLayer(m);});
if(g.onMove)map.off('mousemove',g.onMove);
if(g.onUp)map.off('mouseup',g.onUp);
if(g.onMapClick)map.off('click',g.onMapClick);
map.dragging.enable();
const ref=state.featureRefs[g.type][String(g.id)];
if(ref && ref.setStyle){
if(g.type==='jalan')ref.setStyle({opacity:0.9,interactive:true});
if(g.type==='parsil')ref.setStyle({opacity:1,fillOpacity:0.18,interactive:true});
}
state.geometryEdit=null;
const hint=document.getElementById('hint');
hint.classList.remove('show');
}
function startGeometryEdit(type,id){
if(type!=='jalan' && type!=='parsil')return;
const d=getById(type,id);
if(!d)return;
const points = type==='jalan' ? normalizeLinePoints(d.pts) : normalizePolyPoints(d.pts);
const minPts = type==='jalan' ? 2 : 3;
if(points.length<minPts){
alert('Koordinat belum valid untuk diedit di peta');
return;
}
stopGeometryEdit();
setMode(null);
ensureLayerVisible(type);
const ref=state.featureRefs[type][String(id)];
if(ref && ref.setStyle){
if(type==='jalan')ref.setStyle({opacity:0.2,interactive:false});
if(type==='parsil')ref.setStyle({opacity:0.2,fillOpacity:0.05,interactive:false});
}
const overlay = type==='jalan'
? L.polyline(points,{color:'#1b3a5c',weight:4,dashArray:'8,6',opacity:0.95}).addTo(map)
: L.polygon(points,{color:'#1b3a5c',weight:3,dashArray:'8,6',fillColor:'#1b3a5c',fillOpacity:0.12}).addTo(map);
const g={
type,
id,
points:points.map(p=>[p[0],p[1]]),
overlay,
vertexMarkers:[],
draggingAll:false,
lastLatLng:null,
onMove:null,
onUp:null,
onMapClick:null
};
const redraw=()=>{
if(g.type==='jalan')g.overlay.setLatLngs(g.points);
else g.overlay.setLatLngs(g.points);
g.vertexMarkers.forEach((m,i)=>m.setLatLng(g.points[i]));
syncEditTextarea(g.type,g.points);
};
const icon=makeVertexIcon();
g.points.forEach((p,i)=>{
const vm=L.marker(p,{draggable:true,icon,zIndexOffset:1000}).addTo(map);
vm.on('drag',ev=>{
const ll=ev.target.getLatLng();
g.points[i]=[ll.lat,ll.lng];
redraw();
});
g.vertexMarkers.push(vm);
});
const startDragAll=(latlng)=>{
g.draggingAll=true;
g.lastLatLng=latlng;
map.dragging.disable();
};
g.overlay.on('mousedown',e=>{
L.DomEvent.stopPropagation(e);
startDragAll(e.latlng);
});
g.onMapClick=(e)=>{
if(e.originalEvent && e.originalEvent.shiftKey){
startDragAll(e.latlng);
}
};
g.onMove=(e)=>{
if(!g.draggingAll || !g.lastLatLng)return;
const dLat=e.latlng.lat-g.lastLatLng.lat;
const dLng=e.latlng.lng-g.lastLatLng.lng;
g.points=g.points.map(p=>[p[0]+dLat,p[1]+dLng]);
g.lastLatLng=e.latlng;
redraw();
};
g.onUp=()=>{
g.draggingAll=false;
g.lastLatLng=null;
map.dragging.enable();
};
map.on('click',g.onMapClick);
map.on('mousemove',g.onMove);
map.on('mouseup',g.onUp);
state.geometryEdit=g;
syncEditTextarea(type,g.points);
const hint=document.getElementById('hint');
hint.textContent='Mode edit geometri aktif: drag titik vertex atau drag shape untuk geser keseluruhan.';
hint.classList.add('show');
}
function refreshType(type){
if(state.geometryEdit && state.geometryEdit.type===type)stopGeometryEdit();
if(type==='spbu'){
layers.spbu24.clearLayers();
layers.spbuNon24.clearLayers();
}else{
layers[type].clearLayers();
}
state.featureRefs[type]={};
const list=document.getElementById('list-'+type);
list.querySelectorAll('.dcard').forEach(x=>x.remove());
state[type].forEach(d=>{
if(type==='spbu'){
renderSPBU(d);
addCard('spbu',d.id,d.nama,'No: '+d.no,d.status==='Ya'?'Buka 24 Jam':'Tidak Buka 24 Jam',d.status==='Ya'?'#1a6b3c':'#b93328');
return;
}
if(type==='jalan'){
renderJalan(d);
addCard('jalan',d.id,d.nama,d.status+' · '+fmtLen(d.len),d.status,jColor[d.status] || '#e74c3c');
return;
}
if(type==='kemiskinan'){
renderKemiskinan(d);
addCard('kemiskinan',d.id,d.nama_kk,'Jumlah KK: '+d.jumlah_kk,'Kemiskinan','#9f6500');
return;
}
if(type==='masjid'){
renderMasjid(d);
addCard('masjid',d.id,d.nama_masjid,d.pic_masjid,'Masjid','#0f6250');
return;
}
renderParsil(d);
addCard('parsil',d.id,d.nama,d.status,d.status,'#1a5276');
});
updateCount(type,state[type].length);
}
function openEditSpbu(id){
const d=getById('spbu',id);
if(!d)return;
const ref=state.featureRefs.spbu[String(id)];
const ll=ref && ref.getLatLng ? ref.getLatLng() : {lat:d.lat,lng:d.lng};
const html=`<div class="pf">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit SPBU</h4>
<label>Nama SPBU</label>
<input id="e-spbu-nama" value="${(d.nama||'').replace(/"/g,'&quot;')}">
<label>Nomor SPBU</label>
<input id="e-spbu-no" value="${(d.no||'').replace(/"/g,'&quot;')}">
<label>Status Operasional</label>
<select id="e-spbu-status">
<option value="Ya" ${d.status==='Ya'?'selected':''}>Buka 24 Jam</option>
<option value="Tidak" ${d.status==='Tidak'?'selected':''}>Tidak Buka 24 Jam</option>
</select>
<label>Alamat</label>
<textarea id="e-spbu-alamat">${(d.alamat||'').replace(/</g,'&lt;')}</textarea>
<label>Latitude</label>
<input id="e-spbu-lat" value="${Number(ll.lat).toFixed(6)}">
<label>Longitude</label>
<input id="e-spbu-lng" value="${Number(ll.lng).toFixed(6)}">
<button class="pbtn pbtn-y" onclick="saveEditSpbu(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({maxWidth:290}).setLatLng([ll.lat,ll.lng]).setContent(html).openOn(map);
}
function saveEditSpbu(id){
const nama=document.getElementById('e-spbu-nama').value.trim();
const no=document.getElementById('e-spbu-no').value.trim();
const status=document.getElementById('e-spbu-status').value;
const alamat=document.getElementById('e-spbu-alamat').value.trim();
const lat=parseFloat(document.getElementById('e-spbu-lat').value);
const lng=parseFloat(document.getElementById('e-spbu-lng').value);
if(!nama || !no || !alamat || !Number.isFinite(lat) || !Number.isFinite(lng)){
alert('Data SPBU belum valid');
return;
}
fetch('api/update_data.php',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,nama,no,status,alamat,lat,lng})
})
.then(res=>res.json())
.then(resp=>{
if(resp.status!=='success'){
alert('Gagal update SPBU');
return;
}
const d=getById('spbu',id);
if(!d)return;
d.nama=nama;d.no=no;d.status=status;d.alamat=alamat;d.lat=lat;d.lng=lng;
refreshType('spbu');
map.closePopup();
focusFeature('spbu',id);
})
.catch(()=>alert('Error update SPBU'));
}
function openEditJalan(id){
if(state.geometryEdit && (state.geometryEdit.type!=='jalan' || String(state.geometryEdit.id)!==String(id))){
stopGeometryEdit();
}
const d=getById('jalan',id);
if(!d)return;
const pts=normalizeLinePoints(d.pts);
const ref=state.featureRefs.jalan[String(id)];
const center=ref && ref.getBounds ? ref.getBounds().getCenter() : map.getCenter();
const html=`<div class="pf" style="width:290px">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit Jalan</h4>
<label>Nama Jalan</label>
<input id="e-jalan-nama" value="${(d.nama||'').replace(/"/g,'&quot;')}">
<label>Status Jalan</label>
<select id="e-jalan-status">
<option value="Nasional" ${d.status==='Nasional'?'selected':''}>Jalan Nasional</option>
<option value="Provinsi" ${d.status==='Provinsi'?'selected':''}>Jalan Provinsi</option>
<option value="Kabupaten" ${d.status==='Kabupaten'?'selected':''}>Jalan Kabupaten</option>
</select>
<label>Koordinat Lokasi (satu baris: lat,lng)</label>
<textarea id="e-jalan-pts">${pointsToText(pts)}</textarea>
<button class="pbtn pbtn-b" onclick="startGeometryEdit('jalan',${id})">Edit Vertex / Drag di Peta</button>
<button class="pbtn pbtn-y" onclick="saveEditJalan(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({maxWidth:330}).setLatLng(center).setContent(html).openOn(map);
}
function saveEditJalan(id){
if(state.geometryEdit && state.geometryEdit.type==='jalan' && String(state.geometryEdit.id)===String(id)){
syncEditTextarea('jalan',state.geometryEdit.points);
}
const nama=document.getElementById('e-jalan-nama').value.trim();
const status=document.getElementById('e-jalan-status').value;
const pts=textToPoints(document.getElementById('e-jalan-pts').value,2);
if(!nama || !pts){
alert('Data jalan atau koordinat belum valid');
return;
}
const len=calcLen(pts);
fetch('api/update_jalan.php',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,nama,status,panjang:len,koordinat:toLatLngObjects(pts)})
})
.then(res=>res.json())
.then(resp=>{
if(resp.status!=='success'){
alert('Gagal update jalan');
return;
}
const d=getById('jalan',id);
if(!d)return;
d.nama=nama;d.status=status;d.len=len;d.pts=pts;
stopGeometryEdit();
refreshType('jalan');
map.closePopup();
focusFeature('jalan',id);
})
.catch(()=>alert('Error update jalan'));
}
function openEditParsil(id){
if(state.geometryEdit && (state.geometryEdit.type!=='parsil' || String(state.geometryEdit.id)!==String(id))){
stopGeometryEdit();
}
const d=getById('parsil',id);
if(!d)return;
const pts=normalizePolyPoints(d.pts);
const ref=state.featureRefs.parsil[String(id)];
const center=ref && ref.getBounds ? ref.getBounds().getCenter() : map.getCenter();
const html=`<div class="pf" style="width:290px">
<h4><i class="fas fa-edit" style="color:var(--amber)"></i> Edit Parsil</h4>
<label>Nama / ID Parsil</label>
<input id="e-parsil-nama" value="${(d.nama||'').replace(/"/g,'&quot;')}">
<label>Pemilik</label>
<input id="e-parsil-pemilik" value="${(d.pemilik||'').replace(/"/g,'&quot;')}">
<label>Status Sertifikat</label>
<select id="e-parsil-status">
<option value="SHM" ${d.status==='SHM'?'selected':''}>SHM Hak Milik</option>
<option value="HGB" ${d.status==='HGB'?'selected':''}>HGB Hak Guna Bangunan</option>
<option value="HGU" ${d.status==='HGU'?'selected':''}>HGU Hak Guna Usaha</option>
<option value="HP" ${d.status==='HP'?'selected':''}>HP Hak Pakai</option>
</select>
<label>Koordinat Lokasi (satu baris: lat,lng)</label>
<textarea id="e-parsil-pts">${pointsToText(pts)}</textarea>
<button class="pbtn pbtn-b" onclick="startGeometryEdit('parsil',${id})">Edit Vertex / Drag di Peta</button>
<button class="pbtn pbtn-y" onclick="saveEditParsil(${id})">Simpan Perubahan</button>
</div>`;
map.closePopup();
L.popup({maxWidth:330}).setLatLng(center).setContent(html).openOn(map);
}
function saveEditParsil(id){
if(state.geometryEdit && state.geometryEdit.type==='parsil' && String(state.geometryEdit.id)===String(id)){
syncEditTextarea('parsil',state.geometryEdit.points);
}
const nama=document.getElementById('e-parsil-nama').value.trim();
const pemilik=document.getElementById('e-parsil-pemilik').value.trim();
const status=document.getElementById('e-parsil-status').value;
const pts=textToPoints(document.getElementById('e-parsil-pts').value,3);
if(!nama || !pts){
alert('Data parsil atau koordinat belum valid');
return;
}
const area=calcArea(toLatLngObjects(pts));
fetch('api/update_parsil.php',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({id,nama,pemilik,status,luas:area,koordinat:toLatLngObjects(pts)})
})
.then(res=>res.json())
.then(resp=>{
if(resp.status!=='success'){
alert('Gagal update parsil');
return;
}
const d=getById('parsil',id);
if(!d)return;
d.nama=nama;d.pemilik=pemilik;d.status=status;d.area=area;d.pts=pts;
stopGeometryEdit();
refreshType('parsil');
map.closePopup();
focusFeature('parsil',id);
})
.catch(()=>alert('Error update parsil'));
}
function deleteFeature(type,id){
if(state.geometryEdit && state.geometryEdit.type===type && String(state.geometryEdit.id)===String(id)){
stopGeometryEdit();
}
const label=type==='spbu' ? 'SPBU' : type==='jalan' ? 'Jalan' : type==='kemiskinan' ? 'Kemiskinan' : type==='masjid' ? 'Masjid' : 'Parsil';
if(!confirm(`Hapus data ${label} ini?`))return;
const ep={
spbu:'api/delete_data.php',
jalan:'api/delete_jalan.php',
parsil:'api/delete_parsil.php'
};
fetch(ep[type],{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({id})
})
.then(res=>res.json())
.then(resp=>{
if(resp.status!=='success'){
alert('Gagal menghapus data');
return;
}
state[type]=state[type].filter(x=>String(x.id)!==String(id));
if(state.activeCard==='card-'+type+'-'+id)state.activeCard=null;
refreshType(type);
map.closePopup();
})
.catch(()=>alert('Error hapus data'));
}
/* ── HELPERS ── */
function calcLen(pts){
let t=0;
for(let i=1;i<pts.length;i++)t+=L.latLng(pts[i-1]).distanceTo(L.latLng(pts[i]));
return t;
}
function fmtLen(m){return m>=1000?(m/1000).toFixed(2)+' km':Math.round(m)+' m'}
function calcArea(pts){
let a=0;const n=pts.length;
for(let i=0;i<n;i++){
const j=(i+1)%n;
const la1=pts[i].lat*Math.PI/180,la2=pts[j].lat*Math.PI/180;
const dl=(pts[j].lng-pts[i].lng)*Math.PI/180;
a+=Math.sin(dl)*(2+Math.sin(la1)+Math.sin(la2));
}
return Math.abs(a*6378137*6378137/2);
}
function fmtArea(m){return m>=10000?(m/10000).toFixed(2)+' ha':Math.round(m)+' m²'}
function clearTemp(){
state.tempPoints=[];state.drawingLine=false;state.drawingPoly=false;
state.tempMarkers.forEach(m=>map.removeLayer(m));state.tempMarkers=[];
if(state.tempPolyline){map.removeLayer(state.tempPolyline);state.tempPolyline=null;}
if(state.tempPolygon){map.removeLayer(state.tempPolygon);state.tempPolygon=null;}
}
function cancelDrawing(){
clearTemp();
if(window._tmpM){map.removeLayer(window._tmpM);window._tmpM=null;}
}
function ensureLayerVisible(type,id){
if(type==='spbu'){
const d=id!==undefined ? getById('spbu',id) : null;
if(d){
const targetLayer=d.status==='Ya' ? layers.spbu24 : layers.spbuNon24;
map.addLayer(targetLayer);
}else{
if(state.spbuFilterVis.open24)map.addLayer(layers.spbu24);
if(state.spbuFilterVis.non24)map.addLayer(layers.spbuNon24);
}
return;
}
map.addLayer(layers[type]);
}
function setActiveCard(type,id){
if(state.activeCard){
const prev=document.getElementById(state.activeCard);
if(prev)prev.style.boxShadow='';
}
const cardId='card-'+type+'-'+id;
const cur=document.getElementById(cardId);
if(cur){
cur.style.boxShadow='0 0 0 2px rgba(27,58,92,0.28), 0 2px 8px rgba(0,0,0,0.1)';
state.activeCard=cardId;
}
}
function focusFeature(type,id){
const ref=state.featureRefs[type][String(id)];
if(!ref)return;
ensureLayerVisible(type,id);
setActiveCard(type,id);
if(type==='spbu' && ref.getLatLng){
map.flyTo(ref.getLatLng(),17,{duration:0.7});
}else if(ref.getBounds){
map.fitBounds(ref.getBounds(),{padding:[40,40],maxZoom:17});
}
if(ref.openPopup){
setTimeout(()=>ref.openPopup(),200);
}
}
function updateCount(type,n){document.getElementById('cnt-'+type).textContent=n}
function addCard(type,id,name,sub,badge,color){
const c=document.getElementById('list-'+type);
const el=document.createElement('div');
el.className='dcard';el.id='card-'+type+'-'+id;
el.tabIndex=0;
el.innerHTML=`<div style="position:absolute;left:0;top:0;bottom:0;width:3px;background:${color};border-radius:8px 0 0 8px"></div>
<div class="dc-name">${name}</div>
<div class="dc-sub">${sub}</div>
<div class="dc-badge" style="background:${color}20;color:${color}">${badge}</div>`;
el.onclick=()=>focusFeature(type,id);
el.onkeydown=(e)=>{
if(e.key==='Enter' || e.key===' '){
e.preventDefault();
focusFeature(type,id);
}
};
c.appendChild(el);
}
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){
stopGeometryEdit();
cancelDrawing();
setMode(null);
}
});
document.addEventListener("DOMContentLoaded", function(){
loadSPBU();
loadJalan();
loadParsil();
loadAdminKecamatan();
});
function loadSPBU(){
fetch("api/read_data.php")
.then(res=>res.json())
.then(data=>{
data.forEach(d=>{
const spbu={
id:d.id,
nama:d.nama,
no:d.no,
status:d.status,
alamat:d.alamat,
lat:parseFloat(d.lat),
lng:parseFloat(d.lng)
};
state.spbu.push(spbu);
renderSPBU(spbu);
updateCount('spbu',state.spbu.length);
addCard(
'spbu',
spbu.id,
spbu.nama,
'No: '+spbu.no,
spbu.status === 'Ya' ? 'Buka 24 Jam' : 'Tidak Buka 24 Jam',
spbu.status === 'Ya' ? '#1a6b3c' : '#b93328'
);
});
})
.catch(err=>console.log("SPBU Error:",err));
}
function loadJalan(){
fetch("api/read_jalan.php")
.then(res=>res.json())
.then(data=>{
data.forEach(d=>{
const jalan={
id:d.id,
nama:d.nama,
status:d.status,
len:parseFloat(d.panjang),
pts:d.koordinat
};
state.jalan.push(jalan);
renderJalan(jalan);
updateCount('jalan',state.jalan.length);
addCard(
'jalan',
jalan.id,
jalan.nama,
jalan.status,
jalan.status,
'#e74c3c'
);
});
})
.catch(err=>console.log("Jalan Error:",err));
}
function loadParsil(){
fetch("api/read_parsil.php")
.then(res=>res.json())
.then(data=>{
data.forEach(d=>{
const parsil={
id:d.id,
nama:d.nama,
pemilik:d.pemilik,
status:d.status,
area:parseFloat(d.luas),
pts:d.koordinat
};
state.parsil.push(parsil);
renderParsil(parsil);
updateCount('parsil',state.parsil.length);
addCard(
'parsil',
parsil.id,
parsil.nama,
parsil.status,
parsil.status,
'#1a5276'
);
});
})
.catch(err=>console.log("Parsil Error:",err));
}
</script>
</body>
</html>