/**
* 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,
updateExternalImportFeatureGeometry,
deleteExternalImportFeature,
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';
// ----- Import-spinner helpers ---------------------------------------------
// Shown between "user dropped a file" and "mapping modal opens" — Shapefile
// zip decompression in particular can take several seconds on big files.
function showImportSpinner(filename) {
const overlay = document.getElementById('import-spinner-overlay');
const nameEl = document.getElementById('import-spinner-filename');
if (!overlay) return;
if (nameEl) nameEl.textContent = filename || '';
overlay.classList.remove('d-none');
overlay.classList.add('d-flex');
}
function hideImportSpinner() {
const overlay = document.getElementById('import-spinner-overlay');
if (!overlay) return;
overlay.classList.add('d-none');
overlay.classList.remove('d-flex');
}
// 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.
Return to LUSPA portal
`;
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);
}
});
// When the user modifies an imported feature with the EditBar (Draw mode +
// Select + drag a vertex/edge), persist the new geometry back to the
// matching external_import_features row so the next upload reflects it.
// Non-imported features (drawn parcels, server parcels, etc.) carry no
// _externalImportId / _clientUuid tags, so this is a quick no-op for them.
mapView.onFeatureModified(async (feature) => {
const importId = feature.get('_externalImportId');
const clientUuid = feature.get('_clientUuid');
if (importId == null || !clientUuid) return;
try {
const wkt = wktFormat.writeGeometry(feature.getGeometry(), {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
await updateExternalImportFeatureGeometry(clientUuid, wkt);
console.log('[App] Imported feature geometry updated in staging:', clientUuid);
} catch (error) {
console.warn('[App] Failed to persist imported-feature edit:', error);
showError('Could not save the edit locally: ' + 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 = `
`;
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) {
hideImportSpinner();
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) { hideImportSpinner(); 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). We generate
// a client_uuid up-front and tag the OL feature with it; that's how later
// edits (geometry modifications, deletions) can find the matching staging
// row in external_import_features.
const olFeatures = layer.getSource().getFeatures();
const stagedRows = olFeatures.map((f) => {
const geom = f.getGeometry();
const cuuid = newClientUuid();
f.set('_externalImportId', importId);
f.set('_clientUuid', cuuid);
return {
client_uuid: cuuid,
geometry_wkt: geom ? geometryToWkt4326(geom) : '',
properties: stripGeometryFromProps(f.getProperties()),
};
});
await addExternalImportFeatures(importId, stagedRows);
// ── 1b. When a tagged feature is removed (EditBar's Delete in Draw mode),
// tear down the matching staging row so the next upload doesn't
// re-send a deleted feature. The listener stays on the layer for
// the lifetime of the imported layer.
layer.getSource().on('removefeature', async (e) => {
const f = e.feature;
const cuuid = f?.get('_clientUuid');
const impId = f?.get('_externalImportId');
if (!cuuid || impId == null) return;
try {
await deleteExternalImportFeature(cuuid);
console.log('[FileImport] Removed feature from staging:', cuuid);
} catch (err) {
console.warn('[FileImport] Failed to remove staging row:', err);
}
});
// ── 2. Open the mapping modal ───────────────────────────────────────────
hideImportSpinner();
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);
}
},
});
}
/** Generate a per-feature client_uuid. Uses crypto.randomUUID where available. */
function newClientUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
/** 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 = `
`;
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;
}
// Show the import spinner — hidden again either when the mapping modal opens
// (see stageImport) or by the catch-block below on error.
showImportSpinner(files[0]?.name || 'shapefile');
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) {
hideImportSpinner();
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 {
hideImportSpinner();
showFileImportError('Please select a .zip or at least a .shp file.');
evt.target.value = '';
return;
}
addImportedGeoJSON(geojson, displayName, 'ShpImport');
} catch (error) {
hideImportSpinner();
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;
}
showImportSpinner(file.name);
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 {
hideImportSpinner();
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) {
hideImportSpinner();
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;
}
showImportSpinner(file.name);
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) {
hideImportSpinner();
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) {
hideImportSpinner();
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
Base map
Cached / limit
Approx. size
${rows}
${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();
}