/** * Main Application Entry Point * * Demonstrates integration of: * - Bootstrap 5.3 for UI components * - SQLocal (SQLite in browser via OPFS) * - BroadcastChannel for cross-tab sync * - OpenLayers map with ol-ext LayerSwitcher * - PWA features (Service Worker, install prompt, offline detection) */ // Bootstrap CSS and JS import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap-icons/font/bootstrap-icons.css'; import { Modal, Offcanvas } from 'bootstrap'; // Database module (uses SQLocal directly, BroadcastChannel for tab sync) import { sql, dbReady, initSchema, addLocation, getLocations, getLocationCount, getDatabaseStatus, downloadDatabase, onDatabaseChange, exportToGeoJSON, saveRemoteData, getRemoteData, saveCollectorZones, getLocalCollectorZones, saveUpnGrid, getLocalUpnGrid, createExternalImport, addExternalImportFeatures, updateExternalImport, getExternalImport, getExternalImportFeatures, remapImportedFeatureProperties, saveParcels, getLocalParcels, updateParcel, insertNewParcel, saveBuildingFootprints, getLocalBuildingFootprints, saveOSMRoads, getLocalOSMRoads, isCachedLayerTable, clearTable, clearAllCachedLayers, getTableStats, getTableContent } from './src/database.js'; // Map component with OpenLayers and ol-ext LayerSwitcher import { MapView } from './src/components/MapView.js'; // OpenLayers GeoJSON format (for updating layer sources directly) import GeoJSON from 'ol/format/GeoJSON'; // OpenLayers WKT format (for writing drawn polygon geometries to database) import WKT from 'ol/format/WKT'; // OpenLayers KML format (for KML file import) import KML from 'ol/format/KML'; import { Style, Stroke, Fill, Text as OlText } from 'ol/style'; // Shapefile parser (reads .zip containing .shp/.dbf/.shx/.prj) // Lazy-loaded — only fetched the first time the user imports a shapefile. let _shpModule = null; async function getShp() { if (!_shpModule) { const mod = await import('shpjs'); _shpModule = mod.default || mod; } return _shpModule; } // Map measurement and drawing tools import { MapTools } from './src/components/MapTools.js'; // PWA module (registers Service Worker, handles install/offline) import { initPWA, isOnline, onOfflineChange, getTileCacheStats, clearTileCaches, clearTileCacheForProvider, getStorageEstimate, onServiceWorkerControllerChange } from './src/pwa.js'; import { BASEMAP_TEMPLATES, GHANA_EXTENT_3857, countTiles, estimatedSizeBytes, OfflineTileDownloader, } from './src/offlineTiles.js'; // Remote database API (PostgreSQL backend) import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getUpnGrid, getSession } from './src/remotedb.js'; // GPS live-position + trail recording (reusable engine + LUPMIS wiring) import { geoTracker } from './src/geotracker-lupmis.js'; import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js'; // Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx) import { createEmbedBridge } from './src/embed-bridge.js'; // External-dataset import → staging → upload (see LUPMIS2_Import_Upload_Design.docx) import { openImportMappingModal } from './src/import-modal.js'; import { applyFieldMapping } from './src/import-detect.js'; // GIS export from the analysis popups (Area / Circle) import { openExportGisModal } from './src/export-gis-modal.js'; // Map instance (global for access across functions) let mapView = null; let mapTools = null; // Module-level reference so the embed bridge can access the parcels layer // once loadParcels() has created it. let parcelsLayer = null; let embedBridge = null; // Iframe embed mode. Set by public/embed.php when serving the /embed route; // undefined for the normal /index.php entry point. const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null; const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit'); // Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea' // In embed permit mode we don't want the default Add-Location click to fire, // so start the mode in a neutral state. let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'addLocation'; // ============================================================================ // Application Initialization // ============================================================================ /** * Pre-flight: when an SSO session is present but the user has no district * assigned, the app cannot function (every API call is scoped to a district). * Show a blocking message and halt initialisation so we never silently fall * back to a default district. * * Local dev (no window.LUPMIS_SESSION at all) is *not* affected — that path * still uses the remotedb FALLBACK_DISTRICT_ID for testing. * * @returns {boolean} true if the user is blocked (init should abort) */ function showNoDistrictBlockerIfNeeded() { const session = (typeof window !== 'undefined') ? window.LUPMIS_SESSION : null; if (!session || typeof session !== 'object') return false; // dev mode const id = session.district_id; if (id !== null && id !== undefined && String(id).length > 0) return false; // Authenticated but no district — render an overlay and abort init. console.warn('[App] Authenticated user has no district assigned; halting init.'); const overlay = document.createElement('div'); overlay.id = 'no-district-overlay'; overlay.setAttribute('role', 'alertdialog'); overlay.setAttribute('aria-modal', 'true'); overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;' + 'justify-content:center;background:rgba(255,255,255,0.98);padding:24px;'; const name = session.full_name || session.username || 'You'; overlay.innerHTML = `
🛑

No district assigned

${escapeHtml(name)}, your user profile is not associated with any district. LUPMIS2 cannot load the relevant map data without one.

Please contact the system administrator to have a district assigned to your account.

