Files
WebGIS_Project/02/index.html
T
luthfihadinugroho79 e950468760 add files
2026-06-10 19:42:59 +07:00

1021 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS 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} &nbsp;·&nbsp; ${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>