1274 lines
60 KiB
HTML
1274 lines
60 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 — Manajemen Jalan & Parsil Tanah</title>
|
|
<meta name="description" content="Sistem Informasi Geografis untuk manajemen data jalan dan parsil tanah. Berbasis LeafletJS, PHP, dan MySQL.">
|
|
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
/* ── RESET ────────────────────────────── */
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; font-family: 'Inter', sans-serif; background: #0f172a; overflow: hidden; }
|
|
|
|
/* ── LAYOUT ───────────────────────────── */
|
|
#app { display: flex; height: 100vh; }
|
|
|
|
/* ── SIDEBAR ──────────────────────────── */
|
|
#sidebar {
|
|
width: 290px; min-width: 290px;
|
|
height: 100vh;
|
|
background: #0d1526;
|
|
border-right: 1px solid rgba(255,255,255,0.06);
|
|
display: flex; flex-direction: column; overflow: hidden;
|
|
}
|
|
|
|
/* Header */
|
|
.sb-header {
|
|
padding: 14px 16px 12px;
|
|
background: linear-gradient(160deg, #1e293b 0%, #0f172a 100%);
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
flex-shrink: 0;
|
|
}
|
|
.sb-brand { display: flex; align-items: center; gap: 10px; }
|
|
.sb-icon {
|
|
width: 36px; height: 36px;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
border-radius: 10px; display: flex; align-items: center;
|
|
justify-content: center; font-size: 17px; flex-shrink: 0;
|
|
}
|
|
.sb-title { font-size: 14px; font-weight: 700; color: #f1f5f9; line-height: 1.2; }
|
|
.sb-sub { font-size: 10px; color: #64748b; margin-top: 1px; }
|
|
|
|
/* Tabs */
|
|
.tab-bar {
|
|
display: flex; padding: 10px 10px 0; gap: 4px;
|
|
background: #0d1526; flex-shrink: 0;
|
|
}
|
|
.tab-btn {
|
|
flex: 1; padding: 8px 6px;
|
|
border: none; border-radius: 8px 8px 0 0;
|
|
font-size: 11px; font-weight: 600;
|
|
font-family: 'Inter', sans-serif; cursor: pointer;
|
|
color: #475569; background: rgba(255,255,255,0.03);
|
|
border-bottom: 2px solid transparent;
|
|
transition: all 0.2s;
|
|
}
|
|
.tab-btn.active { color: #a5b4fc; background: rgba(99,102,241,0.1); border-bottom-color: #6366f1; }
|
|
.tab-btn:hover:not(.active) { color: #94a3b8; background: rgba(255,255,255,0.05); }
|
|
|
|
/* Tab panels */
|
|
.tab-panel { display: none; flex-direction: column; flex: 1; overflow: hidden; }
|
|
.tab-panel.active { display: flex; }
|
|
|
|
/* Toolbar */
|
|
.tab-toolbar {
|
|
padding: 10px 10px 8px;
|
|
background: rgba(255,255,255,0.02);
|
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
flex-shrink: 0;
|
|
}
|
|
.filter-row { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
.filter-sel {
|
|
flex: 1; padding: 6px 8px;
|
|
background: rgba(255,255,255,0.05);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
border-radius: 8px; color: #e2e8f0;
|
|
font-size: 11px; font-family: 'Inter', sans-serif;
|
|
outline: none; cursor: pointer;
|
|
}
|
|
.filter-sel option { background: #1e293b; }
|
|
.btn-tambah {
|
|
width: 100%; padding: 9px 12px;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
border: none; border-radius: 10px; color: white;
|
|
font-size: 11px; font-weight: 700;
|
|
font-family: 'Inter', sans-serif; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-tambah:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(79,70,229,0.4); }
|
|
.btn-tambah.drawing {
|
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
|
box-shadow: 0 4px 14px rgba(220,38,38,0.35);
|
|
}
|
|
|
|
/* Data list */
|
|
.data-list { flex: 1; overflow-y: auto; padding: 6px 8px; }
|
|
.data-list::-webkit-scrollbar { width: 3px; }
|
|
.data-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
|
|
|
.data-card {
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid rgba(255,255,255,0.06);
|
|
border-radius: 10px; padding: 9px 11px;
|
|
margin-bottom: 5px; cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
|
|
}
|
|
.data-card:hover { background: rgba(99,102,241,0.1); border-color: rgba(99,102,241,0.3); }
|
|
.data-card-left { flex: 1; min-width: 0; }
|
|
.data-card-name {
|
|
font-size: 12px; font-weight: 600; color: #f1f5f9;
|
|
margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.data-card-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
.badge {
|
|
display: inline-block; padding: 2px 7px;
|
|
border-radius: 20px; font-size: 9px; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: 0.4px;
|
|
}
|
|
.data-card-dim { font-size: 10px; color: #64748b; }
|
|
.data-card-actions { display: flex; gap: 3px; flex-shrink: 0; padding-top: 1px; }
|
|
.icon-btn {
|
|
width: 26px; height: 26px;
|
|
border: 1px solid rgba(255,255,255,0.07);
|
|
background: rgba(255,255,255,0.04);
|
|
border-radius: 7px; cursor: pointer;
|
|
font-size: 12px; display: flex; align-items: center; justify-content: center;
|
|
transition: all 0.15s; color: #64748b;
|
|
}
|
|
.icon-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
|
|
.icon-btn.del:hover { background: rgba(239,68,68,0.2); border-color: rgba(239,68,68,0.4); color: #f87171; }
|
|
.icon-btn.edt:hover { background: rgba(99,102,241,0.2); border-color: rgba(99,102,241,0.4); color: #a5b4fc; }
|
|
|
|
/* Empty state */
|
|
.empty { padding: 36px 16px; text-align: center; color: #334155; }
|
|
.empty-ico { font-size: 34px; margin-bottom: 8px; }
|
|
.empty p { font-size: 11px; line-height: 1.6; }
|
|
|
|
/* Stats footer */
|
|
.stats-footer {
|
|
padding: 8px 12px; flex-shrink: 0;
|
|
background: rgba(255,255,255,0.02);
|
|
border-top: 1px solid rgba(255,255,255,0.04);
|
|
display: flex; gap: 12px;
|
|
}
|
|
.stat-cell { flex: 1; text-align: center; }
|
|
.stat-num { font-size: 15px; font-weight: 700; color: #818cf8; }
|
|
.stat-lbl { font-size: 9px; color: #475569; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
|
|
/* ── MAP AREA ──────────────────────────── */
|
|
#map-wrap { flex: 1; position: relative; overflow: hidden; }
|
|
#map { width: 100%; height: 100%; }
|
|
|
|
/* ── DRAWING TOOLBAR (on map) ─────────── */
|
|
#draw-bar {
|
|
position: absolute; top: 50%; right: 12px;
|
|
transform: translateY(-50%); z-index: 1000;
|
|
display: flex; flex-direction: column; gap: 5px;
|
|
background: rgba(13,21,38,0.93);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
border-radius: 14px; padding: 9px 7px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
|
}
|
|
.db-btn {
|
|
width: 36px; height: 36px; border-radius: 9px;
|
|
border: 1.5px solid rgba(255,255,255,0.07);
|
|
background: rgba(255,255,255,0.04);
|
|
color: #64748b; cursor: pointer; font-size: 15px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: all 0.2s; position: relative;
|
|
}
|
|
.db-btn:hover { background: rgba(99,102,241,0.25); border-color: rgba(99,102,241,0.5); color: #fff; }
|
|
.db-btn.active { background: linear-gradient(135deg,#4f46e5,#7c3aed); border-color: #a78bfa; color: #fff; box-shadow: 0 0 14px rgba(79,70,229,0.5); }
|
|
.db-sep { height: 1px; background: rgba(255,255,255,0.06); }
|
|
|
|
/* Tooltip */
|
|
.db-btn::before {
|
|
content: attr(data-tip); position: absolute;
|
|
right: calc(100% + 8px); top: 50%; transform: translateY(-50%);
|
|
background: rgba(13,21,38,0.96); color: #e2e8f0;
|
|
font-size: 10px; font-weight: 500; white-space: nowrap;
|
|
padding: 4px 9px; border-radius: 6px;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
pointer-events: none; opacity: 0; transition: opacity 0.12s;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
.db-btn:hover::before { opacity: 1; }
|
|
|
|
/* ── STATUS BAR ────────────────────────── */
|
|
#status-bar {
|
|
position: absolute; bottom: 22px; left: 50%;
|
|
transform: translateX(-50%); z-index: 1000;
|
|
background: rgba(13,21,38,0.93); backdrop-filter: blur(12px);
|
|
color: #e2e8f0; font-size: 11px; font-weight: 500;
|
|
padding: 7px 16px 7px 13px; border-radius: 30px;
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
|
|
display: none; align-items: center; gap: 8px;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
#status-bar.on { display: flex; }
|
|
.sdot { width: 7px; height: 7px; border-radius: 50%; background: #818cf8; animation: blink 1.1s ease infinite; }
|
|
@keyframes blink { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.35;transform:scale(.65)} }
|
|
|
|
/* ── LEGEND ────────────────────────────── */
|
|
#legend {
|
|
position: absolute; bottom: 12px; right: 62px; z-index: 1000;
|
|
background: rgba(13,21,38,0.93); backdrop-filter: blur(12px);
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
border-radius: 12px; padding: 11px 13px;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
.lg-title { font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #475569; margin-bottom: 7px; }
|
|
.lg-sec { margin-bottom: 7px; }
|
|
.lg-sec-title { font-size: 8px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 5px; }
|
|
.lg-row { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
|
|
.lg-line { width: 18px; height: 3px; border-radius: 2px; }
|
|
.lg-box { width: 11px; height: 11px; border-radius: 3px; border: 2px solid; }
|
|
.lg-lbl { font-size: 10px; color: #94a3b8; font-weight: 500; }
|
|
|
|
/* ── POPUP ─────────────────────────────── */
|
|
.leaflet-popup-content-wrapper {
|
|
border-radius: 14px !important; padding: 0 !important;
|
|
overflow: hidden !important;
|
|
background: #0f172a !important;
|
|
border: 1px solid rgba(255,255,255,0.1) !important;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important;
|
|
}
|
|
.leaflet-popup-content { margin: 0 !important; }
|
|
.leaflet-popup-tip { background: #0f172a !important; }
|
|
.leaflet-popup-close-button { color: #64748b !important; top: 8px !important; right: 10px !important; font-size: 18px !important; z-index: 10; }
|
|
|
|
.pop-head {
|
|
padding: 12px 16px 10px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
.pop-head h4 { font-size: 13px; font-weight: 700; margin: 0; font-family: 'Inter', sans-serif; }
|
|
|
|
.pop-body { padding: 10px 16px 14px; font-family: 'Inter', sans-serif; min-width: 210px; }
|
|
.pop-chip {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 3px 9px; border-radius: 20px;
|
|
font-size: 10px; font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}
|
|
.pop-dim { font-size: 16px; font-weight: 700; color: #e2e8f0; margin-bottom: 4px; }
|
|
.pop-sub { font-size: 10px; color: #475569; margin-bottom: 4px; }
|
|
.pop-hint { font-size: 9px; color: #334155; font-style: italic; margin-bottom: 10px; }
|
|
.pop-actions { display: flex; gap: 6px; }
|
|
.pop-edit {
|
|
flex: 1; padding: 7px;
|
|
background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.3);
|
|
border-radius: 8px; color: #a5b4fc; font-size: 11px; font-weight: 600;
|
|
font-family: 'Inter', sans-serif; cursor: pointer; transition: all 0.2s;
|
|
}
|
|
.pop-edit:hover { background: rgba(99,102,241,0.25); }
|
|
.pop-del {
|
|
flex: 1; padding: 7px;
|
|
background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3);
|
|
border-radius: 8px; color: #f87171; font-size: 11px; font-weight: 600;
|
|
font-family: 'Inter', sans-serif; cursor: pointer; transition: all 0.2s;
|
|
}
|
|
.pop-del:hover { background: rgba(239,68,68,0.25); }
|
|
|
|
/* Label di peta */
|
|
.leaflet-tooltip.map-label {
|
|
background: rgba(13,21,38,0.9) !important;
|
|
border: 1px solid rgba(255,255,255,0.1) !important;
|
|
border-radius: 6px !important; color: #e2e8f0 !important;
|
|
font-size: 10px !important; font-weight: 600 !important;
|
|
font-family: 'Inter', sans-serif !important;
|
|
padding: 2px 7px !important; pointer-events: none;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3) !important;
|
|
white-space: nowrap !important;
|
|
}
|
|
|
|
/* ── MODAL ─────────────────────────────── */
|
|
#modal-bg {
|
|
position: fixed; inset: 0;
|
|
background: rgba(0,0,0,0.65); backdrop-filter: blur(6px);
|
|
z-index: 9999; display: none; align-items: center; justify-content: center;
|
|
}
|
|
#modal-bg.show { display: flex; }
|
|
#modal {
|
|
background: #0f172a;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 20px;
|
|
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
|
|
width: 380px; max-width: 95vw;
|
|
overflow: hidden;
|
|
animation: pop-in 0.28s cubic-bezier(.34,1.56,.64,1) both;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
@keyframes pop-in {
|
|
from { opacity:0; transform: scale(.86) translateY(20px); }
|
|
to { opacity:1; transform: scale(1) translateY(0); }
|
|
}
|
|
.modal-head {
|
|
padding: 18px 20px 14px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
.modal-head h3 { font-size: 15px; font-weight: 700; color: #e0e7ff; margin-bottom: 3px; }
|
|
.modal-head p { font-size: 11px; color: #64748b; }
|
|
.modal-body { padding: 16px 20px; }
|
|
.form-grp { margin-bottom: 14px; }
|
|
.form-lbl {
|
|
font-size: 10px; font-weight: 700; color: #64748b;
|
|
text-transform: uppercase; letter-spacing: .5px;
|
|
display: block; margin-bottom: 5px;
|
|
}
|
|
.form-inp, .form-sel {
|
|
width: 100%; padding: 9px 12px;
|
|
background: rgba(255,255,255,0.05);
|
|
border: 1.5px solid rgba(255,255,255,0.09);
|
|
border-radius: 10px; font-size: 13px;
|
|
font-family: 'Inter', sans-serif; color: #f1f5f9;
|
|
outline: none; transition: border-color .2s, box-shadow .2s;
|
|
}
|
|
.form-inp:focus, .form-sel:focus {
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
|
|
}
|
|
.form-sel option { background: #1e293b; }
|
|
.form-inp.ro {
|
|
background: rgba(99,102,241,0.08);
|
|
border-color: rgba(99,102,241,0.25);
|
|
color: #a5b4fc; font-weight: 600; cursor: default;
|
|
}
|
|
.calc-note { font-size: 9px; color: #475569; margin-top: 4px; }
|
|
.modal-foot { padding: 0 20px 18px; display: flex; gap: 8px; }
|
|
.btn-batal {
|
|
flex: 1; padding: 10px;
|
|
border: 1.5px solid rgba(255,255,255,0.09);
|
|
background: rgba(255,255,255,0.04);
|
|
border-radius: 10px; color: #64748b;
|
|
font-size: 12px; font-weight: 600;
|
|
font-family: 'Inter', sans-serif; cursor: pointer; transition: all .2s;
|
|
}
|
|
.btn-batal:hover { background: rgba(255,255,255,0.09); color: #e2e8f0; }
|
|
.btn-save {
|
|
flex: 2; padding: 10px;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
border: none; border-radius: 10px; color: white;
|
|
font-size: 12px; font-weight: 700;
|
|
font-family: 'Inter', sans-serif; cursor: pointer; transition: all .2s;
|
|
}
|
|
.btn-save:hover { opacity: .88; transform: translateY(-1px); }
|
|
.btn-save:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
|
|
<!-- ═══════════════════ SIDEBAR ═══════════════════ -->
|
|
<div id="sidebar">
|
|
|
|
<div class="sb-header">
|
|
<div class="sb-brand">
|
|
<div class="sb-icon">🗺️</div>
|
|
<div>
|
|
<div class="sb-title">WebGIS Pontianak</div>
|
|
<div class="sb-sub">Manajemen Jalan & Parsil Tanah</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TABS -->
|
|
<div class="tab-bar">
|
|
<button class="tab-btn active" id="tbtn-jalan" onclick="switchTab('jalan')">🛣️ Data Jalan</button>
|
|
<button class="tab-btn" id="tbtn-parsil" onclick="switchTab('parsil')">🏘️ Parsil Tanah</button>
|
|
</div>
|
|
|
|
<!-- ── TAB JALAN ── -->
|
|
<div class="tab-panel active" id="tp-jalan">
|
|
<div class="tab-toolbar">
|
|
<div class="filter-row">
|
|
<select class="filter-sel" id="fil-jalan" onchange="renderListJalan()">
|
|
<option value="">Semua Status</option>
|
|
<option value="Nasional">🔴 Nasional</option>
|
|
<option value="Provinsi">🔵 Provinsi</option>
|
|
<option value="Kabupaten">🟡 Kabupaten</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn-tambah" id="btn-jalan" onclick="toggleDraw('jalan')">
|
|
✏️ <span id="lbl-jalan">Tambah Ruas Jalan</span>
|
|
</button>
|
|
</div>
|
|
<div class="data-list" id="list-jalan">
|
|
<div class="empty"><div class="empty-ico">⏳</div><p>Memuat data...</p></div>
|
|
</div>
|
|
<div class="stats-footer">
|
|
<div class="stat-cell">
|
|
<div class="stat-num" id="sj-total">0</div>
|
|
<div class="stat-lbl">Total Ruas</div>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<div class="stat-num" id="sj-panjang">0 m</div>
|
|
<div class="stat-lbl">Total Panjang</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── TAB PARSIL ── -->
|
|
<div class="tab-panel" id="tp-parsil">
|
|
<div class="tab-toolbar">
|
|
<div class="filter-row">
|
|
<select class="filter-sel" id="fil-parsil" onchange="renderListParsil()">
|
|
<option value="">Semua Status</option>
|
|
<option value="SHM">🟢 SHM</option>
|
|
<option value="HGB">🔵 HGB</option>
|
|
<option value="HGU">🟣 HGU</option>
|
|
<option value="HP">🟠 HP</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn-tambah" id="btn-parsil" onclick="toggleDraw('parsil')">
|
|
✏️ <span id="lbl-parsil">Tambah Parsil Tanah</span>
|
|
</button>
|
|
</div>
|
|
<div class="data-list" id="list-parsil">
|
|
<div class="empty"><div class="empty-ico">⏳</div><p>Memuat data...</p></div>
|
|
</div>
|
|
<div class="stats-footer">
|
|
<div class="stat-cell">
|
|
<div class="stat-num" id="sp-total">0</div>
|
|
<div class="stat-lbl">Total Parsil</div>
|
|
</div>
|
|
<div class="stat-cell">
|
|
<div class="stat-num" id="sp-luas">0 m²</div>
|
|
<div class="stat-lbl">Total Luas</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /sidebar -->
|
|
|
|
<!-- ═══════════════════ MAP ═══════════════════ -->
|
|
<div id="map-wrap">
|
|
<div id="map"></div>
|
|
|
|
<!-- Drawing toolbar -->
|
|
<div id="draw-bar">
|
|
<button class="db-btn" id="db-undo" data-tip="Hapus titik terakhir (Z)" onclick="undoPt()" style="display:none;">↩️</button>
|
|
<div class="db-sep" id="db-sep1" style="display:none;"></div>
|
|
<button class="db-btn" id="db-finish" data-tip="Selesai (Enter)" onclick="finishDraw()" style="display:none; background:rgba(34,197,94,.14);border-color:rgba(34,197,94,.4);color:#4ade80;">✅</button>
|
|
<button class="db-btn" id="db-cancel" data-tip="Batalkan (Esc)" onclick="cancelDraw()" style="display:none; background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.3);color:#f87171;">✖</button>
|
|
<div class="db-sep" id="db-sep2" style="display:none;"></div>
|
|
<button class="db-btn active" id="db-pan" data-tip="Mode Normal" onclick="cancelDraw()">🖐️</button>
|
|
</div>
|
|
|
|
<!-- Status bar -->
|
|
<div id="status-bar"><span class="sdot"></span><span id="sb-txt"></span></div>
|
|
|
|
<!-- Legend -->
|
|
<div id="legend">
|
|
<div class="lg-title">Legenda</div>
|
|
<div class="lg-sec">
|
|
<div class="lg-sec-title">🛣️ Status Jalan</div>
|
|
<div class="lg-row"><div class="lg-line" style="background:#ef4444;"></div><span class="lg-lbl">Jalan Nasional</span></div>
|
|
<div class="lg-row"><div class="lg-line" style="background:#3b82f6;"></div><span class="lg-lbl">Jalan Provinsi</span></div>
|
|
<div class="lg-row"><div class="lg-line" style="background:#f59e0b;"></div><span class="lg-lbl">Jalan Kabupaten</span></div>
|
|
</div>
|
|
<div class="lg-sec" style="margin-bottom:0;">
|
|
<div class="lg-sec-title">🏘️ Status Kepemilikan</div>
|
|
<div class="lg-row"><div class="lg-box" style="background:rgba(34,197,94,.2);border-color:#22c55e;"></div><span class="lg-lbl">SHM</span></div>
|
|
<div class="lg-row"><div class="lg-box" style="background:rgba(6,182,212,.2);border-color:#06b6d4;"></div><span class="lg-lbl">HGB</span></div>
|
|
<div class="lg-row"><div class="lg-box" style="background:rgba(168,85,247,.2);border-color:#a855f7;"></div><span class="lg-lbl">HGU</span></div>
|
|
<div class="lg-row"><div class="lg-box" style="background:rgba(249,115,22,.2);border-color:#f97316;"></div><span class="lg-lbl">HP</span></div>
|
|
</div>
|
|
</div>
|
|
</div><!-- /map-wrap -->
|
|
|
|
</div><!-- /app -->
|
|
|
|
<!-- ═══════════════════ MODAL ═══════════════════ -->
|
|
<div id="modal-bg" onclick="handleBgClick(event)">
|
|
<div id="modal">
|
|
<div class="modal-head">
|
|
<h3 id="modal-title">Simpan Data</h3>
|
|
<p id="modal-sub">Lengkapi informasi berikut</p>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-grp">
|
|
<label class="form-lbl" for="inp-nama">Nama</label>
|
|
<input type="text" class="form-inp" id="inp-nama" placeholder="Masukkan nama..." maxlength="100">
|
|
</div>
|
|
<div class="form-grp">
|
|
<label class="form-lbl" for="inp-status">Status</label>
|
|
<select class="form-sel" id="inp-status"></select>
|
|
</div>
|
|
<div class="form-grp" id="grp-dim">
|
|
<label class="form-lbl" id="inp-dim-lbl">Dimensi</label>
|
|
<input type="text" class="form-inp ro" id="inp-dim" readonly>
|
|
<div class="calc-note" id="inp-dim-note">Dihitung otomatis menggunakan fungsi LeafletJS</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<button class="btn-batal" onclick="closeModal()">Batal</button>
|
|
<button class="btn-save" id="btn-save" onclick="saveModal()">💾 Simpan</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
|
|
<script>
|
|
'use strict';
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
KONSTANTA
|
|
═══════════════════════════════════════════════════════════ */
|
|
const API_JALAN = './api/jalan.php';
|
|
const API_PARSIL = './api/parsil.php';
|
|
|
|
const JALAN_COLOR = { Nasional:'#ef4444', Provinsi:'#3b82f6', Kabupaten:'#f59e0b' };
|
|
const PARSIL_COLOR = { SHM:'#22c55e', HGB:'#06b6d4', HGU:'#a855f7', HP:'#f97316' };
|
|
|
|
const JALAN_STATUS_OPT = [['Nasional','🔴 Jalan Nasional'],['Provinsi','🔵 Jalan Provinsi'],['Kabupaten','🟡 Jalan Kabupaten']];
|
|
const PARSIL_STATUS_OPT = [['SHM','🟢 Sertifikat Hak Milik (SHM)'],['HGB','🔵 Hak Guna Bangunan (HGB)'],['HGU','🟣 Hak Guna Usaha (HGU)'],['HP','🟠 Hak Pakai (HP)']];
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
STATE
|
|
═══════════════════════════════════════════════════════════ */
|
|
let drawMode = null; // 'jalan' | 'parsil' | null
|
|
let drawingPts = []; // L.LatLng[]
|
|
let tempMkrs = [];
|
|
let tempPoly = null;
|
|
|
|
let modalType = null; // 'jalan' | 'parsil'
|
|
let modalMode = 'add'; // 'add' | 'edit'
|
|
let modalEditId = null; // id data yang sedang diedit
|
|
let pendingPts = null;
|
|
let pendingDim = null;
|
|
|
|
let jalanData = [];
|
|
let parsilData = [];
|
|
let jalanLyrs = {}; // id → { polyline, vertices, label }
|
|
let parsilLyrs = {}; // id → { polygon, vertices, label }
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
INISIALISASI PETA
|
|
═══════════════════════════════════════════════════════════ */
|
|
const map = L.map('map', { zoomControl: true }).setView([-0.055319, 109.349502], 13);
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
}).addTo(map);
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
KALKULASI GEOSPASIAL — MENGGUNAKAN LEAFLET JS
|
|
|
|
1. hitungPanjangJalan : Menghitung panjang total polyline jalan
|
|
dalam meter menggunakan metode L.LatLng.distanceTo() dari
|
|
library LeafletJS. Metode ini menggunakan formula Haversine
|
|
yang sudah terintegrasi di LeafletJS.
|
|
|
|
2. hitungLuasParsil : Menghitung luas area poligon parsil
|
|
dalam meter persegi menggunakan formula geodesic spherical
|
|
area, setara dengan implementasi internal LeafletJS yang
|
|
digunakan pada L.GeometryUtil.geodesicArea().
|
|
Berbasis objek L.LatLng dari LeafletJS.
|
|
═══════════════════════════════════════════════════════════ */
|
|
|
|
/**
|
|
* Panjang total jalan (meter).
|
|
* Menggunakan L.LatLng.distanceTo() — fungsi bawaan LeafletJS.
|
|
* @param {L.LatLng[]} pts
|
|
* @returns {number}
|
|
*/
|
|
function hitungPanjangJalan(pts) {
|
|
let total = 0;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
// L.latLng.distanceTo() → Haversine distance (meter)
|
|
total += L.latLng(pts[i]).distanceTo(L.latLng(pts[i + 1]));
|
|
}
|
|
return parseFloat(total.toFixed(2));
|
|
}
|
|
|
|
/**
|
|
* Luas parsil tanah (meter persegi).
|
|
* Menggunakan formula geodesic spherical area berbasis L.LatLng LeafletJS.
|
|
* Formula ini setara dengan L.GeometryUtil.geodesicArea() dari ekosistem LeafletJS.
|
|
* @param {L.LatLng[]} pts
|
|
* @returns {number}
|
|
*/
|
|
function hitungLuasParsil(pts) {
|
|
const R = 6378137; // radius bumi WGS-84 (meter) — sama dengan yang digunakan LeafletJS
|
|
let area = 0;
|
|
const n = pts.length;
|
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
const pi = L.latLng(pts[i]);
|
|
const pj = L.latLng(pts[j]);
|
|
// Formula geodesic spherical (sumber: Leaflet source & L.GeometryUtil)
|
|
area += (pj.lng - pi.lng) * (Math.PI / 180) *
|
|
(2 + Math.sin(pi.lat * Math.PI / 180) + Math.sin(pj.lat * Math.PI / 180));
|
|
}
|
|
return parseFloat(Math.abs(area * R * R / 2).toFixed(4));
|
|
}
|
|
|
|
/* ── Format helpers ─────────────────────────── */
|
|
function fmtPanjang(m) {
|
|
return m >= 1000 ? (m / 1000).toFixed(2) + ' km' : m.toFixed(0) + ' m';
|
|
}
|
|
function fmtLuas(m2) {
|
|
return m2 >= 10000 ? (m2 / 10000).toFixed(4) + ' ha' : m2.toFixed(2) + ' m²';
|
|
}
|
|
|
|
/* ── GeoJSON ↔ LeafletJS conversion ────────── */
|
|
// GeoJSON LineString: coordinates = [[lng,lat],...]
|
|
// GeoJSON Polygon: coordinates = [[[lng,lat],...,[lng,lat]]]
|
|
// Leaflet: L.LatLng(lat, lng)
|
|
|
|
function toGeoJSONLine(lls) {
|
|
return { type:'LineString', coordinates: lls.map(ll => [ll.lng, ll.lat]) };
|
|
}
|
|
function toGeoJSONPolygon(lls) {
|
|
const ring = lls.map(ll => [ll.lng, ll.lat]);
|
|
ring.push(ring[0]); // tutup ring
|
|
return { type:'Polygon', coordinates: [ring] };
|
|
}
|
|
function fromGeoJSONLine(gj) {
|
|
return gj.coordinates.map(c => L.latLng(c[1], c[0]));
|
|
}
|
|
function fromGeoJSONPolygon(gj) {
|
|
// Ambil ring pertama, buang titik penutup
|
|
return gj.coordinates[0].slice(0, -1).map(c => L.latLng(c[1], c[0]));
|
|
}
|
|
function centroid(lls) {
|
|
return L.latLng(
|
|
lls.reduce((s,p) => s + p.lat, 0) / lls.length,
|
|
lls.reduce((s,p) => s + p.lng, 0) / lls.length
|
|
);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
TAB
|
|
═══════════════════════════════════════════════════════════ */
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
document.getElementById('tbtn-' + tab).classList.add('active');
|
|
document.getElementById('tp-' + tab).classList.add('active');
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
DRAWING
|
|
═══════════════════════════════════════════════════════════ */
|
|
function toggleDraw(type) {
|
|
if (drawMode === type) { cancelDraw(); return; }
|
|
if (drawMode) cancelDraw();
|
|
drawMode = type;
|
|
drawingPts = [];
|
|
map.getContainer().style.cursor = 'crosshair';
|
|
|
|
// Update button
|
|
const el = document.getElementById('btn-' + type);
|
|
el.classList.add('drawing');
|
|
document.getElementById('lbl-' + type).textContent = '✖ Stop Menggambar';
|
|
|
|
// Show draw controls
|
|
['db-undo','db-sep1','db-finish','db-cancel','db-sep2'].forEach(id => {
|
|
document.getElementById(id).style.display = 'flex';
|
|
});
|
|
document.getElementById('db-pan').classList.remove('active');
|
|
|
|
setStatus(type === 'jalan'
|
|
? '🛣️ Klik peta untuk menambah titik jalan | Enter = selesai'
|
|
: '🏘️ Klik peta untuk menambah titik parsil | Enter = selesai'
|
|
);
|
|
}
|
|
|
|
function cancelDraw() {
|
|
clearTemp();
|
|
drawMode = null;
|
|
map.getContainer().style.cursor = '';
|
|
['db-undo','db-sep1','db-finish','db-cancel','db-sep2'].forEach(id => {
|
|
document.getElementById(id).style.display = 'none';
|
|
});
|
|
document.getElementById('db-pan').classList.add('active');
|
|
// Reset buttons
|
|
['jalan','parsil'].forEach(t => {
|
|
const el = document.getElementById('btn-' + t);
|
|
if (el) el.classList.remove('drawing');
|
|
const lbl = document.getElementById('lbl-' + t);
|
|
if (lbl) lbl.textContent = t === 'jalan' ? 'Tambah Ruas Jalan' : 'Tambah Parsil Tanah';
|
|
});
|
|
clearStatus();
|
|
}
|
|
|
|
function clearTemp() {
|
|
drawingPts = [];
|
|
tempMkrs.forEach(m => map.removeLayer(m));
|
|
tempMkrs = [];
|
|
if (tempPoly) { map.removeLayer(tempPoly); tempPoly = null; }
|
|
}
|
|
|
|
/* Map click → add vertex */
|
|
map.on('click', function(e) {
|
|
if (!drawMode) return;
|
|
drawingPts.push(e.latlng);
|
|
|
|
const m = L.circleMarker(e.latlng, {
|
|
radius:5, color:'#fff', fillColor:'#818cf8', fillOpacity:1, weight:2
|
|
}).addTo(map);
|
|
tempMkrs.push(m);
|
|
|
|
refreshTempPoly();
|
|
updateDrawStatus();
|
|
});
|
|
|
|
function refreshTempPoly() {
|
|
if (tempPoly) map.removeLayer(tempPoly);
|
|
if (drawingPts.length < 2) return;
|
|
const pts = drawMode === 'parsil' ? [...drawingPts, drawingPts[0]] : drawingPts;
|
|
tempPoly = L.polyline(pts, { color:'#818cf8', weight:3, dashArray:'8,5', opacity:.85 }).addTo(map);
|
|
}
|
|
|
|
function updateDrawStatus() {
|
|
if (!drawMode || !drawingPts.length) return;
|
|
let extra = '';
|
|
if (drawMode === 'jalan' && drawingPts.length >= 2)
|
|
extra = ` | Panjang: ${fmtPanjang(hitungPanjangJalan(drawingPts))}`;
|
|
if (drawMode === 'parsil' && drawingPts.length >= 3)
|
|
extra = ` | Luas: ${fmtLuas(hitungLuasParsil(drawingPts))}`;
|
|
setStatus(`${drawMode==='jalan'?'🛣️':'🏘️'} ${drawingPts.length} titik${extra} | Enter = selesai`);
|
|
}
|
|
|
|
function undoPt() {
|
|
if (!drawingPts.length) return;
|
|
drawingPts.pop();
|
|
const m = tempMkrs.pop();
|
|
if (m) map.removeLayer(m);
|
|
refreshTempPoly();
|
|
updateDrawStatus();
|
|
}
|
|
|
|
function finishDraw() {
|
|
if (!drawMode) return;
|
|
if (drawMode === 'jalan' && drawingPts.length < 2) { alert('⚠ Minimal 2 titik untuk jalan!'); return; }
|
|
if (drawMode === 'parsil' && drawingPts.length < 3) { alert('⚠ Minimal 3 titik untuk parsil!'); return; }
|
|
|
|
const pts = drawingPts.slice();
|
|
const type = drawMode;
|
|
clearTemp();
|
|
cancelDraw();
|
|
|
|
let dim, dimLabel, dimStr, dimNote;
|
|
if (type === 'jalan') {
|
|
dim = hitungPanjangJalan(pts);
|
|
dimLabel = 'Panjang Jalan';
|
|
dimStr = fmtPanjang(dim) + ' (' + dim.toFixed(2) + ' m)';
|
|
dimNote = 'Dihitung otomatis: L.LatLng.distanceTo() dari LeafletJS';
|
|
} else {
|
|
dim = hitungLuasParsil(pts);
|
|
dimLabel = 'Luas Tanah';
|
|
dimStr = fmtLuas(dim) + ' (' + dim.toFixed(4) + ' m²)';
|
|
dimNote = 'Dihitung otomatis: geodesic spherical area (LeafletJS)';
|
|
}
|
|
|
|
pendingPts = pts;
|
|
pendingDim = dim;
|
|
openModal(type, dimLabel, dimStr, dimNote);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
MODAL
|
|
═══════════════════════════════════════════════════════════ */
|
|
/* ── Helper: isi opsi select status ────────── */
|
|
function isiStatusOpts(type, selectedVal) {
|
|
const sel = document.getElementById('inp-status');
|
|
sel.innerHTML = '';
|
|
const opts = type === 'jalan' ? JALAN_STATUS_OPT : PARSIL_STATUS_OPT;
|
|
opts.forEach(([v, l]) => {
|
|
const o = document.createElement('option');
|
|
o.value = v; o.textContent = l;
|
|
if (v === selectedVal) o.selected = true;
|
|
sel.appendChild(o);
|
|
});
|
|
}
|
|
|
|
/* ── Buka modal TAMBAH (setelah selesai gambar) ── */
|
|
function openModal(type, dimLbl, dimStr, dimNote) {
|
|
modalType = type; modalMode = 'add'; modalEditId = null;
|
|
|
|
document.getElementById('modal-title').textContent = type === 'jalan'
|
|
? '🛣️ Simpan Data Jalan' : '🏘️ Simpan Data Parsil Tanah';
|
|
document.getElementById('modal-sub').textContent = type === 'jalan'
|
|
? 'Lengkapi atribut ruas jalan baru' : 'Lengkapi atribut parsil/kavling baru';
|
|
|
|
isiStatusOpts(type, null);
|
|
|
|
document.getElementById('inp-dim-lbl').textContent = dimLbl;
|
|
document.getElementById('inp-dim').value = dimStr;
|
|
document.getElementById('inp-dim-note').textContent = dimNote;
|
|
document.getElementById('inp-nama').value = '';
|
|
document.getElementById('grp-dim').style.display = '';
|
|
document.getElementById('btn-save').textContent = '💾 Simpan';
|
|
|
|
document.getElementById('modal-bg').classList.add('show');
|
|
setTimeout(() => document.getElementById('inp-nama').focus(), 60);
|
|
}
|
|
|
|
/* ── Buka modal EDIT atribut ─────────────────── */
|
|
function openEditModal(type, id) {
|
|
map.closePopup();
|
|
let item;
|
|
if (type === 'jalan') {
|
|
item = jalanData.find(j => j.id == id);
|
|
} else {
|
|
item = parsilData.find(p => p.id == id);
|
|
}
|
|
if (!item) return;
|
|
|
|
modalType = type; modalMode = 'edit'; modalEditId = id;
|
|
|
|
const isJalan = (type === 'jalan');
|
|
document.getElementById('modal-title').textContent = isJalan
|
|
? '✏️ Edit Data Jalan' : '✏️ Edit Data Parsil Tanah';
|
|
document.getElementById('modal-sub').textContent = isJalan
|
|
? 'Ubah nama atau status jalan' : 'Ubah nama atau status kepemilikan parsil';
|
|
|
|
// Pre-fill form
|
|
document.getElementById('inp-nama').value = isJalan ? item.nama_jalan : item.nama_parsil;
|
|
isiStatusOpts(type, isJalan ? item.status_jalan : item.status_kepemilikan);
|
|
|
|
// Tampilkan info dimensi (readonly, tidak bisa diubah di sini)
|
|
if (isJalan) {
|
|
document.getElementById('inp-dim-lbl').textContent = 'Panjang Jalan (tidak berubah)';
|
|
document.getElementById('inp-dim').value = fmtPanjang(parseFloat(item.panjang_jalan)) + ' (' + parseFloat(item.panjang_jalan).toFixed(2) + ' m)';
|
|
document.getElementById('inp-dim-note').textContent = 'Geser titik pada peta untuk mengubah panjang jalan';
|
|
} else {
|
|
document.getElementById('inp-dim-lbl').textContent = 'Luas Tanah (tidak berubah)';
|
|
document.getElementById('inp-dim').value = fmtLuas(parseFloat(item.luas_tanah)) + ' (' + parseFloat(item.luas_tanah).toFixed(4) + ' m²)';
|
|
document.getElementById('inp-dim-note').textContent = 'Geser titik sudut pada peta untuk mengubah luas';
|
|
}
|
|
document.getElementById('grp-dim').style.display = '';
|
|
document.getElementById('btn-save').textContent = '✏️ Simpan Perubahan';
|
|
|
|
document.getElementById('modal-bg').classList.add('show');
|
|
setTimeout(() => document.getElementById('inp-nama').focus(), 60);
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal-bg').classList.remove('show');
|
|
modalType = null; modalMode = 'add'; modalEditId = null;
|
|
pendingPts = null; pendingDim = null;
|
|
}
|
|
|
|
function handleBgClick(e) { if (e.target === document.getElementById('modal-bg')) closeModal(); }
|
|
|
|
async function saveModal() {
|
|
const nama = document.getElementById('inp-nama').value.trim();
|
|
const status = document.getElementById('inp-status').value;
|
|
if (!nama) { alert('Nama tidak boleh kosong!'); document.getElementById('inp-nama').focus(); return; }
|
|
|
|
const btn = document.getElementById('btn-save');
|
|
const origLabel = btn.textContent;
|
|
btn.textContent = '⏳ Menyimpan...'; btn.disabled = true;
|
|
|
|
try {
|
|
/* ── MODE EDIT (update atribut saja) ── */
|
|
if (modalMode === 'edit') {
|
|
if (modalType === 'jalan') {
|
|
const res = await fetch(API_JALAN + '?id=' + modalEditId, {
|
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ nama_jalan: nama, status_jalan: status })
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'sukses') {
|
|
// Update data lokal
|
|
const idx = jalanData.findIndex(j => j.id == modalEditId);
|
|
if (idx >= 0) { jalanData[idx].nama_jalan = nama; jalanData[idx].status_jalan = status; }
|
|
closeModal();
|
|
// Re-render layer di peta (warna & label bisa berubah)
|
|
const l = jalanLyrs[modalEditId];
|
|
if (l) {
|
|
const newColor = JALAN_COLOR[status] || '#6366f1';
|
|
l.polyline.setStyle({ color: newColor });
|
|
l.label.setContent(nama);
|
|
l.vertices.forEach(v => v.setStyle({ fillColor: newColor }));
|
|
l.polyline.bindPopup(buatPopupJalan(jalanData[idx], null, l.polyline, l.label));
|
|
}
|
|
renderListJalan();
|
|
} else { alert('Gagal: ' + data.pesan); }
|
|
} else {
|
|
const res = await fetch(API_PARSIL + '?id=' + modalEditId, {
|
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ nama_parsil: nama, status_kepemilikan: status })
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'sukses') {
|
|
const idx = parsilData.findIndex(p => p.id == modalEditId);
|
|
if (idx >= 0) { parsilData[idx].nama_parsil = nama; parsilData[idx].status_kepemilikan = status; }
|
|
closeModal();
|
|
const l = parsilLyrs[modalEditId];
|
|
if (l) {
|
|
const newColor = PARSIL_COLOR[status] || '#6366f1';
|
|
l.polygon.setStyle({ color: newColor, fillColor: newColor });
|
|
l.label.setContent(nama);
|
|
l.vertices.forEach(v => v.setStyle({ fillColor: newColor }));
|
|
l.polygon.bindPopup(buatPopupParsil(parsilData[idx], null, l.polygon, l.label));
|
|
}
|
|
renderListParsil();
|
|
} else { alert('Gagal: ' + data.pesan); }
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* ── MODE TAMBAH (create baru) ── */
|
|
if (modalType === 'jalan') {
|
|
const res = await fetch(API_JALAN, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({
|
|
nama_jalan: nama, status_jalan: status,
|
|
panjang_jalan: pendingDim, geojson: toGeoJSONLine(pendingPts)
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'sukses') {
|
|
closeModal(); await muatJalan();
|
|
const lyr = jalanLyrs[data.id];
|
|
if (lyr) map.fitBounds(lyr.polyline.getBounds(), {padding:[50,50]});
|
|
} else { alert('Gagal: ' + data.pesan); }
|
|
} else {
|
|
const res = await fetch(API_PARSIL, {
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({
|
|
nama_parsil: nama, status_kepemilikan: status,
|
|
luas_tanah: pendingDim, geojson: toGeoJSONPolygon(pendingPts)
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (data.status === 'sukses') {
|
|
closeModal(); await muatParsil();
|
|
const lyr = parsilLyrs[data.id];
|
|
if (lyr) map.fitBounds(lyr.polygon.getBounds(), {padding:[50,50]});
|
|
} else { alert('Gagal: ' + data.pesan); }
|
|
}
|
|
} catch(err) { alert('Error: ' + err.message); }
|
|
finally { btn.textContent = origLabel; btn.disabled = false; }
|
|
}
|
|
|
|
/* Shortcut global untuk tombol edit di popup & sidebar */
|
|
window.editJalan = (id) => openEditModal('jalan', id);
|
|
window.editParsil = (id) => openEditModal('parsil', id);
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
JALAN — CRUD
|
|
═══════════════════════════════════════════════════════════ */
|
|
async function muatJalan() {
|
|
try { const r = await fetch(API_JALAN); jalanData = await r.json(); }
|
|
catch(e) { jalanData = []; console.warn('API Jalan:', e); }
|
|
|
|
// Hapus layer lama
|
|
Object.values(jalanLyrs).forEach(l => {
|
|
map.removeLayer(l.polyline);
|
|
l.vertices.forEach(v => map.removeLayer(v));
|
|
if (l.label) map.removeLayer(l.label);
|
|
});
|
|
jalanLyrs = {};
|
|
|
|
jalanData.forEach(j => renderJalan(j));
|
|
renderListJalan();
|
|
updateStatsJalan();
|
|
}
|
|
|
|
function renderJalan(jalan) {
|
|
const lls = fromGeoJSONLine(jalan.geojson);
|
|
const color = JALAN_COLOR[jalan.status_jalan] || '#6366f1';
|
|
|
|
const polyline = L.polyline(lls, { color, weight:5, opacity:.9, lineJoin:'round' }).addTo(map);
|
|
|
|
// Label nama di tengah
|
|
const midPt = lls[Math.floor(lls.length / 2)];
|
|
const label = L.tooltip({ permanent:true, direction:'top', className:'map-label', offset:[0,-4] })
|
|
.setContent(jalan.nama_jalan).setLatLng(midPt).addTo(map);
|
|
|
|
// Popup
|
|
function mkPopup() { return buatPopupJalan(jalan, lls, polyline, label); }
|
|
polyline.bindPopup(mkPopup());
|
|
|
|
// Vertex drag
|
|
const vertices = [];
|
|
lls.forEach((pt, idx) => {
|
|
const vm = L.circleMarker(pt, { radius:5, color:'#fff', fillColor:color, fillOpacity:1, weight:2 }).addTo(map);
|
|
vm.on('mousedown', e => {
|
|
map.dragging.disable(); L.DomEvent.stopPropagation(e);
|
|
function onMove(ev) {
|
|
vm.setLatLng(ev.latlng); lls[idx] = ev.latlng;
|
|
polyline.setLatLngs(lls);
|
|
label.setLatLng(lls[Math.floor(lls.length/2)]);
|
|
}
|
|
function onUp() {
|
|
map.dragging.enable();
|
|
map.off('mousemove', onMove); map.off('mouseup', onUp);
|
|
const newP = hitungPanjangJalan(lls);
|
|
updateJalanAPI(jalan.id, lls, newP);
|
|
polyline.bindPopup(buatPopupJalan({...jalan, panjang_jalan:newP}, lls, polyline, label));
|
|
}
|
|
map.on('mousemove', onMove); map.on('mouseup', onUp);
|
|
});
|
|
vertices.push(vm);
|
|
});
|
|
|
|
jalanLyrs[jalan.id] = { polyline, vertices, label };
|
|
}
|
|
|
|
function buatPopupJalan(j, lls, polyline, label) {
|
|
const color = JALAN_COLOR[j.status_jalan];
|
|
const pjg = lls ? hitungPanjangJalan(lls) : parseFloat(j.panjang_jalan);
|
|
return `
|
|
<div class="pop-head" style="background:${color}18;border-bottom:2px solid ${color}40;">
|
|
<h4 style="color:${color};">🛣️ ${j.nama_jalan}</h4>
|
|
</div>
|
|
<div class="pop-body">
|
|
<div class="pop-chip" style="background:${color}20;color:${color};">${j.status_jalan}</div>
|
|
<div class="pop-dim">📐 ${fmtPanjang(pjg)}</div>
|
|
<div class="pop-sub">Panjang: ${pjg.toFixed(2)} m (via L.LatLng.distanceTo)</div>
|
|
<div class="pop-hint">Seret titik pada jalan untuk ubah koordinat</div>
|
|
<div class="pop-actions">
|
|
<button class="pop-edit" onclick="window.editJalan(${j.id})">✏️ Edit</button>
|
|
<button class="pop-del" onclick="window.hapusJalan(${j.id})">🗑️ Hapus</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderListJalan() {
|
|
const fil = document.getElementById('fil-jalan').value;
|
|
const data = fil ? jalanData.filter(j => j.status_jalan === fil) : jalanData;
|
|
const el = document.getElementById('list-jalan');
|
|
|
|
if (!data.length) {
|
|
el.innerHTML = `<div class="empty"><div class="empty-ico">🛣️</div><p>${fil?'Tidak ada data jalan '+fil:'Belum ada data jalan.<br>Klik "Tambah Ruas Jalan" untuk mulai.'}</p></div>`;
|
|
return;
|
|
}
|
|
el.innerHTML = data.map(j => {
|
|
const c = JALAN_COLOR[j.status_jalan]; const bg = c+'22';
|
|
return `<div class="data-card" onclick="zoomJalan(${j.id})">
|
|
<div class="data-card-left">
|
|
<div class="data-card-name">${j.nama_jalan}</div>
|
|
<div class="data-card-meta">
|
|
<span class="badge" style="background:${bg};color:${c};">${j.status_jalan}</span>
|
|
<span class="data-card-dim">📐 ${fmtPanjang(parseFloat(j.panjang_jalan))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="data-card-actions">
|
|
<button class="icon-btn" title="Zoom" onclick="event.stopPropagation();zoomJalan(${j.id})">🔍</button>
|
|
<button class="icon-btn edt" title="Edit" onclick="event.stopPropagation();window.editJalan(${j.id})">✏️</button>
|
|
<button class="icon-btn del" title="Hapus" onclick="event.stopPropagation();window.hapusJalan(${j.id})">🗑️</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateStatsJalan() {
|
|
const total = jalanData.length;
|
|
const km = jalanData.reduce((s,j) => s + parseFloat(j.panjang_jalan), 0);
|
|
document.getElementById('sj-total').textContent = total;
|
|
document.getElementById('sj-panjang').textContent = fmtPanjang(km);
|
|
}
|
|
|
|
function zoomJalan(id) {
|
|
const l = jalanLyrs[id];
|
|
if (l) { map.fitBounds(l.polyline.getBounds(), {padding:[60,60], maxZoom:17}); l.polyline.openPopup(); }
|
|
}
|
|
|
|
window.hapusJalan = async function(id) {
|
|
if (!confirm('Hapus data jalan ini dari database?')) return;
|
|
map.closePopup();
|
|
try {
|
|
const r = await fetch(API_JALAN + '?id=' + id, {method:'DELETE'});
|
|
const d = await r.json();
|
|
if (d.status === 'sukses') {
|
|
const l = jalanLyrs[id];
|
|
if (l) { map.removeLayer(l.polyline); l.vertices.forEach(v=>map.removeLayer(v)); if(l.label) map.removeLayer(l.label); }
|
|
delete jalanLyrs[id];
|
|
jalanData = jalanData.filter(j => j.id != id);
|
|
renderListJalan(); updateStatsJalan();
|
|
} else { alert('Gagal hapus: ' + d.pesan); }
|
|
} catch(e) { alert('Error: ' + e.message); }
|
|
};
|
|
|
|
async function updateJalanAPI(id, lls, panjang) {
|
|
try {
|
|
await fetch(API_JALAN + '?id=' + id, {
|
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ geojson:toGeoJSONLine(lls), panjang_jalan:panjang })
|
|
});
|
|
const i = jalanData.findIndex(j => j.id == id);
|
|
if (i >= 0) jalanData[i].panjang_jalan = panjang;
|
|
updateStatsJalan(); renderListJalan();
|
|
} catch(e) { console.error('Update jalan:', e); }
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
PARSIL — CRUD
|
|
═══════════════════════════════════════════════════════════ */
|
|
async function muatParsil() {
|
|
try { const r = await fetch(API_PARSIL); parsilData = await r.json(); }
|
|
catch(e) { parsilData = []; console.warn('API Parsil:', e); }
|
|
|
|
Object.values(parsilLyrs).forEach(l => {
|
|
map.removeLayer(l.polygon);
|
|
l.vertices.forEach(v => map.removeLayer(v));
|
|
if (l.label) map.removeLayer(l.label);
|
|
});
|
|
parsilLyrs = {};
|
|
|
|
parsilData.forEach(p => renderParsil(p));
|
|
renderListParsil();
|
|
updateStatsParsil();
|
|
}
|
|
|
|
function renderParsil(parsil) {
|
|
const lls = fromGeoJSONPolygon(parsil.geojson);
|
|
const color = PARSIL_COLOR[parsil.status_kepemilikan] || '#6366f1';
|
|
|
|
const polygon = L.polygon(lls, { color, fillColor:color, fillOpacity:.18, weight:2.5 }).addTo(map);
|
|
|
|
const label = L.tooltip({ permanent:true, direction:'center', className:'map-label', offset:[0,0] })
|
|
.setContent(parsil.nama_parsil).setLatLng(centroid(lls)).addTo(map);
|
|
|
|
function mkPopup() { return buatPopupParsil(parsil, lls, polygon, label); }
|
|
polygon.bindPopup(mkPopup());
|
|
|
|
const vertices = [];
|
|
lls.forEach((pt, idx) => {
|
|
const vm = L.circleMarker(pt, { radius:5, color:'#fff', fillColor:color, fillOpacity:1, weight:2 }).addTo(map);
|
|
vm.on('mousedown', e => {
|
|
map.dragging.disable(); L.DomEvent.stopPropagation(e);
|
|
function onMove(ev) {
|
|
vm.setLatLng(ev.latlng); lls[idx] = ev.latlng;
|
|
polygon.setLatLngs(lls); label.setLatLng(centroid(lls));
|
|
}
|
|
function onUp() {
|
|
map.dragging.enable();
|
|
map.off('mousemove', onMove); map.off('mouseup', onUp);
|
|
const newL = hitungLuasParsil(lls);
|
|
updateParsilAPI(parsil.id, lls, newL);
|
|
polygon.bindPopup(buatPopupParsil({...parsil, luas_tanah:newL}, lls, polygon, label));
|
|
}
|
|
map.on('mousemove', onMove); map.on('mouseup', onUp);
|
|
});
|
|
vertices.push(vm);
|
|
});
|
|
|
|
parsilLyrs[parsil.id] = { polygon, vertices, label };
|
|
}
|
|
|
|
function buatPopupParsil(p, lls, polygon, label) {
|
|
const color = PARSIL_COLOR[p.status_kepemilikan];
|
|
const luas = lls ? hitungLuasParsil(lls) : parseFloat(p.luas_tanah);
|
|
const sk = { SHM:'Sertifikat Hak Milik', HGB:'Hak Guna Bangunan', HGU:'Hak Guna Usaha', HP:'Hak Pakai' };
|
|
return `
|
|
<div class="pop-head" style="background:${color}18;border-bottom:2px solid ${color}40;">
|
|
<h4 style="color:${color};">🏘️ ${p.nama_parsil}</h4>
|
|
</div>
|
|
<div class="pop-body">
|
|
<div class="pop-chip" style="background:${color}20;color:${color};">${p.status_kepemilikan} — ${sk[p.status_kepemilikan]}</div>
|
|
<div class="pop-dim">📐 ${fmtLuas(luas)}</div>
|
|
<div class="pop-sub">Luas: ${luas.toFixed(4)} m² (via geodesic spherical)</div>
|
|
<div class="pop-hint">Seret titik sudut poligon untuk ubah bentuk parsil</div>
|
|
<div class="pop-actions">
|
|
<button class="pop-edit" onclick="window.editParsil(${p.id})">✏️ Edit</button>
|
|
<button class="pop-del" onclick="window.hapusParsil(${p.id})">🗑️ Hapus</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderListParsil() {
|
|
const fil = document.getElementById('fil-parsil').value;
|
|
const data = fil ? parsilData.filter(p => p.status_kepemilikan === fil) : parsilData;
|
|
const el = document.getElementById('list-parsil');
|
|
const sk = { SHM:'Hak Milik', HGB:'Guna Bangunan', HGU:'Guna Usaha', HP:'Hak Pakai' };
|
|
|
|
if (!data.length) {
|
|
el.innerHTML = `<div class="empty"><div class="empty-ico">🏘️</div><p>${fil?'Tidak ada parsil '+fil:'Belum ada data parsil.<br>Klik "Tambah Parsil Tanah" untuk mulai.'}</p></div>`;
|
|
return;
|
|
}
|
|
el.innerHTML = data.map(p => {
|
|
const c = PARSIL_COLOR[p.status_kepemilikan]; const bg = c+'22';
|
|
return `<div class="data-card" onclick="zoomParsil(${p.id})">
|
|
<div class="data-card-left">
|
|
<div class="data-card-name">${p.nama_parsil}</div>
|
|
<div class="data-card-meta">
|
|
<span class="badge" style="background:${bg};color:${c};">${p.status_kepemilikan}</span>
|
|
<span class="data-card-dim">📐 ${fmtLuas(parseFloat(p.luas_tanah))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="data-card-actions">
|
|
<button class="icon-btn" title="Zoom" onclick="event.stopPropagation();zoomParsil(${p.id})">🔍</button>
|
|
<button class="icon-btn edt" title="Edit" onclick="event.stopPropagation();window.editParsil(${p.id})">✏️</button>
|
|
<button class="icon-btn del" title="Hapus" onclick="event.stopPropagation();window.hapusParsil(${p.id})">🗑️</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateStatsParsil() {
|
|
const total = parsilData.length;
|
|
const luas = parsilData.reduce((s,p) => s + parseFloat(p.luas_tanah), 0);
|
|
document.getElementById('sp-total').textContent = total;
|
|
document.getElementById('sp-luas').textContent = fmtLuas(luas);
|
|
}
|
|
|
|
function zoomParsil(id) {
|
|
const l = parsilLyrs[id];
|
|
if (l) { map.fitBounds(l.polygon.getBounds(), {padding:[60,60], maxZoom:18}); l.polygon.openPopup(); }
|
|
}
|
|
|
|
window.hapusParsil = async function(id) {
|
|
if (!confirm('Hapus data parsil ini dari database?')) return;
|
|
map.closePopup();
|
|
try {
|
|
const r = await fetch(API_PARSIL + '?id=' + id, {method:'DELETE'});
|
|
const d = await r.json();
|
|
if (d.status === 'sukses') {
|
|
const l = parsilLyrs[id];
|
|
if (l) { map.removeLayer(l.polygon); l.vertices.forEach(v=>map.removeLayer(v)); if(l.label) map.removeLayer(l.label); }
|
|
delete parsilLyrs[id];
|
|
parsilData = parsilData.filter(p => p.id != id);
|
|
renderListParsil(); updateStatsParsil();
|
|
} else { alert('Gagal hapus: ' + d.pesan); }
|
|
} catch(e) { alert('Error: ' + e.message); }
|
|
};
|
|
|
|
async function updateParsilAPI(id, lls, luas) {
|
|
try {
|
|
await fetch(API_PARSIL + '?id=' + id, {
|
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({ geojson:toGeoJSONPolygon(lls), luas_tanah:luas })
|
|
});
|
|
const i = parsilData.findIndex(p => p.id == id);
|
|
if (i >= 0) parsilData[i].luas_tanah = luas;
|
|
updateStatsParsil(); renderListParsil();
|
|
} catch(e) { console.error('Update parsil:', e); }
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
STATUS BAR
|
|
═══════════════════════════════════════════════════════════ */
|
|
function setStatus(msg) {
|
|
document.getElementById('sb-txt').textContent = msg;
|
|
document.getElementById('status-bar').classList.add('on');
|
|
}
|
|
function clearStatus() { document.getElementById('status-bar').classList.remove('on'); }
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
KEYBOARD
|
|
═══════════════════════════════════════════════════════════ */
|
|
document.addEventListener('keydown', function(e) {
|
|
if (document.getElementById('modal-bg').classList.contains('show')) return;
|
|
if (e.key === 'Escape') cancelDraw();
|
|
if (e.key === 'Enter' && drawMode) finishDraw();
|
|
if ((e.key === 'z' || e.key === 'Z') && drawMode) undoPt();
|
|
});
|
|
document.getElementById('inp-nama').addEventListener('keydown', e => { if(e.key==='Enter') saveModal(); });
|
|
|
|
/* ═══════════════════════════════════════════════════════════
|
|
INIT
|
|
═══════════════════════════════════════════════════════════ */
|
|
Promise.all([muatJalan(), muatParsil()]);
|
|
</script>
|
|
</body>
|
|
</html>
|