/* ============================================================
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: '© OpenStreetMap ',
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: `
${emoji}
`,
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'
? ``
: '';
const bantuanBtn =
p.category === 'poor'
? `
🎁 Bantuan
`
: '';
const bantuanHistory =
bantuan.length
? `
Riwayat Bantuan
${bantuan.map(b => `
• ${b.jenis_bantuan}
(Rp ${Number(b.nominal).toLocaleString('id-ID')})
`).join('')}
`
: '';
return `
${subtypeStr}
${p.description
? `
`
: ''
}
${bantuanHistory}
`;
}
/* 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 = `
${data ? '✏️ Edit Point' : '📍 Add Point'}
Name *
Category *
⛽ SPBU (Gas Station)
🕌 Mosque
🏘️ Poor Population
SPBU Type
24 Hours
Not 24 Hours
Description
${data ? '💾 Update' : '➕ Add Point'}
Cancel
`;
}
else if (type === 'laporan') {
const latlng = extra;
modal.innerHTML = `
📢 Laporan Masyarakat
Nama Pelapor
No HP
Nama Warga
Alamat
Keterangan
Kirim Laporan
Batal
`;
}
else if(type === 'bantuan') {
const pointId = extra;
modal.innerHTML = `
🎁 Tambah Bantuan
Jenis Bantuan
Nominal
Tanggal Bantuan
Instansi Pemberi
Keterangan
Simpan
Batal
`;
}
else if (type === 'road') {
const { latlngs, length } = extra;
const d = data;
modal.innerHTML = `
${d ? '✏️ Edit Road' : '🛣️ Add Road'}
Road Name *
Road Type *
🔴 National Road
🟠 Provincial Road
🔵 District Road
Length (m) — auto-calculated
Description
${d ? '💾 Update' : '➕ Add Road'}
Cancel
`;
// Store pending latlngs in state
state._pendingRoadLatLngs = latlngs;
} else if (type === 'parcel') {
const { ring, area } = extra;
const d = data;
modal.innerHTML = `
${d ? '✏️ Edit Parcel' : '🗺️ Add Land Parcel'}
Owner Name *
Ownership Type *
SHM — Freehold Title
HGB — Building Rights
HGU — Business Rights
HP — Usage Rights
Area (m²) — auto-calculated
Description
${d ? '💾 Update' : '➕ Add Parcel'}
Cancel
`;
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 `
${p.description ? `` : ''}
`;
}
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 `
${p.description ? `` : ''}
`;
}
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 = 'No points yet. Click the map to add.
';
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 `
${escHtml(p.name)}
${catLabel(p.category)}${p.subtype ? ' · ' + p.subtype : ''}
✏️
🗑️
`;
}).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 = 'No roads yet. Use the draw tool to add.
';
return;
}
el.innerHTML = fc.features.map(f => {
const p = f.properties;
const col = ROAD_STYLES[p.road_type]?.color || '#aaa';
return `
${escHtml(p.name)}
${roadTypeLabel(p.road_type)} · ${fmtLength(p.length_m)}
✏️
🗑️
`;
}).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 = 'No parcels yet. Use the polygon tool to add.
';
return;
}
el.innerHTML = fc.features.map(f => {
const p = f.properties;
const col = PARCEL_STYLES[p.ownership_type]?.color || '#aaa';
return `
${escHtml(p.owner_name)}
${p.ownership_type} · ${fmtArea(p.area_m2)}
✏️
🗑️
`;
}).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,'"');
}
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 =
'Belum ada laporan.
';
return;
}
el.innerHTML = data.map(l => `
${l.nama_warga}
${l.status}
${l.alamat || '-'}
${
l.status === 'Menunggu Verifikasi'
? `
✓
✕
`
: ''
}
`).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();
}
});
}
});