// ============ AUTHENTICATION ============ let currentUser = null; async function checkAuth() { try { const response = await fetch('api/get_current_user.php'); const data = await response.json(); if (data.success && data.user) { currentUser = data.user; hideLoginForm(); showUserInfo(); // Initialize map and the application for existing sessions initializeMap(); initializeApp(); return true; } else { showLoginForm(); return false; } } catch (error) { console.error('Auth check error:', error); showLoginForm(); return false; } } function showLoginForm() { const loginOverlay = document.getElementById('login-overlay'); const userBar = document.getElementById('user-info-bar'); const appContainer = document.querySelector('.app-container'); if (loginOverlay) loginOverlay.classList.add('active'); if (userBar) userBar.style.display = 'none'; if (appContainer) appContainer.style.display = 'none'; } function hideLoginForm() { const loginOverlay = document.getElementById('login-overlay'); const userBar = document.getElementById('user-info-bar'); const appContainer = document.querySelector('.app-container'); if (loginOverlay) loginOverlay.classList.remove('active'); if (userBar) userBar.style.display = 'flex'; if (appContainer) appContainer.style.display = 'flex'; } function showUserInfo() { if (!currentUser) return; const userDisplay = document.getElementById('current-user-display'); const roleDisplay = document.getElementById('current-role-display'); if (userDisplay) userDisplay.textContent = `Hi, ${currentUser.username}`; if (roleDisplay) { roleDisplay.textContent = currentUser.role; roleDisplay.className = `role-badge role-${currentUser.role.toLowerCase()}`; } // Update role-based UI elements updateAdminUI(); } async function handleLogin(event) { event.preventDefault(); const email = document.getElementById('login-email').value; const password = document.getElementById('login-password').value; const loginBtn = document.getElementById('login-btn'); const errorDiv = document.getElementById('login-error'); loginBtn.disabled = true; loginBtn.textContent = 'Logging in...'; errorDiv.classList.remove('show'); try { const response = await fetch('api/login.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await response.json(); if (data.success) { currentUser = data.user; hideLoginForm(); showUserInfo(); // Reset form document.getElementById('login-form').reset(); // Initialize app // Ensure the map is initialized for this session initializeMap(); initializeApp(); } else { errorDiv.textContent = data.message || 'Login failed'; errorDiv.classList.add('show'); } } catch (error) { console.error('Login error:', error); errorDiv.textContent = 'An error occurred during login'; errorDiv.classList.add('show'); } finally { loginBtn.disabled = false; loginBtn.textContent = 'Login'; } } async function handleLogout() { try { await fetch('api/logout.php', { method: 'POST' }); currentUser = null; showLoginForm(); // Clear app data if (map) { map.remove(); map = null; } } catch (error) { console.error('Logout error:', error); } } function initializeApp() { // Show Beranda tab on startup switchTab('beranda'); // Setup modal for all forms setupModal(); // Setup configuration controls setupConfigurationSection(); // Prime the combined report preview so it is ready when Laporan opens loadCombinedReportPreview(); } // ============ GLOBAL STATE ============ let map; let markers = {}; let markerData = {}; // Store marker data for editing let markerCount = 0; let currentMapClickCoords = null; let currentEditingMarkerId = null; let markerIcons = {}; // Will be initialized after Leaflet loads let mapClickMode = 'point'; // Masjid management let masjidMarkers = {}; let masjidCircles = {}; let masjidData = {}; let masjidCount = 0; let currentEditingMasjidId = null; // Special needs point management let needPointMarkers = {}; let needPointData = {}; let needPointCount = 0; let currentEditingNeedPointId = null; // Adaptive rendering state let allNeedPoints = []; // store normalized records for client-side filtering let needPointLayer = null; // L.LayerGroup for visible points let renderingMode = 'auto'; // 'high'|'medium'|'low' let visibleMarkerThreshold = 1000; // will be adjusted based on device function getStoredRenderingMode() { try { return localStorage.getItem('renderingMode'); } catch (e) { return null; } } function setStoredRenderingMode(mode) { try { if (!mode) localStorage.removeItem('renderingMode'); else localStorage.setItem('renderingMode', mode); } catch (e) { // ignore } } // Household/Assistance/Verification (Phase A) let households = []; let householdSummary = null; // Lines/Roads Management let lines = {}; let lineData = {}; let lineCount = 0; // Polygons/Land Parcels Management let polygons = {}; let polygonData = {}; let polygonCount = 0; // Drawing state let drawMode = { active: false, type: null, // 'line' or 'polygon' coordinates: [], temporaryLayer: null, defaultClickHandler: null, tempMarkers: [] // Store temporary circle markers }; // Search/Filter state let filterState = { roads: { search: '', dateFrom: '', dateTo: '', condition: '' }, parcels: { search: '', dateFrom: '', dateTo: '', certificate: '' } }; // Pontianak choropleth state let pontianakState = { active: false, adminLevel: 'kecamatan', currentLayers: { // NEW: Multiple layers can be visible simultaneously population: false, road_density: false, damaged_roads_density: false, city_boundary: false }, layers: {}, // RENAMED from areas: layerType -> array of Leaflet GeoJSON layers colorScales: {}, // NEW: Store color scale for each layer type legendData: null, areasData: {}, // Administrative areas data dataLoaded: false, // NEW: Flag to prevent re-fetching hasFittedBoundary: false }; // Search input debounce timers let searchTimeouts = { roads: null, parcels: null }; // Color styles for roads and polygons const lineStyles = { 'Nasional': { color: '#4CAF50', // Green weight: 4, opacity: 0.8, dashArray: null, lineCap: 'round', lineJoin: 'round' }, 'Provinsi': { color: '#2196F3', // Blue weight: 3.5, opacity: 0.8, dashArray: null, lineCap: 'round', lineJoin: 'round' }, 'Kabupaten': { color: '#FF9800', // Orange weight: 3, opacity: 0.8, dashArray: null, lineCap: 'round', lineJoin: 'round' } }; const polygonStyles = { 'SHM': { fillColor: '#4CAF50', // Green weight: 2, opacity: 0.8, color: '#2E7D32', fillOpacity: 0.3, dashArray: null }, 'HGB': { fillColor: '#2196F3', // Blue weight: 2, opacity: 0.8, color: '#1565C0', fillOpacity: 0.3, dashArray: null }, 'HGU': { fillColor: '#FF9800', // Orange weight: 2, opacity: 0.8, color: '#E65100', fillOpacity: 0.3, dashArray: null }, 'HP': { fillColor: '#9C27B0', // Purple weight: 2, opacity: 0.8, color: '#6A1B9A', fillOpacity: 0.3, dashArray: null } }; // Initialize on page load document.addEventListener('DOMContentLoaded', function() { checkAuth(); }); function initializeMap() { if (map) return; // Map already initialized initMap(); } function setupModal() { const modal = document.getElementById('point-modal'); const addBtn = document.getElementById('add-point-btn'); const addMasjidBtn = document.getElementById('add-masjid-btn'); const addNeedPointBtn = document.getElementById('add-need-point-btn'); const addHouseholdBtn = document.getElementById('add-household-btn'); const addAssistanceBtn = document.getElementById('add-assistance-btn'); const addVerificationBtn = document.getElementById('add-verification-btn'); const computeScoresBtn = document.getElementById('compute-household-scores-btn'); const closeBtn = document.querySelector('.close'); const form = document.getElementById('point-form'); const mapClickModeSelect = document.getElementById('map-click-mode'); const householdFilterStatus = document.getElementById('household-filter-status'); if (addBtn) { addBtn.onclick = function() { openPointModal(null); } } if (addMasjidBtn) { addMasjidBtn.onclick = function() { openMasjidModal(null); } } if (addNeedPointBtn) { addNeedPointBtn.onclick = function() { openNeedPointModal(null); } } if (addHouseholdBtn) { addHouseholdBtn.onclick = function() { openHouseholdModal(); } } if (addAssistanceBtn) { addAssistanceBtn.onclick = function() { openAssistanceModal(); } } if (addVerificationBtn) { addVerificationBtn.onclick = function() { openVerificationModal(); } } if (computeScoresBtn) { computeScoresBtn.onclick = function() { computeHouseholdScores(); } } // Wire existing select if present (legacy); we also add map shortcut buttons if (mapClickModeSelect) { mapClickModeSelect.value = mapClickMode; mapClickModeSelect.onchange = function(event) { setMapClickMode(event.target.value, false); } } // Map shortcut buttons (overlay) function setupMapShortcuts() { const shortcuts = [ { id: 'shortcut-point', mode: 'point' }, { id: 'shortcut-masjid', mode: 'masjid' }, { id: 'shortcut-need', mode: 'need' }, { id: 'shortcut-line', mode: 'line' }, { id: 'shortcut-polygon', mode: 'polygon' } ]; shortcuts.forEach(s => { const el = document.getElementById(s.id); if (!el) return; el.onclick = function() { setMapClickMode(s.mode); updateShortcutButtons(); }; }); // Reflect current mode on buttons function updateShortcutButtons() { const current = (mapClickMode === 'need' || mapClickMode === 'need_point') ? 'need' : mapClickMode; shortcuts.forEach(s => { const el = document.getElementById(s.id); if (!el) return; if (current === s.mode) { el.classList.add('active'); } else { el.classList.remove('active'); } }); } // Expose updater so other parts can refresh visuals window.updateShortcutButtons = updateShortcutButtons; // initial reflect updateShortcutButtons(); } setupMapShortcuts(); // Wire additional map tool buttons if present const addLineBtn = document.getElementById('add-line-btn'); const addPolygonBtn = document.getElementById('add-polygon-btn'); if (addLineBtn) addLineBtn.onclick = function() { enableLineDrawMode(); }; if (addPolygonBtn) addPolygonBtn.onclick = function() { enablePolygonDrawMode(); }; if (householdFilterStatus) { householdFilterStatus.onchange = function() { loadHouseholdsForPanel(); } } if (closeBtn) { closeBtn.onclick = function() { closePointModal(); } } window.onclick = function(event) { if (event.target === modal) { closePointModal(); } const editModal = document.getElementById('edit-modal'); if (event.target === editModal) { closeEditModal(); } const lineModal = document.getElementById('line-modal'); if (event.target === lineModal) { closeLineModal(); } const editLineModal = document.getElementById('edit-line-modal'); if (event.target === editLineModal) { closeEditLineModal(); } const polygonModal = document.getElementById('polygon-modal'); if (event.target === polygonModal) { closePolygonModal(); } const editPolygonModal = document.getElementById('edit-polygon-modal'); if (event.target === editPolygonModal) { closeEditPolygonModal(); } const masjidModal = document.getElementById('masjid-modal'); if (event.target === masjidModal) { closeMasjidModal(); } const editMasjidModal = document.getElementById('edit-masjid-modal'); if (event.target === editMasjidModal) { closeEditMasjidModal(); } const needPointModal = document.getElementById('need-point-modal'); if (event.target === needPointModal) { closeNeedPointModal(); } const editNeedPointModal = document.getElementById('edit-need-point-modal'); if (event.target === editNeedPointModal) { closeEditNeedPointModal(); } const householdModal = document.getElementById('household-modal'); if (event.target === householdModal) { closeHouseholdModal(); } const assistanceModal = document.getElementById('assistance-modal'); if (event.target === assistanceModal) { closeAssistanceModal(); } const verificationModal = document.getElementById('verification-modal'); if (event.target === verificationModal) { closeVerificationModal(); } } if (form) { form.onsubmit = function(e) { e.preventDefault(); submitPointForm(); } } // Setup edit modal form const editForm = document.getElementById('edit-form'); if (editForm) { editForm.onsubmit = function(e) { e.preventDefault(); saveMarkerEdit(); } } const masjidForm = document.getElementById('masjid-form'); if (masjidForm) { masjidForm.onsubmit = function(e) { e.preventDefault(); submitMasjidForm(); } } const editMasjidForm = document.getElementById('edit-masjid-form'); if (editMasjidForm) { editMasjidForm.onsubmit = function(e) { e.preventDefault(); saveMasjidEdit(); } } const needPointForm = document.getElementById('need-point-form'); if (needPointForm) { needPointForm.onsubmit = function(e) { e.preventDefault(); submitNeedPointForm(); } } const editNeedPointForm = document.getElementById('edit-need-point-form'); if (editNeedPointForm) { editNeedPointForm.onsubmit = function(e) { e.preventDefault(); saveNeedPointEdit(); } } const householdForm = document.getElementById('household-form'); if (householdForm) { householdForm.onsubmit = function(e) { e.preventDefault(); submitHouseholdForm(); } } const assistanceForm = document.getElementById('assistance-form'); if (assistanceForm) { assistanceForm.onsubmit = function(e) { e.preventDefault(); submitAssistanceForm(); } } const assistanceHouseholdSelect = document.getElementById('assistance-household-id'); if (assistanceHouseholdSelect) { assistanceHouseholdSelect.onchange = function() { updateAssistanceModalSummary(); }; } const verificationForm = document.getElementById('verification-form'); if (verificationForm) { verificationForm.onsubmit = function(e) { e.preventDefault(); submitVerificationForm(); } } } // ============ TAB MANAGEMENT ============ function switchTab(tabName, clickedButton = null) { if (!isTabAllowedForCurrentUser(tabName)) { showNotification('Akses tab ini tidak tersedia untuk peran Anda', true); tabName = 'beranda'; clickedButton = null; } // Hide all tab contents const tabContents = document.querySelectorAll('.tab-content'); tabContents.forEach(tab => { tab.classList.remove('tab-active'); }); // Remove active class from all buttons const tabButtons = document.querySelectorAll('.tab-button'); tabButtons.forEach(btn => { btn.classList.remove('tab-active'); }); // Get the selected tab element const selectedTab = document.getElementById(tabName + '-tab'); if (selectedTab) { // MOVE TAB TO MAIN CONTENT AREA (NEW LAYOUT) const mainContent = document.querySelector('.main-content'); if (mainContent) { // Remove all existing tab-content from main (they will be moved back to sidebar when hidden) const existingTabs = mainContent.querySelectorAll('.tab-content'); existingTabs.forEach(tab => { // Move back to sidebar (to maintain DOM structure) const sidebar = document.querySelector('.sidebar'); if (sidebar) { sidebar.appendChild(tab); } }); // Add selected tab to main content selectedTab.classList.add('tab-active'); mainContent.appendChild(selectedTab); } else { // Fallback: just show the tab (for old layout compatibility) selectedTab.classList.add('tab-active'); } } // Set active button (supports both click and programmatic calls) let activeButton = clickedButton; if (!activeButton) { activeButton = Array.from(tabButtons).find(btn => { const handler = btn.getAttribute('onclick') || ''; return handler.includes(`'${tabName}'`) || handler.includes(`"${tabName}"`); }); } if (activeButton) { activeButton.classList.add('tab-active'); } // Special handling for Beranda tab (home/dashboard) if (tabName === 'beranda') { loadBerandaDashboard(); return; } // Special handling for Peta tab (map) if (tabName === 'peta') { // Move map into peta-tab container for proper display const mapContainer = document.getElementById('peta-map-container'); const mapElement = document.getElementById('map'); if (mapContainer && mapElement && mapElement.parentElement !== mapContainer) { mapContainer.appendChild(mapElement); mapContainer.style.display = 'block'; // Reinitialize map in new container. Give the layout // a bit more time to settle, then force a resize and // refresh the view so tiles render correctly. if (map) { setTimeout(() => { try { map.invalidateSize(); map.invalidateSize(true); // re-center to current center to force redraw const c = map.getCenter(); map.setView([c.lat, c.lng]); } catch (e) { console.warn('Map resize error', e); } }, 300); } } loadMarkers(); loadMasjids(); loadNeedPoints(); loadLines(); loadPolygons(); loadHouseholdsForPanel(); loadHouseholdSummary(); loadHouseholdScoreSummary(); loadHouseholdPriorityRanking(); return; } // Special handling for Managemen tab if (tabName === 'managemen') { loadManagement(); return; } // Special handling for Laporan tab (reports) if (tabName === 'laporan') { loadCombinedReportPreview(); return; } // Special handling for Dashboard tab if (tabName === 'dashboard') { loadDashboard(); return; } // Special handling for Users tab (admin only) if (tabName === 'users') { if (!currentUser || currentUser.role !== 'Admin') { showNotification('Only admins can access this section', true); return; } loadUsersList(); return; } // Special handling for Configuration tab (admin only) if (tabName === 'konfigurasi') { if (!currentUser || currentUser.role !== 'Admin') { showNotification('Only admins can access this section', true); return; } loadConfiguration(); return; } // Special handling for Pontianak tab if (tabName === 'pontianak') { // Clear other features Object.keys(markers).forEach(id => { if (map.hasLayer(markers[id])) map.removeLayer(markers[id]); }); Object.keys(masjidMarkers).forEach(id => { if (map.hasLayer(masjidMarkers[id])) map.removeLayer(masjidMarkers[id]); }); Object.keys(masjidCircles).forEach(id => { if (map.hasLayer(masjidCircles[id])) map.removeLayer(masjidCircles[id]); }); Object.keys(needPointMarkers).forEach(id => { if (map.hasLayer(needPointMarkers[id])) map.removeLayer(needPointMarkers[id]); }); Object.keys(lines).forEach(id => { if (map.hasLayer(lines[id])) map.removeLayer(lines[id]); }); Object.keys(polygons).forEach(id => { if (map.hasLayer(polygons[id])) map.removeLayer(polygons[id]); }); pontianakState.active = true; if (!pontianakState.hasFittedBoundary) { map.setView([-0.0263, 109.3425], 12); } initPontianakMap(); } else { // Unload Pontianak features and reset state Object.keys(pontianakState.layers).forEach(layerType => { if (pontianakState.layers[layerType]) { pontianakState.layers[layerType].forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); }); } }); pontianakState.layers = {}; pontianakState.areasData = {}; pontianakState.dataLoaded = false; pontianakState.hasFittedBoundary = false; pontianakState.active = false; // Reload regular features if this is one of the main tabs if (tabName === 'points' || tabName === 'peta' || tabName === 'roads' || tabName === 'parcels') { loadMarkers(); loadMasjids(); loadNeedPoints(); loadLines(); loadPolygons(); if (tabName === 'points' || tabName === 'peta') { loadHouseholdsForPanel(); loadHouseholdSummary(); loadHouseholdScoreSummary(); loadHouseholdPriorityRanking(); } } } } function setMapClickMode(mode, showToast = true) { // Normalize mode values (support legacy 'need_point') function normalizeMode(m) { if (!m) return m; if (m === 'need_point') return 'need'; return m; } // Accept drawing modes as well const validModes = ['point', 'masjid', 'need', 'line', 'polygon']; const normalized = normalizeMode(mode); if (!validModes.includes(normalized)) return; // If switching from an active draw mode, cancel any drawing first if (drawMode.active && (mode !== 'line' && mode !== 'polygon')) { cancelDrawing(); } mapClickMode = normalized; const modeSelect = document.getElementById('map-click-mode'); if (modeSelect && modeSelect.value !== normalized) modeSelect.value = normalized; if (showToast) { const modeLabels = { point: 'Point Biasa', masjid: 'Masjid', need: 'Point Berkebutuhan', line: 'Gambar Jalan (Draw Road)', polygon: 'Gambar Parcel (Draw Polygon)' }; showNotification('Mode peta: ' + (modeLabels[mode] || mode)); } // If user selected drawing modes via the select, activate draw mode if (normalized === 'line') { enableLineDrawMode(); } else if (normalized === 'polygon') { enablePolygonDrawMode(); } else { // restore default click handler if (drawMode.defaultClickHandler) { map.off('click', handleMapClickDuringDraw); map.on('click', drawMode.defaultClickHandler); } } } // Update shortcut button visuals if present if (window.updateShortcutButtons && typeof window.updateShortcutButtons === 'function') { window.updateShortcutButtons(); } function openPointModal(coords) { const modal = document.getElementById('point-modal'); const form = document.getElementById('point-form'); const latInput = document.getElementById('point-latitude'); const lngInput = document.getElementById('point-longitude'); const nameInput = document.getElementById('point-name'); const formInfo = document.getElementById('form-info'); const modalTitle = document.getElementById('modal-title'); form.reset(); currentMapClickCoords = coords; if (coords) { latInput.value = coords.lat.toFixed(6); lngInput.value = coords.lng.toFixed(6); latInput.readOnly = true; lngInput.readOnly = true; modalTitle.textContent = 'Create Point'; formInfo.textContent = '✓ Coordinates auto-filled from map click. Just add a name!'; formInfo.classList.add('show'); nameInput.focus(); } else { latInput.value = ''; lngInput.value = ''; latInput.readOnly = false; lngInput.readOnly = false; modalTitle.textContent = 'Add New Point'; formInfo.classList.remove('show'); nameInput.focus(); } modal.classList.add('show'); } function closePointModal() { const modal = document.getElementById('point-modal'); modal.classList.remove('show'); currentMapClickCoords = null; } function submitPointForm() { const name = document.getElementById('point-name').value.trim(); const nomor_spbu = document.getElementById('point-nomor-spbu').value.trim(); const latitude = parseFloat(document.getElementById('point-latitude').value); const longitude = parseFloat(document.getElementById('point-longitude').value); const open_24_hours = document.getElementById('point-24hours').checked ? 1 : 0; // Validate inputs if (!name) { showNotification('Please enter a name for the point', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Please enter a valid latitude (-90 to 90)', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Please enter a valid longitude (-180 to 180)', true); return; } addMarker(name, nomor_spbu, latitude, longitude, open_24_hours); } function initMap() { // Create map centered on Pontianak by default map = L.map('map').setView([-0.0263, 109.3425], 13); // Initialize marker icons after Leaflet is loaded markerIcons.open = L.divIcon({ html: `
`, iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -36], className: 'custom-marker open' }); markerIcons.closed = L.divIcon({ html: `
`, iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -36], className: 'custom-marker closed' }); markerIcons.masjid = L.divIcon({ html: `
M
`, iconSize: [38, 38], iconAnchor: [19, 38], popupAnchor: [0, -36], className: 'custom-marker masjid' }); markerIcons.need = L.divIcon({ html: `
K
`, iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -36], className: 'custom-marker need' }); // Add OpenStreetMap tile layer L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); // Store default click handler drawMode.defaultClickHandler = function(e) { if (!drawMode.active) { const coords = { lat: e.latlng.lat, lng: e.latlng.lng }; if (mapClickMode === 'masjid') { openMasjidModal(coords); } else if (mapClickMode === 'need') { openNeedPointModal(coords); } else { openPointModal(coords); } } }; // Handle map click to add marker with auto-filled coordinates map.on('click', drawMode.defaultClickHandler); } function addMarker(name, nomor_spbu, lat, lng, open_24_hours) { // Save to database fetch('api/save_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: name, nomor_spbu: nomor_spbu, latitude: lat, longitude: lng, open_24_hours: open_24_hours }) }) .then(response => response.json()) .then(data => { if (data.success) { displayMarker(data.id, data.name, data.nomor_spbu, data.latitude, data.longitude, data.open_24_hours); updateMarkerCount(); showNotification('✓ Point added successfully'); closePointModal(); } else { showNotification('Error adding point: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error adding point', true); }); } function displayMarker(id, name, nomor_spbu, lat, lng, open_24_hours) { // Store marker data for editing markerData[id] = { id: id, name: name, nomor_spbu: nomor_spbu, latitude: lat, longitude: lng, open_24_hours: open_24_hours }; // Use colored icon based on status const icon = open_24_hours ? markerIcons.open : markerIcons.closed; const marker = L.marker([lat, lng], { draggable: true, icon: icon }).addTo(map); const updateMarkerPopup = (newLat, newLng) => { const hoursText = open_24_hours ? '🕐 Open 24 Hours' : '🕐 Limited Hours'; let popupContent = `
${escapeHtml(name)}
${hoursText}
`; if (nomor_spbu) { popupContent += `
🏢 SPBU: ${escapeHtml(nomor_spbu)}
`; } popupContent += `
Lat: ${newLat.toFixed(4)}
Lng: ${newLng.toFixed(4)}
`; marker.setPopupContent(popupContent); }; updateMarkerPopup(lat, lng); marker.bindPopup(); // Handle marker drag marker.on('dragend', function() { const newLat = marker.getLatLng().lat; const newLng = marker.getLatLng().lng; // Update in database fetch('api/update_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id, latitude: newLat, longitude: newLng }) }) .then(response => response.json()) .then(data => { if (data.success) { updateMarkerPopup(newLat, newLng); // Update markerData with new coordinates markerData[id].latitude = newLat; markerData[id].longitude = newLng; // Reopen popup to show updated coordinates marker.closePopup(); marker.openPopup(); showNotification('✓ Point location updated'); } else { // Revert to old position if update failed marker.setLatLng([lat, lng]); showNotification('Error updating point location', true); } }) .catch(error => { console.error('Error:', error); marker.setLatLng([lat, lng]); showNotification('Error updating point location', true); }); }); // Store marker and update reference markers[id] = marker; markerCount++; } // ============ BERANDA DASHBOARD ============ function loadBerandaDashboard() { Promise.all([ fetch('api/get_need_points.php').then(response => response.json()), fetch('api/get_configuration.php').then(response => response.json()), fetch('api/get_dashboard_summary.php').then(response => response.json()) ]) .then(([needPointData, configurationData, dashboardData]) => { const totalNeedPoints = needPointData?.success && Array.isArray(needPointData.points) ? needPointData.points.length : 0; const populationReference = configurationData?.success ? Number(configurationData.configuration?.population_reference || 288315899) : 288315899; const povertyPercentage = populationReference > 0 ? (totalNeedPoints / populationReference) * 100 : 0; const targetNasional = configurationData?.success ? Number(configurationData.configuration?.target_nasional || 7.50) : 7.50; document.getElementById('beranda-total-poor').textContent = totalNeedPoints.toLocaleString('id-ID'); document.getElementById('beranda-percentage').textContent = povertyPercentage.toFixed(6); document.getElementById('beranda-target').textContent = targetNasional.toFixed(2); if (dashboardData?.success) { updateReportSummary(dashboardData.summary); } }) .catch(error => console.error('Error loading beranda dashboard:', error)); // Load regional data fetch('api/get_area_breakdown.php') .then(response => response.json()) .then(data => { if (data.success && Array.isArray(data.areas)) { renderBerandaRegions(data.areas.slice(0, 5)); } }) .catch(error => console.error('Error loading area breakdown:', error)); // Load trend chart data loadTrendChart(); } function renderBerandaRegions(areas) { const regionList = document.getElementById('beranda-region-list'); if (!regionList) return; if (!Array.isArray(areas) || areas.length === 0) { regionList.innerHTML = `
Belum ada data provinsi yang tersedia.
`; return; } regionList.innerHTML = areas.map((area, index) => `
${index + 1}. ${area.area_name || 'N/A'} ${Number(area.household_count || 0).toLocaleString('id-ID')} titik kebutuhan
`).join(''); } // Load and render trend charts function loadTrendChart() { fetch('api/get_dashboard_trend.php') .then(response => response.json()) .then(data => { if (data.success && data.charts) { renderTrendCharts(data); } }) .catch(error => console.error('Error loading trend data:', error)); } // Render two Chart.js comparison charts: population totals and poverty percentage function renderTrendCharts(data) { const populationCanvas = document.getElementById('populationChart'); const populationPlaceholder = document.getElementById('populationChartPlaceholder'); const percentageCanvas = document.getElementById('percentageChart'); const percentagePlaceholder = document.getElementById('percentageChartPlaceholder'); if (!populationCanvas || !percentageCanvas || typeof Chart === 'undefined') return; // Show canvases and hide placeholders populationCanvas.style.display = 'block'; if (populationPlaceholder) populationPlaceholder.style.display = 'none'; percentageCanvas.style.display = 'block'; if (percentagePlaceholder) percentagePlaceholder.style.display = 'none'; // Destroy existing charts if they exist if (window.populationChartInstance) { window.populationChartInstance.destroy(); } if (window.percentageChartInstance) { window.percentageChartInstance.destroy(); } // CHART 1: Bar chart for absolute population totals const chart1Data = data.charts.chart1; const popCtx = populationCanvas.getContext('2d'); window.populationChartInstance = new Chart(popCtx, { type: 'bar', data: { labels: chart1Data.labels, datasets: [{ data: chart1Data.data, backgroundColor: [ '#FF6B6B', '#4ECDC4' ], borderColor: [ '#ff5252', '#45b8ad' ], borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false, labels: { color: '#fff', font: { size: 12 }, padding: 15 } }, tooltip: { callbacks: { label: function(context) { return context.label + ': ' + formatPopulationValue(context.parsed && typeof context.parsed === 'object' ? context.parsed.y : context.parsed); } }, backgroundColor: 'rgba(0, 0, 0, 0.8)', titleColor: '#fff', bodyColor: '#fff' } }, scales: { x: { ticks: { color: '#fff' }, grid: { color: 'rgba(255,255,255,0.12)' } }, y: { beginAtZero: true, ticks: { color: '#fff', callback: function(value) { return formatPopulationAxisValue(value); } }, grid: { color: 'rgba(255,255,255,0.12)' } } } } }); // CHART 2: Bar chart for poverty percentage vs target const chart2Data = data.charts.chart2; const pctCtx = percentageCanvas.getContext('2d'); window.percentageChartInstance = new Chart(pctCtx, { type: 'bar', data: { labels: chart2Data.labels, datasets: [{ data: chart2Data.data, backgroundColor: [ '#00D4FF', '#FFB84D' ], borderColor: [ '#00b8cc', '#ff9900' ], borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false, labels: { color: '#fff', font: { size: 12 }, padding: 15 } }, tooltip: { callbacks: { label: function(context) { const value = context.parsed && typeof context.parsed === 'object' ? context.parsed.y : context.parsed; return context.label + ': ' + Number(value).toFixed(4) + '%'; } }, backgroundColor: 'rgba(0, 0, 0, 0.8)', titleColor: '#fff', bodyColor: '#fff' } }, scales: { x: { ticks: { color: '#fff' }, grid: { color: 'rgba(255,255,255,0.12)' } }, y: { beginAtZero: true, ticks: { color: '#fff', callback: function(value) { return value + '%'; } }, grid: { color: 'rgba(255,255,255,0.12)' } } } } }); } function formatPopulationValue(value) { const numericValue = Number(value) || 0; if (numericValue >= 1000000000) return (numericValue / 1000000000).toFixed(1) + 'B'; if (numericValue >= 1000000) return (numericValue / 1000000).toFixed(1) + 'M'; if (numericValue >= 1000) return (numericValue / 1000).toFixed(1) + 'K'; return String(Math.round(numericValue)); } function formatPopulationAxisValue(value) { const numericValue = Number(value) || 0; if (numericValue >= 1000000000) return (numericValue / 1000000000).toFixed(1) + 'B'; if (numericValue >= 1000000) return (numericValue / 1000000).toFixed(1) + 'M'; if (numericValue >= 1000) return (numericValue / 1000).toFixed(1) + 'K'; return String(Math.round(numericValue)); } function updateReportSummary(summary) { const reportTotal = document.getElementById('report-total'); const reportPct = document.getElementById('report-pct'); const reportHhTotal = document.getElementById('report-hh-total'); if (reportTotal) reportTotal.textContent = summary.total_households || '0'; if (reportPct) reportPct.textContent = (summary.avg_poverty_score || 0).toFixed(2); if (reportHhTotal) reportHhTotal.textContent = summary.total_households || '0'; } // ============ LAPORAN DOWNLOAD ============ function loadLaporanSummary() { loadCombinedReportPreview(); } async function loadCombinedReportPreview() { const metaElement = document.getElementById('report-preview-meta'); const frameElement = document.getElementById('report-preview-frame'); if (!metaElement || !frameElement) return; metaElement.textContent = 'Memuat data laporan lengkap...'; try { const [summaryResponse, areaResponse, configResponse, needPointsResponse, allRankings] = await Promise.all([ fetch('api/get_dashboard_summary.php'), fetch('api/get_area_breakdown.php'), fetch('api/get_configuration.php'), fetch('api/get_need_points.php'), fetchAllHouseholdRankingForReport() ]); const [summaryData, areaData, configurationData, needPointsData] = await Promise.all([ summaryResponse.json(), areaResponse.json(), configResponse.json(), needPointsResponse.json() ]); const combinedReport = buildCombinedReportData(summaryData, areaData, configurationData, needPointsData, allRankings); const pdfUrl = await renderCombinedPdfPreview(combinedReport); if (window.currentCombinedReportPdfUrl) { URL.revokeObjectURL(window.currentCombinedReportPdfUrl); } frameElement.src = pdfUrl; metaElement.textContent = `Data dimuat: ${combinedReport.summary.totalNeedPoints.toLocaleString('id-ID')} titik kebutuhan & ${combinedReport.rankings.length.toLocaleString('id-ID')} household skor prioritas`; window.currentCombinedReportPdfUrl = pdfUrl; updateReportSummary(combinedReport.summary.dashboardSummary); } catch (error) { console.error('Error loading combined report preview:', error); metaElement.textContent = 'Preview laporan gagal dimuat.'; } } async function fetchAllHouseholdRankingForReport() { const allRows = []; const pageSize = 1000; let offset = 0; while (true) { const response = await fetch(`api/get_household_ranking.php?include_assisted=1&limit=${pageSize}&offset=${offset}`); const data = await response.json(); if (!data || !data.success || !Array.isArray(data.ranking)) { throw new Error('Gagal memuat data ranking household'); } allRows.push(...data.ranking); if (data.ranking.length < pageSize) { break; } offset += pageSize; } return allRows; } function getCanvasImageDataUrl(canvasId) { const canvas = document.getElementById(canvasId); if (!canvas || typeof canvas.toDataURL !== 'function') return null; try { return canvas.toDataURL('image/png'); } catch (error) { console.warn('Gagal mengambil gambar canvas:', canvasId, error); return null; } } function createIndonesiaMapPreviewDataUrl(needPoints) { try { const canvas = document.createElement('canvas'); canvas.width = 960; canvas.height = 520; const ctx = canvas.getContext('2d'); if (!ctx) return null; const w = canvas.width; const h = canvas.height; const mapLeft = 70; const mapTop = 70; const mapWidth = w - 120; const mapHeight = h - 130; // Background and map frame const gradient = ctx.createLinearGradient(0, 0, w, h); gradient.addColorStop(0, '#e7f0ff'); gradient.addColorStop(1, '#f6fbff'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, w, h); ctx.fillStyle = '#ffffff'; ctx.fillRect(mapLeft, mapTop, mapWidth, mapHeight); ctx.strokeStyle = '#b8d0ff'; ctx.lineWidth = 2; ctx.strokeRect(mapLeft, mapTop, mapWidth, mapHeight); ctx.fillStyle = '#2f4f9b'; ctx.font = 'bold 18px Arial'; ctx.fillText('Pratinjau Peta Indonesia (Data Titik Kebutuhan)', 24, 34); const lonMin = 95; const lonMax = 141; const latMin = -11; const latMax = 6; // Grid lines ctx.strokeStyle = '#e1ebff'; ctx.lineWidth = 1; for (let i = 1; i < 6; i++) { const x = mapLeft + (i / 6) * mapWidth; ctx.beginPath(); ctx.moveTo(x, mapTop); ctx.lineTo(x, mapTop + mapHeight); ctx.stroke(); } for (let i = 1; i < 4; i++) { const y = mapTop + (i / 4) * mapHeight; ctx.beginPath(); ctx.moveTo(mapLeft, y); ctx.lineTo(mapLeft + mapWidth, y); ctx.stroke(); } const points = Array.isArray(needPoints) ? needPoints : []; ctx.fillStyle = 'rgba(220, 53, 69, 0.35)'; for (const point of points) { const lat = Number(point.latitude); const lon = Number(point.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; if (lat < latMin || lat > latMax || lon < lonMin || lon > lonMax) continue; const x = mapLeft + ((lon - lonMin) / (lonMax - lonMin)) * mapWidth; const y = mapTop + ((latMax - lat) / (latMax - latMin)) * mapHeight; ctx.beginPath(); ctx.arc(x, y, 1.7, 0, Math.PI * 2); ctx.fill(); } ctx.fillStyle = '#3f5fa8'; ctx.font = '12px Arial'; ctx.fillText('Batas: 95°E - 141°E, 11°S - 6°N', mapLeft, h - 24); ctx.fillText(`Total titik: ${points.length.toLocaleString('id-ID')}`, w - 230, h - 24); return canvas.toDataURL('image/png'); } catch (error) { console.warn('Gagal membuat pratinjau peta Indonesia:', error); return null; } } function buildCombinedReportData(summaryData, areaData, configurationData, needPointsData, rankingData) { const areas = Array.isArray(areaData?.areas) ? areaData.areas : []; const rankings = Array.isArray(rankingData) ? rankingData : []; const dashboardSummary = summaryData?.success ? summaryData.summary : {}; const totalNeedPoints = Array.isArray(needPointsData?.points) ? needPointsData.points.length : 0; const needPoints = Array.isArray(needPointsData?.points) ? needPointsData.points : []; const populationReference = Number(configurationData?.configuration?.population_reference || 288315899); const targetNasional = Number(configurationData?.configuration?.target_nasional || 7.5); const povertyPercentage = populationReference > 0 ? (totalNeedPoints / populationReference) * 100 : 0; const verifiedCount = needPoints.filter(item => Number(item.is_verified) === 1).length; const chartPopulation = getCanvasImageDataUrl('populationChart'); const chartPercentage = getCanvasImageDataUrl('percentageChart'); const mapPreview = createIndonesiaMapPreviewDataUrl(needPoints); return { metadata: { createdAt: new Date(), populationReference, targetNasional, povertyPercentage }, summary: { totalNeedPoints, verifiedCount, totalAssistance: 0, dashboardSummary }, areas, needPoints, rankings, charts: { population: chartPopulation, percentage: chartPercentage }, mapPreview }; } async function renderCombinedPdfPreview(report) { const jsPDFGlobal = window.jspdf && window.jspdf.jsPDF ? window.jspdf.jsPDF : null; if (!jsPDFGlobal) { throw new Error('jsPDF library is not available'); } const doc = new jsPDFGlobal({ unit: 'pt', format: 'a4' }); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); const margin = 40; let cursorY = 48; const now = report.metadata.createdAt ? new Date(report.metadata.createdAt) : new Date(); const formattedDate = now.toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' }); const addPageNumbers = () => { const totalPages = doc.internal.pages.length - 1; for (let i = 1; i <= totalPages; i++) { doc.setPage(i); doc.setFontSize(9); doc.setTextColor(150, 150, 150); doc.text('Halaman ' + i + ' dari ' + totalPages, pageWidth - margin - 40, pageHeight - 20); } }; const addCoverPage = () => { doc.setFillColor(102, 126, 234); doc.rect(0, 0, pageWidth, pageHeight / 2, 'F'); doc.setFillColor(118, 75, 162); doc.rect(0, pageHeight / 2, pageWidth, pageHeight / 2, 'F'); doc.setTextColor(255, 255, 255); doc.setFont('helvetica', 'bold'); doc.setFontSize(32); doc.text('LAPORAN GABUNGAN', pageWidth / 2, 180, { align: 'center' }); doc.setFontSize(24); doc.text('Sistem Informasi Geografis', pageWidth / 2, 220, { align: 'center' }); doc.setDrawColor(255, 255, 255); doc.setLineWidth(2); doc.line(margin * 2, 250, pageWidth - margin * 2, 250); doc.setFont('helvetica', 'normal'); doc.setFontSize(11); const summaryY = 300; doc.text('Total Point Berkebutuhan: ' + String(report.summary.totalNeedPoints), pageWidth / 2, summaryY, { align: 'center' }); doc.text('Persentase Kemiskinan: ' + report.metadata.povertyPercentage.toFixed(2) + '%', pageWidth / 2, summaryY + 20, { align: 'center' }); doc.text('Target Nasional: ' + report.metadata.targetNasional.toFixed(2) + '%', pageWidth / 2, summaryY + 40, { align: 'center' }); doc.text('Total Populasi: ' + String(report.metadata.populationReference), pageWidth / 2, summaryY + 60, { align: 'center' }); doc.setFontSize(9); doc.setTextColor(255, 255, 255); doc.text('Dibuat: ' + formattedDate, pageWidth / 2, pageHeight - 80, { align: 'center' }); doc.setFontSize(8); doc.text('Sistem Informasi Geografis Pontianak', pageWidth / 2, pageHeight - 50, { align: 'center' }); doc.text('Dokumen Internal', pageWidth / 2, pageHeight - 30, { align: 'center' }); doc.addPage(); cursorY = 48; doc.setTextColor(25, 25, 25); }; const addTitle = (title, subtitle) => { doc.setFillColor(102, 126, 234); doc.rect(0, 0, pageWidth, 78, 'F'); doc.setTextColor(255, 255, 255); doc.setFont('helvetica', 'bold'); doc.setFontSize(20); doc.text(title, margin, 32); doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.text(subtitle, margin, 52); cursorY = 100; doc.setTextColor(25, 25, 25); }; const addSectionTitle = (title) => { if (cursorY > pageHeight - 80) { doc.addPage(); cursorY = 48; } doc.setFillColor(243, 246, 255); doc.rect(margin, cursorY - 6, pageWidth - margin * 2, 32, 'F'); doc.setDrawColor(102, 126, 234); doc.setLineWidth(2); doc.line(margin, cursorY + 20, pageWidth - margin, cursorY + 20); doc.setFont('helvetica', 'bold'); doc.setFontSize(13); doc.setTextColor(102, 126, 234); doc.text(title, margin + 10, cursorY + 14); cursorY += 36; doc.setTextColor(54, 65, 83); }; const addMetricsGrid = (metrics) => { const cellWidth = (pageWidth - margin * 2 - 18) / 2; metrics.forEach((metric, index) => { const x = margin + (index % 2) * (cellWidth + 18); const y = cursorY + Math.floor(index / 2) * 58; doc.setFillColor(243, 246, 255); doc.rect(x, y, cellWidth, 50, 'F'); doc.setDrawColor(102, 126, 234); doc.setLineWidth(0.5); doc.rect(x, y, cellWidth, 50); doc.setFont('helvetica', 'bold'); doc.setFontSize(9); doc.setTextColor(102, 126, 234); doc.text(metric.label, x + 8, y + 16); doc.setFontSize(14); doc.setTextColor(24, 24, 24); doc.text(String(metric.value), x + 8, y + 33); }); cursorY += Math.ceil(metrics.length / 2) * 58 + 6; }; const addParagraph = (text) => { const lines = doc.splitTextToSize(text, pageWidth - margin * 2); doc.setFont('helvetica', 'normal'); doc.setFontSize(10); doc.setTextColor(60, 60, 60); doc.text(lines, margin, cursorY); cursorY += lines.length * 12 + 8; }; const addImageBlock = (title, dataUrl, width, height) => { if (!dataUrl) return; if (cursorY + height + 36 > pageHeight - 35) { doc.addPage(); cursorY = 48; } doc.setFont('helvetica', 'bold'); doc.setFontSize(11); doc.setTextColor(54, 65, 83); doc.text(title, margin, cursorY); cursorY += 12; doc.addImage(dataUrl, 'PNG', margin, cursorY, width, height); cursorY += height + 14; }; const addAutoTable = (head, body, options) => { var tableOptions = { startY: cursorY, head: head, body: body, theme: 'striped', headStyles: { fillColor: [102, 126, 234] }, styles: { fontSize: 8, cellPadding: 4 }, margin: { left: margin, right: margin } }; if (options) { Object.assign(tableOptions, options); } doc.autoTable(tableOptions); cursorY = doc.lastAutoTable.finalY + 18; }; addCoverPage(); addTitle('Laporan Gabungan Sistem Informasi Geografis', 'Memuat seluruh data Beranda, Peta, dan Manajemen'); addSectionTitle('Ringkasan Eksekutif'); addMetricsGrid([ { label: 'Total Point Berkebutuhan', value: report.summary.totalNeedPoints.toLocaleString('id-ID') }, { label: 'Persentase Kemiskinan', value: report.metadata.povertyPercentage.toFixed(6) + '%' }, { label: 'Target Nasional', value: report.metadata.targetNasional.toFixed(2) + '%' }, { label: 'Total Populasi Indonesia', value: report.metadata.populationReference.toLocaleString('id-ID') } ]); addParagraph('Laporan ini memuat seluruh informasi utama dari Beranda, pratinjau peta Indonesia berbasis data peta, serta daftar lengkap titik kebutuhan dan household dengan skor prioritas.'); addSectionTitle('Grafik Beranda'); const chartWidth = (pageWidth - margin * 2 - 16) / 2; const chartHeight = 170; if (cursorY + chartHeight + 40 > pageHeight - 35) { doc.addPage(); cursorY = 48; } if (report.charts.population) { doc.addImage(report.charts.population, 'PNG', margin, cursorY, chartWidth, chartHeight); } if (report.charts.percentage) { doc.addImage(report.charts.percentage, 'PNG', margin + chartWidth + 16, cursorY, chartWidth, chartHeight); } cursorY += chartHeight + 18; addSectionTitle('Pratinjau Peta Indonesia'); addImageBlock('Sebaran titik kebutuhan dari modul peta', report.mapPreview, pageWidth - margin * 2, 230); addSectionTitle('Ringkasan Wilayah (Beranda)'); const areaRows = report.areas.map(area => [ area.area_name || '-', String(area.household_count || 0), String(area.area_type || '-') ]); addAutoTable( [['Wilayah', 'Jumlah Titik Kebutuhan', 'Tipe']], areaRows.length > 0 ? areaRows : [['-', '0', '-']] ); addSectionTitle('Daftar Titik Kebutuhan (Semua Data)'); const needPointRows = report.needPoints.map((point, index) => [ String(index + 1), point.name || '-', point.head_name || '-', point.economic_status || '-', point.masjid_name || '-', Number(point.latitude || 0).toFixed(4) + ', ' + Number(point.longitude || 0).toFixed(4) ]); addAutoTable( [['No', 'Nama Titik', 'Kepala Keluarga', 'Status Ekonomi', 'Masjid', 'Koordinat']], needPointRows.length > 0 ? needPointRows : [['-', '-', '-', '-', '-', '-']], { styles: { fontSize: 7, cellPadding: 3 } } ); addSectionTitle('Household dengan Skor Prioritas (Semua Data)'); const rankingRows = report.rankings.map((item, index) => [ String(index + 1), item.household_code || '-', item.head_name || '-', item.priority_level || '-', Number(item.skr || 0).toFixed(3), Number(item.spi || 0).toFixed(3), item.area_name || '-', Number(item.assisted || 0) === 1 ? 'Ya' : 'Belum' ]); addAutoTable( [['No', 'Kode Household', 'Kepala Keluarga', 'Prioritas', 'SKR', 'SPI', 'Wilayah', 'Sudah Dibantu']], rankingRows.length > 0 ? rankingRows : [['-', '-', '-', '-', '0.000', '0.000', '-', '-']], { styles: { fontSize: 7, cellPadding: 3 } } ); addSectionTitle('Data Geospasial'); addParagraph('Total titik kebutuhan: ' + report.needPoints.length.toLocaleString('id-ID') + ' data. Total household skor prioritas: ' + report.rankings.length.toLocaleString('id-ID') + ' data. Ringkasan wilayah beranda: ' + report.areas.length.toLocaleString('id-ID') + ' data. Export GeoJSON tetap tersedia dari menu peta.'); addPageNumbers(); const pdfBlob = doc.output('blob'); return URL.createObjectURL(pdfBlob); } async function downloadCombinedPDF() { if (!window.currentCombinedReportPdfUrl) { await loadCombinedReportPreview(); } if (!window.currentCombinedReportPdfUrl) { showNotification('Preview laporan belum siap', true); return; } const link = document.createElement('a'); link.href = window.currentCombinedReportPdfUrl; link.download = `laporan-gabungan-${new Date().toISOString().slice(0, 10)}.pdf`; document.body.appendChild(link); link.click(); document.body.removeChild(link); } async function downloadGeojson() { try { const response = await fetch('api/export_geojson.php?include_households=true&include_points=true&include_roads=true'); const geojson = await response.json(); const element = document.createElement('a'); element.setAttribute('href', 'data:application/geo+json;charset=utf-8,' + encodeURIComponent(JSON.stringify(geojson, null, 2))); element.setAttribute('download', 'geospatial-data-' + new Date().toISOString().slice(0, 10) + '.geojson'); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); showNotification('GeoJSON data berhasil diunduh'); } catch (error) { console.error('Error downloading GeoJSON:', error); showNotification('Error downloading GeoJSON', true); } } // ============ DASHBOARD ============ function loadDashboard() { fetch('api/get_dashboard_summary.php') .then(response => response.json()) .then(data => { if (data.success) { renderDashboardSummary(data); } }) .catch(error => console.error('Error loading dashboard:', error)); fetch('api/get_area_breakdown.php') .then(response => response.json()) .then(data => { if (data.success) { renderAreaBreakdown(data.areas); } }) .catch(error => console.error('Error loading area breakdown:', error)); } function renderDashboardSummary(data) { const summary = data.summary; // Update summary cards document.getElementById('dash-total-households').textContent = summary.total_households; document.getElementById('dash-avg-score').textContent = summary.avg_poverty_score; document.getElementById('dash-assisted').textContent = summary.households_assisted; document.getElementById('dash-assisted-pct').textContent = summary.percentage_assisted + '%'; // Render status breakdown const statusList = document.getElementById('dash-status-list'); statusList.innerHTML = ''; const statusLabels = { 'unverified': '⏱️ Belum Verifikasi', 'field_verified': '✓ Terverifikasi Lapangan', 'admin_approved': '✅ Admin Approved', 'needs_review': '🔍 Perlu Review', 'rejected': '❌ Ditolak' }; for (const [status, count] of Object.entries(data.summary.status_breakdown || {})) { const item = document.createElement('div'); item.className = 'dashboard-status-item'; item.textContent = `${statusLabels[status] || status}: ${count}`; statusList.appendChild(item); } // Render priority breakdown const priorityList = document.getElementById('dash-priority-list'); priorityList.innerHTML = ''; const priorityLabels = { 'very_high': '🔴 Sangat Tinggi', 'high': '🟠 Tinggi', 'medium': '🟡 Menengah', 'low': '🟢 Rendah' }; for (const [priority, count] of Object.entries(data.summary.priority_breakdown || {})) { const item = document.createElement('div'); item.className = 'dashboard-priority-item'; item.textContent = `${priorityLabels[priority] || priority}: ${count}`; priorityList.appendChild(item); } } function renderAreaBreakdown(areas) { const areaList = document.getElementById('dash-area-list'); areaList.innerHTML = ''; areas.slice(0, 10).forEach(area => { const item = document.createElement('div'); item.className = 'dashboard-area-item'; item.innerHTML = `
${area.area_name}
Keluarga: ${area.household_count || 0}
Score Avg: ${(area.avg_poverty_score || 0).toFixed(1)}
Terverif: ${area.verified_count || 0}
`; areaList.appendChild(item); }); } function refreshDashboard() { loadDashboard(); showNotification('Dashboard refreshed'); } // ============ USER MANAGEMENT ============ function loadUsersList() { if (!currentUser || currentUser.role !== 'Admin') { showNotification('Only admins can manage users', true); return; } fetch('api/get_users.php') .then(response => response.json()) .then(data => { if (data.success) { renderUsersList(data.users); } else { showNotification('Error loading users: ' + (data.error || 'Unknown error'), true); } }) .catch(error => { console.error('Error loading users:', error); showNotification('Error loading users', true); }); } function renderUsersList(users) { const usersList = document.getElementById('users-list'); usersList.innerHTML = ''; const activeCountEl = document.getElementById('users-active-count'); const inactiveCountEl = document.getElementById('users-inactive-count'); const activeCount = Array.isArray(users) ? users.filter(user => Number(user.is_active) === 1).length : 0; const inactiveCount = Array.isArray(users) ? Math.max(0, users.length - activeCount) : 0; if (activeCountEl) activeCountEl.textContent = String(activeCount); if (inactiveCountEl) inactiveCountEl.textContent = String(inactiveCount); if (users.length === 0) { usersList.innerHTML = '
Belum ada pengguna yang terdaftar.
'; return; } users.forEach(user => { const item = document.createElement('div'); item.className = 'user-item'; const roleLabel = { 'Admin': '👑 Admin', 'Petugas': '📝 Petugas Data', 'Pimpinan': '👔 Pimpinan' }[user.role] || user.role; const statusText = user.is_active ? 'Aktif' : 'Nonaktif'; const statusClass = user.is_active ? 'status-active' : 'status-inactive'; item.innerHTML = `
${(user.username || '?').charAt(0).toUpperCase()}
${user.username}
${user.email}
${roleLabel} ${statusText}
`; usersList.appendChild(item); }); } async function createNewUser() { const username = document.getElementById('new-user-username').value.trim(); const email = document.getElementById('new-user-email').value.trim(); const password = document.getElementById('new-user-password').value; const role = document.getElementById('new-user-role').value; const resultDiv = document.getElementById('user-creation-result'); // Validation if (!username || !email || !password || !role) { resultDiv.textContent = '❌ Semua field harus diisi'; resultDiv.className = 'error'; resultDiv.style.display = 'block'; return; } if (password.length < 6) { resultDiv.textContent = '❌ Password minimal 6 karakter'; resultDiv.className = 'error'; resultDiv.style.display = 'block'; return; } try { const response = await fetch('api/create_user.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, email, password, role }) }); const data = await response.json(); if (data.success) { resultDiv.textContent = `✅ ${data.message}`; resultDiv.className = 'users-result success'; resultDiv.style.display = 'block'; // Clear form document.getElementById('new-user-username').value = ''; document.getElementById('new-user-email').value = ''; document.getElementById('new-user-password').value = ''; document.getElementById('new-user-role').value = 'Petugas'; // Refresh users list setTimeout(() => loadUsersList(), 1500); } else { resultDiv.textContent = `❌ ${data.message || data.error}`; resultDiv.className = 'users-result error'; resultDiv.style.display = 'block'; } } catch (error) { console.error('Error creating user:', error); resultDiv.textContent = '❌ Error creating user'; resultDiv.className = 'users-result error'; resultDiv.style.display = 'block'; } } async function deleteUserConfirm(userId, username) { if (confirm(`Are you sure you want to deactivate user "${username}"?`)) { try { const response = await fetch('api/delete_user.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: userId }) }); const data = await response.json(); if (data.success) { showNotification('User deactivated successfully'); loadUsersList(); } else { showNotification(`Error: ${data.error}`, true); } } catch (error) { console.error('Error deleting user:', error); showNotification('Error deactivating user', true); } } } function openEditUserModal(userId, currentRole) { const newRole = prompt('Select new role:\n1 = Admin\n2 = Petugas\n3 = Pimpinan\n\nCurrent: ' + currentRole); if (newRole === null) return; // User cancelled const roleMap = { '1': 'Admin', '2': 'Petugas', '3': 'Pimpinan' }; const role = roleMap[newRole]; if (!role) { showNotification('Invalid role selection', true); return; } updateUserRole(userId, role); } async function updateUserRole(userId, newRole) { try { const response = await fetch('api/update_user.php', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: userId, role: newRole }) }); const data = await response.json(); if (data.success) { showNotification('User role updated successfully'); loadUsersList(); } else { showNotification(`Error: ${data.error}`, true); } } catch (error) { console.error('Error updating user:', error); showNotification('Error updating user role', true); } } function isTabAllowedForCurrentUser(tabName) { if (!currentUser || !currentUser.role) return true; const role = String(currentUser.role).toLowerCase(); if (role === 'pimpinan') { return tabName === 'beranda' || tabName === 'laporan'; } return true; } // Show users tab button if user is admin function updateAdminUI() { if (!currentUser) return; const adminButtons = document.querySelectorAll('.admin-only'); adminButtons.forEach(button => { button.style.display = currentUser.role === 'Admin' ? 'inline-flex' : 'none'; }); const restrictedForPimpinan = document.querySelectorAll('.tab-button:not(.admin-only)'); restrictedForPimpinan.forEach(button => { const handler = button.getAttribute('onclick') || ''; const allowsOnlyHomeAndReport = currentUser.role && String(currentUser.role).toLowerCase() === 'pimpinan'; if (allowsOnlyHomeAndReport) { const isAllowed = handler.includes("'beranda'") || handler.includes('"beranda"') || handler.includes("'laporan'") || handler.includes('"laporan"'); button.style.display = isAllowed ? 'inline-flex' : 'none'; } else { button.style.display = 'inline-flex'; } }); if (currentUser.role && String(currentUser.role).toLowerCase() === 'pimpinan') { if (!isTabAllowedForCurrentUser(getActiveTabName())) { switchTab('beranda'); } } } function getActiveTabName() { const activeButton = document.querySelector('.tab-button.tab-active'); if (!activeButton) return 'beranda'; const handler = activeButton.getAttribute('onclick') || ''; const match = handler.match(/switchTab\(['\"]([^'\"]+)['\"]/); return match ? match[1] : 'beranda'; } function setupConfigurationSection() { const saveButton = document.getElementById('save-configuration-btn'); if (saveButton) { saveButton.onclick = saveConfiguration; } // wire rendering mode + refresh/export controls (moved into Konfigurasi tab) const renderingSelect = document.getElementById('rendering-mode-select'); const refreshBtn = document.getElementById('refresh-needpoints-btn'); const exportBtn = document.getElementById('export-needpoints-btn'); if (renderingSelect) { const stored = getStoredRenderingMode(); renderingSelect.value = stored ? stored : (renderingMode === 'auto' ? 'auto' : renderingMode); renderingSelect.onchange = function(e) { const val = e.target.value; if (val === 'auto') renderingMode = detectRenderingMode(); else renderingMode = val; setStoredRenderingMode(val === 'auto' ? null : val); if (renderingMode === 'high') visibleMarkerThreshold = 3000; else if (renderingMode === 'medium') visibleMarkerThreshold = 1500; else if (renderingMode === 'low') visibleMarkerThreshold = 0; else visibleMarkerThreshold = 600; renderVisibleNeedPoints(); showNotification('Rendering mode set to ' + renderingMode); }; } if (refreshBtn) refreshBtn.onclick = function() { loadNeedPoints(); showNotification('Refreshing need points'); }; if (exportBtn) exportBtn.onclick = function() { downloadGeojson(); showNotification('GeoJSON export started'); }; loadConfiguration(); } function loadConfiguration() { fetch('api/get_configuration.php') .then(response => response.json()) .then(data => { if (!data.success) return; const targetInput = document.getElementById('target-nasional-input'); if (targetInput) { targetInput.value = Number(data.configuration?.target_nasional || 7.5).toFixed(2); } const populationInput = document.getElementById('population-reference-input'); if (populationInput) { populationInput.value = Number(data.configuration?.population_reference || 288315899); } const targetDisplay = document.getElementById('beranda-target'); if (targetDisplay) { targetDisplay.textContent = Number(data.configuration?.target_nasional || 7.5).toFixed(2); } }) .catch(error => console.error('Error loading configuration:', error)); } function saveConfiguration() { const targetInput = document.getElementById('target-nasional-input'); const populationInput = document.getElementById('population-reference-input'); const resultBox = document.getElementById('configuration-result'); const targetNasional = targetInput ? parseFloat(targetInput.value) : NaN; const populationReference = populationInput ? parseInt(populationInput.value, 10) : NaN; if (isNaN(targetNasional) || targetNasional < 0 || targetNasional > 100) { if (resultBox) { resultBox.style.display = 'block'; resultBox.style.background = 'rgba(244,67,54,0.2)'; resultBox.style.border = '1px solid rgba(244,67,54,0.4)'; resultBox.textContent = 'Target nasional harus antara 0 sampai 100.'; } return; } if (isNaN(populationReference) || populationReference < 1) { if (resultBox) { resultBox.style.display = 'block'; resultBox.style.background = 'rgba(244,67,54,0.2)'; resultBox.style.border = '1px solid rgba(244,67,54,0.4)'; resultBox.textContent = 'Total populasi Indonesia harus lebih besar dari 0.'; } return; } fetch('api/save_configuration.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target_nasional: targetNasional, population_reference: populationReference }) }) .then(response => response.json()) .then(data => { if (data.success) { if (resultBox) { resultBox.style.display = 'block'; resultBox.style.background = 'rgba(76,175,80,0.2)'; resultBox.style.border = '1px solid rgba(76,175,80,0.4)'; resultBox.textContent = 'Konfigurasi berhasil disimpan.'; } const targetDisplay = document.getElementById('beranda-target'); if (targetDisplay) { targetDisplay.textContent = Number(data.configuration?.target_nasional || targetNasional).toFixed(2); } loadBerandaDashboard(); loadCombinedReportPreview(); } else if (resultBox) { resultBox.style.display = 'block'; resultBox.style.background = 'rgba(244,67,54,0.2)'; resultBox.style.border = '1px solid rgba(244,67,54,0.4)'; resultBox.textContent = data.error || 'Gagal menyimpan konfigurasi.'; } }) .catch(error => { console.error('Error saving configuration:', error); if (resultBox) { resultBox.style.display = 'block'; resultBox.style.background = 'rgba(244,67,54,0.2)'; resultBox.style.border = '1px solid rgba(244,67,54,0.4)'; resultBox.textContent = 'Gagal menyimpan konfigurasi.'; } }); } // ============ MANAGEMENT / CONFIGURATION FOR NEED POINTS ============ function loadManagement() { // Load a preview list of need points (10 per page) in the Managemen panel setupManagementNeedpointControls(); loadManagementNeedPoints(); // Ensure household data is available for score computation path loadHouseholdsForPanel(); const computeBtn = document.getElementById('manage-compute-score-btn'); if (computeBtn) { computeBtn.onclick = function() { computeHouseholdScores(); // refresh management preview shortly after compute call starts setTimeout(loadManagementScorePreview, 500); }; } loadManagementScorePreview(); } // Management need-point carousel state let managementNeedPoints = []; let currentMgmtNeedPage = 0; const MGMT_NEEDPAGE_SIZE = 10; function setupManagementNeedpointControls() { const prev = document.getElementById('needpoint-prev'); const next = document.getElementById('needpoint-next'); if (prev) prev.onclick = function() { renderManagementNeedPointPage(currentMgmtNeedPage - 1); }; if (next) next.onclick = function() { renderManagementNeedPointPage(currentMgmtNeedPage + 1); }; } function loadManagementNeedPoints() { fetch('api/get_need_points.php') .then(r => r.json()) .then(data => { if (data.success && Array.isArray(data.points)) { managementNeedPoints = data.points.map(p => normalizeNeedPointRecord(p)); } else { managementNeedPoints = []; } currentMgmtNeedPage = 0; renderManagementNeedPointPage(0); }) .catch(err => { console.error('Error loading management need points', err); managementNeedPoints = []; renderManagementNeedPointPage(0); }); } function renderManagementNeedPointPage(pageIndex) { if (!Array.isArray(managementNeedPoints)) managementNeedPoints = []; const total = managementNeedPoints.length; const maxPage = Math.max(0, Math.ceil(total / MGMT_NEEDPAGE_SIZE) - 1); if (pageIndex < 0) pageIndex = 0; if (pageIndex > maxPage) pageIndex = maxPage; currentMgmtNeedPage = pageIndex; const start = pageIndex * MGMT_NEEDPAGE_SIZE; const slice = managementNeedPoints.slice(start, start + MGMT_NEEDPAGE_SIZE); const container = document.getElementById('management-needpoint-list'); const pager = document.getElementById('management-needpoint-pager'); if (!container) return; container.innerHTML = ''; if (slice.length === 0) { container.innerHTML = '
No need points available
'; } else { let rowsHtml = ''; slice.forEach(p => { const id = Number(p.id) || 0; const assisted = Number(p.assisted || 0) === 1; const name = escapeHtml(p.name || '-'); const nameClass = assisted ? 'assisted-point-name' : ''; const hh = escapeHtml(p.household_name || '-'); const head = escapeHtml(p.head_name || '-'); const econRaw = String(p.economic_status || '-').toLowerCase(); const econClass = econRaw.replace(/[^a-z_]/g, ''); const econLabel = escapeHtml(econRaw === '-' ? '-' : econRaw.replace(/_/g, ' ')); const coords = `${Number(p.latitude).toFixed(4)}, ${Number(p.longitude).toFixed(4)}`; const verified = Number(p.is_verified || 0) === 1; const verifyIcon = verified ? '✓' : '✕'; const verifyTitle = verified ? 'Verified' : 'Not Verified'; const bantuanDisabled = !verified; const bantuanClass = 'mini-icon-btn bantuan' + (bantuanDisabled ? ' disabled' : ''); const bantuanTitle = bantuanDisabled ? 'Lock: household not verified' : 'Kelola Bantuan'; const bantuanOnclick = bantuanDisabled ? '' : `onclick="openNeedPointBantuanModal(${id})"`; rowsHtml += `${id}${name}${hh}${head}${econLabel}${coords}`; }); container.innerHTML = `
${rowsHtml}
ID Point Nama Rumah/Keluarga Kepala Rumah Keadaan Ekonomi Koordinat Verif Bantuan
`; } if (pager) pager.textContent = `Page ${pageIndex + 1} / ${maxPage + 1} • ${total} items`; } function toggleNeedPointVerification(id) { fetch('api/toggle_need_point_verification.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id }) }) .then(r => r.json()) .then(data => { if (!data.success || !data.point) { showNotification('Gagal mengubah status verifikasi', true); return; } const updated = normalizeNeedPointRecord(data.point); const idx = managementNeedPoints.findIndex(p => Number(p.id) === Number(id)); if (idx !== -1) { managementNeedPoints[idx] = { ...managementNeedPoints[idx], ...updated }; } renderManagementNeedPointPage(currentMgmtNeedPage); showNotification(updated.is_verified ? 'Point berkebutuhan diverifikasi' : 'Verifikasi dibatalkan'); }) .catch(err => { console.error('Verification toggle error:', err); showNotification('Gagal mengubah verifikasi', true); }); } function openNeedPointBantuanModal(id) { const point = managementNeedPoints.find(p => Number(p.id) === Number(id)); const pointName = point && point.name ? point.name : ('ID ' + id); // Populate modal content const modal = document.getElementById('bantuan-manage-modal'); if (!modal) return; const titleEl = modal.querySelector('.bantuan-point-title'); const instructionsEl = modal.querySelector('.bantuan-instructions'); const openAssistanceBtn = modal.querySelector('.bantuan-open-assistance'); if (titleEl) titleEl.textContent = pointName; if (instructionsEl) instructionsEl.innerHTML = `
1Verifikasi kondisi household.
2Pilih jenis bantuan yang sesuai.
3Catat nominal, frekuensi, dan petugas penyalur.
4Lanjutkan ke form catat bantuan.
`; if (openAssistanceBtn) { openAssistanceBtn.onclick = function() { // If there are no households yet, create one from the need_point record automatically if (!Array.isArray(households) || households.length === 0) { createHouseholdFromNeedPoint(point) .then(household => { // Refresh local selects with the newly created household populateHouseholdSelectOptions(); closeBantuanManageModal(); openAssistanceModal(household.id); }) .catch(err => { console.error('Auto-create household failed:', err); showNotification('Gagal membuat household otomatis: ' + (err.message || err), true); }); return; } // Pre-populate assistance modal if possible by matching head_name or household_name populateHouseholdSelectOptions(); const select = document.getElementById('assistance-household-id'); if (select && point) { // Try to find a household id by head_name or household_name const match = households.find(h => h.head_name === point.head_name || h.household_name === point.household_name); if (match) { select.value = String(match.id); } } closeBantuanManageModal(); openAssistanceModal(select ? select.value : null); }; } modal.classList.add('show'); } function closeBantuanManageModal() { const modal = document.getElementById('bantuan-manage-modal'); if (modal) modal.classList.remove('show'); } function loadManagementScorePreview() { fetch('api/get_household_score_summary.php') .then(response => response.json()) .then(data => { if (!data.success || !data.summary) { return; } const counts = data.summary.priority_counts || {}; const assistedCount = Number(data.summary.assisted_households || 0); const assistedEl = document.getElementById('mgmt-assisted-count'); const veryHighEl = document.getElementById('mgmt-priority-very-high-count'); const highEl = document.getElementById('mgmt-priority-high-count'); const mediumEl = document.getElementById('mgmt-priority-medium-count'); const lowEl = document.getElementById('mgmt-priority-low-count'); if (assistedEl) assistedEl.textContent = assistedCount; if (veryHighEl) veryHighEl.textContent = Number(counts.very_high || 0); if (highEl) highEl.textContent = Number(counts.high || 0); if (mediumEl) mediumEl.textContent = Number(counts.medium || 0); if (lowEl) lowEl.textContent = Number(counts.low || 0); }) .catch(error => { console.error('Error loading management score preview:', error); }); } function loadMarkers() { fetch('api/get_points.php') .then(response => response.json()) .then(data => { if (data.success && data.markers) { // Clear existing markers Object.keys(markers).forEach(id => { map.removeLayer(markers[id]); }); markers = {}; markerCount = 0; // Load new markers data.markers.forEach(marker => { displayMarker( marker.id, marker.name, marker.nomor_spbu, marker.latitude, marker.longitude, marker.open_24_hours ); }); updateMarkerCount(); } }) .catch(error => console.error('Error loading markers:', error)); } function updateMarkerCount() { const countElement = document.getElementById('marker-count'); if (countElement) { countElement.textContent = markerCount; } } function deleteMarker(id) { if (!confirm('Are you sure you want to delete this point?')) { return; } fetch('api/delete_point.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id }) }) .then(response => response.json()) .then(data => { if (data.success) { if (markers[id]) { map.removeLayer(markers[id]); delete markers[id]; markerCount--; updateMarkerCount(); showNotification('✓ Point deleted'); } } else { showNotification('Error deleting point: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error deleting point', true); }); } function editMarker(id) { if (!markerData[id]) { showNotification('Marker data not found', true); return; } const data = markerData[id]; currentEditingMarkerId = id; // Pre-fill form with current marker data document.getElementById('edit-name').value = data.name; document.getElementById('edit-nomor-spbu').value = data.nomor_spbu || ''; document.getElementById('edit-latitude').value = data.latitude; document.getElementById('edit-longitude').value = data.longitude; document.getElementById('edit-24hours').checked = data.open_24_hours ? true : false; // Open edit modal const editModal = document.getElementById('edit-modal'); editModal.classList.add('show'); document.getElementById('edit-name').focus(); } function closeEditModal() { const editModal = document.getElementById('edit-modal'); editModal.classList.remove('show'); currentEditingMarkerId = null; } function saveMarkerEdit() { if (!currentEditingMarkerId) { showNotification('No marker selected for editing', true); return; } const name = document.getElementById('edit-name').value.trim(); const nomor_spbu = document.getElementById('edit-nomor-spbu').value.trim(); const latitude = parseFloat(document.getElementById('edit-latitude').value); const longitude = parseFloat(document.getElementById('edit-longitude').value); const open_24_hours = document.getElementById('edit-24hours').checked ? 1 : 0; // Validate inputs if (!name) { showNotification('Please enter a name for the point', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Please enter a valid latitude (-90 to 90)', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Please enter a valid longitude (-180 to 180)', true); return; } const id = currentEditingMarkerId; // Update in database fetch('api/update_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id, name: name, nomor_spbu: nomor_spbu, latitude: latitude, longitude: longitude, open_24_hours: open_24_hours }) }) .then(response => response.json()) .then(data => { if (data.success) { const oldStatus = markerData[id].open_24_hours; const newStatus = open_24_hours; // Update local marker data markerData[id] = { id: id, name: name, nomor_spbu: nomor_spbu, latitude: latitude, longitude: longitude, open_24_hours: open_24_hours }; // If status changed, update marker icon and color if (oldStatus !== newStatus) { const icon = open_24_hours ? markerIcons.open : markerIcons.closed; markers[id].setIcon(icon); } // Update marker position if changed const currentPos = markers[id].getLatLng(); if (currentPos.lat !== latitude || currentPos.lng !== longitude) { markers[id].setLatLng([latitude, longitude]); } // Force popup update if (markers[id].isPopupOpen()) { markers[id].closePopup(); } closeEditModal(); showNotification('✓ Point updated successfully'); } else { showNotification('Error updating point: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error updating point', true); }); } function toggleMarkerStatus(id) { if (!markerData[id]) { showNotification('Marker data not found', true); return; } const currentStatus = markerData[id].open_24_hours; const newStatus = currentStatus ? 0 : 1; // Update in database fetch('api/update_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id, open_24_hours: newStatus }) }) .then(response => response.json()) .then(data => { if (data.success) { // Update local marker data markerData[id].open_24_hours = newStatus; // Update marker icon color const icon = newStatus ? markerIcons.open : markerIcons.closed; markers[id].setIcon(icon); // Update popup content const marker = markers[id]; const name = markerData[id].name; const nomor_spbu = markerData[id].nomor_spbu; const lat = markerData[id].latitude; const lng = markerData[id].longitude; const hoursText = newStatus ? '🕐 Open 24 Hours' : '🕐 Limited Hours'; let popupContent = `
${escapeHtml(name)}
${hoursText}
`; if (nomor_spbu) { popupContent += `
🏢 SPBU: ${escapeHtml(nomor_spbu)}
`; } popupContent += `
Lat: ${lat.toFixed(4)}
Lng: ${lng.toFixed(4)}
`; marker.setPopupContent(popupContent); marker.closePopup(); marker.openPopup(); showNotification('✓ Status updated'); } else { showNotification('Error updating status: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error updating status', true); }); } function distanceMeters(lat1, lng1, lat2, lng2) { return L.latLng(lat1, lng1).distanceTo(L.latLng(lat2, lng2)); } function findNearestCoveringMasjidLocal(latitude, longitude) { let nearestMasjid = null; let nearestDistance = null; Object.keys(masjidData).forEach(id => { const masjid = masjidData[id]; const distance = distanceMeters(latitude, longitude, masjid.latitude, masjid.longitude); if (distance <= masjid.radius_meters) { if (nearestDistance === null || distance < nearestDistance) { nearestMasjid = masjid; nearestDistance = distance; } } }); if (!nearestMasjid) { return null; } return { ...nearestMasjid, distance_meters: nearestDistance }; } function getMasjidPopupContent(id) { const masjid = masjidData[id]; if (!masjid) { return '
Masjid data not found
'; } const radiusLabel = Number(masjid.radius_meters).toFixed(0); return `
${escapeHtml(masjid.name)}
🕌 Masjid
Radius: ${radiusLabel} m
Lat: ${masjid.latitude.toFixed(4)} | Lng: ${masjid.longitude.toFixed(4)}
`; } function getNeedPointPopupContent(id) { const point = needPointData[id]; if (!point) { return '
Point data not found
'; } const distanceLabel = Number(point.distance_meters || 0).toFixed(1); return `
${escapeHtml(point.name)}
♿ Point Berkebutuhan
Masjid: ${escapeHtml(point.masjid_name || '-')}
Jarak ke masjid: ${distanceLabel} m
Lat: ${point.latitude.toFixed(4)} | Lng: ${point.longitude.toFixed(4)}
Nama Rumah: ${escapeHtml(point.household_name || '-')}
Kepala Rumah: ${escapeHtml(point.head_name || '-')}
Keadaan Ekonomi: ${escapeHtml(point.economic_status || '-')}
`; } function updateMasjidCount() { const countElement = document.getElementById('masjid-count'); if (countElement) { countElement.textContent = masjidCount; } } function updateNeedPointCount() { const countElement = document.getElementById('need-point-count'); if (countElement) { countElement.textContent = needPointCount; } } function removeNeedPointsByIds(ids) { if (!Array.isArray(ids) || ids.length === 0) { return; } ids.forEach(idValue => { const id = Number(idValue); if (needPointMarkers[id] && map.hasLayer(needPointMarkers[id])) { map.removeLayer(needPointMarkers[id]); } if (needPointMarkers[id]) { delete needPointMarkers[id]; } if (needPointData[id]) { delete needPointData[id]; needPointCount = Math.max(0, needPointCount - 1); } }); // remove from allNeedPoints cache allNeedPoints = allNeedPoints.filter(p => !ids.includes(String(p.id)) && !ids.includes(p.id)); // re-render visible layer to keep consistency if (needPointLayer) renderVisibleNeedPoints(); updateNeedPointCount(); } function normalizeNeedPointRecord(point) { return { id: Number(point.id), name: point.name, latitude: Number(point.latitude), longitude: Number(point.longitude), masjid_id: Number(point.masjid_id), household_name: point.household_name || null, head_name: point.head_name || null, economic_status: point.economic_status || null, is_verified: Number(point.is_verified || 0), masjid_name: point.masjid_name, masjid_latitude: Number(point.masjid_latitude), masjid_longitude: Number(point.masjid_longitude), radius_meters: Number(point.radius_meters), distance_meters: Number(point.distance_meters || 0) }; } function applyNeedPointRecord(record) { const normalized = normalizeNeedPointRecord(record); const id = normalized.id; needPointData[id] = normalized; if (needPointMarkers[id]) { needPointMarkers[id].setLatLng([normalized.latitude, normalized.longitude]); needPointMarkers[id].setPopupContent(getNeedPointPopupContent(id)); } // keep cache in sync const idx = allNeedPoints.findIndex(p => Number(p.id) === Number(id)); if (idx !== -1) { allNeedPoints[idx] = normalized; } else { allNeedPoints.push(normalized); } } function loadMasjids() { fetch('api/get_masjids.php') .then(response => response.json()) .then(data => { if (data.success && data.masjids) { Object.keys(masjidMarkers).forEach(id => { if (map.hasLayer(masjidMarkers[id])) { map.removeLayer(masjidMarkers[id]); } }); Object.keys(masjidCircles).forEach(id => { if (map.hasLayer(masjidCircles[id])) { map.removeLayer(masjidCircles[id]); } }); masjidMarkers = {}; masjidCircles = {}; masjidData = {}; masjidCount = 0; data.masjids.forEach(masjid => { displayMasjid(masjid); }); updateMasjidCount(); } }) .catch(error => console.error('Error loading masjids:', error)); } function displayMasjid(masjid) { const id = Number(masjid.id); const radiusMeters = Number(masjid.radius_meters || 500); masjidData[id] = { id: id, name: masjid.name, latitude: Number(masjid.latitude), longitude: Number(masjid.longitude), radius_meters: radiusMeters, need_point_count: Number(masjid.need_point_count || 0) }; const marker = L.marker([masjidData[id].latitude, masjidData[id].longitude], { draggable: true, icon: markerIcons.masjid }).addTo(map); const circle = L.circle([masjidData[id].latitude, masjidData[id].longitude], { radius: radiusMeters, color: '#1976d2', fillColor: '#64b5f6', fillOpacity: 0.15, weight: 2, interactive: false }).addTo(map); marker.bindPopup(getMasjidPopupContent(id)); marker.on('dragend', function() { const oldLat = masjidData[id].latitude; const oldLng = masjidData[id].longitude; const newPosition = marker.getLatLng(); fetch('api/update_masjid.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id, latitude: newPosition.lat, longitude: newPosition.lng }) }) .then(response => response.json()) .then(data => { if (data.success && data.masjid) { masjidData[id].name = data.masjid.name; masjidData[id].latitude = Number(data.masjid.latitude); masjidData[id].longitude = Number(data.masjid.longitude); masjidData[id].radius_meters = Number(data.masjid.radius_meters); marker.setLatLng([masjidData[id].latitude, masjidData[id].longitude]); marker.setPopupContent(getMasjidPopupContent(id)); circle.setLatLng([masjidData[id].latitude, masjidData[id].longitude]); circle.setRadius(masjidData[id].radius_meters); if (Array.isArray(data.deleted_need_point_ids) && data.deleted_need_point_ids.length > 0) { removeNeedPointsByIds(data.deleted_need_point_ids); showNotification('✓ Masjid dipindah, ' + data.deleted_need_point_ids.length + ' point berkebutuhan dihapus otomatis'); } else { showNotification('✓ Lokasi masjid berhasil diperbarui'); } } else { marker.setLatLng([oldLat, oldLng]); showNotification('Error updating masjid: ' + (data.error || 'unknown error'), true); } }) .catch(error => { console.error('Error:', error); marker.setLatLng([oldLat, oldLng]); showNotification('Error updating masjid location', true); }); }); masjidMarkers[id] = marker; masjidCircles[id] = circle; masjidCount++; } function openMasjidModal(coords) { const modal = document.getElementById('masjid-modal'); const form = document.getElementById('masjid-form'); const latInput = document.getElementById('masjid-latitude'); const lngInput = document.getElementById('masjid-longitude'); const info = document.getElementById('masjid-form-info'); if (!modal || !form || !latInput || !lngInput) { return; } form.reset(); if (coords) { latInput.value = coords.lat.toFixed(6); lngInput.value = coords.lng.toFixed(6); latInput.readOnly = true; lngInput.readOnly = true; if (info) { info.textContent = 'Koordinat masjid diisi otomatis dari klik peta.'; info.classList.add('show'); } } else { latInput.value = ''; lngInput.value = ''; latInput.readOnly = false; lngInput.readOnly = false; if (info) { info.classList.remove('show'); } } modal.classList.add('show'); document.getElementById('masjid-name').focus(); } function closeMasjidModal() { const modal = document.getElementById('masjid-modal'); if (modal) { modal.classList.remove('show'); } } function submitMasjidForm() { const name = document.getElementById('masjid-name').value.trim(); const latitude = parseFloat(document.getElementById('masjid-latitude').value); const longitude = parseFloat(document.getElementById('masjid-longitude').value); const radiusValue = document.getElementById('masjid-radius').value.trim(); if (!name) { showNotification('Nama masjid wajib diisi', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Latitude masjid tidak valid', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Longitude masjid tidak valid', true); return; } if (radiusValue !== '') { const radius = parseFloat(radiusValue); if (isNaN(radius) || radius < 50 || radius > 5000) { showNotification('Radius harus 50-5000 meter', true); return; } } addMasjid(name, latitude, longitude, radiusValue); } function addMasjid(name, latitude, longitude, radiusValue) { fetch('api/save_masjid.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: name, latitude: latitude, longitude: longitude, radius_meters: radiusValue === '' ? null : parseFloat(radiusValue) }) }) .then(response => response.json()) .then(data => { if (data.success && data.masjid) { displayMasjid(data.masjid); updateMasjidCount(); closeMasjidModal(); showNotification('✓ Masjid berhasil ditambahkan'); } else { showNotification('Error adding masjid: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error adding masjid', true); }); } function editMasjid(id) { if (!masjidData[id]) { showNotification('Data masjid tidak ditemukan', true); return; } const masjid = masjidData[id]; currentEditingMasjidId = id; document.getElementById('edit-masjid-name').value = masjid.name; document.getElementById('edit-masjid-latitude').value = masjid.latitude; document.getElementById('edit-masjid-longitude').value = masjid.longitude; document.getElementById('edit-masjid-radius').value = masjid.radius_meters; const modal = document.getElementById('edit-masjid-modal'); modal.classList.add('show'); document.getElementById('edit-masjid-name').focus(); } function closeEditMasjidModal() { const modal = document.getElementById('edit-masjid-modal'); if (modal) { modal.classList.remove('show'); } currentEditingMasjidId = null; } function saveMasjidEdit() { if (!currentEditingMasjidId) { showNotification('Tidak ada masjid yang dipilih', true); return; } const name = document.getElementById('edit-masjid-name').value.trim(); const latitude = parseFloat(document.getElementById('edit-masjid-latitude').value); const longitude = parseFloat(document.getElementById('edit-masjid-longitude').value); const radiusValue = document.getElementById('edit-masjid-radius').value.trim(); if (!name) { showNotification('Nama masjid wajib diisi', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Latitude masjid tidak valid', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Longitude masjid tidak valid', true); return; } if (radiusValue !== '') { const radius = parseFloat(radiusValue); if (isNaN(radius) || radius < 50 || radius > 5000) { showNotification('Radius harus 50-5000 meter', true); return; } } fetch('api/update_masjid.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: currentEditingMasjidId, name: name, latitude: latitude, longitude: longitude, radius_meters: radiusValue === '' ? null : parseFloat(radiusValue) }) }) .then(response => response.json()) .then(data => { if (data.success && data.masjid) { const id = Number(currentEditingMasjidId); masjidData[id].name = data.masjid.name; masjidData[id].latitude = Number(data.masjid.latitude); masjidData[id].longitude = Number(data.masjid.longitude); masjidData[id].radius_meters = Number(data.masjid.radius_meters); if (masjidMarkers[id]) { masjidMarkers[id].setLatLng([masjidData[id].latitude, masjidData[id].longitude]); masjidMarkers[id].setPopupContent(getMasjidPopupContent(id)); } if (masjidCircles[id]) { masjidCircles[id].setLatLng([masjidData[id].latitude, masjidData[id].longitude]); masjidCircles[id].setRadius(masjidData[id].radius_meters); } if (Array.isArray(data.deleted_need_point_ids) && data.deleted_need_point_ids.length > 0) { removeNeedPointsByIds(data.deleted_need_point_ids); showNotification('✓ Masjid diperbarui, ' + data.deleted_need_point_ids.length + ' point berkebutuhan dihapus otomatis'); } else { showNotification('✓ Masjid berhasil diperbarui'); } closeEditMasjidModal(); } else { showNotification('Error updating masjid: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error updating masjid', true); }); } function deleteMasjid(id) { if (!confirm('Apakah Anda yakin ingin menghapus masjid ini?')) { return; } fetch('api/delete_masjid.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id }) }) .then(response => response.json()) .then(data => { if (data.success) { if (masjidMarkers[id] && map.hasLayer(masjidMarkers[id])) { map.removeLayer(masjidMarkers[id]); } if (masjidCircles[id] && map.hasLayer(masjidCircles[id])) { map.removeLayer(masjidCircles[id]); } delete masjidMarkers[id]; delete masjidCircles[id]; if (masjidData[id]) { delete masjidData[id]; masjidCount = Math.max(0, masjidCount - 1); } updateMasjidCount(); if (Array.isArray(data.deleted_need_point_ids) && data.deleted_need_point_ids.length > 0) { removeNeedPointsByIds(data.deleted_need_point_ids); showNotification('✓ Masjid dihapus, ' + data.deleted_need_point_ids.length + ' point berkebutuhan ikut dihapus'); } else { showNotification('✓ Masjid berhasil dihapus'); } } else { showNotification('Error deleting masjid: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error deleting masjid', true); }); } function loadNeedPoints() { fetch('api/get_need_points.php') .then(response => response.json()) .then(data => { if (data.success && data.points) { // store normalized points for viewport-driven rendering allNeedPoints = data.points.map(p => normalizeNeedPointRecord(p)); // detect device capability and set threshold (allow stored override) const stored = getStoredRenderingMode(); renderingMode = stored ? stored : detectRenderingMode(); if (renderingMode === 'high') visibleMarkerThreshold = 3000; else if (renderingMode === 'medium') visibleMarkerThreshold = 1500; else visibleMarkerThreshold = 600; // initialize layer container if (needPointLayer && map && map.hasLayer(needPointLayer)) { map.removeLayer(needPointLayer); } needPointLayer = L.layerGroup().addTo(map); // render only visible points and update on view changes renderVisibleNeedPoints(); // debounce handlers let debounceTimer = null; map.off('moveend', renderVisibleNeedPoints); map.off('zoomend', renderVisibleNeedPoints); map.on('moveend', function() { clearTimeout(debounceTimer); debounceTimer = setTimeout(renderVisibleNeedPoints, 120); }); map.on('zoomend', function() { clearTimeout(debounceTimer); debounceTimer = setTimeout(renderVisibleNeedPoints, 120); }); } }) .catch(error => console.error('Error loading need points:', error)); } function renderVisibleNeedPoints() { if (!map || !Array.isArray(allNeedPoints)) return; const bounds = map.getBounds().pad(0.25); const visible = allNeedPoints.filter(p => bounds.contains([p.latitude, p.longitude])); // Clear previous layer needPointLayer.clearLayers(); needPointMarkers = {}; needPointData = {}; needPointCount = 0; if (visible.length === 0) { updateNeedPointCount(); return; } // If number of visible points is small, use full markers with popups if (visible.length <= visibleMarkerThreshold) { visible.forEach(p => { const id = p.id; needPointData[id] = p; const marker = L.marker([p.latitude, p.longitude], { icon: markerIcons.need }); marker.bindPopup(getNeedPointPopupContent(id)); marker.on('click', () => { marker.openPopup(); }); needPointLayer.addLayer(marker); needPointMarkers[id] = marker; needPointCount++; }); } else { // Heavy view: use canvas-rendered circle markers for performance and no per-feature popups const canvasRenderer = L.canvas({ padding: 0.5 }); visible.forEach(p => { const id = p.id; needPointData[id] = p; const circle = L.circleMarker([p.latitude, p.longitude], { radius: 4, fillColor: '#ff9800', color: '#ffffff', weight: 1, opacity: 0.9, fillOpacity: 0.9, renderer: canvasRenderer }); needPointLayer.addLayer(circle); needPointMarkers[id] = circle; needPointCount++; }); // Add a light click handler on the map to show nearest point popup when user clicks map.off('click', heavyModeMapClickHandler); map.on('click', heavyModeMapClickHandler); } updateNeedPointCount(); } function heavyModeMapClickHandler(e) { // find nearest point within a small pixel radius const clickPoint = map.latLngToContainerPoint(e.latlng); let nearest = null; let minPx = 40; // pixels Object.values(needPointData).forEach(p => { const pt = map.latLngToContainerPoint([p.latitude, p.longitude]); const dx = pt.x - clickPoint.x; const dy = pt.y - clickPoint.y; const d = Math.sqrt(dx*dx + dy*dy); if (d < minPx) { minPx = d; nearest = p; } }); if (nearest) { // show popup at the point const popup = L.popup({ maxWidth: 300 }) .setLatLng([nearest.latitude, nearest.longitude]) .setContent(getNeedPointPopupContent(nearest.id)); popup.openOn(map); } } function detectRenderingMode() { try { const cores = navigator.hardwareConcurrency || 2; const dpr = window.devicePixelRatio || 1; if (cores >= 8 && dpr <= 2) return 'high'; if (cores >= 4) return 'medium'; return 'low'; } catch (e) { return 'medium'; } } function openNeedPointModal(coords) { const modal = document.getElementById('need-point-modal'); const form = document.getElementById('need-point-form'); const latInput = document.getElementById('need-point-latitude'); const lngInput = document.getElementById('need-point-longitude'); const info = document.getElementById('need-point-form-info'); // Management household state and functions let managementHouseholds = []; let currentMgmtPage = 0; const MGMT_PAGE_SIZE = 10; function loadManagementHouseholds() { fetch('api/get_households.php?limit=1000') .then(r => r.json()) .then(data => { if (data.success && Array.isArray(data.households)) { managementHouseholds = data.households; currentMgmtPage = 0; renderManagementHouseholdPage(0); } else { managementHouseholds = []; renderManagementHouseholdPage(0); } }) .catch(err => { console.error('Error loading management households', err); managementHouseholds = []; renderManagementHouseholdPage(0); }); } function renderManagementHouseholdPage(pageIndex) { if (!Array.isArray(managementHouseholds)) managementHouseholds = []; const total = managementHouseholds.length; const maxPage = Math.max(0, Math.ceil(total / MGMT_PAGE_SIZE) - 1); if (pageIndex < 0) pageIndex = 0; if (pageIndex > maxPage) pageIndex = maxPage; currentMgmtPage = pageIndex; const start = pageIndex * MGMT_PAGE_SIZE; const slice = managementHouseholds.slice(start, start + MGMT_PAGE_SIZE); const container = document.getElementById('management-household-list'); const pager = document.getElementById('management-household-pager'); if (!container) return; container.innerHTML = ''; if (slice.length === 0) { container.innerHTML = '
No households available
'; } else { slice.forEach(h => { const div = document.createElement('div'); div.className = 'management-household-item'; const code = escapeHtml(h.household_code || '-'); const head = escapeHtml(h.head_name || '-'); const status = escapeHtml(h.verification_status || '-'); const meta = `Lat: ${Number(h.latitude||0).toFixed(4)} Lng: ${Number(h.longitude||0).toFixed(4)}`; div.innerHTML = `
${code} • ${head}
${meta}
Status: ${status}
`; div.onclick = () => { openHouseholdModal(); }; container.appendChild(div); }); } if (pager) pager.textContent = `Page ${pageIndex + 1} / ${maxPage + 1} • ${total} households`; } if (!modal || !form || !latInput || !lngInput) { return; } form.reset(); if (coords) { latInput.value = coords.lat.toFixed(6); lngInput.value = coords.lng.toFixed(6); latInput.readOnly = true; lngInput.readOnly = true; if (info) { info.textContent = 'Koordinat point berkebutuhan diisi otomatis dari klik peta.'; info.classList.add('show'); } } else { latInput.value = ''; lngInput.value = ''; latInput.readOnly = false; lngInput.readOnly = false; if (info) { info.classList.remove('show'); } } modal.classList.add('show'); document.getElementById('need-point-name').focus(); } function closeNeedPointModal() { const modal = document.getElementById('need-point-modal'); if (modal) { modal.classList.remove('show'); } } function submitNeedPointForm() { const name = document.getElementById('need-point-name').value.trim(); const latitude = parseFloat(document.getElementById('need-point-latitude').value); const longitude = parseFloat(document.getElementById('need-point-longitude').value); const householdName = document.getElementById('need-point-household') ? document.getElementById('need-point-household').value.trim() : ''; const headName = document.getElementById('need-point-head') ? document.getElementById('need-point-head').value.trim() : ''; const economicStatus = document.getElementById('need-point-economic') ? document.getElementById('need-point-economic').value : ''; if (!name) { showNotification('Nama point berkebutuhan wajib diisi', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Latitude point berkebutuhan tidak valid', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Longitude point berkebutuhan tidak valid', true); return; } if (!findNearestCoveringMasjidLocal(latitude, longitude)) { showNotification('Point berkebutuhan harus berada dalam radius masjid', true); return; } addNeedPoint(name, latitude, longitude, householdName, headName, economicStatus); } function addNeedPoint(name, latitude, longitude) { // legacy signature support: addNeedPoint(name, lat, lng, household, head, economic) const household = arguments.length >= 4 ? arguments[3] : ''; const head = arguments.length >= 5 ? arguments[4] : ''; const economic = arguments.length >= 6 ? arguments[5] : ''; fetch('api/save_need_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: name, latitude: latitude, longitude: longitude, household_name: household, head_name: head, economic_status: economic }) }) .then(response => response.json()) .then(data => { if (data.success && data.point) { // add to local cache and re-render visible points const p = normalizeNeedPointRecord(data.point); allNeedPoints.push(p); renderVisibleNeedPoints(); closeNeedPointModal(); showNotification('✓ Point berkebutuhan berhasil ditambahkan'); } else { showNotification('Error adding point berkebutuhan: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error adding point berkebutuhan', true); }); } function editNeedPoint(id) { if (!needPointData[id]) { showNotification('Data point berkebutuhan tidak ditemukan', true); return; } const point = needPointData[id]; currentEditingNeedPointId = id; document.getElementById('edit-need-point-name').value = point.name; document.getElementById('edit-need-point-latitude').value = point.latitude; document.getElementById('edit-need-point-longitude').value = point.longitude; document.getElementById('edit-need-point-masjid').value = point.masjid_name || '-'; // populate new household fields const hh = point.household_name || ''; const head = point.head_name || ''; const econ = point.economic_status || 'rentan'; const hhInput = document.getElementById('edit-need-point-household'); const headInput = document.getElementById('edit-need-point-head'); const econInput = document.getElementById('edit-need-point-economic'); if (hhInput) hhInput.value = hh; if (headInput) headInput.value = head; if (econInput) econInput.value = econ; const modal = document.getElementById('edit-need-point-modal'); modal.classList.add('show'); document.getElementById('edit-need-point-name').focus(); } function closeEditNeedPointModal() { const modal = document.getElementById('edit-need-point-modal'); if (modal) { modal.classList.remove('show'); } currentEditingNeedPointId = null; } function saveNeedPointEdit() { if (!currentEditingNeedPointId) { showNotification('Tidak ada point berkebutuhan yang dipilih', true); return; } const name = document.getElementById('edit-need-point-name').value.trim(); const latitude = parseFloat(document.getElementById('edit-need-point-latitude').value); const longitude = parseFloat(document.getElementById('edit-need-point-longitude').value); if (!name) { showNotification('Nama point berkebutuhan wajib diisi', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Latitude point berkebutuhan tidak valid', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Longitude point berkebutuhan tidak valid', true); return; } if (!findNearestCoveringMasjidLocal(latitude, longitude)) { showNotification('Point berkebutuhan harus berada dalam radius masjid', true); return; } const householdName = document.getElementById('edit-need-point-household') ? document.getElementById('edit-need-point-household').value.trim() : ''; const headName = document.getElementById('edit-need-point-head') ? document.getElementById('edit-need-point-head').value.trim() : ''; const economicStatus = document.getElementById('edit-need-point-economic') ? document.getElementById('edit-need-point-economic').value : ''; fetch('api/update_need_point.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: currentEditingNeedPointId, name: name, latitude: latitude, longitude: longitude, household_name: householdName, head_name: headName, economic_status: economicStatus }) }) .then(response => response.json()) .then(data => { if (data.success && data.point) { applyNeedPointRecord(data.point); closeEditNeedPointModal(); showNotification('✓ Point berkebutuhan berhasil diperbarui'); } else { showNotification('Error updating point berkebutuhan: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error updating point berkebutuhan', true); }); } function deleteNeedPoint(id) { if (!confirm('Apakah Anda yakin ingin menghapus point berkebutuhan ini?')) { return; } fetch('api/delete_need_point.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id }) }) .then(response => response.json()) .then(data => { if (data.success) { removeNeedPointsByIds([id]); showNotification('✓ Point berkebutuhan berhasil dihapus'); } else { showNotification('Error deleting point berkebutuhan: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error deleting point berkebutuhan', true); }); } // ============ HOUSEHOLD / ASSISTANCE / VERIFICATION (PHASE A) ============ // Toggle household panel visibility in peta tab function toggleHouseholdPanel() { const panel = document.getElementById('household-panel-wrapper'); if (panel) { panel.classList.toggle('visible'); } } function refreshHouseholdPanel() { loadHouseholdsForPanel(); loadHouseholdSummary(); loadHouseholdScoreSummary(); loadHouseholdPriorityRanking(); } function openHouseholdModal() { const modal = document.getElementById('household-modal'); const form = document.getElementById('household-form'); const latInput = document.getElementById('household-latitude'); const lngInput = document.getElementById('household-longitude'); if (!modal || !form) { return; } form.reset(); if (latInput && lngInput && map) { const center = map.getCenter(); latInput.value = center.lat.toFixed(6); lngInput.value = center.lng.toFixed(6); } modal.classList.add('show'); const headNameInput = document.getElementById('household-head-name'); if (headNameInput) { headNameInput.focus(); } } function closeHouseholdModal() { const modal = document.getElementById('household-modal'); if (modal) { modal.classList.remove('show'); } } function openAssistanceModal(preselectedHouseholdId = null) { // Always populate household select options; if none exist the form will still open populateHouseholdSelectOptions(); const modal = document.getElementById('assistance-modal'); const form = document.getElementById('assistance-form'); const dateInput = document.getElementById('assistance-date'); if (!modal || !form) { return; } form.reset(); if (dateInput) { dateInput.value = new Date().toISOString().slice(0, 10); } const householdSelect = document.getElementById('assistance-household-id'); if (householdSelect && preselectedHouseholdId) { householdSelect.value = String(preselectedHouseholdId); } updateAssistanceModalSummary(); modal.classList.add('show'); if (householdSelect) { householdSelect.focus(); } } function updateAssistanceModalSummary() { const select = document.getElementById('assistance-household-id'); const labelEl = document.getElementById('assistance-selected-household-label'); const metaEl = document.getElementById('assistance-selected-household-meta'); const selectedId = select ? String(select.value || '') : ''; const selectedHousehold = Array.isArray(households) ? households.find(item => String(item.id) === selectedId) : null; if (labelEl) { labelEl.textContent = selectedHousehold ? `${selectedHousehold.household_code || 'HH'} - ${selectedHousehold.head_name || 'Tanpa nama'}` : 'Pilih household'; } if (metaEl) { metaEl.textContent = selectedHousehold ? `${selectedHousehold.verification_status || 'unverified'} • ${selectedHousehold.latitude || '-'}, ${selectedHousehold.longitude || '-'}` : 'Data akan terisi otomatis jika tersedia.'; } } function createHouseholdFromNeedPoint(point) { return new Promise((resolve, reject) => { if (!point || !point.latitude || !point.longitude) { reject(new Error('Invalid need point data')); return; } const headName = point.head_name || point.household_name || ('Keluarga ' + Math.random().toString(16).slice(2,8)); const payload = { head_name: headName, latitude: Number(point.latitude), longitude: Number(point.longitude), need_point_id: Number(point.id || 0), economic_status: point.economic_status || 'unknown', verification_status: Number(point.is_verified || 0) === 1 ? 'field_verified' : 'unverified' }; fetch('api/save_household.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(r => r.json()) .then(data => { if (data && data.success && data.household) { // Insert into local households cache for immediate use households = households || []; households.unshift(data.household); resolve(data.household); } else { reject(new Error(data && data.error ? data.error : 'Failed to create household')); } }) .catch(err => reject(err)); }); } function closeAssistanceModal() { const modal = document.getElementById('assistance-modal'); if (modal) { modal.classList.remove('show'); } } function openVerificationModal() { if (!Array.isArray(households) || households.length === 0) { showNotification('Belum ada household. Tambahkan household terlebih dahulu.', true); return; } populateHouseholdSelectOptions(); const modal = document.getElementById('verification-modal'); const form = document.getElementById('verification-form'); if (!modal || !form) { return; } form.reset(); modal.classList.add('show'); const householdSelect = document.getElementById('verification-household-id'); if (householdSelect) { householdSelect.focus(); } } function closeVerificationModal() { const modal = document.getElementById('verification-modal'); if (modal) { modal.classList.remove('show'); } } function loadHouseholdsForPanel() { const statusFilter = document.getElementById('household-filter-status'); const selectedStatus = statusFilter ? statusFilter.value : ''; let url = 'api/get_households.php?limit=200'; if (selectedStatus) { url += '&verification_status=' + encodeURIComponent(selectedStatus); } fetch(url) .then(response => response.json()) .then(data => { if (data.success && Array.isArray(data.households)) { households = data.households; renderHouseholdList(households); populateHouseholdSelectOptions(); } else { households = []; renderHouseholdList([]); } }) .catch(error => { console.error('Error loading households:', error); households = []; renderHouseholdList([]); showNotification('Error loading household data', true); }); } function loadHouseholdSummary() { fetch('api/get_household_summary.php') .then(response => response.json()) .then(data => { if (data.success && data.summary) { householdSummary = data.summary; updateHouseholdSummaryUI(householdSummary); } }) .catch(error => { console.error('Error loading household summary:', error); }); } function updateHouseholdSummaryUI(summary) { const totalHouseholds = Number(summary?.totals?.total_households || 0); const assistedHouseholds = Number(summary?.assistance?.assisted_households || 0); const verifiedCount = Number(summary?.totals?.field_verified_count || 0) + Number(summary?.totals?.admin_approved_count || 0); const totalEl = document.getElementById('household-total-count'); const assistedEl = document.getElementById('household-assisted-count'); const verifiedEl = document.getElementById('household-verified-count'); if (totalEl) totalEl.textContent = totalHouseholds; if (assistedEl) assistedEl.textContent = assistedHouseholds; if (verifiedEl) verifiedEl.textContent = verifiedCount; } function populateHouseholdSelectOptions() { const selectIds = ['assistance-household-id', 'verification-household-id']; selectIds.forEach(selectId => { const select = document.getElementById(selectId); if (!select) { return; } const previousValue = select.value; let options = ''; households.forEach(household => { const id = Number(household.id); const code = household.household_code || 'HH'; const headName = household.head_name || '-'; const label = escapeHtml(String(code + ' - ' + headName)); options += ``; }); select.innerHTML = options; if (previousValue && households.some(h => String(h.id) === String(previousValue))) { select.value = previousValue; } }); } function getVerificationStatusLabel(status) { const labels = { unverified: 'Unverified', field_verified: 'Field Verified', admin_approved: 'Approved', needs_review: 'Needs Review', rejected: 'Rejected' }; return labels[status] || status; } function formatRupiah(value) { const numberValue = Number(value || 0); return 'Rp ' + new Intl.NumberFormat('id-ID').format(Math.round(numberValue)); } function renderHouseholdList(householdItems) { const listContainer = document.getElementById('households-list'); if (!listContainer) { return; } if (!Array.isArray(householdItems) || householdItems.length === 0) { listContainer.innerHTML = '
Belum ada data household.
'; return; } const html = householdItems.slice(0, 30).map(household => { const status = String(household.verification_status || 'unverified'); const statusClass = status.replace(/[^a-z_]/g, ''); const statusLabel = escapeHtml(getVerificationStatusLabel(status)); const householdCode = escapeHtml(String(household.household_code || '-')); const headName = escapeHtml(String(household.head_name || '-')); const incomeLabel = escapeHtml(formatRupiah(household.monthly_income)); const dependentsLabel = Number(household.dependents_count || 0); const assistanceCountLabel = Number(household.assistance_count || 0); const masjidName = escapeHtml(String(household.masjid_name || '-')); return `
${householdCode} ${statusLabel}
${headName}
Pendapatan: ${incomeLabel} Tanggungan: ${dependentsLabel} Bantuan tercatat: ${assistanceCountLabel} Masjid: ${masjidName}
`; }).join(''); listContainer.innerHTML = html; } function submitHouseholdForm() { const code = document.getElementById('household-code').value.trim(); const headName = document.getElementById('household-head-name').value.trim(); const nik = document.getElementById('household-nik').value.trim(); const latitudeRaw = document.getElementById('household-latitude').value; const longitudeRaw = document.getElementById('household-longitude').value; const monthlyIncomeRaw = document.getElementById('household-monthly-income').value; const dependentsRaw = document.getElementById('household-dependents-count').value; const housingScoreRaw = document.getElementById('household-housing-score').value; const employmentStatus = document.getElementById('household-employment-status').value; const verificationStatus = document.getElementById('household-verification-status').value; const vulnerabilityNotes = document.getElementById('household-vulnerability-notes').value.trim(); const latitude = parseFloat(latitudeRaw); const longitude = parseFloat(longitudeRaw); if (!headName) { showNotification('Nama kepala keluarga wajib diisi', true); return; } if (isNaN(latitude) || latitude < -90 || latitude > 90) { showNotification('Latitude tidak valid', true); return; } if (isNaN(longitude) || longitude < -180 || longitude > 180) { showNotification('Longitude tidak valid', true); return; } const payload = { head_name: headName, latitude: latitude, longitude: longitude, monthly_income: monthlyIncomeRaw === '' ? null : parseFloat(monthlyIncomeRaw), dependents_count: dependentsRaw === '' ? null : parseInt(dependentsRaw, 10), housing_condition_score: housingScoreRaw === '' ? null : parseInt(housingScoreRaw, 10), employment_status: employmentStatus, verification_status: verificationStatus, vulnerability_notes: vulnerabilityNotes || null }; if (code) payload.household_code = code; if (nik) payload.nik = nik; fetch('api/save_household.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload) }) .then(response => response.json()) .then(data => { if (data.success) { closeHouseholdModal(); refreshHouseholdPanel(); showNotification('✓ Household berhasil ditambahkan'); } else { showNotification('Error menambah household: ' + (data.error || 'unknown error'), true); } }) .catch(error => { console.error('Error:', error); showNotification('Error menambah household', true); }); } function submitAssistanceForm() { const householdIdRaw = document.getElementById('assistance-household-id').value; const assistanceType = document.getElementById('assistance-type').value; const assistanceValueRaw = document.getElementById('assistance-value').value; const assistanceFrequency = document.getElementById('assistance-frequency').value; const distributionDate = document.getElementById('assistance-date').value; const deliveryStatus = document.getElementById('assistance-delivery-status').value; const deliveredBy = document.getElementById('assistance-delivered-by').value.trim(); const notes = document.getElementById('assistance-notes').value.trim(); if (!householdIdRaw || isNaN(parseInt(householdIdRaw, 10))) { showNotification('Household wajib dipilih', true); return; } if (!distributionDate) { showNotification('Tanggal distribusi wajib diisi', true); return; } const payload = { household_id: parseInt(householdIdRaw, 10), assistance_type: assistanceType, assistance_value: assistanceValueRaw === '' ? 0 : parseFloat(assistanceValueRaw), assistance_frequency: assistanceFrequency, distribution_date: distributionDate, delivery_status: deliveryStatus, delivered_by: deliveredBy || null, notes: notes || null }; fetch('api/save_assistance_distribution.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload) }) .then(response => response.json()) .then(data => { if (data.success) { closeAssistanceModal(); refreshHouseholdPanel(); showNotification('✓ Data bantuan berhasil disimpan'); } else { showNotification('Error menyimpan bantuan: ' + (data.error || 'unknown error'), true); } }) .catch(error => { console.error('Error:', error); showNotification('Error menyimpan bantuan', true); }); } function submitVerificationForm() { const householdIdRaw = document.getElementById('verification-household-id').value; const verificationStatus = document.getElementById('verification-status').value; const verifierRole = document.getElementById('verification-verifier-role').value.trim(); const verifierName = document.getElementById('verification-verifier-name').value.trim(); const evidenceCountRaw = document.getElementById('verification-evidence-count').value; const confidenceScoreRaw = document.getElementById('verification-confidence-score').value; const fieldNote = document.getElementById('verification-field-note').value.trim(); if (!householdIdRaw || isNaN(parseInt(householdIdRaw, 10))) { showNotification('Household wajib dipilih', true); return; } if (!verifierName) { showNotification('Nama verifikator wajib diisi', true); return; } if (confidenceScoreRaw !== '') { const confidence = parseFloat(confidenceScoreRaw); if (isNaN(confidence) || confidence < 0 || confidence > 1) { showNotification('Skor kepercayaan data harus di antara 0 sampai 1', true); return; } } const payload = { household_id: parseInt(householdIdRaw, 10), verification_status: verificationStatus, verifier_name: verifierName, verifier_role: verifierRole || null, evidence_photo_count: evidenceCountRaw === '' ? 0 : parseInt(evidenceCountRaw, 10), data_confidence_score: confidenceScoreRaw === '' ? null : parseFloat(confidenceScoreRaw), field_note: fieldNote || null }; fetch('api/save_verification_log.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload) }) .then(response => response.json()) .then(data => { if (data.success) { closeVerificationModal(); refreshHouseholdPanel(); showNotification('✓ Verifikasi berhasil disimpan'); } else { showNotification('Error menyimpan verifikasi: ' + (data.error || 'unknown error'), true); } }) .catch(error => { console.error('Error:', error); showNotification('Error menyimpan verifikasi', true); }); } function computeHouseholdScores() { fetch('api/compute_household_scores.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({}) }) .then(response => response.json()) .then(data => { if (data.success) { const computedCount = Number(data.computed_count || 0); loadHouseholdScoreSummary(); loadManagementScorePreview(); loadHouseholdPriorityRanking(); showNotification('✓ Skor prioritas dihitung untuk ' + computedCount + ' household'); } else { showNotification('Error menghitung skor: ' + (data.error || 'unknown error'), true); } }) .catch(error => { console.error('Error:', error); showNotification('Error menghitung skor prioritas', true); }); } function loadHouseholdScoreSummary() { fetch('api/get_household_score_summary.php') .then(response => response.json()) .then(data => { if (!data.success || !data.summary) { return; } const counts = data.summary.priority_counts || {}; const assistedCount = Number(data.summary.assisted_households || 0); const assistedEl = document.getElementById('priority-assisted-count'); const veryHighEl = document.getElementById('priority-very-high-count'); const highEl = document.getElementById('priority-high-count'); const mediumEl = document.getElementById('priority-medium-count'); const lowEl = document.getElementById('priority-low-count'); if (assistedEl) assistedEl.textContent = assistedCount; if (veryHighEl) veryHighEl.textContent = Number(counts.very_high || 0); if (highEl) highEl.textContent = Number(counts.high || 0); if (mediumEl) mediumEl.textContent = Number(counts.medium || 0); if (lowEl) lowEl.textContent = Number(counts.low || 0); }) .catch(error => { console.error('Error loading household score summary:', error); }); } function loadHouseholdPriorityRanking() { fetch('api/get_household_ranking.php?limit=10') .then(response => response.json()) .then(data => { if (data.success && Array.isArray(data.ranking)) { renderHouseholdPriorityList(data.ranking); } }) .catch(error => { console.error('Error loading household ranking:', error); }); } function getPriorityLevelLabel(level) { const labels = { very_high: 'Very High', high: 'High', medium: 'Medium', low: 'Low' }; return labels[level] || level; } function renderHouseholdPriorityList(rankingItems) { const container = document.getElementById('household-priority-list'); if (!container) { return; } if (!Array.isArray(rankingItems) || rankingItems.length === 0) { container.innerHTML = '
Belum ada data skor prioritas.
'; return; } const html = rankingItems.map(item => { const priorityLevel = String(item.priority_level || 'low'); const priorityClass = priorityLevel.replace(/[^a-z_]/g, ''); const priorityLabel = escapeHtml(getPriorityLevelLabel(priorityLevel)); const householdCode = escapeHtml(String(item.household_code || '-')); const headName = escapeHtml(String(item.head_name || '-')); const spiValue = Number(item.spi || 0).toFixed(3); const assisted = Number(item.assisted || 0) === 1; const statusLabel = assisted ? 'Assisted' : priorityLabel; const statusClass = assisted ? 'assisted' : priorityClass; const nameClass = assisted ? 'priority-item-name assisted-point-name' : 'priority-item-name'; return `
${householdCode} ${headName}
SPI ${spiValue} ${statusLabel}
`; }).join(''); container.innerHTML = html; } function showNotification(message, isError = false) { const notification = document.createElement('div'); notification.className = 'notification' + (isError ? ' error' : ''); notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.remove(); }, 3000); } function escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } // ============ LINES/ROADS MANAGEMENT ============ function calculatePolylineLength(coordinates) { let totalDistance = 0; for (let i = 0; i < coordinates.length - 1; i++) { const latlng1 = L.latLng(coordinates[i][0], coordinates[i][1]); const latlng2 = L.latLng(coordinates[i + 1][0], coordinates[i + 1][1]); totalDistance += latlng1.distanceTo(latlng2); } return Math.round(totalDistance * 100) / 100; } function loadLines() { fetch('api/get_lines.php') .then(response => response.json()) .then(data => { if (data.success && data.lines) { // Clear existing lines Object.keys(lines).forEach(id => { if (map.hasLayer(lines[id])) { map.removeLayer(lines[id]); } }); lines = {}; lineCount = 0; // Load new lines data.lines.forEach(line => { displayLine( line.id, line.name, line.road_type, line.coordinates, line.length_meters ); }); updateLineCount(); } }) .catch(error => console.error('Error loading lines:', error)); } function displayLine(id, name, road_type, coordinates, length_meters, condition_status = 'normal') { lineData[id] = { id: id, name: name, road_type: road_type, coordinates: coordinates, length_meters: length_meters, condition_status: condition_status }; const baseStyle = lineStyles[road_type] || lineStyles['Nasional']; const finalStyle = applyConditionStyle(baseStyle, condition_status); const polyline = L.polyline(coordinates, finalStyle).addTo(map); const conditionLabel = condition_status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); const conditionIcon = condition_status === 'normal' ? '✓' : (condition_status === 'minor_damage' ? '⚠️' : '🔴'); const popupContent = `
${escapeHtml(name)}
🛣️ ${road_type}
${conditionIcon} Condition: ${conditionLabel}
Length: ${(length_meters/1000).toFixed(2)} km
Points: ${coordinates.length}
`; polyline.bindPopup(popupContent); lines[id] = polyline; lineCount++; } function applyConditionStyle(baseStyle, condition) { const conditionStyles = { 'normal': { dashArray: null, opacity: 0.8 }, 'minor_damage': { dashArray: '5, 5', opacity: 0.7 }, 'major_damage': { color: '#d32f2f', dashArray: '2, 3', opacity: 0.85 } }; return { ...baseStyle, ...conditionStyles[condition] }; } function updateLineCount() { const countElement = document.getElementById('line-count'); if (countElement) { countElement.textContent = lineCount; } } function addLine(name, road_type, coordinates, length_meters) { fetch('api/save_line.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: name, road_type: road_type, coordinates: coordinates, length_meters: length_meters }) }) .then(response => response.json()) .then(data => { if (data.success) { displayLine(data.id, data.name, data.road_type, data.coordinates, data.length_meters); updateLineCount(); showNotification('✓ Road added successfully'); closeLineModal(); } else { showNotification('Error adding road: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error adding road', true); }); } function editLine(id) { if (!lineData[id]) { showNotification('Line data not found', true); return; } const data = lineData[id]; document.getElementById('edit-line-name').value = data.name; document.getElementById('edit-line-road-type').value = data.road_type; document.getElementById('edit-line-length').value = (data.length_meters / 1000).toFixed(2); document.getElementById('edit-line-points').value = data.coordinates.length; document.getElementById('edit-line-condition').value = data.condition_status || 'normal'; document.getElementById('current-editing-line-id').value = id; const editModal = document.getElementById('edit-line-modal'); editModal.classList.add('show'); document.getElementById('edit-line-name').focus(); } function closeLineModal() { const modal = document.getElementById('line-modal'); modal.classList.remove('show'); // Clean up drawing mode drawMode.active = false; drawMode.type = null; drawMode.coordinates = []; if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); drawMode.temporaryLayer = null; } // Remove temporary markers drawMode.tempMarkers.forEach(marker => { map.removeLayer(marker); }); drawMode.tempMarkers = []; // Restore default click handler if (drawMode.defaultClickHandler) { map.on('click', drawMode.defaultClickHandler); } } function closeEditLineModal() { const editModal = document.getElementById('edit-line-modal'); editModal.classList.remove('show'); } function saveLineEdit() { const id = parseInt(document.getElementById('current-editing-line-id').value); const name = document.getElementById('edit-line-name').value.trim(); const road_type = document.getElementById('edit-line-road-type').value; const condition_status = document.getElementById('edit-line-condition').value; if (!name) { showNotification('Please enter a name for the road', true); return; } fetch('api/update_line.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id, name: name, road_type: road_type, condition_status: condition_status }) }) .then(response => response.json()) .then(data => { if (data.success) { const line = data.line; lineData[id].name = line.name; lineData[id].road_type = line.road_type; lineData[id].condition_status = line.condition_status; const baseStyle = lineStyles[line.road_type] || lineStyles['Nasional']; const finalStyle = applyConditionStyle(baseStyle, line.condition_status); lines[id].setStyle(finalStyle); closeEditLineModal(); showNotification('✓ Road updated successfully'); } else { showNotification('Error updating road: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error updating road', true); }); } function deleteLine(id) { if (!confirm('Are you sure you want to delete this road?')) { return; } fetch('api/delete_line.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id }) }) .then(response => response.json()) .then(data => { if (data.success) { if (lines[id] && map.hasLayer(lines[id])) { map.removeLayer(lines[id]); delete lines[id]; delete lineData[id]; lineCount--; updateLineCount(); showNotification('✓ Road deleted'); } } else { showNotification('Error deleting road: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error deleting road', true); }); } // ============ POLYGONS/LAND PARCELS MANAGEMENT ============ function calculatePolygonArea(coordinates) { // Close the polygon if not already closed const closed = (coordinates[coordinates.length - 1][0] === coordinates[0][0] && coordinates[coordinates.length - 1][1] === coordinates[0][1]) ? coordinates : [...coordinates, coordinates[0]]; // Shoelace formula let area = 0; for (let i = 0; i < closed.length - 1; i++) { const lat1 = closed[i][0], lng1 = closed[i][1]; const lat2 = closed[i + 1][0], lng2 = closed[i + 1][1]; area += (lng2 - lng1) * (lat2 + lat1) / 2; } // Convert from degrees to approximate square meters const metersPerDegree = 111320; const areaSqMeters = Math.abs(area) * metersPerDegree * metersPerDegree; return Math.round(areaSqMeters * 100) / 100; } function loadPolygons() { fetch('api/get_polygons.php') .then(response => response.json()) .then(data => { if (data.success && data.polygons) { // Clear existing polygons Object.keys(polygons).forEach(id => { if (map.hasLayer(polygons[id])) { map.removeLayer(polygons[id]); } }); polygons = {}; polygonCount = 0; // Load new polygons data.polygons.forEach(polygon => { displayPolygon( polygon.id, polygon.name, polygon.certificate_type, polygon.coordinates, polygon.area_sqm ); }); updatePolygonCount(); } }) .catch(error => console.error('Error loading polygons:', error)); } function displayPolygon(id, name, certificate_type, coordinates, area_sqm) { polygonData[id] = { id: id, name: name, certificate_type: certificate_type, coordinates: coordinates, area_sqm: area_sqm }; const style = polygonStyles[certificate_type] || polygonStyles['SHM']; const polygon = L.polygon(coordinates, style).addTo(map); const popupContent = `
${escapeHtml(name)}
🏞️ ${certificate_type}
Area: ${(area_sqm/10000).toFixed(2)} hectares
Points: ${coordinates.length}
`; polygon.bindPopup(popupContent); polygons[id] = polygon; polygonCount++; } function updatePolygonCount() { const countElement = document.getElementById('polygon-count'); if (countElement) { countElement.textContent = polygonCount; } } function addPolygon(name, certificate_type, coordinates, area_sqm) { fetch('api/save_polygon.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: name, certificate_type: certificate_type, coordinates: coordinates, area_sqm: area_sqm }) }) .then(response => response.json()) .then(data => { if (data.success) { displayPolygon(data.id, data.name, data.certificate_type, data.coordinates, data.area_sqm); updatePolygonCount(); showNotification('✓ Parcel added successfully'); closePolygonModal(); } else { showNotification('Error adding parcel: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error adding parcel', true); }); } function editPolygon(id) { if (!polygonData[id]) { showNotification('Polygon data not found', true); return; } const data = polygonData[id]; document.getElementById('edit-polygon-name').value = data.name; document.getElementById('edit-polygon-cert-type').value = data.certificate_type; document.getElementById('edit-polygon-area').value = (data.area_sqm / 10000).toFixed(2); document.getElementById('edit-polygon-points').value = data.coordinates.length; document.getElementById('current-editing-polygon-id').value = id; const editModal = document.getElementById('edit-polygon-modal'); editModal.classList.add('show'); document.getElementById('edit-polygon-name').focus(); } function closePolygonModal() { const modal = document.getElementById('polygon-modal'); modal.classList.remove('show'); // Clean up drawing mode drawMode.active = false; drawMode.type = null; drawMode.coordinates = []; if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); drawMode.temporaryLayer = null; } // Remove temporary markers drawMode.tempMarkers.forEach(marker => { map.removeLayer(marker); }); drawMode.tempMarkers = []; // Restore default click handler if (drawMode.defaultClickHandler) { map.on('click', drawMode.defaultClickHandler); } } function closeEditPolygonModal() { const editModal = document.getElementById('edit-polygon-modal'); editModal.classList.remove('show'); } function savePolygonEdit() { const id = parseInt(document.getElementById('current-editing-polygon-id').value); const name = document.getElementById('edit-polygon-name').value.trim(); const certificate_type = document.getElementById('edit-polygon-cert-type').value; if (!name) { showNotification('Please enter a name for the parcel', true); return; } fetch('api/update_polygon.php', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id, name: name, certificate_type: certificate_type }) }) .then(response => response.json()) .then(data => { if (data.success) { const polygon = data.polygon; polygonData[id].name = polygon.name; polygonData[id].certificate_type = polygon.certificate_type; const newStyle = polygonStyles[polygon.certificate_type] || polygonStyles['SHM']; polygons[id].setStyle(newStyle); closeEditPolygonModal(); showNotification('✓ Parcel updated successfully'); } else { showNotification('Error updating parcel: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error updating parcel', true); }); } function deletePolygon(id) { if (!confirm('Are you sure you want to delete this parcel?')) { return; } fetch('api/delete_polygon.php', { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: id }) }) .then(response => response.json()) .then(data => { if (data.success) { if (polygons[id] && map.hasLayer(polygons[id])) { map.removeLayer(polygons[id]); delete polygons[id]; delete polygonData[id]; polygonCount--; updatePolygonCount(); showNotification('✓ Parcel deleted'); } } else { showNotification('Error deleting parcel: ' + data.error, true); } }) .catch(error => { console.error('Error:', error); showNotification('Error deleting parcel', true); }); } // ============ DRAWING MODE ============ function enableLineDrawMode() { if (drawMode.active) { showNotification('Drawing mode already active', true); return; } drawMode.active = true; drawMode.type = 'line'; drawMode.coordinates = []; drawMode.tempMarkers = []; // Disable default point click handler if (drawMode.defaultClickHandler) { map.off('click', drawMode.defaultClickHandler); } showNotification('Click map points to draw road. Double-click to finish.'); document.getElementById('drawing-instructions').textContent = 'Click map points to draw road. Double-click to finish.'; document.getElementById('drawing-toolbar').classList.remove('hidden'); map.on('click', handleMapClickDuringDraw); map.on('dblclick', completeLineDrawing); } function enablePolygonDrawMode() { if (drawMode.active) { showNotification('Drawing mode already active', true); return; } drawMode.active = true; drawMode.type = 'polygon'; drawMode.coordinates = []; drawMode.tempMarkers = []; // Disable default point click handler if (drawMode.defaultClickHandler) { map.off('click', drawMode.defaultClickHandler); } showNotification('Click map points to draw parcel. Right-click to finish.'); document.getElementById('drawing-instructions').textContent = 'Click map points to draw parcel. Right-click to finish.'; document.getElementById('drawing-toolbar').classList.remove('hidden'); map.on('click', handleMapClickDuringDraw); map.on('contextmenu', completePolygonDrawing); } function handleMapClickDuringDraw(e) { if (!drawMode.active) return; // Prevent default behavior and opening point modal L.DomEvent.stopPropagation(e); L.DomEvent.preventDefault(e.originalEvent); const latlng = e.latlng; drawMode.coordinates.push([latlng.lat, latlng.lng]); // Update point counter document.getElementById('point-count').textContent = drawMode.coordinates.length; // Draw temporary polyline or polygon if (drawMode.type === 'line') { if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); } drawMode.temporaryLayer = L.polyline(drawMode.coordinates, { color: '#667eea', weight: 3, opacity: 0.7, dashArray: '5, 5' }).addTo(map); // Draw markers at each point const marker = L.circleMarker([latlng.lat, latlng.lng], { radius: 5, fillColor: '#667eea', color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.8 }).addTo(map); drawMode.tempMarkers.push(marker); } else if (drawMode.type === 'polygon') { if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); } drawMode.temporaryLayer = L.polygon(drawMode.coordinates, { fillColor: '#667eea', weight: 2, opacity: 0.7, color: '#667eea', fillOpacity: 0.3 }).addTo(map); // Draw markers at each point const marker = L.circleMarker([latlng.lat, latlng.lng], { radius: 5, fillColor: '#667eea', color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.8 }).addTo(map); drawMode.tempMarkers.push(marker); } } function completeLineDrawing() { if (!drawMode.active || drawMode.type !== 'line') return; map.off('click', handleMapClickDuringDraw); map.off('dblclick', completeLineDrawing); if (drawMode.coordinates.length < 2) { showNotification('Need at least 2 points to create a road', true); cancelDrawing(); return; } const length_meters = calculatePolylineLength(drawMode.coordinates); document.getElementById('line-name').value = ''; document.getElementById('line-road-type').value = ''; document.getElementById('line-length').value = (length_meters / 1000).toFixed(2); document.getElementById('line-points').value = drawMode.coordinates.length; document.getElementById('line-coordinates').value = JSON.stringify(drawMode.coordinates); document.getElementById('line-length-meters').value = length_meters; // Clean up drawing state but keep active until modal closes if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); drawMode.temporaryLayer = null; } // Remove temporary markers drawMode.tempMarkers.forEach(marker => { map.removeLayer(marker); }); drawMode.tempMarkers = []; document.getElementById('drawing-toolbar').classList.add('hidden'); document.getElementById('line-modal').classList.add('show'); } function completePolygonDrawing(e) { if (!drawMode.active || drawMode.type !== 'polygon') return; if (e) { L.DomEvent.preventDefault(e); } map.off('click', handleMapClickDuringDraw); map.off('contextmenu', completePolygonDrawing); if (drawMode.coordinates.length < 3) { showNotification('Need at least 3 points to create a parcel', true); cancelDrawing(); return; } const area_sqm = calculatePolygonArea(drawMode.coordinates); document.getElementById('polygon-name').value = ''; document.getElementById('polygon-cert-type').value = ''; document.getElementById('polygon-area').value = (area_sqm / 10000).toFixed(2); document.getElementById('polygon-points').value = drawMode.coordinates.length; document.getElementById('polygon-coordinates').value = JSON.stringify(drawMode.coordinates); document.getElementById('polygon-area-sqm').value = area_sqm; // Clean up drawing state but keep active until modal closes if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); drawMode.temporaryLayer = null; } // Remove temporary markers drawMode.tempMarkers.forEach(marker => { map.removeLayer(marker); }); drawMode.tempMarkers = []; document.getElementById('drawing-toolbar').classList.add('hidden'); document.getElementById('polygon-modal').classList.add('show'); } function cancelDrawing() { map.off('click', handleMapClickDuringDraw); map.off('dblclick', completeLineDrawing); map.off('contextmenu', completePolygonDrawing); if (drawMode.temporaryLayer) { map.removeLayer(drawMode.temporaryLayer); drawMode.temporaryLayer = null; } // Remove temporary markers drawMode.tempMarkers.forEach(marker => { map.removeLayer(marker); }); drawMode.tempMarkers = []; drawMode.active = false; drawMode.type = null; drawMode.coordinates = []; // Restore default click handler for point modal if (drawMode.defaultClickHandler) { map.on('click', drawMode.defaultClickHandler); } document.getElementById('drawing-toolbar').classList.add('hidden'); showNotification('Drawing cancelled'); } // Helper to complete current drawing depending on active draw type function completeDrawing() { if (!drawMode || !drawMode.active) return; if (drawMode.type === 'line') { completeLineDrawing(); } else if (drawMode.type === 'polygon') { completePolygonDrawing(); } } function submitLineForm() { const name = document.getElementById('line-name').value.trim(); const road_type = document.getElementById('line-road-type').value; const coordinates = JSON.parse(document.getElementById('line-coordinates').value); const length_meters = parseFloat(document.getElementById('line-length-meters').value); if (!name) { showNotification('Please enter a name for the road', true); return; } if (!road_type) { showNotification('Please select a road type', true); return; } addLine(name, road_type, coordinates, length_meters); } function submitPolygonForm() { const name = document.getElementById('polygon-name').value.trim(); const certificate_type = document.getElementById('polygon-cert-type').value; const coordinates = JSON.parse(document.getElementById('polygon-coordinates').value); const area_sqm = parseFloat(document.getElementById('polygon-area-sqm').value); if (!name) { showNotification('Please enter a name for the parcel', true); return; } if (!certificate_type) { showNotification('Please select a certificate type', true); return; } addPolygon(name, certificate_type, coordinates, area_sqm); } // ============ SEARCH & FILTER FOR ROADS ============ function applyRoadsFilter() { const search = document.getElementById('roads-search').value; const dateFrom = document.getElementById('roads-date-from').value; const dateTo = document.getElementById('roads-date-to').value; const condition = document.getElementById('roads-condition').value; const params = new URLSearchParams(); if (search) params.append('search', search); if (dateFrom) params.append('from_date', dateFrom); if (dateTo) params.append('to_date', dateTo); if (condition) params.append('condition_status', condition); fetch(`api/search_roads.php?${params}`) .then(response => response.json()) .then(data => { if (data.success) { Object.keys(lines).forEach(id => { if (map.hasLayer(lines[id])) map.removeLayer(lines[id]); }); lines = {}; lineData = {}; lineCount = 0; data.lines.forEach(line => { displayLine(line.id, line.name, line.road_type, line.coordinates, line.length_meters, line.condition_status); }); updateLineCount(); showNotification(`✓ ${data.total} roads found`); } else { showNotification('Error applying filter', true); } }) .catch(error => { console.error('Error:', error); showNotification('Error searching roads', true); }); } function clearRoadsFilter() { document.getElementById('roads-search').value = ''; document.getElementById('roads-date-from').value = ''; document.getElementById('roads-date-to').value = ''; document.getElementById('roads-condition').value = ''; loadLines(); showNotification('✓ Filter cleared'); } // ============ SEARCH & FILTER FOR PARCELS ============ function applyParcelsFilter() { const search = document.getElementById('parcels-search').value; const dateFrom = document.getElementById('parcels-date-from').value; const dateTo = document.getElementById('parcels-date-to').value; const certificate = document.getElementById('parcels-certificate').value; const params = new URLSearchParams(); if (search) params.append('search', search); if (dateFrom) params.append('from_date', dateFrom); if (dateTo) params.append('to_date', dateTo); if (certificate) params.append('certificate_type', certificate); fetch(`api/search_parcels.php?${params}`) .then(response => response.json()) .then(data => { if (data.success) { Object.keys(polygons).forEach(id => { if (map.hasLayer(polygons[id])) map.removeLayer(polygons[id]); }); polygons = {}; polygonData = {}; polygonCount = 0; data.polygons.forEach(polygon => { displayPolygon(polygon.id, polygon.name, polygon.certificate_type, polygon.coordinates, polygon.area_sqm); }); updatePolygonCount(); showNotification(`✓ ${data.total} parcels found`); } else { showNotification('Error applying filter', true); } }) .catch(error => { console.error('Error:', error); showNotification('Error searching parcels', true); }); } function clearParcelsFilter() { document.getElementById('parcels-search').value = ''; document.getElementById('parcels-date-from').value = ''; document.getElementById('parcels-date-to').value = ''; document.getElementById('parcels-certificate').value = ''; loadPolygons(); showNotification('✓ Filter cleared'); } // ============ DEBOUNCED SEARCH ============ setTimeout(function() { const roadsSearchInput = document.getElementById('roads-search'); if (roadsSearchInput) { roadsSearchInput.addEventListener('input', function() { clearTimeout(searchTimeouts.roads); searchTimeouts.roads = setTimeout(() => applyRoadsFilter(), 300); }); } const parcelsSearchInput = document.getElementById('parcels-search'); if (parcelsSearchInput) { parcelsSearchInput.addEventListener('input', function() { clearTimeout(searchTimeouts.parcels); searchTimeouts.parcels = setTimeout(() => applyParcelsFilter(), 300); }); } }, 500); // ============ PONTIANAK CHOROPLETH MAP ============ function buildChoroplethQuery(layerType) { const params = new URLSearchParams({ layer_type: layerType }); if (layerType !== 'city_boundary') { params.set('admin_level', pontianakState.adminLevel || 'kecamatan'); } return params.toString(); } // Toggle layer visibility on/off function toggleChoroplethLayer(layerType) { // 1. Update state pontianakState.currentLayers[layerType] = !pontianakState.currentLayers[layerType]; // 2. If toggling OFF, remove from map if (!pontianakState.currentLayers[layerType]) { if (pontianakState.layers[layerType]) { pontianakState.layers[layerType].forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); }); delete pontianakState.layers[layerType]; } } else { // 3. If toggling ON, fetch and render fetchAndRenderChoroplethLayer(layerType); } // 4. Update legend to reflect current visible layers updateChoroplethLegend(); } // Fetch and render a specific layer function fetchAndRenderChoroplethLayer(layerType) { fetch(`api/get_choropleth_data.php?${buildChoroplethQuery(layerType)}`) .then(response => response.json()) .then(data => { if (data.success) { renderChoroplethLayer(data, layerType); } }) .catch(error => { console.error('Error fetching layer:', layerType, error); pontianakState.currentLayers[layerType] = false; // Revert on error showNotification(`Error loading ${layerType} layer`, true); }); } function initPontianakMap() { if (pontianakState.dataLoaded) return; // Load administrative areas data fetch('api/get_pontianak_areas.php') .then(response => response.json()) .then(data => { if (data.success) { data.areas.forEach(area => { pontianakState.areasData[area.id] = area; }); } }) .catch(error => { console.error('Error loading Pontianak areas:', error); showNotification('Error loading Pontianak areas', true); }); // Render only currently visible layers (from checkboxes) Object.keys(pontianakState.currentLayers).forEach(layerType => { if (pontianakState.currentLayers[layerType]) { if (layerType === 'city_boundary') { renderCityBoundary(); } else { fetchAndRenderChoroplethLayer(layerType); } } }); pontianakState.dataLoaded = true; } function switchChoropleathLayer(layerType) { pontianakState.currentLayer = layerType; fetch(`api/get_choropleth_data.php?${buildChoroplethQuery(layerType)}`) .then(response => response.json()) .then(data => { if (data.success) { renderChoroplethLayer(data, layerType); updateChoroplethLegend(); } else { showNotification('Error loading choropleth data', true); } }) .catch(error => { console.error('Error:', error); showNotification('Error loading choropleth data', true); }); } function renderChoroplethLayer(data, layerType) { // Re-render this layer cleanly if (pontianakState.layers[layerType]) { pontianakState.layers[layerType].forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); }); } pontianakState.layers[layerType] = []; // Special handling for city boundary if (layerType === 'city_boundary') { renderCityBoundary(); return; } // Create color scale for this layer pontianakState.colorScales[layerType] = createColorScale(data.min_value, data.max_value); // Render each area data.data.forEach(feature => { const color = pontianakState.colorScales[layerType](feature.value); const geoJsonFeature = { type: 'Feature', geometry: typeof feature.geometry === 'string' ? JSON.parse(feature.geometry) : feature.geometry, properties: { area_id: feature.area_id, area_name: feature.area_name, value: feature.value, layer_type: layerType, value_source: feature.value_source || '' } }; const layer = L.geoJSON(geoJsonFeature, { style: { fillColor: color, fillOpacity: 0.6, // Slightly reduced for layer overlap visibility color: '#1f2a44', weight: 1.1, className: `choropleth-layer-${layerType}` }, onEachFeature: function(feature, layer) { const name = feature.properties.area_name; const value = feature.properties.value.toFixed(2); const label = getLayerLabel(layerType); const valueSource = feature.properties.value_source ? `
Source: ${feature.properties.value_source}` : ''; layer.bindPopup( `
${escapeHtml(name)}
${value}
${label}${valueSource}
` ); } }).addTo(map); pontianakState.layers[layerType].push(layer); }); } function renderCityBoundary() { if (!pontianakState.areasData.city_boundary && Object.keys(pontianakState.areasData).length === 0) { // Fetch city boundary if areas not loaded yet fetch('api/get_pontianak_areas.php?area_type=city') .then(response => response.json()) .then(data => { if (data.success && data.areas.length > 0) { const cityBoundary = data.areas[0]; pontianakState.areasData.city_boundary = cityBoundary; renderCityBoundaryLayer(cityBoundary); } }) .catch(error => console.error('Error loading city boundary:', error)); } else if (pontianakState.areasData.city_boundary) { renderCityBoundaryLayer(pontianakState.areasData.city_boundary); } else { // Fetch from areas data fetch('api/get_pontianak_areas.php?area_type=city') .then(response => response.json()) .then(data => { if (data.success && data.areas.length > 0) { const cityBoundary = data.areas[0]; pontianakState.areasData.city_boundary = cityBoundary; renderCityBoundaryLayer(cityBoundary); } }); } } function renderCityBoundaryLayer(cityBoundary) { if (pontianakState.layers.city_boundary) { pontianakState.layers.city_boundary.forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); }); } const geom = typeof cityBoundary.geometry === 'string' ? JSON.parse(cityBoundary.geometry) : cityBoundary.geometry; const layer = L.geoJSON({ type: 'Feature', geometry: geom, properties: { name: 'Pontianak City Boundary' } }, { style: { fillColor: '#7fb3d5', fillOpacity: 0.08, color: '#0d3557', weight: 4, dashArray: null, className: 'choropleth-layer-city_boundary' }, onEachFeature: function(feature, layer) { layer.bindPopup('🏙️ Pontianak City Boundary'); } }).addTo(map); if (!pontianakState.layers.city_boundary) { pontianakState.layers.city_boundary = []; } pontianakState.layers.city_boundary.push(layer); if (pontianakState.active && !pontianakState.hasFittedBoundary) { const bounds = layer.getBounds(); if (bounds && bounds.isValid()) { map.fitBounds(bounds, { padding: [20, 20] }); pontianakState.hasFittedBoundary = true; } } } function getLayerLabel(layerType) { const labels = { 'population': 'People/km²', 'road_density': 'Roads/km²', 'damaged_roads_density': 'Damaged roads/km²', 'city_boundary': 'City Boundary' }; return labels[layerType] || layerType; } function createColorScale(minVal, maxVal) { const colors = ['#2E86AB', '#A23B72', '#F18F01', '#C73E1D']; if (minVal === maxVal) { return () => colors[Math.floor(colors.length / 2)]; } return function(value) { const normalized = (value - minVal) / (maxVal - minVal); const index = Math.max(0, Math.min(normalized * (colors.length - 1), colors.length - 1)); return colors[Math.floor(index)]; }; } function updateChoroplethLegend() { const legendDiv = document.getElementById('choropleth-legend'); if (!legendDiv) return; // Get visible data layers (exclude city_boundary which has no values) const visibleLayers = Object.keys(pontianakState.currentLayers) .filter(key => pontianakState.currentLayers[key] && key !== 'city_boundary'); if (visibleLayers.length === 0) { legendDiv.innerHTML = '
No layers selected
'; return; } legendDiv.innerHTML = '
Loading legend...
'; const requests = visibleLayers.map(layerType => fetch(`api/get_choropleth_data.php?${buildChoroplethQuery(layerType)}`) .then(response => response.json()) .then(data => { if (!data.success) { return null; } return { layerType: layerType, minValue: data.min_value, maxValue: data.max_value }; }) .catch(error => { console.error('Error updating legend for', layerType, error); return null; }) ); Promise.all(requests).then(results => { const validResults = results.filter(Boolean); if (validResults.length === 0) { legendDiv.innerHTML = '
Legend unavailable
'; return; } legendDiv.innerHTML = ''; validResults.forEach(item => { const div = document.createElement('div'); div.className = 'legend-section'; div.setAttribute('data-layer', item.layerType); div.innerHTML = buildLegendSection(item.layerType, item.minValue, item.maxValue); legendDiv.appendChild(div); }); }); } function buildLegendSection(layerType, minVal, maxVal) { const labels = { 'population': 'Population Density (People/km²)', 'road_density': 'Road Density (Roads/km²)', 'damaged_roads_density': 'Damaged Roads Density (Roads/km²)' }; return `
${labels[layerType] || layerType}
${minVal.toFixed(1)}
${((minVal + maxVal) / 2).toFixed(1)}
${maxVal.toFixed(1)}
`; } function exportChoroplethGeoJSON() { window.location.href = 'api/export_geojson.php?type=pontianak_areas'; showNotification('✓ GeoJSON export started'); }