/* ============================================================ 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' ? ` ` : ''; 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'}

`; } else if (type === 'laporan') { const latlng = extra; modal.innerHTML = `

📢 Laporan Masyarakat

`; } else if(type === 'bantuan') { const pointId = extra; modal.innerHTML = `

🎁 Tambah Bantuan

`; } else if (type === 'road') { const { latlngs, length } = extra; const d = data; modal.innerHTML = `

${d ? '✏️ Edit Road' : '🛣️ Add Road'}

`; // 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'}

`; 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(); } }); } });