1569 lines
43 KiB
JavaScript
1569 lines
43 KiB
JavaScript
/* ============================================================
|
||
WebGIS — Main Application JavaScript
|
||
Features: Points, Roads (Lines), Parcels (Polygons)
|
||
Leaflet.js + Leaflet.draw + Full CRUD via PHP API
|
||
============================================================ */
|
||
|
||
/* ── API base path ─────────────────────────────────────────── */
|
||
const API = {
|
||
points: 'api/points.php',
|
||
roads: 'api/roads.php',
|
||
parcels: 'api/parcels.php',
|
||
laporan: 'api/laporan.php',
|
||
bantuan:'api/bantuan.php'
|
||
};
|
||
|
||
/* ── Color/style maps ──────────────────────────────────────── */
|
||
const ROAD_STYLES = {
|
||
national: { color: '#ef4444', weight: 4, dashArray: null },
|
||
provincial: { color: '#f97316', weight: 3, dashArray: '6,4' },
|
||
district: { color: '#3b82f6', weight: 2, dashArray: '4,3' },
|
||
};
|
||
|
||
const PARCEL_STYLES = {
|
||
SHM: { color: '#10b981', fillColor: '#10b981', fillOpacity: 0.25, weight: 2 },
|
||
HGB: { color: '#6366f1', fillColor: '#6366f1', fillOpacity: 0.25, weight: 2 },
|
||
HGU: { color: '#f59e0b', fillColor: '#f59e0b', fillOpacity: 0.25, weight: 2 },
|
||
HP: { color: '#ec4899', fillColor: '#ec4899', fillOpacity: 0.25, weight: 2 },
|
||
};
|
||
|
||
const POINT_COLORS = {
|
||
'spbu-24hours': '#22c55e',
|
||
'spbu-not24hours':'#eab308',
|
||
mosque: '#a78bfa',
|
||
poor: '#f43f5e',
|
||
};
|
||
|
||
/* ===== RADIUS ANALYSIS ===== */
|
||
let poorMarkers = [];
|
||
let activeCircle = null;
|
||
let selectedCenter = null;
|
||
let radius = 500;
|
||
|
||
/* ── State ─────────────────────────────────────────────────── */
|
||
const state = {
|
||
currentTab: 'layers',
|
||
drawMode: null, // 'point', 'road', 'parcel'
|
||
editingId: null,
|
||
pendingLatLng: null,
|
||
counts: { points: 0, roads: 0, parcels: 0 },
|
||
|
||
};
|
||
|
||
/* ── Layer Groups ──────────────────────────────────────────── */
|
||
const layers = {
|
||
spbu24: L.layerGroup(),
|
||
spbuNot: L.layerGroup(),
|
||
mosque: L.layerGroup(),
|
||
poor: L.layerGroup(),
|
||
national: L.layerGroup(),
|
||
provincial: L.layerGroup(),
|
||
district: L.layerGroup(),
|
||
shm: L.layerGroup(),
|
||
hgb: L.layerGroup(),
|
||
hgu: L.layerGroup(),
|
||
hp: L.layerGroup(),
|
||
};
|
||
|
||
/* Store references: id → Leaflet layer */
|
||
const layerRefs = {
|
||
points: {},
|
||
roads: {},
|
||
parcels: {},
|
||
};
|
||
|
||
/* ── Map Init ──────────────────────────────────────────────── */
|
||
const map = L.map('map', {
|
||
center: [-0.0457391, 109.3394621],
|
||
zoom: 13,
|
||
zoomControl: false,
|
||
});
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a>',
|
||
maxZoom: 19,
|
||
}).addTo(map);
|
||
|
||
// Add all layer groups to map
|
||
Object.values(layers).forEach(lg => lg.addTo(map));
|
||
|
||
// Zoom control bottom-right
|
||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||
|
||
/* ── Leaflet Draw Setup ─────────────────────────────────────── */
|
||
const drawnItems = new L.FeatureGroup();
|
||
map.addLayer(drawnItems);
|
||
|
||
// We create draw controls dynamically to use only what's needed
|
||
let activeDrawHandler = null;
|
||
|
||
function startDraw(type) {
|
||
// Cancel any active draw
|
||
if (activeDrawHandler) {
|
||
activeDrawHandler.disable();
|
||
activeDrawHandler = null;
|
||
}
|
||
|
||
if (type === 'road') {
|
||
activeDrawHandler = new L.Draw.Polyline(map, {
|
||
shapeOptions: { color: '#4ade80', weight: 3 },
|
||
});
|
||
} else if (type === 'parcel') {
|
||
activeDrawHandler = new L.Draw.Polygon(map, {
|
||
shapeOptions: { color: '#38bdf8', weight: 2, fillOpacity: 0.2 },
|
||
});
|
||
}
|
||
|
||
if (activeDrawHandler) activeDrawHandler.enable();
|
||
}
|
||
|
||
function stopDraw() {
|
||
if (activeDrawHandler) {
|
||
activeDrawHandler.disable();
|
||
activeDrawHandler = null;
|
||
}
|
||
state.drawMode = null;
|
||
updateDrawModeUI();
|
||
}
|
||
|
||
/* ── Draw event: when user finishes drawing ─────────────────── */
|
||
map.on(L.Draw.Event.CREATED, function (e) {
|
||
const layer = e.layer;
|
||
const latlngs = layer.getLatLngs();
|
||
|
||
if (state.drawMode === 'road') {
|
||
// Calculate length automatically
|
||
const flat = latlngs.flat ? latlngs.flat(Infinity) : latlngs;
|
||
const length = calculatePolylineLength(flat);
|
||
openRoadModal(null, flat, length);
|
||
} else if (state.drawMode === 'parcel') {
|
||
// Calculate area automatically (latlngs is array of arrays for polygon)
|
||
const ring = latlngs[0] || latlngs;
|
||
const area = L.GeometryUtil.geodesicArea(ring);
|
||
openParcelModal(null, ring, area);
|
||
}
|
||
|
||
stopDraw();
|
||
});
|
||
|
||
/* ── Haversine / Leaflet length calculation ─────────────────── */
|
||
function calculatePolylineLength(latlngs) {
|
||
let total = 0;
|
||
for (let i = 0; i < latlngs.length - 1; i++) {
|
||
total += latlngs[i].distanceTo(latlngs[i + 1]);
|
||
}
|
||
return Math.round(total * 100) / 100; // meters, 2 decimals
|
||
}
|
||
|
||
/* ── Custom point marker ────────────────────────────────────── */
|
||
function makeIcon(category, subtype) {
|
||
const key = category === 'spbu' ? `spbu-${subtype}` : category;
|
||
const color = POINT_COLORS[key] || '#aaa';
|
||
|
||
const icons = {
|
||
spbu: '⛽',
|
||
mosque: '🕌',
|
||
poor: '🏘️',
|
||
};
|
||
const emoji = icons[category] || '📍';
|
||
|
||
return L.divIcon({
|
||
html: `<div style="
|
||
background:${color};
|
||
width:30px;height:30px;
|
||
border-radius:50% 50% 50% 0;
|
||
transform:rotate(-45deg);
|
||
border:2px solid rgba(255,255,255,0.3);
|
||
display:flex;align-items:center;justify-content:center;
|
||
"><span style="transform:rotate(45deg);font-size:14px">${emoji}</span></div>`,
|
||
className: '',
|
||
iconSize: [30, 30],
|
||
iconAnchor: [15, 30],
|
||
popupAnchor:[0, -32],
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
POINTS — Add / Load / Edit / Delete
|
||
============================================================ */
|
||
|
||
/* Click on map to add point */
|
||
let pointClickHandler = null;
|
||
let laporanClickHandler = null;
|
||
|
||
function enablePointAdd() {
|
||
state.drawMode = 'point';
|
||
updateDrawModeUI();
|
||
toast('Click on the map to place a point', 'info');
|
||
|
||
pointClickHandler = function(e) {
|
||
state.pendingLatLng = e.latlng;
|
||
openPointModal(null, e.latlng);
|
||
disablePointAdd();
|
||
};
|
||
map.once('click', pointClickHandler);
|
||
}
|
||
|
||
function openLaporanModal(latlng) {
|
||
showModal(
|
||
'laporan',
|
||
null,
|
||
latlng
|
||
);
|
||
}
|
||
|
||
function enableLaporanAdd() {
|
||
|
||
toast(
|
||
'Klik lokasi warga yang ingin dilaporkan',
|
||
'info'
|
||
);
|
||
|
||
laporanClickHandler = function(e) {
|
||
|
||
openLaporanModal(e.latlng);
|
||
|
||
disableLaporanAdd();
|
||
};
|
||
|
||
map.once('click', laporanClickHandler);
|
||
}
|
||
|
||
function disablePointAdd() {
|
||
if (pointClickHandler) {
|
||
map.off('click', pointClickHandler);
|
||
pointClickHandler = null;
|
||
}
|
||
state.drawMode = null;
|
||
updateDrawModeUI();
|
||
}
|
||
|
||
function disableLaporanAdd() {
|
||
if (laporanClickHandler) {
|
||
map.off('click', laporanClickHandler);
|
||
laporanClickHandler = null;
|
||
}
|
||
}
|
||
|
||
/* Load all points from API */
|
||
async function loadPoints() {
|
||
const res = await fetch(API.points);
|
||
const fc = await res.json();
|
||
clearLayerGroup('points');
|
||
|
||
fc.features.forEach(f => addPointToMap(f.properties, f.geometry.coordinates));
|
||
updateCounts();
|
||
refreshPointList();
|
||
}
|
||
|
||
/* Add a point feature to the correct layer group */
|
||
function addPointToMap(props, coords) {
|
||
// coords = [lng, lat]
|
||
const latlng = L.latLng(coords[1], coords[0]);
|
||
const marker = L.marker(latlng, { icon: makeIcon(props.category, props.subtype) });
|
||
if (props.category === 'poor') {
|
||
poorMarkers.push(marker);
|
||
}
|
||
|
||
if (props.category === 'mosque') {
|
||
marker.on('click', function(e) {
|
||
selectedCenter = e.latlng;
|
||
|
||
// hapus circle lama
|
||
if (activeCircle) {
|
||
map.removeLayer(activeCircle);
|
||
}
|
||
|
||
// buat circle baru
|
||
activeCircle = L.circle(selectedCenter, {
|
||
radius: radius,
|
||
color: 'blue',
|
||
fillOpacity: 0.2
|
||
}).addTo(map);
|
||
|
||
updateMarkerColors();
|
||
});
|
||
}
|
||
|
||
marker.on('click', async function () {
|
||
|
||
const html =
|
||
await buildPointPopup(props);
|
||
|
||
marker.bindPopup(html)
|
||
.openPopup();
|
||
|
||
});
|
||
// Put in correct layer group
|
||
const lg = getPointLayer(props.category, props.subtype);
|
||
if (lg) lg.addLayer(marker);
|
||
|
||
layerRefs.points[props.id] = marker;
|
||
return marker;
|
||
}
|
||
|
||
function getPointLayer(category, subtype) {
|
||
if (category === 'spbu') return subtype === '24hours' ? layers.spbu24 : layers.spbuNot;
|
||
if (category === 'mosque') return layers.mosque;
|
||
if (category === 'poor') return layers.poor;
|
||
return null;
|
||
}
|
||
async function buildPointPopup(p) {
|
||
|
||
const bantuan =
|
||
p.category === 'poor'
|
||
? await getRiwayatBantuan(p.id)
|
||
: [];
|
||
|
||
const subtypeStr = p.category === 'spbu'
|
||
? `<div class="popup-row">
|
||
<span class="popup-key">Type</span>
|
||
<span class="popup-val">
|
||
${p.subtype === '24hours'
|
||
? '24 Hours'
|
||
: 'Not 24 Hours'}
|
||
</span>
|
||
</div>`
|
||
: '';
|
||
|
||
const bantuanBtn =
|
||
p.category === 'poor'
|
||
? `
|
||
<button
|
||
class="btn btn-sm btn-primary"
|
||
onclick="event.stopPropagation(); openBantuanModal(${p.id})">
|
||
|
||
🎁 Bantuan
|
||
|
||
</button>
|
||
`
|
||
: '';
|
||
|
||
const bantuanHistory =
|
||
bantuan.length
|
||
? `
|
||
<hr style="margin:8px 0">
|
||
|
||
<div style="font-weight:bold">
|
||
Riwayat Bantuan
|
||
</div>
|
||
|
||
${bantuan.map(b => `
|
||
<div style="
|
||
font-size:12px;
|
||
margin-top:4px;
|
||
">
|
||
• ${b.jenis_bantuan}
|
||
(Rp ${Number(b.nominal).toLocaleString('id-ID')})
|
||
</div>
|
||
`).join('')}
|
||
`
|
||
: '';
|
||
|
||
return `
|
||
<div class="popup-title">
|
||
${escHtml(p.name)}
|
||
</div>
|
||
|
||
<div class="popup-row">
|
||
<span class="popup-key">Category</span>
|
||
<span class="popup-val">
|
||
${catLabel(p.category)}
|
||
</span>
|
||
</div>
|
||
|
||
${subtypeStr}
|
||
|
||
${p.description
|
||
? `
|
||
<div class="popup-row">
|
||
<span class="popup-key">Desc</span>
|
||
<span class="popup-val">
|
||
${escHtml(p.description)}
|
||
</span>
|
||
</div>
|
||
`
|
||
: ''
|
||
}
|
||
|
||
${bantuanHistory}
|
||
|
||
<div class="popup-actions">
|
||
|
||
${bantuanBtn}
|
||
|
||
<button
|
||
class="btn btn-sm btn-warn"
|
||
onclick="event.stopPropagation(); openPointModal(${p.id})">
|
||
|
||
Edit
|
||
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-sm btn-danger"
|
||
onclick="event.stopPropagation(); deletePoint(${p.id})">
|
||
|
||
Delete
|
||
|
||
</button>
|
||
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* Open modal for adding/editing a point */
|
||
async function openPointModal(id, latlng) {
|
||
let point = null;
|
||
if (id) {
|
||
const res = await fetch(`${API.points}?id=${id}`);
|
||
point = await res.json();
|
||
latlng = L.latLng(point.latitude, point.longitude);
|
||
}
|
||
|
||
showModal('point', point, latlng);
|
||
}
|
||
|
||
function openBantuanModal(pointId) {
|
||
|
||
showModal(
|
||
'bantuan',
|
||
null,
|
||
pointId
|
||
);
|
||
|
||
}
|
||
|
||
function showModal(type, data, extra) {
|
||
const overlay = document.getElementById('modal-overlay');
|
||
const modal = document.getElementById('modal');
|
||
|
||
if (type === 'point') {
|
||
const latlng = extra;
|
||
modal.innerHTML = `
|
||
<h3>${data ? '✏️ Edit Point' : '📍 Add Point'}</h3>
|
||
<div class="form-group">
|
||
<label>Name *</label>
|
||
<input type="text" id="f-name" value="${escAttr(data?.name || '')}" placeholder="Location name">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Category *</label>
|
||
<select id="f-cat" onchange="toggleSubtype()">
|
||
<option value="spbu" ${data?.category==='spbu'?'selected':''}>⛽ SPBU (Gas Station)</option>
|
||
<option value="mosque" ${data?.category==='mosque'?'selected':''}>🕌 Mosque</option>
|
||
<option value="poor" ${data?.category==='poor'?'selected':''}>🏘️ Poor Population</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" id="subtype-group" style="${data?.category !== 'mosque' && data?.category !== 'poor' ? '' : 'display:none'}">
|
||
<label>SPBU Type</label>
|
||
<select id="f-subtype">
|
||
<option value="24hours" ${data?.subtype==='24hours'?'selected':''}>24 Hours</option>
|
||
<option value="not24hours" ${data?.subtype==='not24hours'?'selected':''}>Not 24 Hours</option>
|
||
</select>
|
||
</div>
|
||
<div class="coord-row">
|
||
<div class="form-group">
|
||
<label>Latitude</label>
|
||
<input type="text" id="f-lat" value="${latlng?.lat?.toFixed(7) || ''}" readonly>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Longitude</label>
|
||
<input type="text" id="f-lng" value="${latlng?.lng?.toFixed(7) || ''}" readonly>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description</label>
|
||
<textarea id="f-desc" placeholder="Optional notes...">${escHtml(data?.description || '')}</textarea>
|
||
</div>
|
||
<div id="poor-fields" style="${!data || data?.category === 'poor' ? '' : 'display:none'}">
|
||
|
||
<div class="form-group">
|
||
<label>Tanggal Lahir</label>
|
||
<input type="date" id="f-tanggal-lahir"
|
||
value="${data?.tanggal_lahir || ''}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Pendidikan</label>
|
||
<input type="text" id="f-pendidikan"
|
||
value="${data?.pendidikan || ''}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Pekerjaan</label>
|
||
<input type="text" id="f-pekerjaan"
|
||
value="${data?.pekerjaan || ''}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Jumlah Tanggungan</label>
|
||
<input type="number" id="f-tanggungan"
|
||
value="${data?.jumlah_tanggungan || ''}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Riwayat Penyakit</label>
|
||
<textarea id="f-penyakit">${data?.riwayat_penyakit || ''}</textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Alamat</label>
|
||
<textarea id="f-alamat">${data?.alamat || ''}</textarea>
|
||
</div>
|
||
|
||
<input type="hidden" id="f-status-verifikasi" value="${data?.status_verifikasi || 'Menunggu Verifikasi'}">
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-primary btn-full" onclick="savePoint(${data?.id || 'null'}, ${latlng?.lat}, ${latlng?.lng})">
|
||
${data ? '💾 Update' : '➕ Add Point'}
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
</div>`;
|
||
}
|
||
|
||
else if (type === 'laporan') {
|
||
|
||
const latlng = extra;
|
||
|
||
modal.innerHTML = `
|
||
|
||
<h3>📢 Laporan Masyarakat</h3>
|
||
|
||
<div class="form-group">
|
||
<label>Nama Pelapor</label>
|
||
<input type="text" id="lp-nama">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>No HP</label>
|
||
<input type="text" id="lp-hp">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Nama Warga</label>
|
||
<input type="text" id="lp-warga">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Alamat</label>
|
||
<textarea id="lp-alamat"></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Keterangan</label>
|
||
<textarea id="lp-ket"></textarea>
|
||
</div>
|
||
|
||
<div class="btn-row">
|
||
|
||
<button
|
||
class="btn btn-primary btn-full"
|
||
onclick="saveLaporan(${latlng.lat},${latlng.lng})">
|
||
|
||
Kirim Laporan
|
||
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-secondary"
|
||
onclick="closeModal()">
|
||
|
||
Batal
|
||
|
||
</button>
|
||
|
||
</div>
|
||
`;
|
||
}
|
||
else if(type === 'bantuan') {
|
||
|
||
const pointId = extra;
|
||
|
||
modal.innerHTML = `
|
||
|
||
<h3>🎁 Tambah Bantuan</h3>
|
||
|
||
<div class="form-group">
|
||
<label>Jenis Bantuan</label>
|
||
<input type="text" id="b-jenis">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Nominal</label>
|
||
<input type="number" id="b-nominal">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Tanggal Bantuan</label>
|
||
<input type="date" id="b-tanggal">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Instansi Pemberi</label>
|
||
<input type="text" id="b-instansi">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Keterangan</label>
|
||
<textarea id="b-ket"></textarea>
|
||
</div>
|
||
|
||
<div class="btn-row">
|
||
|
||
<button
|
||
class="btn btn-primary btn-full"
|
||
onclick="saveBantuan(${pointId})">
|
||
|
||
Simpan
|
||
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-secondary"
|
||
onclick="closeModal()">
|
||
|
||
Batal
|
||
|
||
</button>
|
||
|
||
</div>
|
||
|
||
`;
|
||
}
|
||
else if (type === 'road') {
|
||
const { latlngs, length } = extra;
|
||
const d = data;
|
||
modal.innerHTML = `
|
||
<h3>${d ? '✏️ Edit Road' : '🛣️ Add Road'}</h3>
|
||
<div class="form-group">
|
||
<label>Road Name *</label>
|
||
<input type="text" id="f-name" value="${escAttr(d?.name || '')}" placeholder="e.g. Jl. Sudirman">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Road Type *</label>
|
||
<select id="f-type">
|
||
<option value="national" ${d?.road_type==='national'?'selected':''}>🔴 National Road</option>
|
||
<option value="provincial" ${d?.road_type==='provincial'?'selected':''}>🟠 Provincial Road</option>
|
||
<option value="district" ${d?.road_type==='district'?'selected':''}>🔵 District Road</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Length (m) — auto-calculated</label>
|
||
<input type="text" id="f-length" value="${length?.toFixed(2) || d?.length_m || 0}" readonly>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description</label>
|
||
<textarea id="f-desc" placeholder="Optional notes...">${escHtml(d?.description || '')}</textarea>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-primary btn-full" onclick="saveRoad(${d?.id || 'null'})">
|
||
${d ? '💾 Update' : '➕ Add Road'}
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
</div>`;
|
||
// Store pending latlngs in state
|
||
state._pendingRoadLatLngs = latlngs;
|
||
} else if (type === 'parcel') {
|
||
const { ring, area } = extra;
|
||
const d = data;
|
||
modal.innerHTML = `
|
||
<h3>${d ? '✏️ Edit Parcel' : '🗺️ Add Land Parcel'}</h3>
|
||
<div class="form-group">
|
||
<label>Owner Name *</label>
|
||
<input type="text" id="f-owner" value="${escAttr(d?.owner_name || '')}" placeholder="e.g. Budi Santoso">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Ownership Type *</label>
|
||
<select id="f-own-type">
|
||
<option value="SHM" ${d?.ownership_type==='SHM'?'selected':''}>SHM — Freehold Title</option>
|
||
<option value="HGB" ${d?.ownership_type==='HGB'?'selected':''}>HGB — Building Rights</option>
|
||
<option value="HGU" ${d?.ownership_type==='HGU'?'selected':''}>HGU — Business Rights</option>
|
||
<option value="HP" ${d?.ownership_type==='HP' ?'selected':''}>HP — Usage Rights</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Area (m²) — auto-calculated</label>
|
||
<input type="text" id="f-area" value="${area?.toFixed(4) || d?.area_m2 || 0}" readonly>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Description</label>
|
||
<textarea id="f-desc" placeholder="Optional notes...">${escHtml(d?.description || '')}</textarea>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-primary btn-full" onclick="saveParcel(${d?.id || 'null'})">
|
||
${d ? '💾 Update' : '➕ Add Parcel'}
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
</div>`;
|
||
state._pendingParcelRing = ring;
|
||
}
|
||
|
||
overlay.classList.add('open');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modal-overlay').classList.remove('open');
|
||
state._pendingRoadLatLngs = null;
|
||
state._pendingParcelRing = null;
|
||
}
|
||
|
||
function toggleSubtype() {
|
||
const cat = document.getElementById('f-cat').value;
|
||
|
||
document.getElementById('subtype-group').style.display =
|
||
cat === 'spbu' ? '' : 'none';
|
||
|
||
const poorFields = document.getElementById('poor-fields');
|
||
|
||
if (poorFields) {
|
||
poorFields.style.display =
|
||
cat === 'poor' ? '' : 'none';
|
||
}
|
||
}
|
||
|
||
/* Save point (create or update) */
|
||
async function savePoint(id, lat, lng) {
|
||
const name = document.getElementById('f-name').value.trim();
|
||
const category = document.getElementById('f-cat').value;
|
||
const subtype = category === 'spbu' ? document.getElementById('f-subtype').value : null;
|
||
const desc = document.getElementById('f-desc').value.trim();
|
||
|
||
if (!name) { toast('Name is required', 'error'); return; }
|
||
|
||
const body = {
|
||
name,
|
||
category,
|
||
subtype,
|
||
latitude: lat,
|
||
longitude: lng,
|
||
description: desc,
|
||
|
||
tanggal_lahir:
|
||
document.getElementById('f-tanggal-lahir')?.value || null,
|
||
|
||
pendidikan:
|
||
document.getElementById('f-pendidikan')?.value || null,
|
||
|
||
pekerjaan:
|
||
document.getElementById('f-pekerjaan')?.value || null,
|
||
|
||
jumlah_tanggungan:
|
||
document.getElementById('f-tanggungan')?.value || null,
|
||
|
||
riwayat_penyakit:
|
||
document.getElementById('f-penyakit')?.value || null,
|
||
|
||
alamat:
|
||
document.getElementById('f-alamat')?.value || null,
|
||
|
||
status_verifikasi:
|
||
id ? (document.getElementById('f-status-verifikasi')?.value || 'Menunggu Verifikasi') : 'Menunggu Verifikasi'
|
||
};
|
||
try {
|
||
let res;
|
||
if (id) {
|
||
res = await fetch(`${API.points}?id=${id}`, { method: 'PUT', headers: jsonHeader(), body: JSON.stringify(body) });
|
||
} else {
|
||
res = await fetch(API.points, { method: 'POST', headers: jsonHeader(), body: JSON.stringify(body) });
|
||
}
|
||
const data = await res.json();
|
||
if (!res.ok) { toast(data.error || 'Error saving point', 'error'); return; }
|
||
|
||
closeModal();
|
||
toast(id ? 'Point updated ✓' : 'Point added ✓', 'success');
|
||
await loadPoints();
|
||
switchTab('points');
|
||
} catch (e) {
|
||
toast('Network error', 'error');
|
||
}
|
||
}
|
||
|
||
async function deletePoint(id) {
|
||
if (!confirm('Delete this point?')) return;
|
||
const res = await fetch(`${API.points}?id=${id}`, { method: 'DELETE' });
|
||
if (res.ok) { toast('Point deleted', 'success'); await loadPoints(); }
|
||
else toast('Delete failed', 'error');
|
||
}
|
||
|
||
/* ============================================================
|
||
ROADS — Add / Load / Edit / Delete
|
||
============================================================ */
|
||
|
||
function startAddRoad() {
|
||
state.drawMode = 'road';
|
||
updateDrawModeUI();
|
||
toast('Draw a polyline on the map. Double-click to finish.', 'info');
|
||
startDraw('road');
|
||
}
|
||
|
||
function openRoadModal(id, latlngs, length) {
|
||
if (id) {
|
||
fetch(`${API.roads}?id=${id}`).then(r => r.json()).then(d => {
|
||
showModal('road', d, { latlngs: null, length: d.length_m });
|
||
});
|
||
} else {
|
||
showModal('road', null, { latlngs, length });
|
||
}
|
||
}
|
||
|
||
async function saveRoad(id) {
|
||
const name = document.getElementById('f-name').value.trim();
|
||
const road_type = document.getElementById('f-type').value;
|
||
const length_m = parseFloat(document.getElementById('f-length').value) || 0;
|
||
const desc = document.getElementById('f-desc').value.trim();
|
||
|
||
if (!name) { toast('Road name is required', 'error'); return; }
|
||
|
||
let geojson;
|
||
if (id) {
|
||
// editing: re-use existing geometry from API
|
||
const res = await fetch(`${API.roads}?id=${id}`);
|
||
const d = await res.json();
|
||
geojson = d.geojson;
|
||
} else {
|
||
const latlngs = state._pendingRoadLatLngs;
|
||
if (!latlngs || latlngs.length < 2) { toast('No road geometry', 'error'); return; }
|
||
geojson = {
|
||
type: 'LineString',
|
||
coordinates: latlngs.map(ll => [ll.lng, ll.lat]),
|
||
};
|
||
}
|
||
|
||
const body = { name, road_type, length_m, geojson, description: desc };
|
||
|
||
try {
|
||
let res;
|
||
if (id) {
|
||
res = await fetch(`${API.roads}?id=${id}`, { method: 'PUT', headers: jsonHeader(), body: JSON.stringify(body) });
|
||
} else {
|
||
res = await fetch(API.roads, { method: 'POST', headers: jsonHeader(), body: JSON.stringify(body) });
|
||
}
|
||
const data = await res.json();
|
||
if (!res.ok) { toast(data.error || 'Error saving road', 'error'); return; }
|
||
|
||
closeModal();
|
||
toast(id ? 'Road updated ✓' : 'Road added ✓', 'success');
|
||
await loadRoads();
|
||
switchTab('roads');
|
||
} catch (e) {
|
||
toast('Network error', 'error');
|
||
}
|
||
}
|
||
|
||
async function loadRoads() {
|
||
const res = await fetch(API.roads);
|
||
const fc = await res.json();
|
||
clearLayerGroup('roads');
|
||
|
||
fc.features.forEach(f => addRoadToMap(f.properties, f.geometry));
|
||
updateCounts();
|
||
refreshRoadList();
|
||
}
|
||
|
||
function addRoadToMap(props, geometry) {
|
||
const coords = geometry.coordinates.map(c => [c[1], c[0]]);
|
||
const style = ROAD_STYLES[props.road_type] || ROAD_STYLES.national;
|
||
const polyline = L.polyline(coords, style);
|
||
polyline.bindPopup(buildRoadPopup(props));
|
||
|
||
const lg = layers[props.road_type];
|
||
if (lg) lg.addLayer(polyline);
|
||
|
||
layerRefs.roads[props.id] = polyline;
|
||
return polyline;
|
||
}
|
||
|
||
function buildRoadPopup(p) {
|
||
return `
|
||
<div class="popup-title">${escHtml(p.name)}</div>
|
||
<div class="popup-row"><span class="popup-key">Type</span><span class="popup-val">${roadTypeLabel(p.road_type)}</span></div>
|
||
<div class="popup-row"><span class="popup-key">Length</span><span class="popup-val">${fmtLength(p.length_m)}</span></div>
|
||
${p.description ? `<div class="popup-row"><span class="popup-key">Desc</span><span class="popup-val">${escHtml(p.description)}</span></div>` : ''}
|
||
<div class="popup-actions">
|
||
<button class="btn btn-sm btn-warn" onclick="openRoadModal(${p.id})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteRoad(${p.id})">Delete</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function deleteRoad(id) {
|
||
if (!confirm('Delete this road?')) return;
|
||
const res = await fetch(`${API.roads}?id=${id}`, { method: 'DELETE' });
|
||
if (res.ok) { toast('Road deleted', 'success'); await loadRoads(); }
|
||
else toast('Delete failed', 'error');
|
||
}
|
||
|
||
/* ============================================================
|
||
PARCELS — Add / Load / Edit / Delete
|
||
============================================================ */
|
||
|
||
function startAddParcel() {
|
||
state.drawMode = 'parcel';
|
||
updateDrawModeUI();
|
||
toast('Draw a polygon on the map. Double-click to close.', 'info');
|
||
startDraw('parcel');
|
||
}
|
||
|
||
function openParcelModal(id, ring, area) {
|
||
if (id) {
|
||
fetch(`${API.parcels}?id=${id}`).then(r => r.json()).then(d => {
|
||
showModal('parcel', d, { ring: null, area: d.area_m2 });
|
||
});
|
||
} else {
|
||
showModal('parcel', null, { ring, area });
|
||
}
|
||
}
|
||
|
||
async function saveParcel(id) {
|
||
const owner_name = document.getElementById('f-owner').value.trim();
|
||
const ownership_type = document.getElementById('f-own-type').value;
|
||
const area_m2 = parseFloat(document.getElementById('f-area').value) || 0;
|
||
const desc = document.getElementById('f-desc').value.trim();
|
||
|
||
if (!owner_name) { toast('Owner name is required', 'error'); return; }
|
||
|
||
let geojson;
|
||
if (id) {
|
||
const res = await fetch(`${API.parcels}?id=${id}`);
|
||
const d = await res.json();
|
||
geojson = d.geojson;
|
||
} else {
|
||
const ring = state._pendingParcelRing;
|
||
if (!ring || ring.length < 3) { toast('No polygon geometry', 'error'); return; }
|
||
const coords = ring.map(ll => [ll.lng, ll.lat]);
|
||
coords.push(coords[0]); // close ring
|
||
geojson = { type: 'Polygon', coordinates: [coords] };
|
||
}
|
||
|
||
const body = { owner_name, ownership_type, area_m2, geojson, description: desc };
|
||
|
||
try {
|
||
let res;
|
||
if (id) {
|
||
res = await fetch(`${API.parcels}?id=${id}`, { method: 'PUT', headers: jsonHeader(), body: JSON.stringify(body) });
|
||
} else {
|
||
res = await fetch(API.parcels, { method: 'POST', headers: jsonHeader(), body: JSON.stringify(body) });
|
||
}
|
||
const data = await res.json();
|
||
if (!res.ok) { toast(data.error || 'Error saving parcel', 'error'); return; }
|
||
|
||
closeModal();
|
||
toast(id ? 'Parcel updated ✓' : 'Parcel added ✓', 'success');
|
||
await loadParcels();
|
||
switchTab('parcels');
|
||
} catch (e) {
|
||
toast('Network error', 'error');
|
||
}
|
||
}
|
||
|
||
async function loadParcels() {
|
||
const res = await fetch(API.parcels);
|
||
const fc = await res.json();
|
||
clearLayerGroup('parcels');
|
||
|
||
fc.features.forEach(f => addParcelToMap(f.properties, f.geometry));
|
||
updateCounts();
|
||
refreshParcelList();
|
||
}
|
||
|
||
function addParcelToMap(props, geometry) {
|
||
const coords = geometry.coordinates[0].map(c => [c[1], c[0]]);
|
||
const style = PARCEL_STYLES[props.ownership_type] || PARCEL_STYLES.SHM;
|
||
const polygon = L.polygon(coords, style);
|
||
polygon.bindPopup(buildParcelPopup(props));
|
||
|
||
const lgKey = props.ownership_type.toLowerCase();
|
||
const lg = layers[lgKey];
|
||
if (lg) lg.addLayer(polygon);
|
||
|
||
layerRefs.parcels[props.id] = polygon;
|
||
return polygon;
|
||
}
|
||
|
||
function buildParcelPopup(p) {
|
||
return `
|
||
<div class="popup-title">${escHtml(p.owner_name)}</div>
|
||
<div class="popup-row"><span class="popup-key">Ownership</span><span class="popup-val">${p.ownership_type}</span></div>
|
||
<div class="popup-row"><span class="popup-key">Area</span><span class="popup-val">${fmtArea(p.area_m2)}</span></div>
|
||
${p.description ? `<div class="popup-row"><span class="popup-key">Desc</span><span class="popup-val">${escHtml(p.description)}</span></div>` : ''}
|
||
<div class="popup-actions">
|
||
<button class="btn btn-sm btn-warn" onclick="openParcelModal(${p.id})">Edit</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteParcel(${p.id})">Delete</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function deleteParcel(id) {
|
||
if (!confirm('Delete this parcel?')) return;
|
||
const res = await fetch(`${API.parcels}?id=${id}`, { method: 'DELETE' });
|
||
if (res.ok) { toast('Parcel deleted', 'success'); await loadParcels(); }
|
||
else toast('Delete failed', 'error');
|
||
}
|
||
|
||
/* ============================================================
|
||
Layer Control — checkboxes in sidebar
|
||
============================================================ */
|
||
|
||
function toggleLayer(layerKey, show) {
|
||
const lg = layers[layerKey];
|
||
if (!lg) return;
|
||
if (show) { map.addLayer(lg); }
|
||
else { map.removeLayer(lg); }
|
||
}
|
||
|
||
function initLayerCheckboxes() {
|
||
document.querySelectorAll('[data-layer]').forEach(cb => {
|
||
cb.addEventListener('change', function() {
|
||
toggleLayer(this.dataset.layer, this.checked);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
Sidebar tabs
|
||
============================================================ */
|
||
|
||
function switchTab(tab) {
|
||
state.currentTab = tab;
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.toggle('active', p.id === `panel-${tab}`));
|
||
}
|
||
|
||
/* ============================================================
|
||
List renderers (sidebar panels)
|
||
============================================================ */
|
||
|
||
async function refreshPointList() {
|
||
const res = await fetch(API.points);
|
||
const fc = await res.json();
|
||
const el = document.getElementById('point-list');
|
||
if (!el) return;
|
||
|
||
if (!fc.features.length) {
|
||
el.innerHTML = '<div class="info-box">No points yet. Click the map to add.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = fc.features.map(f => {
|
||
const p = f.properties;
|
||
const key = p.category === 'spbu' ? `spbu-${p.subtype}` : p.category;
|
||
const col = POINT_COLORS[key] || '#aaa';
|
||
return `<div class="feature-item" onclick="zoomToPoint(${p.id})">
|
||
<div class="feature-dot" style="background:${col}"></div>
|
||
<div class="feature-info">
|
||
<div class="feature-name">${escHtml(p.name)}</div>
|
||
<div class="feature-meta">${catLabel(p.category)}${p.subtype ? ' · ' + p.subtype : ''}</div>
|
||
</div>
|
||
<div class="feature-actions">
|
||
<button class="btn btn-sm btn-warn" onclick="event.stopPropagation();openPointModal(${p.id})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deletePoint(${p.id})">🗑️</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function refreshRoadList() {
|
||
const res = await fetch(API.roads);
|
||
const fc = await res.json();
|
||
const el = document.getElementById('road-list');
|
||
if (!el) return;
|
||
|
||
if (!fc.features.length) {
|
||
el.innerHTML = '<div class="info-box">No roads yet. Use the draw tool to add.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = fc.features.map(f => {
|
||
const p = f.properties;
|
||
const col = ROAD_STYLES[p.road_type]?.color || '#aaa';
|
||
return `<div class="feature-item" onclick="zoomToRoad(${p.id})">
|
||
<div class="feature-dot road" style="background:${col}"></div>
|
||
<div class="feature-info">
|
||
<div class="feature-name">${escHtml(p.name)}</div>
|
||
<div class="feature-meta">${roadTypeLabel(p.road_type)} · ${fmtLength(p.length_m)}</div>
|
||
</div>
|
||
<div class="feature-actions">
|
||
<button class="btn btn-sm btn-warn" onclick="event.stopPropagation();openRoadModal(${p.id})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deleteRoad(${p.id})">🗑️</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function saveLaporan(lat,lng) {
|
||
|
||
const body = {
|
||
|
||
nama_pelapor:
|
||
document.getElementById('lp-nama').value,
|
||
|
||
no_hp:
|
||
document.getElementById('lp-hp').value,
|
||
|
||
nama_warga:
|
||
document.getElementById('lp-warga').value,
|
||
|
||
alamat:
|
||
document.getElementById('lp-alamat').value,
|
||
|
||
keterangan:
|
||
document.getElementById('lp-ket').value,
|
||
|
||
latitude: lat,
|
||
longitude: lng
|
||
|
||
};
|
||
|
||
const res = await fetch(
|
||
API.laporan,
|
||
{
|
||
method:'POST',
|
||
headers:jsonHeader(),
|
||
body:JSON.stringify(body)
|
||
}
|
||
);
|
||
|
||
if(res.ok){
|
||
|
||
toast(
|
||
'Laporan berhasil dikirim',
|
||
'success'
|
||
);
|
||
|
||
closeModal();
|
||
|
||
refreshLaporanList();
|
||
|
||
} else {
|
||
|
||
toast(
|
||
'Gagal mengirim laporan',
|
||
'error'
|
||
);
|
||
|
||
}
|
||
}
|
||
|
||
async function saveBantuan(pointId){
|
||
|
||
const body = {
|
||
|
||
point_id: pointId,
|
||
|
||
jenis_bantuan:
|
||
document.getElementById('b-jenis').value,
|
||
|
||
nominal:
|
||
document.getElementById('b-nominal').value,
|
||
|
||
tanggal_bantuan:
|
||
document.getElementById('b-tanggal').value,
|
||
|
||
instansi_pemberi:
|
||
document.getElementById('b-instansi').value,
|
||
|
||
keterangan:
|
||
document.getElementById('b-ket').value
|
||
|
||
};
|
||
|
||
const res = await fetch(
|
||
API.bantuan,
|
||
{
|
||
method:'POST',
|
||
headers:jsonHeader(),
|
||
body:JSON.stringify(body)
|
||
}
|
||
);
|
||
|
||
if(res.ok){
|
||
|
||
toast(
|
||
'Bantuan berhasil ditambahkan',
|
||
'success'
|
||
);
|
||
|
||
closeModal();
|
||
|
||
} else {
|
||
|
||
toast(
|
||
'Gagal menambahkan bantuan',
|
||
'error'
|
||
);
|
||
|
||
}
|
||
}
|
||
|
||
async function getRiwayatBantuan(pointId) {
|
||
|
||
const res = await fetch(
|
||
`${API.bantuan}?point_id=${pointId}`
|
||
);
|
||
|
||
return await res.json();
|
||
}
|
||
|
||
async function refreshParcelList() {
|
||
const res = await fetch(API.parcels);
|
||
const fc = await res.json();
|
||
const el = document.getElementById('parcel-list');
|
||
if (!el) return;
|
||
|
||
if (!fc.features.length) {
|
||
el.innerHTML = '<div class="info-box">No parcels yet. Use the polygon tool to add.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = fc.features.map(f => {
|
||
const p = f.properties;
|
||
const col = PARCEL_STYLES[p.ownership_type]?.color || '#aaa';
|
||
return `<div class="feature-item" onclick="zoomToParcel(${p.id})">
|
||
<div class="feature-dot poly" style="background:${col}"></div>
|
||
<div class="feature-info">
|
||
<div class="feature-name">${escHtml(p.owner_name)}</div>
|
||
<div class="feature-meta">${p.ownership_type} · ${fmtArea(p.area_m2)}</div>
|
||
</div>
|
||
<div class="feature-actions">
|
||
<button class="btn btn-sm btn-warn" onclick="event.stopPropagation();openParcelModal(${p.id})">✏️</button>
|
||
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deleteParcel(${p.id})">🗑️</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
/* Zoom-to helpers */
|
||
function zoomToPoint(id) {
|
||
const m = layerRefs.points[id];
|
||
if (m) { map.setView(m.getLatLng(), 17); m.openPopup(); }
|
||
}
|
||
function zoomToRoad(id) {
|
||
const l = layerRefs.roads[id];
|
||
if (l) { map.fitBounds(l.getBounds()); l.openPopup(l.getBounds().getCenter()); }
|
||
}
|
||
function zoomToParcel(id) {
|
||
const l = layerRefs.parcels[id];
|
||
if (l) { map.fitBounds(l.getBounds()); l.openPopup(l.getBounds().getCenter()); }
|
||
}
|
||
|
||
/* ============================================================
|
||
Layer-group management
|
||
============================================================ */
|
||
|
||
function clearLayerGroup(type) {
|
||
if (type === 'points') {
|
||
['spbu24','spbuNot','mosque','poor'].forEach(k => layers[k].clearLayers());
|
||
layerRefs.points = {};
|
||
} else if (type === 'roads') {
|
||
['national','provincial','district'].forEach(k => layers[k].clearLayers());
|
||
layerRefs.roads = {};
|
||
} else if (type === 'parcels') {
|
||
['shm','hgb','hgu','hp'].forEach(k => layers[k].clearLayers());
|
||
layerRefs.parcels = {};
|
||
}
|
||
if (type === 'points') {
|
||
poorMarkers = []; // reset biar ga numpuk
|
||
}
|
||
}
|
||
|
||
/* ============================================================
|
||
Stats, counts, UI helpers
|
||
============================================================ */
|
||
|
||
function updateCounts() {
|
||
const pc = Object.keys(layerRefs.points).length;
|
||
const rc = Object.keys(layerRefs.roads).length;
|
||
const lc = Object.keys(layerRefs.parcels).length;
|
||
|
||
document.getElementById('stat-points').textContent = pc;
|
||
document.getElementById('stat-roads').textContent = rc;
|
||
document.getElementById('stat-parcels').textContent= lc;
|
||
|
||
// Update layer count badges
|
||
document.querySelectorAll('[data-count-layer]').forEach(el => {
|
||
const key = el.dataset.countLayer;
|
||
const lg = layers[key];
|
||
if (lg) el.textContent = lg.getLayers().length;
|
||
});
|
||
}
|
||
|
||
function updateDrawModeUI() {
|
||
document.querySelectorAll('.map-tool-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.mode === state.drawMode);
|
||
});
|
||
}
|
||
|
||
/* ── Coordinates tracker ───────────────────────────────────── */
|
||
map.on('mousemove', function(e) {
|
||
const el = document.getElementById('coords-display');
|
||
if (el) el.textContent = `${e.latlng.lat.toFixed(6)}, ${e.latlng.lng.toFixed(6)}`;
|
||
});
|
||
|
||
/* ── Toast notifications ───────────────────────────────────── */
|
||
function toast(msg, type = 'info') {
|
||
const container = document.getElementById('toast-container');
|
||
const el = document.createElement('div');
|
||
el.className = `toast ${type}`;
|
||
el.textContent = msg;
|
||
container.appendChild(el);
|
||
setTimeout(() => el.remove(), 3500);
|
||
}
|
||
|
||
/* ── Format helpers ─────────────────────────────────────────── */
|
||
function fmtLength(m) {
|
||
m = parseFloat(m) || 0;
|
||
return m >= 1000 ? `${(m/1000).toFixed(2)} km` : `${m.toFixed(1)} m`;
|
||
}
|
||
|
||
function fmtArea(m2) {
|
||
m2 = parseFloat(m2) || 0;
|
||
return m2 >= 10000 ? `${(m2/10000).toFixed(4)} ha` : `${m2.toFixed(2)} m²`;
|
||
}
|
||
|
||
function catLabel(cat) {
|
||
return { spbu: 'SPBU', mosque: 'Mosque', poor: 'Poor Population' }[cat] || cat;
|
||
}
|
||
|
||
function roadTypeLabel(t) {
|
||
return { national: 'National', provincial: 'Provincial', district: 'District' }[t] || t;
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
function escAttr(str) { return escHtml(str); }
|
||
|
||
function jsonHeader() {
|
||
return { 'Content-Type': 'application/json' };
|
||
}
|
||
|
||
/* ============================================================
|
||
Keyboard shortcut: Escape to cancel draw
|
||
============================================================ */
|
||
document.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Escape') {
|
||
stopDraw();
|
||
disablePointAdd();
|
||
disableLaporanAdd();
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
|
||
function updateMarkerColors() {
|
||
if (!selectedCenter) return;
|
||
|
||
let inside = 0;
|
||
let outside = 0;
|
||
|
||
poorMarkers.forEach(marker => {
|
||
let distance = map.distance(selectedCenter, marker.getLatLng());
|
||
const el = marker.getElement();
|
||
if (el) {
|
||
if (distance <= radius) {
|
||
el.style.filter = "hue-rotate(0deg)";
|
||
inside++;
|
||
} else {
|
||
el.style.filter = "hue-rotate(120deg)";
|
||
outside++;
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log("Inside:", inside, "Outside:", outside);
|
||
}
|
||
|
||
/* ============================================================
|
||
Bootstrap — load all data on page ready
|
||
============================================================ */
|
||
document.addEventListener('DOMContentLoaded', async function() {
|
||
// Tab switching
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||
});
|
||
|
||
// Layer checkboxes
|
||
initLayerCheckboxes();
|
||
|
||
// Load all data
|
||
await Promise.all([loadPoints(), loadRoads(), loadParcels(), refreshLaporanList()]);
|
||
|
||
toast('WebGIS loaded ✓', 'success');
|
||
});
|
||
|
||
async function rejectLaporan(id){
|
||
|
||
const res = await fetch(
|
||
`${API.laporan}?id=${id}`,
|
||
{
|
||
method:'PUT',
|
||
headers:jsonHeader(),
|
||
body:JSON.stringify({
|
||
status:'Ditolak'
|
||
})
|
||
}
|
||
);
|
||
|
||
if(res.ok){
|
||
|
||
toast(
|
||
'Laporan ditolak',
|
||
'success'
|
||
);
|
||
|
||
refreshLaporanList();
|
||
}
|
||
}
|
||
async function approveLaporan(id){
|
||
|
||
const lapRes =
|
||
await fetch(`${API.laporan}?id=${id}`);
|
||
|
||
const laporan =
|
||
await lapRes.json();
|
||
|
||
const pointBody = {
|
||
|
||
name:
|
||
laporan.nama_warga,
|
||
|
||
category:
|
||
'poor',
|
||
|
||
latitude:
|
||
laporan.latitude,
|
||
|
||
longitude:
|
||
laporan.longitude,
|
||
|
||
description:
|
||
laporan.keterangan,
|
||
|
||
alamat:
|
||
laporan.alamat,
|
||
|
||
status_verifikasi:
|
||
'Layak'
|
||
|
||
};
|
||
|
||
const resPoint =
|
||
await fetch(
|
||
API.points,
|
||
{
|
||
method:'POST',
|
||
headers:jsonHeader(),
|
||
body:JSON.stringify(pointBody)
|
||
}
|
||
);
|
||
|
||
if(!resPoint.ok){
|
||
|
||
toast(
|
||
'Gagal membuat data warga',
|
||
'error'
|
||
);
|
||
|
||
return;
|
||
}
|
||
|
||
await fetch(
|
||
`${API.laporan}?id=${id}`,
|
||
{
|
||
method:'PUT',
|
||
headers:jsonHeader(),
|
||
body:JSON.stringify({
|
||
status:'Disetujui'
|
||
})
|
||
}
|
||
);
|
||
|
||
toast(
|
||
'Laporan disetujui',
|
||
'success'
|
||
);
|
||
|
||
await loadPoints();
|
||
|
||
await refreshLaporanList();
|
||
}
|
||
|
||
async function refreshLaporanList() {
|
||
|
||
const res = await fetch(API.laporan);
|
||
const data = await res.json();
|
||
|
||
const el = document.getElementById('laporan-list');
|
||
|
||
if (!el) return;
|
||
|
||
if (!data.length) {
|
||
el.innerHTML =
|
||
'<div class="info-box">Belum ada laporan.</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = data.map(l => `
|
||
|
||
<div class="feature-item">
|
||
|
||
<div class="feature-info">
|
||
|
||
<div class="feature-name">
|
||
${l.nama_warga}
|
||
</div>
|
||
|
||
<div class="feature-meta">
|
||
${l.status}
|
||
</div>
|
||
|
||
<div style="font-size:12px;color:#999">
|
||
${l.alamat || '-'}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="feature-actions">
|
||
|
||
${
|
||
l.status === 'Menunggu Verifikasi'
|
||
? `
|
||
<button
|
||
class="btn btn-sm btn-primary"
|
||
onclick="approveLaporan(${l.id})">
|
||
|
||
✓
|
||
|
||
</button>
|
||
|
||
<button
|
||
class="btn btn-sm btn-danger"
|
||
onclick="rejectLaporan(${l.id})">
|
||
|
||
✕
|
||
</button>
|
||
`
|
||
: ''
|
||
}
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
`).join('');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const slider = document.getElementById("radiusSlider");
|
||
|
||
if (slider) {
|
||
slider.addEventListener("input", function () {
|
||
radius = parseInt(this.value);
|
||
document.getElementById("radiusValue").innerText = radius;
|
||
|
||
if (activeCircle) {
|
||
activeCircle.setRadius(radius);
|
||
updateMarkerColors();
|
||
}
|
||
});
|
||
}
|
||
});
|