Add WebGIS project documentation
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
:root {
|
||||
--primary: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--bg-light: #f3f4f6;
|
||||
--bg-white: #ffffff;
|
||||
--text-main: #1f2937;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
|
||||
--jalan-nasional: #ef4444; /* Red */
|
||||
--jalan-provinsi: #f97316; /* Orange */
|
||||
--jalan-kabupaten: #eab308; /* Yellow */
|
||||
--parsil-shm: #22c55e;
|
||||
--parsil-hgb: #3b82f6;
|
||||
--parsil-hgu: #a855f7;
|
||||
--parsil-hp: #06b6d4;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-light);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* NAVBAR */
|
||||
.navbar {
|
||||
background: var(--bg-white);
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--bg-light);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: #eef2ff;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* VIEW CONTROLLER */
|
||||
main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.view-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* VIEW 1: MAP INPUT */
|
||||
#view-input #map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.floating-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px 24px;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.floating-info i { color: var(--primary); }
|
||||
|
||||
.floating-legend {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
z-index: 999;
|
||||
font-size: 12px;
|
||||
min-width: 180px;
|
||||
}
|
||||
.floating-legend h4 { margin-bottom: 10px; color: var(--text-main); font-size: 13px;}
|
||||
.legend-grid div { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; color: var(--text-muted);}
|
||||
.legend-grid span { display: inline-block; width: 14px; height: 14px; border-radius: 3px; }
|
||||
|
||||
/* VIEW 2: DASHBOARD */
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.dashboard-header h2 { font-size: 24px; color: var(--text-main); margin-bottom: 5px; }
|
||||
.dashboard-header p { color: var(--text-muted); font-size: 14px; }
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-white);
|
||||
padding: 20px 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-info h3 { font-size: 14px; color: var(--text-muted); font-weight: 500; margin-bottom: 5px; }
|
||||
.stat-info h2 { font-size: 32px; color: var(--text-main); }
|
||||
.stat-icon { width: 50px; height: 50px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; }
|
||||
|
||||
.content-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Table Area */
|
||||
.table-card {
|
||||
background: var(--bg-white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-white);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: #eef2ff;
|
||||
color: var(--primary);
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
.search-box i {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.search-box input {
|
||||
padding: 8px 12px 8px 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
width: 250px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.search-box input:focus { border-color: var(--primary); }
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f9fafb;
|
||||
text-align: left;
|
||||
padding: 12px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 15px 20px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-main);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr:hover td { background-color: #f9fafb; }
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 50px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-sm-del {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.btn-sm-del:hover { background: #fecaca; }
|
||||
|
||||
/* Minimap Card */
|
||||
.minimap-card {
|
||||
background: var(--bg-white);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.minimap-header {
|
||||
padding: 15px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
#minimap {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.minimap-footer {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modals */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0; top: 0; width: 100%; height: 100%;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-white);
|
||||
border-radius: 12px;
|
||||
width: 420px;
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h3 { font-size: 18px; }
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.form-group input[readonly] {
|
||||
background: #f9fafb;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
@@ -0,0 +1,505 @@
|
||||
// Maps Initialization
|
||||
const map = L.map('map').setView([-0.060586, 109.344989], 16);
|
||||
L.tileLayer('https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
|
||||
attribution: '© Google Maps', maxZoom: 20
|
||||
}).addTo(map);
|
||||
|
||||
const minimap = L.map('minimap', { zoomControl: false }).setView([-0.060586, 109.344989], 14);
|
||||
L.tileLayer('https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
|
||||
attribution: '© Google Maps', maxZoom: 20
|
||||
}).addTo(minimap);
|
||||
|
||||
// Feature Groups
|
||||
const drawnItems = new L.FeatureGroup().addTo(map);
|
||||
const minimapLayer = L.featureGroup().addTo(minimap);
|
||||
|
||||
// Draw Control on Main Map
|
||||
const drawControl = new L.Control.Draw({
|
||||
draw: {
|
||||
marker: true, circle: false, circlemarker: false, rectangle: false,
|
||||
polyline: { shapeOptions: { color: '#ffffff', weight: 4 } },
|
||||
polygon: { allowIntersection: false, showArea: true, shapeOptions: { color: '#ffffff', weight: 2 } }
|
||||
},
|
||||
edit: { featureGroup: drawnItems, edit: true, remove: false }
|
||||
});
|
||||
map.addControl(drawControl);
|
||||
|
||||
// Configuration
|
||||
const JALAN_STATUS = ['Jalan Nasional', 'Jalan Provinsi', 'Jalan Kabupaten'];
|
||||
const PARSIL_STATUS = [
|
||||
{ value: 'SHM', label: 'Sertifikat Hak Milik (SHM)' },
|
||||
{ value: 'HGB', label: 'Sertifikat Hak Guna Bangunan (HGB)' },
|
||||
{ value: 'HGU', label: 'Sertifikat Hak Guna Usaha (HGU)' },
|
||||
{ value: 'HP', label: 'Sertifikat Hak Pakai (HP)' }
|
||||
];
|
||||
const COLORS = {
|
||||
'Jalan Nasional': '#ef4444', 'Jalan Provinsi': '#f97316', 'Jalan Kabupaten': '#eab308',
|
||||
'SHM': '#22c55e', 'HGB': '#3b82f6', 'HGU': '#a855f7', 'HP': '#06b6d4'
|
||||
};
|
||||
const BG_COLORS = {
|
||||
'Jalan Nasional': '#fef2f2', 'Jalan Provinsi': '#fff7ed', 'Jalan Kabupaten': '#fefce8',
|
||||
'SHM': '#f0fdf4', 'HGB': '#eff6ff', 'HGU': '#faf5ff', 'HP': '#ecfeff'
|
||||
};
|
||||
|
||||
const moveIcon = L.divIcon({
|
||||
html: '<div style="background:white; border:2px solid #333; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; cursor:grab; box-shadow:0 2px 4px rgba(0,0,0,0.3);"><i class="fa-solid fa-arrows-up-down-left-right" style="color:#333; font-size:12px;"></i></div>',
|
||||
className: 'custom-move-icon',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
|
||||
let currentTempLayer = null;
|
||||
let rawData = { jalan: [], parsil: [], titik: [] };
|
||||
let currentTableTab = 'jalan';
|
||||
|
||||
// View Navigation
|
||||
function switchView(view) {
|
||||
document.querySelectorAll('.nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(`btn-tab-${view}`).classList.add('active');
|
||||
|
||||
document.getElementById('view-input').classList.add('view-hidden');
|
||||
document.getElementById('view-data').classList.add('view-hidden');
|
||||
|
||||
document.getElementById(`view-${view}`).classList.remove('view-hidden');
|
||||
|
||||
// Invalidate map sizes because of display:none toggling
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
minimap.invalidateSize();
|
||||
if (view === 'data') renderTable();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Table Tab Navigation
|
||||
function switchTableTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.getElementById(`tab-${tab}`).classList.add('active');
|
||||
currentTableTab = tab;
|
||||
|
||||
document.getElementById('th-metric').innerText = tab === 'jalan' ? 'Panjang (m)' : (tab === 'titik' ? 'Foto' : 'Luas (m²)');
|
||||
document.getElementById('search-input').value = '';
|
||||
renderTable();
|
||||
}
|
||||
|
||||
// Table Search Event
|
||||
document.getElementById('search-input').addEventListener('input', renderTable);
|
||||
|
||||
// Map Draw Event
|
||||
map.on(L.Draw.Event.CREATED, function (e) {
|
||||
currentTempLayer = e.layer;
|
||||
const type = e.layerType;
|
||||
let geojson = currentTempLayer.toGeoJSON();
|
||||
|
||||
const formType = type === 'polyline' ? 'jalan' : (type === 'polygon' ? 'parsil' : 'titik');
|
||||
document.getElementById('form-type').value = formType;
|
||||
if (geojson.geometry) {
|
||||
document.getElementById('data-geojson').value = JSON.stringify(geojson.geometry);
|
||||
}
|
||||
|
||||
// Reset data
|
||||
document.getElementById('data-id').value = '';
|
||||
document.getElementById('data-nama').value = '';
|
||||
|
||||
// Setup Modal UI
|
||||
document.getElementById('modal-title').innerText = type === 'polyline' ? 'Input Data Jalan' : (type === 'marker' ? 'Laporkan Titik Kerusakan' : 'Input Data Parsil Tanah');
|
||||
|
||||
// Toggle Fields
|
||||
if (type === 'marker') {
|
||||
document.getElementById('data-status').parentElement.style.display = 'none';
|
||||
document.getElementById('field-foto').style.display = 'block';
|
||||
document.getElementById('field-metric').style.display = 'none';
|
||||
document.getElementById('label-nama').innerText = 'Keterangan Kerusakan';
|
||||
document.getElementById('data-status').removeAttribute('required');
|
||||
document.getElementById('data-metric').removeAttribute('required');
|
||||
document.getElementById('foto-preview').innerHTML = '';
|
||||
|
||||
} else {
|
||||
document.getElementById('data-status').parentElement.style.display = 'block';
|
||||
document.getElementById('field-foto').style.display = 'none';
|
||||
document.getElementById('field-metric').style.display = 'block';
|
||||
document.getElementById('label-nama').innerText = 'Nama';
|
||||
document.getElementById('data-status').setAttribute('required', 'true');
|
||||
document.getElementById('data-metric').setAttribute('required', 'true');
|
||||
|
||||
const statusSelect = document.getElementById('data-status');
|
||||
statusSelect.innerHTML = '';
|
||||
const options = type === 'polyline' ? JALAN_STATUS : PARSIL_STATUS;
|
||||
options.forEach(opt => {
|
||||
let val = opt.value || opt;
|
||||
let label = opt.label || opt;
|
||||
statusSelect.innerHTML += `<option value="${val}">${label}</option>`;
|
||||
});
|
||||
|
||||
if (type === 'polyline') {
|
||||
const lengthKm = turf.length(geojson, {units: 'meters'});
|
||||
document.getElementById('data-metric').value = lengthKm.toFixed(2);
|
||||
} else {
|
||||
const areaSqMeters = turf.area(geojson);
|
||||
document.getElementById('data-metric').value = areaSqMeters.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('modal-form').style.display = 'flex';
|
||||
});
|
||||
|
||||
// Handle Geometry Drag/Vertex Edit Saves
|
||||
async function saveGeomToDB(layer) {
|
||||
if (!layer.dbId) return;
|
||||
const geojson = layer.toGeoJSON();
|
||||
const payload = {
|
||||
id: layer.dbId,
|
||||
status: layer.dbItem.status,
|
||||
geojson: JSON.stringify(geojson.geometry)
|
||||
};
|
||||
|
||||
if (layer.dbType === 'jalan') {
|
||||
payload.nama_jalan = layer.dbItem.nama_jalan;
|
||||
payload.panjang = turf.length(geojson, {units: 'meters'}).toFixed(2);
|
||||
} else {
|
||||
payload.nama_pemilik = layer.dbItem.nama_pemilik;
|
||||
payload.luas = turf.area(geojson).toFixed(2);
|
||||
}
|
||||
await fetch(`api/${layer.dbType}.php`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
map.on(L.Draw.Event.EDITED, function (e) {
|
||||
const layers = e.layers;
|
||||
layers.eachLayer(saveGeomToDB);
|
||||
setTimeout(loadData, 500);
|
||||
});
|
||||
|
||||
// Custom Center Drag Handles for Total Shape Movement
|
||||
let dragHandles = [];
|
||||
|
||||
map.on('draw:editstart', function() {
|
||||
drawnItems.eachLayer(layer => {
|
||||
if (!layer.getBounds) return; // Skip if no bounds
|
||||
let center = layer.getBounds().getCenter();
|
||||
let handle = L.marker(center, { icon: moveIcon, draggable: true, zIndexOffset: 2000 }).addTo(map);
|
||||
|
||||
let startLatLng, initialLatLngs;
|
||||
|
||||
handle.on('dragstart', () => {
|
||||
startLatLng = handle.getLatLng();
|
||||
initialLatLngs = layer.getLatLngs();
|
||||
});
|
||||
|
||||
handle.on('drag', (e) => {
|
||||
let currentLatLng = handle.getLatLng();
|
||||
let dLat = currentLatLng.lat - startLatLng.lat;
|
||||
let dLng = currentLatLng.lng - startLatLng.lng;
|
||||
|
||||
function shift(ll) {
|
||||
if (Array.isArray(ll)) return ll.map(shift);
|
||||
return new L.LatLng(ll.lat + dLat, ll.lng + dLng);
|
||||
}
|
||||
layer.setLatLngs(shift(initialLatLngs));
|
||||
});
|
||||
|
||||
handle.on('dragend', () => {
|
||||
saveGeomToDB(layer); // Instantly save DB on drop
|
||||
});
|
||||
|
||||
dragHandles.push(handle);
|
||||
});
|
||||
});
|
||||
|
||||
map.on('draw:editstop', function() {
|
||||
dragHandles.forEach(h => map.removeLayer(h));
|
||||
dragHandles = [];
|
||||
setTimeout(loadData, 500);
|
||||
});
|
||||
|
||||
// Load and Render Logic
|
||||
async function loadData() {
|
||||
drawnItems.clearLayers();
|
||||
minimapLayer.clearLayers();
|
||||
|
||||
let resJalan = await fetch('api/jalan.php');
|
||||
rawData.jalan = await resJalan.json();
|
||||
rawData.jalan.forEach(item => renderToMap(item, 'jalan'));
|
||||
|
||||
let resParsil = await fetch('api/parsil.php');
|
||||
rawData.parsil = await resParsil.json();
|
||||
rawData.parsil.forEach(item => renderToMap(item, 'parsil'));
|
||||
|
||||
let resTitik = await fetch('api/titik.php');
|
||||
rawData.titik = await resTitik.json();
|
||||
rawData.titik.forEach(item => renderToMap(item, 'titik'));
|
||||
|
||||
// Update Dashboard Stats
|
||||
document.getElementById('stat-jalan').innerText = rawData.jalan.length;
|
||||
document.getElementById('stat-parsil').innerText = rawData.parsil.length;
|
||||
document.getElementById('stat-titik').innerText = rawData.titik.length;
|
||||
|
||||
if (!document.getElementById('view-data').classList.contains('view-hidden')) {
|
||||
renderTable();
|
||||
}
|
||||
}
|
||||
|
||||
function renderToMap(item, type) {
|
||||
try {
|
||||
const geom = JSON.parse(item.geojson);
|
||||
let displayStatus = item.status;
|
||||
if (type === 'parsil') {
|
||||
const found = PARSIL_STATUS.find(p => p.value === item.status);
|
||||
if (found) displayStatus = found.label;
|
||||
}
|
||||
|
||||
let style = {};
|
||||
if (type === 'jalan') {
|
||||
style = { color: COLORS[item.status] || '#333', weight: 4 };
|
||||
} else if (type === 'parsil') {
|
||||
style = { color: COLORS[item.status] || '#333', weight: 2, fillColor: COLORS[item.status] || '#333', fillOpacity: 0.5 };
|
||||
}
|
||||
|
||||
const layer = L.geoJSON(geom, { style }).getLayers()[0];
|
||||
|
||||
// Internal state for DB update on drag
|
||||
layer.dbId = item.id;
|
||||
layer.dbType = type;
|
||||
layer.dbItem = item;
|
||||
|
||||
// Click to open edit modal
|
||||
layer.on('click', () => {
|
||||
// Ignore click if Leaflet.Draw edit mode is active (pencil toolbar)
|
||||
if (layer.editing && layer.editing.enabled()) return;
|
||||
|
||||
currentTempLayer = null;
|
||||
document.getElementById('form-type').value = type;
|
||||
document.getElementById('data-id').value = item.id;
|
||||
document.getElementById('data-geojson').value = item.geojson;
|
||||
|
||||
document.getElementById('modal-title').innerText = type === 'jalan' ? 'Edit Data Jalan' : 'Edit Data Parsil Tanah';
|
||||
|
||||
// Toggle Fields
|
||||
if (type === 'titik') {
|
||||
document.getElementById('data-status').parentElement.style.display = 'none';
|
||||
document.getElementById('field-foto').style.display = 'block';
|
||||
document.getElementById('field-metric').style.display = 'none';
|
||||
document.getElementById('label-nama').innerText = 'Keterangan Kerusakan';
|
||||
document.getElementById('data-status').removeAttribute('required');
|
||||
document.getElementById('data-metric').removeAttribute('required');
|
||||
|
||||
document.getElementById('data-existing-foto').value = item.foto_url;
|
||||
document.getElementById('foto-preview').innerHTML = `<img src="${item.foto_url}" style="max-height:150px; border-radius:5px;">`;
|
||||
} else {
|
||||
document.getElementById('data-status').parentElement.style.display = 'block';
|
||||
document.getElementById('field-foto').style.display = 'none';
|
||||
document.getElementById('field-metric').style.display = 'block';
|
||||
document.getElementById('label-nama').innerText = 'Nama';
|
||||
|
||||
const statusSelect = document.getElementById('data-status');
|
||||
statusSelect.innerHTML = '';
|
||||
const options = type === 'jalan' ? JALAN_STATUS : PARSIL_STATUS;
|
||||
options.forEach(opt => {
|
||||
let val = opt.value || opt;
|
||||
let label = opt.label || opt;
|
||||
statusSelect.innerHTML += `<option value="${val}">${label}</option>`;
|
||||
});
|
||||
statusSelect.value = item.status;
|
||||
}
|
||||
|
||||
document.getElementById('data-nama').value = type === 'titik' ? item.keterangan : (type === 'jalan' ? item.nama_jalan : item.nama_pemilik);
|
||||
if (type !== 'titik') document.getElementById('data-metric').value = type === 'jalan' ? item.panjang : item.luas;
|
||||
|
||||
document.getElementById('btn-delete').style.display = 'block';
|
||||
document.getElementById('modal-form').style.display = 'flex';
|
||||
});
|
||||
|
||||
if (type === 'jalan') {
|
||||
layer.bindTooltip(`<b>${item.nama_jalan}</b><br>${displayStatus}<br>${item.panjang} m`);
|
||||
} else if (type === 'parsil') {
|
||||
layer.bindTooltip(`<b>${item.nama_pemilik}</b><br>${displayStatus}<br>${item.luas} m²`);
|
||||
} else {
|
||||
layer.bindTooltip(`<div style="text-align:center;"><b>${item.keterangan}</b><br><img src="${item.foto_url}" style="width:120px; height:auto; margin-top:5px; border-radius:5px;"><br>Laporkan: Titik Rusak</div>`, {direction: 'top'});
|
||||
}
|
||||
drawnItems.addLayer(layer);
|
||||
|
||||
} catch (e) {
|
||||
console.error("GeoJSON parsing err", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Table Rendering
|
||||
function renderTable() {
|
||||
const list = rawData[currentTableTab];
|
||||
const tbody = document.getElementById('table-body');
|
||||
const search = document.getElementById('search-input').value.toLowerCase();
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
let filteredList = list.filter(item => {
|
||||
const name = currentTableTab === 'titik' ? item.keterangan : (currentTableTab === 'jalan' ? item.nama_jalan : item.nama_pemilik);
|
||||
return name.toLowerCase().includes(search);
|
||||
});
|
||||
|
||||
if (filteredList.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:#9ca3af;">Data kosong atau tidak ditemukan.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
filteredList.forEach((item, index) => {
|
||||
const name = currentTableTab === 'titik' ? item.keterangan : (currentTableTab === 'jalan' ? item.nama_jalan : item.nama_pemilik);
|
||||
const metric = currentTableTab === 'titik' ? `<img src="${item.foto_url}" style="height:30px; border-radius:3px;">` : (currentTableTab === 'jalan' ? item.panjang : item.luas);
|
||||
const color = currentTableTab === 'titik' ? '#ef4444' : COLORS[item.status];
|
||||
const bg = currentTableTab === 'titik' ? '#fef2f2' : BG_COLORS[item.status];
|
||||
|
||||
let displayStatus = currentTableTab === 'titik' ? 'Titik Rusak' : item.status;
|
||||
if (currentTableTab === 'parsil') {
|
||||
const found = PARSIL_STATUS.find(p => p.value === item.status);
|
||||
if (found) displayStatus = found.label;
|
||||
}
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.onclick = () => showOnMinimap(item, currentTableTab);
|
||||
tr.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td style="font-weight:500;">${name}</td>
|
||||
<td><span class="status-badge" style="color:${color}; background:${bg};">${displayStatus}</span></td>
|
||||
<td>${metric}</td>
|
||||
<td>
|
||||
<button class="btn-sm-del" onclick="event.stopPropagation(); deleteData('${currentTableTab}', ${item.id})">
|
||||
<i class="fa-solid fa-trash"></i> Hapus
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function showOnMinimap(item, type) {
|
||||
minimapLayer.clearLayers();
|
||||
try {
|
||||
const geom = JSON.parse(item.geojson);
|
||||
const style = type === 'jalan'
|
||||
? { color: COLORS[item.status] || '#333', weight: 4 }
|
||||
: { color: COLORS[item.status] || '#333', weight: 2, fillColor: COLORS[item.status] || '#333', fillOpacity: 0.5 };
|
||||
|
||||
const layer = L.geoJSON(geom, { style });
|
||||
minimapLayer.addLayer(layer);
|
||||
minimap.fitBounds(layer.getBounds(), { padding: [20, 20], maxZoom: 17 });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Form Submission
|
||||
document.getElementById('main-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const type = document.getElementById('form-type').value;
|
||||
const dataId = document.getElementById('data-id').value;
|
||||
|
||||
if (type === 'titik') {
|
||||
const fd = new FormData();
|
||||
if (dataId) fd.append('id', dataId);
|
||||
fd.append('keterangan', document.getElementById('data-nama').value);
|
||||
fd.append('geojson', document.getElementById('data-geojson').value);
|
||||
fd.append('existing_foto', document.getElementById('data-existing-foto').value);
|
||||
|
||||
const photoFile = document.getElementById('data-foto').files[0];
|
||||
if (photoFile) fd.append('foto', photoFile);
|
||||
|
||||
await fetch(`api/titik.php`, { method: 'POST', body: fd });
|
||||
} else {
|
||||
let payload = {
|
||||
status: document.getElementById('data-status').value,
|
||||
geojson: document.getElementById('data-geojson').value
|
||||
};
|
||||
|
||||
if (type === 'jalan') {
|
||||
payload.nama_jalan = document.getElementById('data-nama').value;
|
||||
payload.panjang = document.getElementById('data-metric').value;
|
||||
} else {
|
||||
payload.nama_pemilik = document.getElementById('data-nama').value;
|
||||
payload.luas = document.getElementById('data-metric').value;
|
||||
}
|
||||
|
||||
const method = dataId ? 'PUT' : 'POST';
|
||||
if (dataId) payload.id = dataId;
|
||||
|
||||
await fetch(`api/${type}.php`, { method: method, body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
closeModal();
|
||||
loadData();
|
||||
});
|
||||
|
||||
function deleteData(type, id) {
|
||||
if (!confirm('Hapus data ini dari sistem?')) return;
|
||||
fetch(`api/${type}.php`, { method: 'DELETE', body: JSON.stringify({ id }) }).then(() => loadData());
|
||||
}
|
||||
|
||||
function deleteFromModal() {
|
||||
const type = document.getElementById('form-type').value;
|
||||
const id = document.getElementById('data-id').value;
|
||||
if (!id) return;
|
||||
deleteData(type, id);
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-form').style.display = 'none';
|
||||
document.getElementById('main-form').reset();
|
||||
document.getElementById('data-id').value = '';
|
||||
document.getElementById('btn-delete').style.display = 'none';
|
||||
if (currentTempLayer) {
|
||||
map.removeLayer(currentTempLayer);
|
||||
currentTempLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// EXIF Auto Extractor
|
||||
document.getElementById('auto-upload-exif').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
EXIF.getData(file, function() {
|
||||
const latRef = EXIF.getTag(this, "GPSLatitudeRef");
|
||||
const lat = EXIF.getTag(this, "GPSLatitude");
|
||||
const lngRef = EXIF.getTag(this, "GPSLongitudeRef");
|
||||
const lng = EXIF.getTag(this, "GPSLongitude");
|
||||
|
||||
if (lat && lng && latRef && lngRef) {
|
||||
const getDec = (rational) => rational.numerator / rational.denominator;
|
||||
const decimalLat = (getDec(lat[0]) + getDec(lat[1])/60 + getDec(lat[2])/3600) * (latRef === "S" ? -1 : 1);
|
||||
const decimalLng = (getDec(lng[0]) + getDec(lng[1])/60 + getDec(lng[2])/3600) * (lngRef === "W" ? -1 : 1);
|
||||
|
||||
const geojsonPoint = { type: "Point", coordinates: [decimalLng, decimalLat] };
|
||||
|
||||
currentTempLayer = L.marker([decimalLat, decimalLng]);
|
||||
drawnItems.addLayer(currentTempLayer);
|
||||
map.flyTo([decimalLat, decimalLng], 18);
|
||||
|
||||
document.getElementById('form-type').value = 'titik';
|
||||
document.getElementById('data-geojson').value = JSON.stringify(geojsonPoint);
|
||||
document.getElementById('data-id').value = '';
|
||||
document.getElementById('data-nama').value = '';
|
||||
document.getElementById('modal-title').innerText = 'Laporan Kerusakan via GPS Kamera';
|
||||
|
||||
document.getElementById('data-status').parentElement.style.display = 'none';
|
||||
document.getElementById('field-foto').style.display = 'block';
|
||||
document.getElementById('field-metric').style.display = 'none';
|
||||
document.getElementById('label-nama').innerText = 'Keterangan Kerusakan';
|
||||
document.getElementById('data-status').removeAttribute('required');
|
||||
document.getElementById('data-metric').removeAttribute('required');
|
||||
|
||||
// Transfer file silently to form
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
document.getElementById('data-foto').files = dt.files;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(evt) {
|
||||
document.getElementById('foto-preview').innerHTML = `<img src="${evt.target.result}" style="max-height:150px; border-radius:5px;">`;
|
||||
}
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
document.getElementById('modal-form').style.display = 'flex';
|
||||
} else {
|
||||
alert('Gagal mendeteksi koordinat: Gambar ini tidak memiliki meta GPS (EXIF). Silakan masukan Pin secara manual di peta.');
|
||||
document.getElementById('auto-upload-exif').value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Init
|
||||
loadData();
|
||||
Reference in New Issue
Block a user