Files
WEB-GIS/fitur_jalan/index.html
T

952 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sistem GIS — Infrastruktur Jalan</title>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Leaflet CSS & Draw CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Outfit', 'sans-serif'],
}
}
}
}
</script>
<style>
html, body { height: 100%; font-family: 'Outfit', sans-serif; overflow: hidden; }
#map {
height: 100vh;
width: 100%;
z-index: 1;
}
.custom-popup .leaflet-popup-content-wrapper {
border-radius: 16px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(226, 232, 240, 0.8);
padding: 4px;
}
.custom-popup .leaflet-popup-content {
margin: 12px;
font-family: 'Outfit', sans-serif;
}
</style>
</head>
<body class="bg-slate-50 text-slate-800">
<!-- App Shell -->
<div class="relative w-full h-screen flex overflow-hidden">
<!-- Sidebar -->
<div class="w-[380px] h-full bg-white border-r border-slate-200/80 flex flex-col z-10 shadow-lg relative">
<!-- Header -->
<div class="p-6 border-b border-slate-100 flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<a href="../index.html" class="flex items-center justify-center w-8 h-8 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</a>
<div class="flex items-center gap-1.5 text-xs font-semibold text-rose-600 bg-rose-50 px-2.5 py-1 rounded-full uppercase tracking-wider">
<span>Informatika UNTAN</span>
</div>
</div>
<h1 class="text-xl font-bold text-slate-800 mt-2 font-black">Infrastruktur Jalan</h1>
<p class="text-xs text-slate-500">Pemetaan jaringan jalan beserta pelaporan titik kerusakan jalan.</p>
</div>
<!-- Tabs -->
<div class="border-b border-slate-100 flex">
<button onclick="switchTab('jalan')" id="tabJalan" class="flex-1 py-3 text-center text-sm font-bold border-b-2 border-indigo-600 text-indigo-600 transition">
Jaringan Jalan
</button>
<button onclick="switchTab('rusak')" id="tabRusak" class="flex-1 py-3 text-center text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-700 transition">
Laporan Kerusakan
</button>
</div>
<!-- Layer toggles inside sidebar -->
<div class="px-6 py-4 bg-slate-50 border-b border-slate-100 space-y-2">
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Visibilitas Layer</div>
<label class="flex items-center gap-2.5 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="toggleDrawnRoads" checked class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Jalan Digambar (Polyline)</span>
</label>
<label class="flex items-center gap-2.5 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="toggleDamagedRoads" checked class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Laporan Jalan Rusak (Poin)</span>
</label>
<label class="flex items-center gap-2.5 text-xs text-slate-700 cursor-pointer">
<input type="checkbox" id="toggleExternalRoads" class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
<span>Jaringan Jalan Eksternal (GeoJSON)</span>
</label>
</div>
<!-- List & Search -->
<div class="flex-1 flex flex-col min-h-0">
<div class="p-4 border-b border-slate-100">
<div class="relative">
<input id="searchQuery" type="text" placeholder="Cari..." class="w-full bg-slate-50 border border-slate-200 rounded-xl pl-10 pr-4 py-2.5 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-100 transition shadow-sm">
<svg class="absolute left-3.5 top-3.5 text-slate-400" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</div>
</div>
<!-- Tab 1: Jalan Digambar -->
<div id="listJalan" class="flex-1 overflow-y-auto p-4 space-y-3">
<!-- Loading Placeholder -->
<div class="text-center py-8 text-slate-400 text-sm">Memuat data jalan...</div>
</div>
<!-- Tab 2: Jalan Rusak -->
<div id="listRusak" class="flex-1 overflow-y-auto p-4 space-y-3 hidden">
<!-- Loading Placeholder -->
<div class="text-center py-8 text-slate-400 text-sm">Memuat laporan kerusakan...</div>
</div>
</div>
<!-- Tips Panel -->
<div class="p-4 bg-rose-50/50 border-t border-rose-100/50 text-xs text-rose-800 flex gap-2.5">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0 mt-0.5"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<div id="tipsText">
Gunakan tool <b>Draw Polyline</b> di sisi kanan peta untuk menambahkan ruas jalan baru dan menghitung panjangnya.
</div>
</div>
</div>
<!-- Map Container -->
<div class="flex-1 h-full relative">
<div id="map"></div>
<!-- Toast notification -->
<div id="toast" class="absolute top-4 right-4 z-[2000] hidden items-center gap-3 px-4 py-3 bg-slate-900/90 text-white rounded-xl shadow-xl backdrop-blur-md transition transform scale-95 opacity-0">
<span id="toastMessage" class="text-sm font-medium">Notifikasi</span>
</div>
</div>
</div>
<!-- Modal Input Jalan Polyline Baru -->
<div id="newJalanModal" class="fixed inset-0 z-[10000] hidden items-center justify-center bg-slate-900/40 backdrop-blur-sm px-4">
<div class="w-full max-w-md bg-white rounded-2xl shadow-2xl border border-slate-100 overflow-hidden transform scale-95 opacity-0 transition-all duration-300">
<div class="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 class="font-bold text-slate-800">Simpan Ruas Jalan Baru</h3>
<button onclick="closeNewJalanModal()" class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-slate-200 transition text-slate-500">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="p-5 space-y-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Nama Jalan</label>
<input id="newJalanName" type="text" placeholder="Contoh: Jl. Ahmad Yani" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-100 transition shadow-sm">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Kategori Status</label>
<select id="newJalanStatus" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-100 transition shadow-sm">
<option value="Jalan Nasional">Jalan Nasional</option>
<option value="Jalan Provinsi">Jalan Provinsi</option>
<option value="Jalan Kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Panjang Ruas</label>
<input id="newJalanLength" type="text" readonly class="w-full bg-slate-100 border border-slate-200 rounded-xl px-4 py-2.5 text-sm text-slate-500 outline-none">
</div>
</div>
</div>
<div class="p-5 border-t border-slate-100 bg-slate-50/50 flex gap-3">
<button onclick="saveNewJalan()" class="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm py-2.5 px-4 rounded-xl shadow-lg transition">Simpan Jalan</button>
<button onclick="closeNewJalanModal()" class="flex-1 bg-slate-200 hover:bg-slate-300 text-slate-700 font-semibold text-sm py-2.5 px-4 rounded-xl transition">Batal</button>
</div>
</div>
</div>
<!-- Modal Input Laporan Jalan Rusak -->
<div id="newDamageModal" class="fixed inset-0 z-[10000] hidden items-center justify-center bg-slate-900/40 backdrop-blur-sm px-4">
<div class="w-full max-w-md bg-white rounded-2xl shadow-2xl border border-slate-100 overflow-hidden transform scale-95 opacity-0 transition-all duration-300">
<div class="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 class="font-bold text-slate-800">Lapor Jalan Rusak</h3>
<button onclick="closeNewDamageModal()" class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-slate-200 transition text-slate-500">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="p-5 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Jenis Kerusakan</label>
<select id="damageType" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-red-500 focus:bg-white focus:ring-2 focus:ring-red-100 transition shadow-sm">
<option value="lubang">Lubang</option>
<option value="retak">Retak</option>
<option value="ambles">Ambles</option>
<option value="bergelombang">Bergelombang</option>
<option value="lainnya">Lainnya</option>
</select>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Tingkat Keparahan</label>
<select id="damageSeverity" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-red-500 focus:bg-white focus:ring-2 focus:ring-red-100 transition shadow-sm">
<option value="ringan">Ringan</option>
<option value="sedang" selected>Sedang</option>
<option value="berat">Berat</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Deskripsi Kondisi</label>
<textarea id="damageDesc" placeholder="Jelaskan kondisi detail kerusakan..." rows="3" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:border-red-500 focus:bg-white focus:ring-2 focus:ring-red-100 transition shadow-sm"></textarea>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Foto Kerusakan (Opsional)</label>
<input id="damageImage" type="file" accept="image/*" class="w-full border border-slate-200 rounded-xl p-2 text-sm bg-slate-50">
</div>
<div class="text-[10px] text-slate-400 bg-slate-50 p-2 rounded-lg">
📍 Koordinat: <span id="damageCoords" class="font-mono">0, 0</span>
</div>
</div>
<div class="p-5 border-t border-slate-100 bg-slate-50/50 flex gap-3">
<button onclick="saveNewDamage()" class="flex-1 bg-red-600 hover:bg-red-700 text-white font-semibold text-sm py-2.5 px-4 rounded-xl shadow-lg transition">Kirim Laporan</button>
<button onclick="closeNewDamageModal()" class="flex-1 bg-slate-200 hover:bg-slate-300 text-slate-700 font-semibold text-sm py-2.5 px-4 rounded-xl transition">Batal</button>
</div>
</div>
</div>
<!-- Modal Edit Jalan (Polyline) -->
<div id="editJalanModal" class="fixed inset-0 z-[10000] hidden items-center justify-center bg-slate-900/40 backdrop-blur-sm px-4">
<div class="w-full max-w-md bg-white rounded-2xl shadow-2xl border border-slate-100 overflow-hidden transform scale-95 opacity-0 transition-all duration-300">
<div class="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<h3 class="font-bold text-slate-800">Edit Atribut Jalan</h3>
<button onclick="closeEditJalanModal()" class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-slate-200 transition text-slate-500">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="p-5 space-y-4">
<input type="hidden" id="editJalanId">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Nama Jalan</label>
<input id="editJalanName" type="text" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-100 transition shadow-sm">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Kategori Status</label>
<select id="editJalanStatus" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-2 focus:ring-indigo-100 transition shadow-sm">
<option value="Jalan Nasional">Jalan Nasional</option>
<option value="Jalan Provinsi">Jalan Provinsi</option>
<option value="Jalan Kabupaten">Jalan Kabupaten</option>
</select>
</div>
<div>
<label class="block text-xs font-bold uppercase tracking-wider text-slate-400 mb-1.5">Panjang Ruas</label>
<input id="editJalanLength" type="text" readonly class="w-full bg-slate-100 border border-slate-200 rounded-xl px-4 py-2.5 text-sm text-slate-500 outline-none">
</div>
</div>
</div>
<div class="p-5 border-t border-slate-100 bg-slate-50/50 flex gap-2">
<button onclick="updateJalanAttr()" class="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm py-2.5 px-4 rounded-xl shadow-lg transition">Simpan</button>
<button onclick="deleteJalanDirect()" class="bg-red-50 hover:bg-red-100 text-red-600 font-semibold text-sm py-2.5 px-4 rounded-xl transition">Hapus</button>
<button onclick="closeEditJalanModal()" class="bg-slate-200 hover:bg-slate-300 text-slate-700 font-semibold text-sm py-2.5 px-4 rounded-xl transition">Batal</button>
</div>
</div>
</div>
<!-- Leaflet & Turf JS Libraries -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="https://unpkg.com/@turf/turf@6.5.0/turf.min.js"></script>
<script>
// Init Map Pontianak
const map = L.map('map', { zoomControl: false }).setView([-0.0263, 109.3425], 13);
L.control.zoom({ position: 'topright' }).addTo(map);
// Tile Layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
// Layers Group
const drawnRoadsGroup = L.featureGroup().addTo(map);
const damagedRoadsGroup = L.featureGroup().addTo(map);
const externalRoadsGroup = L.featureGroup(); // toggled manually
// Draw control
const drawControl = new L.Control.Draw({
draw: {
polyline: {
shapeOptions: {
color: '#6366f1',
weight: 4
}
},
marker: {
icon: L.icon({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41]
})
},
polygon: false,
circle: false,
circlemarker: false,
rectangle: false
},
edit: {
featureGroup: drawnRoadsGroup,
remove: true
}
});
map.addControl(drawControl);
let activeTab = 'jalan';
let roadRecords = [];
let damageRecords = [];
let pendingLayer = null;
let pendingDistance = 0;
let mapRoadLayers = {};
let mapDamageLayers = {};
let pendingDamageLatLng = null;
// Styles color based on category
const ROAD_COLORS = {
'Jalan Nasional': '#16a34a', // green-600
'Jalan Provinsi': '#f59e0b', // amber-500
'Jalan Kabupaten': '#dc2626' // red-600
};
const DAMAGE_SEVERITY_COLORS = {
'ringan': '#eab308', // yellow
'sedang': '#f97316', // orange
'berat': '#ef4444' // red
};
// Load data
async function loadJalanData() {
try {
// Fetch drawn roads
const resJalan = await fetch('api/get_features.php');
roadRecords = await resJalan.json();
renderRoadList(roadRecords);
displayRoadsOnMap(roadRecords);
// Fetch damaged roads
const resDamage = await fetch('api/get_jalan_rusak.php');
damageRecords = await resDamage.json();
renderDamageList(damageRecords);
displayDamageOnMap(damageRecords);
} catch (err) {
console.error(err);
showToast('Gagal memuat data jalan.', 'error');
}
}
// Helper to parse geometry coordinates from GeoJSON or raw format
function parseGeometryToCoords(geom, type) {
if (!geom) return null;
if (typeof geom === 'object' && geom.type && geom.coordinates) {
if (geom.type === 'Polygon' || geom.type === 'MultiPolygon') {
const rings = geom.coordinates;
return rings.map(ring => ring.map(coord => [coord[1], coord[0]]));
} else if (geom.type === 'LineString' || geom.type === 'MultiLineString') {
const coords = geom.coordinates;
if (geom.type === 'LineString') {
return coords.map(coord => [coord[1], coord[0]]);
} else {
return coords.map(line => line.map(coord => [coord[1], coord[0]]));
}
}
}
return geom;
}
// Display drawn roads on Map
function displayRoadsOnMap(items) {
drawnRoadsGroup.clearLayers();
mapRoadLayers = {};
items.forEach(item => {
if (!item.geometry_data) return;
const coords = parseGeometryToCoords(item.geometry_data, 'polyline');
if (!coords) return;
const color = ROAD_COLORS[item.status_objek] || '#4f46e5';
const layer = L.polyline(coords, {
color: color,
weight: 4,
opacity: 0.85
}).addTo(drawnRoadsGroup);
layer.on('click', (e) => {
L.DomEvent.stopPropagation(e);
openEditJalanModal(item);
});
layer.bindTooltip(`<b>${item.nama_objek}</b><br>${item.status_objek}<br>Panjang: ${Math.round(item.nilai_ukur).toLocaleString('id-ID')} m`, {
sticky: true,
className: 'text-xs rounded-lg'
});
mapRoadLayers[item.id] = layer;
});
}
// Display damaged roads on Map
function displayDamageOnMap(items) {
damagedRoadsGroup.clearLayers();
mapDamageLayers = {};
items.forEach(item => {
const color = DAMAGE_SEVERITY_COLORS[item.severity] || '#ef4444';
// Create custom divicon for severity
const iconHtml = `
<div class="relative w-6 h-6 flex items-center justify-center rounded-full bg-white shadow-lg border border-slate-200 transition transform hover:scale-110">
<div class="w-3.5 h-3.5 rounded-full flex items-center justify-center" style="background-color: ${color}">
<div class="w-1.5 h-1.5 bg-white rounded-full"></div>
</div>
</div>`;
const icon = L.divIcon({
html: iconHtml,
className: 'spbu-marker-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const marker = L.marker([item.latitude, item.longitude], { icon: icon }).addTo(damagedRoadsGroup);
// Popup Content
const imgPath = item.gambar ? `../../${item.gambar}` : null;
const imgTag = imgPath ? `<img src="${imgPath}" alt="Foto Kerusakan" class="w-full max-h-36 rounded-lg object-cover mt-2.5">` : '';
const popupContent = `
<div class="custom-popup">
<h4 class="font-bold text-slate-800 text-sm">Kerusakan: <span class="capitalize">${item.jenis_kerusakan}</span></h4>
<div class="mt-1 flex items-center gap-1.5">
<span class="text-[9px] font-extrabold uppercase tracking-wider px-2 py-0.5 rounded-full" style="background-color: ${color}20; color: ${color}">
Keparahan: ${item.severity}
</span>
</div>
<p class="text-xs text-slate-600 mt-2">${escapeHtml(item.deskripsi || 'Tidak ada deskripsi')}</p>
${imgTag}
<div class="mt-3 pt-2.5 border-t border-slate-100 flex justify-end">
<button onclick="deleteDamageDirect(${item.id})" class="text-xs text-red-600 font-bold hover:underline">Hapus Laporan</button>
</div>
</div>`;
marker.bindPopup(popupContent, { className: 'custom-popup-wrapper' });
mapDamageLayers[item.id] = marker;
});
}
// Render Road list
function renderRoadList(items) {
const list = document.getElementById('listJalan');
if (items.length === 0) {
list.innerHTML = `<div class="text-center py-8 text-slate-400 text-sm">Belum ada jaringan jalan yang digambar.</div>`;
return;
}
list.innerHTML = items.map(item => {
const color = ROAD_COLORS[item.status_objek] || '#4f46e5';
return `
<div onclick="focusRoad(${item.id})" class="p-4 bg-white border border-slate-200 rounded-2xl hover:border-indigo-500 cursor-pointer transition flex flex-col gap-1.5">
<div class="flex items-start justify-between gap-3">
<span class="font-bold text-slate-800 text-sm leading-tight">${escapeHtml(item.nama_objek)}</span>
<span class="text-[9px] font-extrabold uppercase tracking-wider text-white px-2 py-0.5 rounded-full" style="background-color: ${color}">
${item.status_objek.replace('Jalan ', '')}
</span>
</div>
<div class="flex items-center justify-between text-xs text-slate-500">
<span>Panjang: <strong>${Math.round(item.nilai_ukur)} m</strong></span>
</div>
</div>`;
}).join('');
}
// Render damage list
function renderDamageList(items) {
const list = document.getElementById('listRusak');
if (items.length === 0) {
list.innerHTML = `<div class="text-center py-8 text-slate-400 text-sm">Belum ada laporan jalan rusak.</div>`;
return;
}
list.innerHTML = items.map(item => {
const color = DAMAGE_SEVERITY_COLORS[item.severity] || '#ef4444';
return `
<div onclick="focusDamage(${item.id})" class="p-4 bg-white border border-slate-200 rounded-2xl hover:border-red-500 cursor-pointer transition flex flex-col gap-1.5">
<div class="flex items-start justify-between gap-3">
<span class="font-bold text-slate-800 text-sm leading-tight capitalize">${item.jenis_kerusakan}</span>
<span class="text-[9px] font-extrabold uppercase tracking-wider px-2 py-0.5 rounded-full" style="background-color: ${color}20; color: ${color}">
${item.severity}
</span>
</div>
<p class="text-xs text-slate-500 line-clamp-1">${escapeHtml(item.deskripsi || 'Tidak ada deskripsi')}</p>
</div>`;
}).join('');
}
// Focus & Zoom to road polyline
function focusRoad(id) {
const layer = mapRoadLayers[id];
if (layer) {
map.fitBounds(layer.getBounds(), { padding: [40, 40], maxZoom: 17 });
}
}
// Focus & Zoom to damage marker
function focusDamage(id) {
const marker = mapDamageLayers[id];
if (marker) {
map.setView(marker.getLatLng(), 17);
marker.openPopup();
}
}
// Leaflet Draw event handler
map.on(L.Draw.Event.CREATED, function (e) {
const layer = e.layer;
pendingLayer = layer;
if (layer instanceof L.Polyline && !(layer instanceof L.Polygon)) {
// It is a polyline (road)
const geo = layer.toGeoJSON();
pendingDistance = turf.length(geo, { units: 'meters' });
document.getElementById('newJalanName').value = '';
document.getElementById('newJalanStatus').value = 'Jalan Kabupaten';
document.getElementById('newJalanLength').value = `${Math.round(pendingDistance)} m`;
openNewJalanModal();
} else if (layer instanceof L.Marker) {
// It is a marker (road damage report)
pendingDamageLatLng = layer.getLatLng();
document.getElementById('damageDesc').value = '';
document.getElementById('damageImage').value = '';
document.getElementById('damageCoords').textContent = `${pendingDamageLatLng.lat.toFixed(6)}, ${pendingDamageLatLng.lng.toFixed(6)}`;
openNewDamageModal();
}
});
// Edit polyline geometry
map.on(L.Draw.Event.EDITED, function (e) {
const layers = e.layers;
layers.eachLayer(async function (layer) {
let dbId = null;
for (let id in mapRoadLayers) {
if (mapRoadLayers[id] === layer) {
dbId = id;
break;
}
}
if (dbId) {
const geo = layer.toGeoJSON();
const distance = turf.length(geo, { units: 'meters' });
const geomString = JSON.stringify(geo.geometry);
const formData = new FormData();
formData.append('id', dbId);
formData.append('geometry', geomString);
formData.append('nilai', distance);
try {
const res = await fetch('api/update_feature.php', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
showToast('Geometri jalan berhasil diperbarui.', 'success');
loadJalanData();
} else {
throw new Error(data.message);
}
} catch (err) {
console.error(err);
showToast('Gagal memperbarui geometri jalan.', 'error');
loadJalanData();
}
}
});
});
// Delete geometry via draw tools
map.on(L.Draw.Event.DELETED, function (e) {
const layers = e.layers;
layers.eachLayer(async function (layer) {
let dbId = null;
for (let id in mapRoadLayers) {
if (mapRoadLayers[id] === layer) {
dbId = id;
break;
}
}
if (dbId) {
const formData = new FormData();
formData.append('id', dbId);
try {
await fetch('api/delete_feature.php', { method: 'POST', body: formData });
} catch (err) {
console.error(err);
}
}
});
setTimeout(loadJalanData, 500);
});
// Save new road
async function saveNewJalan() {
const name = document.getElementById('newJalanName').value.trim();
const status = document.getElementById('newJalanStatus').value;
if (!name) {
showToast('Nama jalan wajib diisi.', 'error');
return;
}
const geo = pendingLayer.toGeoJSON();
const geomString = JSON.stringify(geo.geometry);
const formData = new FormData();
formData.append('nama', name);
formData.append('status', status);
formData.append('type', 'polyline');
formData.append('geometry', geomString);
formData.append('nilai', pendingDistance);
try {
const res = await fetch('api/save_feature.php', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
showToast('Jalan berhasil disimpan.', 'success');
closeNewJalanModal();
loadJalanData();
} else {
throw new Error(data.message);
}
} catch (err) {
showToast(err.message || 'Gagal menyimpan jalan.', 'error');
}
}
// Save road damage report
async function saveNewDamage() {
const type = document.getElementById('damageType').value;
const severity = document.getElementById('damageSeverity').value;
const desc = document.getElementById('damageDesc').value.trim();
const imgFile = document.getElementById('damageImage').files[0];
const formData = new FormData();
formData.append('latitude', pendingDamageLatLng.lat);
formData.append('longitude', pendingDamageLatLng.lng);
formData.append('jenis_kerusakan', type);
formData.append('deskripsi', desc);
formData.append('severity', severity);
if (imgFile) {
formData.append('gambar', imgFile);
}
try {
const res = await fetch('api/save_jalan_rusak.php', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
showToast('Laporan jalan rusak berhasil dikirim.', 'success');
closeNewDamageModal();
loadJalanData();
} else {
throw new Error(data.message);
}
} catch (err) {
showToast(err.message || 'Gagal mengirim laporan.', 'error');
}
}
// Open Edit road attributes modal
function openEditJalanModal(record) {
document.getElementById('editJalanId').value = record.id;
document.getElementById('editJalanName').value = record.nama_objek;
document.getElementById('editJalanStatus').value = record.status_objek;
document.getElementById('editJalanLength').value = `${Math.round(record.nilai_ukur)} m`;
const modal = document.getElementById('editJalanModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
setTimeout(() => {
modal.children[0].classList.remove('scale-95', 'opacity-0');
modal.children[0].classList.add('scale-100', 'opacity-100');
}, 10);
}
// Update road attributes
async function updateJalanAttr() {
const id = document.getElementById('editJalanId').value;
const name = document.getElementById('editJalanName').value.trim();
const status = document.getElementById('editJalanStatus').value;
if (!name) {
showToast('Nama jalan tidak boleh kosong.', 'error');
return;
}
const formData = new FormData();
formData.append('id', id);
formData.append('nama', name);
formData.append('status', status);
try {
const res = await fetch('api/update_feature.php', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
showToast('Atribut jalan berhasil disimpan.', 'success');
closeEditJalanModal();
loadJalanData();
} else {
throw new Error(data.message);
}
} catch (err) {
showToast(err.message || 'Gagal memperbarui jalan.', 'error');
}
}
// Delete road direct from edit modal
async function deleteJalanDirect() {
const id = document.getElementById('editJalanId').value;
if (!confirm('Hapus ruas jalan ini?')) return;
const formData = new FormData();
formData.append('id', id);
try {
const res = await fetch('api/delete_feature.php', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
showToast('Jalan berhasil dihapus.', 'success');
closeEditJalanModal();
loadJalanData();
} else {
throw new Error(data.message);
}
} catch (err) {
showToast(err.message || 'Gagal menghapus jalan.', 'error');
}
}
// Delete road damage report
window.deleteDamageDirect = async function(id) {
if (!confirm('Apakah Anda ingin menghapus laporan kerusakan ini?')) return;
const formData = new FormData();
formData.append('id', id);
try {
const res = await fetch('api/delete_jalan_rusak.php', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
showToast('Laporan kerusakan dihapus.', 'success');
map.closePopup();
loadJalanData();
} else {
throw new Error(data.message);
}
} catch (err) {
showToast(err.message || 'Gagal menghapus laporan.', 'error');
}
}
// Toggle visibility of layers based on checkbox inputs
document.getElementById('toggleDrawnRoads').addEventListener('change', function(e) {
if (e.target.checked) {
map.addLayer(drawnRoadsGroup);
} else {
map.removeLayer(drawnRoadsGroup);
}
});
document.getElementById('toggleDamagedRoads').addEventListener('change', function(e) {
if (e.target.checked) {
map.addLayer(damagedRoadsGroup);
} else {
map.removeLayer(damagedRoadsGroup);
}
});
// Load external roads network from geojson file
let externalRoadsLoaded = false;
document.getElementById('toggleExternalRoads').addEventListener('change', async function(e) {
if (e.target.checked) {
if (!externalRoadsLoaded) {
try {
const res = await fetch('../../data/Export_Output_2.json');
const data = await res.json();
L.geoJSON(data, {
style: {
color: '#a855f7',
weight: 2.5,
opacity: 0.6
}
}).addTo(externalRoadsGroup);
externalRoadsLoaded = true;
} catch (err) {
console.error('Gagal memuat jalan eksternal:', err);
showToast('File data jalan eksternal tidak ditemukan.', 'error');
e.target.checked = false;
return;
}
}
map.addLayer(externalRoadsGroup);
} else {
map.removeLayer(externalRoadsGroup);
}
});
// Sidebar search filter
document.getElementById('searchQuery').addEventListener('input', function(e) {
const query = e.target.value.toLowerCase();
if (activeTab === 'jalan') {
const filtered = roadRecords.filter(rec => rec.nama_objek.toLowerCase().includes(query) || rec.status_objek.toLowerCase().includes(query));
renderRoadList(filtered);
} else {
const filtered = damageRecords.filter(rec => rec.jenis_kerusakan.toLowerCase().includes(query) || rec.deskripsi.toLowerCase().includes(query));
renderDamageList(filtered);
}
});
// Tabs management
function switchTab(tab) {
activeTab = tab;
const tabJalan = document.getElementById('tabJalan');
const tabRusak = document.getElementById('tabRusak');
const listJalan = document.getElementById('listJalan');
const listRusak = document.getElementById('listRusak');
const tipsText = document.getElementById('tipsText');
if (tab === 'jalan') {
tabJalan.className = "flex-1 py-3 text-center text-sm font-bold border-b-2 border-indigo-600 text-indigo-600 transition";
tabRusak.className = "flex-1 py-3 text-center text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-700 transition";
listJalan.classList.remove('hidden');
listRusak.classList.add('hidden');
tipsText.innerHTML = "Gunakan tool <b>Draw Polyline</b> di sisi kanan peta untuk menambahkan ruas jalan baru dan menghitung panjangnya.";
} else {
tabRusak.className = "flex-1 py-3 text-center text-sm font-bold border-b-2 border-indigo-600 text-indigo-600 transition";
tabJalan.className = "flex-1 py-3 text-center text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-700 transition";
listJalan.classList.add('hidden');
listRusak.classList.remove('hidden');
tipsText.innerHTML = "Gunakan tool <b>Draw Marker</b> di sisi kanan peta, lalu letakkan di titik jalan rusak untuk mengisi detail laporan.";
}
}
// Modals management helpers
function openNewJalanModal() {
const modal = document.getElementById('newJalanModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
setTimeout(() => {
modal.children[0].classList.remove('scale-95', 'opacity-0');
modal.children[0].classList.add('scale-100', 'opacity-100');
}, 10);
}
function closeNewJalanModal() {
const modal = document.getElementById('newJalanModal');
modal.children[0].classList.remove('scale-100', 'opacity-100');
modal.children[0].classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
if (pendingLayer) {
map.removeLayer(pendingLayer);
pendingLayer = null;
}
}, 150);
}
function openNewDamageModal() {
const modal = document.getElementById('newDamageModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
setTimeout(() => {
modal.children[0].classList.remove('scale-95', 'opacity-0');
modal.children[0].classList.add('scale-100', 'opacity-100');
}, 10);
}
function closeNewDamageModal() {
const modal = document.getElementById('newDamageModal');
modal.children[0].classList.remove('scale-100', 'opacity-100');
modal.children[0].classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
if (pendingLayer) {
map.removeLayer(pendingLayer);
pendingLayer = null;
}
}, 150);
}
function closeEditJalanModal() {
const modal = document.getElementById('editJalanModal');
modal.children[0].classList.remove('scale-100', 'opacity-100');
modal.children[0].classList.add('scale-95', 'opacity-0');
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
}, 150);
}
// Toast Helper
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
const msg = document.getElementById('toastMessage');
msg.textContent = message;
toast.classList.remove('hidden', 'bg-red-500', 'bg-emerald-500', 'bg-indigo-500');
toast.classList.add('flex');
if (type === 'error') {
toast.classList.add('bg-red-500');
} else if (type === 'success') {
toast.classList.add('bg-emerald-500');
} else {
toast.classList.add('bg-indigo-500');
}
setTimeout(() => {
toast.classList.remove('scale-95', 'opacity-0');
toast.classList.add('scale-100', 'opacity-100');
}, 10);
setTimeout(() => {
toast.classList.remove('scale-100', 'opacity-100');
toast.classList.add('scale-95', 'opacity-0');
setTimeout(() => {
toast.classList.add('hidden');
}, 150);
}, 3000);
}
// General helpers
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Startup load
loadJalanData();
</script>
</body>
</html>