1021 lines
36 KiB
HTML
1021 lines
36 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 Infrastruktur</title>
|
||
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
:root {
|
||
--bg: #0f1117;
|
||
--surface: #181c27;
|
||
--surface2: #1e2336;
|
||
--border: rgba(255,255,255,0.07);
|
||
--accent: #4f9fff;
|
||
--accent2: #7c5cfc;
|
||
--danger: #ff4e6a;
|
||
--success: #2ecc71;
|
||
--warn: #f39c12;
|
||
--text: #e8eaf0;
|
||
--muted: #6b7280;
|
||
--road: #4f9fff;
|
||
--parcel: #a78bfa;
|
||
--damaged: #ff6b6b;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: 'Space Grotesk', sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* ===== HEADER ===== */
|
||
.app-header {
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 10px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
z-index: 1000;
|
||
flex-shrink: 0;
|
||
}
|
||
.app-logo {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.5px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.logo-dot {
|
||
width: 10px; height: 10px;
|
||
background: var(--accent);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 8px var(--accent);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||
|
||
.search-wrap {
|
||
flex: 1;
|
||
max-width: 420px;
|
||
position: relative;
|
||
}
|
||
.search-input {
|
||
width: 100%;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
padding: 8px 14px 8px 38px;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: border-color .2s;
|
||
}
|
||
.search-input:focus { border-color: var(--accent); }
|
||
.search-icon {
|
||
position: absolute;
|
||
left: 12px; top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--muted);
|
||
font-size: 15px;
|
||
}
|
||
.search-results {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
left: 0; right: 0;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
z-index: 9999;
|
||
display: none;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,.6);
|
||
}
|
||
.search-results.active { display: block; }
|
||
.search-item {
|
||
padding: 10px 14px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
transition: background .15s;
|
||
}
|
||
.search-item:last-child { border-bottom: none; }
|
||
.search-item:hover { background: rgba(79,159,255,.1); }
|
||
.search-tag {
|
||
font-size: 10px;
|
||
padding: 2px 7px;
|
||
border-radius: 20px;
|
||
font-weight: 600;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
}
|
||
.tag-road { background: rgba(79,159,255,.2); color: var(--road); }
|
||
.tag-parcel { background: rgba(167,139,250,.2); color: var(--parcel); }
|
||
.tag-damaged { background: rgba(255,107,107,.2); color: var(--damaged); }
|
||
|
||
.header-stats {
|
||
margin-left: auto;
|
||
display: flex;
|
||
gap: 12px;
|
||
font-size: 12px;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
}
|
||
.stat-chip {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 4px 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.stat-dot { width:7px; height:7px; border-radius:50%; }
|
||
|
||
/* ===== LAYOUT ===== */
|
||
.main-layout {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ===== SIDEBAR ===== */
|
||
#sidebar {
|
||
width: 320px;
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.stab {
|
||
flex: 1;
|
||
padding: 12px 6px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: .5px;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
border-bottom: 2px solid transparent;
|
||
transition: .2s;
|
||
}
|
||
.stab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||
.stab-icon { font-size: 16px; display: block; margin-bottom: 3px; }
|
||
|
||
.sidebar-panel {
|
||
display: none;
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
}
|
||
.sidebar-panel.active { display: block; }
|
||
|
||
.data-card {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 12px;
|
||
margin-bottom: 8px;
|
||
cursor: pointer;
|
||
transition: .2s;
|
||
position: relative;
|
||
}
|
||
.data-card:hover { border-color: rgba(255,255,255,.15); transform: translateX(3px); }
|
||
.data-card-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
|
||
.data-card-meta { font-size: 11px; color: var(--muted); font-family: 'IBM Plex Mono', monospace; }
|
||
.data-card-actions {
|
||
position: absolute;
|
||
top: 10px; right: 10px;
|
||
display: flex;
|
||
gap: 4px;
|
||
opacity: 0;
|
||
transition: .2s;
|
||
}
|
||
.data-card:hover .data-card-actions { opacity: 1; }
|
||
.icon-btn {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
width: 28px; height: 28px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: .15s;
|
||
}
|
||
.icon-btn:hover { background: var(--surface2); }
|
||
.icon-btn.del:hover { border-color: var(--danger); background: rgba(255,78,106,.1); }
|
||
|
||
.type-strip {
|
||
width: 3px;
|
||
border-radius: 3px;
|
||
position: absolute;
|
||
left: 0; top: 8px; bottom: 8px;
|
||
border-radius: 0 0 0 10px;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
color: var(--muted);
|
||
padding: 40px 20px;
|
||
font-size: 13px;
|
||
}
|
||
.empty-state .icon { font-size: 32px; margin-bottom: 8px; }
|
||
|
||
/* ===== MAP ===== */
|
||
#map { flex: 1; }
|
||
|
||
/* ===== MODAL ===== */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,.75);
|
||
z-index: 5000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-box {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 16px;
|
||
width: 460px;
|
||
max-width: 95vw;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 24px 80px rgba(0,0,0,.8);
|
||
}
|
||
.modal-head {
|
||
padding: 20px 24px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.modal-head h2 { font-size: 18px; font-weight: 700; }
|
||
.modal-close {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
width: 32px; height: 32px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
}
|
||
.modal-body { padding: 20px 24px; }
|
||
.modal-foot {
|
||
padding: 16px 24px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-label {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
color: var(--muted);
|
||
margin-bottom: 6px;
|
||
}
|
||
.form-control, .form-select {
|
||
width: 100%;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
padding: 9px 12px;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: border-color .2s;
|
||
}
|
||
.form-control:focus, .form-select:focus { border-color: var(--accent); }
|
||
.form-select option { background: var(--surface2); }
|
||
textarea.form-control { resize: vertical; min-height: 80px; }
|
||
|
||
/* Upload foto */
|
||
.foto-upload-area {
|
||
border: 2px dashed var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: .2s;
|
||
position: relative;
|
||
}
|
||
.foto-upload-area:hover { border-color: var(--accent); }
|
||
.foto-upload-area input[type=file] {
|
||
position: absolute; inset: 0;
|
||
opacity: 0; cursor: pointer;
|
||
}
|
||
.foto-preview {
|
||
margin-top: 10px;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
display: none;
|
||
}
|
||
.foto-preview img { width: 100%; max-height: 160px; object-fit: cover; }
|
||
|
||
/* Current foto */
|
||
.current-foto {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
margin-bottom: 10px;
|
||
position: relative;
|
||
}
|
||
.current-foto img { width: 100%; max-height: 160px; object-fit: cover; }
|
||
.current-foto-label {
|
||
position: absolute;
|
||
bottom: 0; left: 0; right: 0;
|
||
background: rgba(0,0,0,.6);
|
||
font-size: 11px;
|
||
text-align: center;
|
||
padding: 4px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.measure-display {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 9px 12px;
|
||
font-family: 'IBM Plex Mono', monospace;
|
||
font-size: 14px;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.btn-primary-custom {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 9px 20px;
|
||
font-family: inherit;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: .15s;
|
||
}
|
||
.btn-primary-custom:hover { filter: brightness(1.1); }
|
||
.btn-secondary-custom {
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 9px 20px;
|
||
font-family: inherit;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: .15s;
|
||
}
|
||
|
||
/* Toast */
|
||
.toast-container {
|
||
position: fixed;
|
||
bottom: 24px; right: 24px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.toast-msg {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 12px 16px;
|
||
font-size: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
animation: slideIn .3s ease;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,.5);
|
||
}
|
||
@keyframes slideIn { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
|
||
.toast-success { border-left: 3px solid var(--success); }
|
||
.toast-error { border-left: 3px solid var(--danger); }
|
||
|
||
/* Leaflet overrides */
|
||
.leaflet-control-draw-toolbar .leaflet-draw-draw-polyline { }
|
||
.leaflet-popup-content-wrapper {
|
||
background: var(--surface2) !important;
|
||
color: var(--text) !important;
|
||
border: 1px solid var(--border) !important;
|
||
border-radius: 10px !important;
|
||
}
|
||
.leaflet-popup-tip { background: var(--surface2) !important; }
|
||
.popup-foto { width: 100%; max-height: 120px; object-fit: cover; border-radius: 6px; margin-bottom: 8px; }
|
||
.popup-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
|
||
.popup-meta { font-size: 12px; color: var(--muted); }
|
||
.popup-edit-btn {
|
||
display: inline-block;
|
||
margin-top: 8px;
|
||
background: var(--accent);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 4px 12px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 5px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- HEADER -->
|
||
<div class="app-header">
|
||
<div class="app-logo">
|
||
<div class="logo-dot"></div>
|
||
WebGIS Infrastruktur
|
||
</div>
|
||
|
||
<div class="search-wrap">
|
||
<span class="search-icon">🔍</span>
|
||
<input type="text" class="search-input" id="searchInput" placeholder="Cari jalan, tanah, jalan rusak..." autocomplete="off">
|
||
<div class="search-results" id="searchResults"></div>
|
||
</div>
|
||
|
||
<div class="header-stats">
|
||
<div class="stat-chip">
|
||
<div class="stat-dot" style="background:var(--road)"></div>
|
||
<span id="stat-road">0</span> Jalan
|
||
</div>
|
||
<div class="stat-chip">
|
||
<div class="stat-dot" style="background:var(--parcel)"></div>
|
||
<span id="stat-parcel">0</span> Persil
|
||
</div>
|
||
<div class="stat-chip">
|
||
<div class="stat-dot" style="background:var(--damaged)"></div>
|
||
<span id="stat-damaged">0</span> Rusak
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MAIN -->
|
||
<div class="main-layout">
|
||
|
||
<!-- SIDEBAR -->
|
||
<div id="sidebar">
|
||
<div class="sidebar-tabs">
|
||
<div class="stab active" onclick="switchTab('road')">
|
||
<span class="stab-icon">🛣️</span>Jalan
|
||
</div>
|
||
<div class="stab" onclick="switchTab('parcel')">
|
||
<span class="stab-icon">🏘️</span>Tanah
|
||
</div>
|
||
<div class="stab" onclick="switchTab('damaged')">
|
||
<span class="stab-icon">⚠️</span>Rusak
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-panel active" id="panel-road">
|
||
<div id="list-road"></div>
|
||
</div>
|
||
<div class="sidebar-panel" id="panel-parcel">
|
||
<div id="list-parcel"></div>
|
||
</div>
|
||
<div class="sidebar-panel" id="panel-damaged">
|
||
<div id="list-damaged"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MAP -->
|
||
<div id="map"></div>
|
||
</div>
|
||
|
||
<!-- MODAL -->
|
||
<div class="modal-overlay" id="modal">
|
||
<div class="modal-box">
|
||
<div class="modal-head">
|
||
<h2 id="modal-title">Tambah Data</h2>
|
||
<div class="modal-close" onclick="closeModal()">×</div>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="f_id">
|
||
<input type="hidden" id="f_type">
|
||
<input type="hidden" id="f_geom">
|
||
<input type="hidden" id="f_measure">
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Nama Objek</label>
|
||
<input type="text" class="form-control" id="f_name" placeholder="Masukkan nama...">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Status</label>
|
||
<select class="form-select" id="f_status"></select>
|
||
</div>
|
||
|
||
<div class="form-group" id="grp_description" style="display:none">
|
||
<label class="form-label">Deskripsi Kerusakan</label>
|
||
<textarea class="form-control" id="f_description" placeholder="Jelaskan kondisi kerusakan..."></textarea>
|
||
</div>
|
||
|
||
<div class="form-group" id="grp_measure" style="display:none">
|
||
<label class="form-label" id="label_measure">Hasil Perhitungan</label>
|
||
<div class="measure-display" id="display_measure">-</div>
|
||
</div>
|
||
|
||
<!-- Foto saat ini (saat edit) -->
|
||
<div class="form-group" id="grp_current_foto" style="display:none">
|
||
<label class="form-label">Foto Saat Ini</label>
|
||
<div class="current-foto">
|
||
<img id="current_foto_img" src="" alt="Foto">
|
||
<div class="current-foto-label">Foto tersimpan — unggah baru untuk mengganti</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Upload Foto (Opsional)</label>
|
||
<div class="foto-upload-area">
|
||
<input type="file" id="f_foto" accept="image/*" onchange="previewFoto(this)">
|
||
<div id="upload_placeholder">
|
||
<div style="font-size:28px">📷</div>
|
||
<div style="font-size:13px;color:var(--muted);margin-top:6px">Klik atau seret foto ke sini</div>
|
||
<div style="font-size:11px;color:var(--muted)">JPG, PNG, WebP — maks 5MB</div>
|
||
</div>
|
||
<div class="foto-preview" id="foto_preview">
|
||
<img id="foto_preview_img" src="">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn-secondary-custom" onclick="closeModal()">Batal</button>
|
||
<button class="btn-primary-custom" onclick="processSave()">💾 Simpan Data</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TOAST -->
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
|
||
|
||
<script>
|
||
// ============================================================
|
||
// INIT MAP
|
||
// ============================================================
|
||
const map = L.map('map').setView([-0.02, 109.34], 14);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap'
|
||
}).addTo(map);
|
||
|
||
const savedLayers = L.featureGroup().addTo(map);
|
||
const layerRegistry = {};
|
||
|
||
const STATUS_COLORS = {
|
||
'Nasional': '#4f9fff', 'Provinsi': '#2ecc71', 'Kabupaten': '#f39c12',
|
||
'SHM': '#a78bfa', 'HGB': '#f1c40f', 'HGU': '#e67e22', 'HP': '#9b59b6',
|
||
'Ringan': '#f39c12', 'Sedang': '#e67e22', 'Berat': '#ff4e6a', 'Kritis': '#c0392b'
|
||
};
|
||
|
||
const STATUS_OPTIONS = {
|
||
road: [
|
||
{ value: 'Nasional', label: 'Jalan Nasional' },
|
||
{ value: 'Provinsi', label: 'Jalan Provinsi' },
|
||
{ value: 'Kabupaten', label: 'Jalan Kabupaten' },
|
||
],
|
||
parcel: [
|
||
{ value: 'SHM', label: 'SHM (Hak Milik)' },
|
||
{ value: 'HGB', label: 'HGB (Hak Guna Bangunan)' },
|
||
{ value: 'HGU', label: 'HGU (Hak Guna Usaha)' },
|
||
{ value: 'HP', label: 'HP (Hak Pakai)' },
|
||
],
|
||
damaged: [
|
||
{ value: 'Ringan', label: 'Kerusakan Ringan' },
|
||
{ value: 'Sedang', label: 'Kerusakan Sedang' },
|
||
{ value: 'Berat', label: 'Kerusakan Berat' },
|
||
{ value: 'Kritis', label: 'Kritis / Darurat' },
|
||
]
|
||
};
|
||
|
||
// ============================================================
|
||
// DRAW CONTROL
|
||
// ============================================================
|
||
const drawControl = new L.Control.Draw({
|
||
draw: {
|
||
polyline: { shapeOptions: { color: '#4f9fff', weight: 4 } },
|
||
polygon: { shapeOptions: { color: '#a78bfa', weight: 2, fillOpacity: 0.4 } },
|
||
marker: true,
|
||
circle: false,
|
||
circlemarker: false,
|
||
rectangle: false,
|
||
},
|
||
edit: false
|
||
});
|
||
map.addControl(drawControl);
|
||
|
||
let pendingLayer = null;
|
||
|
||
map.on(L.Draw.Event.CREATED, function(e) {
|
||
const layer = e.layer;
|
||
const layerType = e.layerType;
|
||
const geojson = layer.toGeoJSON();
|
||
|
||
// Hapus pending layer sebelumnya
|
||
if (pendingLayer) { map.removeLayer(pendingLayer); }
|
||
pendingLayer = layer;
|
||
layer.addTo(map);
|
||
|
||
let type, measure = 0;
|
||
|
||
if (layerType === 'polyline') {
|
||
type = 'road';
|
||
measure = turf.length(geojson, { units: 'meters' }).toFixed(2);
|
||
openModal(type, null, geojson.geometry, measure);
|
||
} else if (layerType === 'polygon') {
|
||
type = 'parcel';
|
||
measure = turf.area(geojson).toFixed(2);
|
||
openModal(type, null, geojson.geometry, measure);
|
||
} else if (layerType === 'marker') {
|
||
type = 'damaged';
|
||
openModal(type, null, geojson.geometry, null);
|
||
}
|
||
});
|
||
|
||
// ============================================================
|
||
// MODAL LOGIC
|
||
// ============================================================
|
||
function openModal(type, editData, geom, measure) {
|
||
document.getElementById('f_id').value = editData ? editData.id : '';
|
||
document.getElementById('f_type').value = type;
|
||
document.getElementById('f_geom').value = editData ? editData.geom : JSON.stringify(geom);
|
||
document.getElementById('f_measure').value = measure || '';
|
||
document.getElementById('f_name').value = editData ? editData.name : '';
|
||
document.getElementById('f_description').value = editData ? (editData.description || '') : '';
|
||
|
||
// Reset foto
|
||
document.getElementById('f_foto').value = '';
|
||
document.getElementById('foto_preview').style.display = 'none';
|
||
document.getElementById('upload_placeholder').style.display = 'block';
|
||
|
||
// Current foto (edit mode)
|
||
const grpCurrentFoto = document.getElementById('grp_current_foto');
|
||
if (editData && editData.foto) {
|
||
grpCurrentFoto.style.display = 'block';
|
||
document.getElementById('current_foto_img').src = 'uploads/' + editData.foto;
|
||
} else {
|
||
grpCurrentFoto.style.display = 'none';
|
||
}
|
||
|
||
// Title
|
||
const titles = { road: '🛣️ Data Jalan', parcel: '🏘️ Data Persil Tanah', damaged: '⚠️ Data Jalan Rusak' };
|
||
document.getElementById('modal-title').textContent = editData ? 'Edit ' + titles[type] : 'Tambah ' + titles[type];
|
||
|
||
// Status options
|
||
const statusSel = document.getElementById('f_status');
|
||
statusSel.innerHTML = STATUS_OPTIONS[type].map(o =>
|
||
`<option value="${o.value}" ${editData && editData.status === o.value ? 'selected' : ''}>${o.label}</option>`
|
||
).join('');
|
||
|
||
// Measure display
|
||
const grpMeasure = document.getElementById('grp_measure');
|
||
if (measure) {
|
||
grpMeasure.style.display = 'block';
|
||
const unit = type === 'road' ? 'm' : 'm²';
|
||
document.getElementById('label_measure').textContent = type === 'road' ? 'Panjang Jalan' : 'Luas Persil';
|
||
document.getElementById('display_measure').textContent = parseFloat(measure).toLocaleString('id-ID') + ' ' + unit;
|
||
} else {
|
||
grpMeasure.style.display = 'none';
|
||
}
|
||
|
||
// Description (damaged only)
|
||
document.getElementById('grp_description').style.display = type === 'damaged' ? 'block' : 'none';
|
||
|
||
document.getElementById('modal').classList.add('open');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modal').classList.remove('open');
|
||
if (pendingLayer) { map.removeLayer(pendingLayer); pendingLayer = null; }
|
||
}
|
||
|
||
// ============================================================
|
||
// SAVE DATA
|
||
// ============================================================
|
||
function processSave() {
|
||
const name = document.getElementById('f_name').value.trim();
|
||
if (!name) { showToast('Nama objek tidak boleh kosong!', 'error'); return; }
|
||
|
||
const fd = new FormData();
|
||
fd.append('id', document.getElementById('f_id').value);
|
||
fd.append('type', document.getElementById('f_type').value);
|
||
fd.append('name', name);
|
||
fd.append('status', document.getElementById('f_status').value);
|
||
fd.append('geom', document.getElementById('f_geom').value);
|
||
fd.append('measure', document.getElementById('f_measure').value);
|
||
fd.append('description', document.getElementById('f_description').value);
|
||
|
||
const fotoFile = document.getElementById('f_foto').files[0];
|
||
if (fotoFile) fd.append('foto', fotoFile);
|
||
|
||
fetch('api.php?action=save', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(res => {
|
||
if (res.success) {
|
||
showToast(res.message, 'success');
|
||
document.getElementById('modal').classList.remove('open');
|
||
pendingLayer = null;
|
||
loadData();
|
||
} else {
|
||
showToast(res.message, 'error');
|
||
}
|
||
})
|
||
.catch(() => showToast('Terjadi kesalahan koneksi', 'error'));
|
||
}
|
||
|
||
// ============================================================
|
||
// DELETE DATA
|
||
// ============================================================
|
||
function deleteData(id, type, e) {
|
||
e.stopPropagation();
|
||
if (!confirm('Hapus data ini? Tindakan tidak dapat dibatalkan.')) return;
|
||
const fd = new FormData();
|
||
fd.append('id', id); fd.append('type', type);
|
||
fetch('api.php?action=delete', { method: 'POST', body: fd })
|
||
.then(r => r.json())
|
||
.then(res => {
|
||
showToast(res.message, 'success');
|
||
loadData();
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// EDIT DATA
|
||
// ============================================================
|
||
function editData(id, type, e) {
|
||
e.stopPropagation();
|
||
fetch(`api.php?action=get_one&id=${id}&type=${type}`)
|
||
.then(r => r.json())
|
||
.then(res => {
|
||
if (!res.success || !res.data) { showToast('Data tidak ditemukan', 'error'); return; }
|
||
const data = res.data;
|
||
const measure = type === 'road' ? data.length_m : (type === 'parcel' ? data.area_sqm : null);
|
||
openModal(type, data, null, measure);
|
||
})
|
||
.catch(() => showToast('Gagal memuat data', 'error'));
|
||
}
|
||
|
||
// ============================================================
|
||
// LOAD DATA
|
||
// ============================================================
|
||
function loadData() {
|
||
savedLayers.clearLayers();
|
||
Object.keys(layerRegistry).forEach(k => delete layerRegistry[k]);
|
||
document.getElementById('list-road').innerHTML = '';
|
||
document.getElementById('list-parcel').innerHTML = '';
|
||
document.getElementById('list-damaged').innerHTML = '';
|
||
|
||
fetch('api.php?action=get_data')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// Stats
|
||
document.getElementById('stat-road').textContent = data.roads.length;
|
||
document.getElementById('stat-parcel').textContent = data.parcels.length;
|
||
document.getElementById('stat-damaged').textContent = data.damaged_roads.length;
|
||
|
||
// Roads
|
||
data.roads.forEach(r => {
|
||
const color = STATUS_COLORS[r.status] || '#4f9fff';
|
||
const geo = L.geoJSON(JSON.parse(r.geom), { style: { color, weight: 5 } });
|
||
geo.bindPopup(buildPopup(r, 'road'));
|
||
geo.addTo(savedLayers);
|
||
layerRegistry['road_' + r.id] = geo;
|
||
appendCard('road', r, r.length_m + ' m');
|
||
});
|
||
|
||
// Parcels
|
||
data.parcels.forEach(p => {
|
||
const color = STATUS_COLORS[p.status] || '#a78bfa';
|
||
const geo = L.geoJSON(JSON.parse(p.geom), {
|
||
style: { fillColor: color, fillOpacity: 0.5, color: 'white', weight: 1.5 }
|
||
});
|
||
geo.bindPopup(buildPopup(p, 'parcel'));
|
||
geo.addTo(savedLayers);
|
||
layerRegistry['parcel_' + p.id] = geo;
|
||
appendCard('parcel', p, p.area_sqm + ' m²');
|
||
});
|
||
|
||
// Damaged roads (points)
|
||
data.damaged_roads.forEach(d => {
|
||
const color = STATUS_COLORS[d.status] || '#ff4e6a';
|
||
const geom = JSON.parse(d.geom);
|
||
const latlng = [geom.coordinates[1], geom.coordinates[0]];
|
||
|
||
const icon = L.divIcon({
|
||
className: '',
|
||
html: `<div style="
|
||
width:32px;height:32px;border-radius:50% 50% 50% 0;
|
||
background:${color};border:3px solid white;
|
||
transform:rotate(-45deg);box-shadow:0 2px 8px rgba(0,0,0,.5);
|
||
display:flex;align-items:center;justify-content:center;
|
||
"><span style="transform:rotate(45deg);font-size:14px">⚠️</span></div>`,
|
||
iconSize: [32, 32],
|
||
iconAnchor: [16, 32]
|
||
});
|
||
|
||
const marker = L.marker(latlng, { icon });
|
||
marker.bindPopup(buildPopup(d, 'damaged'));
|
||
marker.addTo(savedLayers);
|
||
layerRegistry['damaged_' + d.id] = marker;
|
||
appendCard('damaged', d, d.status);
|
||
});
|
||
|
||
// Empty states
|
||
['road','parcel','damaged'].forEach(t => {
|
||
const el = document.getElementById('list-' + t);
|
||
if (!el.children.length) {
|
||
el.innerHTML = `<div class="empty-state"><div class="icon">${t==='road'?'🛣️':t==='parcel'?'🏘️':'⚠️'}</div>Belum ada data ${t==='road'?'jalan':t==='parcel'?'persil tanah':'jalan rusak'}</div>`;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function buildPopup(data, type) {
|
||
const measureHtml = type === 'road'
|
||
? `<br>Panjang: <b>${data.length_m} m</b>`
|
||
: type === 'parcel'
|
||
? `<br>Luas: <b>${data.area_sqm} m²</b>`
|
||
: (data.description ? `<br><i>${data.description}</i>` : '');
|
||
|
||
const fotoHtml = data.foto
|
||
? `<img class="popup-foto" src="uploads/${data.foto}" alt="Foto">`
|
||
: '';
|
||
|
||
return `<div style="min-width:200px">
|
||
${fotoHtml}
|
||
<div class="popup-title">${data.name || 'Tanpa Nama'}</div>
|
||
<div class="popup-meta">Status: ${data.status}${measureHtml}</div>
|
||
<button class="popup-edit-btn" onclick="editData(${data.id},'${type}',event)">✏️ Edit</button>
|
||
</div>`;
|
||
}
|
||
|
||
function appendCard(type, data, meta) {
|
||
const color = STATUS_COLORS[data.status] || '#888';
|
||
const container = document.getElementById('list-' + type);
|
||
const div = document.createElement('div');
|
||
div.className = 'data-card';
|
||
div.onclick = () => zoomTo(type + '_' + data.id);
|
||
div.innerHTML = `
|
||
<div class="type-strip" style="background:${color}"></div>
|
||
<div style="padding-left:8px">
|
||
<div class="data-card-name">${data.name || 'Tanpa Nama'}</div>
|
||
<div class="data-card-meta">${data.status} · ${meta}</div>
|
||
</div>
|
||
<div class="data-card-actions">
|
||
<div class="icon-btn" title="Edit" onclick="editData(${data.id},'${type}',event)">✏️</div>
|
||
<div class="icon-btn del" title="Hapus" onclick="deleteData(${data.id},'${type}',event)">🗑️</div>
|
||
</div>
|
||
`;
|
||
container.appendChild(div);
|
||
}
|
||
|
||
function zoomTo(key) {
|
||
const layer = layerRegistry[key];
|
||
if (!layer) return;
|
||
if (layer.getBounds) {
|
||
map.fitBounds(layer.getBounds(), { padding: [60, 60] });
|
||
} else {
|
||
map.setView(layer.getLatLng(), 17);
|
||
}
|
||
layer.openPopup();
|
||
}
|
||
|
||
// ============================================================
|
||
// SIDEBAR TAB
|
||
// ============================================================
|
||
function switchTab(type) {
|
||
document.querySelectorAll('.stab').forEach((el, i) => {
|
||
el.classList.toggle('active', ['road','parcel','damaged'][i] === type);
|
||
});
|
||
document.querySelectorAll('.sidebar-panel').forEach(el => el.classList.remove('active'));
|
||
document.getElementById('panel-' + type).classList.add('active');
|
||
}
|
||
|
||
// ============================================================
|
||
// FOTO PREVIEW
|
||
// ============================================================
|
||
function previewFoto(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
document.getElementById('foto_preview_img').src = e.target.result;
|
||
document.getElementById('foto_preview').style.display = 'block';
|
||
document.getElementById('upload_placeholder').style.display = 'none';
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// ============================================================
|
||
// SEARCH
|
||
// ============================================================
|
||
let searchTimer;
|
||
const searchInput = document.getElementById('searchInput');
|
||
const searchResults = document.getElementById('searchResults');
|
||
|
||
searchInput.addEventListener('input', function() {
|
||
clearTimeout(searchTimer);
|
||
const q = this.value.trim();
|
||
if (q.length < 2) { searchResults.classList.remove('active'); return; }
|
||
searchTimer = setTimeout(() => doSearch(q), 300);
|
||
});
|
||
|
||
searchInput.addEventListener('blur', function() {
|
||
setTimeout(() => searchResults.classList.remove('active'), 200);
|
||
});
|
||
|
||
function doSearch(q) {
|
||
fetch(`api.php?action=search&q=${encodeURIComponent(q)}`)
|
||
.then(r => r.json())
|
||
.then(res => {
|
||
const results = res.data || [];
|
||
if (!results.length) {
|
||
searchResults.innerHTML = `<div class="search-item" style="color:var(--muted)">Tidak ada hasil ditemukan</div>`;
|
||
} else {
|
||
const tagClass = { road: 'tag-road', parcel: 'tag-parcel', damaged: 'tag-damaged' };
|
||
const tagLabel = { road: 'Jalan', parcel: 'Tanah', damaged: 'Rusak' };
|
||
searchResults.innerHTML = results.slice(0, 10).map(r => `
|
||
<div class="search-item" onclick="selectSearchResult('${r.type}_${r.id}', '${r.type}')">
|
||
<span class="search-tag ${tagClass[r.type]}">${tagLabel[r.type]}</span>
|
||
<span>${r.name}</span>
|
||
<span style="margin-left:auto;font-size:11px;color:var(--muted)">${r.status}</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
searchResults.classList.add('active');
|
||
})
|
||
.catch(() => {
|
||
searchResults.innerHTML = `<div class="search-item" style="color:var(--muted)">Gagal menghubungi server</div>`;
|
||
searchResults.classList.add('active');
|
||
});
|
||
}
|
||
|
||
function selectSearchResult(key, type) {
|
||
searchResults.classList.remove('active');
|
||
searchInput.value = '';
|
||
switchTab(type);
|
||
zoomTo(key);
|
||
}
|
||
|
||
// ============================================================
|
||
// TOAST
|
||
// ============================================================
|
||
function showToast(msg, type = 'success') {
|
||
const icon = type === 'success' ? '✅' : '❌';
|
||
const div = document.createElement('div');
|
||
div.className = `toast-msg toast-${type}`;
|
||
div.innerHTML = `<span>${icon}</span><span>${msg}</span>`;
|
||
document.getElementById('toastContainer').appendChild(div);
|
||
setTimeout(() => div.remove(), 4000);
|
||
}
|
||
|
||
// ============================================================
|
||
// CLOSE SEARCH ON MAP CLICK
|
||
// ============================================================
|
||
map.on('click', () => searchResults.classList.remove('active'));
|
||
|
||
// ============================================================
|
||
// START
|
||
// ============================================================
|
||
loadData();
|
||
</script>
|
||
</body>
|
||
</html> |