`; document.body.appendChild(overlay); overlay.querySelector('#no-district-portal-btn')?.addEventListener('click', () => { window.location.href = 'https://lupmis4luspa.org/'; }); return true; } async function initApp() { console.log('[App] Initializing...'); // Pre-flight: authenticated user must have a district assigned. if (showNoDistrictBlockerIfNeeded()) return; // 1. Initialize PWA features (Service Worker, install prompt, offline detection) await initPWA({ installButton: '#install-btn', offlineIndicator: '#offline-indicator', autoRegisterSW: true }); // 2. Initialize the map // Restore the user's preferred default base map from localStorage const savedBasemap = localStorage.getItem('default-basemap') || 'topo'; mapView = new MapView('map', { center: [-1.5, 7.5], // Ghana zoom: 7, basemap: savedBasemap, }); // Initialize map measurement tools mapTools = new MapTools(mapView.getMap()); // Wire up GPS live-position + trail recording initGpsTracking(); // Handle measurement results mapTools.onMeasureComplete((result) => { console.log('[MapTools] Measurement complete:', result); // Only show the Polygon Attributes popup for polygons drawn with the // Draw tool — NOT for area measurements (which have _layerType = 'measure_area'). if (result.type === 'polygon' && result.coordinate) { const lt = result.feature?.get('_layerType'); if (lt !== 'measure_area') { mapView?.showDrawnPolygonPopup(result.feature, result.coordinate); } } }); // Category emojis are set up in MapView: // 'water': '💧', 'school': '🏫', 'health': '🏥', // 'market': '🏪', 'default': '📍', 'other': '📌' // In iframe embed permit mode, install the postMessage bridge BEFORE the // regular handlers so its outbound parcel:select / parcel:cleared events // are wired up; the regular click/dblclick handlers below short-circuit in // that mode (the bridge owns map interaction in the embed). if (IS_EMBED_PERMIT) { embedBridge = createEmbedBridge({ mapView, embedConfig: EMBED_CONFIG }); } // Set up map click handler immediately after map creation mapView.onClick((lon, lat, feature, evt) => { // Embed permit mode: the bridge handles parcel selection itself; the // normal popup/add-location behaviour does not apply. if (IS_EMBED_PERMIT) return; console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4)); console.log('[MapClick] currentMode =', currentMode); // In draw or measurement modes, clicks drive the tool — don't // open popups or select features. if (currentMode === 'draw' || currentMode.startsWith('measure')) { return; } // Check if a parcel feature was clicked let parcelFeature = null; mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => { if (f.get('_layerType') === 'parcel') { parcelFeature = f; return true; // stop at first parcel hit } }); // Parcel click: open Edit Attributes form in ANY non-draw mode. // The feature is NOT selected — only the popup is shown. if (parcelFeature) { console.log('[MapClick] Clicked on parcel → Edit Attributes'); mapView.showParcelEditPopup(parcelFeature, evt.coordinate); return; } // UPN-grid cell click: show a popup with the upn_prefix. This runs in // ANY non-draw mode and is checked AFTER the parcel branch so that a // parcel sitting inside a grid cell wins (parcels are the specific // object, the grid is contextual). let upnGridFeature = null; mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => { if (f.get('_layerType') === 'upn_grid') { upnGridFeature = f; return true; } }); if (upnGridFeature) { console.log('[MapClick] Clicked on UPN-grid cell → Info popup'); mapView.showInfoPopup(upnGridFeature, evt.coordinate, { title: 'UPN Grid Cell', color: '#7c3aed', }); return; } // Non-parcel clicks (markers, empty space) only in addLocation mode if (currentMode !== 'addLocation') { return; } if (feature) { // Clicked on existing marker - select it and show details console.log('[MapClick] Clicked on marker:', feature.getId()); mapView.selectMarker(feature); showLocationDetails(feature); } else { // Clicked on empty space - show add location popup at click position console.log('[MapClick] Empty space → Add Location popup'); mapView.clearSelection(); mapView.showAddLocationPopup(evt.coordinate); } }); // Set up double-click handler for overlay feature info // Uses '_layerType' property to distinguish zone features from other layers mapView.onDblClick((lon, lat, feature, evt) => { // Embed permit mode shows no info popups (the host owns the UI). if (IS_EMBED_PERMIT) return; if (!feature) return; const layerType = feature.get('_layerType'); console.log('[App] Double-click on feature, _layerType:', layerType || 'none'); if (layerType === 'measure_circle') { // Circle measurement: show intersection analysis with other layers mapView.showCircleIntersectionPopup(feature, evt.coordinate); } else if (layerType === 'measure_circle_radius') { // Clicked on the radius line — ignore return; } else if (layerType === 'measure_area') { // Area measurement polygon: show intersection analysis mapView.showAreaIntersectionPopup(feature, evt.coordinate); } else if (layerType === 'collector_zone') { mapView.showInfoPopup(feature, evt.coordinate, { title: 'Zone Info', color: '#7c3aed', }); } else if (layerType === 'parcel') { mapView.showInfoPopup(feature, evt.coordinate, { title: 'Parcel Info', color: '#0ea5e9', }); } else { mapView.showInfoPopup(feature, evt.coordinate, { title: 'Feature Info', color: '#e11d48', }); } }); // Set up handler for the map add location popup form mapView.onAddLocation(async (data) => { console.log('[App] Add location from map popup:', data); try { const result = await addLocation(data.name, data.lon, data.lat, { description: data.description || null, category: data.category || 'default' }); console.log('[App] Location added:', data.name, 'id:', result.id); await loadLocations(); // Zoom to the new location on the map mapView?.zoomTo(data.lon, data.lat, 14); // Select the new marker if (result.id) { mapView?.selectMarker(result.id); } showSuccess('Location added successfully'); } catch (error) { console.error('[App] Failed to add location:', error); showError('Failed to add location: ' + error.message); } }); // Set up parcel edit save handler mapView.onParcelEdit(async (feature, updatedProps) => { const parcelId = updatedProps.id || updatedProps.parcelid || updatedProps.parcel_id; console.log('[App] Parcel edit saved:', parcelId, updatedProps); if (!parcelId) { console.warn('[App] No parcel ID found in updated properties — skipping local save'); return; } try { await updateParcel(parcelId, updatedProps); showSuccess('Parcel updated locally'); } catch (error) { console.error('[App] Failed to save parcel update:', error); showError('Failed to save parcel: ' + error.message); } }); // Set up drawn polygon attribute save handler const wktFormat = new WKT(); mapView.onDrawnPolygonSave(async (feature, props) => { console.log('[App] Drawn polygon attributes saved:', props); try { // Convert the OL geometry (EPSG:3857) to WKT in EPSG:4326 for storage const wktString = wktFormat.writeGeometry(feature.getGeometry(), { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857', }); const result = await insertNewParcel(wktString, props); console.log('[App] New parcel inserted with id:', result.id); showSuccess('New parcel saved (pending verification)'); } catch (error) { console.error('[App] Failed to save new parcel:', error); showError('Failed to save parcel: ' + error.message); } }); // 3. Initialize database try { console.log('[App] Initializing database...'); // Initialize schema (creates tables if they don't exist) // This also resolves dbReady when complete await initSchema(); // Now dbReady should be resolved console.log('[App] Database ready'); // Show database status const status = await getDatabaseStatus(); console.log('[App] Database status:', status); // Quick server reachability check (5 s timeout) — if the API server // is down, all load functions will skip remote fetches and fall back // to local cached data immediately, keeping the app responsive. if (isOnline()) { const reachable = await checkServerReachable(); if (!reachable) { console.warn('[App] API server unreachable — using local data only'); showWarning('Server not responding — loading cached data.'); } } // Load remote overlays (needs remote_data table from initSchema) // loadLayers must complete first so the layer groups exist // before loadDistrictBoundary adds into the Administration group. await loadLayers(); // Initialise EditBar with its own "Drawings" layer group mapView?.initEditBar(); loadDistrictBoundary(); loadUpnGrid(); loadCollectorZones(); loadParcels(); // In embed permit mode the parcels layer is the user's working surface, // so make it visible immediately and hand the layer to the bridge so it // can emit `ready` (and resolve any pending `set:selected` UPN) once the // features arrive. loadParcels() runs its synchronous prologue (creating // the layer and assigning the module-level reference) before returning // its promise, so `parcelsLayer` is already set here. if (IS_EMBED_PERMIT && embedBridge && parcelsLayer) { parcelsLayer.setVisible(true); embedBridge.attachParcelsLayer(parcelsLayer); } loadBuildingFootprints(); loadContoursHillshade(); loadOSMRoads(); loadExternalWMSLayers(); } catch (error) { console.error('[App] Database initialization failed:', error); showError('Failed to initialize database. Please refresh the page.'); return; } // 4. Initialize UI initUI(); // 5. Load initial data and display on map await loadLocations(); // 6. Listen for database changes (local + other tabs) onDatabaseChange((change) => { console.log('[App] Database change:', change); if (change.table === 'locations' && !change.local) { // Reload locations when another tab makes changes loadLocations(); } if (change.table === 'parcels') { // Refresh the Local Data stats panel if it is visible const statsContainer = document.getElementById('local-data-stats'); if (statsContainer && !statsContainer.classList.contains('d-none')) { refreshLocalDataStats(); } } }); // 7. Set up offline handling onOfflineChange((offline) => { if (offline) { console.log('[App] Working offline - data will sync when back online'); } else { console.log('[App] Back online - syncing data...'); syncData(); } }); // 8. Fieldwork mode (high-contrast + large touch targets) initFieldworkMode(); // 9. Measurement system toggle (metric / imperial) initMeasurementSystem(); // 10. Dark mode initDarkMode(); // 11. Default base map selector initDefaultBasemap(); // 12. Offline tile-cache stats card initOfflineTileCache(); // 13. Offline-download dialog initOfflineDownloadDialog(); // 14. Account card (signed-in user + sign-out) initAccountCard(); console.log('[App] Initialized successfully'); } // ============================================================================ // UI Initialization // ============================================================================ function initUI() { console.log('[initUI] Starting UI initialization...'); // Message log (persistent stack in right panel) initMessageLog(); // Export button const exportBtn = document.getElementById('export-btn'); if (exportBtn) { exportBtn.addEventListener('click', handleExport); } // Local Data button — shows tables and record counts const localDataBtn = document.getElementById('local-data-btn'); if (localDataBtn) { localDataBtn.addEventListener('click', () => refreshLocalDataStats()); } // File import buttons (Shapefile, GeoJSON, KML) const importShpBtn = document.getElementById('import-shp-btn'); const shpFileInput = document.getElementById('shp-file-input'); if (importShpBtn && shpFileInput) { importShpBtn.addEventListener('click', () => shpFileInput.click()); shpFileInput.addEventListener('change', handleShapefileImport); } const importGeoJSONBtn = document.getElementById('import-geojson-btn'); const geojsonFileInput = document.getElementById('geojson-file-input'); if (importGeoJSONBtn && geojsonFileInput) { importGeoJSONBtn.addEventListener('click', () => geojsonFileInput.click()); geojsonFileInput.addEventListener('change', handleGeoJSONImport); } const importKMLBtn = document.getElementById('import-kml-btn'); const kmlFileInput = document.getElementById('kml-file-input'); if (importKMLBtn && kmlFileInput) { importKMLBtn.addEventListener('click', () => kmlFileInput.click()); kmlFileInput.addEventListener('change', handleKMLImport); } // Drag-and-drop file import on the map initMapDropZone(); // GeoJSON Export button const exportGeoJSONBtn = document.getElementById('exportGeoJSON-btn'); if (exportGeoJSONBtn) { exportGeoJSONBtn.addEventListener('click', handleExportGeoJSON); } // Status button const statusBtn = document.getElementById('status-btn'); if (statusBtn) { statusBtn.addEventListener('click', handleShowStatus); } // Fit to markers button const fitBtn = document.getElementById('fit-btn'); if (fitBtn) { fitBtn.addEventListener('click', () => mapView?.fitToMarkers()); } // ============================================ // Mode Selector & Measurement Tools (Bottom Dock) // ============================================ const addLocationBtn = document.getElementById('dock-btn-add-location'); const measureCircleBtn = document.getElementById('dock-btn-measure-circle'); const measureLineBtn = document.getElementById('dock-btn-measure-line'); const measureAreaBtn = document.getElementById('dock-btn-measure-area'); const drawBtn = document.getElementById('dock-btn-draw'); const clearBtn = document.getElementById('dock-btn-clear'); // Debug: Check if buttons are found console.log('[initUI] Buttons found:', { addLocation: !!addLocationBtn, measureCircle: !!measureCircleBtn, measureLine: !!measureLineBtn, measureArea: !!measureAreaBtn, draw: !!drawBtn, clear: !!clearBtn }); // All mode buttons (mutually exclusive) const modeButtons = [addLocationBtn, measureCircleBtn, measureLineBtn, measureAreaBtn, drawBtn]; // Helper to set active mode and update button states // Note: This updates the module-level currentMode variable const setMode = (mode, activeBtn) => { console.log('[setMode] Changing mode from', currentMode, 'to', mode); currentMode = mode; console.log('[setMode] currentMode is now:', currentMode); // Update button active states modeButtons.forEach(btn => { if (btn) btn.classList.toggle('active', btn === activeBtn); }); // Deactivate any measurement tool when switching modes mapTools?.deactivate(); // Leave edit mode when switching away from draw if (mode !== 'draw') { mapView?.setEditMode(false); } // Hide add location popup when leaving addLocation mode if (mode !== 'addLocation') { mapView?.hideAddLocationPopup(); } // Activate the appropriate tool for the new mode switch (mode) { case 'measureCircle': mapTools?.startCircleMeasure(); break; case 'measureLine': mapTools?.startLineMeasure(); break; case 'measureArea': mapTools?.startAreaMeasure(); break; case 'draw': mapView?.setEditMode(true); break; // addLocation mode doesn't need tool activation } }; // Add Location mode button if (addLocationBtn) { addLocationBtn.addEventListener('click', () => { console.log('[Button] Add Location clicked'); setMode('addLocation', addLocationBtn); }); } // Circle measurement button if (measureCircleBtn) { measureCircleBtn.addEventListener('click', () => { console.log('[Button] Circle clicked, currentMode is:', currentMode); if (currentMode === 'measureCircle') { // Toggle off - return to addLocation mode setMode('addLocation', addLocationBtn); } else { setMode('measureCircle', measureCircleBtn); } }); } // Line measurement button if (measureLineBtn) { measureLineBtn.addEventListener('click', () => { console.log('[Button] Line clicked, currentMode is:', currentMode); if (currentMode === 'measureLine') { setMode('addLocation', addLocationBtn); } else { setMode('measureLine', measureLineBtn); } }); } // Area measurement button if (measureAreaBtn) { measureAreaBtn.addEventListener('click', () => { console.log('[Button] Area clicked, currentMode is:', currentMode); if (currentMode === 'measureArea') { setMode('addLocation', addLocationBtn); } else { setMode('measureArea', measureAreaBtn); } }); } // Draw / Edit button if (drawBtn) { drawBtn.addEventListener('click', () => { console.log('[Button] Draw clicked, currentMode is:', currentMode); if (currentMode === 'draw') { setMode('addLocation', addLocationBtn); } else { setMode('draw', drawBtn); } }); } // Clear button - clears measurements but stays in current mode if (clearBtn) { clearBtn.addEventListener('click', () => { mapTools?.clearMeasurements(); // If in a measurement mode, restart the tool if (currentMode.startsWith('measure')) { mapTools?.deactivate(); switch (currentMode) { case 'measureCircle': mapTools?.startCircleMeasure(); break; case 'measureLine': mapTools?.startLineMeasure(); break; case 'measureArea': mapTools?.startAreaMeasure(); break; } } }); } } // ============================================================================ // Location Handlers // ============================================================================ async function handleAddLocation(event) { event.preventDefault(); const form = event.target; const formData = new FormData(form); const name = formData.get('name'); const longitude = parseFloat(formData.get('longitude')); const latitude = parseFloat(formData.get('latitude')); const description = formData.get('description') || null; const category = formData.get('category') || 'default'; if (!name || isNaN(longitude) || isNaN(latitude)) { showError('Please fill in all required fields'); return; } try { const result = await addLocation(name, longitude, latitude, { description, category }); console.log('[App] Location added:', name, 'id:', result.id); form.reset(); await loadLocations(); // Zoom to the new location on the map mapView?.zoomTo(longitude, latitude, 14); // Select the new marker if (result.id) { mapView?.selectMarker(result.id); } showSuccess('Location added successfully'); } catch (error) { console.error('[App] Failed to add location:', error); showError('Failed to add location: ' + error.message); } } async function loadLocations() { try { console.log('[App] Loading locations...'); const locations = await getLocations(); console.log('[App] Locations loaded:', locations); // Update the list renderLocations(locations); // Update the map markers if (mapView) { mapView.clearMarkers(); if (locations.length > 0) { mapView.addMarkers(locations); console.log('[App] Added', locations.length, 'markers to map'); } } // Update count display const countEl = document.getElementById('location-count'); if (countEl) { countEl.textContent = locations.length; } } catch (error) { console.error('[App] Failed to load locations:', error); } } /** * Show details for a selected location */ function showLocationDetails(feature) { const name = feature.get('name'); const description = feature.get('description'); const category = feature.get('category'); const lon = feature.get('lon') || feature.get('longitude'); const lat = feature.get('lat') || feature.get('latitude'); // You could show a popup or info panel here // For now, just log to console console.log('[App] Selected location:', { name, description, category, lon, lat }); // Optionally zoom to the location // mapView.zoomTo(lon, lat, 14); } function renderLocations(locations) { const container = document.getElementById('locations-list'); if (!container) return; // Also update mobile count const mobileCount = document.getElementById('location-count-mobile'); if (mobileCount) { mobileCount.textContent = locations.length; } if (locations.length === 0) { container.innerHTML = `

No locations yet.

