Files
SIG_WEBGIS_Nur_Ichsanul_Alif/assets/js/app.js
T
2026-06-10 22:46:10 +07:00

1558 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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);
map.off('click', laporanClickHandler);
};
map.once('click', laporanClickHandler);
}
function disablePointAdd() {
if (pointClickHandler) {
map.off('click', pointClickHandler);
pointClickHandler = null;
}
state.drawMode = null;
updateDrawModeUI();
}
/* 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>
</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:
'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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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();
closeModal();
}
});
function updateMarkerColors() {
if (!selectedCenter) return;
let inside = 0;
let outside = 0;
poorMarkers.forEach(marker => {
let distance = map.distance(selectedCenter, marker.getLatLng());
if (distance <= radius) {
marker.getElement().style.filter = "hue-rotate(0deg)";
inside++;
} else {
marker.getElement().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();
}
});
}
});