2160 lines
61 KiB
HTML
2160 lines
61 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="id">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>WebGIS SPBU & 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:true});
|
||
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,'"')}">
|
||
<label>Nomor SPBU</label>
|
||
<input id="e-spbu-no" value="${(d.no||'').replace(/"/g,'"')}">
|
||
<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,'<')}</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,'"')}">
|
||
<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,'"')}">
|
||
<label>Pemilik</label>
|
||
<input id="e-parsil-pemilik" value="${(d.pemilik||'').replace(/"/g,'"')}">
|
||
<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> |