Click the map or fill the form above!
`; return; } // Category emoji mapping const categoryEmojis = { 'water': '💧', 'school': '🏫', 'health': '🏥', 'market': '🏪', 'default': '📍', 'other': '📌' }; container.innerHTML = locations.map(loc => { const emoji = categoryEmojis[loc.category] || '📍'; return `
${emoji} ${escapeHtml(loc.name)}
${loc.latitude.toFixed(5)}, ${loc.longitude.toFixed(5)}
${loc.category}
${loc.description ? `${escapeHtml(loc.description)}` : ''}
`; }).join(''); // Add click handlers to zoom to location container.querySelectorAll('.location-item').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const lon = parseFloat(item.dataset.lon); const lat = parseFloat(item.dataset.lat); const id = parseInt(item.dataset.id); // Zoom to location on map mapView?.zoomTo(lon, lat, 14); // Select the marker mapView?.selectMarker(id); }); }); } // ============================================================================ // Local Data Stats // ============================================================================ /** * Refresh the Local Data stats panel in the left offcanvas. * If the panel is already visible it updates in-place; otherwise it opens it. */ async function refreshLocalDataStats() { const statsContainer = document.getElementById('local-data-stats'); const tbody = document.getElementById('local-data-tbody'); const clearAllBtn = document.getElementById('clear-all-cached-btn'); if (!statsContainer || !tbody) return; try { const stats = await getTableStats(); tbody.innerHTML = stats.map((t) => { const isCached = isCachedLayerTable(t.name); const clearBtn = isCached ? `` : ''; return ` ${escapeHtml(t.name)} ${t.count} ${clearBtn} `; }).join(''); statsContainer.classList.remove('d-none'); // Table-name link → open content modal tbody.querySelectorAll('.table-name-link').forEach((link) => { link.addEventListener('click', (e) => { e.preventDefault(); showTableContent(link.dataset.table); }); }); // Per-row clear → confirm, clear that table, refresh stats tbody.querySelectorAll('.table-clear-btn').forEach((btn) => { btn.addEventListener('click', async (e) => { e.preventDefault(); const tableName = btn.dataset.table; if (!confirm(`Clear local cache for "${tableName}"?\n\nThe data will be re-downloaded from the server on the next app start.`)) return; try { const removed = await clearTable(tableName); showSuccess(`Cleared ${removed} row${removed === 1 ? '' : 's'} from "${tableName}". It will re-download on next start.`); await refreshLocalDataStats(); } catch (err) { console.error('[App] Per-table clear failed:', err); showError(`Could not clear "${tableName}": ${err.message}`); } }); }); } catch (error) { console.error('[App] Failed to load table stats:', error); tbody.innerHTML = `Failed to load`; statsContainer.classList.remove('d-none'); } // Bulk-clear button — wire up once if (clearAllBtn && !clearAllBtn._wired) { clearAllBtn._wired = true; clearAllBtn.addEventListener('click', handleClearAllCachedLayers); } } /** * Clear every cached layer table and offer to reload the app so the layers * re-download immediately. If the user dismisses the reload prompt, the * fresh fetch will happen on the next manual app start. */ async function handleClearAllCachedLayers() { if (!confirm( 'Delete all cached map layers from this device?\n\n' + 'The next time the app starts (or after a reload), every layer will be ' + 're-downloaded from the server. Your locally drawn data is not affected.' )) return; try { const results = await clearAllCachedLayers(); const total = results.reduce((s, r) => s + r.count, 0); showSuccess(`Cleared ${total} row${total === 1 ? '' : 's'} across ${results.length} table${results.length === 1 ? '' : 's'}.`); await refreshLocalDataStats(); if (confirm('Reload the app now to re-download the layers fresh from the server?')) { window.location.reload(); } } catch (err) { console.error('[App] Clear-all failed:', err); showError('Failed to clear cached layers: ' + err.message); } } // ============================================================================ // Table Content Viewer // ============================================================================ /** * Load and display all rows of a table in a modal. * @param {string} tableName - The table to show */ async function showTableContent(tableName) { const modalTitle = document.getElementById('tableContentModalLabel'); const modalBody = document.getElementById('table-content-body'); const modalInfo = document.getElementById('table-content-info'); // Set title and show spinner modalTitle.textContent = `Table: ${tableName}`; modalBody.innerHTML = `
Loading...
`; modalInfo.textContent = ''; // Open the modal const modal = new Modal(document.getElementById('tableContentModal')); modal.show(); try { const { columns, rows } = await getTableContent(tableName); if (rows.length === 0) { modalBody.innerHTML = `
Table is empty
`; modalInfo.textContent = '0 rows'; return; } // Build a responsive table const headerCells = columns.map(c => `${escapeHtml(c)}`).join(''); const bodyRows = rows.map(row => { const cells = columns.map(c => { let val = row[c]; if (val === null || val === undefined) return 'NULL'; val = String(val); // Truncate long values for display const display = val.length > 120 ? val.substring(0, 120) + '...' : val; return `${escapeHtml(display)}`; }).join(''); return `${cells}`; }).join(''); modalBody.innerHTML = `
${headerCells}${bodyRows}
`; modalInfo.textContent = `${rows.length}${rows.length >= 200 ? '+' : ''} row(s), ${columns.length} column(s)`; } catch (error) { console.error('[App] Failed to load table content:', error); modalBody.innerHTML = `
Failed to load: ${escapeHtml(error.message)}
`; } } // ============================================================================ // Export Handler // ============================================================================ async function handleExport() { try { await downloadDatabase('lupmis-backup.sqlite3'); showSuccess('Database exported successfully'); } catch (error) { console.error('[App] Export failed:', error); showError('Export failed: ' + error.message); } } // Export as GeoJSON file async function handleExportGeoJSON() { try { const geojson = await exportToGeoJSON(); // Download as file const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'locations.geojson'; a.click(); URL.revokeObjectURL(url); showSuccess(`Exported ${geojson.features.length} location(s)`); }catch (error) { console.error('[App] GeoJSON Export failed:', error); showError('GeoJSON Export failed: ' + error.message); } } // ============================================================================ // Status Handler // ============================================================================ async function handleShowStatus() { try { const status = await getDatabaseStatus(); // Update modal content const statusContent = document.getElementById('status-content'); if (statusContent) { statusContent.innerHTML = `
Ready: ${status.ready ? 'Yes' : 'No'}
Online: ${isOnline() ? 'Yes' : 'Offline'}
Database: ${status.databasePath || 'N/A'}
Tables: ${status.tables.map(t => `${t}`).join('')}
Locations: ${status.locationCount}
`; } // Show the modal using Bootstrap const statusModal = new Modal(document.getElementById('statusModal')); statusModal.show(); } catch (error) { console.error('[App] Failed to get status:', error); showError('Failed to get status'); } } // ============================================================================ // Remote Data Loading // ============================================================================ /** * Parse a coordinate ring string into an array of [lon, lat] pairs. * @param {string} ringStr - e.g. "lon lat,lon lat,..." * @returns {Array} Array of [lon, lat] */ function parseCoordRing(ringStr) { return ringStr.replace(/^\(+/, '').replace(/\)+$/, '') .split(',') .map(pair => { const [lon, lat] = pair.trim().split(/\s+/).map(Number); return [lon, lat]; }); } /** * Parse a WKT POLYGON string into GeoJSON geometry. * Handles: POLYGON((lon lat,...),(hole,...)) * * @param {string} wkt - WKT POLYGON string * @returns {Object} GeoJSON geometry { type: 'Polygon', coordinates: [...] } */ function parseWKTPolygon(wkt) { const inner = wkt.trim() .replace(/^POLYGON\s*\(\s*/i, '') .replace(/\s*\)$/, ''); const ringStrings = inner.split('),('); const rings = ringStrings.map(parseCoordRing); return { type: 'Polygon', coordinates: rings }; } /** * Parse a WKT MULTIPOLYGON string into GeoJSON geometry. * Handles: MULTIPOLYGON(((lon lat,...),(hole),...),((polygon2))) * * @param {string} wkt - WKT MULTIPOLYGON string * @returns {Object} GeoJSON geometry { type: 'MultiPolygon', coordinates: [...] } */ function parseWKTMultiPolygon(wkt) { const inner = wkt.trim() .replace(/^MULTIPOLYGON\s*\(\s*/i, '') .replace(/\s*\)$/, ''); const polygonStrings = inner.split(')),(('); const polygons = polygonStrings.map(polyStr => { const cleaned = polyStr.replace(/^\(+/, '').replace(/\)+$/, ''); const ringStrings = cleaned.split('),('); return ringStrings.map(parseCoordRing); }); return { type: 'MultiPolygon', coordinates: polygons }; } /** * Parse any supported WKT geometry string (POLYGON or MULTIPOLYGON). * @param {string} wkt - WKT geometry string * @returns {Object|null} GeoJSON geometry or null if unsupported */ function parseWKT(wkt) { if (!wkt) return null; const trimmed = wkt.trim().toUpperCase(); if (trimmed.startsWith('MULTIPOLYGON')) return parseWKTMultiPolygon(wkt); if (trimmed.startsWith('POLYGON')) return parseWKTPolygon(wkt); console.warn('[App] Unsupported WKT type:', trimmed.substring(0, 30)); return null; } /** * Convert the API response to a GeoJSON FeatureCollection. * The API returns: { success, data: { boundary: "MULTIPOLYGON(...)", districtid, district_name } } * * @param {Object} apiResponse - Raw API response * @returns {Object|null} GeoJSON FeatureCollection or null */ function apiResponseToGeoJSON(apiResponse) { if (!apiResponse?.success || !apiResponse?.data?.boundary) { console.warn('[App] API response missing success or boundary data'); return null; } const { boundary, districtid, district_name } = apiResponse.data; const geometry = parseWKT(boundary); return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { districtid: districtid, district_name: district_name }, geometry: geometry }] }; } /** * Convert the collector zones API response to a GeoJSON FeatureCollection. * The API returns: { success, data: [{ id, zone_name, boundary, ... }, ...] } * Each zone feature gets a '_layerType' = 'collector_zone' property for identification. * * @param {Array} zones - Array of zone objects from the API * @returns {Object|null} GeoJSON FeatureCollection or null */ function zonesToGeoJSON(zones) { if (!Array.isArray(zones) || zones.length === 0) return null; const features = []; for (const zone of zones) { // API returns WKT in 'polygon' field (not 'boundary') const wkt = zone.polygon || zone.boundary; const geometry = parseWKT(wkt); if (!geometry) continue; // Collect all properties except the raw WKT geometry const properties = { _layerType: 'collector_zone' }; for (const [key, value] of Object.entries(zone)) { if (key === 'polygon' || key === 'boundary') continue; properties[key] = value; } features.push({ type: 'Feature', properties, geometry }); } if (features.length === 0) return null; return { type: 'FeatureCollection', features }; } /** * Load district boundary with local-first strategy: * 1. Always read from local SQLite cache (GeoJSON) first — instant, works offline * 2. If online, fetch from API, convert WKT → GeoJSON, cache and display */ async function loadDistrictBoundary() { const CACHE_KEY = 'district_boundary'; const ADMIN_GROUP_ID = 1; // Administration layer group const boundaryStyle = { strokeColor: '#e11d48', strokeWidth: 2.5, fillColor: 'rgba(225,29,72,0.08)', typeDescription: 'Vector / Polygon', }; // Target group: Administration (id 1), fall back to root overlay group const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null; /** * Remove existing District Boundary layer from a group's layers. */ function removeBoundaryLayer(group) { if (!group) return; const layers = group.getLayers(); const toRemove = []; layers.forEach((layer) => { if (layer.get('title') === 'District Boundary') { toRemove.push(layer); } }); toRemove.forEach((layer) => layers.remove(layer)); } /** * Zoom the map to fit the boundary layer's extent. */ function zoomToBoundary(layer) { if (!layer || !mapView) return; const extent = layer.getSource().getExtent(); if (extent && extent[0] !== Infinity) { mapView.getMap().getView().fit(extent, { padding: [40, 40, 40, 40], duration: 600, }); } } try { // Step 1: Load from local cache (already stored as GeoJSON) const cached = await getRemoteData(CACHE_KEY); if (cached) { console.log('[App] District boundary loaded from local cache'); const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup); zoomToBoundary(layer); } // Step 2: If online and server reachable, fetch fresh data from the API if (isOnline() && isServerReachable()) { console.log('[App] Fetching district boundary from API...'); const apiResponse = await getDistrictBoundary(); // Convert WKT response to GeoJSON const geojson = apiResponseToGeoJSON(apiResponse); if (!geojson) { console.warn('[App] Could not convert API response to GeoJSON'); return; } console.log('[App] District boundary:', geojson.features[0]?.properties?.district_name, '→', geojson.features[0]?.geometry?.coordinates?.length, 'polygon(s)'); // Save converted GeoJSON to local cache for offline use await saveRemoteData(CACHE_KEY, geojson); // Replace old cached layer if present if (cached) { removeBoundaryLayer(adminGroup || mapView?.getOverlayGroup()); } const layer = mapView?.addGeoJSONLayer(geojson, 'District Boundary', boundaryStyle, adminGroup); zoomToBoundary(layer); console.log('[App] District boundary loaded from API'); } else if (!cached) { console.log('[App] District boundary not available — offline and no local cache'); } } catch (error) { console.error('[App] Failed to load district boundary:', error); } } /** * Convert UPN-grid rows (either from the API or the local cache) into a * GeoJSON FeatureCollection. Each feature carries only `_layerType` and * `upn_prefix` as properties so the click popup shows nothing but the * prefix (no fetched_at, no internal IDs). */ function upnGridToGeoJSON(rows) { if (!Array.isArray(rows) || rows.length === 0) return null; const features = []; for (const r of rows) { const wkt = r.polygon || r.geometry_wkt || r.geom; const geometry = parseWKT(wkt); if (!geometry) continue; features.push({ type: 'Feature', properties: { _layerType: 'upn_grid', upn_prefix: r.upn_prefix ?? null }, geometry, }); } if (features.length === 0) return null; return { type: 'FeatureCollection', features }; } /** * Load the UPN-grid (district sub-division) layer into the Administration * group. The grid never changes, so it is fetched once per district and * served from the local cache thereafter; a fetch only happens when no * cells are cached for the current district (e.g. first load, or the user * now belongs to a different district). * * The "UPN Grid" layer is added to the Administration LayerGroup (id 1), * initially not visible — the user toggles it in the LayerSwitcher. */ async function loadUpnGrid() { const ADMIN_GROUP_ID = 1; // Base style passed to addGeoJSONLayer keeps the LayerSwitcher subtitle // ("Vector / Polygon") and a sensible initial render. We override the // visual style below via setStyle() with a zoom-aware function. const upnGridStyle = { strokeColor: '#5b21b6', strokeWidth: 1.5, fillColor: 'rgba(124,58,237,0.04)', typeDescription: 'Vector / Polygon', }; const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null; const emptyGeoJSON = { type: 'FeatureCollection', features: [] }; const upnGridLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'UPN Grid', upnGridStyle, adminGroup); if (!upnGridLayer) { console.warn('[App] Could not create UPN Grid layer'); return; } upnGridLayer.setVisible(false); // Custom style function: // - White casing under a bolder violet dashed line so the grid stays // visible on top of (or under) the sky-blue Parcels layer. // - Label: render the feature's upn_prefix at scales ≥ 1:25,000. // Using the OGC convention scale = resolution / 0.00028, that is // resolution ≤ 7 m/px in Web Mercator. const UPN_LABEL_MAX_RESOLUTION = 7; // ≈ 1:25,000 upnGridLayer.setStyle((feature, resolution) => { const styles = [ // 1) White halo / casing — drawn first, so it sits underneath. new Style({ stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3.5 }), }), // 2) Violet dashed stroke + faint fill. new Style({ stroke: new Stroke({ color: '#5b21b6', width: 1.5, lineDash: [7, 4] }), fill: new Fill({ color: 'rgba(124,58,237,0.04)' }), }), ]; // 3) Label, only when sufficiently zoomed in. if (resolution <= UPN_LABEL_MAX_RESOLUTION) { const prefix = feature.get('upn_prefix'); if (prefix != null && String(prefix).length > 0) { styles.push(new Style({ text: new OlText({ text: String(prefix), font: '600 12px Arial, sans-serif', fill: new Fill({ color: '#3b0764' }), stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3 }), overflow: true, // allow drawing in tight cells }), })); } } return styles; }); upnGridLayer.on('change:visible', () => { if (upnGridLayer.getVisible() && upnGridLayer.getSource().getFeatures().length === 0) { showError('No UPN grid available locally. Connect to the internet to download it.'); } }); function setFeatures(geojson) { const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' }); upnGridLayer.getSource().clear(); upnGridLayer.getSource().addFeatures(newFeatures); } try { const session = getSession(); const districtId = session?.district_id ?? null; // 1) Local cache for the CURRENT district → use it; skip the API call. // The cache is keyed by districtid so a cache from another district // (e.g. dev fallback) won't satisfy this lookup and we'll re-fetch. const cached = await getLocalUpnGrid(districtId); if (cached) { const geojson = upnGridToGeoJSON(cached); if (geojson) setFeatures(geojson); console.log('[App] UPN grid from cache:', cached.length, 'cells (district', districtId, ')'); return; } // 2) Not cached for this district → fetch, save, render. If offline, // leave the layer empty; the toggle handler above will explain. if (!isOnline() || !isServerReachable()) { console.log('[App] UPN grid not available — offline and no cache for district', districtId); return; } console.log('[App] Fetching UPN grid from API (district', districtId, ')...'); const apiResponse = await getUpnGrid(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getUpnGrid invalid response:', apiResponse); return; } const rows = apiResponse.data; console.log('[App] UPN grid from API:', rows.length, 'cells'); await saveUpnGrid(rows, districtId); const geojson = upnGridToGeoJSON(rows); if (geojson) setFeatures(geojson); console.log('[App] UPN grid loaded:', geojson?.features.length ?? 0, 'cells rendered'); } catch (error) { console.error('[App] Failed to load UPN grid:', error); } } /** * Load collector zones with local-first strategy: * 1. Read from local collector_zones table → convert to GeoJSON → display * 2. If online, fetch from API → save to local table → convert → display * * The "Zones" layer is added to the Administration LayerGroup (id 1), * initially not visible. It becomes visible when toggled in the LayerSwitcher. */ async function loadCollectorZones() { const ADMIN_GROUP_ID = 1; const zoneStyle = { strokeColor: '#7c3aed', strokeWidth: 1.5, fillColor: 'rgba(124,58,237,0.12)', typeDescription: 'Vector / Polygon', }; const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null; console.log('[App] loadCollectorZones — adminGroup:', adminGroup ? adminGroup.get('title') : 'null'); // Create the Zones layer immediately (empty) so it always appears // in the LayerSwitcher. Features will be added once data is available. const emptyGeoJSON = { type: 'FeatureCollection', features: [] }; const zonesLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Zones', zoneStyle, adminGroup); if (!zonesLayer) { console.warn('[App] Could not create Zones layer'); return; } zonesLayer.setVisible(false); // Warn when the user enables the layer but it has no data zonesLayer.on('change:visible', () => { if (zonesLayer.getVisible() && zonesLayer.getSource().getFeatures().length === 0) { showError('No collector zones available locally. Connect to the internet to download zone data.'); } }); /** * Replace the layer's source features with features parsed from GeoJSON. */ function setZoneFeatures(geojson) { const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857', }); zonesLayer.getSource().clear(); zonesLayer.getSource().addFeatures(newFeatures); } try { // Step 1: Load from local table const cached = await getLocalCollectorZones(); if (cached) { const geojson = zonesToGeoJSON(cached); if (geojson) { console.log('[App] Collector zones loaded from local cache:', geojson.features.length, 'zones'); setZoneFeatures(geojson); } } // Step 2: If online and server reachable, fetch fresh data from the API if (isOnline() && isServerReachable()) { console.log('[App] Fetching collector zones from API...'); const apiResponse = await getCollectorZones(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getCollectorZones API response invalid:', apiResponse); return; } const zones = apiResponse.data; console.log('[App] Collector zones from API:', zones.length, 'entries'); // Save to local table await saveCollectorZones(zones); // Convert to GeoJSON and update the existing layer const geojson = zonesToGeoJSON(zones); if (!geojson) { console.warn('[App] Could not convert zones to GeoJSON'); return; } setZoneFeatures(geojson); console.log('[App] Collector zones updated from API:', geojson.features.length, 'zones'); } else if (!cached) { console.log('[App] Collector zones not available — offline and no local cache'); } } catch (error) { console.error('[App] Failed to load collector zones:', error); } } /** * Convert parcels data to a GeoJSON FeatureCollection. * Each parcel feature gets a '_layerType' = 'parcel' property for identification. * * @param {Array} parcels - Array of parcel objects from the API * @returns {Object|null} GeoJSON FeatureCollection or null */ function parcelsToGeoJSON(parcels) { if (!Array.isArray(parcels) || parcels.length === 0) return null; // Deduplicate by id — the API may return the same parcel more than once const seen = new Set(); const features = []; for (const parcel of parcels) { const id = parcel.id || parcel.parcelid || parcel.parcel_id; if (id != null) { if (seen.has(id)) continue; seen.add(id); } // Geometry sources, in order: // - API path: `geom` is a GeoJSON object, `boundary` is the WKT string // - local cache: `geometry_wkt` is the WKT string let geometry = null; if (parcel.geom && parcel.geom.type && parcel.geom.coordinates) { geometry = { type: parcel.geom.type, coordinates: parcel.geom.coordinates }; } else if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) { geometry = { type: parcel.sp_boundary.type, coordinates: parcel.sp_boundary.coordinates }; } else { const wkt = parcel.boundary || parcel.geometry_wkt || parcel.polygon || parcel.wkt; geometry = parseWKT(wkt); } if (!geometry) continue; // Collect all properties except bulky geometry fields and local housekeeping. const skipKeys = new Set(['polygon', 'boundary', 'geom', 'geometry_wkt', 'wkt', 'textboundary', 'sp_boundary', 'fetched_at']); const properties = { _layerType: 'parcel' }; for (const [key, value] of Object.entries(parcel)) { if (skipKeys.has(key)) continue; properties[key] = value; } features.push({ type: 'Feature', properties, geometry }); } if (features.length === 0) return null; return { type: 'FeatureCollection', features }; } /** * Load parcels with local-first strategy: * 1. Read from local parcels table → convert to GeoJSON → display * 2. If online, fetch from API → save to local table → convert → display * * The "Parcels" layer is added to the "Land Use and Land Tenure" LayerGroup (id 4), * initially not visible. It becomes visible when toggled in the LayerSwitcher. */ async function loadParcels() { const LAND_USE_GROUP_ID = 4; const parcelStyle = { strokeColor: '#0ea5e9', strokeWidth: 1.5, fillColor: 'rgba(14,165,233,0.12)', typeDescription: 'Vector / Polygon', }; const landUseGroup = mapView?.getLayerGroup(LAND_USE_GROUP_ID) || null; console.log('[App] loadParcels — landUseGroup:', landUseGroup ? landUseGroup.get('title') : 'null'); // Create the Parcels layer immediately (empty) so it always appears // in the LayerSwitcher. Features will be added once data is available. const emptyGeoJSON = { type: 'FeatureCollection', features: [] }; // Assigned to the module-level `parcelsLayer` so the iframe embed bridge // can pick it up after loadParcels() returns from its sync prologue. parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup); if (!parcelsLayer) { console.warn('[App] Could not create Parcels layer'); return; } parcelsLayer.setVisible(false); // Warn when the user enables the layer but it has no data parcelsLayer.on('change:visible', () => { if (parcelsLayer.getVisible() && parcelsLayer.getSource().getFeatures().length === 0) { showError('No parcels available locally. Connect to the internet to download parcel data.'); } }); /** * Replace the layer's source features with features parsed from GeoJSON. */ function setParcelFeatures(geojson) { const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857', }); parcelsLayer.getSource().clear(); parcelsLayer.getSource().addFeatures(newFeatures); } try { // Step 1: Load from local table const cached = await getLocalParcels(); if (cached) { const geojson = parcelsToGeoJSON(cached); if (geojson) { console.log('[App] Parcels loaded from local cache:', geojson.features.length, 'parcels'); setParcelFeatures(geojson); } } // Step 2: If online and server reachable, fetch fresh data from the API if (isOnline() && isServerReachable()) { console.log('[App] Fetching parcels from API...'); const apiResponse = await getDistrictParcels(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getDistrictParcels API response invalid:', apiResponse); return; } const parcels = apiResponse.data; console.log('[App] Parcels from API:', parcels.length, 'entries'); // Log first parcel's keys for debugging field names if (parcels.length > 0) { console.log('[App] First parcel keys:', Object.keys(parcels[0])); } // Save to local table await saveParcels(parcels); // Convert to GeoJSON and update the existing layer const geojson = parcelsToGeoJSON(parcels); if (!geojson) { console.warn('[App] Could not convert parcels to GeoJSON'); return; } setParcelFeatures(geojson); console.log('[App] Parcels updated from API:', geojson.features.length, 'parcels'); } else if (!cached) { console.log('[App] Parcels not available — offline and no local cache'); } } catch (error) { console.error('[App] Failed to load parcels:', error); } } /** * Convert an array of building footprint objects to a GeoJSON FeatureCollection. * Each footprint's WKT geometry field is parsed; all other fields become properties. * * @param {Array} footprints - Array of footprint objects * @returns {Object|null} GeoJSON FeatureCollection, or null if no valid features */ function footprintsToGeoJSON(footprints) { if (!Array.isArray(footprints) || footprints.length === 0) return null; const geomKeys = ['polygon', 'boundary', 'geom', 'wkt', 'footprint']; const features = []; for (const fp of footprints) { const raw = fp.polygon || fp.boundary || fp.geom || fp.wkt || fp.footprint; // Geometry may be WKT string or GeoJSON object let geometry; if (typeof raw === 'object' && raw !== null && raw.type) { // Already a GeoJSON geometry object geometry = raw; } else { geometry = parseWKT(raw); } if (!geometry) continue; const properties = { _layerType: 'building_footprint' }; for (const [key, value] of Object.entries(fp)) { if (geomKeys.includes(key)) continue; // Skip nested objects that aren't useful as flat properties if (typeof value === 'object' && value !== null) continue; properties[key] = value; } features.push({ type: 'Feature', properties, geometry }); } if (features.length === 0) return null; return { type: 'FeatureCollection', features }; } /** * Load building footprints with local-first strategy: * 1. Read from local building_footprints table → convert to GeoJSON → display * 2. If online, fetch from API → save to local table → convert → display * * The "Building footprints" layer is added to the "Physical Infrastructures" LayerGroup (id 5), * initially not visible. It becomes visible when toggled in the LayerSwitcher. */ async function loadBuildingFootprints() { const PHYS_INFRA_GROUP_ID = 5; const footprintStyle = { strokeColor: '#8b6f47', strokeWidth: 1, fillColor: 'rgba(139,111,71,0.18)', typeDescription: 'Vector / Polygon', }; const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null; console.log('[App] loadBuildingFootprints — physInfraGroup:', physInfraGroup ? physInfraGroup.get('title') : 'null'); // Create the layer immediately (empty) so it always appears in the LayerSwitcher. const emptyGeoJSON = { type: 'FeatureCollection', features: [] }; const footprintsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Building footprints', footprintStyle, physInfraGroup); if (!footprintsLayer) { console.warn('[App] Could not create Building footprints layer'); return; } footprintsLayer.setVisible(false); // Warn when the user enables the layer but it has no data footprintsLayer.on('change:visible', () => { if (footprintsLayer.getVisible() && footprintsLayer.getSource().getFeatures().length === 0) { showError('No building footprints available locally. Connect to the internet to download footprint data.'); } }); /** * Replace the layer's source features with features parsed from GeoJSON. */ function setFootprintFeatures(geojson) { const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857', }); footprintsLayer.getSource().clear(); footprintsLayer.getSource().addFeatures(newFeatures); } try { // Step 1: Load from local table const cached = await getLocalBuildingFootprints(); if (cached) { const geojson = footprintsToGeoJSON(cached); if (geojson) { console.log('[App] Building footprints loaded from local cache:', geojson.features.length, 'footprints'); setFootprintFeatures(geojson); } } // Step 2: If online and server reachable, fetch fresh data from the API if (isOnline() && isServerReachable()) { console.log('[App] Fetching building footprints from API...'); const apiResponse = await getBuildingFootprints(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getBuildingFootprints API response invalid:', apiResponse); return; } const footprints = apiResponse.data; console.log('[App] Building footprints from API:', footprints.length, 'entries'); // Log first footprint's keys for debugging field names if (footprints.length > 0) { console.log('[App] First footprint keys:', Object.keys(footprints[0])); } // Save to local table await saveBuildingFootprints(footprints); // Convert to GeoJSON and update the existing layer const geojson = footprintsToGeoJSON(footprints); if (!geojson) { console.warn('[App] Could not convert building footprints to GeoJSON'); return; } setFootprintFeatures(geojson); console.log('[App] Building footprints updated from API:', geojson.features.length, 'footprints'); } else if (!cached) { console.log('[App] Building footprints not available — offline and no local cache'); } } catch (error) { console.error('[App] Failed to load building footprints:', error); } } /** * Convert an array of DB rows (each with a WKT geom field) to GeoJSON. * Uses OpenLayers' WKT parser so LINESTRING, MULTILINESTRING, POLYGON, etc. * are all supported out of the box. * * @param {Array} rows — API rows, each having a WKT-valued geom/geometry/wkt field * @param {string} layerType — value to store in each feature's _layerType property * @returns {Object|null} GeoJSON FeatureCollection, or null if no valid rows */ function wktRowsToGeoJSON(rows, layerType) { if (!Array.isArray(rows) || rows.length === 0) return null; const wktFormat = new WKT(); const geojsonFormat = new GeoJSON(); // Field-name fallbacks — different endpoints alias the geometry column // differently (e.g. get_osm_roads uses `road`, get_contours_hillshade uses // `geom`). The first non-null match wins. const geomKeys = ['geom', 'geometry', 'wkt', 'polygon', 'boundary', 'road', 'line']; const features = []; for (const row of rows) { const raw = row.geom || row.geometry || row.wkt || row.polygon || row.boundary || row.road || row.line; if (!raw) continue; let olGeom; try { if (typeof raw === 'object' && raw !== null && raw.type) { // Already a GeoJSON geometry — just pass through features.push({ type: 'Feature', properties: flattenProps(row, geomKeys, layerType), geometry: raw, }); continue; } olGeom = wktFormat.readGeometry(raw); } catch (err) { console.warn(`[App] Could not parse WKT for ${layerType}:`, err, raw?.toString().slice(0, 60)); continue; } const geometry = JSON.parse(geojsonFormat.writeGeometry(olGeom)); features.push({ type: 'Feature', properties: flattenProps(row, geomKeys, layerType), geometry, }); } if (features.length === 0) return null; return { type: 'FeatureCollection', features }; } /** * Flatten a DB row into properties, skipping geometry fields and nested objects. */ function flattenProps(row, skipKeys, layerType) { const props = { _layerType: layerType }; for (const [key, value] of Object.entries(row)) { if (skipKeys.includes(key)) continue; if (typeof value === 'object' && value !== null) continue; props[key] = value; } return props; } /** * Load the "Contours hillshade" layer — elevation contours derived from * OpenTopography, stored in PostgreSQL as `contours_hillshade`. * * Added to the "Biophysical Environment" LayerGroup, initially not visible. * No local caching (the server is the source of truth). */ async function loadContoursHillshade() { const contoursStyle = { strokeColor: '#78716c', // warm grey — traditional contour colour strokeWidth: 0.8, typeDescription: 'Vector / Line', fillColor: 'rgba(0,0,0,0)', }; const biophysGroup = mapView?.getLayerGroupByTitle('Biophysical Environment'); console.log('[App] loadContoursHillshade — group:', biophysGroup ? biophysGroup.get('title') : 'null'); // Create empty layer first so it always appears in the LayerSwitcher const emptyGeoJSON = { type: 'FeatureCollection', features: [] }; const contoursLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Contours hillshade', contoursStyle, biophysGroup); if (!contoursLayer) { console.warn('[App] Could not create Contours hillshade layer'); return; } contoursLayer.setVisible(false); // Warn when the user enables the layer but it has no data contoursLayer.on('change:visible', () => { if (contoursLayer.getVisible() && contoursLayer.getSource().getFeatures().length === 0) { showError('No Contours hillshade data available. Connect to the internet to download it.'); } }); // Fetch from API (only when online and server reachable — no local cache) if (!isOnline() || !isServerReachable()) { console.log('[App] Contours hillshade not available — offline or server unreachable'); return; } try { console.log('[App] Fetching contours_hillshade from API...'); const apiResponse = await getContoursHillshade(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getContoursHillshade API response invalid:', apiResponse); return; } const rows = apiResponse.data; console.log('[App] Contours hillshade from API:', rows.length, 'rows'); if (rows.length > 0) { console.log('[App] First row keys:', Object.keys(rows[0])); } const geojson = wktRowsToGeoJSON(rows, 'contours_hillshade'); if (!geojson) { console.warn('[App] Could not convert contours to GeoJSON'); return; } const features = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' }); contoursLayer.getSource().clear(); contoursLayer.getSource().addFeatures(features); console.log('[App] Contours hillshade loaded:', features.length, 'features'); } catch (error) { console.error('[App] Failed to load contours_hillshade:', error); } } /** * Load the "OSM_roads" layer — OpenStreetMap road network for the district. * * Added to the "Physical Infrastructures" LayerGroup (id 5), initially not * visible — becomes visible when the user toggles it in the LayerSwitcher. * * Local-first caching: * 1. Read from the local `osm_roads` table → render immediately if available * 2. If online, fetch from the API → overwrite the local table → re-render */ async function loadOSMRoads() { const PHYS_INFRA_GROUP_ID = 5; // Cartographic road casing: a black outer stroke makes the light-coloured // inner stroke (the "road body") readable on every base map. const roadsStyle = { strokeColor: '#F0F1F0', // inner — road body strokeWidth: 1.5, lineCasingColor: '#000000', // outer — black casing lineCasingWidth: 3.5, fillColor: 'rgba(0,0,0,0)', typeDescription: 'Vector / Line', }; const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null; console.log('[App] loadOSMRoads — group:', physInfraGroup ? physInfraGroup.get('title') : 'null'); // Create the layer immediately (empty) so it appears in the LayerSwitcher // even when offline. const emptyGeoJSON = { type: 'FeatureCollection', features: [] }; const roadsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'OSM_roads', roadsStyle, physInfraGroup); if (!roadsLayer) { console.warn('[App] Could not create OSM_roads layer'); return; } roadsLayer.setVisible(false); // Warn only when the layer is enabled AND truly empty AND no source is reachable roadsLayer.on('change:visible', () => { if (roadsLayer.getVisible() && roadsLayer.getSource().getFeatures().length === 0) { showError('No OSM roads available locally. Connect to the internet to download them.'); } }); /** Replace the layer's features with those parsed from the API/cache rows. */ function setRoadFeatures(rows) { const geojson = wktRowsToGeoJSON(rows, 'osm_road'); if (!geojson) { console.warn('[App] Could not convert OSM roads to GeoJSON'); return 0; } const features = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' }); roadsLayer.getSource().clear(); roadsLayer.getSource().addFeatures(features); return features.length; } try { // Step 1 — local cache (works offline, instant) const cached = await getLocalOSMRoads(); if (cached) { const n = setRoadFeatures(cached); console.log('[App] OSM_roads loaded from local cache:', n, 'features'); } // Step 2 — fetch fresh from API when online if (isOnline() && isServerReachable()) { console.log('[App] Fetching OSM_roads from API...'); const apiResponse = await getOSMRoads(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getOSMRoads API response invalid:', apiResponse); return; } const rows = apiResponse.data; console.log('[App] OSM_roads from API:', rows.length, 'rows'); if (rows.length > 0) { console.log('[App] First row keys:', Object.keys(rows[0])); } // Persist to local table so it's available next time offline await saveOSMRoads(rows); const n = setRoadFeatures(rows); console.log('[App] OSM_roads updated from API:', n, 'features'); } else if (!cached) { console.log('[App] OSM_roads not available — offline and no local cache'); } } catch (error) { console.error('[App] Failed to load OSM_roads:', error); } } /** * Add external WMS/XYZ layers to the map. * Called after loadLayers() so the target layer groups already exist. */ function loadExternalWMSLayers() { // DEAfrica Coastlines v0.4.0 — annual shorelines & rates of change // Source: Digital Earth Africa GeoServer // Latest available version as of 2026: v0.4.0 mapView?.addWMSLayer( 'Biophysical Environment', 'DEAfrica Coastlines v0.4', 'https://geoserver.digitalearth.africa/geoserver/wms', 'coastlines:DEAfrica_Coastlines', { serverType: 'geoserver', visible: false, onlineOnly: true } ); // Note: OpenTopoMap is available as the "Topographic" base map — // no separate overlay in "Biophysical Environment" needed. // Digital Earth Africa — SRTM-derived Slope (30m) // Shows terrain steepness as a background overlay — hills and valleys stand // out naturally, reading like a traditional shaded-relief topographic map. // Service: datacube-ows (not GeoServer). // Layer 'srtm_deriv' styles: 'style_slope', 'style_mrvbf' (valley bottoms), // 'style_mrrtf' (ridge tops). mapView?.addWMSLayer( 'Biophysical Environment', 'DEAfrica Slope (SRTM 30m)', 'https://ows.digitalearth.africa/wms', 'srtm_deriv', { serverType: null, style: 'style_slope', visible: false, opacity: 0.5, zIndex: -50, onlineOnly: true, attributions: '© Digital Earth Africa — ' + 'SRTM-derived Slope', legendUrl: 'https://ows.digitalearth.africa/legend/srtm_deriv/style_slope/legend.png', } ); } /** * Load layer categories from the API and create empty VectorLayers on the map. * Uses local-first caching — reads from SQLite first, then refreshes from API when online. * * API response: { success: true, data: [{ id, name, description, createdt, editdt }, ...] } */ async function loadLayers() { const CACHE_KEY = 'layer_categories'; /** * Create layer groups on the map from the layer category list. * @param {Array} layers - Array of { id, name, description, ... } */ function createLayerGroupsOnMap(layers) { // Sort by id descending so that id 1 is pushed last → ends up // at the top of the layer stack and at the top of the LayerSwitcher. const sorted = [...layers].sort((a, b) => b.id - a.id); for (const layer of sorted) { mapView?.addLayerGroup(layer.id, layer.name, layer.description || ''); } console.log('[App] Created', layers.length, 'layer groups on map'); } try { // Step 1: Load from local cache const cached = await getRemoteData(CACHE_KEY); if (cached) { console.log('[App] Layer categories loaded from local cache:', cached.length, 'entries'); createLayerGroupsOnMap(cached); } // Step 2: If online and server reachable, fetch fresh data from the API if (isOnline() && isServerReachable()) { console.log('[App] Fetching layer categories from API...'); const apiResponse = await getLayers(); if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) { console.warn('[App] getLayers API response invalid:', apiResponse); return; } const layers = apiResponse.data; console.log('[App] Layer categories from API:', layers.length, 'entries'); // Save to local cache await saveRemoteData(CACHE_KEY, layers); // Replace layers on map if we already created from cache if (cached) { // Remove previously created empty layers (keep District Boundary) const overlayLayers = mapView?.getOverlayGroup()?.getLayers(); if (overlayLayers) { const toRemove = []; overlayLayers.forEach((layer) => { if (layer.get('layerId') !== undefined) { toRemove.push(layer); } }); toRemove.forEach((layer) => overlayLayers.remove(layer)); } } createLayerGroupsOnMap(layers); console.log('[App] Layer categories refreshed from API'); } else if (!cached) { console.log('[App] Layer categories not available — offline and no local cache'); } } catch (error) { console.error('[App] Failed to load layer categories:', error); } } // ============================================================================ // Sync (placeholder - implement based on your backend) // ============================================================================ async function syncData() { if (!isOnline()) { console.log('[App] Cannot sync - offline'); return; } // TODO: Implement sync with your backend // Example: // const unsynced = await getUnsyncedLocations(); // for (const location of unsynced) { // await fetch('/api/locations', { // method: 'POST', // body: JSON.stringify(location) // }); // await markLocationsSynced([location.id]); // } console.log('[App] Sync placeholder - implement based on your backend'); } // ============================================================================ // File Import (Shapefile, GeoJSON, KML) // ============================================================================ /** All layers added by file imports — shared across formats. */ const importedFileLayers = []; /** Default style for imported layers. */ const IMPORT_STYLE = { strokeColor: '#e11d48', strokeWidth: 2, fillColor: 'rgba(225,29,72,0.12)', }; /** * Show an error message inside the left panel's file-import alert area. */ function showFileImportError(message) { logMessage('error', message); const el = document.getElementById('file-import-alert'); if (el) { el.querySelector('.message-text').textContent = message; el.classList.remove('d-none'); setTimeout(() => el.classList.add('d-none'), 8000); } } /** * Add a GeoJSON FeatureCollection (or array of them) to the map, zoom to * the data, and refresh the imported-layers info card. * * @param {Object|Object[]} geojsonInput - Single FeatureCollection or array * @param {string} fallbackName - Layer name when the FC has no fileName * @param {string} tag - Log prefix, e.g. 'ShpImport' */ function addImportedGeoJSON(geojsonInput, fallbackName, tag) { const collections = Array.isArray(geojsonInput) ? geojsonInput : [geojsonInput]; let totalFeatures = 0; for (const fc of collections) { if (!fc || fc.type !== 'FeatureCollection' || !fc.features?.length) continue; const layerName = fc.fileName ? fc.fileName.replace(/\.[^/.]+$/, '') : fallbackName; const layer = mapView?.addGeoJSONLayer(fc, layerName, IMPORT_STYLE); if (layer) { // Imported file layers are not part of the built-in data model; // the user can remove them via the LayerSwitcher × button. layer.set('removable', true); layer.set('typeTag', 'GEO'); importedFileLayers.push(layer); totalFeatures += fc.features.length; // Stage the import into external_imports + external_import_features // (LUPMIS2_Import_Upload_Design.docx §3 + §4). The mapping modal opens // automatically; the layer gets tagged with its import_id so the // LayerSwitcher chip can find the row. stageImport(fc, layerName, layer).catch((err) => console.warn('[FileImport] Staging failed (layer remains view-only):', err) ); } } if (totalFeatures === 0) { showFileImportError('No features found in the file.'); return; } console.log(`[${tag}] Added ${totalFeatures} feature(s) from ${collections.length} layer(s)`); // Zoom to the last imported layer const lastLayer = importedFileLayers[importedFileLayers.length - 1]; if (lastLayer) { const extent = lastLayer.getSource().getExtent(); mapView?.getMap().getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 18 }); } refreshImportedLayersCard(); } // =========================================================================== // External-dataset import staging (see LUPMIS2_Import_Upload_Design.docx) // =========================================================================== const wktFormat4326 = new WKT(); /** * Convert an OL geometry (Map projection EPSG:3857) to a WKT string in * EPSG:4326 — the format the server expects. */ function geometryToWkt4326(geometry) { return wktFormat4326.writeGeometry(geometry, { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857', }); } /** * Stage an imported FeatureCollection into external_imports + * external_import_features, then open the mapping modal. * * 1. Create the external_imports row (target_type='other', status='other'). * 2. Insert one external_import_features row per feature with the raw * properties (mapping is applied later, when the user confirms). * 3. Tag the OL layer with _externalImportId so the LayerSwitcher chip * can render its status. * 4. Open the mapping modal; on Save the row is updated with the chosen * target type and mapping, the staged-feature properties are remapped, * and (if requested) an upload is kicked off. */ async function stageImport(fc, displayName, layer) { const featureCount = fc?.features?.length ?? 0; if (featureCount === 0) return; // ── 1. Create the staging row + features ──────────────────────────────── const { id: importId } = await createExternalImport({ filename: displayName || 'imported dataset', targetType: 'other', featureCount, }); layer.set('_externalImportId', importId); // Convert each OL feature to (WKT 4326 + raw source properties). const olFeatures = layer.getSource().getFeatures(); const stagedRows = olFeatures.map((f) => { const geom = f.getGeometry(); return { geometry_wkt: geom ? geometryToWkt4326(geom) : '', properties: stripGeometryFromProps(f.getProperties()), }; }); await addExternalImportFeatures(importId, stagedRows); // ── 2. Open the mapping modal ─────────────────────────────────────────── openImportMappingModal({ importId, filename: displayName, fc, onResult: async (result) => { try { await handleImportModalResult(importId, layer, result); } catch (err) { console.error('[FileImport] Failed to apply mapping result:', err); showError('Could not save the import mapping: ' + err.message); } }, }); } /** OL Feature#getProperties() includes the geometry; we don't want it as JSON. */ function stripGeometryFromProps(props) { const out = {}; for (const [k, v] of Object.entries(props || {})) { if (k === 'geometry') continue; out[k] = v; } return out; } /** * Apply the user's choice from the mapping modal: * cancel → keep as 'other' (view-only); no further action. * save → set target_type + mapping, status='mapped', remap staged props. * upload → same as save, then run the upload stub. */ async function handleImportModalResult(importId, layer, result) { if (!result || result.action === 'cancel') { layer?.set('_externalImportStatus', 'other'); refreshLayerSwitcherChip(layer); return; } const { action, targetType, mapping } = result; if (!targetType || targetType === 'other') { await updateExternalImport(importId, { targetType: 'other', mapping: null, status: 'other' }); layer?.set('_externalImportStatus', 'other'); refreshLayerSwitcherChip(layer); return; } // Remap each staged feature's properties to LUPMIS2 column names. The // helper wraps every UPDATE in a single transaction. await remapImportedFeatureProperties(importId, (props) => applyFieldMapping(props, mapping) ); await updateExternalImport(importId, { targetType, mapping, status: 'mapped' }); layer?.set('_externalImportStatus', 'mapped'); layer?.set('_externalImportTargetType', targetType); refreshLayerSwitcherChip(layer); if (action === 'upload') { await runUpload(importId, layer); } } /** * Upload stub. The server endpoints (upload_parcels.php, …) don't exist yet * (LUPMIS2_Import_Upload_Design.docx §5 is the proposal sent to the database * team). Until they're live we mark the row 'uploading' briefly so the chip * can flash a spinner, log the would-be payload for verification, then * revert to 'mapped' with a clear info toast — nothing is lost; the user * can retry once the endpoint exists. */ async function runUpload(importId, layer) { layer?.set('_externalImportStatus', 'uploading'); refreshLayerSwitcherChip(layer); try { await updateExternalImport(importId, { status: 'uploading' }); const imp = await getExternalImport(importId); const features = await getExternalImportFeatures(importId); const session = getSession(); // Build the request body exactly as the server will receive it once // upload_.php is live. district_id + api_token are merged // in by remotePost from API_CREDENTIALS; user_id_upload comes from the // SSO session (server may also derive it server-side — we send it for // logging/audit completeness as agreed with the database team). const body = { user_id_upload: session?.user_id ?? null, import: { client_import_id: imp.client_import_id, filename: imp.filename, feature_count: features.length, }, features: features.map((f) => ({ client_uuid: f.client_uuid, geom: f.geometry_wkt, props: f.properties, })), }; // ── TODO when the database team ships upload_.php ──────── // const apiResponse = await remotePost(`upload_${imp.target_type}.php`, body); // then walk apiResponse.results, update each feature's upload_status, // and flip the import row to 'submitted' (or 'failed' if any rows // failed). For now we just log the payload and roll back. console.log('[Upload]', { endpoint: `upload_${imp.target_type}.php (not yet available on the server)`, target_type: imp.target_type, body, }); await updateExternalImport(importId, { status: 'mapped', lastUploadedAt: new Date().toISOString(), }); layer?.set('_externalImportStatus', 'mapped'); refreshLayerSwitcherChip(layer); showWarning( 'The server upload endpoint is not yet available. ' + 'The data stays staged locally — you can upload again later.' ); } catch (err) { console.error('[Upload] Stub failed:', err); layer?.set('_externalImportStatus', 'mapped'); refreshLayerSwitcherChip(layer); showError('Upload preparation failed: ' + err.message); } } // Export GIS button on the Area / Circle Analysis popup — MapView dispatches // this CustomEvent so main.js can own the format/rename modal + writers. window.addEventListener('lupmis:export-gis', (e) => { openExportGisModal(e.detail || {}); }); // LayerSwitcher chip click — dispatched as a window CustomEvent by MapView's // _decorateLayerListItem. We only act on the 'mapped' state today (upload); // 'failed' will open an error-review modal once the server endpoints exist. window.addEventListener('lupmis:import-chip-click', (e) => { const { importId, status, layer } = e.detail || {}; if (status === 'mapped') { runUpload(importId, layer).catch((err) => console.error('[FileImport] runUpload failed:', err) ); } }); /** * Force the LayerSwitcher row for this layer to re-render. ol-ext rebuilds * the panel on every render() call; the drawlist hook will read the new * _externalImportStatus property and render the appropriate chip. */ function refreshLayerSwitcherChip(layer) { if (!layer || !mapView) return; const switcher = mapView.getMap() ?.getControls() ?.getArray() ?.find((c) => c?.constructor?.name === 'LayerSwitcher' || c?.element?.classList?.contains('ol-layerswitcher')); if (switcher && typeof switcher.drawPanel === 'function') { switcher.drawPanel(); } } /** * Rebuild the imported-layers info card in the left panel. */ function refreshImportedLayersCard() { const infoEl = document.getElementById('imported-layers-info'); if (!infoEl) return; if (importedFileLayers.length === 0) { infoEl.innerHTML = ''; infoEl.classList.add('d-none'); return; } infoEl.innerHTML = `
Imported Layers
    `; const listEl = infoEl.querySelector('#imported-layers-list'); importedFileLayers.forEach((l, idx) => { const li = document.createElement('li'); li.className = 'list-group-item d-flex justify-content-between align-items-center py-2'; li.innerHTML = `${escapeHtml(l.get('title'))} ${l.getSource().getFeatures().length} `; listEl.appendChild(li); }); infoEl.classList.remove('d-none'); // Per-layer remove buttons infoEl.querySelectorAll('[data-remove-idx]').forEach(btn => { btn.addEventListener('click', () => { removeImportedLayer(Number(btn.dataset.removeIdx)); }); }); // Remove-all button infoEl.querySelector('#remove-imported-layers')?.addEventListener('click', () => { removeImportedLayers(); }); } /** * Remove a single imported layer by its index in importedFileLayers. */ function removeImportedLayer(idx) { if (idx < 0 || idx >= importedFileLayers.length) return; const layer = importedFileLayers[idx]; const overlayGroup = mapView?.getOverlayGroup(); if (overlayGroup) { overlayGroup.getLayers().remove(layer); } importedFileLayers.splice(idx, 1); refreshImportedLayersCard(); console.log('[FileImport] Removed layer:', layer.get('title')); } /** * Remove all imported layers from the map and clear the info card. */ function removeImportedLayers() { const overlayGroup = mapView?.getOverlayGroup(); if (overlayGroup) { for (const layer of importedFileLayers) { overlayGroup.getLayers().remove(layer); } } importedFileLayers.length = 0; refreshImportedLayersCard(); console.log('[FileImport] All imported layers removed'); } // --------------------------------------------------------------------------- // Shapefile (.shp / .zip) // --------------------------------------------------------------------------- /** * Build a lookup of selected files keyed by lowercase extension. */ function indexFilesByExtension(files) { const map = {}; for (const f of files) { const ext = f.name.split('.').pop().toLowerCase(); map[ext] = f; } return map; } async function handleShapefileImport(evt) { const files = evt.target.files; if (!files || files.length === 0) return; const MAX_FILE_SIZE = 200 * 1024 * 1024; const totalSize = Array.from(files).reduce((s, f) => s + f.size, 0); if (totalSize > MAX_FILE_SIZE) { const sizeMB = (totalSize / (1024 * 1024)).toFixed(0); showFileImportError( `Files too large (${sizeMB} MB total). Maximum supported size is 200 MB.` ); evt.target.value = ''; return; } try { let geojson; let displayName; const byExt = indexFilesByExtension(files); if (byExt.zip) { const file = byExt.zip; displayName = file.name.replace(/\.zip$/i, ''); console.log('[ShpImport] Parsing zip', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)'); const shp = await getShp(); geojson = await shp(await file.arrayBuffer()); } else if (byExt.shp) { displayName = byExt.shp.name.replace(/\.shp$/i, ''); const required = ['dbf', 'shx', 'prj']; const missing = required.filter(ext => !byExt[ext]); if (missing.length > 0) { showFileImportError('Missing required file(s): ' + missing.map(e => '.' + e).join(', ') + '. Please select .shp, .dbf, .shx and .prj together.'); evt.target.value = ''; return; } const shpObj = {}; shpObj.shp = await byExt.shp.arrayBuffer(); shpObj.dbf = await byExt.dbf.arrayBuffer(); shpObj.prj = await new Response(byExt.prj).text(); if (byExt.cpg) shpObj.cpg = await new Response(byExt.cpg).text(); console.log('[ShpImport] Parsing loose files:', Object.keys(byExt).map(e => '.' + e).join(', '), '(' + (byExt.shp.size / 1024).toFixed(1) + ' KB .shp)'); const shp = await getShp(); geojson = await shp(shpObj); } else { showFileImportError('Please select a .zip or at least a .shp file.'); evt.target.value = ''; return; } addImportedGeoJSON(geojson, displayName, 'ShpImport'); } catch (error) { console.error('[ShpImport] Failed:', error); showFileImportError('Failed to parse shapefile: ' + error.message); } evt.target.value = ''; } // --------------------------------------------------------------------------- // GeoJSON (.geojson / .json) // --------------------------------------------------------------------------- async function handleGeoJSONImport(evt) { const file = evt.target.files?.[0]; if (!file) return; // Guard: reject files larger than 200 MB — JSON.parse cannot reliably // handle them in a single pass and the browser will freeze or crash. const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200 MB if (file.size > MAX_FILE_SIZE) { const sizeMB = (file.size / (1024 * 1024)).toFixed(0); showFileImportError( `File too large (${sizeMB} MB). Maximum supported size is 200 MB. ` + 'Consider splitting the file into smaller tiles with ogr2ogr or QGIS.' ); evt.target.value = ''; return; } try { const text = await file.text(); console.log('[GeoJSONImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)'); const parsed = JSON.parse(text); // Normalise to a FeatureCollection let fc; if (parsed.type === 'FeatureCollection') { fc = parsed; } else if (parsed.type === 'Feature') { fc = { type: 'FeatureCollection', features: [parsed] }; } else if (parsed.type && parsed.coordinates) { // Bare geometry object fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: parsed, properties: {} }] }; } else { showFileImportError('The file does not contain valid GeoJSON.'); evt.target.value = ''; return; } const displayName = file.name.replace(/\.(geo)?json$/i, ''); addImportedGeoJSON(fc, displayName, 'GeoJSONImport'); } catch (error) { console.error('[GeoJSONImport] Failed:', error); const sizeMB = (file.size / (1024 * 1024)).toFixed(1); showFileImportError( `Failed to import "${file.name}" (${sizeMB} MB): ${error.message}` ); } evt.target.value = ''; } // --------------------------------------------------------------------------- // KML (.kml) // --------------------------------------------------------------------------- async function handleKMLImport(evt) { const file = evt.target.files?.[0]; if (!file) return; const MAX_FILE_SIZE = 200 * 1024 * 1024; if (file.size > MAX_FILE_SIZE) { const sizeMB = (file.size / (1024 * 1024)).toFixed(0); showFileImportError( `File too large (${sizeMB} MB). Maximum supported size is 200 MB.` ); evt.target.value = ''; return; } try { const text = await file.text(); console.log('[KMLImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)'); const kmlFormat = new KML({ extractStyles: false }); const features = kmlFormat.readFeatures(text, { featureProjection: 'EPSG:3857', }); if (!features || features.length === 0) { showFileImportError('No features found in the KML file.'); evt.target.value = ''; return; } // Convert OL features back to GeoJSON so we can use the shared pipeline const geojsonFormat = new GeoJSON(); const fc = JSON.parse(geojsonFormat.writeFeatures(features, { featureProjection: 'EPSG:3857', dataProjection: 'EPSG:4326', })); const displayName = file.name.replace(/\.kml$/i, ''); addImportedGeoJSON(fc, displayName, 'KMLImport'); } catch (error) { console.error('[KMLImport] Failed:', error); const sizeMB = (file.size / (1024 * 1024)).toFixed(1); showFileImportError( `Failed to import "${file.name}" (${sizeMB} MB): ${error.message}` ); } evt.target.value = ''; } // --------------------------------------------------------------------------- // Drag-and-drop on the map // --------------------------------------------------------------------------- /** * Set up the map container as a drop zone for .shp/.zip, .geojson/.json, .kml * files. Dragging files over the map shows a visual overlay; dropping them * routes to the correct import handler. */ function initMapDropZone() { const container = document.querySelector('.map-container'); if (!container) return; let dragCounter = 0; // track nested enter/leave events container.addEventListener('dragenter', (e) => { e.preventDefault(); dragCounter++; container.classList.add('drag-over'); }); container.addEventListener('dragover', (e) => { e.preventDefault(); // required to allow drop }); container.addEventListener('dragleave', (e) => { e.preventDefault(); dragCounter--; if (dragCounter <= 0) { dragCounter = 0; container.classList.remove('drag-over'); } }); container.addEventListener('drop', (e) => { e.preventDefault(); dragCounter = 0; container.classList.remove('drag-over'); const files = e.dataTransfer?.files; if (!files || files.length === 0) return; // Build extension lookup to decide which handler to use const byExt = indexFilesByExtension(files); const exts = Object.keys(byExt); if (byExt.zip || byExt.shp) { // Shapefile import (zip or loose .shp + companions) const fakeEvt = { target: { files, value: '' } }; Object.defineProperty(fakeEvt.target, 'value', { writable: true }); handleShapefileImport(fakeEvt); } else if (byExt.geojson || byExt.json) { const file = byExt.geojson || byExt.json; const fakeEvt = { target: { files: [file], value: '' } }; Object.defineProperty(fakeEvt.target, 'value', { writable: true }); handleGeoJSONImport(fakeEvt); } else if (byExt.kml) { const fakeEvt = { target: { files: [byExt.kml], value: '' } }; Object.defineProperty(fakeEvt.target, 'value', { writable: true }); handleKMLImport(fakeEvt); } else { showFileImportError( 'Unsupported file type(s): ' + exts.map(e => '.' + e).join(', ') + '. Drop .zip, .shp, .geojson, .json, or .kml files.' ); } }); console.log('[FileImport] Map drop zone initialised'); } // ============================================================================ // Utilities // ============================================================================ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ============================================================================ // Message Log — persistent stack in the right panel // ============================================================================ const MESSAGE_LOG_MAX = 50; const MSG_CONFIG = { error: { icon: 'bi-x-circle-fill', color: 'var(--destructive, #dc3545)' }, warning: { icon: 'bi-exclamation-triangle-fill', color: 'var(--warning, #ffc107)' }, success: { icon: 'bi-check-circle-fill', color: 'var(--success, #198754)' }, info: { icon: 'bi-info-circle-fill', color: 'var(--primary, #0d6efd)' }, }; /** * Append a message to the persistent log in the right panel. * Also logs to the browser console. * * @param {'error'|'warning'|'success'|'info'} type * @param {string} text */ function logMessage(type, text) { const cfg = MSG_CONFIG[type] || MSG_CONFIG.info; // Console mirror const consoleFn = type === 'error' ? console.error : type === 'warning' ? console.warn : console.log; consoleFn('[App]', text); const log = document.getElementById('message-log'); if (!log) return; // Remove the "No messages yet" placeholder if present const placeholder = log.querySelector('.text-muted'); if (placeholder) placeholder.remove(); // Build the entry const entry = document.createElement('div'); entry.className = 'list-group-item message-log-entry py-2 px-3'; const now = new Date(); const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); entry.innerHTML = `
    ` + `` + `
    ${escapeHtml(text)}
    ` + `${time}` + `
    `; // Prepend (newest first) log.prepend(entry); // Cap the list while (log.children.length > MESSAGE_LOG_MAX) { log.lastElementChild.remove(); } } /** Wire up the "clear" button */ function initMessageLog() { const btn = document.getElementById('clear-message-log'); if (btn) { btn.addEventListener('click', () => { const log = document.getElementById('message-log'); if (log) { log.innerHTML = '
    No messages yet.
    '; } }); } } // ============================================================================ // GPS live-position + trail recording // // Wiring only — all GPS logic lives in the reusable src/geotracker/ engine and // the LUPMIS adapter in src/geotracker-lupmis.js. Here we connect the engine's // events to the navbar readout and the map's render/control hooks. // ============================================================================ function initGpsTracking() { const readout = document.getElementById('gps-readout'); const coordsEl = document.getElementById('gps-coords'); const accEl = document.getElementById('gps-accuracy'); const satsEl = document.getElementById('gps-sats'); if (!geoTracker.isSupported) { if (coordsEl) coordsEl.textContent = 'No GPS'; return; } // Live navbar readout — fires for every fix (one-shot Locate or watch). geoTracker.on('position', (fix) => { if (coordsEl) coordsEl.textContent = `${formatCoord(fix.lat)}, ${formatCoord(fix.lon)}`; if (accEl) accEl.textContent = formatAccuracy(fix.accuracy); if (satsEl) satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`; if (readout) { readout.classList.add('active'); readout.classList.remove('quality-good', 'quality-fair', 'quality-poor'); readout.classList.add('quality-' + accuracyQuality(fix.accuracy)); } mapView?.showCurrentPosition(fix.lon, fix.lat, fix.accuracy); }); // Each recorded waypoint extends the on-map trail line. geoTracker.on('point', (evt) => { mapView?.appendTrailPoint(evt.point.lon, evt.point.lat); }); geoTracker.on('error', (err) => { console.warn('[GPS]', err?.message || err); if (err && err.code === 1) { // PERMISSION_DENIED showError('Location permission denied. Enable location access to use GPS.'); } }); // "Locate me" → one-shot position + recenter. mapView.onLocateMe(async () => { try { const fix = await geoTracker.getCurrentPosition(); mapView.centerOn(fix.lon, fix.lat, 16); } catch (err) { showError('Could not get your location: ' + (err?.message || err)); } }); // "Record trail" → start/stop. Recording persists locally and syncs on stop. mapView.onToggleRecording(async (start) => { if (start) { try { await dbReady; mapView.startTrailRender(); mapView.setRecordingState(true); readout?.classList.add('recording'); await geoTracker.startRecording({ name: `Trail ${new Date().toLocaleString()}` }); showSuccess('GPS trail recording started'); } catch (err) { mapView.setRecordingState(false); readout?.classList.remove('recording'); showError('Could not start recording: ' + (err?.message || err)); } } else { try { const res = await geoTracker.stopRecording(); mapView.setRecordingState(false); readout?.classList.remove('recording'); if (res) { const msg = `Trail saved: ${res.pointCount} points, ${formatDistance(res.distanceM)}` + (res.synced ? ' — synced' : ' — will sync when online'); showSuccess(msg); } } catch (err) { showError('Error stopping recording: ' + (err?.message || err)); } } }); // Retry uploading trails recorded while offline — on load and when back online. const trySync = async () => { if (!isOnline()) return; try { await dbReady; const r = await geoTracker.syncPending(); if (r.pushed) console.log(`[GPS] Synced ${r.pushed} pending trail(s)`); } catch (e) { console.warn('[GPS] pending-sync error', e); } }; trySync(); onOfflineChange((offline) => { if (!offline) trySync(); }); } // ============================================================================ // Toast-style alerts (auto-dismiss) + persistent log // ============================================================================ function showError(message) { logMessage('error', message); const el = document.getElementById('error-message'); if (el) { el.querySelector('.message-text').textContent = message; el.classList.remove('d-none'); setTimeout(() => el.classList.add('d-none'), 5000); } } function showSuccess(message) { logMessage('success', message); const el = document.getElementById('success-message'); if (el) { el.querySelector('.message-text').textContent = message; el.classList.remove('d-none'); setTimeout(() => el.classList.add('d-none'), 3000); } } function showWarning(message) { logMessage('warning', message); const el = document.getElementById('warning-message'); if (el) { el.querySelector('.message-text').textContent = message; el.classList.remove('d-none'); setTimeout(() => el.classList.add('d-none'), 5000); } } // ============================================================================ // Fieldwork Mode // ============================================================================ function initFieldworkMode() { const toggle = document.getElementById('fieldwork-mode-toggle'); if (!toggle) return; // Restore saved preference const saved = localStorage.getItem('fieldwork-mode'); if (saved === 'true') { document.documentElement.classList.add('fieldwork-mode'); toggle.checked = true; } toggle.addEventListener('change', () => { document.documentElement.classList.toggle('fieldwork-mode', toggle.checked); localStorage.setItem('fieldwork-mode', toggle.checked); console.log('[Settings] Fieldwork mode', toggle.checked ? 'ON' : 'OFF'); }); } // ============================================================================ // Dark Mode // ============================================================================ function initDarkMode() { const toggle = document.getElementById('dark-mode-toggle'); if (!toggle) return; function applyDark(on) { document.documentElement.classList.toggle('dark-mode', on); // Bootstrap 5.3 built-in dark mode support document.documentElement.setAttribute('data-bs-theme', on ? 'dark' : 'light'); } // Restore saved preference const saved = localStorage.getItem('dark-mode'); if (saved === 'true') { toggle.checked = true; applyDark(true); } toggle.addEventListener('change', () => { applyDark(toggle.checked); localStorage.setItem('dark-mode', toggle.checked); console.log('[Settings] Dark mode', toggle.checked ? 'ON' : 'OFF'); }); } // ============================================================================ // Measurement System // ============================================================================ function initMeasurementSystem() { const toggle = document.getElementById('measurement-system-toggle'); const label = document.getElementById('measurement-system-label'); if (!toggle) return; function updateLabel() { if (label) label.textContent = toggle.checked ? 'Imperial' : 'Metric'; } // Restore saved preference const saved = localStorage.getItem('measurement-system'); if (saved === 'imperial') { toggle.checked = true; } updateLabel(); // Apply saved setting to the scale bar on load mapView?.setScaleBarUnits(saved || 'metric'); toggle.addEventListener('change', () => { const system = toggle.checked ? 'imperial' : 'metric'; localStorage.setItem('measurement-system', system); updateLabel(); mapView?.setScaleBarUnits(system); console.log('[Settings] Measurement system:', system); }); } /** * Default base map selector — persisted in localStorage. * Keys must match those handled by MapView.setBaseMap(). */ function initDefaultBasemap() { const select = document.getElementById('default-basemap-select'); if (!select) return; // Restore saved preference (default: topo) const saved = localStorage.getItem('default-basemap') || 'topo'; select.value = saved; select.addEventListener('change', () => { const key = select.value; localStorage.setItem('default-basemap', key); mapView?.setBaseMap(key); console.log('[Settings] Default base map:', key); }); // Keep the dropdown in sync when the user switches via the floating // base-map picker (or any other UI) — MapView fires `basemapchange` // from setBaseMap(). mapView?.getMap()?.on('basemapchange', (evt) => { if (evt?.key && select.value !== evt.key) { select.value = evt.key; try { localStorage.setItem('default-basemap', evt.key); } catch {} } }); } /** * Offline Map Tiles card — shows per-provider cache stats and offers a * "Clear cached tiles" button. Stats refresh whenever the Settings panel * is opened so the numbers are always current. */ function initOfflineTileCache() { const statsEl = document.getElementById('tile-cache-stats'); const clearBtn = document.getElementById('clear-tiles-btn'); const offcanvas = document.getElementById('offcanvasBottom'); if (!statsEl || !clearBtn || !offcanvas) return; /** Format a byte count into a human-friendly string. */ function fmtBytes(bytes) { if (!bytes) return '0 KB'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } // Track in-flight refresh so rapid calls don't overlap and to allow a // controllerchange handler to know when a refresh is already underway. let refreshInFlight = null; /** Render the stats panel. */ async function refresh() { if (refreshInFlight) return refreshInFlight; // If the SW hasn't taken control yet, give the user a friendly hint // instead of immediately failing. The wait inside getTileCacheStats() // will resolve once the SW becomes available, at which point this // refresh completes normally — no reload needed. const swActive = !!navigator.serviceWorker?.controller; statsEl.innerHTML = swActive ? '
    Loading…
    ' : '
    Initialising service worker…
    '; refreshInFlight = (async () => { try { const stats = await getTileCacheStats(); if (!stats) { statsEl.innerHTML = `
    Tile cache stats unavailable. Try reloading the page if this persists.
    `; return; } const total = stats.totals; const rows = stats.byProvider .filter((p) => p.count > 0) .map((p) => ` ${escapeHtml(p.label)} ${p.count.toLocaleString()} / ${p.limit.toLocaleString()} ${fmtBytes(p.estBytes)} `).join(''); let storageNote = ''; const est = await getStorageEstimate(); if (est && est.quota > 0) { const pct = ((est.usage / est.quota) * 100).toFixed(1); storageNote = `
    Total app storage: ${fmtBytes(est.usage)} of ${fmtBytes(est.quota)} available (${pct}%)
    `; } if (total.count === 0) { statsEl.innerHTML = `
    No tiles cached yet. Pan and zoom the map to start caching tiles automatically.
    ${storageNote}`; clearBtn.disabled = true; return; } statsEl.innerHTML = `
    ${total.count.toLocaleString()} tiles cached, ~${fmtBytes(total.estBytes)} on this device
    ${rows}
    Base map Cached / limit Approx. size
    ${storageNote}`; clearBtn.disabled = false; // Per-provider Clear — confirm, clear that bucket only, refresh statsEl.querySelectorAll('.provider-clear-btn').forEach((btn) => { btn.addEventListener('click', async (e) => { e.preventDefault(); const cacheName = btn.dataset.cache; const label = btn.dataset.label || cacheName; if (!confirm(`Clear cached "${label}" tiles?\n\nOther providers are not affected. The tiles will re-download as you browse online.`)) { return; } btn.disabled = true; const ok = await clearTileCacheForProvider(cacheName); if (ok) { console.log(`[Settings] Cleared tile cache for ${label}`); } else { console.warn(`[Settings] Could not clear tile cache for ${label}`); } await refresh(); }); }); } finally { refreshInFlight = null; } })(); return refreshInFlight; } // Clear button — confirm, then clear, then refresh clearBtn.addEventListener('click', async () => { if (!confirm('Clear all cached map tiles from this device? You will need to be online to view them again.')) { return; } clearBtn.disabled = true; const ok = await clearTileCaches(); if (ok) { console.log('[Settings] Tile caches cleared'); } else { console.warn('[Settings] Tile-cache clear failed'); } await refresh(); }); // Refresh stats whenever the Settings offcanvas opens offcanvas.addEventListener('show.bs.offcanvas', refresh); // Auto-refresh when a (new) service worker takes control of the page — // makes the panel populate as soon as the SW is available, even if the // user is staring at it during initial install or during an SW update. onServiceWorkerControllerChange(() => { console.log('[Settings] SW controller changed → refreshing tile-cache stats'); refresh(); }); // Also do an initial render so the card isn't empty if Settings is open // immediately on load. refresh(); } /** * Offline-download dialog (Phase 2). Allows users to pre-fetch tiles for a * chosen extent and zoom range so they can use the map without connectivity. */ function initOfflineDownloadDialog() { const triggerBtn = document.getElementById('download-tiles-btn'); const modalEl = document.getElementById('offline-download-modal'); if (!triggerBtn || !modalEl) return; const modal = Modal.getOrCreateInstance(modalEl); // ----- Element refs ----- const formView = document.getElementById('offline-download-form-view'); const progressView = document.getElementById('offline-download-progress-view'); const doneView = document.getElementById('offline-download-done-view'); const cancelBtn = document.getElementById('offline-download-cancel-btn'); const startBtn = document.getElementById('offline-download-start-btn'); const closeDoneBtn = document.getElementById('offline-download-close-done-btn'); const headerCloseBtn = document.getElementById('offline-download-close-btn'); const basemapSelect = document.getElementById('offline-basemap-select'); const minZoomInput = document.getElementById('offline-min-zoom'); const maxZoomInput = document.getElementById('offline-max-zoom'); const ackCheck = document.getElementById('offline-ack-check'); const estimateEl = document.getElementById('offline-estimate-detail'); const estimateBox = document.getElementById('offline-estimate'); const areaViewRadio = document.getElementById('offline-area-view'); const areaDistrictRadio = document.getElementById('offline-area-district'); const areaGhanaRadio = document.getElementById('offline-area-ghana'); const areaViewInfo = document.getElementById('offline-area-view-info'); const areaDistrictInfo = document.getElementById('offline-area-district-info'); const progressBar = document.getElementById('offline-progress-bar'); const progressPercent = document.getElementById('offline-progress-percent'); const progressCounts = document.getElementById('offline-progress-counts'); const progressOk = document.getElementById('offline-progress-ok'); const progressFailed = document.getElementById('offline-progress-failed'); const progressEta = document.getElementById('offline-progress-eta'); const doneTitle = document.getElementById('offline-done-title'); const doneDetail = document.getElementById('offline-done-detail'); // ----- State ----- let currentDownloader = null; /** Format byte count for display. */ function fmtBytes(b) { if (!b) return '0 KB'; if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'; if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB'; return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } /** Format ms → human-readable duration. */ function fmtDuration(ms) { if (!ms || ms < 1000) return '< 1 s'; const s = Math.round(ms / 1000); if (s < 60) return s + ' s'; const m = Math.floor(s / 60); const r = s % 60; if (m < 60) return `${m} min ${r} s`; const h = Math.floor(m / 60); return `${h} h ${m % 60} min`; } /** Get the chosen extent based on the radio selection. Returns null if invalid. */ function getSelectedExtent() { if (areaViewRadio.checked) { return mapView?.getCurrentViewExtent() || null; } if (areaDistrictRadio.checked) { return mapView?.getDistrictBoundaryExtent()?.extent || null; } if (areaGhanaRadio.checked) { return GHANA_EXTENT_3857; } return null; } /** Recalculate and update the live estimate display. */ function updateEstimate() { const baseMap = basemapSelect.value; const minZ = parseInt(minZoomInput.value, 10); const maxZ = parseInt(maxZoomInput.value, 10); if (Number.isNaN(minZ) || Number.isNaN(maxZ) || minZ > maxZ) { estimateEl.textContent = 'Invalid zoom range'; estimateBox.classList.replace('alert-info', 'alert-warning'); startBtn.disabled = true; return; } const extent = getSelectedExtent(); if (!extent) { estimateEl.textContent = 'Selected area is not available.'; estimateBox.classList.replace('alert-info', 'alert-warning'); startBtn.disabled = true; return; } const tplMaxZoom = BASEMAP_TEMPLATES[baseMap]?.maxZoom ?? 19; const effMaxZ = Math.min(maxZ, tplMaxZoom); const count = countTiles(extent, minZ, effMaxZ); const bytes = estimatedSizeBytes(count); let warningHTML = ''; if (effMaxZ < maxZ) { warningHTML = `
    Zoom ${maxZ} is above this provider's max (${tplMaxZoom}); will clamp to ${tplMaxZoom}.`; } if (count > 8000) { warningHTML += `
    More than 8 000 tiles — exceeds the per-provider cache limit. Earlier tiles will be evicted as new ones arrive.`; } estimateEl.innerHTML = `${count.toLocaleString()} tiles · ` + `~${fmtBytes(bytes)}` + warningHTML; estimateBox.classList.toggle('alert-warning', !!warningHTML); estimateBox.classList.toggle('alert-info', !warningHTML); startBtn.disabled = !ackCheck.checked || count === 0; } /** Update the area-radio info labels (tile count + size estimate). */ function updateAreaInfos() { const view = mapView?.getCurrentViewExtent(); if (view) { areaViewInfo.textContent = ' · ready'; } else { areaViewInfo.textContent = ''; } const dist = mapView?.getDistrictBoundaryExtent(); if (dist) { areaDistrictInfo.textContent = ''; areaDistrictRadio.disabled = false; } else { areaDistrictInfo.textContent = ' (not loaded — connect online to fetch)'; areaDistrictRadio.disabled = true; if (areaDistrictRadio.checked) areaViewRadio.checked = true; } } /** Reset the modal to its initial form state. */ function resetModal() { formView.classList.remove('d-none'); progressView.classList.add('d-none'); doneView.classList.add('d-none'); startBtn.classList.remove('d-none'); cancelBtn.classList.remove('d-none'); cancelBtn.textContent = 'Cancel'; closeDoneBtn.classList.add('d-none'); headerCloseBtn.disabled = false; ackCheck.checked = false; startBtn.disabled = true; currentDownloader = null; } // ----- Event wiring ----- triggerBtn.addEventListener('click', () => { resetModal(); updateAreaInfos(); updateEstimate(); modal.show(); }); // Recalculate estimate on any input change basemapSelect.addEventListener('change', updateEstimate); minZoomInput.addEventListener('input', updateEstimate); maxZoomInput.addEventListener('input', updateEstimate); areaViewRadio.addEventListener('change', updateEstimate); areaDistrictRadio.addEventListener('change', updateEstimate); areaGhanaRadio.addEventListener('change', updateEstimate); ackCheck.addEventListener('change', updateEstimate); // Start the download startBtn.addEventListener('click', async () => { const baseMap = basemapSelect.value; const minZ = parseInt(minZoomInput.value, 10); const maxZ = parseInt(maxZoomInput.value, 10); const extent = getSelectedExtent(); if (!extent) return; // Switch UI to progress view formView.classList.add('d-none'); progressView.classList.remove('d-none'); startBtn.classList.add('d-none'); cancelBtn.textContent = 'Cancel download'; headerCloseBtn.disabled = true; progressBar.style.width = '0%'; progressBar.setAttribute('aria-valuenow', '0'); progressPercent.textContent = '0%'; progressCounts.textContent = '0 of 0 tiles'; progressOk.textContent = '0'; progressFailed.textContent = '0'; progressEta.textContent = '—'; currentDownloader = new OfflineTileDownloader({ baseMap, extent3857: extent, minZoom: minZ, maxZoom: maxZ, onProgress: (s) => { if (s.total > 0) { const pct = Math.min(100, Math.round((s.done / s.total) * 100)); progressBar.style.width = pct + '%'; progressBar.setAttribute('aria-valuenow', String(pct)); progressPercent.textContent = pct + '%'; progressCounts.textContent = `${s.done.toLocaleString()} of ${s.total.toLocaleString()} tiles`; } progressOk.textContent = s.ok.toLocaleString(); progressFailed.textContent = s.failed.toLocaleString(); progressEta.textContent = s.etaMs != null ? fmtDuration(s.etaMs) : '—'; }, }); let result; try { result = await currentDownloader.start(); } catch (err) { console.error('[OfflineDownload] failed:', err); result = { phase: 'error', done: 0, total: 0, ok: 0, failed: 0 }; } // Switch UI to done view progressView.classList.add('d-none'); doneView.classList.remove('d-none'); cancelBtn.classList.add('d-none'); closeDoneBtn.classList.remove('d-none'); headerCloseBtn.disabled = false; if (result.phase === 'cancelled') { doneTitle.textContent = 'Download cancelled'; doneDetail.innerHTML = `Stopped after ${result.done.toLocaleString()} of ${result.total.toLocaleString()} tiles.
    ` + `${result.ok.toLocaleString()} fetched · ${result.failed.toLocaleString()} failed.`; } else if (result.phase === 'error') { doneTitle.textContent = 'Download failed'; doneDetail.textContent = 'See console for details.'; } else { doneTitle.textContent = 'Download complete'; doneDetail.innerHTML = `${result.ok.toLocaleString()} tiles cached` + (result.failed > 0 ? `, ${result.failed.toLocaleString()} failed` : '') + `.
    Took ${fmtDuration(result.elapsedMs)}.`; } }); // Cancel button — either close modal (form view) or cancel download (progress view) cancelBtn.addEventListener('click', () => { if (currentDownloader) { currentDownloader.cancel(); } }); // When modal is fully hidden, reset for next time modalEl.addEventListener('hidden.bs.modal', () => { if (currentDownloader) currentDownloader.cancel(); resetModal(); }); } /** * Account card — displays the signed-in user from window.LUPMIS_SESSION * (injected by public/index.php) and wires the "Sign out" button. * * In local dev (no PHP), window.LUPMIS_SESSION is absent / empty and the * card shows "Guest (no session)" without a Sign-out button. */ /** * Account UI — populates the right-side Menu offcanvas (id="menuOffcanvas") * with the signed-in user's details, and wires the Sign-out button. * The Menu is opened from the navbar Menu button (id="menu-btn"). * * Three states: * • authenticated — show name, email, district info, and "Sign out" * • unauthenticated (PHP ran, no SSO cookie) — show "Sign in" link * • no-session (window.LUPMIS_SESSION undefined → dev mode) — show * a warning note that the page wasn't served via index.php */ function initAccountCard() { const session = getSession(); const menuBtn = document.getElementById('menu-btn'); const avatarEl = document.getElementById('menu-user-avatar'); const nameEl = document.getElementById('menu-user-name'); const emailEl = document.getElementById('menu-user-email'); const detailEl = document.getElementById('menu-user-detail'); const signoutBtn = document.getElementById('menu-signout-btn'); const signinLink = document.getElementById('menu-signin-link'); const noSessNote = document.getElementById('menu-no-session-note'); if (!menuBtn || !avatarEl || !nameEl || !emailEl || !detailEl || !signoutBtn) { console.warn('[AccountMenu] One or more elements missing — shell may be stale. Hard-refresh.'); return; } const isAuthenticated = !!session && !!session.user_id; if (isAuthenticated) { // ---------- Authenticated state ---------- const displayName = [session.title, session.full_name].filter(Boolean).join(' ').trim() || session.username || 'Authenticated user'; const initial = (session.full_name || session.username || '?').trim().charAt(0).toUpperCase(); avatarEl.textContent = initial; avatarEl.style.background = 'var(--brand-navy, #1e1a4b)'; nameEl.textContent = displayName; emailEl.textContent = session.email || ''; const bits = []; if (session.district_id != null) bits.push(`District ${escapeHtml(String(session.district_id))}`); if (session.region_id != null) bits.push(`Region ${escapeHtml(String(session.region_id))}`); if (session.ua_position) bits.push(escapeHtml(session.ua_position)); detailEl.innerHTML = bits.join(' · ') || 'No district info'; signoutBtn.classList.remove('d-none'); signoutBtn.addEventListener('click', () => handleSignOut(session), { once: false }); signinLink?.classList.add('d-none'); noSessNote?.classList.add('d-none'); menuBtn.removeAttribute('data-state'); menuBtn.setAttribute('title', `Menu — ${displayName}`); } else if (typeof window.LUPMIS_SESSION === 'undefined') { // ---------- Dev mode (no PHP processing) ---------- avatarEl.innerHTML = ''; avatarEl.style.background = 'var(--brand-orange-warm, #ff9e1b)'; nameEl.textContent = 'No session injected'; emailEl.textContent = ''; detailEl.textContent = ''; signoutBtn.classList.add('d-none'); signinLink?.classList.add('d-none'); noSessNote?.classList.remove('d-none'); menuBtn.dataset.state = 'no-session'; menuBtn.setAttribute('title', 'Menu (no session — dev mode)'); } else { // ---------- PHP ran but the user has no valid SSO session ---------- avatarEl.innerHTML = ''; avatarEl.style.background = 'var(--brand-gray-medium, #7a7a7a)'; nameEl.textContent = 'Not signed in'; emailEl.textContent = ''; detailEl.textContent = ''; signoutBtn.classList.add('d-none'); signinLink?.classList.remove('d-none'); noSessNote?.classList.add('d-none'); menuBtn.dataset.state = 'unauthenticated'; menuBtn.setAttribute('title', 'Menu (not signed in)'); } } // Legacy chip+popover removed — replaced by the navbar Menu button + // right-side menuOffcanvas. See initAccountCard above. /** * Sign-out flow: * 1. Confirm with the user. * 2. Best-effort fire-and-forget call to the SSO logout endpoint so the * server-side token is invalidated (no-cors mode tolerates CORS issues). * 3. Expire the local sso_auth_token cookie on the parent domain so the * browser stops sending it. * 4. Redirect to the SSO login page — leaves the user on familiar ground * (and on next visit, index.php sees no session and serves a fresh * page with no LUPMIS_SESSION). */ async function handleSignOut(session) { if (!confirm(`Return to Landing Page, ${session?.full_name || session?.username || 'user'}?`)) { return; } // 1. Best-effort: invalidate the SSO token server-side const cookieToken = document.cookie .split(';') .map((c) => c.trim()) .find((c) => c.startsWith('sso_auth_token=')) ?.split('=')[1]; if (cookieToken) { try { // no-cors swallows CORS errors; we don't read the response await fetch('https://lupmis4luspa.org/sso/logout?token=' + encodeURIComponent(cookieToken), { method: 'GET', mode: 'no-cors', credentials: 'include', cache: 'no-store', }); } catch (err) { console.warn('[Signout] Best-effort SSO logout call failed:', err); } } // 2. Clear the cookie on the shared parent domain // Set with both leading-dot and no-dot variants; browsers vary on which sticks. const past = 'Thu, 01 Jan 1970 00:00:00 GMT'; document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=.lupmis4luspa.org`; document.cookie = `sso_auth_token=; expires=${past}; path=/; domain=lupmis4luspa.org`; document.cookie = `sso_auth_token=; expires=${past}; path=/`; // 3. Redirect to the central LUSPA login window.location.href = 'https://lupmis4luspa.org/'; } // ============================================================================ // Start Application // ============================================================================ // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initApp); } else { initApp(); }